|
|
@@ -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.",
|
|
|
+});
|