Updated Alarm Panel

 avatar
unknown
html
19 hours ago
33 kB
7
No Index
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Alarm Panel</title>
    <style>
      :root {
        --bg: #0c0f14;
        --panel-top: #233247;
        --panel-bot: #1b2636;
        --chip-blue: #0f76c7;
        --chip-cyan: #12a6d8;
        --text: #e9f2fb;
        --muted: #b9c6d6;
      }

      @property --g1 {
        syntax: "<color>";
        inherits: false;
        initial-value: #0d3563;
      }
      @property --g2 {
        syntax: "<color>";
        inherits: false;
        initial-value: #0e74c8;
      }
      @property --g3 {
        syntax: "<color>";
        inherits: false;
        initial-value: #0f89ce;
      }
      /* animated shield colors (must inherit so .shield sees body’s updates) */
      @property --sh1 {
        syntax: "<color>";
        inherits: true;
        initial-value: #1d90d1;
      }
      @property --sh2 {
        syntax: "<color>";
        inherits: true;
        initial-value: #0f76c7;
      }
      @property --sh3 {
        syntax: "<color>";
        inherits: true;
        initial-value: #0e5aa7;
      }

      html,
      body {
        height: 100%;
      }

      body {
        margin: 0;
        font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial;
        background: linear-gradient(
          135deg,
          var(--g1) 0%,
          var(--g2) 50%,
          var(--g3) 100%
        );
        color: var(--text);
        display: flex;
        align-items: center;
        justify-content: center;
        transition: --g1 0.45s ease, --g2 0.45s ease, --g3 0.45s ease;
      }

      body.bg-blue {
        --g1: #0d3563;
        --g2: #0e74c8;
        --g3: #0f89ce;
        --sh1: #1d90d1;
        --sh2: #0f76c7;
        --sh3: #0e5aa7;
      }
      body.bg-red {
        --g1: #b42727;
        --g2: #0e74c8;
        --g3: #0f89ce;
        --sh1: #e56a6a;
        --sh2: #c53c3c;
        --sh3: #8a2f2f;
      }
      body.bg-green {
        --g1: #10a34a;
        --g2: #0e74c8;
        --g3: #0f89ce;
        --sh1: #31d27f;
        --sh2: #10a34a;
        --sh3: #0d7f3a;
      }

      .wrap {
        width: min(1200px, 96vw);
        display: grid;
        grid-template-columns: 1.1fr 1fr;
        gap: 28px;
      }

      /* Left: status + actions */
      .status {
        min-height: 280px;
        border-radius: 24px;
        padding: 24px;
        background: linear-gradient(
          180deg,
          var(--panel-top) 0%,
          var(--panel-bot) 100%
        );
        box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35),
          inset 0 0 0 1px rgba(255, 255, 255, 0.04);
        display: grid;
        grid-template-rows: 1fr auto auto;
        place-items: center;
        position: relative;
      }
      .shield {
        width: 140px;
        height: 140px;
        border-radius: 999px;
        display: grid;
        place-items: center;
        position: relative;
        background: radial-gradient(
          circle at 50% 40%,
          var(--sh1) 0%,
          var(--sh2) 65%,
          var(--sh3) 100%
        );
        box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
          inset 0 0 22px rgba(255, 255, 255, 0.08);
        overflow: visible;
        /* animate the shield colors */
        transition: --sh1 0.45s ease, --sh2 0.45s ease, --sh3 0.45s ease;
      }

      .shield svg {
        width: 76px;
        height: 76px;
        fill: #fff;
      }
      .title {
        font-weight: 700;
        font-size: 22px;
        margin-top: 6px;
      }
      .state {
        color: var(--muted);
        font-size: 14px;
        margin-top: 4px;
      }
      .actions {
        margin-top: 18px;
        display: grid;
        grid-template-columns: repeat(2, 1fr);
        gap: 14px;
      }
      .actions.one {
        grid-template-columns: 1fr;
      }

      .action {
        border-radius: 16px;
        padding: 14px;
        background: #1f2838;
        text-align: center;
        box-shadow: 0 10px 22px rgba(0, 0, 0, 0.35);
        user-select: none;
        cursor: pointer;
        transition: transform 0.12s ease, filter 0.2s ease, box-shadow 0.2s ease;
      }
      .action:active {
        transform: scale(0.97);
      }
      .iconchip {
        display: inline-grid;
        place-items: center;
        width: 42px;
        height: 42px;
        border-radius: 999px;
        color: #fff;
        box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
        margin-bottom: 8px;
      }
      .chip-blue {
        background: var(--chip-blue);
      }
      .chip-cyan {
        background: var(--chip-cyan);
      }
      .svgicon {
        width: 24px;
        height: 24px;
        background: #fff;
        -webkit-mask-repeat: no-repeat;
        mask-repeat: no-repeat;
        -webkit-mask-position: center;
        mask-position: center;
        -webkit-mask-size: contain;
        mask-size: contain;
      }
      .action span {
        display: block;
        font-weight: 600;
        color: var(--text);
      }
      .action.danger {
        background: linear-gradient(180deg, #b64848 0%, #8a2f2f 100%);
      }

      /* Right: keypad panel */
      .panel {
        border-radius: 24px;
        padding: 18px 20px 20px;
        background: linear-gradient(180deg, #222b38 0%, #1a2230 100%);
        box-shadow: 0 18px 40px rgba(0, 0, 0, 0.36),
          inset 0 0 0 1px rgba(255, 255, 255, 0.04);
      }
      .panel .head {
        font-size: 22px;
        font-weight: 700;
        display: flex;
        align-items: center;
        gap: 10px;
      }
      .panel .head {
        display: none !important;
      }
      .badge {
        font-size: 12px;
        padding: 3px 10px;
        border-radius: 999px;
        background: rgba(255, 255, 255, 0.08);
      }

      .codebox {
        margin: 12px 0 16px;
        background: #212a39;
        border-radius: 12px;
        padding: 10px 12px;
        color: #e9f2fb;
        border: 1px solid transparent;
        min-height: 22px;
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
      }
      .codebox input {
        font-size: 28px;
        letter-spacing: 2px;
        font-weight: 600;
        background: none;
        border: none;
        color: #ffffff;
      }
      .pill {
        border-radius: 999px;
        padding: 8px 12px;
        background: rgba(18, 166, 216, 0.18);
        border: 1px solid rgba(255, 255, 255, 0.06);
        cursor: pointer;
        user-select: none;
      }
      .pill.icon {
        width: 44px;
        height: 38px;
        display: grid;
        place-items: center;
        font-size: 18px;
        padding: 0;
      }
      .pill.icon#clearBtn {
        visibility: hidden;
        font-size: 30px;
        color: #ffffff;
        border-radius: 100%;
        height: 44px;
      }

      .keypad {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 16px;
      }
      .key {
        position: relative;
        overflow: hidden;
        height: 72px;
        border-radius: 999px;
        display: grid;
        place-items: center;
        font-size: 20px;
        color: #cbd7e6;
        background: rgba(255, 255, 255, 0.06);
        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04),
          0 6px 18px rgba(0, 0, 0, 0.35);
        user-select: none;
        cursor: pointer;
        transition: transform 0.1s ease;
      }
      .key:active {
        transform: scale(0.96);
      }
      .key .r {
        position: absolute;
        border-radius: 999px;
        pointer-events: none;
        width: 12px;
        height: 12px;
        background: rgba(255, 255, 255, 0.25);
        transform: translate(-50%, -50%) scale(0.2);
        animation: rip 0.45s ease-out;
      }
      @keyframes rip {
        to {
          opacity: 0;
          transform: translate(-50%, -50%) scale(2.2);
        }
      }
      /* Key highlight effect */
      .key.lit {
        background: rgba(18, 166, 216, 0.35);
        color: #fff;
        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1),
          0 0 0 10px rgba(18, 166, 216, 0.12), 0 6px 18px rgba(0, 0, 0, 0.35);
      }

      .hidden {
        display: none;
      }
      .busy .action,
      .busy .key {
        pointer-events: none;
        filter: grayscale(0.2);
        opacity: 0.85;
      }


      .shield {
        overflow: visible;
      }

      @keyframes breatheRed {
        0% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 0 rgba(214, 77, 77, 0.45),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
        70% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 36px rgba(214, 77, 77, 0),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
        100% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 0 rgba(214, 77, 77, 0),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
      }
      @keyframes breatheGreen {
        0% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 0 rgba(16, 163, 74, 0.45),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
        70% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 36px rgba(16, 163, 74, 0),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
        100% {
          box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38),
            0 0 0 0 rgba(16, 163, 74, 0),
            inset 0 0 22px rgba(255, 255, 255, 0.08);
        }
      }
      .shield.arming {
        animation: breatheRed 1.05s ease-out infinite;
      }
      .shield.disarming {
        animation: breatheGreen 1.05s ease-out infinite;
      }

      /* Expanding ring that travels outwards */
      .shield::after {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: inherit;
        pointer-events: none;
        border: 2px solid transparent;
        opacity: 0;
        transform: scale(1);
      }
      @keyframes ringOutRed {
        0% {
          transform: scale(1);
          opacity: 0.55;
          border-color: rgba(214, 77, 77, 0.7);
        }
        100% {
          transform: scale(2.6);
          opacity: 0;
          border-color: rgba(214, 77, 77, 0);
        }
      }
      @keyframes ringOutGreen {
        0% {
          transform: scale(1);
          opacity: 0.55;
          border-color: rgba(16, 163, 74, 0.7);
        }
        100% {
          transform: scale(2.6);
          opacity: 0;
          border-color: rgba(16, 163, 74, 0);
        }
      }
      .shield.arming::after {
        animation: ringOutRed 1.05s ease-out infinite;
      }
      .shield.disarming::after {
        animation: ringOutGreen 1.05s ease-out infinite;
      }

      .codebox.error {
        border: 1px solid #d64d4d;
        box-shadow: 0 0 0 6px rgba(214, 77, 77, 0.15),
          inset 0 0 0 1px rgba(255, 255, 255, 0.05);
        background: radial-gradient(
          circle,
          rgba(214, 77, 77, 0.15) 0%,
          rgba(214, 77, 77, 0.05) 100%
        );
        animation: wobble 0.35s;
      }
      @keyframes wobble {
        0% {
          transform: translateX(0);
        }
        25% {
          transform: translateX(-6px);
        }
        50% {
          transform: translateX(6px);
        }
        75% {
          transform: translateX(-3px);
        }
        100% {
          transform: translateX(0);
        }
      }

      @media (max-width: 900px) {
        .wrap {
          grid-template-columns: 1fr;
        }
        .status {
          min-height: 240px;
        }
      }
    </style>
  </head>
  <body class="bg-blue">
    <div class="wrap">
      <!-- LEFT: Status + actions -->
      <div>
        <div class="status" id="statusTile">
          <div class="shield" id="shieldIcon" aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <path
                d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"
              />
            </svg>
          </div>
          <div class="title" id="titleText">Ready to Arm</div>
          <div class="state" id="stateText">—</div>
        </div>

        <!-- dynamic actions go here -->
        <div class="actions" id="actions"></div>
      </div>

      <!-- RIGHT: Keypad -->
      <div class="panel">
        <div class="head">
          Keypad <span class="badge" id="badgeState">—</span>
        </div>

        <div class="codebox">
          <input
            id="codeInput"
            type="password"
            inputmode="numeric"
            pattern="[0-9]*"
            placeholder="Code"
          />
          <button
            id="clearBtn"
            class="pill icon"
            aria-label="Clear"
            title="Clear"
          >
            ×
          </button>
        </div>

        <div class="keypad" id="keypad"></div>
      </div>
    </div>

    <script>
      /*** CONFIG ***/
      const HA_BASE_URL = window.location.origin;
      const HA_TOKEN = "REPLACE_WITH_YOUR_LONG_LIVED_ACCESS_TOKEN";
      const ALARM_ENTITY = "alarm_control_panel.home_alarm"; // change to your alarm entity_id
      const REQUIRE_CODE_TO_ARM = true; // set true if your alarm needs a code to arm

      /*** REST helpers ***/
      async function haService(domain, service, data) {
        const res = await fetch(
          `${HA_BASE_URL}/api/services/${domain}/${service}`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${HA_TOKEN}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify(data),
          }
        );
        const text = await res.text();
        if (!res.ok)
          throw new Error(
            `${res.status} ${res.statusText}\n${text || ""}`.trim()
          );
        return text ? JSON.parse(text) : {};
      }
      async function haState(entity_id) {
        const res = await fetch(`${HA_BASE_URL}/api/states/${entity_id}`, {
          headers: { Authorization: `Bearer ${HA_TOKEN}` },
        });
        if (!res.ok) throw new Error("state fetch failed");
        return res.json();
      }

      /*** Elements ***/
      const body = document.body;
      const wrap = document.body;
      const badgeState = document.getElementById("badgeState");
      const stateText = document.getElementById("stateText");
      const titleText = document.getElementById("titleText");
      const shield = document.getElementById("shieldIcon");
      const actions = document.getElementById("actions");
      const codeInput = document.getElementById("codeInput");
      const clearBtn = document.getElementById("clearBtn");

      /*** Countdowns / state cache ***/
      let awayCountdownTimer = null;
      let awayCountdownRemaining = null;
      let transientBGUntil = 0;
      let lastKnownState = null;
      let lastRequestedArmMode = null; // 'away' | 'home' | null
      let awayCountdownSource = null; // null | 'fallback' | 'ha'
      let disarmingUntil = 0; // timestamp until which we show "Disarming"

      /*** Pretty state + icons ***/
      function prettyState(st) {
        if (st === "disarmed") return "Disarmed";
        if (st === "armed_home") return "Armed (Stay)";
        if (st === "armed_away") return "Armed (Away)";
        if (st === "armed_night") return "Armed (Night)";
        if (st === "pending" || st === "arming") return "Arming";
        return st?.replace("_", " ") || "—";
      }
      function shieldPathFor(st) {
        const check =
          '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>';
        const home =
          '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,7L7,11H9V17H15V11H17"/>';
        const lock =
          '<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M15,11V9A3,3 0 0,0 9,9V11H8A2,2 0 0,0 6,13V18A2,2 0 0,0 8,20H16A2,2 0 0,0 18,18V13A2,2 0 0,0 16,11H15M12,17A2,2 0 0,1 10,15A2,2 0 0,1 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17Z"/>';
        const alarm =
          '<path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M11,6H13V13H11V6M11,16H13V18H11V16Z"/>';
        if (st === "disarmed") return check;
        if (st === "armed_home") return home;
        if (st === "armed_away") return lock;
        if (st === "pending" || st === "arming" || st === "triggered")
          return alarm;
        return check;
      }

      /*** BG color helpers ***/
      function setBG(color) {
        body.classList.remove("bg-blue", "bg-red", "bg-green");
        body.classList.add("bg-" + color);
      }
      function parseRemaining(attrs) {
        const dyn = [
          "seconds_remaining",
          "remaining",
          "exit_delay_remaining",
          "delay_remaining",
          "time_remaining",
        ];
        for (const k of dyn) {
          const v = Number(attrs?.[k]);
          if (Number.isFinite(v) && v >= 0)
            return { value: Math.ceil(v), source: "ha" };
        }
        const stat = ["exit_delay", "delay", "arming_time"];
        for (const k of stat) {
          const v = Number(attrs?.[k]);
          if (Number.isFinite(v) && v > 0)
            return { value: Math.ceil(v), source: "fallback" };
        }
        return null;
      }
      function setUI(st, attrs = {}) {
        const isArming = st === "pending" || st === "arming";
        const isDisarmed = st === "disarmed";
        const isArmedAny =
          st === "armed_home" ||
          st === "armed_away" ||
          st === "armed_night" ||
          st === "armed_custom_bypass";

        // Clear input only on transition to disarmed
        const wasDisarmed = lastKnownState === "disarmed";
        if (!wasDisarmed && isDisarmed) {
          if (codeInput) codeInput.value = "";
          toggleClear();
          const box = document.querySelector(".codebox");
          box?.classList.remove("error");
          stopAwayCountdown();
          lastRequestedArmMode = null;
          disarmingUntil = 0; // ensure it stops showing "Disarming"
        }
        if (isArmedAny) {
          // once fully armed, clear the remembered request
          lastRequestedArmMode = null;
        }

        badgeState.textContent = prettyState(st);
        titleText.textContent = isArming
          ? "System Arming"
          : isDisarmed
          ? "Ready to Arm"
          : "System Armed";
        shield.innerHTML = `<svg viewBox="0 0 24 24">${shieldPathFor(
          st
        )}</svg>`;

        // Background color logic (keeps transient green during disarm)
        if (Date.now() < transientBGUntil) {
          // keep transient color
        } else {
          if (isDisarmed) setBG("blue");
          else setBG("red"); // arming & armed = red
        }

        // Shield animation
        shield.classList.remove("disarming", "arming");
        if (isArming) shield.classList.add("arming");

        // Actions
        renderActions(st);

        // ----- Arming Away countdown logic (no reset from static attrs) -----
        const awayAttr =
          attrs?.arm_mode === "away" ||
          attrs?.exit_mode === "away" ||
          attrs?.mode === "away" ||
          attrs?.armed_mode === "away";

        const awayModeActive = awayAttr || lastRequestedArmMode === "away";

        if ((st === "pending" || st === "arming") && awayModeActive) {
          const rem = parseRemaining(attrs);
          if (rem) {
            if (rem.source === "ha") {
              if (
                awayCountdownSource !== "ha" ||
                Math.abs((awayCountdownRemaining ?? rem.value) - rem.value) > 2
              ) {
                startAwayCountdown(rem.value, "ha");
              }
            } else {
              if (awayCountdownRemaining == null)
                startAwayCountdown(rem.value, "fallback");
            }
          } else if (awayCountdownRemaining == null) {
            startAwayCountdown(60, "fallback");
          }
        } else if (st === "armed_away" || isDisarmed) {
          stopAwayCountdown();
        }

        // Countdown text vs normal
        if (awayCountdownRemaining != null) {
          stateText.textContent = `Arming Away (${awayCountdownRemaining}s)`;
          badgeState.textContent = "Arming";
          titleText.textContent = "System Arming";
        } else {
          stateText.textContent = prettyState(st);
        }

        // While disarming (after button tap, before HA flips), override labels
        if (Date.now() < disarmingUntil) {
          titleText.textContent = "Disarming";
          badgeState.textContent = "Disarming";
          stateText.textContent = "Disarming";
        }

        // update last known
        lastKnownState = st;
      }

      /*** Actions area ***/
      function renderActions(st) {
        actions.innerHTML = "";
        if (st === "disarmed" || st === "unknown") {
          actions.className = "actions";
          actions.appendChild(
            makeAction("Arm Away", "armAway.svg", "chip-blue", armAway)
          );
          actions.appendChild(
            makeAction("Arm Stay", "armStay.svg", "chip-cyan", armHome)
          );
        } else {
          actions.className = "actions one";
          const dis = makeAction("Disarm", "disarm.svg", "chip-cyan", disarm);
          dis.classList.add("danger");
          actions.appendChild(dis);
        }
      }
      function makeAction(label, svgPath, chipClass, onClick) {
        const b = document.createElement("button");
        b.type = "button";
        b.className = "action";
        b.innerHTML = `
        <div class="iconchip ${chipClass}">
          <div class="svgicon" style="-webkit-mask-image:url('${svgPath}'); mask-image:url('${svgPath}');"></div>
        </div>
        <span>${label}</span>`;
        b.addEventListener("click", () => {
          press(b);
          onClick();
        });
        return b;
      }

      /*** Poll state ***/
      async function refresh() {
        try {
          const s = await haState(ALARM_ENTITY);
          setUI(s.state, s.attributes || {});
        } catch (e) {
          console.error(e);
          badgeState.textContent = "Offline";
        }
      }

      /*** Determine outcome after a service call (state-based verification) ***/
      function isArmedState(st) {
        return (
          st === "armed_home" ||
          st === "armed_away" ||
          st === "armed_night" ||
          st === "armed_custom_bypass"
        );
      }
      function isDisarmedState(st) {
        return st === "disarmed";
      }
      function isArmingState(st) {
        return st === "pending" || st === "arming";
      }

      /**
       * Verify that the requested action actually took effect by polling state briefly.
       * - For disarm: expect disarmed; otherwise treat as incorrect code.
       * - For arm_*: expect pending or armed_*; otherwise treat as failure (likely needs code).
       */
      async function verifyOutcome(service, maxMs = 2500) {
        const start = Date.now();
        while (Date.now() - start < maxMs) {
          const s = await haState(ALARM_ENTITY);
          const st = s.state;
          if (service === "alarm_disarm") {
            if (isDisarmedState(st)) return { ok: true, state: st };
          } else {
            // arm_home / arm_away
            if (isArmingState(st) || isArmedState(st))
              return { ok: true, state: st };
          }
          await new Promise((r) => setTimeout(r, 250));
        }
        return { ok: false, state: lastKnownState };
      }

      /*** Toast + inline code error ***/
      function toast(msg, type = "info") {
        let t = document.getElementById("toast");
        if (!t) {
          t = document.createElement("div");
          t.id = "toast";
          t.style.cssText =
            "position:fixed;left:50%;bottom:28px;transform:translateX(-50%);" +
            "color:#e9f2fb;padding:10px 14px;border-radius:999px;" +
            "box-shadow:0 6px 18px rgba(0,0,0,.35);z-index:9999;font:600 14px system-ui;transition:opacity .2s";
          document.body.appendChild(t);
        }
        const bg =
          type === "error" ? "#b64848" : type === "ok" ? "#146c3d" : "#1e2a3a";
        t.style.background = bg;
        t.textContent = msg;
        t.style.opacity = "1";
        clearTimeout(toast._t);
        toast._t = setTimeout(() => (t.style.opacity = "0"), 1800);
      }
      function codeError(message = "Incorrect code") {
        const box = document.querySelector(".codebox");
        box.classList.add("error");
        codeInput.value = "";
        toggleClear();
        clearTimeout(codeError._t);
        codeError._t = setTimeout(() => {
          box.classList.remove("error");
        }, 2000);
        toast(message, "error");
      }

      /*** Service calls with verification ***/
      function needCodeFor(service) {
        return service === "alarm_disarm" || REQUIRE_CODE_TO_ARM;
      }

      async function callAlarm(service) {
        const typed = (codeInput?.value || "").trim();
        const must = needCodeFor(service);
        const isArm = service.startsWith("alarm_arm_");

        if (must && !typed) {
          toast("Enter code first", "error");
          shake(".codebox");
          return;
        }

        // Remember which mode was requested (drives countdown/fallback)
        if (service === "alarm_arm_away") lastRequestedArmMode = "away";
        else if (service === "alarm_arm_home") lastRequestedArmMode = "home";
        else if (service === "alarm_disarm") lastRequestedArmMode = null;

        // Intent: pulse + background
        shield.classList.remove("disarming", "arming");
        if (service === "alarm_disarm") {
          shield.classList.add("disarming"); // green pulse
          setBG("green");
          disarmingUntil = Date.now() + 5000; 
          transientBGUntil = disarmingUntil; 
        } else {
          shield.classList.add("arming"); // red pulse
          setBG("red");
          if (service === "alarm_arm_away" && awayCountdownRemaining == null) {
            startAwayCountdown(60, "fallback"); // immediate fallback; HA will override when attrs arrive
          }
        }
        wrap.classList.add("busy");

        const payload = {
          entity_id: ALARM_ENTITY,
          ...(typed ? { code: typed } : {}),
        };

        try {
          await haService("alarm_control_panel", service, payload);

          // Verify that the state actually changed as intended
          const result = await verifyOutcome(service, 2500);

          if (!result.ok) {
            // Treat as incorrect/missing code
            if (service === "alarm_disarm") {
              codeError("Incorrect code");
              setBG("red"); // still armed
              disarmingUntil = 0; // stop saying Disarming
            } else if (isArm) {
              toast("Arming failed — enter code", "error");
              shake(".codebox");
            }
          } else {
            // Success: snap UI and clear code after arm (not on disarm)
            setTimeout(refresh, 250);
            if (service !== "alarm_disarm") {
              codeInput.value = "";
              toggleClear();
            } else {
              disarmingUntil = 0; // disarmed now; stop saying Disarming
            }
          }
        } catch (e) {
          console.error(e);
          const msg = (e?.message || "").toLowerCase();
          if (service === "alarm_disarm") disarmingUntil = 0; // stop Disarming on error
          if (msg.includes("code") || msg.includes("pin") || typed) {
            codeError("Incorrect code");
          } else if (!typed && isArm) {
            toast("Code required to arm — enter code", "error");
          } else {
            toast("Action failed", "error");
          }
        } finally {
          setTimeout(() => {
            shield.classList.remove("disarming", "arming");
            wrap.classList.remove("busy");
          }, 700);
        }
      }

      const armHome = () => callAlarm("alarm_arm_home");
      const armAway = () => callAlarm("alarm_arm_away");
      const disarm = () => callAlarm("alarm_disarm");

      /*** Countdown helpers ***/
      function startAwayCountdown(seconds, source = "fallback") {
        stopAwayCountdown(); // clear any old timer/source
        awayCountdownSource = source;
        awayCountdownRemaining = Math.max(1, Math.ceil(Number(seconds) || 60));
        updateCountdownUI();
        awayCountdownTimer = setInterval(() => {
          awayCountdownRemaining--;
          updateCountdownUI();
          if (awayCountdownRemaining <= 0) {
            stopAwayCountdown();
          }
        }, 1000);
      }
      function stopAwayCountdown() {
        if (awayCountdownTimer) {
          clearInterval(awayCountdownTimer);
          awayCountdownTimer = null;
        }
        awayCountdownRemaining = null;
        awayCountdownSource = null;
      }
      function updateCountdownUI() {
        if (awayCountdownRemaining != null) {
          stateText.textContent = `Arming Away (${awayCountdownRemaining}s)`;
          badgeState.textContent = "Arming";
          titleText.textContent = "System Arming";
        }
      }

      /*** Build keypad (ripple + blue "light" effect) ***/
      (function buildKeypad() {
        const kp = document.getElementById("keypad");
        const layout = [
          "1",
          "2",
          "3",
          "4",
          "5",
          "6",
          "7",
          "8",
          "9",
          "×",
          "0",
          "←",
        ];
        layout.forEach((k) => {
          const key = document.createElement("div");
          key.className = "key";
          key.textContent = k;
          key.addEventListener("click", (ev) => {
            press(key);
            ripple(key, ev);
            light(key);
            if (k === "×") {
              codeInput.value = "";
              toggleClear();
              return;
            }
            if (k === "←") {
              codeInput.value = codeInput.value.slice(0, -1);
              toggleClear();
              return;
            }
            if (/[0-9]/.test(k)) {
              codeInput.value += k;
              toggleClear();
            }
          });
          kp.appendChild(key);
        });
      })();

      /*** Anim + UI helpers ***/
      function press(el) {
        el.style.transform = "scale(0.97)";
        setTimeout(() => (el.style.transform = ""), 110);
      }
      function ripple(el, ev) {
        const r = document.createElement("span");
        r.className = "r";
        const rect = el.getBoundingClientRect();
        r.style.left = ev.clientX - rect.left + "px";
        r.style.top = ev.clientY - rect.top + "px";
        el.appendChild(r);
        setTimeout(() => r.remove(), 460);
      }
      function light(el) {
        el.classList.add("lit");
        setTimeout(() => el.classList.remove("lit"), 180);
      }
      function shake(sel) {
        const el = document.querySelector(sel);
        if (!el) return;
        el.animate(
          [
            { transform: "translateX(0)" },
            { transform: "translateX(-6px)" },
            { transform: "translateX(6px)" },
            { transform: "translateX(0)" },
          ],
          { duration: 300, iterations: 1 }
        );
      }
      function toggleClear() {
        clearBtn.style.visibility = codeInput.value.length
          ? "visible"
          : "hidden";
      }

      /*** Bindings ***/
      codeInput.addEventListener("input", toggleClear);
      clearBtn.onclick = () => {
        codeInput.value = "";
        toggleClear();
      };

      /*** Init ***/
      setBG("blue");
      toggleClear();
      refresh();
      setInterval(refresh, 3000);
    </script>
  </body>
</html>
Editor is loading...