فهرست منبع

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}` |
 
 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)
 and are preserved when a code is cleared.
 
 ## Services
 
-All services take `lock_entity_id` plus:
+Every service takes exactly one of `lock_entity_id` or `group_name`, plus:
 
 | 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_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.
 
 ## Lovelace card
@@ -57,8 +67,8 @@ add it manually as a JavaScript module resource.
 
 ```yaml
 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
 ```
 

+ 7 - 0
custom_components/zlock_slots/coordinator.py

@@ -116,12 +116,19 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     self.group_data: dict[str, GroupState] = {
       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
     self._lock_to_groups: dict[str, list[str]] = {}
     for g in groups:
       for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
         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:
     stored = await self._store.async_load()
     if stored:

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

@@ -3,8 +3,10 @@
  *
  * Card config:
  *   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
+ *   # — or, to target a group instead of a single lock —
+ *   group_name: All Locks                 # corresponding group name
  */
 
 const DOMAIN = "zlock_slots";
@@ -22,7 +24,9 @@ class ZLockSlotsCard extends HTMLElement {
 
   setConfig(config) {
     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._render();
   }
@@ -176,9 +180,15 @@ class ZLockSlotsCard extends HTMLElement {
     this._attachEvents();
   }
 
+  _target() {
+    return this._config.group_name
+      ? { group_name: this._config.group_name }
+      : { lock_entity_id: this._config.lock_entity_id };
+  }
+
   _attachEvents() {
     const root = this.shadowRoot;
-    const lock = this._config.lock_entity_id;
+    const target = this._target();
 
     root.querySelectorAll("[data-action]").forEach(el => {
       el.addEventListener("click", async (e) => {
@@ -187,11 +197,11 @@ class ZLockSlotsCard extends HTMLElement {
 
         if (action === "toggle-enabled") {
           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") {
           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") {
           root.getElementById(`name-display-${slot}`).style.display = "none";
@@ -231,11 +241,11 @@ class ZLockSlotsCard extends HTMLElement {
             const code = input.value;
             if (code.length < 4 || code.length > 8) return;
             closePin(slot);
-            await this._callService("set_code", { lock_entity_id: lock, slot, code });
+            await this._callService("set_code", { ...target, slot, code });
           } else {
             const name = input.value.trim();
             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") {
           input.removeEventListener("blur", input._blurHandler);
@@ -260,7 +270,7 @@ class ZLockSlotsCard extends HTMLElement {
 
       status.textContent = "";
       try {
-        const data = { lock_entity_id: lock, code };
+        const data = { ...target, code };
         if (slotVal !== undefined) data.slot = slotVal;
         await this._callService("set_code", data);
         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,
   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(
@@ -46,6 +46,7 @@ async def async_setup_entry(
     group_lock_ids: list[str] = group.get(CONF_LOCK_ENTITY_IDS, [])
     entities.append(ZLockGroupLastActionSensor(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):
       entities.append(
         ZLockGroupDoorSensor(coordinator, entry, group_name, group_lock_ids)
@@ -283,6 +284,41 @@ class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
     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):
   _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_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)

+ 32 - 8
custom_components/zlock_slots/services.yaml

@@ -1,15 +1,21 @@
 set_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:
     lock_entity_id:
       name: Lock entity
       description: The lock entity to set the code on.
-      required: true
+      required: false
       selector:
         entity:
           domain: lock
           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:
       name: Slot number
       description: Code slot number (1 or higher). If omitted, the lowest unused slot is assigned automatically.
@@ -35,16 +41,22 @@ set_code:
 
 clear_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:
     lock_entity_id:
       name: Lock entity
       description: The lock entity to clear the code on.
-      required: true
+      required: false
       selector:
         entity:
           domain: lock
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Clear the slot on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
       name: Slot number
       description: Code slot number to clear.
@@ -57,16 +69,22 @@ clear_code:
 
 set_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:
     lock_entity_id:
       name: Lock entity
       description: The lock entity the slot belongs to.
-      required: true
+      required: false
       selector:
         entity:
           domain: lock
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Set the slot name on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
       name: Slot number
       description: Code slot number to name.
@@ -85,16 +103,22 @@ set_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:
     lock_entity_id:
       name: Lock entity
       description: The lock entity the slot belongs to.
-      required: true
+      required: false
       selector:
         entity:
           domain: lock
           integration: zwave_js
+    group_name:
+      name: Group
+      description: Enable or disable the slot on every lock in this group.
+      required: false
+      selector:
+        text:
     slot:
       name: Slot number
       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,
   ZLockGroupLastActionSensor,
   ZLockGroupLastUserSensor,
+  ZLockGroupSlotsSensor,
 )
 from custom_components.zlock_slots.const import (
   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"
 
@@ -211,6 +214,37 @@ async def test_group_last_user_after_alarm(hass, group_config_entry, mock_node):
   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
 # ---------------------------------------------------------------------------

+ 157 - 1
tests/test_services.py

@@ -1,15 +1,20 @@
 """Tests for zlock_slots service handlers."""
 import pytest
+import voluptuous as vol
 from unittest.mock import patch, AsyncMock
+from pytest_homeassistant_custom_component.common import MockConfigEntry
 
 from homeassistant.exceptions import ServiceValidationError
 
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 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
 
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+LOCK_2 = "lock.back_door"
 
 
 @pytest.fixture
@@ -33,6 +38,48 @@ async def service_env(hass, config_entry, mock_node):
   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
 # ---------------------------------------------------------------------------
@@ -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]
   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,
+    )