فهرست منبع

Add group targeting to services and a group slots union sensor

Services now accept a group_name in place of lock_entity_id, fanning
writes out to every member lock (failures on individual locks are
logged, not fatal); set_code auto-assign picks the lowest slot unused
across the whole group. ZLockGroupSlotsSensor exposes the naive union
of member slots for the card and other consumers. The card can target
a group the same way it targets a single lock.
ZiB 3 ماه پیش
والد
کامیت
219e775f03

+ 14 - 4
README.md

@@ -31,14 +31,17 @@ For each configured lock (`lock.<name>`):
 | `sensor.<name>_slots` | count of occupied code slots; `attributes.slots` lists `{slot_num, status, name}` |
 | `sensor.<name>_slots` | count of occupied code slots; `attributes.slots` lists `{slot_num, status, name}` |
 
 
 Each group gets its own `last_action` / `last_user` sensor pair, updated by
 Each group gets its own `last_action` / `last_user` sensor pair, updated by
-events from any lock in the group. Last action/user survive HA restarts.
+events from any lock in the group, plus a `slots` sensor showing the union of
+occupied slots across its member locks (a slot present on any member appears;
+status/name come from whichever member has it first — groups don't reconcile
+disagreements between members). Last action/user survive HA restarts.
 
 
 Slot names are stored by the integration (they are not written to the lock)
 Slot names are stored by the integration (they are not written to the lock)
 and are preserved when a code is cleared.
 and are preserved when a code is cleared.
 
 
 ## Services
 ## Services
 
 
-All services take `lock_entity_id` plus:
+Every service takes exactly one of `lock_entity_id` or `group_name`, plus:
 
 
 | Service | Fields | Notes |
 | Service | Fields | Notes |
 |---|---|---|
 |---|---|---|
@@ -47,6 +50,13 @@ All services take `lock_entity_id` plus:
 | `zlock_slots.set_name` | `slot`, `name` | Set an empty name to remove it |
 | `zlock_slots.set_name` | `slot`, `name` | Set an empty name to remove it |
 | `zlock_slots.set_slot_enabled` | `slot`, `enabled` | Enable/disable without changing the PIN |
 | `zlock_slots.set_slot_enabled` | `slot`, `enabled` | Enable/disable without changing the PIN |
 
 
+Targeting a `group_name` fans the operation out to every lock in the group.
+Failures on individual locks are logged but don't block the others or roll
+back — a group is a convenience for acting on several locks at once, not a
+sync guarantee. When auto-assigning a slot for a group, the lowest slot
+unused by *any* member is chosen, so the same slot number lands on all of
+them.
+
 Slot 0 is reserved by Z-Wave and never shown or written.
 Slot 0 is reserved by Z-Wave and never shown or written.
 
 
 ## Lovelace card
 ## Lovelace card
@@ -57,8 +67,8 @@ add it manually as a JavaScript module resource.
 
 
 ```yaml
 ```yaml
 type: custom:zlock-slots-card
 type: custom:zlock-slots-card
-entity: sensor.front_door_slots    # the *_slots sensor
-lock_entity_id: lock.front_door
+entity: sensor.front_door_slots    # the *_slots sensor (lock or group)
+lock_entity_id: lock.front_door    # — or group_name: All Locks
 title: Front Door                  # optional
 title: Front Door                  # optional
 ```
 ```
 
 

+ 7 - 0
custom_components/zlock_slots/coordinator.py

@@ -116,12 +116,19 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     self.group_data: dict[str, GroupState] = {
     self.group_data: dict[str, GroupState] = {
       g[CONF_GROUP_NAME]: GroupState() for g in groups
       g[CONF_GROUP_NAME]: GroupState() for g in groups
     }
     }
