// Lift Card // Purpose: Renders a shared lift controller panel synced from server session state. // Scope: Presents public activity controls while reflecting global busy/position/cooldown state. import { useEffect, useMemo, useState } from 'react'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import CardFrame from '../CardFrame/index.jsx'; import { isFeatureEnabled } from '../../lib/features.js'; function badgeClass(tone) { if (tone === 'good') return 'bg-emerald-600 text-white'; if (tone === 'warn') return 'bg-amber-500 text-slate-900'; if (tone === 'danger') return 'bg-rose-600 text-white'; return 'bg-slate-700 text-slate-100'; } function positionLabel(value) { if (value === 'up') return 'Up'; if (value === 'down') return 'Down'; if (value === 'stopped') return 'Transitioning...'; if (value === 'conflict') return 'Conflict'; return '--'; } export default function LiftCard() { const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'lift')); /* Lift is an optional Home Assistant-backed hardware feature. The card owns that existence check so disabled installs do not need layout-level guards. */ if (!enabled) return null; return ; } function LiftCardContent() { /* The lift card is a complete Activities-tab feature, so it reads the shared lift state and socket actions directly. Keeping that wiring inside the card means each tab only decides where the card appears, and the command policy cannot accidentally diverge between mobile and desktop layouts. */ const lift = useSessionSelector((state) => state.session?.lift || null); const { liftUp, liftDown } = useSessionActions(); const [working, setWorking] = useState(''); const [nowMs, setNowMs] = useState(() => Date.now()); const configured = Boolean(lift?.configured); const connected = Boolean(lift?.enabled && lift?.connected); const busy = Boolean(lift?.busy); const activeTarget = String(lift?.target || '').toLowerCase(); const position = String(lift?.position || '').toLowerCase(); const upAvailable = Boolean(lift?.availability?.upSwitch); const downAvailable = Boolean(lift?.availability?.downSwitch); const lastActionAt = Number(lift?.lastActionAt || 0); const commandCooldownMs = Math.max(0, Number(lift?.commandCooldownMs || 0)); const cooldownRemainingMs = Math.max(0, lastActionAt + commandCooldownMs - nowMs); const cooldownActive = !busy && cooldownRemainingMs > 0; const blocked = busy || cooldownActive; useEffect(() => { if (!blocked) return undefined; const t = setInterval(() => { setNowMs(Date.now()); }, 100); return () => clearInterval(t); }, [blocked]); useEffect(() => { setNowMs(Date.now()); }, [lastActionAt, commandCooldownMs, busy]); const status = !configured ? 'Not configured' : !connected ? 'Offline' : blocked ? 'Busy' : 'Ready'; const statusTone = !configured ? 'warn' : !connected ? 'danger' : blocked ? 'warn' : 'good'; const canRun = configured && connected && !blocked && !working && upAvailable && downAvailable; const cooldownSeconds = useMemo(() => Math.ceil(cooldownRemainingMs / 1000), [cooldownRemainingMs]); const run = async (dir, fn) => { if (!fn) return; setWorking(dir); try { await fn(); } catch { // Errors are surfaced by shared state and command ack handling. } finally { setWorking(''); } }; return ( {status} } > {blocked ? (

{busy ? 'Preparing to move' : 'Moving!'}

Controls are disabled while the lift is moving, otherwise its tiny brain would get confused.

{!busy && cooldownActive ? (

About {cooldownSeconds}s remaining.

) : null}
) : null}

Move the lift up and down. Please don't break anything...

Position
{positionLabel(position)}
{!configured || !connected || !upAvailable || !downAvailable ? (

{!configured ? 'Set homeAssistant.lift.upSwitch and homeAssistant.lift.downSwitch in server config.' : !connected ? 'Home Assistant is offline.' : 'Waiting for required lift switch entities in Home Assistant.'}

) : null} {lift?.lastError ?

Last error: {lift.lastError}

: null}
); }