Skip to content

Commit 5bd5bdf

Browse files
authored
Handle more update scenarios (#66)
1 parent 9aa3d2c commit 5bd5bdf

File tree

8 files changed

+129
-55
lines changed

8 files changed

+129
-55
lines changed

custom_components/lock_code_manager/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
281281

282282
async def async_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
283283
"""Update listener."""
284-
# No need to update if the options match the data
284+
# No need to update if there are no options because that only happens at the end
285+
# of this function
285286
if not config_entry.options:
286287
return
287288

custom_components/lock_code_manager/binary_sensor.py

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .coordinator import LockUsercodeUpdateCoordinator
3939
from .entity import BaseLockCodeManagerEntity
4040
from .exceptions import EntityNotFoundError
41+
from .providers import BaseLock
4142

4243
_LOGGER = logging.getLogger(__name__)
4344

@@ -92,10 +93,21 @@ def __init__(
9293
)
9394
self.coordinators = coordinators
9495
self._entity_id_map: dict[str, str] = {}
95-
self._update_usercodes_task: asyncio.Task | None = None
9696
self._issue_reg: ir.IssueRegistry | None = None
9797
self._call_later_unsub: Callable | None = None
9898

99+
def _lock_slot_sensor_state(self, lock: BaseLock) -> str:
100+
"""Return lock slot sensor entity ID."""
101+
if not (
102+
entity_id := self.ent_reg.async_get_entity_id(
103+
SENSOR_DOMAIN,
104+
DOMAIN,
105+
f"{self.base_unique_id}|{self.slot_num}|{ATTR_CODE}|{lock.lock.entity_id}",
106+
)
107+
) or not (state := self.hass.states.get(entity_id)):
108+
raise EntityNotFoundError(lock, self.slot_num, ATTR_CODE)
109+
return state.state
110+
99111
async def async_update_usercodes(
100112
self, states: dict[str, dict[str, str]] | None = None
101113
) -> None:
@@ -115,15 +127,7 @@ async def async_update_usercodes(
115127
)
116128
)
117129
for lock in self.locks:
118-
lock_slot_sensor_entity_id = self.ent_reg.async_get_entity_id(
119-
SENSOR_DOMAIN,
120-
DOMAIN,
121-
f"{self.base_unique_id}|{self.slot_num}|{ATTR_CODE}|{lock.lock.entity_id}",
122-
)
123-
124-
if not lock_slot_sensor_entity_id:
125-
raise EntityNotFoundError(lock_slot_sensor_entity_id)
126-
130+
lock_slot_sensor_state = self._lock_slot_sensor_state(lock)
127131
if self.is_on:
128132
name_entity_id = self._entity_id_map[CONF_NAME]
129133
name_state = self.hass.states.get(name_entity_id)
@@ -138,9 +142,7 @@ async def async_update_usercodes(
138142
if not state:
139143
raise ValueError(f"State not found for {entity_id}")
140144

141-
if (
142-
state := self.hass.states.get(lock_slot_sensor_entity_id)
143-
) and state.state == pin_state.state:
145+
if lock_slot_sensor_state == pin_state.state:
144146
continue
145147

146148
_LOGGER.info(
@@ -155,9 +157,10 @@ async def async_update_usercodes(
155157
int(self.slot_num), pin_state.state, name_state.state
156158
)
157159
else:
158-
if (
159-
state := self.hass.states.get(lock_slot_sensor_entity_id)
160-
) and state.state in ("", STATE_UNKNOWN):
160+
if lock_slot_sensor_state in (
161+
"",
162+
STATE_UNKNOWN,
163+
):
161164
continue
162165

163166
_LOGGER.info(
@@ -198,7 +201,7 @@ async def _update_state(self, _: datetime | None = None) -> None:
198201

199202
states: dict[str, dict[str, str]] = {}
200203
for key, entity_id in entity_id_map.items():
201-
if key in (EVENT_PIN_USED, CONF_NAME, CONF_PIN):
204+
if key in (EVENT_PIN_USED, CONF_NAME):
202205
continue
203206
issue_id = f"{self.config_entry.entry_id}_{self.slot_num}_no_{key}"
204207
if not (state := self.hass.states.get(entity_id)):
@@ -225,19 +228,33 @@ async def _update_state(self, _: datetime | None = None) -> None:
225228
self._attr_is_on = bool(
226229
states
227230
and all(
228-
(key != CONF_NUMBER_OF_USES and state["state"] == STATE_ON)
231+
(
232+
key not in (CONF_NUMBER_OF_USES, CONF_PIN)
233+
and state["state"] == STATE_ON
234+
)
229235
or (
230236
key == CONF_NUMBER_OF_USES
231237
and state["state"] not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
232238
and int(float(state["state"])) > 0
233239
)
234240
for key, state in states.items()
241+
if key != CONF_PIN
235242
)
236243
)
237244

238-
if not (
239-
state := self.hass.states.get(self.entity_id)
240-
) or state.state != self.state:
245+
# If the state of the binary sensor has changed, or the binary sensor is on and
246+
# the desired PIN state has changed, update the usercodes
247+
if (
248+
not (state := self.hass.states.get(self.entity_id))
249+
or state.state != self.state
250+
or (
251+
self.is_on
252+
and any(
253+
self._lock_slot_sensor_state(lock) != states[CONF_PIN]["state"]
254+
for lock in self.locks
255+
)
256+
)
257+
):
241258
try:
242259
await self.async_update_usercodes(states)
243260
except EntityNotFoundError:
@@ -248,6 +265,14 @@ async def _update_state(self, _: datetime | None = None) -> None:
248265
return
249266
self.async_write_ha_state()
250267

268+
async def _config_entry_update_listener(
269+
self, hass: HomeAssistant, config_entry: ConfigEntry
270+
) -> None:
271+
"""Update listener."""
272+
if config_entry.options:
273+
return
274+
await self._update_state()
275+
251276
async def _remove_keys_to_track(self, keys: list[str]) -> None:
252277
"""Remove keys to track."""
253278
for key in keys:
@@ -266,20 +291,16 @@ async def _add_keys_to_track(self, keys: list[str]) -> None:
266291
)
267292
await self._update_state()
268293

269-
async def _handle_state_changes(self, entity_id: str, _: State, __: State) -> None:
270-
"""Handle state change."""
271-
entity_id_map = self._entity_id_map.copy()
272-
if self._calendar_entity_id:
273-
entity_id_map[CONF_CALENDAR] = self._calendar_entity_id
274-
if any(
275-
entity_id == key_entity_id
276-
for key, key_entity_id in entity_id_map.items()
277-
if key not in (EVENT_PIN_USED, CONF_NAME, CONF_PIN)
278-
):
294+
async def _handle_calendar_state_changes(
295+
self, entity_id: str, _: State, __: State
296+
) -> None:
297+
"""Handle calendar state changes."""
298+
if entity_id == self._calendar_entity_id:
279299
await self._update_state()
280300

281301
async def async_will_remove_from_hass(self) -> None:
282302
"""Run when entity will be removed from hass."""
303+
await asyncio.sleep(0)
283304
if self._call_later_unsub:
284305
self._call_later_unsub()
285306
self._call_later_unsub = None
@@ -317,5 +338,11 @@ async def async_added_to_hass(self) -> None:
317338
)
318339

319340
self.async_on_remove(
320-
async_track_state_change(self.hass, MATCH_ALL, self._handle_state_changes)
341+
async_track_state_change(
342+
self.hass, MATCH_ALL, self._handle_calendar_state_changes
343+
)
344+
)
345+
346+
self.async_on_remove(
347+
self.config_entry.add_update_listener(self._config_entry_update_listener)
321348
)

custom_components/lock_code_manager/entity.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ def __init__(
6767
self.ent_reg = ent_reg
6868

6969
self._uid_cache: dict[str, str] = {}
70-
self._entity_id_map: dict[str, str] = {}
7170
self._unsub_initial_state: CALLBACK_TYPE | None = None
7271

7372
key_parts = key.lower().split("_")

custom_components/lock_code_manager/exceptions.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
57
from homeassistant.exceptions import HomeAssistantError
68

9+
if TYPE_CHECKING:
10+
from .providers import BaseLock
11+
712

813
class LockCodeManagerError(HomeAssistantError):
914
"""Base class for lock_code_manager exceptions."""
@@ -12,10 +17,12 @@ class LockCodeManagerError(HomeAssistantError):
1217
class EntityNotFoundError(LockCodeManagerError):
1318
"""Raise when en entity is not found."""
1419

15-
def __init__(self, entity_id: str):
20+
def __init__(self, lock: BaseLock, slot_num: int, key: str):
1621
"""Initialize the error."""
17-
self.entity_id = entity_id
18-
super().__init__(f"Entity not found: {entity_id}")
22+
self.lock = lock
23+
self.key = key
24+
self.slot_num = slot_num
25+
super().__init__(f"Entity not found for lock {lock} slot {slot_num} key {key}")
1926

2027

2128
class LockDisconnected(LockCodeManagerError):

tests/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
CONF_PIN: "5678",
3737
CONF_ENABLED: True,
3838
CONF_NUMBER_OF_USES: 5,
39-
CONF_CALENDAR: "calendar.test",
39+
CONF_CALENDAR: "calendar.test_1",
4040
},
4141
},
4242
}

