Răsfoiți Sursa

Initial implementation of zlock_slots v0.1.0

HACS-distributed HA custom integration for managing Z-Wave lock user
code slots. Includes config flow, coordinator (slot fetch + name store
+ event listener), three sensors per lock, three services, and a
custom Lovelace card.
ZiB 4 luni în urmă
comite
e3c7fd3c34

+ 69 - 0
custom_components/zlock_slots/__init__.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.components.http import StaticPathConfig
+
+from .const import DOMAIN
+from .coordinator import ZLockCoordinator
+from .services import register_services, unregister_services
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS = ["sensor"]
+
+_CARD_URL = f"/{DOMAIN}/zlock-slots-card.js"
+_CARD_PATH = Path(__file__).parent / "frontend" / "zlock-slots-card.js"
+
+
+async def async_setup(hass: HomeAssistant, config: dict) -> bool:
+  hass.data.setdefault(DOMAIN, {})
+  return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+  coordinator = ZLockCoordinator(hass, entry)
+  await coordinator.async_setup()
+
+  hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+
+  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+  register_services(hass)
+  await _async_register_frontend(hass)
+
+  return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+  unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+  if unloaded:
+    coordinator: ZLockCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
+    coordinator.async_teardown()
+
+    if not hass.data[DOMAIN]:
+      unregister_services(hass)
+
+  return unloaded
+
+
+async def _async_register_frontend(hass: HomeAssistant) -> None:
+  try:
+    await hass.http.async_register_static_paths([
+      StaticPathConfig(_CARD_URL, str(_CARD_PATH), cache_headers=False)
+    ])
+  except Exception:
+    _LOGGER.debug("Static path %s already registered", _CARD_URL)
+
+  try:
+    from homeassistant.components.frontend import async_register_extra_module_url
+    async_register_extra_module_url(hass, _CARD_URL)
+  except Exception:
+    _LOGGER.warning(
+      "Could not auto-register Lovelace card. Add manually via Settings → Dashboard → Resources: %s",
+      _CARD_URL,
+    )

+ 48 - 0
custom_components/zlock_slots/config_flow.py

@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers import selector
+
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+
+
+class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
+  VERSION = 1
+
+  async def async_step_user(
+    self, user_input: dict | None = None
+  ) -> ConfigFlowResult:
+    errors: dict[str, str] = {}
+
+    if user_input is not None:
+      lock_ids: list[str] = user_input.get(CONF_LOCK_ENTITY_IDS, [])
+      if not lock_ids:
+        errors[CONF_LOCK_ENTITY_IDS] = "no_locks_selected"
+      else:
+        title = self._make_title(lock_ids)
+        return self.async_create_entry(title=title, data={CONF_LOCK_ENTITY_IDS: lock_ids})
+
+    schema = vol.Schema({
+      vol.Required(CONF_LOCK_ENTITY_IDS): selector.EntitySelector(
+        selector.EntitySelectorConfig(
+          domain="lock",
+          integration="zwave_js",
+          multiple=True,
+        )
+      ),
+    })
+
+    return self.async_show_form(
+      step_id="user",
+      data_schema=schema,
+      errors=errors,
+    )
+
+  def _make_title(self, lock_ids: list[str]) -> str:
+    if len(lock_ids) == 1:
+      state = self.hass.states.get(lock_ids[0])
+      if state:
+        return state.name
+    return f"Z-Lock Slots ({len(lock_ids)} locks)"

+ 23 - 0
custom_components/zlock_slots/const.py

