Skip to content

Commit 570adb6

Browse files
committed
Feat: Add services for managing Schlage door codes
This commit introduces three new services for the Schlage integration: - `add_code`: Allows users to add a new access code to a Schlage lock. - `delete_code`: Enables users to delete an existing access code from a Schlage lock. - `get_codes`: Retrieves all access codes associated with a Schlage lock. Signed-off-by: Andrew Grimberg <[email protected]>
1 parent a50b035 commit 570adb6

File tree

6 files changed

+773
-1
lines changed

6 files changed

+773
-1
lines changed

homeassistant/components/schlage/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from homeassistant.exceptions import ConfigEntryAuthFailed
1111

1212
from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator
13+
from .services import async_setup_services
1314

1415
PLATFORMS: list[Platform] = [
1516
Platform.BINARY_SENSOR,
@@ -33,6 +34,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b
3334
hass, entry, username, pyschlage.Schlage(auth)
3435
)
3536
entry.runtime_data = coordinator
37+
38+
await async_setup_services(hass)
39+
3640
await coordinator.async_config_entry_first_refresh()
3741
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
3842
return True

homeassistant/components/schlage/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@
77
LOGGER = logging.getLogger(__package__)
88
MANUFACTURER = "Schlage"
99
UPDATE_INTERVAL = timedelta(seconds=30)
10+
11+
SERVICE_ADD_CODE = "add_code"
12+
SERVICE_DELETE_CODE = "delete_code"
13+
SERVICE_GET_CODES = "get_codes"