tests/conftest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,18 @@ async def async_setup_entry_lock_platform(
103103
MockPlatform(async_setup_entry=async_setup_entry_lock_platform),
104104
)
105105

106-
calendar = hass.data["lock_code_manager_calendar"] = MockCalendarEntity("test")
106+
calendars = hass.data["lock_code_manager_calendars"] = [
107+
MockCalendarEntity("test_1"),
108+
MockCalendarEntity("test_2"),
109+
]
107110

108111
async def async_setup_entry_calendar_platform(
109112
hass: HomeAssistant,
110113
config_entry: ConfigEntry,
111114
async_add_entities: AddEntitiesCallback,
112115
) -> None:
113116
"""Set up test calendar platform via config entry."""
114-
async_add_entities([calendar])
117+
async_add_entities(calendars)
115118

116119
mock_platform(
117120
hass,

tests/test_binary_sensor.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
1-
"""Test sensor platform."""
1+
"""Test binary sensor platform."""
22

3+
import copy
34
from datetime import timedelta
45
import logging
56

67
from homeassistant.components.number import (
78
ATTR_VALUE,
89
DOMAIN as NUMBER_DOMAIN,
9-
SERVICE_SET_VALUE,
10+
SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE,
1011
)
1112
from homeassistant.components.switch import (
1213
DOMAIN as SWITCH_DOMAIN,
1314
SERVICE_TURN_OFF,
1415
SERVICE_TURN_ON,
1516
)
17+
from homeassistant.components.text import (
18+
DOMAIN as TEXT_DOMAIN,
19+
SERVICE_SET_VALUE as TEXT_SERVICE_SET_VALUE,
20+
)
1621
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
1722
from homeassistant.core import HomeAssistant
1823
from homeassistant.util import dt as dt_util
1924

20-
from .common import ENABLED_ENTITY, NUMBER_OF_USES_ENTITY, PIN_SYNCED_ENTITY
25+
from custom_components.lock_code_manager.const import CONF_CALENDAR, CONF_SLOTS
26+
27+
from .common import (
28+
BASE_CONFIG,
29+
ENABLED_ENTITY,
30+
LOCK_1_ENTITY_ID,
31+
LOCK_DATA,
32+
NUMBER_OF_USES_ENTITY,
33+
PIN_ENTITY,
34+
PIN_SYNCED_ENTITY,
35+
)
2136

2237
_LOGGER = logging.getLogger(__name__)
2338

@@ -28,7 +43,8 @@ async def test_binary_sensor_entity(
2843
lock_code_manager_config_entry,
2944
):
3045
"""Test sensor entity."""
31-
state = hass.states.get("calendar.test")
46+
calendar_1, calendar_2 = hass.data["lock_code_manager_calendars"]
47+
state = hass.states.get("calendar.test_1")
3248
assert state
3349
assert state.state == STATE_OFF
3450

@@ -40,25 +56,21 @@ async def test_binary_sensor_entity(
4056
start = now - timedelta(hours=1)
4157
end = now + timedelta(hours=1)
4258

43-
cal_event = hass.data["lock_code_manager_calendar"].create_event(
44-
dtstart=start, dtend=end, summary="test"
45-
)
59+
cal_event = calendar_1.create_event(dtstart=start, dtend=end, summary="test")
4660
await hass.async_block_till_done()
4761

4862
state = hass.states.get(PIN_SYNCED_ENTITY)
4963
assert state
5064
assert state.state == STATE_ON
5165

52-
hass.data["lock_code_manager_calendar"].delete_event(cal_event.uid)
66+
calendar_1.delete_event(cal_event.uid)
5367
await hass.async_block_till_done()
5468

5569
state = hass.states.get(PIN_SYNCED_ENTITY)
5670
assert state
5771
assert state.state == STATE_OFF
5872

59-
hass.data["lock_code_manager_calendar"].create_event(
60-
dtstart=start, dtend=end, summary="test"
61-
)
73+
calendar_1.create_event(dtstart=start, dtend=end, summary="test")
6274
await hass.async_block_till_done()
6375

6476
state = hass.states.get(PIN_SYNCED_ENTITY)
@@ -67,7 +79,7 @@ async def test_binary_sensor_entity(
6779

6880
await hass.services.async_call(
6981
NUMBER_DOMAIN,
70-
SERVICE_SET_VALUE,
82+
NUMBER_SERVICE_SET_VALUE,
7183
service_data={ATTR_VALUE: "0"},
7284
target={ATTR_ENTITY_ID: NUMBER_OF_USES_ENTITY},
7385
blocking=True,
@@ -80,7 +92,7 @@ async def test_binary_sensor_entity(
8092

8193
await hass.services.async_call(
8294
NUMBER_DOMAIN,
83-
SERVICE_SET_VALUE,
95+
NUMBER_SERVICE_SET_VALUE,
8496
service_data={ATTR_VALUE: "5"},
8597
target={ATTR_ENTITY_ID: NUMBER_OF_USES_ENTITY},
8698
blocking=True,
@@ -114,3 +126,28 @@ async def test_binary_sensor_entity(
114126
state = hass.states.get(PIN_SYNCED_ENTITY)
115127
assert state
116128
assert state.state == STATE_ON
129+
130+
await hass.services.async_call(
131+
TEXT_DOMAIN,
132+
TEXT_SERVICE_SET_VALUE,
133+
service_data={ATTR_VALUE: "0987"},
134+
target={ATTR_ENTITY_ID: PIN_ENTITY},
135+
blocking=True,
136+
)
137+
await hass.async_block_till_done()
138+
139+
assert hass.data[LOCK_DATA][LOCK_1_ENTITY_ID]["service_calls"]["set_usercode"] == [
140+
(2, "0987", "test2")
141+
]
142+
143+
new_config = copy.deepcopy(BASE_CONFIG)
144+
new_config[CONF_SLOTS][2][CONF_CALENDAR] = "calendar.test_2"
145+
146+
hass.config_entries.async_update_entry(
147+
lock_code_manager_config_entry, options=new_config
148+
)
149+
await hass.async_block_till_done()
150+
151+
state = hass.states.get(PIN_SYNCED_ENTITY)
152+
assert state
153+
assert state.state == STATE_OFF

tests/test_websocket.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ async def test_get_slot_calendar_data(
3434
assert msg["success"]
3535
assert msg["result"] == {
3636
CONF_LOCKS: [LOCK_1_ENTITY_ID, LOCK_2_ENTITY_ID],
37-
CONF_SLOTS: {"1": None, "2": "calendar.test"},
37+
CONF_SLOTS: {"1": None, "2": "calendar.test_1"},
3838
}
3939

4040
# Try API call with entry title
@@ -49,7 +49,7 @@ async def test_get_slot_calendar_data(
4949
assert msg["success"]
5050
assert msg["result"] == {
5151
CONF_LOCKS: [LOCK_1_ENTITY_ID, LOCK_2_ENTITY_ID],
52-
CONF_SLOTS: {"1": None, "2": "calendar.test"},
52+
CONF_SLOTS: {"1": None, "2": "calendar.test_1"},
5353
}
5454

5555
# Try API call with invalid entry ID

0 commit comments

Comments
 (0)