@@ -0,0 +1,23 @@
+DOMAIN = "zlock_slots"
+
+STORAGE_KEY = "zlock_slots_names"
+STORAGE_VERSION = 1
+
+CONF_LOCK_ENTITY_IDS = "lock_entity_ids"
+
+ZWAVE_JS_NOTIFICATION_EVENT = "zwave_js_notification"
+
+LOCK_OP_TYPE = 6
+
+# Maps Z-Wave lock operation event number → (last_action, last_user_template)
+# last_user_template is "keypad" (resolve slot), "<Manual>", or "<Radio>"
+EVENT_MAP: dict[int, tuple[str, str]] = {
+  1: ("unlocked", "<Radio>"),
+  2: ("locked",   "<Radio>"),
+  3: ("unlocked", "keypad"),
+  4: ("locked",   "keypad"),
+  5: ("unlocked", "<Manual>"),
+  6: ("locked",   "<Manual>"),
+  7: ("unlocked", "<Radio>"),
+  8: ("locked",   "<Radio>"),
+}

+ 161 - 0
custom_components/zlock_slots/coordinator.py

@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from homeassistant.core import HomeAssistant, Event, callback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import (
+  DOMAIN,
+  STORAGE_KEY,
+  STORAGE_VERSION,
+  CONF_LOCK_ENTITY_IDS,
+  ZWAVE_JS_NOTIFICATION_EVENT,
+  LOCK_OP_TYPE,
+  EVENT_MAP,
+)
+
+if TYPE_CHECKING:
+  from homeassistant.config_entries import ConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class SlotInfo:
+  status: str  # "enabled" or "disabled"
+  name: str | None = None
+
+
+@dataclass
+class LockState:
+  slots: dict[int, SlotInfo] = field(default_factory=dict)
+  last_action: str | None = None
+  last_user: str | None = None
+
+
+class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
+  """Manages slot data, name storage, and event listening for all configured locks."""
+
+  def __init__(self, hass: HomeAssistant, entry: "ConfigEntry") -> None:
+    super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
+    self._entry = entry
+    self._lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
+    self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
+    self._names: dict[str, str] = {}  # "{entity_id}:{slot_num}" → name
+    self._device_to_entity: dict[str, str] = {}  # device_id → lock_entity_id
+    self._unsub_event: callback | None = None
+    self.data: dict[str, LockState] = {eid: LockState() for eid in self._lock_entity_ids}
+
+  async def async_setup(self) -> None:
+    stored = await self._store.async_load()
+    if stored:
+      self._names = stored.get("names", {})
+
+    self._build_device_map()
+    await self._async_fetch_all_slots()
+
+    self._unsub_event = self.hass.bus.async_listen(
+      ZWAVE_JS_NOTIFICATION_EVENT, self._handle_zwave_event
+    )
+
+  def async_teardown(self) -> None:
+    if self._unsub_event:
+      self._unsub_event()
+      self._unsub_event = None
+
+  def _build_device_map(self) -> None:
+    er_obj = er.async_get(self.hass)
+    for lock_entity_id in self._lock_entity_ids:
+      entry = er_obj.async_get(lock_entity_id)
+      if entry and entry.device_id:
+        self._device_to_entity[entry.device_id] = lock_entity_id
+
+  async def _async_fetch_all_slots(self) -> None:
+    for lock_entity_id in self._lock_entity_ids:
+      slots = await self._fetch_slots(lock_entity_id)
+      self.data[lock_entity_id].slots = slots
+    self.async_update_listeners()
+
+  async def _fetch_slots(self, lock_entity_id: str) -> dict[int, SlotInfo]:
+    try:
+      from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
+      node = await async_get_node_from_entity_id(self.hass, lock_entity_id)
+      users = await node.access_control.get_users_cached()
+    except Exception:
+      _LOGGER.exception("Failed to fetch code slots for %s", lock_entity_id)
+      return {}
+
+    slots: dict[int, SlotInfo] = {}
+    for slot_num, user in users.items():
+      if slot_num == 0:
+        continue
+      status_name = user.status.name if hasattr(user.status, "name") else str(user.status)
+      if status_name == "Available":
+        continue
+      slots[slot_num] = SlotInfo(
+        status=status_name.lower(),
+        name=self.get_name(lock_entity_id, slot_num),
+      )
+    return slots
+
+  @callback
+  def _handle_zwave_event(self, event: Event) -> None:
+    data = event.data
+    if data.get("type") != LOCK_OP_TYPE:
+      return
+
+    device_id = data.get("device_id")
+    lock_entity_id = self._device_to_entity.get(device_id)
+    if not lock_entity_id:
+      return
+
+    event_num = data.get("event")
+    mapping = EVENT_MAP.get(event_num)
+    if not mapping:
+      return
+
+    action, user_template = mapping
+    if user_template == "keypad":
+      params = data.get("parameters", {})
+      user_id = params.get("userId")
+      if user_id is not None:
+        user_str = self.get_name(lock_entity_id, int(user_id)) or str(user_id)
+      else:
+        user_str = "<Keypad>"
+    else:
+      user_str = user_template
+
+    self.data[lock_entity_id].last_action = action
+    self.data[lock_entity_id].last_user = user_str
+    self.async_update_listeners()
+
+  def get_name(self, lock_entity_id: str, slot_num: int) -> str | None:
+    return self._names.get(f"{lock_entity_id}:{slot_num}")
+
+  async def async_set_name(self, lock_entity_id: str, slot_num: int, name: str) -> None:
+    key = f"{lock_entity_id}:{slot_num}"
+    if name:
+      self._names[key] = name
+    else:
+      self._names.pop(key, None)
+    await self._store.async_save({"names": self._names})
+
+    # Refresh the name on the cached slot if it exists
+    state = self.data.get(lock_entity_id)
+    if state and slot_num in state.slots:
+      state.slots[slot_num].name = name or None
+    self.async_update_listeners()
+
+  async def async_refresh_lock(self, lock_entity_id: str) -> None:
+    slots = await self._fetch_slots(lock_entity_id)
+    self.data[lock_entity_id].slots = slots
+    self.async_update_listeners()
+
+  async def _async_update_data(self) -> dict[str, LockState]:
+    await self._async_fetch_all_slots()
+    return self.data

