Skip to content

Commit 6e3fc05

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 01effb7 commit 6e3fc05

File tree

8 files changed

+551
-2
lines changed

8 files changed

+551
-2
lines changed

homeassistant/components/schlage/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b
3333
hass, entry, username, pyschlage.Schlage(auth)
3434
)
3535
entry.runtime_data = coordinator
36+
3637
await coordinator.async_config_entry_first_refresh()
3738
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
3839
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"services": {
3+
"add_code": {
4+
"service": "mdi:key-plus"
5+
},
6+
"delete_code": {
7+
"service": "mdi:key-minus"
8+
},
9+
"get_codes": {
10+
"service": "mdi:table-key"
11+
}
12+
}
13+
}

homeassistant/components/schlage/lock.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,22 @@
44

55
from typing import Any
66

7+
from pyschlage.code import AccessCode
8+
import voluptuous as vol
9+
710
from homeassistant.components.lock import LockEntity
8-
from homeassistant.core import HomeAssistant, callback
11+
from homeassistant.core import (
12+
HomeAssistant,
13+
ServiceCall,
14+
ServiceResponse,
15+
SupportsResponse,
16+
callback,
17+
)
18+
from homeassistant.exceptions import ServiceValidationError
19+
from homeassistant.helpers import config_validation as cv, entity_platform
920
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1021

22+
from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES
1123
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
1224
from .entity import SchlageEntity
1325

@@ -29,6 +41,128 @@ def _add_new_locks(locks: dict[str, LockData]) -> None:
2941
_add_new_locks(coordinator.data.locks)
3042
coordinator.new_locks_callbacks.append(_add_new_locks)
3143

44+
# Custom services
45+
def _validate_code_name(codes: dict[str, AccessCode] | None, name: str) -> None:
46+
"""Validate that the code name doesn't already exist."""
47+
if codes and any(code.name.lower() == name.lower() for code in codes.values()):
48+
raise ServiceValidationError(
49+
translation_domain=DOMAIN,
50+
translation_key="schlage_name_exists",
51+
)
52+
53+
def _validate_code_value(codes: dict[str, AccessCode] | None, code: str) -> None:
54+
"""Validate that the code value doesn't already exist."""
55+
if codes and any(
56+
existing_code.code == code for existing_code in codes.values()
57+
):
58+
raise ServiceValidationError(
59+
translation_domain=DOMAIN,
60+
translation_key="schlage_code_exists",
61+
)
62+
63+
async def _add_code(entity: SchlageLockEntity, calls: ServiceCall) -> None:
64+
"""Add a lock code."""
65+
name = calls.data.get("name")
66+
code = calls.data.get("code")
67+
68+
# name is required by voluptuous, the following is just to satisfy type
69+
# checker, it should never be None
70+
if name is None:
71+
raise ServiceValidationError(
72+
translation_domain=DOMAIN,
73+
translation_key="schlage_name_required",
74+
) # pragma: no cover
75+
76+
# code is required by voluptuous, the following is just to satisfy type
77+
# checker, it should never be None
78+
if code is None:
79+
raise ServiceValidationError(
80+
translation_domain=DOMAIN,
81+
translation_key="schlage_code_required",
82+
) # pragma: no cover
83+
84+
codes = entity._lock.access_codes # noqa: SLF001
85+
_validate_code_name(codes, name)
86+
_validate_code_value(codes, code)
87+
88+
access_code = AccessCode(name=name, code=code)
89+
await hass.async_add_executor_job(entity._lock.add_access_code, access_code) # noqa: SLF001
90+
await coordinator.async_request_refresh()
91+
92+
async def _delete_code(entity: SchlageLockEntity, calls: ServiceCall) -> None:
93+
"""Delete a lock code."""
94+
name = calls.data.get("name")
95+
96+
# name is required by voluptuous, the following is just to satisfy type
97+
# checker, it should never be None
98+
if name is None:
99+
raise ServiceValidationError(
100+
translation_domain=DOMAIN,
101+
translation_key="schlage_name_required",
102+
) # pragma: no cover
103+
104+
codes = entity._lock.access_codes # noqa: SLF001
105+
if not codes:
106+
return
107+
108+
code_id_to_delete = next(
109+
(
110+
code_id
111+
for code_id, code_data in codes.items()
112+
if code_data.name.lower() == name.lower()
113+
),
114+
None,
115+
)
116+
117+
if not code_id_to_delete:
118+
return
119+
120+
if entity._lock.access_codes: # noqa: SLF001
121+
await hass.async_add_executor_job(
122+
entity._lock.access_codes[code_id_to_delete].delete # noqa: SLF001
123+
)
124+
await coordinator.async_request_refresh()
125+
126+
async def _get_codes(
127+
entity: SchlageLockEntity, calls: ServiceCall
128+
) -> ServiceResponse:
129+
"""Get lock codes."""
130+
131+
if entity._lock.access_codes: # noqa: SLF001
132+
return {
133+
code: {
134+
"name": entity._lock.access_codes[code].name, # noqa: SLF001
135+
"code": entity._lock.access_codes[code].code, # noqa: SLF001
136+
}
137+
for code in entity._lock.access_codes # noqa: SLF001
138+
}
139+
return {}
140+
141+
platform = entity_platform.async_get_current_platform()
142+
platform.async_register_entity_service(
143+
name=SERVICE_ADD_CODE,
144+
schema={
145+
vol.Required("name"): cv.string,
146+
vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"),
147+
},
148+
func=_add_code,
149+
)
150+
151+
platform.async_register_entity_service(
152+
name=SERVICE_DELETE_CODE,
153+
schema={
154+
vol.Required("name"): cv.string,
155+
},
156+
func=_delete_code,
157+
)
158+
159+
platform.async_register_entity_service(
160+
name=SERVICE_GET_CODES,
161+
schema=None,
162+
func=_get_codes,
163+
supports_response=SupportsResponse.ONLY,
164+
)
165+
32166

