|
|
@@ -18,29 +18,46 @@ SERVICE_CLEAR_CODE = "clear_code"
|
|
|
SERVICE_SET_NAME = "set_name"
|
|
|
SERVICE_SET_SLOT_ENABLED = "set_slot_enabled"
|
|
|
|
|
|
-SET_CODE_SCHEMA = vol.Schema({
|
|
|
- vol.Required("lock_entity_id"): cv.entity_id,
|
|
|
- vol.Optional("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
- vol.Required("code"): cv.string,
|
|
|
- vol.Optional("name"): cv.string,
|
|
|
-})
|
|
|
-
|
|
|
-CLEAR_CODE_SCHEMA = vol.Schema({
|
|
|
- vol.Required("lock_entity_id"): cv.entity_id,
|
|
|
- vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
-})
|
|
|
-
|
|
|
-SET_NAME_SCHEMA = vol.Schema({
|
|
|
- vol.Required("lock_entity_id"): cv.entity_id,
|
|
|
- vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
- vol.Required("name"): cv.string,
|
|
|
-})
|
|
|
-
|
|
|
-SET_SLOT_ENABLED_SCHEMA = vol.Schema({
|
|
|
- vol.Required("lock_entity_id"): cv.entity_id,
|
|
|
- vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
- vol.Required("enabled"): cv.boolean,
|
|
|
-})
|
|
|
+_TARGET_FIELDS = {
|
|
|
+ vol.Exclusive("lock_entity_id", "target"): cv.entity_id,
|
|
|
+ vol.Exclusive("group_name", "target"): cv.string,
|
|
|
+}
|
|
|
+
|
|
|
+SET_CODE_SCHEMA = vol.All(
|
|
|
+ cv.has_at_least_one_key("lock_entity_id", "group_name"),
|
|
|
+ vol.Schema({
|
|
|
+ **_TARGET_FIELDS,
|
|
|
+ vol.Optional("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
+ vol.Required("code"): cv.string,
|
|
|
+ vol.Optional("name"): cv.string,
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+CLEAR_CODE_SCHEMA = vol.All(
|
|
|
+ cv.has_at_least_one_key("lock_entity_id", "group_name"),
|
|
|
+ vol.Schema({
|
|
|
+ **_TARGET_FIELDS,
|
|
|
+ vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+SET_NAME_SCHEMA = vol.All(
|
|
|
+ cv.has_at_least_one_key("lock_entity_id", "group_name"),
|
|
|
+ vol.Schema({
|
|
|
+ **_TARGET_FIELDS,
|
|
|
+ vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
+ vol.Required("name"): cv.string,
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+SET_SLOT_ENABLED_SCHEMA = vol.All(
|
|
|
+ cv.has_at_least_one_key("lock_entity_id", "group_name"),
|
|
|
+ vol.Schema({
|
|
|
+ **_TARGET_FIELDS,
|
|
|
+ vol.Required("slot"): vol.All(int, vol.Range(min=1)),
|
|
|
+ vol.Required("enabled"): cv.boolean,
|
|
|
+ }),
|
|
|
+)
|
|
|
|
|
|
|
|
|
def _get_coordinator(hass: HomeAssistant, lock_entity_id: str) -> ZLockCoordinator:
|
|
|
@@ -51,65 +68,98 @@ def _get_coordinator(hass: HomeAssistant, lock_entity_id: str) -> ZLockCoordinat
|
|
|
raise ServiceValidationError(f"{lock_entity_id} is not managed by {DOMAIN}")
|
|
|
|
|
|
|
|
|
+def _get_coordinator_for_group(hass: HomeAssistant, group_name: str) -> ZLockCoordinator:
|
|
|
+ for entry_data in hass.data.get(DOMAIN, {}).values():
|
|
|
+ coordinator: ZLockCoordinator = entry_data
|
|
|
+ if group_name in coordinator.group_data:
|
|
|
+ return coordinator
|
|
|
+ raise ServiceValidationError(f"'{group_name}' is not a configured {DOMAIN} group")
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_targets(hass: HomeAssistant, call: ServiceCall) -> tuple[ZLockCoordinator, list[str]]:
|
|
|
+ """Resolve a service call's target to a coordinator and the lock(s) to act on.
|
|
|
+
|
|
|
+ A group target fans out to every member lock; individual lock failures are
|
|
|
+ logged and otherwise ignored (no rollback, no partial-failure semantics) —
|
|
|
+ the group is an abstraction over its members, not a sync/integrity mechanism.
|
|
|
+ """
|
|
|
+ if lock_entity_id := call.data.get("lock_entity_id"):
|
|
|
+ return _get_coordinator(hass, lock_entity_id), [lock_entity_id]
|
|
|
+ group_name = call.data["group_name"]
|
|
|
+ coordinator = _get_coordinator_for_group(hass, group_name)
|
|
|
+ return coordinator, coordinator.group_lock_ids(group_name)
|
|
|
+
|
|
|
+
|
|
|
+async def _fanout(lock_ids: list[str], action) -> None:
|
|
|
+ for lock_entity_id in lock_ids:
|
|
|
+ try:
|
|
|
+ await action(lock_entity_id)
|
|
|
+ except Exception:
|
|
|
+ _LOGGER.exception("zlock_slots: operation failed for %s", lock_entity_id)
|
|
|
+
|
|
|
+
|
|
|
def register_services(hass: HomeAssistant) -> None:
|
|
|
async def handle_set_code(call: ServiceCall) -> None:
|
|
|
- lock_entity_id = call.data["lock_entity_id"]
|
|
|
code = call.data["code"]
|
|
|
name = call.data.get("name")
|
|
|
+ coordinator, lock_ids = _resolve_targets(hass, call)
|
|
|
|
|
|
- coordinator = _get_coordinator(hass, lock_entity_id)
|
|
|
slot = call.data.get("slot")
|
|
|
if slot is None:
|
|
|
- used = set(coordinator.data[lock_entity_id].slots.keys())
|
|
|
+ used: set[int] = set()
|
|
|
+ for lid in lock_ids:
|
|
|
+ used |= set(coordinator.data[lid].slots.keys())
|
|
|
slot = next(i for i in range(1, 1000) if i not in used)
|
|
|
|
|
|
- await hass.services.async_call(
|
|
|
- "zwave_js",
|
|
|
- "set_lock_usercode",
|
|
|
- {"entity_id": lock_entity_id, "code_slot": slot, "usercode": code},
|
|
|
- blocking=True,
|
|
|
- )
|
|
|
+ async def _set(lock_entity_id: str) -> None:
|
|
|
+ await hass.services.async_call(
|
|
|
+ "zwave_js",
|
|
|
+ "set_lock_usercode",
|
|
|
+ {"entity_id": lock_entity_id, "code_slot": slot, "usercode": code},
|
|
|
+ blocking=True,
|
|
|
+ )
|
|
|
+ if name is not None:
|
|
|
+ await coordinator.async_set_name(lock_entity_id, slot, name)
|
|
|
+ await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
|
|
|
- if name is not None:
|
|
|
- await coordinator.async_set_name(lock_entity_id, slot, name)
|
|
|
-
|
|
|
- await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
+ await _fanout(lock_ids, _set)
|
|
|
|
|
|
async def handle_clear_code(call: ServiceCall) -> None:
|
|
|
- lock_entity_id = call.data["lock_entity_id"]
|
|
|
slot = call.data["slot"]
|
|
|
+ coordinator, lock_ids = _resolve_targets(hass, call)
|
|
|
|
|
|
- coordinator = _get_coordinator(hass, lock_entity_id)
|
|
|
-
|
|
|
- await hass.services.async_call(
|
|
|
- "zwave_js",
|
|
|
- "clear_lock_usercode",
|
|
|
- {"entity_id": lock_entity_id, "code_slot": slot},
|
|
|
- blocking=True,
|
|
|
- )
|
|
|
+ async def _clear(lock_entity_id: str) -> None:
|
|
|
+ await hass.services.async_call(
|
|
|
+ "zwave_js",
|
|
|
+ "clear_lock_usercode",
|
|
|
+ {"entity_id": lock_entity_id, "code_slot": slot},
|
|
|
+ blocking=True,
|
|
|
+ )
|
|
|
+ await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
|
|
|
- await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
+ await _fanout(lock_ids, _clear)
|
|
|
|
|
|
async def handle_set_name(call: ServiceCall) -> None:
|
|
|
- lock_entity_id = call.data["lock_entity_id"]
|
|
|
slot = call.data["slot"]
|
|
|
name = call.data["name"]
|
|
|
+ coordinator, lock_ids = _resolve_targets(hass, call)
|
|
|
|
|
|
- coordinator = _get_coordinator(hass, lock_entity_id)
|
|
|
- await coordinator.async_set_name(lock_entity_id, slot, name)
|
|
|
+ await _fanout(lock_ids, lambda lid: coordinator.async_set_name(lid, slot, name))
|
|
|
|
|
|
async def handle_set_slot_enabled(call: ServiceCall) -> None:
|
|
|
from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
|
|
|
from zwave_js_server.model.access_control import SetUserOptions
|
|
|
|
|
|
- lock_entity_id = call.data["lock_entity_id"]
|
|
|
slot = call.data["slot"]
|
|
|
enabled = call.data["enabled"]
|
|
|
+ coordinator, lock_ids = _resolve_targets(hass, call)
|
|
|
+
|
|
|
+ async def _set_enabled(lock_entity_id: str) -> None:
|
|
|
+ node = async_get_node_from_entity_id(hass, lock_entity_id)
|
|
|
+ await node.access_control.set_user(slot, SetUserOptions(active=enabled))
|
|
|
+ await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
|
|
|
- coordinator = _get_coordinator(hass, lock_entity_id)
|
|
|
- node = async_get_node_from_entity_id(hass, lock_entity_id)
|
|
|
- await node.access_control.set_user(slot, SetUserOptions(active=enabled))
|
|
|
- await coordinator.async_refresh_lock(lock_entity_id)
|
|
|
+ await _fanout(lock_ids, _set_enabled)
|
|
|
|
|
|
hass.services.async_register(DOMAIN, SERVICE_SET_CODE, handle_set_code, SET_CODE_SCHEMA)
|
|
|
hass.services.async_register(DOMAIN, SERVICE_CLEAR_CODE, handle_clear_code, CLEAR_CODE_SCHEMA)
|