+    # group name → list of member lock entity_ids
+    self._group_to_locks: dict[str, list[str]] = {
+      g[CONF_GROUP_NAME]: list(g.get(CONF_LOCK_ENTITY_IDS, [])) for g in groups
+    }
     # lock_entity_id → list of group names it belongs to
     # lock_entity_id → list of group names it belongs to
     self._lock_to_groups: dict[str, list[str]] = {}
     self._lock_to_groups: dict[str, list[str]] = {}
     for g in groups:
     for g in groups:
       for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
       for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
         self._lock_to_groups.setdefault(lid, []).append(g[CONF_GROUP_NAME])
         self._lock_to_groups.setdefault(lid, []).append(g[CONF_GROUP_NAME])
 
 
+  def group_lock_ids(self, group_name: str) -> list[str]:
+    return self._group_to_locks.get(group_name, [])
+
   async def async_setup(self) -> None:
   async def async_setup(self) -> None:
     stored = await self._store.async_load()
     stored = await self._store.async_load()
     if stored:
     if stored:

+ 18 - 8
custom_components/zlock_slots/frontend/zlock-slots-card.js

@@ -3,8 +3,10 @@
  *
  *
  * Card config:
  * Card config:
  *   type: custom:zlock-slots-card
  *   type: custom:zlock-slots-card
- *   entity: sensor.my_lock_slots          # ZLockSlotsSensor entity
+ *   entity: sensor.my_lock_slots          # slots sensor (lock or group)
  *   lock_entity_id: lock.my_lock          # corresponding lock entity
  *   lock_entity_id: lock.my_lock          # corresponding lock entity
+ *   # — or, to target a group instead of a single lock —
+ *   group_name: All Locks                 # corresponding group name
  */
  */
 
 
 const DOMAIN = "zlock_slots";
 const DOMAIN = "zlock_slots";
@@ -22,7 +24,9 @@ class ZLockSlotsCard extends HTMLElement {
 
 
   setConfig(config) {
   setConfig(config) {
     if (!config.entity) throw new Error("'entity' is required (the slots sensor)");
     if (!config.entity) throw new Error("'entity' is required (the slots sensor)");
-    if (!config.lock_entity_id) throw new Error("'lock_entity_id' is required");
+    if (!config.lock_entity_id && !config.group_name) {
+      throw new Error("either 'lock_entity_id' or 'group_name' is required");
+    }
     this._config = config;
     this._config = config;
     this._render();
     this._render();
   }
   }
@@ -176,9 +180,15 @@ class ZLockSlotsCard extends HTMLElement {
     this._attachEvents();
     this._attachEvents();
   }
   }
 
 