+ 221 - 0
custom_components/zlock_slots/frontend/zlock-slots-card.js

@@ -0,0 +1,221 @@
+/**
+ * zlock-slots-card — Lovelace card for Z-Lock Slots integration.
+ *
+ * Card config:
+ *   type: custom:zlock-slots-card
+ *   entity: sensor.my_lock_slots          # ZLockSlotsSensor entity
+ *   lock_entity_id: lock.my_lock          # corresponding lock entity
+ */
+
+const DOMAIN = "zlock_slots";
+
+class ZLockSlotsCard extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: "open" });
+    this._config = null;
+    this._hass = null;
+  }
+
+  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");
+    this._config = config;
+    this._render();
+  }
+
+  set hass(hass) {
+    this._hass = hass;
+    this._render();
+  }
+
+  _slotsEntity() {
+    return this._hass && this._config ? this._hass.states[this._config.entity] : null;
+  }
+
+  _sibling(suffix) {
+    if (!this._config || !this._hass) return null;
+    const base = this._config.entity.replace(/^sensor\./, "sensor.").replace(/_slots$/, "");
+    return this._hass.states[`${base}_${suffix}`];
+  }
+
+  async _callService(service, data) {
+    await this._hass.callService(DOMAIN, service, data);
+  }
+
+  _render() {
+    if (!this._hass || !this._config) return;
+
+    const slotsState = this._slotsEntity();
+    const slots = slotsState?.attributes?.slots ?? [];
+    const lastAction = this._sibling("last_action")?.state ?? "—";
+    const lastUser = this._sibling("last_user")?.state ?? "—";
+
+    this.shadowRoot.innerHTML = `
+      <style>
+        :host { display: block; }
+        ha-card { padding: 16px; }
+        h3 { margin: 0 0 12px; font-size: 1rem; font-weight: 600; }
+        table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
+        th { text-align: left; padding: 4px 8px; opacity: 0.6; font-weight: 500; }
+        td { padding: 4px 8px; vertical-align: middle; }
+        tr:not(:last-child) td { border-bottom: 1px solid var(--divider-color, #e0e0e0); }
+        .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
+        .badge.enabled { background: var(--success-color, #4caf50); color: #fff; }
+        .badge.disabled { background: var(--warning-color, #ff9800); color: #fff; }
+        .name-cell { display: flex; align-items: center; gap: 6px; }
+        .edit-btn { background: none; border: none; cursor: pointer; padding: 2px; opacity: 0.5; font-size: 0.9rem; }
+        .edit-btn:hover { opacity: 1; }
+        .section { margin-top: 20px; }
+        .form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
+        label { font-size: 0.8rem; opacity: 0.7; }
+        input { padding: 6px 8px; border: 1px solid var(--divider-color, #ccc); border-radius: 4px; font-size: 0.9rem; background: var(--card-background-color, #fff); color: var(--primary-text-color); }
+        button.action { background: var(--primary-color, #03a9f4); color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-size: 0.9rem; }
+        button.action:hover { opacity: 0.85; }
+        button.danger { background: var(--error-color, #f44336); }
+        .status-row { display: flex; gap: 24px; font-size: 0.85rem; margin-bottom: 4px; }
+        .status-label { opacity: 0.6; }
+        .empty-note { opacity: 0.5; font-size: 0.85rem; padding: 8px 0; }
+        .inline-edit { display: flex; gap: 6px; align-items: center; }
+        .inline-edit input { flex: 1; }
+      </style>
+      <ha-card>
+        <div class="section">
+          <div class="status-row">
+            <span><span class="status-label">Last action:</span> ${lastAction}</span>
+            <span><span class="status-label">Last user:</span> ${lastUser}</span>
+          </div>
+        </div>
+
+        <div class="section">
+          <h3>Active code slots</h3>
+          ${slots.length === 0
+            ? '<div class="empty-note">No active code slots.</div>'
+            : `<table>
+                <thead><tr><th>#</th><th>Status</th><th>Name</th><th></th></tr></thead>
+                <tbody>
+                  ${slots.map(s => `
+                    <tr data-slot="${s.slot_num}">
+                      <td>${s.slot_num}</td>
+                      <td><span class="badge ${s.status}">${s.status}</span></td>
+                      <td>
+                        <span class="name-cell" id="name-display-${s.slot_num}">
+                          ${s.name ?? '<em style="opacity:.4">unnamed</em>'}
+                          <button class="edit-btn" data-action="edit-name" data-slot="${s.slot_num}" title="Edit name">✏️</button>
+                        </span>
+                        <span class="inline-edit" id="name-edit-${s.slot_num}" style="display:none">
+                          <input id="name-input-${s.slot_num}" type="text" value="${s.name ?? ''}" placeholder="Enter name" />
+                          <button class="action" data-action="save-name" data-slot="${s.slot_num}">Save</button>
+                          <button class="edit-btn" data-action="cancel-name" data-slot="${s.slot_num}">✕</button>
+                        </span>
+                      </td>
+                      <td>
+                        <button class="action danger" data-action="clear" data-slot="${s.slot_num}" style="padding:4px 10px;font-size:0.8rem">Clear</button>
+                      </td>
+                    </tr>
+                  `).join("")}
+                </tbody>
+              </table>`
+          }
+        </div>
+
+        <div class="section">
+          <h3>Add / overwrite slot</h3>
+          <div class="form-row">
+            <label>Slot number</label>
+            <input id="add-slot" type="number" min="1" placeholder="e.g. 5" />
+          </div>
+          <div class="form-row">
+            <label>Code (PIN)</label>
+            <input id="add-code" type="password" placeholder="PIN code" />
+          </div>
+          <div class="form-row">
+            <label>Name (optional)</label>
+            <input id="add-name" type="text" placeholder="e.g. Front door guest" />
+          </div>
+          <button class="action" id="add-btn">Set code</button>
+          <span id="add-status" style="margin-left:8px;font-size:0.85rem;opacity:0.7"></span>
+        </div>
+      </ha-card>
+    `;
+
+    this._attachEvents();
+  }
+
+  _attachEvents() {
+    const root = this.shadowRoot;
+    const lock = this._config.lock_entity_id;
+
+    root.querySelectorAll("[data-action]").forEach(el => {
+      el.addEventListener("click", async (e) => {
+        const action = el.dataset.action;
+        const slot = parseInt(el.dataset.slot, 10);
+
+        if (action === "clear") {
+          if (!confirm(`Clear code slot ${slot}?`)) return;
+          await this._callService("clear_code", { lock_entity_id: lock, slot });
+
+        } else if (action === "edit-name") {
+          root.getElementById(`name-display-${slot}`).style.display = "none";
+          root.getElementById(`name-edit-${slot}`).style.display = "flex";
+          root.getElementById(`name-input-${slot}`).focus();
+
+        } else if (action === "cancel-name") {
+          root.getElementById(`name-display-${slot}`).style.display = "flex";
+          root.getElementById(`name-edit-${slot}`).style.display = "none";
+
+        } else if (action === "save-name") {
+          const name = root.getElementById(`name-input-${slot}`).value.trim();
+          await this._callService("set_name", { lock_entity_id: lock, slot, name });
+          root.getElementById(`name-display-${slot}`).style.display = "flex";
+          root.getElementById(`name-edit-${slot}`).style.display = "none";
+        }
+      });
+    });
+
+    root.getElementById("add-btn")?.addEventListener("click", async () => {
+      const slotInput = root.getElementById("add-slot");
+      const codeInput = root.getElementById("add-code");
+      const nameInput = root.getElementById("add-name");
+      const status = root.getElementById("add-status");
+
+      const slot = parseInt(slotInput.value, 10);
+      const code = codeInput.value.trim();
+      const name = nameInput.value.trim() || undefined;
+
+      if (!slot || slot < 1) { status.textContent = "Enter a valid slot number."; return; }
+      if (!code) { status.textContent = "Enter a PIN code."; return; }
+
+      status.textContent = "Setting…";
+      try {
+        await this._callService("set_code", { lock_entity_id: lock, slot, code, ...(name ? { name } : {}) });
+        slotInput.value = "";
+        codeInput.value = "";
+        nameInput.value = "";
+        status.textContent = "Done.";
+        setTimeout(() => { status.textContent = ""; }, 3000);
+      } catch (err) {
+        status.textContent = `Error: ${err.message ?? err}`;
+      }
+    });
+  }
+
+  getCardSize() { return 4; }
+
+  static getConfigElement() {
+    return document.createElement("zlock-slots-card-editor");
+  }
+
+  static getStubConfig() {
+    return { entity: "sensor.my_lock_slots", lock_entity_id: "lock.my_lock" };
+  }
+}
+
+customElements.define("zlock-slots-card", ZLockSlotsCard);
+
+window.customCards = window.customCards ?? [];
+window.customCards.push({
+  type: "zlock-slots-card",
+  name: "Z-Lock Slots",
+  description: "Manage Z-Wave lock code slots.",
+});