homeassistant/components/schlage/coordinator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def __init__(
6565
async def _async_update_data(self) -> SchlageData:
6666
"""Fetch the latest data from the Schlage API."""
6767
try:
68-
locks = await self.hass.async_add_executor_job(self.api.locks)
68+
locks = await self.hass.async_add_executor_job(self.api.locks, True)
6969
except NotAuthorizedError as ex:
7070
raise ConfigEntryAuthFailed from ex
7171
except SchlageError as ex:
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""Services for Schlage Lock integration."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import re
7+
from typing import Any
8+
9+
from pyschlage.code import AccessCode
10+
11+
from homeassistant.core import (
12+
HomeAssistant,
13+
ServiceCall,
14+
ServiceResponse,
15+
SupportsResponse,
16+
)
17+
from homeassistant.helpers import device_registry as dr, entity_registry as er
18+
19+
from .const import (
20+
DOMAIN,
21+
LOGGER,
22+
SERVICE_ADD_CODE,
23+
SERVICE_DELETE_CODE,
24+
SERVICE_GET_CODES,
25+
)
26+
from .coordinator import SchlageDataUpdateCoordinator
27+
28+
29+
def _add_coordinator_from_device(
30+
device_id: str,
31+
call: ServiceCall,
32+
device_registry: dr.DeviceRegistry,
33+
coordinators: set[SchlageDataUpdateCoordinator],
34+
) -> None:
35+
"""Add coordinator from device ID if found."""
36+
LOGGER.debug("Device ID provided: %s", device_id)
37+
device_entry = device_registry.async_get(device_id)
38+
if not device_entry:
39+
LOGGER.error("Device not found in registry: %s", device_id)
40+
return
41+
42+
LOGGER.debug("Device found in registry: %s", device_entry)
43+
for config_entry_id in device_entry.config_entries:
44+
config_entry = call.hass.config_entries.async_get_entry(config_entry_id)
45+
if not config_entry or config_entry.domain != DOMAIN:
46+
continue
47+
48+
LOGGER.debug("Config entry matches domain '%s': %s", DOMAIN, config_entry)
49+
coordinator: SchlageDataUpdateCoordinator = config_entry.runtime_data
50+
if coordinator:
51+
coordinators.add(coordinator)
52+
LOGGER.debug("Coordinator added for config entry %s", config_entry.entry_id)
53+
else:
54+
LOGGER.debug(
55+
"No coordinator found for config entry %s", config_entry.entry_id
56+
)
57+
58+
59+
def _add_coordinator_from_entity(
60+
entity_id: str,
61+
call: ServiceCall,
62+
entity_registry: er.EntityRegistry,
63+
coordinators: set[SchlageDataUpdateCoordinator],
64+
) -> None:
65+
"""Add coordinator from entity ID if found."""
66+
LOGGER.debug("Entity ID provided: %s", entity_id)
67+
entity_entry = entity_registry.async_get(entity_id)
68+
if not entity_entry:
69+
LOGGER.error("Entity not found in registry: %s", entity_id)
70+
return
71+
72+
LOGGER.debug("Entity found in registry: %s", entity_entry)
73+
if not entity_entry.config_entry_id:
74+
LOGGER.debug("Entity has no config entry ID: %s", entity_id)
75+
return
76+
77+
config_entry = call.hass.config_entries.async_get_entry(
78+
entity_entry.config_entry_id
79+
)
80+
if not config_entry or config_entry.domain != DOMAIN:
81+
LOGGER.debug(
82+
"Config entry does not match domain '%s': %s", DOMAIN, config_entry
83+
)
84+
return
85+
86+
LOGGER.debug("Config entry matches domain '%s': %s", DOMAIN, config_entry)
87+
coordinator: SchlageDataUpdateCoordinator = config_entry.runtime_data
88+
if coordinator:
89+
coordinators.add(coordinator)
90+
LOGGER.debug("Coordinator added for config entry %s", config_entry.entry_id)
91+
else:
92+
LOGGER.debug("No coordinator found for config entry %s", config_entry.entry_id)
93+
94+
95+
def _get_coordinator(call: ServiceCall) -> set[SchlageDataUpdateCoordinator]:
96+
"""Get all unique coordinators related to the service call."""
97+
coordinators: set[SchlageDataUpdateCoordinator] = set()
98+
device_registry = dr.async_get(call.hass)
99+
entity_registry = er.async_get(call.hass)
100+
101+
if "device_id" in call.data:
102+
for device_id in call.data["device_id"]:
103+
_add_coordinator_from_device(device_id, call, device_registry, coordinators)
104+
105+
if "entity_id" in call.data:
106+
for entity_id in call.data["entity_id"]:
107+
_add_coordinator_from_entity(entity_id, call, entity_registry, coordinators)
108+
109+
return coordinators
110+
111+
112+
def _get_lock_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str | None:
113+
"""Extract the lock ID from the entity ID."""
114+
115+
entity_registry = er.async_get(hass)
116+
entity_entry = entity_registry.async_get(entity_id)
117+
if entity_entry and entity_entry.unique_id:
118+
return entity_entry.unique_id
119+
return None
120+
121+
122+
def _get_codes_from_lock(
123+
coordinator: SchlageDataUpdateCoordinator, lock_id: str
124+
) -> dict[str, Any]:
125+
"""Get access codes from a specific lock."""
126+
if lock_id in coordinator.data.locks:
127+
lock_data = coordinator.data.locks[lock_id]
128+
if lock_data.lock.access_codes:
129+
return {
130+
code: {
131+
"name": lock_data.lock.access_codes[code].name,
132+
"code": lock_data.lock.access_codes[code].code,
133+
}
134+
for code in lock_data.lock.access_codes
135+
}
136+
return {}
137+
138+
139+
def _get_code_id_from_name(codes: dict[str, Any], name: str) -> str | None:
140+
"""Get the code ID from the name."""
141+
for code_id, code_data in codes.items():
142+
if code_data["name"].lower() == name.lower():
143+
return code_id
144+
return None
145+
146+
147+
async def service_get_codes(call: ServiceCall) -> ServiceResponse:
148+
"""Handle the get_codes service call."""
149+
150+
LOGGER.debug("Service call to get_codes received: %s", call)
151+
LOGGER.debug("Service call data: %s", call.data)
152+
153+
coordinators: set[SchlageDataUpdateCoordinator] = _get_coordinator(call)
154+
LOGGER.debug("Coordinators found: %s", coordinators)
155+
156+
resp: dict[str, Any] = {}
157+
158+
if not coordinators:
159+
LOGGER.error("No coordinators found for the provided device or entity IDs")
160+
161+
if "entity_id" in call.data:
162+
for entity_id in call.data["entity_id"]:
163+
LOGGER.debug("Processing entity ID: %s", entity_id)
164+
lock_id = _get_lock_id_from_entity_id(call.hass, entity_id)
165+
if lock_id:
166+
LOGGER.debug("Lock ID extracted from entity ID: %s", lock_id)
167+
for coordinator in coordinators:
168+
LOGGER.debug("Processing coordinator: %s", coordinator)
169+
resp.update({entity_id: _get_codes_from_lock(coordinator, lock_id)})
170+
else:
171+
error_msg = f"No lock ID found for entity ID: {entity_id}"
172+
LOGGER.error(error_msg)
173+
174+
return resp
175+
176+
177+
async def service_add_code(call: ServiceCall) -> None:
178+
"""Handle the add_code service call."""
179+
LOGGER.debug("Service call to add_code received: %s", call)
180+
181+
coordinators: set[SchlageDataUpdateCoordinator] = _get_coordinator(call)
182+
183+
if "entity_id" not in call.data:
184+
raise ValueError("The 'entity_id' field is required in the service call.")
185+
186+
if not coordinators:
187+
raise ValueError("No coordinators found for the provided device or entity IDs.")
188+
189+
# validate code and name
190+
code = call.data["code"]
191+
code_pattern = re.compile(r"^\d{4,8}$")
192+
if not isinstance(code, str) or not code_pattern.match(code):
193+
raise ValueError("Code must be a string between 4 and 8 digits long.")
194+
195+
name = call.data["name"]
196+
197+
for entity_id in call.data["entity_id"]:
198+
LOGGER.debug("Processing entity ID: %s", entity_id)
199+
lock_id = _get_lock_id_from_entity_id(call.hass, entity_id)
200+
if lock_id:
201+
LOGGER.debug("Lock ID extracted from entity ID: %s", lock_id)
202+
for coordinator in coordinators:
203+
LOGGER.debug("Processing coordinator: %s", coordinator)
204+
codes = _get_codes_from_lock(coordinator, lock_id)
205+
if codes:
206+
if any(
207+
code in inner_codes.values() for inner_codes in codes.values()
208+
):
209+
error_msg = f"Code already exists for lock ID {lock_id}: {code}"
210+
raise ValueError(error_msg)
211+
if any(
212+
name.lower() in str(inner_codes.values()).lower()
213+
for inner_codes in codes.values()
214+
):
215+
error_msg = f"Name already exists for lock ID {lock_id}: {name}"
216+
raise ValueError(error_msg)
217+
218+
if lock_id in coordinator.data.locks:
219+
lock = coordinator.data.locks[lock_id].lock
220+
LOGGER.info(
221+
"Adding code for lock ID %s with name: %s, code: %s",
222+
lock_id,
223+
name,
224+
code,
225+
)
226+
access_code = AccessCode(
227+
name=name,
228+
code=code,
229+
)
230+
await asyncio.to_thread(lock.add_access_code, access_code)
231+
else:
232+
error_msg = f"No lock ID found for entity ID: {entity_id}"
233+
raise ValueError(error_msg)
234+
235+
236+
async def service_delete_code(call: ServiceCall) -> None:
237+
"""Handle the delete_code service call."""
238+
LOGGER.debug("Service call to delete_code received: %s", call)
239+
240+
coordinators: set[SchlageDataUpdateCoordinator] = _get_coordinator(call)
241+
242+
if "entity_id" not in call.data:
243+
raise ValueError("The 'entity_id' field is required in the service call.")
244+
245+
if not coordinators:
246+
raise ValueError("No coordinators found for the provided device or entity IDs.")
247+
name = call.data["name"]
248+
249+
for entity_id in call.data["entity_id"]:
250+
LOGGER.debug("Processing entity ID: %s", entity_id)
251+
lock_id = _get_lock_id_from_entity_id(call.hass, entity_id)
252+
if lock_id:
253+
LOGGER.debug("Lock ID extracted from entity ID: %s", lock_id)
254+
for coordinator in coordinators:
255+
LOGGER.debug("Processing coordinator: %s", coordinator)
256+
codes = _get_codes_from_lock(coordinator, lock_id)
257+
if codes:
258+
code_id = _get_code_id_from_name(codes, name)
259+
if not code_id:
260+
continue # No code found with the given name
261+
LOGGER.debug(
262+
"Deleting code with ID %s for lock ID %s", code_id, lock_id
263+
)
264+
if lock_id in coordinator.data.locks:
265+
lock = coordinator.data.locks[lock_id].lock
266+
if lock.access_codes and code_id in lock.access_codes:
267+
await asyncio.to_thread(lock.access_codes[code_id].delete)
268+
LOGGER.debug(
269+
"Code with ID %s deleted for lock ID %s",
270+
code_id,
271+
lock_id,
272+
)
273+
274+
275+
async def async_setup_services(hass: HomeAssistant) -> None:
276+
"""Set up Schlage services."""
277+
278+
hass.services.async_register(
279+
DOMAIN,
280+
SERVICE_GET_CODES,
281+
service_get_codes,
282+
supports_response=SupportsResponse.ONLY,
283+
)
284+
285+
hass.services.async_register(
286+
DOMAIN,
287+
SERVICE_ADD_CODE,
288+
service_add_code,
289+
)
290+
291+
hass.services.async_register(
292+
DOMAIN,
293+
SERVICE_DELETE_CODE,
294+
service_delete_code,
295+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
get_codes:
2+
name: Get Access Codes
3+
description: Retrieve access codes from the lock.
4+
fields:
5+
entity_id:
6+
description: The ID of the lock to retrieve access codes from.
7+
required: true
8+
selector:
9+
entity:
10+
filter:
11+
domain: lock
12+
integration: schlage
13+
multiple: true
14+
15+
add_code:
16+
name: Add Access Code
17+
description: Add a new access code to the lock.
18+
fields:
19+
entity_id:
20+
description: The ID of the lock to retrieve access codes from.
21+
required: true
22+
selector:
23+
entity:
24+
filter:
25+
domain: lock
26+
integration: schlage
27+
multiple: true
28+
code:
29+
required: true
30+
description: |
31+
The access code to add. Must be unique to the lock and be
32+
between 4 and 8 digits long.
33+
example: "1111"
34+
selector:
35+
text:
36+
multiline: false
37+
name:
38+
required: true
39+
description: |
40+
The name associated with the access code. Must be unique to the lock.
41+
example: "Example Person"
42+
selector:
43+
text:
44+
multiline: false
45+
46+
delete_code:
47+
name: Delete Access Code
48+
description: Delete an access code from the lock.
49+
fields:
50+
entity_id:
51+
description: The ID of the lock to delete access codes from.
52+
required: true
53+
selector:
54+
entity:
55+
filter:
56+
domain: lock
57+
integration: schlage
58+
multiple: true
59+
name:
60+
required: true
61+
description: |
62+
The name associated with the access code. Must be unique to the lock.
63+
example: "Example Person"
64+
selector:
65+
text:
66+
multiline: false

0 commit comments

Comments
 (0)