+  _target() {
+    return this._config.group_name
+      ? { group_name: this._config.group_name }
+      : { lock_entity_id: this._config.lock_entity_id };
+  }
+
   _attachEvents() {
   _attachEvents() {
     const root = this.shadowRoot;
     const root = this.shadowRoot;
-    const lock = this._config.lock_entity_id;
+    const target = this._target();
 
 
     root.querySelectorAll("[data-action]").forEach(el => {
     root.querySelectorAll("[data-action]").forEach(el => {
       el.addEventListener("click", async (e) => {
       el.addEventListener("click", async (e) => {
@@ -187,11 +197,11 @@ class ZLockSlotsCard extends HTMLElement {
 
 
         if (action === "toggle-enabled") {
         if (action === "toggle-enabled") {
           const currentlyEnabled = el.dataset.enabled === "true";
           const currentlyEnabled = el.dataset.enabled === "true";
-          await this._callService("set_slot_enabled", { lock_entity_id: lock, slot, enabled: !currentlyEnabled });
+          await this._callService("set_slot_enabled", { ...target, slot, enabled: !currentlyEnabled });
 
 
         } else if (action === "clear") {
         } else if (action === "clear") {
           if (!confirm(`Clear code slot ${slot}?`)) return;
           if (!confirm(`Clear code slot ${slot}?`)) return;
-          await this._callService("clear_code", { lock_entity_id: lock, slot });
+          await this._callService("clear_code", { ...target, slot });
 
 
         } else if (action === "edit-name") {
         } else if (action === "edit-name") {
           root.getElementById(`name-display-${slot}`).style.display = "none";
           root.getElementById(`name-display-${slot}`).style.display = "none";
@@ -231,11 +241,11 @@ class ZLockSlotsCard extends HTMLElement {
             const code = input.value;
             const code = input.value;
             if (code.length < 4 || code.length > 8) return;
             if (code.length < 4 || code.length > 8) return;
             closePin(slot);
             closePin(slot);
-            await this._callService("set_code", { lock_entity_id: lock, slot, code });
+            await this._callService("set_code", { ...target, slot, code });
           } else {
           } else {
             const name = input.value.trim();
             const name = input.value.trim();
             closeName(slot);
             closeName(slot);
-            await this._callService("set_name", { lock_entity_id: lock, slot, name });
+            await this._callService("set_name", { ...target, slot, name });
           }
           }
         } else if (e.key === "Escape") {
         } else if (e.key === "Escape") {
           input.removeEventListener("blur", input._blurHandler);
           input.removeEventListener("blur", input._blurHandler);
@@ -260,7 +270,7 @@ class ZLockSlotsCard extends HTMLElement {
 
 
       status.textContent = "";
       status.textContent = "";
       try {
       try {
-        const data = { lock_entity_id: lock, code };
+        const data = { ...target, code };
         if (slotVal !== undefined) data.slot = slotVal;
         if (slotVal !== undefined) data.slot = slotVal;
         await this._callService("set_code", data);
         await this._callService("set_code", data);
         slotInput.value = "";
         slotInput.value = "";

+ 37 - 1
custom_components/zlock_slots/sensor.py

@@ -12,7 +12,7 @@ from .const import (
   DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
   DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
   CONF_DOOR_SENSOR_MAP, DoorLockState,
   CONF_DOOR_SENSOR_MAP, DoorLockState,
 )
 )
-from .coordinator import ZLockCoordinator, compute_combined_state
+from .coordinator import ZLockCoordinator, SlotInfo, compute_combined_state
 
 
 
 
 async def async_setup_entry(
 async def async_setup_entry(
@@ -46,6 +46,7 @@ async def async_setup_entry(
     group_lock_ids: list[str] = group.get(CONF_LOCK_ENTITY_IDS, [])
     group_lock_ids: list[str] = group.get(CONF_LOCK_ENTITY_IDS, [])
     entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
+    entities.append(ZLockGroupSlotsSensor(coordinator, entry, group_name, group_lock_ids))
     if any(lid in door_sensor_map for lid in group_lock_ids):
     if any(lid in door_sensor_map for lid in group_lock_ids):
       entities.append(
       entities.append(
         ZLockGroupDoorSensor(coordinator, entry, group_name, group_lock_ids)
         ZLockGroupDoorSensor(coordinator, entry, group_name, group_lock_ids)
@@ -283,6 +284,41 @@ class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
     return state.last_user if state else None
     return state.last_user if state else None
 
 
 
 
+class ZLockGroupSlotsSensor(_ZLockGroupBaseSensor):
+  _attr_name = "Slots"
+
+  def __init__(self, coordinator, entry, group_name, group_lock_ids: list[str]):
+    super().__init__(coordinator, entry, group_name)
+    self._group_lock_ids = group_lock_ids
+    self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_slots"
+
+  def _union_slots(self) -> dict[int, SlotInfo]:
+    # Naive union: per slot number, take whichever member has it first. Names
+    # come from the shared Store so they agree across members; status is not
+    # reconciled if members disagree — the group has no sync/integrity role.
+    union: dict[int, SlotInfo] = {}
+    for lid in self._group_lock_ids:
+      lock_state = self.coordinator.data.get(lid)
+      if not lock_state:
+        continue
+      for num, info in lock_state.slots.items():
+        union.setdefault(num, info)
+    return union
+
+  @property
+  def native_value(self) -> int:
+    return len(self._union_slots())
+
+  @property
+  def extra_state_attributes(self) -> dict:
+    return {
+      "slots": [
+        {"slot_num": num, "status": info.status, "name": info.name}
+        for num, info in sorted(self._union_slots().items())
+      ]
+    }
+
+
 class ZLockGroupDoorSensor(_ZLockGroupBaseSensor):
 class ZLockGroupDoorSensor(_ZLockGroupBaseSensor):
   _attr_name = "Door"
   _attr_name = "Door"
 
 

+ 104 - 54
custom_components/zlock_slots/services.py

@@ -18,29 +18,46 @@ SERVICE_CLEAR_CODE = "clear_code"
 SERVICE_SET_NAME = "set_name"
 SERVICE_SET_NAME = "set_name"
 SERVICE_SET_SLOT_ENABLED = "set_slot_enabled"
 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:
 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}")
   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:
 def register_services(hass: HomeAssistant) -> None:
   async def handle_set_code(call: ServiceCall) -> None:
   async def handle_set_code(call: ServiceCall) -> None:
-    lock_entity_id = call.data["lock_entity_id"]
     code = call.data["code"]
     code = call.data["code"]
     name = call.data.get("name")
     name = call.data.get("name")
+    coordinator, lock_ids = _resolve_targets(hass, call)
 
 
-    coordinator = _get_coordinator(hass, lock_entity_id)
     slot = call.data.get("slot")
     slot = call.data.get("slot")
     if slot is None:
     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)
       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:
   async def handle_clear_code(call: ServiceCall) -> None:
-    lock_entity_id = call.data["lock_entity_id"]
     slot = call.data["slot"]
     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:
   async def handle_set_name(call: ServiceCall) -> None:
-    lock_entity_id = call.data["lock_entity_id"]
     slot = call.data["slot"]
     slot = call.data["slot"]
     name = call.data["name"]
     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:
   async def handle_set_slot_enabled(call: ServiceCall) -> None:
     from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
     from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
     from zwave_js_server.model.access_control import SetUserOptions
     from zwave_js_server.model.access_control import SetUserOptions
 
 
-    lock_entity_id = call.data["lock_entity_id"]
     slot = call.data["slot"]
     slot = call.data["slot"]
     enabled = call.data["enabled"]
     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_SET_CODE, handle_set_code, SET_CODE_SCHEMA)
   hass.services.async_register(DOMAIN, SERVICE_CLEAR_CODE, handle_clear_code, CLEAR_CODE_SCHEMA)
   hass.services.async_register(DOMAIN, SERVICE_CLEAR_CODE, handle_clear_code, CLEAR_CODE_SCHEMA)

+ 32 - 8
custom_components/zlock_slots/services.yaml

@@ -1,15 +1,21 @@
 set_code:
 set_code:
   name: Set Lock Code
   name: Set Lock Code
-  description: Set a user code in a lock slot. Optionally assign a name to the slot.
+  description: Set a user code in a lock slot. Optionally assign a name to the slot. Provide exactly one of lock_entity_id or group_name.
   fields:
   fields:
     lock_entity_id:
     lock_entity_id:
       name: Lock entity
       name: Lock entity
       description: The lock entity to set the code on.
       description: The lock entity to set the code on.
-      required: true
+      required: false
       selector:
       selector:
         entity:
         entity:
           domain: lock
           domain: lock
           integration: zwave_js
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Set the code on every lock in this group. Auto-assigned slot numbers are chosen against the union of slots used across the group.
+      required: false
+      selector:
+        text:
     slot:
     slot:
       name: Slot number
       name: Slot number
       description: Code slot number (1 or higher). If omitted, the lowest unused slot is assigned automatically.
       description: Code slot number (1 or higher). If omitted, the lowest unused slot is assigned automatically.
@@ -35,16 +41,22 @@ set_code:
 
 
 clear_code:
 clear_code:
   name: Clear Lock Code
   name: Clear Lock Code
-  description: Remove the user code from a lock slot. The slot name is preserved.
+  description: Remove the user code from a lock slot. The slot name is preserved. Provide exactly one of lock_entity_id or group_name.
   fields:
   fields:
     lock_entity_id:
     lock_entity_id:
       name: Lock entity
       name: Lock entity
       description: The lock entity to clear the code on.
       description: The lock entity to clear the code on.
-      required: true
+      required: false
       selector:
       selector:
         entity:
         entity:
           domain: lock
           domain: lock
           integration: zwave_js
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Clear the slot on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
     slot:
       name: Slot number
       name: Slot number
       description: Code slot number to clear.
       description: Code slot number to clear.
@@ -57,16 +69,22 @@ clear_code:
 
 
 set_name:
 set_name:
   name: Set Slot Name
   name: Set Slot Name
-  description: Assign or update the display name for a lock slot.
+  description: Assign or update the display name for a lock slot. Provide exactly one of lock_entity_id or group_name.
   fields:
   fields:
     lock_entity_id:
     lock_entity_id:
       name: Lock entity
       name: Lock entity
       description: The lock entity the slot belongs to.
       description: The lock entity the slot belongs to.
-      required: true
+      required: false
       selector:
       selector:
         entity:
         entity:
           domain: lock
           domain: lock
           integration: zwave_js
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Set the slot name on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
     slot:
       name: Slot number
       name: Slot number
       description: Code slot number to name.
       description: Code slot number to name.
@@ -85,16 +103,22 @@ set_name:
 
 
 set_slot_enabled:
 set_slot_enabled:
   name: Set Slot Enabled
   name: Set Slot Enabled
-  description: Enable or disable a lock code slot without changing its PIN.
+  description: Enable or disable a lock code slot without changing its PIN. Provide exactly one of lock_entity_id or group_name.
   fields:
   fields:
     lock_entity_id:
     lock_entity_id:
       name: Lock entity
       name: Lock entity
       description: The lock entity the slot belongs to.
       description: The lock entity the slot belongs to.
-      required: true
+      required: false
       selector:
       selector:
         entity:
         entity:
           domain: lock
           domain: lock
           integration: zwave_js
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Enable or disable the slot on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
     slot:
       name: Slot number
       name: Slot number
       description: Code slot number to enable or disable.
       description: Code slot number to enable or disable.

+ 35 - 1
tests/test_sensor.py

@@ -12,11 +12,14 @@ from custom_components.zlock_slots.sensor import (
   ZLockSlotsSensor,
   ZLockSlotsSensor,
   ZLockGroupLastActionSensor,
   ZLockGroupLastActionSensor,
   ZLockGroupLastUserSensor,
   ZLockGroupLastUserSensor,
+  ZLockGroupSlotsSensor,
 )
 )
 from custom_components.zlock_slots.const import (
 from custom_components.zlock_slots.const import (
   DOMAIN, CONF_GROUPS, CONF_GROUP_NAME, CONF_LOCK_ENTITY_IDS,
   DOMAIN, CONF_GROUPS, CONF_GROUP_NAME, CONF_LOCK_ENTITY_IDS,
 )
 )
-from tests.conftest import LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY
+from tests.conftest import LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY, FakeNode, FakeUserData
+
+LOCK_2 = "lock.back_door"
 
 
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
 
 
@@ -211,6 +214,37 @@ async def test_group_last_user_after_alarm(hass, group_config_entry, mock_node):
   c.async_teardown()
   c.async_teardown()
 
 
 
 
+async def test_group_slots_sensor_naive_union(hass):
+  """Slot numbers from every member merge; a slot present on only one member still appears."""
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
+      }]
+    },
+    entry_id="test_group_union_entry",
+  )
+  entry.add_to_hass(hass)
+
+  nodes = {
+    LOCK_ENTITY_ID: FakeNode(users=[FakeUserData(1, active=True)]),
+    LOCK_2: FakeNode(users=[FakeUserData(2, active=False)]),
+  }
+
+  with patch(_ZWAVE_HELPER, side_effect=lambda hass_, eid: nodes[eid]):
+    c = ZLockCoordinator(hass, entry)
+    await c.async_setup()
+
+  sensor = ZLockGroupSlotsSensor(c, entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2])
+  assert sensor.native_value == 2
+  slots = {s["slot_num"]: s["status"] for s in sensor.extra_state_attributes["slots"]}
+  assert slots == {1: "enabled", 2: "disabled"}
+  c.async_teardown()
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Device attachment
 # Device attachment
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 157 - 1
tests/test_services.py

@@ -1,15 +1,20 @@
 """Tests for zlock_slots service handlers."""
 """Tests for zlock_slots service handlers."""
 import pytest
 import pytest
+import voluptuous as vol
 from unittest.mock import patch, AsyncMock
 from unittest.mock import patch, AsyncMock
+from pytest_homeassistant_custom_component.common import MockConfigEntry
 
 
 from homeassistant.exceptions import ServiceValidationError
 from homeassistant.exceptions import ServiceValidationError
 
 
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.services import register_services, unregister_services
 from custom_components.zlock_slots.services import register_services, unregister_services
-from custom_components.zlock_slots.const import DOMAIN
+from custom_components.zlock_slots.const import (
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+)
 from tests.conftest import LOCK_ENTITY_ID, FakeUserData, FakeNode
 from tests.conftest import LOCK_ENTITY_ID, FakeUserData, FakeNode
 
 
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+LOCK_2 = "lock.back_door"
 
 
 
 
 @pytest.fixture
 @pytest.fixture
@@ -33,6 +38,48 @@ async def service_env(hass, config_entry, mock_node):
   coord.async_teardown()
   coord.async_teardown()
 
 
 
 
+@pytest.fixture
+async def group_service_env(hass):
+  """Two-lock group, each backed by its own fake node, services registered."""
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
+      }]
+    },
+    entry_id="test_group_service_entry",
+  )
+  entry.add_to_hass(hass)
+
+  nodes = {
+    LOCK_ENTITY_ID: FakeNode(users=[FakeUserData(1, active=True)]),
+    LOCK_2: FakeNode(users=[FakeUserData(2, active=True)]),
+  }
+
+  def _node_for(hass_, entity_id):
+    return nodes[entity_id]
+
+  with patch(_ZWAVE_HELPER, side_effect=_node_for):
+    coord = ZLockCoordinator(hass, entry)
+    await coord.async_setup()
+    hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
+
+    set_lock_usercode = AsyncMock()
+    clear_lock_usercode = AsyncMock()
+    hass.services.async_register("zwave_js", "set_lock_usercode", set_lock_usercode)
+    hass.services.async_register("zwave_js", "clear_lock_usercode", clear_lock_usercode)
+
+    register_services(hass)
+    with patch(_ZWAVE_HELPER, side_effect=_node_for):
+      yield coord, nodes, set_lock_usercode, clear_lock_usercode
+
+  unregister_services(hass)
+  coord.async_teardown()
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # set_code
 # set_code
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -169,3 +216,112 @@ async def test_set_slot_enabled_true(hass, service_env, mock_node):
 
 
   _, opts_arg = mock_node.access_control.set_user.call_args[0]
   _, opts_arg = mock_node.access_control.set_user.call_args[0]
   assert opts_arg.active is True
   assert opts_arg.active is True
+
+
+# ---------------------------------------------------------------------------
+# Group targeting: fan-out and target validation
+# ---------------------------------------------------------------------------
+
+async def test_set_code_group_fanout(hass, group_service_env):
+  _, _, set_lock_usercode, _ = group_service_env
+  await hass.services.async_call(
+    DOMAIN, "set_code",
+    {"group_name": "All Locks", "slot": 5, "code": "1234"},
+    blocking=True,
+  )
+
+  assert set_lock_usercode.await_count == 2
+  entity_ids = {c[0][0].data["entity_id"] for c in set_lock_usercode.call_args_list}
+  assert entity_ids == {LOCK_ENTITY_ID, LOCK_2}
+
+
+async def test_set_code_group_auto_assign_uses_union_of_members(hass, group_service_env):
+  """LOCK_ENTITY_ID has slot 1 occupied, LOCK_2 has slot 2 — union picks 3 for both."""
+  _, _, set_lock_usercode, _ = group_service_env
+  await hass.services.async_call(
+    DOMAIN, "set_code",
+    {"group_name": "All Locks", "code": "5678"},
+    blocking=True,
+  )
+
+  slots_used = {c[0][0].data["code_slot"] for c in set_lock_usercode.call_args_list}
+  assert slots_used == {3}
+
+
+async def test_clear_code_group_fanout(hass, group_service_env):
+  _, _, _, clear_lock_usercode = group_service_env
+  await hass.services.async_call(
+    DOMAIN, "clear_code",
+    {"group_name": "All Locks", "slot": 1},
+    blocking=True,
+  )
+
+  assert clear_lock_usercode.await_count == 2
+  entity_ids = {c[0][0].data["entity_id"] for c in clear_lock_usercode.call_args_list}
+  assert entity_ids == {LOCK_ENTITY_ID, LOCK_2}
+
+
+async def test_set_name_group_fanout(hass, group_service_env):
+  coord, _, _, _ = group_service_env
+  await hass.services.async_call(
+    DOMAIN, "set_name",
+    {"group_name": "All Locks", "slot": 1, "name": "Everyone"},
+    blocking=True,
+  )
+
+  assert coord.get_name(LOCK_ENTITY_ID, 1) == "Everyone"
+  assert coord.get_name(LOCK_2, 1) == "Everyone"
+
+
+async def test_set_slot_enabled_group_fanout(hass, group_service_env):
+  coord, nodes, _, _ = group_service_env
+  await hass.services.async_call(
+    DOMAIN, "set_slot_enabled",
+    {"group_name": "All Locks", "slot": 1, "enabled": False},
+    blocking=True,
+  )
+
+  nodes[LOCK_ENTITY_ID].access_control.set_user.assert_awaited_once()
+  nodes[LOCK_2].access_control.set_user.assert_awaited_once()
+
+
+async def test_group_fanout_continues_after_one_member_fails(hass, group_service_env):
+  """One lock erroring must not block the operation from reaching the others."""
+  coord, nodes, _, _ = group_service_env
+  nodes[LOCK_ENTITY_ID].access_control.set_user.side_effect = RuntimeError("boom")
+
+  await hass.services.async_call(
+    DOMAIN, "set_slot_enabled",
+    {"group_name": "All Locks", "slot": 1, "enabled": False},
+    blocking=True,
+  )
+
+  nodes[LOCK_ENTITY_ID].access_control.set_user.assert_awaited_once()
+  nodes[LOCK_2].access_control.set_user.assert_awaited_once()
+
+
+async def test_unknown_group_raises(hass, group_service_env):
+  with pytest.raises(ServiceValidationError):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"group_name": "Nonexistent", "slot": 1, "code": "1234"},
+      blocking=True,
+    )
+
+
+async def test_both_lock_and_group_target_rejected(hass, group_service_env):
+  with pytest.raises(vol.Invalid):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "group_name": "All Locks", "slot": 1, "code": "1234"},
+      blocking=True,
+    )
+
+
+async def test_neither_lock_nor_group_target_rejected(hass, group_service_env):
+  with pytest.raises(vol.Invalid):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"slot": 1, "code": "1234"},
+      blocking=True,
+    )