+ 12 - 0
custom_components/zlock_slots/manifest.json

@@ -0,0 +1,12 @@
+{
+  "domain": "zlock_slots",
+  "name": "Z-Lock Slots",
+  "version": "0.1.0",
+  "codeowners": [],
+  "config_flow": true,
+  "dependencies": ["zwave_js", "frontend"],
+  "documentation": "",
+  "integration_type": "hub",
+  "iot_class": "local_push",
+  "requirements": []
+}

+ 113 - 0
custom_components/zlock_slots/sensor.py

@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from homeassistant.components.sensor import SensorEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from .coordinator import ZLockCoordinator
+
+
+async def async_setup_entry(
+  hass: HomeAssistant,
+  entry: ConfigEntry,
+  async_add_entities: AddEntitiesCallback,
+) -> None:
+  coordinator: ZLockCoordinator = hass.data[DOMAIN][entry.entry_id]
+  entities = []
+  for lock_entity_id in entry.data[CONF_LOCK_ENTITY_IDS]:
+    entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id))
+    entities.append(ZLockLastUserSensor(coordinator, entry, lock_entity_id))
+    entities.append(ZLockSlotsSensor(coordinator, entry, lock_entity_id))
+  async_add_entities(entities)
+
+
+def _lock_object_id(lock_entity_id: str) -> str:
+  return lock_entity_id.split(".")[-1]
+
+
+class _ZLockBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
+  _attr_has_entity_name = True
+
+  def __init__(
+    self,
+    coordinator: ZLockCoordinator,
+    entry: ConfigEntry,
+    lock_entity_id: str,
+  ) -> None:
+    super().__init__(coordinator)
+    self._lock_entity_id = lock_entity_id
+    self._entry = entry
+
+  @property
+  def _lock_state(self):
+    return self.coordinator.data.get(self._lock_entity_id)
+
+
+class ZLockLastActionSensor(_ZLockBaseSensor):
+  _attr_translation_key = "last_action"
+
+  def __init__(self, coordinator, entry, lock_entity_id):
+    super().__init__(coordinator, entry, lock_entity_id)
+    self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_last_action"
+
+  @property
+  def name(self) -> str:
+    return f"{_lock_object_id(self._lock_entity_id)} last action"
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._lock_state
+    return state.last_action if state else None
+
+
+class ZLockLastUserSensor(_ZLockBaseSensor):
+  _attr_translation_key = "last_user"
+
+  def __init__(self, coordinator, entry, lock_entity_id):
+    super().__init__(coordinator, entry, lock_entity_id)
+    self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_last_user"
+
+  @property
+  def name(self) -> str:
+    return f"{_lock_object_id(self._lock_entity_id)} last user"
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._lock_state
+    return state.last_user if state else None
+
+
+class ZLockSlotsSensor(_ZLockBaseSensor):
+  _attr_translation_key = "slots"
+
+  def __init__(self, coordinator, entry, lock_entity_id):
+    super().__init__(coordinator, entry, lock_entity_id)
+    self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_slots"
+
+  @property
+  def name(self) -> str:
+    return f"{_lock_object_id(self._lock_entity_id)} slots"
+
+  @property
+  def native_value(self) -> int:
+    state = self._lock_state
+    return len(state.slots) if state else 0
+
+  @property
+  def extra_state_attributes(self) -> dict:
+    state = self._lock_state
+    if not state:
+      return {"slots": []}
+    return {
+      "slots": [
+        {
+          "slot_num": num,
+          "status": info.status,
+          "name": info.name,
+        }
+        for num, info in sorted(state.slots.items())
+      ]
+    }