33167
class SchlageLockEntity(SchlageEntity, LockEntity):
34168
"""Schlage lock entity."""
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
get_codes:
2+
fields:
3+
entity_id:
4+
required: true
5+
selector:
6+
entity:
7+
filter:
8+
domain: lock
9+
integration: schlage
10+
multiple: true
11+
12+
add_code:
13+
fields:
14+
entity_id:
15+
required: true
16+
selector:
17+
entity:
18+
filter:
19+
domain: lock
20+
integration: schlage
21+
multiple: true
22+
name:
23+
required: true
24+
example: "Example Person"
25+
selector:
26+
text:
27+
multiline: false
28+
code:
29+
required: true
30+
example: "1111"
31+
selector:
32+
text:
33+
multiline: false
34+
35+
delete_code:
36+
fields:
37+
entity_id:
38+
required: true
39+
selector:
40+
entity:
41+
filter:
42+
domain: lock
43+
integration: schlage
44+
multiple: true
45+
name:
46+
required: true
47+
example: "Example Person"
48+
selector:
49+
text:
50+
multiline: false

homeassistant/components/schlage/strings.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,62 @@
5858
"exceptions": {
5959
"schlage_refresh_failed": {
6060
"message": "Failed to refresh Schlage data"
61+
},
62+
"schlage_code_exists": {
63+
"message": "A PIN code with this value already exists on the lock"
64+
},
65+
"schlage_code_required": {
66+
"message": "PIN code is required"
67+
},
68+
"schlage_name_exists": {
69+
"message": "A PIN code with this name already exists on the lock"
70+
},
71+
"schlage_name_required": {
72+
"message": "PIN name is required"
73+
}
74+
},
75+
"services": {
76+
"add_code": {
77+
"name": "Add PIN code",
78+
"description": "Add a PIN code to a lock.",
79+
"fields": {
80+
"entity_id": {
81+
"name": "Lock",
82+
"description": "The lock to add the PIN code to."
83+
},
84+
"name": {
85+
"name": "PIN Name",
86+
"description": "Name for PIN code. Must be unique to lock."
87+
},
88+
"code": {
89+
"name": "PIN code",
90+
"description": "The PIN code to add. Must be unique to lock and be between 4 and 8 digits long."
91+
}
92+
}
93+
},
94+
"delete_code": {
95+
"name": "Delete PIN code",
96+
"description": "Delete a PIN code from a lock.",
97+
"fields": {
98+
"entity_id": {
99+
"name": "Lock",
100+
"description": "The lock to delete the PIN code from."
101+
},
102+
"name": {
103+
"name": "PIN Name",
104+
"description": "Name of PIN code to delete."
105+
}
106+
}
107+
},
108+
"get_codes": {
109+
"name": "Get PIN codes",
110+
"description": "Retrieve all PIN codes from the lock",
111+
"fields": {
112+
"entity_id": {
113+
"name": "Lock",
114+
"description": "The lock to retrieve PIN codes from."
115+
}
116+
}
61117
}
62118
}
63119
}

0 commit comments

Comments
 (0)