diff --git a/packages/ha/alarm/alarm.test.ts b/packages/ha/alarm/alarm.test.ts new file mode 100644 index 0000000..3541b6c --- /dev/null +++ b/packages/ha/alarm/alarm.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + formatArmFailureMessage, + getAlarmPanelSnapshot, + getAlarmSecurityStatus, + normalizeAlarmAction, + requiresAlarmCode, + resolveAlarmGestureAction, + serviceErrorMessage, +} from "./alarm"; + +describe("normalizeAlarmAction", () => { + it("maps legacy aliases to HA services", () => { + assert.equal(normalizeAlarmAction("disarm"), "alarm_disarm"); + assert.equal(normalizeAlarmAction("arm_night"), "alarm_arm_night"); + assert.equal(normalizeAlarmAction("alarm_arm_away"), "alarm_arm_away"); + }); + + it("falls back to none", () => { + assert.equal(normalizeAlarmAction(undefined), "none"); + assert.equal(normalizeAlarmAction("nope"), "none"); + }); +}); + +describe("resolveAlarmGestureAction", () => { + it("keeps configured arm action when disarmed", () => { + assert.equal( + resolveAlarmGestureAction("alarm_arm_night", "disarmed"), + "alarm_arm_night" + ); + }); + + it("switches arm actions to disarm when armed", () => { + assert.equal( + resolveAlarmGestureAction("alarm_arm_night", "armed_night"), + "alarm_disarm" + ); + assert.equal( + resolveAlarmGestureAction("arm_away", "armed_away"), + "alarm_disarm" + ); + }); + + it("leaves none alone even when armed", () => { + assert.equal(resolveAlarmGestureAction("none", "armed_home"), "none"); + }); +}); + +describe("getAlarmSecurityStatus", () => { + it("maps common panel states", () => { + assert.deepEqual(getAlarmSecurityStatus("disarmed"), { + mode: "Home", + detail: "Disarmed", + tone: "ok", + isActive: false, + }); + assert.deepEqual(getAlarmSecurityStatus("armed_night"), { + mode: "Night", + detail: "Armed", + tone: "armed", + isActive: true, + }); + }); +}); + +describe("getAlarmPanelSnapshot", () => { + it("exposes force-arm failure from Verisure-style attributes", () => { + const snap = getAlarmPanelSnapshot({ + state: "disarmed", + attributes: { + force_arm_available: true, + arm_exceptions: ["Kitchen window", "Hall door"], + code_format: "number", + code_arm_required: false, + }, + }); + assert.equal(snap.forceArmAvailable, true); + assert.deepEqual(snap.armExceptions, ["Kitchen window", "Hall door"]); + assert.equal(snap.forceArmFailure?.canForceArm, true); + assert.match(snap.forceArmFailure?.message ?? "", /Kitchen window/); + }); + + it("has no force-arm failure when attribute is absent", () => { + const snap = getAlarmPanelSnapshot({ + state: "armed_away", + attributes: { code_format: "number" }, + }); + assert.equal(snap.forceArmAvailable, false); + assert.equal(snap.forceArmFailure, null); + assert.equal(snap.status.isActive, true); + }); +}); + +describe("requiresAlarmCode", () => { + it("requires code for disarm when code_format is set", () => { + assert.equal( + requiresAlarmCode("alarm_disarm", { code_format: "number" }), + true + ); + }); + + it("requires code for arm only when code_arm_required", () => { + assert.equal( + requiresAlarmCode("alarm_arm_night", { + code_format: "number", + code_arm_required: false, + }), + false + ); + assert.equal( + requiresAlarmCode("alarm_arm_night", { + code_format: "number", + code_arm_required: true, + }), + true + ); + }); +}); + +describe("formatArmFailureMessage / serviceErrorMessage", () => { + it("formats zone lists", () => { + assert.equal(formatArmFailureMessage(["A"]), "A is open."); + assert.equal(formatArmFailureMessage(["A", "B"]), "A, B are open."); + }); + + it("reads nested HA error messages", () => { + assert.equal( + serviceErrorMessage({ error: { message: "Open zone" } }), + "Open zone" + ); + }); +}); diff --git a/packages/ha/alarm/alarm.ts b/packages/ha/alarm/alarm.ts new file mode 100644 index 0000000..07362db --- /dev/null +++ b/packages/ha/alarm/alarm.ts @@ -0,0 +1,272 @@ +/** Domains that expose force_arm / force_arm_cancel (Verisure OWA + legacy). */ +export const FORCE_ARM_DOMAINS = ["verisure_owa", "securitas"] as const; + +export type AlarmPanelService = + | "alarm_disarm" + | "alarm_arm_home" + | "alarm_arm_away" + | "alarm_arm_night" + | "alarm_arm_vacation" + | "alarm_trigger"; + +export type AlarmAction = AlarmPanelService | "none"; + +export type AlarmSecurityTone = "ok" | "armed" | "alert" | "pending"; + +export type AlarmSecurityStatus = { + mode: string; + detail: string; + tone: AlarmSecurityTone; + /** Armed, arming, pending, or triggered. */ + isActive: boolean; +}; + +export type AlarmArmFailure = { + /** Human-readable failure summary. */ + message: string; + /** Open zone / sensor names (e.g. Verisure `arm_exceptions`). */ + zones: string[]; + /** When true, integration supports force-arm override. */ + canForceArm: boolean; +}; + +export type AlarmPanelSnapshot = { + state?: string; + codeFormat: string | null; + codeArmRequired: boolean; + forceArmAvailable: boolean; + armExceptions: string[]; + status: AlarmSecurityStatus; + /** Blocking failure derived from force-arm attributes, if any. */ + forceArmFailure: AlarmArmFailure | null; +}; + +type HassConnection = { + sendMessagePromise: (msg: unknown) => Promise; +}; + +/** Legacy UI/config values → HA alarm_control_panel service names. */ +const ACTION_ALIASES: Record = { + none: "none", + disarm: "alarm_disarm", + alarm_disarm: "alarm_disarm", + arm_home: "alarm_arm_home", + alarm_arm_home: "alarm_arm_home", + arm_away: "alarm_arm_away", + alarm_arm_away: "alarm_arm_away", + arm_night: "alarm_arm_night", + alarm_arm_night: "alarm_arm_night", + arm_vacation: "alarm_arm_vacation", + alarm_arm_vacation: "alarm_arm_vacation", + trigger: "alarm_trigger", + alarm_trigger: "alarm_trigger", +}; + +const ACTIVE_ALARM_STATES = new Set([ + "armed_home", + "armed_away", + "armed_night", + "armed_vacation", + "armed_custom_bypass", + "triggered", + "pending", + "arming", +]); + +export function normalizeAlarmAction(action?: string | null): AlarmAction { + if (!action) return "none"; + return ACTION_ALIASES[action] ?? "none"; +} + +export function isAlarmActive(state?: string): boolean { + return !!state && ACTIVE_ALARM_STATES.has(state); +} + +/** When armed, arm/trigger gestures become disarm so the panel can be cleared. */ +export function resolveAlarmGestureAction( + configured: string | undefined, + state?: string +): AlarmAction { + const action = normalizeAlarmAction(configured); + if (action === "none") return "none"; + if (isAlarmActive(state) && action !== "alarm_disarm") { + return "alarm_disarm"; + } + return action; +} + +export function readArmExceptions( + attributes: Record | null | undefined +): string[] { + const raw = attributes?.arm_exceptions; + if (!Array.isArray(raw)) return []; + return raw.map((z) => String(z)).filter(Boolean); +} + +export function formatArmFailureMessage( + zones: string[], + fallback = "The alarm could not be armed." +): string { + if (zones.length === 1) return `${zones[0]} is open.`; + if (zones.length > 1) return `${zones.join(", ")} are open.`; + return fallback; +} + +export function serviceErrorMessage( + err: unknown, + fallback = "The alarm action failed." +): string { + if (!err) return fallback; + if (typeof err === "string") return err; + const e = err as { message?: string; error?: { message?: string } }; + return e.message || e.error?.message || fallback; +} + +/** Map HA alarm_control_panel state → mode / detail / tone. */ +export function getAlarmSecurityStatus(state?: string): AlarmSecurityStatus { + const isActive = isAlarmActive(state); + switch (state) { + case "disarmed": + return { mode: "Home", detail: "Disarmed", tone: "ok", isActive }; + case "armed_home": + return { mode: "Home", detail: "Armed", tone: "armed", isActive }; + case "armed_away": + return { mode: "Away", detail: "Armed", tone: "armed", isActive }; + case "armed_night": + return { mode: "Night", detail: "Armed", tone: "armed", isActive }; + case "armed_vacation": + return { mode: "Vacation", detail: "Armed", tone: "armed", isActive }; + case "armed_custom_bypass": + return { mode: "Custom", detail: "Armed", tone: "armed", isActive }; + case "triggered": + return { mode: "Alarm", detail: "Triggered", tone: "alert", isActive }; + case "pending": + return { mode: "Security", detail: "Pending", tone: "pending", isActive }; + case "arming": + return { mode: "Security", detail: "Arming", tone: "pending", isActive }; + default: + if (!state) { + return { mode: "Security", detail: "Unknown", tone: "pending", isActive }; + } + return { + mode: "Security", + detail: state + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), + tone: "pending", + isActive, + }; + } +} + +export function requiresAlarmCode( + action: AlarmAction, + attributes: Record | null | undefined +): boolean { + if (action === "none") return false; + const codeFormat = attributes?.code_format; + const hasCodeFormat = Boolean(codeFormat); + if (action === "alarm_disarm") return hasCodeFormat; + return hasCodeFormat && Boolean(attributes?.code_arm_required); +} + +/** Snapshot status + force-arm context from an HA alarm entity. */ +export function getAlarmPanelSnapshot( + entity: { state?: string; attributes?: Record } | null | undefined +): AlarmPanelSnapshot { + const attributes = entity?.attributes ?? undefined; + const state = entity?.state; + const forceArmAvailable = attributes?.force_arm_available === true; + const armExceptions = readArmExceptions(attributes); + const codeFormat = + typeof attributes?.code_format === "string" ? attributes.code_format : null; + + return { + state, + codeFormat, + codeArmRequired: Boolean(attributes?.code_arm_required), + forceArmAvailable, + armExceptions, + status: getAlarmSecurityStatus(state), + forceArmFailure: forceArmAvailable + ? { + zones: armExceptions, + canForceArm: true, + message: formatArmFailureMessage(armExceptions), + } + : null, + }; +} + +export async function callAlarmService( + connection: HassConnection, + entityId: string, + service: AlarmPanelService, + code?: string +): Promise { + const service_data: Record = { entity_id: entityId }; + if (code) service_data.code = code; + await connection.sendMessagePromise({ + type: "call_service", + domain: "alarm_control_panel", + service, + service_data, + }); +} + +async function callForceService( + connection: HassConnection, + service: "force_arm" | "force_arm_cancel", + entityId: string, + code?: string +): Promise { + const service_data: Record = { entity_id: entityId }; + if (code) service_data.code = code; + let lastError: unknown; + for (const domain of FORCE_ARM_DOMAINS) { + try { + await connection.sendMessagePromise({ + type: "call_service", + domain, + service, + service_data, + }); + return; + } catch (err) { + lastError = err; + } + } + throw lastError ?? new Error("Force arm is not available for this alarm."); +} + +export function forceArmAlarm( + connection: HassConnection, + entityId: string, + code?: string +): Promise { + return callForceService(connection, "force_arm", entityId, code); +} + +export function cancelForceArmAlarm( + connection: HassConnection, + entityId: string +): Promise { + return callForceService(connection, "force_arm_cancel", entityId); +} + +export type AlarmCallResult = + | { ok: true } + | { ok: false; failure: AlarmArmFailure }; + +export function toAlarmCallFailure( + err: unknown, + zones: string[] = [], + canForceArm = false +): AlarmArmFailure { + return { + zones, + canForceArm, + message: serviceErrorMessage(err), + }; +} diff --git a/packages/ha/hooks/useAlarm.ts b/packages/ha/hooks/useAlarm.ts new file mode 100644 index 0000000..c7207d7 --- /dev/null +++ b/packages/ha/hooks/useAlarm.ts @@ -0,0 +1,266 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + callAlarmService, + cancelForceArmAlarm, + forceArmAlarm, + getAlarmPanelSnapshot, + normalizeAlarmAction, + requiresAlarmCode, + resolveAlarmGestureAction, + toAlarmCallFailure, + type AlarmAction, + type AlarmArmFailure, + type AlarmCallResult, + type AlarmPanelService, + type AlarmPanelSnapshot, +} from "../alarm/alarm"; +import { useHA } from "../provider/HAProvider"; +import { useEntity } from "./useEntity"; + +const PENDING_TIMEOUT_MS = 30_000; + +export type UseAlarmOptions = { + /** Optional stored code used when the caller does not pass one. */ + code?: string; +}; + +export type UseAlarmResult = { + entity: ReturnType; + snapshot: AlarmPanelSnapshot; + /** Attribute-based force-arm failure, or the last service-call error. */ + failure: AlarmArmFailure | null; + /** True while an arm/disarm/force-arm call is awaiting a terminal outcome. */ + isBusy: boolean; + /** Service currently in flight, if any. */ + pendingService: AlarmPanelService | null; + clearFailure: () => void; + requiresCode: (action: AlarmAction | string) => boolean; + resolveGestureAction: (configured?: string) => AlarmAction; + call: (action: AlarmAction | string, code?: string) => Promise; + forceArm: (code?: string) => Promise; + cancelForceArm: () => Promise; +}; + +function isTerminalSuccess( + pending: AlarmPanelService, + state: string | undefined +): boolean { + if (pending === "alarm_disarm") return state === "disarmed"; + if (pending.startsWith("alarm_arm_") || pending === "alarm_trigger") { + return typeof state === "string" && state.startsWith("armed_"); + } + return false; +} + +/** + * Alarm control panel helper: live status, code requirements, Verisure-style + * force-arm context, and service callers. + */ +export function useAlarm( + entityId: string, + options: UseAlarmOptions = {} +): UseAlarmResult { + const { code } = options; + const entity = useEntity(entityId); + const { connection } = useHA(); + const lastCodeRef = useRef(undefined); + const lastArmServiceRef = useRef(null); + const [pendingService, setPendingService] = useState( + null + ); + const [callFailure, setCallFailure] = useState(null); + const busyTimeoutRef = useRef | null>(null); + + const snapshot = useMemo( + () => getAlarmPanelSnapshot(entity), + // Recompute when HA pushes a new state object. + // eslint-disable-next-line react-hooks/exhaustive-deps + [entity?.state, entity?.attributes] + ); + + const failure = snapshot.forceArmFailure ?? callFailure; + const isBusy = pendingService !== null; + + const clearFailure = useCallback(() => { + setCallFailure(null); + }, []); + + const clearBusyTimer = useCallback(() => { + if (busyTimeoutRef.current) { + clearTimeout(busyTimeoutRef.current); + busyTimeoutRef.current = null; + } + }, []); + + const endPending = useCallback(() => { + setPendingService(null); + clearBusyTimer(); + }, [clearBusyTimer]); + + const beginPending = useCallback( + (service: AlarmPanelService) => { + setPendingService(service); + setCallFailure(null); + clearBusyTimer(); + busyTimeoutRef.current = setTimeout(() => { + setPendingService(null); + busyTimeoutRef.current = null; + setCallFailure({ + zones: [], + canForceArm: false, + message: "No response from the alarm. Please try again.", + }); + }, PENDING_TIMEOUT_MS); + }, + [clearBusyTimer] + ); + + useEffect(() => () => clearBusyTimer(), [clearBusyTimer]); + + // Resolve pending only on terminal outcomes — not intermediate states like "arming". + useEffect(() => { + if (!pendingService) return; + + if (snapshot.forceArmAvailable) { + endPending(); + setCallFailure(null); + return; + } + + if (isTerminalSuccess(pendingService, entity?.state)) { + endPending(); + } + }, [ + pendingService, + snapshot.forceArmAvailable, + entity?.state, + endPending, + ]); + + const requiresCode = useCallback( + (action: AlarmAction | string) => + requiresAlarmCode(normalizeAlarmAction(action), entity?.attributes), + [entity?.attributes] + ); + + const resolveGestureAction = useCallback( + (configured?: string) => resolveAlarmGestureAction(configured, entity?.state), + [entity?.state] + ); + + const call = useCallback( + async (action: AlarmAction | string, enteredCode?: string): Promise => { + const normalized = normalizeAlarmAction(action); + if (!connection || !entityId || normalized === "none") { + return { + ok: false, + failure: { + zones: [], + canForceArm: false, + message: "Alarm is not ready.", + }, + }; + } + + const service = normalized as AlarmPanelService; + const codeToUse = enteredCode ?? code; + lastCodeRef.current = codeToUse; + if (service !== "alarm_disarm") { + lastArmServiceRef.current = service; + } + beginPending(service); + + try { + await callAlarmService(connection, entityId, service, codeToUse); + // Stay busy until armed_*, force_arm_available, error, or timeout. + return { ok: true }; + } catch (err) { + const next = toAlarmCallFailure(err); + setCallFailure(next); + endPending(); + return { ok: false, failure: next }; + } + }, + [connection, entityId, code, beginPending, endPending] + ); + + const forceArm = useCallback( + async (enteredCode?: string): Promise => { + if (!connection || !entityId) { + return { + ok: false, + failure: { + zones: snapshot.armExceptions, + canForceArm: true, + message: "Alarm is not ready.", + }, + }; + } + const codeToUse = enteredCode ?? lastCodeRef.current ?? code; + beginPending(lastArmServiceRef.current ?? "alarm_arm_away"); + try { + await forceArmAlarm(connection, entityId, codeToUse); + return { ok: true }; + } catch (err) { + const next = toAlarmCallFailure(err, snapshot.armExceptions, true); + setCallFailure(next); + endPending(); + return { ok: false, failure: next }; + } + }, + [ + connection, + entityId, + code, + snapshot.armExceptions, + pendingService, + beginPending, + endPending, + ] + ); + + const cancelForceArm = useCallback(async (): Promise => { + if (!connection || !entityId) { + setCallFailure(null); + endPending(); + return { ok: true }; + } + try { + if (snapshot.forceArmAvailable) { + await cancelForceArmAlarm(connection, entityId); + } + setCallFailure(null); + endPending(); + return { ok: true }; + } catch (err) { + setCallFailure(null); + endPending(); + return { + ok: false, + failure: toAlarmCallFailure(err, snapshot.armExceptions, true), + }; + } + }, [ + connection, + entityId, + snapshot.forceArmAvailable, + snapshot.armExceptions, + endPending, + ]); + + return { + entity, + snapshot, + failure, + isBusy, + pendingService, + clearFailure, + requiresCode, + resolveGestureAction, + call, + forceArm, + cancelForceArm, + }; +} diff --git a/packages/ha/index.ts b/packages/ha/index.ts index 3680624..bede46b 100644 --- a/packages/ha/index.ts +++ b/packages/ha/index.ts @@ -3,8 +3,35 @@ export { useHA } from "./provider/HAProvider"; export { useEntity } from "./hooks/useEntity"; export { useEntities } from "./hooks/useEntities"; export { useEntityHistory } from "./hooks/useEntityHistory"; +export { useAlarm } from "./hooks/useAlarm"; +export type { UseAlarmOptions, UseAlarmResult } from "./hooks/useAlarm"; export type { EntityHistoryPoint } from "./hooks/entityHistory"; export { toChartDate } from "./hooks/entityHistory"; +export { + FORCE_ARM_DOMAINS, + normalizeAlarmAction, + isAlarmActive, + resolveAlarmGestureAction, + readArmExceptions, + formatArmFailureMessage, + serviceErrorMessage, + getAlarmSecurityStatus, + requiresAlarmCode, + getAlarmPanelSnapshot, + callAlarmService, + forceArmAlarm, + cancelForceArmAlarm, + toAlarmCallFailure, +} from "./alarm/alarm"; +export type { + AlarmPanelService, + AlarmAction, + AlarmSecurityTone, + AlarmSecurityStatus, + AlarmArmFailure, + AlarmPanelSnapshot, + AlarmCallResult, +} from "./alarm/alarm"; export { connect, getEntity, diff --git a/packages/ha/package.json b/packages/ha/package.json index da6043d..9df6538 100644 --- a/packages/ha/package.json +++ b/packages/ha/package.json @@ -39,7 +39,7 @@ "dev": "tsup --watch", "lint": "eslint . --max-warnings 0", "check-types": "tsc --noEmit", - "test": "tsx --test camera/**/*.test.ts hooks/**/*.test.ts connection/**/*.test.ts", + "test": "tsx --test camera/**/*.test.ts hooks/**/*.test.ts connection/**/*.test.ts alarm/**/*.test.ts", "prepack": "npm run build && node ./scripts/prepack.mjs", "postpack": "node ./scripts/postpack.mjs", "prepublishOnly": "npm run test && npm run build" diff --git a/packages/tailwind-config/style.css b/packages/tailwind-config/style.css index 225f583..deb29cc 100644 --- a/packages/tailwind-config/style.css +++ b/packages/tailwind-config/style.css @@ -924,6 +924,55 @@ cursor: not-allowed; } +.hk-modal__zones { + list-style: none; + margin: 0; + padding: 0.75rem 1rem; + border-radius: 1rem; + background: #fff4e5; + color: #9a3412; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.hk-modal__zone { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.3; +} + +.hk-modal__zone::before { + content: "•"; + margin-right: 0.5rem; + color: #c2410c; +} + +.hk-modal__loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + min-height: 3rem; + font-size: 0.875rem; + color: var(--hk-modal-muted); +} + +.hk-modal__spinner { + width: 1.125rem; + height: 1.125rem; + border-radius: 9999px; + border: 2px solid var(--hk-modal-border); + border-top-color: var(--hk-modal-title); + animation: hk-modal-spin 0.7s linear infinite; +} + +@keyframes hk-modal-spin { + to { + transform: rotate(360deg); + } +} + /* Compact / list variant for room rows — flat rows, status on the right */ [data-casaboard-style="homekit"] .card-shell[data-tile-layout="row"] { aspect-ratio: auto; diff --git a/packages/ui/components/Alarm/Alarm.config.tsx b/packages/ui/components/Alarm/Alarm.config.tsx index 14606d4..cfa27c7 100644 --- a/packages/ui/components/Alarm/Alarm.config.tsx +++ b/packages/ui/components/Alarm/Alarm.config.tsx @@ -5,12 +5,12 @@ import EntityField from "../EntityAutocomplete/EntityField"; const ACTION_OPTIONS = [ { label: "None", value: "none" }, - { label: "Disarm", value: "disarm" }, - { label: "Arm Home", value: "arm_home" }, - { label: "Arm Away", value: "arm_away" }, + { label: "Disarm", value: "alarm_disarm" }, + { label: "Arm Home", value: "alarm_arm_home" }, + { label: "Arm Away", value: "alarm_arm_away" }, { label: "Arm Night", value: "alarm_arm_night" }, - { label: "Arm Vacation", value: "arm_vacation" }, - { label: "Trigger", value: "trigger" }, + { label: "Arm Vacation", value: "alarm_arm_vacation" }, + { label: "Trigger", value: "alarm_trigger" }, ]; export const AlarmConfig = { diff --git a/packages/ui/components/Alarm/AlarmConfirmPopup.tsx b/packages/ui/components/Alarm/AlarmConfirmPopup.tsx index 83b8949..100f3d9 100644 --- a/packages/ui/components/Alarm/AlarmConfirmPopup.tsx +++ b/packages/ui/components/Alarm/AlarmConfirmPopup.tsx @@ -3,7 +3,9 @@ import { XMarkIcon, BackspaceIcon } from "@heroicons/react/24/outline"; import { Modal, ModalContent, ModalBody } from "@heroui/react"; import { useEffect, useState } from "react"; import classNames from "classnames"; -import type { AlarmAction } from "./index"; +import type { AlarmAction, AlarmArmFailure } from "@casaboard/ha"; + +export type { AlarmArmFailure }; const ACTION_CONFIRM_LABEL: Record, string> = { alarm_disarm: "Disarm", @@ -21,6 +23,14 @@ interface AlarmConfirmPopupProps { isOpen: boolean; onClose: () => void; onConfirm: (code?: string) => void; + /** When true, show PIN keypad; code is sent to HA for validation. */ + requiresCode?: boolean; + /** Service call in flight. */ + isSubmitting?: boolean; + /** Arm/disarm failure — zones + optional force-arm. */ + failure?: AlarmArmFailure | null; + onForceArm?: () => void; + onForceCancel?: () => void; } export const AlarmConfirmPopup = ({ @@ -28,9 +38,18 @@ export const AlarmConfirmPopup = ({ isOpen, onClose, onConfirm, + requiresCode = false, + isSubmitting = false, + failure = null, + onForceArm, + onForceCancel, }: AlarmConfirmPopupProps) => { const [pin, setPin] = useState(""); const isDisarm = action === "alarm_disarm"; + // Disarm always prompts for a PIN when HA exposes a code format; arming + // follows `requiresCode` (code_arm_required). Fallback: disarm always asks. + const showPin = !failure && !isSubmitting && (requiresCode || isDisarm); + const showFailure = Boolean(failure) && !isSubmitting; useEffect(() => { if (isOpen) setPin(""); @@ -45,18 +64,21 @@ export const AlarmConfirmPopup = ({ }; const handleConfirm = () => { - if (isDisarm && pin.length === 0) return; - onConfirm(isDisarm ? pin : undefined); + if (isSubmitting) return; + if (showPin && pin.length === 0) return; + onConfirm(showPin ? pin : undefined); }; const label = action ? ACTION_CONFIRM_LABEL[action] : ""; - const canConfirm = !isDisarm || pin.length > 0; + const canConfirm = !showPin || pin.length > 0; return ( @@ -78,19 +101,49 @@ export const AlarmConfirmPopup = ({

- {isDisarm ? "Security" : "Confirm action"} + {showFailure ? "Security" : showPin ? "Security" : "Confirm action"}

- {isDisarm ? "Enter PIN to Disarm" : label} + {isSubmitting + ? `${label}…` + : showFailure + ? failure?.canForceArm + ? "Open sensor(s) — arm anyway?" + : "Arming failed" + : showPin + ? isDisarm + ? "Enter PIN to Disarm" + : `Enter PIN to ${label}` + : label}

- {!isDisarm && ( + {!showPin && !showFailure && !isSubmitting && (

Are you sure you want to {label.toLowerCase()}?

)} + {showFailure && failure?.message && ( +

{failure.message}

+ )}
- {isDisarm && ( + {showFailure && failure && failure.zones.length > 0 && ( +
    + {failure.zones.map((zone) => ( +
  • + {zone} +
  • + ))} +
+ )} + + {isSubmitting && ( +
+ + Talking to Home Assistant… +
+ )} + + {showPin && ( <>
{pin.length === 0 ? ( @@ -132,27 +185,39 @@ export const AlarmConfirmPopup = ({ )} -
- - -
+ {!isSubmitting && ( +
+ + {showFailure && failure?.canForceArm ? ( + + ) : !showFailure ? ( + + ) : null} +
+ )}
diff --git a/packages/ui/components/Alarm/index.tsx b/packages/ui/components/Alarm/index.tsx index 62ff569..122fdbd 100644 --- a/packages/ui/components/Alarm/index.tsx +++ b/packages/ui/components/Alarm/index.tsx @@ -1,23 +1,20 @@ "use client"; -import { useEntity, useHA } from "@casaboard/ha"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useAlarm, + type AlarmAction, + type AlarmArmFailure, +} from "@casaboard/ha"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Skeleton } from "@heroui/react"; import { useEntityLoading } from "@repo/hooks/useEntityLoading"; import { AlarmConfirmPopup } from "./AlarmConfirmPopup"; -export type AlarmAction = - | "alarm_disarm" - | "alarm_arm_home" - | "alarm_arm_away" - | "alarm_arm_night" - | "alarm_arm_vacation" - | "alarm_trigger" - | "none"; +export type { AlarmAction, AlarmArmFailure }; export interface AlarmProps { entityId: string; - tapAction?: AlarmAction; - longPressAction?: AlarmAction; + tapAction?: AlarmAction | string; + longPressAction?: AlarmAction | string; code?: string; } @@ -32,132 +29,149 @@ const ACTION_LABEL: Record, string> = { alarm_trigger: "Triggering", }; -/** Map HA alarm_control_panel state → HomeKit "Mode · Status" line. */ -function formatSecurityStatus(state?: string): { mode: string; detail: string; tone: "ok" | "armed" | "alert" | "pending" } { - switch (state) { - case "disarmed": - return { mode: "Home", detail: "Disarmed", tone: "ok" }; - case "armed_home": - return { mode: "Home", detail: "Armed", tone: "armed" }; - case "armed_away": - return { mode: "Away", detail: "Armed", tone: "armed" }; - case "armed_night": - return { mode: "Night", detail: "Armed", tone: "armed" }; - case "armed_vacation": - return { mode: "Vacation", detail: "Armed", tone: "armed" }; - case "armed_custom_bypass": - return { mode: "Custom", detail: "Armed", tone: "armed" }; - case "triggered": - return { mode: "Alarm", detail: "Triggered", tone: "alert" }; - case "pending": - return { mode: "Security", detail: "Pending", tone: "pending" }; - case "arming": - return { mode: "Security", detail: "Arming", tone: "pending" }; - default: - if (!state) return { mode: "Security", detail: "Unknown", tone: "pending" }; - return { - mode: "Security", - detail: state - .split("_") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "), - tone: "pending", - }; - } -} - export const Alarm = ({ entityId, tapAction = "none", longPressAction = "none", code, }: AlarmProps) => { - const entity = useEntity(entityId); - const { connection } = useHA(); + const { + entity, + snapshot, + failure, + isBusy, + pendingService, + clearFailure, + requiresCode, + resolveGestureAction, + call, + forceArm, + cancelForceArm, + } = useAlarm(entityId, { code }); + const { isEntityReady, showNotAvailable, isLoaded } = useEntityLoading(entity); const timerRef = useRef | null>(null); - const timeoutRef = useRef | null>(null); const didLongPress = useRef(false); - const [pendingAction, setPendingAction] = useState | null>(null); + const lastArmActionRef = useRef | null>(null); + const sawForceArmRef = useRef(false); + const [confirmAction, setConfirmAction] = useState | null>(null); + const effectiveTap = resolveGestureAction(tapAction); + const effectiveLongPress = resolveGestureAction(longPressAction); + const { status, forceArmAvailable } = snapshot; + + const closePopup = useCallback(() => { + setConfirmAction(null); + clearFailure(); + }, [clearFailure]); + + // When HA reports a force-arm window, keep/open the confirm popup. useEffect(() => { - if (!pendingAction) return; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => setPendingAction(null), 30_000); - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, [pendingAction]); + if (forceArmAvailable) { + sawForceArmRef.current = true; + setConfirmAction((current) => { + if (current) return current; + return ( + lastArmActionRef.current ?? + (effectiveTap !== "none" && effectiveTap !== "alarm_disarm" + ? effectiveTap + : "alarm_arm_away") + ); + }); + return; + } + // Force-arm window cleared (armed, cancelled, or expired). + if (sawForceArmRef.current) { + sawForceArmRef.current = false; + if (confirmAction) closePopup(); + } + }, [forceArmAvailable, effectiveTap, confirmAction, closePopup]); + + // Close once the panel reaches the intended armed/disarmed state. useEffect(() => { - if (pendingAction) setPendingAction(null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entity?.state]); - - const callAction = useCallback( - (action: AlarmAction, enteredCode?: string) => { - if (!connection || !entityId || action === "none") return; - setPendingAction(action as Exclude); - const service_data: Record = { entity_id: entityId }; - const codeToUse = enteredCode ?? code; - if (codeToUse) service_data.code = codeToUse; - connection - .sendMessagePromise({ - type: "call_service", - domain: "alarm_control_panel", - service: action, - service_data, - }) - .catch(() => setPendingAction(null)); - }, - [connection, entityId, code] - ); + if (!confirmAction || failure?.canForceArm) return; + if (confirmAction === "alarm_disarm" && snapshot.state === "disarmed") { + closePopup(); + return; + } + if ( + confirmAction.startsWith("alarm_arm_") && + typeof snapshot.state === "string" && + snapshot.state.startsWith("armed_") + ) { + closePopup(); + } + }, [snapshot.state, confirmAction, failure, closePopup]); const openConfirm = useCallback( (action: AlarmAction) => { - if (action === "none" || pendingAction) return; + if (action === "none" || isBusy) return; + + if (forceArmAvailable) { + setConfirmAction( + lastArmActionRef.current ?? + (action !== "alarm_disarm" + ? (action as Exclude) + : "alarm_arm_away") + ); + return; + } + + clearFailure(); setConfirmAction(action as Exclude); }, - [pendingAction] + [isBusy, forceArmAvailable, clearFailure] ); const handleConfirmed = useCallback( - (enteredCode?: string) => { - setConfirmAction(null); - if (confirmAction) callAction(confirmAction, enteredCode); + async (enteredCode?: string) => { + if (!confirmAction || isBusy) return; + if (confirmAction !== "alarm_disarm") { + lastArmActionRef.current = confirmAction; + } + await call(confirmAction, enteredCode); }, - [confirmAction, callAction] + [confirmAction, call, isBusy] ); + const handleForceArm = useCallback(async () => { + await forceArm(); + }, [forceArm]); + + const handleForceCancel = useCallback(async () => { + await cancelForceArm(); + closePopup(); + }, [cancelForceArm, closePopup]); + const handlePointerDown = useCallback(() => { - if (pendingAction) return; + if (isBusy) return; didLongPress.current = false; - if (longPressAction && longPressAction !== "none") { + if (effectiveLongPress !== "none") { timerRef.current = setTimeout(() => { didLongPress.current = true; - openConfirm(longPressAction); + openConfirm(effectiveLongPress); }, LONG_PRESS_MS); } - }, [longPressAction, openConfirm, pendingAction]); + }, [effectiveLongPress, openConfirm, isBusy]); const handlePointerUp = useCallback(() => { if (timerRef.current) clearTimeout(timerRef.current); - if (!didLongPress.current && !pendingAction) { - openConfirm(tapAction ?? "none"); + if (!didLongPress.current && !isBusy) { + if (forceArmAvailable) { + openConfirm(effectiveTap !== "none" ? effectiveTap : "alarm_arm_away"); + return; + } + openConfirm(effectiveTap); } - }, [tapAction, openConfirm, pendingAction]); + }, [effectiveTap, openConfirm, isBusy, forceArmAvailable]); const handlePointerLeave = useCallback(() => { if (timerRef.current) clearTimeout(timerRef.current); }, []); - const status = useMemo( - () => formatSecurityStatus(entity?.state), - [entity?.state] - ); - if (!entityId) { return (
@@ -167,22 +181,35 @@ export const Alarm = ({ } const isInteractive = - !pendingAction && + !isBusy && isEntityReady && - ((tapAction && tapAction !== "none") || - (longPressAction && longPressAction !== "none")); + (forceArmAvailable || + effectiveTap !== "none" || + effectiveLongPress !== "none"); + + const busyAction = pendingService ?? confirmAction; + const statusLine = isBusy + ? busyAction + ? `${ACTION_LABEL[busyAction]}…` + : "Working…" + : forceArmAvailable + ? "Arming blocked" + : `${status.mode} • ${status.detail}`; - const statusLine = pendingAction - ? `${ACTION_LABEL[pendingAction]}…` - : `${status.mode} • ${status.detail}`; + const tone = forceArmAvailable ? "alert" : status.tone; return ( <> setConfirmAction(null)} + onClose={failure?.canForceArm ? handleForceCancel : closePopup} onConfirm={handleConfirmed} + requiresCode={confirmAction ? requiresCode(confirmAction) : false} + isSubmitting={isBusy} + failure={failure} + onForceArm={handleForceArm} + onForceCancel={handleForceCancel} /> ) : isEntityReady ? (
- {pendingAction ? ( + {isBusy ? ( ) : (