+ 99 - 0
custom_components/zlock_slots/services.py

@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from .coordinator import ZLockCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_SET_CODE = "set_code"
+SERVICE_CLEAR_CODE = "clear_code"
+SERVICE_SET_NAME = "set_name"
+
+SET_CODE_SCHEMA = vol.Schema({
+  vol.Required("lock_entity_id"): cv.entity_id,
+  vol.Required("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,
+})
+
+
+def _get_coordinator(hass: HomeAssistant, lock_entity_id: str) -> ZLockCoordinator:
+  for entry_data in hass.data.get(DOMAIN, {}).values():
+    coordinator: ZLockCoordinator = entry_data
+    if lock_entity_id in coordinator._lock_entity_ids:
+      return coordinator
+  raise ServiceValidationError(f"{lock_entity_id} is not managed by {DOMAIN}")
+
+
+def register_services(hass: HomeAssistant) -> None:
+  async def handle_set_code(call: ServiceCall) -> None:
+    lock_entity_id = call.data["lock_entity_id"]
+    slot = call.data["slot"]
+    code = call.data["code"]
+    name = call.data.get("name")
+
+    coordinator = _get_coordinator(hass, lock_entity_id)
+
+    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)
+
+  async def handle_clear_code(call: ServiceCall) -> None:
+    lock_entity_id = call.data["lock_entity_id"]
+    slot = call.data["slot"]
+
+    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,
+    )
+
+    await coordinator.async_refresh_lock(lock_entity_id)
+
+  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 = _get_coordinator(hass, lock_entity_id)
+    await coordinator.async_set_name(lock_entity_id, slot, name)
+
+  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_SET_NAME, handle_set_name, SET_NAME_SCHEMA)
+
+
+def unregister_services(hass: HomeAssistant) -> None:
+  for service in (SERVICE_SET_CODE, SERVICE_CLEAR_CODE, SERVICE_SET_NAME):
+    if hass.services.has_service(DOMAIN, service):
+      hass.services.async_remove(DOMAIN, service)

