/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import "@material/web/button/outlined-button"; import "@material/web/iconbutton/outlined-icon-button"; import { mdiArrowDown, mdiArrowLeft, mdiArrowRight, mdiArrowUp, mdiCircleMedium, mdiContentSaveOutline, mdiMinus, mdiPencil, mdiPlus, mdiTrashCan, } from "@mdi/js"; import { css, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import "../../../components/ha-svg-icon.js"; import { showAlertDialog, showPromptDialog } from "../../../components/dialog-box/show-dialog-box.js"; import { handleAsync, handleAsyncEvent } from "../../../util/async-handler.js"; import { AVSUM_CLUSTER_ID, moveToPreset, readDptzStreams, readFeatures, readMovementState, readPosition, readPresets, readRanges, relativeMove, removePreset, savePreset, } from "../../../util/avsum.js"; import { BaseClusterCommands } from "../base-cluster-commands.js"; import { registerClusterCommands } from "../registry.js"; @customElement("avsum-cluster-commands") class AvsumClusterCommands extends BaseClusterCommands { private _unsubscribeNodes?: () => void; @state() private _toast: string | null = null; private _toastTimer?: ReturnType; @state() private _newPresetName = ""; override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has("client") && this.client && !this._unsubscribeNodes) { this._unsubscribeNodes = this.client.addEventListener("nodes_changed", () => this.requestUpdate()); } } override disconnectedCallback() { super.disconnectedCallback(); this._unsubscribeNodes?.(); if (this._toastTimer) { clearTimeout(this._toastTimer); this._toastTimer = undefined; } } override render() { if (!this.node || this.cluster !== AVSUM_CLUSTER_ID) return nothing; const features = readFeatures(this.node, this.endpoint); const pos = readPosition(this.node, this.endpoint); const ranges = readRanges(this.node, this.endpoint); const movement = readMovementState(this.node, this.endpoint); return html`
Camera AV Settings — Pan / Tilt / Zoom
${features.mPan && pos.pan !== null ? html`Pan ${this._fmtDeg(pos.pan)}` : nothing} ${features.mTilt && pos.tilt !== null ? html`Tilt ${this._fmtDeg(pos.tilt)}` : nothing} ${features.mZoom && pos.zoom !== null ? html`Zoom ${pos.zoom}×` : nothing} ${movement !== "unknown" ? html`${movement === "moving" ? "Moving…" : "Idle"}` : nothing} ${features.mPan && ranges.panMin !== null && ranges.panMax !== null ? html`P [${ranges.panMin}°, ${ranges.panMax}°]` : nothing} ${features.mTilt && ranges.tiltMin !== null && ranges.tiltMax !== null ? html`T [${ranges.tiltMin}°, ${ranges.tiltMax}°]` : nothing} ${features.mZoom && ranges.zoomMax !== null ? html`Z [1×, ${ranges.zoomMax}×]` : nothing}
${features.mPan || features.mTilt || features.mZoom ? html`
this._move({ tiltDelta: this._stepFromEvent(e, 10) }), )} > this._move({ panDelta: this._stepFromEvent(e, -10) }), )} > this._move({ panDelta: this._stepFromEvent(e, 10) }), )} > this._move({ tiltDelta: this._stepFromEvent(e, -10) }), )} >
${features.mZoom ? html`
this._move({ zoomDelta: this._stepFromEvent(e, 10) }), )} > zoom this._move({ zoomDelta: this._stepFromEvent(e, -10) }), )} >
` : nothing}
` : nothing} ${this._toast ? html`
${this._toast}
` : nothing} ${features.mPresets ? (() => { const { items, max } = readPresets(this.node, this.endpoint); return html`
Presets ${items.length} / ${max}
${items.map( p => html``, )} ${items.length === 0 ? html`No presets saved.` : nothing}
Manage presets… ${items.map( p => html`
#${p.presetId} ${p.name} p=${this._fmtDeg(p.settings.pan ?? 0)} · t=${this._fmtDeg(p.settings.tilt ?? 0)} · z=${p.settings.zoom ?? 1}× this._savePresetUpdate(p.presetId))} > this._renamePreset(p.presetId, p.name), )} > this._removePreset(p.presetId, p.name), )} >
`, )}
(this._newPresetName = (e.target as HTMLInputElement).value)} /> = max} @click=${handleAsync(() => this._saveNewPreset())} > Save current MPTZ
`; })() : nothing} ${features.dptz ? (() => { const streams = readDptzStreams(this.node, this.endpoint); return html`
Digital PTZ: ${streams.length} active stream${streams.length === 1 ? "" : "s"} (controls available during live view)
`; })() : nothing}
`; } private _fmtDeg(v: number): string { const sign = v >= 0 ? "+" : ""; return `${sign}${v}°`; } private _showBusy() { this._toast = "Camera busy"; this._scheduleToastClear(); } private _isBusy(err: unknown): boolean { const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); return msg.includes("busy"); } private _stepFromEvent(e: MouseEvent, base: number): number { return e.shiftKey ? Math.sign(base) : base; } private async _move(delta: { panDelta?: number; tiltDelta?: number; zoomDelta?: number }) { try { await relativeMove(this.client, this.node.node_id, this.endpoint, delta); } catch (err) { if (this._isBusy(err)) { this._showBusy(); return; } showAlertDialog({ title: "Move failed", text: err instanceof Error ? err.message : String(err) }); } } private async _goPreset(presetId: number) { try { await moveToPreset(this.client, this.node.node_id, this.endpoint, presetId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (this._isBusy(err)) return this._showBusy(); if (msg.toLowerCase().includes("not_found")) { this._toast = "Preset removed"; this._scheduleToastClear(); return; } showAlertDialog({ title: "Move failed", text: msg }); } } private _scheduleToastClear() { if (this._toastTimer) clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { this._toast = null; }, 2000); } private async _savePresetUpdate(presetId: number) { if (!this.node) return; const preset = readPresets(this.node, this.endpoint).items.find(p => p.presetId === presetId); if (!preset) return; try { await savePreset(this.client, this.node.node_id, this.endpoint, preset.name, presetId); } catch (err) { showAlertDialog({ title: "Update failed", text: err instanceof Error ? err.message : String(err) }); } } private async _renamePreset(presetId: number, currentName: string) { const next = window.prompt("New name (max 32 chars)", currentName); if (typeof next !== "string" || !next.trim()) return; try { await savePreset(this.client, this.node.node_id, this.endpoint, next.trim().slice(0, 32), presetId); } catch (err) { showAlertDialog({ title: "Rename failed", text: err instanceof Error ? err.message : String(err) }); } } private async _removePreset(presetId: number, name: string) { const ok = await showPromptDialog({ title: "Remove preset", text: `Remove "${name}"?`, confirmText: "Remove", }); if (!ok) return; try { await removePreset(this.client, this.node.node_id, this.endpoint, presetId); } catch (err) { showAlertDialog({ title: "Remove failed", text: err instanceof Error ? err.message : String(err) }); } } private async _saveNewPreset() { if (!this.node) return; const name = this._newPresetName.trim().slice(0, 32); if (!name) return; try { await savePreset(this.client, this.node.node_id, this.endpoint, name); this._newPresetName = ""; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.toLowerCase().includes("resource_exhausted")) { const max = readPresets(this.node, this.endpoint).max; this._toast = `Preset list full (${max})`; this._scheduleToastClear(); return; } showAlertDialog({ title: "Save failed", text: msg }); } } static override styles = [ ...(Array.isArray(BaseClusterCommands.styles) ? BaseClusterCommands.styles : [BaseClusterCommands.styles]), css` .readout { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-family: var(--monospace-font, monospace); font-size: 0.85rem; padding: 4px 0 12px; } .readout b { font-weight: 500; color: var(--md-sys-color-on-surface-variant); margin-right: 4px; } .badge { padding: 2px 8px; border-radius: 4px; background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); font-size: 0.75rem; } .badge.moving { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } .range { color: var(--md-sys-color-on-surface-variant); font-size: 0.75rem; } .dpad-row { display: flex; align-items: center; justify-content: center; gap: 24px; padding: 12px 0; } md-outlined-icon-button { --md-outlined-icon-button-icon-color: var(--md-sys-color-on-surface); --md-outlined-icon-button-outline-color: var(--md-sys-color-outline); } md-outlined-icon-button[disabled] { opacity: 0.6; } .dpad-grid { display: grid; grid-template-columns: 40px 40px 40px; grid-template-rows: 40px 40px 40px; gap: 4px; } .dpad-center { display: grid; place-items: center; color: var(--md-sys-color-on-surface-variant); opacity: 0.4; } .zoom-col { display: flex; flex-direction: column; align-items: center; gap: 4px; } .muted.small { font-size: 0.7rem; color: var(--md-sys-color-on-surface-variant); } .muted { color: var(--md-sys-color-on-surface-variant); } .toast { margin-top: 8px; padding: 6px 12px; background: var(--md-sys-color-inverse-surface); color: var(--md-sys-color-inverse-on-surface); border-radius: 4px; font-size: 0.85rem; text-align: center; } .presets-frame { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--md-sys-color-outline-variant); } .presets-header { display: flex; justify-content: space-between; font-weight: 500; margin-bottom: 8px; } .chip-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } .chip { padding: 6px 14px; border-radius: 16px; background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); border: none; cursor: pointer; font-size: 0.85rem; font-family: inherit; } .chip:hover { filter: brightness(1.05); } details.manager summary { cursor: pointer; font-size: 0.85rem; color: var(--md-sys-color-on-surface-variant); padding: 4px 0; } .preset-row { display: flex; align-items: center; gap: 12px; padding: 4px 8px; border-radius: 4px; } .preset-row:hover { background: var(--md-sys-color-surface-container-high); } .pid { font-family: var(--monospace-font, monospace); color: var(--md-sys-color-on-surface-variant); font-size: 0.85rem; min-width: 32px; } .pname { font-weight: 500; } .pcoord { font-family: var(--monospace-font, monospace); font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant); } .grow { flex: 1; } .add-bar { display: flex; gap: 8px; align-items: center; padding-top: 8px; margin-top: 8px; border-top: 1px solid var(--md-sys-color-outline-variant); } .add-input { flex: 1; padding: 6px 8px; border: 1px solid var(--md-sys-color-outline); border-radius: 4px; background: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font-family: inherit; font-size: 0.85rem; } .dptz-note { margin-top: 12px; padding: 8px 12px; background: var(--md-sys-color-surface-container); border-radius: 4px; font-size: 0.85rem; } .dptz-note b { font-weight: 500; } `, ]; } registerClusterCommands(AVSUM_CLUSTER_ID, "avsum-cluster-commands"); declare global { interface HTMLElementTagNameMap { "avsum-cluster-commands": AvsumClusterCommands; } }