+ 84 - 0
custom_components/zlock_slots/services.yaml

@@ -0,0 +1,84 @@
+set_code:
+  name: Set Lock Code
+  description: Set a user code in a lock slot. Optionally assign a name to the slot.
+  fields:
+    lock_entity_id:
+      name: Lock entity
+      description: The lock entity to set the code on.
+      required: true
+      selector:
+        entity:
+          domain: lock
+          integration: zwave_js
+    slot:
+      name: Slot number
+      description: Code slot number (1 or higher; slot 0 is reserved).
+      required: true
+      selector:
+        number:
+          min: 1
+          max: 250
+          mode: box
+    code:
+      name: Code
+      description: The PIN code to set.
+      required: true
+      selector:
+        text:
+          type: password
+    name:
+      name: Slot name
+      description: Optional label for this slot.
+      required: false
+      selector:
+        text:
+
+clear_code:
+  name: Clear Lock Code
+  description: Remove the user code from a lock slot. The slot name is preserved.
+  fields:
+    lock_entity_id:
+      name: Lock entity
+      description: The lock entity to clear the code on.
+      required: true
+      selector:
+        entity:
+          domain: lock
+          integration: zwave_js
+    slot:
+      name: Slot number
+      description: Code slot number to clear.
+      required: true
+      selector:
+        number:
+          min: 1
+          max: 250
+          mode: box
+
+set_name:
+  name: Set Slot Name
+  description: Assign or update the display name for a lock slot.
+  fields:
+    lock_entity_id:
+      name: Lock entity
+      description: The lock entity the slot belongs to.
+      required: true
+      selector:
+        entity:
+          domain: lock
+          integration: zwave_js
+    slot:
+      name: Slot number
+      description: Code slot number to name.
+      required: true
+      selector:
+        number:
+          min: 1
+          max: 250
+          mode: box
+    name:
+      name: Name
+      description: Display name for the slot.
+      required: true
+      selector:
+        text:

+ 19 - 0
custom_components/zlock_slots/strings.json

@@ -0,0 +1,19 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Z-Lock Slots",
+        "description": "Select the Z-Wave lock(s) to manage code slots for.",
+        "data": {
+          "lock_entity_ids": "Lock(s)"
+        }
+      }
+    },
+    "error": {
+      "no_locks_selected": "Please select at least one lock."
+    },
+    "abort": {
+      "already_configured": "This configuration already exists."
+    }
+  }
+}

+ 19 - 0
custom_components/zlock_slots/translations/en.json

@@ -0,0 +1,19 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Z-Lock Slots",
+        "description": "Select the Z-Wave lock(s) to manage code slots for.",
+        "data": {
+          "lock_entity_ids": "Lock(s)"
+        }
+      }
+    },
+    "error": {
+      "no_locks_selected": "Please select at least one lock."
+    },
+    "abort": {
+      "already_configured": "This configuration already exists."
+    }
+  }
+}

+ 4 - 0
hacs.json

@@ -0,0 +1,4 @@
+{
+  "name": "Z-Lock Slots",
+  "homeassistant": "2024.6.0"
+}