Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant

This commit is contained in:
root
2026-02-25 23:01:20 -06:00
commit 97a7912eae
84 changed files with 12059 additions and 0 deletions

328
src/App.tsx Normal file
View File

@@ -0,0 +1,328 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { Dashboard } from '@/components/layout';
import { ThermostatOverlay } from '@/components/climate';
import { LightsOverlay } from '@/components/lights';
import { LocksOverlay } from '@/components/locks';
import { AlarmoPanel } from '@/components/alarm';
import { CalendarWidget } from '@/components/calendar';
import { TodoWidget } from '@/components/todo';
import { SettingsPanel, ConnectionModal } from '@/components/settings';
import { CameraOverlay } from '@/components/cameras';
import { CameraFeed } from '@/components/cameras/CameraFeed';
import { JellyfinOverlay } from '@/components/media';
import { GlobalKeyboard } from '@/components/keyboard';
import { useHomeAssistant } from '@/hooks';
// Motion detection now runs in Electron main process (MotionDetector.ts)
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
// Front porch alert overlay - shows for 30 seconds when person detected
function FrontPorchAlert({ onClose }: { onClose: () => void }) {
const cameras = useSettingsStore((state) => state.config.cameras);
const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch');
useEffect(() => {
const timer = setTimeout(onClose, 30000); // 30 seconds
return () => clearTimeout(timer);
}, [onClose]);
if (!frontPorchCamera) return null;
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="h-14 bg-dark-secondary border-b border-status-warning flex items-center justify-between px-5">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
<h2 className="text-lg font-semibold text-status-warning">
Person Detected - Front Porch
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 p-2">
<CameraFeed
camera={frontPorchCamera}
className="w-full h-full"
showLabel={false}
/>
</div>
</div>
);
}
// Simple thermostat temp display
function ThermostatTemp({ entityId }: { entityId: string }) {
const currentTemp = useEntityAttribute<number>(entityId, 'current_temperature');
return <>{currentTemp?.toFixed(0) ?? '--'}°</>;
}
function ConnectionPrompt() {
const openSettings = useUIStore((state) => state.openSettings);
return (
<div className="h-screen w-screen flex items-center justify-center bg-imperial-black">
<div className="card-imperial max-w-md text-center">
<div className="w-20 h-20 rounded-full bg-imperial-red mx-auto mb-6 flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="font-display text-2xl uppercase tracking-wider text-imperial-red mb-4">
Imperial Command Center
</h1>
<p className="text-gray-400 mb-6">
Enter your Home Assistant long-lived access token to connect.
</p>
<button onClick={openSettings} className="btn-primary">
Configure Connection
</button>
</div>
</div>
);
}
function DashboardContent() {
const config = useSettingsStore((state) => state.config);
const openLightsOverlay = useUIStore((state) => state.openLightsOverlay);
const openLocksOverlay = useUIStore((state) => state.openLocksOverlay);
const openThermostatsOverlay = useUIStore((state) => state.openThermostatsOverlay);
return (
<>
{/* Left Column - Calendar (spans 2 columns) */}
<div className="col-span-8 flex flex-col gap-4">
{config.calendar && (
<div className="flex-1 min-h-0">
<CalendarWidget />
</div>
)}
</div>
{/* Right Column - Controls, Alarm, Todo */}
<div className="col-span-4 flex flex-col gap-3">
{/* Control Buttons Row - Lights, Locks, Thermostats */}
<div className="grid grid-cols-3 gap-2">
{config.lights.length > 0 && (
<button
onClick={openLightsOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className="text-xs">Lights</span>
</button>
)}
{config.locks.length > 0 && (
<button
onClick={openLocksOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<svg className="w-6 h-6 text-status-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-xs">Locks</span>
</button>
)}
{config.thermostats.map((thermostat) => (
<button
key={thermostat.entityId}
onClick={openThermostatsOverlay}
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
>
<span className="text-xl font-light text-orange-400">
<ThermostatTemp entityId={thermostat.entityId} />
</span>
<span className="text-xs text-gray-400">{thermostat.name}</span>
</button>
))}
</div>
{/* Alarm Panel */}
{config.alarm && <AlarmoPanel />}
{/* Todo List */}
{config.todoList && (
<div className="flex-1 min-h-0">
<TodoWidget />
</div>
)}
</div>
</>
);
}
export default function App() {
const { isConnected, connectionState } = useHomeAssistant();
const accessToken = useHAStore((state) => state.accessToken);
const connect = useHAStore((state) => state.connect);
const entities = useHAStore((state) => state.entities);
const settingsOpen = useUIStore((state) => state.settingsOpen);
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
// Front porch alert state
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false);
const frontPorchAlertShownRef = useRef(false);
// Motion detection now runs in the Electron main process (MotionDetector.ts)
// This prevents browser throttling when the screensaver is active
// Report touch/click activity to main process for screen wake on Wayland
useEffect(() => {
const handleActivity = () => {
if (window.electronAPI?.screen?.activity) {
window.electronAPI.screen.activity();
}
};
// Listen for any touch or click events
document.addEventListener('touchstart', handleActivity, { passive: true });
document.addEventListener('mousedown', handleActivity, { passive: true });
return () => {
document.removeEventListener('touchstart', handleActivity);
document.removeEventListener('mousedown', handleActivity);
};
}, []);
// Auto-connect if token is stored (check localStorage first, then config file)
useEffect(() => {
const initConfig = async () => {
// Load HA token
let storedToken = localStorage.getItem('ha_access_token');
// If no token in localStorage, try to get from config file
if (!storedToken && window.electronAPI?.config?.getStoredToken) {
const fileToken = await window.electronAPI.config.getStoredToken();
if (fileToken) {
storedToken = fileToken;
localStorage.setItem('ha_access_token', fileToken);
}
}
if (storedToken && !accessToken) {
connect(storedToken);
}
// Load Jellyfin API key from config file
if (window.electronAPI?.config?.getJellyfinApiKey) {
const jellyfinKey = await window.electronAPI.config.getJellyfinApiKey();
if (jellyfinKey) {
useSettingsStore.getState().setJellyfinApiKey(jellyfinKey);
}
}
};
initConfig();
}, [accessToken, connect]);
// Listen for Front Porch person detection - show full screen overlay for 30 seconds
useEffect(() => {
if (!isConnected) return;
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy'];
const isPersonDetected = frontPorchEntity?.state === 'on';
if (isPersonDetected && !frontPorchAlertShownRef.current) {
// Person just detected - show alert
frontPorchAlertShownRef.current = true;
setShowFrontPorchAlert(true);
// Also wake the screen
if (window.electronAPI?.screen?.wake) {
window.electronAPI.screen.wake();
}
} else if (!isPersonDetected) {
// Reset flag when person clears so next detection triggers alert
frontPorchAlertShownRef.current = false;
}
}, [isConnected, entities]);
const closeFrontPorchAlert = useCallback(() => {
setShowFrontPorchAlert(false);
}, []);
// Set up screen idle timeout
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.screen.setIdleTimeout(env.screenIdleTimeout);
}
}, []);
// Show connection prompt if no token
if (!accessToken) {
return (
<>
<ConnectionPrompt />
{settingsOpen && <ConnectionModal />}
</>
);
}
// Show loading state
if (connectionState === 'connecting') {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-primary">
<div className="text-center">
<div className="w-16 h-16 border-4 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-lg text-accent">
Connecting to Home Assistant...
</p>
</div>
</div>
);
}
// Show error state
if (connectionState === 'error') {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-primary">
<div className="widget max-w-md text-center p-8">
<div className="w-20 h-20 rounded-full bg-status-error mx-auto mb-6 flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-status-error mb-4">
Connection Error
</h2>
<p className="text-gray-400 mb-6">
Failed to connect to Home Assistant. Please check your configuration.
</p>
<button onClick={() => window.location.reload()} className="btn btn-primary">
Retry Connection
</button>
</div>
</div>
);
}
return (
<>
<Dashboard>
<DashboardContent />
</Dashboard>
{lightsOverlayOpen && <LightsOverlay />}
{locksOverlayOpen && <LocksOverlay />}
{thermostatsOverlayOpen && <ThermostatOverlay />}
{mediaOverlayOpen && <JellyfinOverlay />}
{cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />}
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />}
<GlobalKeyboard />
</>
);
}

View File

@@ -0,0 +1,266 @@
import { useState } from 'react';
import { useAlarmo, AlarmoAction } from '@/hooks/useAlarmo';
import { useAlarmoKeypad } from '@/stores/uiStore';
import { KeyPad } from './KeyPad';
export function AlarmoPanel() {
const {
alarm,
isLoading,
error,
state,
isDisarmed,
isArmed,
isPending,
isTriggered,
armHome,
armAway,
armNight,
disarm,
clearError,
} = useAlarmo();
const keypad = useAlarmoKeypad();
const [localError, setLocalError] = useState<string | null>(null);
const handleArmAction = async (action: AlarmoAction) => {
if (action === 'disarm') {
keypad.open(action);
} else {
if (alarm?.codeRequired) {
keypad.open(action);
} else {
try {
setLocalError(null);
switch (action) {
case 'arm_home':
await armHome();
break;
case 'arm_away':
await armAway();
break;
case 'arm_night':
await armNight();
break;
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : 'Action failed');
}
}
}
};
const handleKeypadSubmit = async (code: string) => {
try {
setLocalError(null);
switch (keypad.action) {
case 'arm_home':
await armHome(code);
break;
case 'arm_away':
await armAway(code);
break;
case 'arm_night':
await armNight(code);
break;
case 'disarm':
await disarm(code);
break;
}
keypad.close();
} catch (err) {
setLocalError(err instanceof Error ? err.message : 'Invalid code');
}
};
const getStateColor = () => {
if (isTriggered) return 'bg-status-error';
if (isPending) return 'bg-status-warning';
if (isDisarmed) return 'bg-status-success';
return 'bg-status-error';
};
const getStateTextColor = () => {
if (isTriggered) return 'text-status-error';
if (isPending) return 'text-status-warning';
if (isDisarmed) return 'text-status-success';
return 'text-status-error';
};
const getStateText = () => {
switch (state) {
case 'disarmed':
return 'Disarmed';
case 'armed_home':
return 'Armed Home';
case 'armed_away':
return 'Armed Away';
case 'armed_night':
return 'Armed Night';
case 'pending':
case 'arming':
return 'Arming...';
case 'triggered':
return 'TRIGGERED!';
default:
return state || 'Unknown';
}
};
const getKeypadTitle = () => {
switch (keypad.action) {
case 'arm_home':
return 'Enter code to arm home';
case 'arm_away':
return 'Enter code to arm away';
case 'arm_night':
return 'Enter code to arm night';
case 'disarm':
return 'Enter code to disarm';
default:
return 'Enter code';
}
};
if (!alarm) {
return (
<div className="widget animate-pulse">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Alarm
</div>
<div className="widget-content flex items-center justify-center">
<span className="text-gray-500">Loading...</span>
</div>
</div>
);
}
return (
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Alarmo
</div>
<div className="widget-content">
{/* Keypad Modal - shown when alarm is armed */}
{keypad.isOpen ? (
<div className="flex flex-col items-center">
<KeyPad
onSubmit={handleKeypadSubmit}
onCancel={keypad.close}
title={getKeypadTitle()}
submitLabel={keypad.action === 'disarm' ? 'Disarm' : 'Arm'}
/>
{(localError || error) && (
<div className="mt-3 text-status-error text-xs text-center">
{localError || error}
</div>
)}
</div>
) : (
<>
{/* Status Display */}
<div className="flex flex-col items-center mb-4">
<div
className={`w-16 h-16 rounded-full ${getStateColor()} flex items-center justify-center mb-2 transition-all ${
isTriggered || isPending ? 'animate-pulse' : ''
}`}
style={{
boxShadow: `0 0 20px ${
isTriggered ? 'rgba(239, 68, 68, 0.5)' :
isPending ? 'rgba(245, 158, 11, 0.5)' :
isDisarmed ? 'rgba(34, 197, 94, 0.3)' :
'rgba(239, 68, 68, 0.4)'
}`
}}
>
{isTriggered ? (
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
) : isArmed ? (
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
) : (
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</div>
<div className={`text-sm font-semibold uppercase tracking-wider ${getStateTextColor()}`}>
{getStateText()}
</div>
</div>
{/* Arm Buttons */}
{isDisarmed && (
<div className="grid grid-cols-3 gap-2 mb-3">
<button
onClick={() => handleArmAction('arm_home')}
disabled={isLoading}
className="btn flex flex-col items-center gap-1 h-auto py-2.5"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span className="text-[0.65rem]">Home</span>
</button>
<button
onClick={() => handleArmAction('arm_away')}
disabled={isLoading}
className="btn flex flex-col items-center gap-1 h-auto py-2.5"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span className="text-[0.65rem]">Away</span>
</button>
<button
onClick={() => handleArmAction('arm_night')}
disabled={isLoading}
className="btn flex flex-col items-center gap-1 h-auto py-2.5"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<span className="text-[0.65rem]">Night</span>
</button>
</div>
)}
{/* Disarm Button - shows keypad when clicked */}
{(isArmed || isPending || isTriggered) && (
<button
onClick={() => handleArmAction('disarm')}
disabled={isLoading}
className={`btn-primary w-full ${isTriggered ? 'animate-pulse' : ''}`}
>
{isTriggered ? 'DISARM NOW' : 'Disarm'}
</button>
)}
{/* Error Display */}
{(localError || error) && (
<button
onClick={() => {
setLocalError(null);
clearError();
}}
className="mt-3 text-status-error text-xs text-center w-full hover:underline"
>
{localError || error}
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { useState, useCallback } from 'react';
interface KeyPadProps {
onSubmit: (code: string) => void;
onCancel: () => void;
title: string;
submitLabel?: string;
maxLength?: number;
}
export function KeyPad({
onSubmit,
onCancel,
title,
submitLabel = 'Submit',
maxLength = 6,
}: KeyPadProps) {
const [code, setCode] = useState('');
const [error, setError] = useState(false);
const handleDigit = useCallback((digit: string) => {
if (code.length < maxLength) {
setCode((prev) => prev + digit);
setError(false);
}
}, [code.length, maxLength]);
const handleBackspace = useCallback(() => {
setCode((prev) => prev.slice(0, -1));
setError(false);
}, []);
const handleClear = useCallback(() => {
setCode('');
setError(false);
}, []);
const handleSubmit = useCallback(() => {
if (code.length > 0) {
onSubmit(code);
} else {
setError(true);
}
}, [code, onSubmit]);
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', ''];
return (
<div className="flex flex-col items-center">
{/* Title */}
<h3 className="text-sm font-medium text-gray-400 mb-3">
{title}
</h3>
{/* Code Display */}
<div
className={`w-full max-w-[200px] h-12 mb-3 flex items-center justify-center gap-2 bg-dark-tertiary rounded-xl border transition-colors ${
error ? 'border-status-error animate-pulse' : 'border-dark-border'
}`}
>
{Array.from({ length: maxLength }).map((_, i) => (
<div
key={i}
className={`w-3 h-3 rounded-full transition-all ${
i < code.length
? 'bg-accent'
: 'bg-dark-border'
}`}
style={i < code.length ? { boxShadow: '0 0 6px rgba(59, 130, 246, 0.5)' } : undefined}
/>
))}
</div>
{/* Keypad Grid */}
<div className="grid grid-cols-3 gap-2 mb-3">
{digits.map((digit, index) => (
digit ? (
<button
key={digit}
onClick={() => handleDigit(digit)}
className="keypad-btn"
>
{digit}
</button>
) : (
<div key={`empty-${index}`} className="w-14 h-14" />
)
))}
</div>
{/* Action Buttons */}
<div className="flex gap-2 w-full max-w-[200px]">
<button
onClick={handleClear}
className="flex-1 h-10 rounded-xl bg-dark-tertiary border border-dark-border hover:bg-dark-hover transition-colors text-xs font-medium"
>
Clear
</button>
<button
onClick={handleBackspace}
className="flex-1 h-10 rounded-xl bg-dark-tertiary border border-dark-border hover:bg-dark-hover transition-colors"
aria-label="Backspace"
>
<svg className="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
</div>
{/* Submit/Cancel */}
<div className="flex gap-2 w-full max-w-[200px] mt-3">
<button
onClick={onCancel}
className="btn btn-sm flex-1"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={code.length === 0}
className="btn btn-sm btn-primary flex-1 disabled:opacity-50"
>
{submitLabel}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { AlarmoPanel } from './AlarmoPanel';
export { KeyPad } from './KeyPad';

View File

@@ -0,0 +1,37 @@
import { useBinarySensor } from '@/hooks/useEntity';
import { useSettingsStore } from '@/stores/settingsStore';
export function PackageStatus() {
const packageSensor = useSettingsStore((state) => state.config.packageSensor);
const sensor = useBinarySensor(packageSensor || '');
// Don't render if no sensor configured or sensor not found
if (!packageSensor || !sensor) {
return null;
}
// Only show alert when package is detected
if (!sensor.isOn) {
return null;
}
return (
<div className="card-imperial bg-status-pending/20 border-status-pending animate-pulse-red">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-status-pending flex items-center justify-center">
<svg className="w-6 h-6 text-imperial-black" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<div className="font-display text-sm uppercase tracking-wider text-status-pending">
Package Detected
</div>
<div className="text-sm text-gray-400">
A package has been detected at your door
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { usePersonAlert, useCameraOverlay } from '@/stores/uiStore';
import { entitiesConfig, getCameraByName } from '@/config/entities';
import { CameraFeed } from '@/components/cameras/CameraFeed';
const AUTO_DISMISS_TIMEOUT = 30000; // 30 seconds
export function PersonDetectionAlert() {
const { isActive, camera, dismiss } = usePersonAlert();
const { open: openCameras } = useCameraOverlay();
const [countdown, setCountdown] = useState(30);
const cameraConfig = camera ? getCameraByName(entitiesConfig.cameras, camera) : null;
// Auto-dismiss after timeout
useEffect(() => {
if (!isActive) return;
const dismissTimer = setTimeout(dismiss, AUTO_DISMISS_TIMEOUT);
// Countdown timer
const countdownInterval = setInterval(() => {
setCountdown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => {
clearTimeout(dismissTimer);
clearInterval(countdownInterval);
setCountdown(30);
};
}, [isActive, dismiss]);
const handleViewAllCameras = () => {
dismiss();
openCameras();
};
if (!isActive || !cameraConfig) return null;
return (
<div className="overlay-full z-[100]">
{/* Alert Header */}
<div className="h-16 bg-status-error/10 border-b-2 border-status-error flex items-center justify-between px-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-status-error flex items-center justify-center animate-pulse">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-white">
Person Detected
</h2>
<p className="text-status-error text-sm">
{cameraConfig.displayName}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Countdown */}
<div className="text-right">
<div className="text-xs text-gray-400">Auto-dismiss</div>
<div className="text-xl font-semibold text-white">{countdown}s</div>
</div>
{/* Dismiss Button */}
<button
onClick={dismiss}
className="btn btn-primary h-10 px-6"
>
Dismiss
</button>
</div>
</div>
{/* Camera Feed */}
<div className="flex-1 p-4 flex items-center justify-center">
<CameraFeed
camera={cameraConfig}
className="w-full max-w-5xl aspect-video shadow-2xl ring-2 ring-status-error rounded-xl overflow-hidden"
showLabel={false}
/>
</div>
{/* Quick Actions */}
<div className="h-14 bg-dark-secondary border-t border-dark-border flex items-center justify-center gap-3 px-5">
<button
onClick={handleViewAllCameras}
className="btn btn-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
View All Cameras
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { PersonDetectionAlert } from './PersonDetectionAlert';
export { PackageStatus } from './PackageStatus';

View File

@@ -0,0 +1,471 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay, isToday } from 'date-fns';
import { useCalendar, CalendarEvent, formatEventTime } from '@/hooks/useCalendar';
import { VirtualKeyboard } from '@/components/keyboard';
function EventItem({ event }: { event: CalendarEvent }) {
return (
<div className="text-[0.6rem] truncate px-1 py-0.5 rounded bg-accent/20 text-accent-light">
{formatEventTime(event)} {event.summary}
</div>
);
}
function DayCell({
date,
isCurrentMonth,
events,
isSelected,
onSelect,
}: {
date: Date;
isCurrentMonth: boolean;
events: CalendarEvent[];
isSelected: boolean;
onSelect: (date: Date) => void;
}) {
const today = isToday(date);
return (
<button
onClick={() => onSelect(date)}
className={`min-h-[9vh] p-2 border border-dark-border text-left transition-colors touch-manipulation ${
isCurrentMonth ? 'bg-dark-tertiary' : 'bg-dark-primary/50 text-gray-600'
} ${isSelected ? 'ring-2 ring-accent' : ''} ${
today ? 'border-accent' : ''
} hover:bg-dark-hover`}
>
<div
className={`text-xs font-medium mb-0.5 ${
today
? 'w-5 h-5 rounded-full bg-accent text-white flex items-center justify-center'
: ''
}`}
>
{format(date, 'd')}
</div>
<div className="space-y-0.5 overflow-hidden">
{events.slice(0, 2).map((event) => (
<EventItem key={event.id} event={event} />
))}
{events.length > 2 && (
<div className="text-[0.6rem] text-gray-500">+{events.length - 2}</div>
)}
</div>
</button>
);
}
function EventDetails({ date, events, onAddEvent }: { date: Date; events: CalendarEvent[]; onAddEvent: () => void }) {
return (
<div className="mt-3 p-3 bg-dark-tertiary rounded-xl">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-accent">
{format(date, 'EEEE, MMM d')}
</h4>
<button
onClick={onAddEvent}
className="text-xs px-2 py-1 bg-accent/20 hover:bg-accent/30 text-accent rounded transition-colors"
>
+ Add
</button>
</div>
{events.length === 0 ? (
<p className="text-xs text-gray-500">No events</p>
) : (
<div className="space-y-1.5 max-h-24 overflow-y-auto">
{events.map((event) => (
<div key={event.id} className="text-xs">
<div className="flex items-center gap-2">
<span className="text-accent font-medium">
{formatEventTime(event)}
</span>
<span className="flex-1 truncate">{event.summary}</span>
</div>
{event.location && (
<div className="text-[0.65rem] text-gray-500 truncate ml-12">
{event.location}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
interface AddEventFormProps {
initialDate: Date;
onSubmit: (data: {
summary: string;
startDateTime: Date;
endDateTime: Date;
description?: string;
location?: string;
allDay?: boolean;
}) => Promise<void>;
onCancel: () => void;
}
function AddEventForm({ initialDate, onSubmit, onCancel }: AddEventFormProps) {
const [summary, setSummary] = useState('');
const [date, setDate] = useState(format(initialDate, 'yyyy-MM-dd'));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [location, setLocation] = useState('');
const [allDay, setAllDay] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeField, setActiveField] = useState<'summary' | 'location' | null>(null);
const handleSubmit = async () => {
if (!summary.trim()) return;
setIsSubmitting(true);
setError(null);
try {
let startDateTime: Date;
let endDateTime: Date;
if (allDay) {
startDateTime = new Date(date + 'T00:00:00');
endDateTime = addDays(startDateTime, 1);
} else {
startDateTime = new Date(`${date}T${startTime}:00`);
endDateTime = new Date(`${date}T${endTime}:00`);
// If end time is before start time, assume next day
if (endDateTime <= startDateTime) {
endDateTime = addDays(endDateTime, 1);
}
}
await onSubmit({
summary: summary.trim(),
startDateTime,
endDateTime,
location: location.trim() || undefined,
allDay,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create event');
} finally {
setIsSubmitting(false);
}
};
const handleKeyPress = useCallback((key: string) => {
if (!activeField) return;
const setter = activeField === 'summary' ? setSummary : setLocation;
const value = activeField === 'summary' ? summary : location;
if (key === 'Backspace') {
setter(value.slice(0, -1));
} else {
setter(value + key);
}
}, [activeField, summary, location]);
return (
<>
<div className="mt-3 p-3 bg-dark-tertiary rounded-xl space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-accent">New Event</h4>
<button
type="button"
onClick={onCancel}
className="text-gray-400 hover:text-white text-lg leading-none"
>
×
</button>
</div>
{error && (
<div className="text-xs text-status-error bg-status-error/20 p-2 rounded">
{error}
</div>
)}
{/* Event Title */}
<button
onClick={() => setActiveField('summary')}
className={`w-full px-2 py-1.5 text-xs bg-dark-secondary border border-dark-border rounded text-left ${
summary ? 'text-white' : 'text-gray-500'
} ${activeField === 'summary' ? 'ring-2 ring-accent' : ''}`}
>
{summary || 'Tap to enter event title'}
</button>
{/* Date and All Day */}
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="flex-1 px-2 py-1.5 text-xs bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
<label className="flex items-center gap-1 text-xs text-gray-400">
<input
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="rounded border-dark-border"
/>
All day
</label>
</div>
{/* Time Selection */}
{!allDay && (
<div className="flex items-center gap-2">
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="flex-1 px-2 py-1.5 text-xs bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
<span className="text-xs text-gray-500">to</span>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="flex-1 px-2 py-1.5 text-xs bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
</div>
)}
{/* Location */}
<button
onClick={() => setActiveField('location')}
className={`w-full px-2 py-1.5 text-xs bg-dark-secondary border border-dark-border rounded text-left ${
location ? 'text-white' : 'text-gray-500'
} ${activeField === 'location' ? 'ring-2 ring-accent' : ''}`}
>
{location || 'Location (optional)'}
</button>
{/* Buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-3 py-1.5 text-xs bg-dark-secondary hover:bg-dark-hover border border-dark-border rounded transition-colors"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
className="flex-1 px-3 py-1.5 text-xs bg-accent hover:bg-accent/80 text-white rounded transition-colors disabled:opacity-50"
disabled={isSubmitting || !summary.trim()}
>
{isSubmitting ? 'Adding...' : 'Add Event'}
</button>
</div>
</div>
{/* Virtual Keyboard */}
{activeField && (
<VirtualKeyboard
onKeyPress={handleKeyPress}
onClose={() => setActiveField(null)}
/>
)}
</>
);
}
export function CalendarWidget() {
const {
isAuthenticated,
currentMonth,
isLoading,
error,
nextMonth,
prevMonth,
goToToday,
getEventsForDate,
createEvent,
} = useCalendar();
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [showAddForm, setShowAddForm] = useState(false);
// Auto-select today and update at midnight so the dashboard always shows the current day
useEffect(() => {
const updateToday = () => {
const now = new Date();
if (!selectedDate || !isSameDay(selectedDate, now)) {
setSelectedDate(now);
// Also navigate to the current month if the date rolled over
goToToday();
}
};
// Check every 60 seconds for date rollover
const interval = setInterval(updateToday, 60000);
// Also set today immediately on mount
updateToday();
return () => clearInterval(interval);
}, [selectedDate, goToToday]);
const calendarDays = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart);
const calendarEnd = endOfWeek(monthEnd);
const days: Date[] = [];
let day = calendarStart;
while (day <= calendarEnd) {
days.push(day);
day = addDays(day, 1);
}
return days;
}, [currentMonth]);
const selectedEvents = useMemo(() => {
if (!selectedDate) return [];
return getEventsForDate(selectedDate);
}, [selectedDate, getEventsForDate]);
const handleAddEvent = () => {
setShowAddForm(true);
};
const handleCreateEvent = async (data: Parameters<typeof createEvent>[0]) => {
await createEvent(data);
setShowAddForm(false);
};
if (!isAuthenticated) {
return (
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Calendar
</div>
<div className="widget-content flex flex-col items-center justify-center">
<p className="text-gray-400 mb-2 text-center text-sm">
Waiting for Home Assistant connection...
</p>
<p className="text-gray-500 text-xs text-center">
Calendar will load automatically when connected
</p>
</div>
</div>
);
}
return (
<div className="widget min-h-[65vh]">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Calendar
{/* Month Navigation */}
<div className="ml-auto flex items-center gap-1">
<button
onClick={prevMonth}
className="p-1 hover:bg-dark-hover rounded transition-colors"
aria-label="Previous month"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => {
goToToday();
setSelectedDate(new Date());
setShowAddForm(false);
}}
className="text-xs hover:text-accent transition-colors px-1"
>
{format(currentMonth, 'MMM yyyy')}
</button>
<button
onClick={nextMonth}
className="p-1 hover:bg-dark-hover rounded transition-colors"
aria-label="Next month"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<div className="widget-content overflow-hidden flex flex-col">
{/* Error Display */}
{error && (
<div className="mb-2 p-1.5 bg-status-error/20 border border-status-error rounded-lg text-xs text-status-error">
{error}
</div>
)}
{/* Loading Indicator */}
{isLoading && (
<div className="absolute top-10 right-4">
<div className="w-3 h-3 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Day Headers */}
<div className="grid grid-cols-7 gap-px mb-1">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div
key={i}
className="text-center text-[0.6rem] font-medium text-gray-500 py-0.5"
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-px flex-1">
{calendarDays.map((date) => (
<DayCell
key={date.toISOString()}
date={date}
isCurrentMonth={isSameMonth(date, currentMonth)}
events={getEventsForDate(date)}
isSelected={selectedDate ? isSameDay(date, selectedDate) : false}
onSelect={(d) => {
setSelectedDate(d);
setShowAddForm(false);
}}
/>
))}
</div>
{/* Selected Day Details or Add Form */}
{showAddForm && selectedDate ? (
<AddEventForm
initialDate={selectedDate}
onSubmit={handleCreateEvent}
onCancel={() => setShowAddForm(false)}
/>
) : selectedDate ? (
<EventDetails
date={selectedDate}
events={selectedEvents}
onAddEvent={handleAddEvent}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { CalendarWidget } from './CalendarWidget';

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { Go2RTCWebRTC } from '@/services/go2rtc';
import { CameraConfig } from '@/config/entities';
interface CameraFeedProps {
camera: CameraConfig;
onClick?: () => void;
className?: string;
showLabel?: boolean;
useSubstream?: boolean;
delayMs?: number;
}
export function CameraFeed({
camera,
onClick,
className = '',
showLabel = true,
useSubstream = false,
delayMs = 0,
}: CameraFeedProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null);
// Use substream for grid view (lower bandwidth)
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
useEffect(() => {
let mounted = true;
let timeoutId: ReturnType<typeof setTimeout>;
const connect = async () => {
try {
setIsConnecting(true);
setError(null);
webrtcRef.current = new Go2RTCWebRTC(streamName);
await webrtcRef.current.connect((stream) => {
if (mounted && videoRef.current) {
videoRef.current.srcObject = stream;
setIsConnecting(false);
}
});
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to connect');
setIsConnecting(false);
}
}
};
// Stagger connections to avoid overwhelming go2rtc
if (delayMs > 0) {
timeoutId = setTimeout(connect, delayMs);
} else {
connect();
}
return () => {
mounted = false;
if (timeoutId) clearTimeout(timeoutId);
webrtcRef.current?.disconnect();
};
}, [streamName, delayMs]);
return (
<div
className={`relative bg-imperial-dark rounded-imperial overflow-hidden ${className}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{/* Video Element */}
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover"
/>
{/* Loading Overlay */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 border-2 border-imperial-red border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-400">Connecting...</span>
</div>
</div>
)}
{/* Error Overlay */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
<div className="flex flex-col items-center gap-2 text-center p-4">
<svg className="w-8 h-8 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm text-gray-400">{error}</span>
</div>
</div>
)}
{/* Camera Label */}
{showLabel && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-imperial-black/90 to-transparent p-2">
<span className="text-sm font-medium truncate">
{camera.displayName}
</span>
</div>
)}
{/* Click Indicator */}
{onClick && (
<div className="absolute inset-0 bg-imperial-red/0 hover:bg-imperial-red/10 transition-colors cursor-pointer" />
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { CameraFeed } from './CameraFeed';
export function CameraOverlay() {
const { isOpen, selectedCamera, close, selectCamera } = useCameraOverlay();
const cameras = useSettingsStore((state) => state.config.cameras);
if (!isOpen) return null;
const selectedCameraConfig = cameras.find((c) => c.name === selectedCamera);
return (
<div className="overlay-full">
{/* Header */}
<div className="h-14 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5">
<h2 className="text-lg font-semibold text-white">
{selectedCameraConfig ? selectedCameraConfig.displayName : 'All Cameras'}
</h2>
<div className="flex items-center gap-3">
{/* Back to grid button (when viewing single camera) */}
{selectedCamera && (
<button
onClick={() => selectCamera(null)}
className="btn btn-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
All Cameras
</button>
)}
{/* Close button */}
<button
onClick={close}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
aria-label="Close camera overlay"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden p-2">
{selectedCamera && selectedCameraConfig ? (
// Single camera full view
<div className="h-full flex items-center justify-center">
<CameraFeed
camera={selectedCameraConfig}
className="w-full h-full object-contain"
showLabel={false}
/>
</div>
) : (
// Camera grid - calculate optimal grid based on camera count
<div className={`h-full grid gap-2 ${
cameras.length <= 4 ? 'grid-cols-2 grid-rows-2' :
cameras.length <= 6 ? 'grid-cols-3 grid-rows-2' :
cameras.length <= 9 ? 'grid-cols-3 grid-rows-3' :
cameras.length <= 12 ? 'grid-cols-4 grid-rows-3' :
'grid-cols-4 grid-rows-4'
}`}>
{cameras.map((camera, index) => (
<CameraFeed
key={camera.name}
camera={camera}
onClick={() => selectCamera(camera.name)}
className="w-full h-full cursor-pointer hover:ring-2 hover:ring-accent transition-all rounded-lg overflow-hidden"
useSubstream={true}
delayMs={index * 300}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CameraFeed } from './CameraFeed';
export { CameraOverlay } from './CameraOverlay';

View File

@@ -0,0 +1,188 @@
import { useClimate } from '@/hooks/useEntity';
import { climateServices } from '@/services/homeAssistant';
import { useSettingsStore, ThermostatConfig } from '@/stores/settingsStore';
import { useUIStore } from '@/stores/uiStore';
function ThermostatControl({ config }: { config: ThermostatConfig }) {
const climate = useClimate(config.entityId);
if (!climate) {
return (
<div className="bg-dark-tertiary rounded-xl p-4 animate-pulse">
<div className="text-gray-500">{config.name}</div>
</div>
);
}
const isHeatCool = climate.hvacMode === 'heat_cool';
const targetTempHigh = climate.targetTempHigh;
const targetTempLow = climate.targetTempLow;
const handleHeatTempChange = async (delta: number) => {
if (!targetTempLow) return;
const newTemp = targetTempLow + delta;
const minTemp = climate.minTemp ?? 50;
if (newTemp >= minTemp && targetTempHigh && newTemp < targetTempHigh - 2) {
await climateServices.setTemperatureRange(config.entityId, newTemp, targetTempHigh);
}
};
const handleCoolTempChange = async (delta: number) => {
if (!targetTempHigh) return;
const newTemp = targetTempHigh + delta;
const maxTemp = climate.maxTemp ?? 90;
if (newTemp <= maxTemp && targetTempLow && newTemp > targetTempLow + 2) {
await climateServices.setTemperatureRange(config.entityId, targetTempLow, newTemp);
}
};
const handleSingleTempChange = async (delta: number) => {
if (!climate.targetTemperature) return;
const newTemp = climate.targetTemperature + delta;
const minTemp = climate.minTemp ?? 50;
const maxTemp = climate.maxTemp ?? 90;
if (newTemp >= minTemp && newTemp <= maxTemp) {
await climateServices.setTemperature(config.entityId, newTemp);
}
};
const getActionColor = () => {
switch (climate.hvacAction) {
case 'heating': return 'text-orange-400';
case 'cooling': return 'text-sky-400';
default: return 'text-gray-400';
}
};
const getActionText = () => {
switch (climate.hvacAction) {
case 'heating': return 'Heating';
case 'cooling': return 'Cooling';
case 'idle': return 'Idle';
case 'off': return 'Off';
default: return '';
}
};
return (
<div className="bg-dark-tertiary rounded-xl p-5">
<div className="text-sm font-medium text-gray-400 mb-3">{config.name}</div>
{/* Current Temperature */}
<div className="text-center mb-4">
<div className={`text-5xl font-light ${getActionColor()}`}>
{climate.currentTemperature?.toFixed(0) ?? '--'}°
</div>
<div className={`text-sm ${getActionColor()}`}>{getActionText()}</div>
</div>
{/* Setpoints */}
{isHeatCool && targetTempLow && targetTempHigh ? (
<div className="flex justify-center gap-6">
{/* Heat */}
<div className="text-center">
<div className="text-xs text-gray-500 uppercase mb-2">Heat</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleHeatTempChange(-1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-orange-400/50 text-orange-400 text-xl flex items-center justify-center hover:bg-orange-400/20"
>
</button>
<span className="text-2xl font-semibold text-orange-400 w-14 text-center">
{targetTempLow.toFixed(0)}°
</span>
<button
onClick={() => handleHeatTempChange(1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-orange-400/50 text-orange-400 text-xl flex items-center justify-center hover:bg-orange-400/20"
>
+
</button>
</div>
</div>
{/* Cool */}
<div className="text-center">
<div className="text-xs text-gray-500 uppercase mb-2">Cool</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleCoolTempChange(-1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-sky-400/50 text-sky-400 text-xl flex items-center justify-center hover:bg-sky-400/20"
>
</button>
<span className="text-2xl font-semibold text-sky-400 w-14 text-center">
{targetTempHigh.toFixed(0)}°
</span>
<button
onClick={() => handleCoolTempChange(1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-sky-400/50 text-sky-400 text-xl flex items-center justify-center hover:bg-sky-400/20"
>
+
</button>
</div>
</div>
</div>
) : (
<div className="flex justify-center">
<div className="flex items-center gap-3">
<button
onClick={() => handleSingleTempChange(-1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-dark-border text-xl flex items-center justify-center hover:bg-dark-hover"
>
</button>
<span className="text-2xl font-semibold w-16 text-center">
{climate.targetTemperature?.toFixed(0) ?? '--'}°
</span>
<button
onClick={() => handleSingleTempChange(1)}
className="w-10 h-10 rounded-full bg-dark-secondary border border-dark-border text-xl flex items-center justify-center hover:bg-dark-hover"
>
+
</button>
</div>
</div>
)}
</div>
);
}
export function ThermostatOverlay() {
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
const closeThermostatsOverlay = useUIStore((state) => state.closeThermostatsOverlay);
const thermostats = useSettingsStore((state) => state.config.thermostats);
if (!thermostatsOverlayOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" onClick={closeThermostatsOverlay}>
<div className="bg-dark-secondary rounded-2xl border border-dark-border max-w-lg w-full mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-border">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h2 className="text-lg font-semibold">Climate</h2>
</div>
<button
onClick={closeThermostatsOverlay}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3 max-h-[70vh] overflow-y-auto">
{thermostats.map((thermostat) => (
<ThermostatControl key={thermostat.entityId} config={thermostat} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useClimate } from '@/hooks/useEntity';
import { climateServices } from '@/services/homeAssistant';
import { ThermostatConfig } from '@/stores/settingsStore';
interface ThermostatWidgetProps {
config: ThermostatConfig;
}
export function ThermostatWidget({ config }: ThermostatWidgetProps) {
const climate = useClimate(config.entityId);
if (!climate) {
return (
<div className="widget animate-pulse">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{config.name}
</div>
<div className="widget-content flex items-center justify-center">
<span className="text-gray-500">Loading...</span>
</div>
</div>
);
}
// Check if in heat_cool mode (Nest dual setpoint)
const isHeatCool = climate.hvacMode === 'heat_cool';
const targetTempHigh = climate.targetTempHigh;
const targetTempLow = climate.targetTempLow;
const handleHeatTempChange = async (delta: number) => {
if (!targetTempLow) return;
const newTemp = targetTempLow + delta;
const minTemp = climate.minTemp ?? 50;
if (newTemp >= minTemp && targetTempHigh && newTemp < targetTempHigh - 2) {
await climateServices.setTemperatureRange(config.entityId, newTemp, targetTempHigh);
}
};
const handleCoolTempChange = async (delta: number) => {
if (!targetTempHigh) return;
const newTemp = targetTempHigh + delta;
const maxTemp = climate.maxTemp ?? 90;
if (newTemp <= maxTemp && targetTempLow && newTemp > targetTempLow + 2) {
await climateServices.setTemperatureRange(config.entityId, targetTempLow, newTemp);
}
};
const handleSingleTempChange = async (delta: number) => {
if (!climate.targetTemperature) return;
const newTemp = climate.targetTemperature + delta;
const minTemp = climate.minTemp ?? 50;
const maxTemp = climate.maxTemp ?? 90;
if (newTemp >= minTemp && newTemp <= maxTemp) {
await climateServices.setTemperature(config.entityId, newTemp);
}
};
const getActionColor = () => {
switch (climate.hvacAction) {
case 'heating':
return 'text-orange-400';
case 'cooling':
return 'text-sky-400';
case 'idle':
return 'text-gray-400';
default:
return 'text-gray-500';
}
};
const getActionText = () => {
switch (climate.hvacAction) {
case 'heating':
return 'Heating';
case 'cooling':
return 'Cooling';
case 'idle':
return 'Idle';
case 'off':
return 'Off';
default:
return '';
}
};
return (
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{config.name}
</div>
<div className="widget-content flex flex-col">
{/* Current Temperature - Large Display */}
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className={`text-4xl font-light tracking-tight ${getActionColor()}`}>
{climate.currentTemperature?.toFixed(0) ?? '--'}°
</div>
{climate.hvacAction && (
<div className={`text-[0.65rem] uppercase tracking-wide ${getActionColor()}`}>
{getActionText()}
</div>
)}
</div>
</div>
{/* Setpoints */}
<div className="mt-3 pt-3 border-t border-dark-border">
{isHeatCool && targetTempLow && targetTempHigh ? (
// Nest Heat/Cool Range Mode - Horizontal compact
<div className="flex justify-center gap-4">
{/* Heat Setpoint */}
<div className="flex items-center gap-1">
<button
onClick={() => handleHeatTempChange(-1)}
className="w-6 h-6 rounded-full bg-dark-tertiary border border-orange-400/50 text-orange-400 text-sm flex items-center justify-center hover:bg-orange-400/15"
aria-label="Decrease heat temperature"
>
</button>
<div className="text-center min-w-[3rem]">
<div className="text-[0.5rem] text-gray-500 uppercase">Heat</div>
<div className="text-lg font-semibold text-orange-400">{targetTempLow.toFixed(0)}°</div>
</div>
<button
onClick={() => handleHeatTempChange(1)}
className="w-6 h-6 rounded-full bg-dark-tertiary border border-orange-400/50 text-orange-400 text-sm flex items-center justify-center hover:bg-orange-400/15"
aria-label="Increase heat temperature"
>
+
</button>
</div>
{/* Cool Setpoint */}
<div className="flex items-center gap-1">
<button
onClick={() => handleCoolTempChange(-1)}
className="w-6 h-6 rounded-full bg-dark-tertiary border border-sky-400/50 text-sky-400 text-sm flex items-center justify-center hover:bg-sky-400/15"
aria-label="Decrease cool temperature"
>
</button>
<div className="text-center min-w-[3rem]">
<div className="text-[0.5rem] text-gray-500 uppercase">Cool</div>
<div className="text-lg font-semibold text-sky-400">{targetTempHigh.toFixed(0)}°</div>
</div>
<button
onClick={() => handleCoolTempChange(1)}
className="w-6 h-6 rounded-full bg-dark-tertiary border border-sky-400/50 text-sky-400 text-sm flex items-center justify-center hover:bg-sky-400/15"
aria-label="Increase cool temperature"
>
+
</button>
</div>
</div>
) : (
// Single Setpoint Mode
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleSingleTempChange(-1)}
className="w-7 h-7 rounded-full bg-dark-tertiary border border-dark-border text-sm flex items-center justify-center hover:bg-dark-hover"
aria-label="Decrease temperature"
>
</button>
<div className="text-center min-w-[3.5rem]">
<div className="text-[0.5rem] text-gray-500 uppercase">Set</div>
<div className="text-xl font-semibold">
{climate.targetTemperature?.toFixed(0) ?? '--'}°
</div>
</div>
<button
onClick={() => handleSingleTempChange(1)}
className="w-7 h-7 rounded-full bg-dark-tertiary border border-dark-border text-sm flex items-center justify-center hover:bg-dark-hover"
aria-label="Increase temperature"
>
+
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ThermostatWidget } from './ThermostatWidget';
export { ThermostatOverlay } from './ThermostatOverlay';

View File

@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { useUIStore } from '@/stores/uiStore';
import { VirtualKeyboard } from './VirtualKeyboard';
export function GlobalKeyboard() {
const keyboardOpen = useUIStore((state) => state.keyboardOpen);
const keyboardNumpad = useUIStore((state) => state.keyboardNumpad);
const closeKeyboard = useUIStore((state) => state.closeKeyboard);
const handleKeyPress = useCallback((key: string) => {
// Get the currently focused element
const activeElement = document.activeElement as HTMLInputElement | HTMLTextAreaElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
const start = activeElement.selectionStart ?? activeElement.value.length;
const end = activeElement.selectionEnd ?? activeElement.value.length;
if (key === 'Backspace') {
if (start === end && start > 0) {
// Delete character before cursor
activeElement.value = activeElement.value.slice(0, start - 1) + activeElement.value.slice(end);
activeElement.selectionStart = activeElement.selectionEnd = start - 1;
} else {
// Delete selection
activeElement.value = activeElement.value.slice(0, start) + activeElement.value.slice(end);
activeElement.selectionStart = activeElement.selectionEnd = start;
}
} else {
// Insert character at cursor
activeElement.value = activeElement.value.slice(0, start) + key + activeElement.value.slice(end);
activeElement.selectionStart = activeElement.selectionEnd = start + key.length;
}
// Trigger input event for React controlled components
activeElement.dispatchEvent(new Event('input', { bubbles: true }));
} else {
// Fallback: dispatch keyboard event to document
const eventType = key === 'Backspace' ? 'keydown' : 'keypress';
const event = new KeyboardEvent(eventType, {
key: key,
code: key === 'Backspace' ? 'Backspace' : `Key${key.toUpperCase()}`,
bubbles: true,
});
document.dispatchEvent(event);
}
}, []);
if (!keyboardOpen) return null;
return (
<VirtualKeyboard
onKeyPress={handleKeyPress}
onClose={closeKeyboard}
showNumpad={keyboardNumpad}
/>
);
}

View File

@@ -0,0 +1,58 @@
import { useState, useRef, useCallback, InputHTMLAttributes } from 'react';
import { VirtualKeyboard } from './VirtualKeyboard';
interface KeyboardInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
numpad?: boolean;
}
export function KeyboardInput({
value,
onChange,
numpad = false,
className = '',
...props
}: KeyboardInputProps) {
const [showKeyboard, setShowKeyboard] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleKeyPress = useCallback((key: string) => {
if (key === 'Backspace') {
onChange(value.slice(0, -1));
} else {
onChange(value + key);
}
}, [value, onChange]);
const handleFocus = () => {
setShowKeyboard(true);
};
const handleClose = () => {
setShowKeyboard(false);
inputRef.current?.blur();
};
return (
<>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={handleFocus}
readOnly // Prevent native keyboard on mobile
className={`cursor-pointer ${className}`}
{...props}
/>
{showKeyboard && (
<VirtualKeyboard
onKeyPress={handleKeyPress}
onClose={handleClose}
showNumpad={numpad}
/>
)}
</>
);
}

View File

@@ -0,0 +1,111 @@
import { useState, useCallback } from 'react';
interface VirtualKeyboardProps {
onKeyPress: (key: string) => void;
onClose: () => void;
showNumpad?: boolean;
}
const KEYBOARD_ROWS = [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-'],
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', "'"],
['shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', 'backspace'],
['@', 'space', '.com', 'done'],
];
const NUMPAD_ROWS = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['.', '0', 'backspace'],
['done'],
];
export function VirtualKeyboard({ onKeyPress, onClose, showNumpad = false }: VirtualKeyboardProps) {
const [isShift, setIsShift] = useState(false);
const handleKeyPress = useCallback((key: string) => {
switch (key) {
case 'shift':
setIsShift(!isShift);
break;
case 'backspace':
onKeyPress('Backspace');
break;
case 'space':
onKeyPress(' ');
break;
case 'done':
onClose();
break;
default:
onKeyPress(isShift ? key.toUpperCase() : key);
if (isShift) setIsShift(false);
break;
}
}, [isShift, onKeyPress, onClose]);
const getKeyLabel = (key: string) => {
switch (key) {
case 'backspace':
return (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
);
case 'shift':
return (
<svg className={`w-6 h-6 ${isShift ? 'text-accent' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11l5-5m0 0l5 5m-5-5v12" />
</svg>
);
case 'space':
return 'space';
case 'done':
return 'Done';
default:
return isShift ? key.toUpperCase() : key;
}
};
const getKeyClass = (key: string) => {
const base = 'flex items-center justify-center rounded-lg font-medium transition-colors touch-manipulation active:scale-95';
switch (key) {
case 'space':
return `${base} flex-1 min-w-[150px] h-12 bg-dark-tertiary hover:bg-dark-hover text-gray-300`;
case 'done':
return `${base} px-6 h-12 bg-accent hover:bg-accent/80 text-white`;
case 'shift':
return `${base} w-14 h-12 ${isShift ? 'bg-accent text-white' : 'bg-dark-tertiary hover:bg-dark-hover text-gray-300'}`;
case 'backspace':
return `${base} w-16 h-12 bg-dark-tertiary hover:bg-dark-hover text-gray-300`;
case '.com':
return `${base} px-4 h-12 bg-dark-tertiary hover:bg-dark-hover text-gray-400 text-sm`;
default:
return `${base} w-10 h-12 bg-dark-secondary hover:bg-dark-hover text-white`;
}
};
const rows = showNumpad ? NUMPAD_ROWS : KEYBOARD_ROWS;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-dark-primary border-t border-dark-border p-3 pb-6">
<div className="max-w-4xl mx-auto space-y-2">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex justify-center gap-1.5">
{row.map((key) => (
<button
key={key}
onClick={() => handleKeyPress(key)}
className={getKeyClass(key)}
>
{getKeyLabel(key)}
</button>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { VirtualKeyboard } from './VirtualKeyboard';
export { KeyboardInput } from './KeyboardInput';
export { GlobalKeyboard } from './GlobalKeyboard';

View File

@@ -0,0 +1,39 @@
import { ReactNode } from 'react';
import { Header } from './Header';
import { useUIStore } from '@/stores/uiStore';
import { CameraOverlay } from '@/components/cameras/CameraOverlay';
import { PersonDetectionAlert } from '@/components/alerts/PersonDetectionAlert';
import { SettingsPanel } from '@/components/settings/SettingsPanel';
interface DashboardProps {
children: ReactNode;
}
export function Dashboard({ children }: DashboardProps) {
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
const personAlertActive = useUIStore((state) => state.personAlertActive);
const settingsOpen = useUIStore((state) => state.settingsOpen);
return (
<div className="h-screen w-screen flex flex-col bg-dark-primary overflow-hidden">
{/* Header */}
<Header />
{/* Main Content */}
<main className="flex-1 overflow-hidden p-4">
<div className="h-full grid grid-cols-12 gap-3">
{children}
</div>
</main>
{/* Camera Overlay */}
{cameraOverlayOpen && <CameraOverlay />}
{/* Person Detection Alert */}
{personAlertActive && <PersonDetectionAlert />}
{/* Settings Panel */}
{settingsOpen && <SettingsPanel />}
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useConnectionState, useEntityState, useEntityAttribute } from '@/stores/haStore';
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
import { format } from 'date-fns';
import { useState, useEffect } from 'react';
interface PersonAvatarProps {
entityId: string;
name: string;
avatarUrl?: string;
}
function PersonAvatar({ entityId, name, avatarUrl }: PersonAvatarProps) {
const state = useEntityState(entityId) ?? 'unknown';
const entityPicture = useEntityAttribute<string>(entityId, 'entity_picture');
// Build avatar URL: prefer HA entity_picture, then configured avatarUrl, then show initials
const resolvedAvatarUrl = entityPicture
? `${env.haUrl}${entityPicture}`
: avatarUrl;
const getLocationClass = () => {
switch (state) {
case 'home':
return 'home';
case 'work':
return 'work';
default:
return 'away';
}
};
const getLocationLabel = () => {
switch (state) {
case 'home':
return 'Home';
case 'work':
return 'Work';
case 'not_home':
return 'Away';
default:
return state.charAt(0).toUpperCase() + state.slice(1);
}
};
const locationClass = getLocationClass();
return (
<div className="person-status">
<div className={`person-avatar ${locationClass}`}>
{resolvedAvatarUrl ? (
<img src={resolvedAvatarUrl} alt={name} />
) : (
<div className="w-full h-full bg-dark-tertiary flex items-center justify-center text-sm font-medium text-gray-400">
{name.charAt(0).toUpperCase()}
</div>
)}
</div>
<span className={`person-location ${locationClass}`}>
{getLocationLabel()}
</span>
</div>
);
}
function PackageStatus() {
const packageSensor = useSettingsStore((state) => state.config.packageSensor);
const entityState = useEntityState(packageSensor || '');
const hasPackage = packageSensor && entityState === 'on';
if (!hasPackage) return null;
return (
<div className="status-icon package" title="Package detected">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
);
}
export function Header() {
const connectionState = useConnectionState();
const openCameraOverlay = useUIStore((state) => state.openCameraOverlay);
const openMediaOverlay = useUIStore((state) => state.openMediaOverlay);
const openSettings = useUIStore((state) => state.openSettings);
const people = useSettingsStore((state) => state.config.people);
const cameras = useSettingsStore((state) => state.config.cameras);
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
const getConnectionStatusClass = () => {
switch (connectionState) {
case 'connected':
return 'connected';
case 'connecting':
return 'connecting';
case 'error':
case 'disconnected':
return 'disconnected';
}
};
const getConnectionText = () => {
switch (connectionState) {
case 'connected':
return 'Connected';
case 'connecting':
return 'Connecting...';
case 'error':
return 'Error';
case 'disconnected':
return 'Disconnected';
}
};
return (
<header className="h-14 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5">
{/* Left - Time and Date */}
<div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold text-white tracking-tight">
{format(currentTime, 'h:mm')}
</span>
<span className="text-sm text-gray-500">
{format(currentTime, 'EEE, MMM d')}
</span>
</div>
{/* Center - Status Icons */}
<div className="flex items-center gap-4">
{/* Package Status */}
<PackageStatus />
{/* People */}
{people.length > 0 && (
<div className="flex items-center gap-3">
{people.map((person) => (
<PersonAvatar
key={person.entityId}
entityId={person.entityId}
name={person.name}
avatarUrl={person.avatarUrl}
/>
))}
</div>
)}
</div>
{/* Right - Connection Status, Cameras, Settings */}
<div className="flex items-center gap-4">
{/* Connection Status */}
<div className="status-badge">
<div className={`status-dot ${getConnectionStatusClass()}`} />
<span>{getConnectionText()}</span>
</div>
{/* Media Button */}
<button
onClick={openMediaOverlay}
className="btn btn-sm"
aria-label="Open media"
>
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Media
</button>
{/* Camera Button */}
{cameras.length > 0 && (
<button
onClick={() => openCameraOverlay()}
className="btn btn-sm"
aria-label="View cameras"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Cameras
</button>
)}
{/* Settings Button */}
<button
onClick={openSettings}
className="btn btn-sm"
aria-label="Settings"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,2 @@
export { Dashboard } from './Dashboard';
export { Header } from './Header';

View File

@@ -0,0 +1,137 @@
import { useMemo } from 'react';
import { useLight } from '@/hooks/useEntity';
import { lightServices } from '@/services/homeAssistant';
import { useSettingsStore, LightConfig } from '@/stores/settingsStore';
import { useUIStore } from '@/stores/uiStore';
function LightToggle({ config }: { config: LightConfig }) {
const light = useLight(config.entityId);
const handleToggle = async () => {
await lightServices.toggle(config.entityId);
};
if (!light) {
return (
<div className="compact-row animate-pulse">
<span className="text-gray-500 text-sm">{config.name}</span>
<div className="w-9 h-5 bg-dark-elevated rounded-full" />
</div>
);
}
return (
<button
onClick={handleToggle}
className="compact-row w-full"
>
<div className="flex items-center gap-2.5">
<div
className={`w-2.5 h-2.5 rounded-full transition-colors ${
light.isOn ? 'bg-yellow-400' : 'bg-gray-600'
}`}
style={light.isOn ? { boxShadow: '0 0 8px rgba(250, 204, 21, 0.6)' } : undefined}
/>
<span className={`text-sm ${light.isOn ? 'text-white' : 'text-gray-400'}`}>
{config.name}
</span>
</div>
<div
className={`toggle ${light.isOn ? 'active' : ''}`}
role="switch"
aria-checked={light.isOn}
>
<div className="toggle-thumb" />
</div>
</button>
);
}
interface RoomLightsProps {
room: string;
lights: LightConfig[];
}
function RoomLights({ room, lights }: RoomLightsProps) {
return (
<div className="bg-dark-secondary rounded-xl p-4">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
{room}
</div>
<div className="space-y-1.5">
{lights.map((light) => (
<LightToggle key={light.entityId} config={light} />
))}
</div>
</div>
);
}
export function LightsOverlay() {
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
const closeLightsOverlay = useUIStore((state) => state.closeLightsOverlay);
const lights = useSettingsStore((state) => state.config.lights);
const lightsByRoom = useMemo(() => {
return lights.reduce((acc, light) => {
if (!acc[light.room]) {
acc[light.room] = [];
}
acc[light.room].push(light);
return acc;
}, {} as Record<string, LightConfig[]>);
}, [lights]);
const handleAllLightsOff = async () => {
await Promise.all(
lights.map((l) => lightServices.turnOff(l.entityId))
);
};
const handleAllLightsOn = async () => {
await Promise.all(
lights.map((l) => lightServices.turnOn(l.entityId))
);
};
if (!lightsOverlayOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" onClick={closeLightsOverlay}>
<div className="bg-dark-secondary rounded-2xl border border-dark-border max-w-2xl w-full mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-border">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<h2 className="text-lg font-semibold">Lights</h2>
</div>
<div className="flex items-center gap-2">
<button onClick={handleAllLightsOn} className="btn btn-sm">All On</button>
<button onClick={handleAllLightsOff} className="btn btn-sm">All Off</button>
<button
onClick={closeLightsOverlay}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="p-4 max-h-[70vh] overflow-y-auto">
<div className="grid grid-cols-2 gap-3">
{Object.entries(lightsByRoom).map(([room, roomLights]) => (
<RoomLights key={room} room={room} lights={roomLights} />
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { useLight } from '@/hooks/useEntity';
import { lightServices } from '@/services/homeAssistant';
import { useSettingsStore, LightConfig } from '@/stores/settingsStore';
function LightToggle({ config }: { config: LightConfig }) {
const light = useLight(config.entityId);
const handleToggle = async () => {
await lightServices.toggle(config.entityId);
};
if (!light) {
return (
<div className="compact-row animate-pulse">
<span className="text-gray-500 text-sm">{config.name}</span>
<div className="w-9 h-5 bg-dark-elevated rounded-full" />
</div>
);
}
return (
<button
onClick={handleToggle}
className="compact-row w-full"
>
<div className="flex items-center gap-2.5">
<div
className={`w-2 h-2 rounded-full transition-colors ${
light.isOn ? 'bg-yellow-400' : 'bg-gray-600'
}`}
style={light.isOn ? { boxShadow: '0 0 6px rgba(250, 204, 21, 0.5)' } : undefined}
/>
<span className={`text-sm ${light.isOn ? 'text-white' : 'text-gray-400'}`}>
{config.name}
</span>
</div>
<div
className={`toggle ${light.isOn ? 'active' : ''}`}
role="switch"
aria-checked={light.isOn}
>
<div className="toggle-thumb" />
</div>
</button>
);
}
interface RoomLightsProps {
room: string;
lights: LightConfig[];
}
function RoomLights({ room, lights }: RoomLightsProps) {
return (
<div className="mb-3 last:mb-0">
<div className="text-[0.65rem] font-semibold text-gray-500 uppercase tracking-wider mb-1.5 px-1">
{room}
</div>
<div className="space-y-1">
{lights.map((light) => (
<LightToggle key={light.entityId} config={light} />
))}
</div>
</div>
);
}
export function LightsWidget() {
const lights = useSettingsStore((state) => state.config.lights);
const lightsByRoom = useMemo(() => {
return lights.reduce((acc, light) => {
if (!acc[light.room]) {
acc[light.room] = [];
}
acc[light.room].push(light);
return acc;
}, {} as Record<string, LightConfig[]>);
}, [lights]);
const handleAllLightsOff = async () => {
await Promise.all(
lights.map((l) => lightServices.turnOff(l.entityId))
);
};
return (
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Lights
<button
onClick={handleAllLightsOff}
className="ml-auto text-[0.65rem] px-2 py-0.5 rounded bg-dark-tertiary hover:bg-dark-hover text-gray-400 hover:text-white transition-colors"
aria-label="Turn all lights off"
>
All Off
</button>
</div>
<div className="widget-content overflow-y-auto">
{Object.entries(lightsByRoom).map(([room, lights]) => (
<RoomLights key={room} room={room} lights={lights} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { LightsWidget } from './LightsWidget';
export { LightsOverlay } from './LightsOverlay';

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { useLock } from '@/hooks/useEntity';
import { lockServices } from '@/services/homeAssistant';
import { useSettingsStore, LockConfig } from '@/stores/settingsStore';
import { useUIStore } from '@/stores/uiStore';
function LockControl({ config }: { config: LockConfig }) {
const lock = useLock(config.entityId);
const [confirming, setConfirming] = useState(false);
const handleToggle = async () => {
if (lock?.isLocked) {
setConfirming(true);
} else {
await lockServices.lock(config.entityId);
}
};
const handleConfirmUnlock = async () => {
await lockServices.unlock(config.entityId);
setConfirming(false);
};
const handleCancelUnlock = () => {
setConfirming(false);
};
if (!lock) {
return (
<div className="bg-dark-tertiary rounded-xl p-4 animate-pulse">
<span className="text-gray-500">{config.name}</span>
</div>
);
}
const isLocked = lock.isLocked;
if (confirming) {
return (
<div className="bg-dark-tertiary rounded-xl p-4 border-2 border-status-warning">
<div className="flex items-center justify-between mb-3">
<span className="font-medium">{config.name}</span>
<span className="text-status-warning text-sm">Confirm Unlock?</span>
</div>
<div className="flex gap-2">
<button
onClick={handleConfirmUnlock}
className="flex-1 py-2 rounded-lg bg-status-error text-white font-medium"
>
Unlock
</button>
<button
onClick={handleCancelUnlock}
className="flex-1 py-2 rounded-lg bg-dark-secondary text-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div className="bg-dark-tertiary rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isLocked ? 'bg-status-success/20' : 'bg-status-warning/20'}`}>
{isLocked ? (
<svg className="w-6 h-6 text-status-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-6 h-6 text-status-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</div>
<div>
<div className="font-medium">{config.name}</div>
<div className={`text-sm ${isLocked ? 'text-status-success' : 'text-status-warning'}`}>
{isLocked ? 'Locked' : 'Unlocked'}
</div>
</div>
</div>
</div>
<button
onClick={handleToggle}
disabled={lock.isLocking || lock.isUnlocking}
className={`w-full py-2.5 rounded-lg font-medium transition-colors ${
isLocked
? 'bg-status-warning/20 text-status-warning hover:bg-status-warning/30'
: 'bg-status-success/20 text-status-success hover:bg-status-success/30'
} disabled:opacity-50`}
>
{lock.isLocking || lock.isUnlocking ? 'Working...' : isLocked ? 'Unlock' : 'Lock'}
</button>
</div>
);
}
export function LocksOverlay() {
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
const closeLocksOverlay = useUIStore((state) => state.closeLocksOverlay);
const locks = useSettingsStore((state) => state.config.locks);
const handleLockAll = async () => {
await Promise.all(
locks.map((l) => lockServices.lock(l.entityId))
);
};
if (!locksOverlayOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" onClick={closeLocksOverlay}>
<div className="bg-dark-secondary rounded-2xl border border-dark-border max-w-md w-full mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-border">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-status-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h2 className="text-lg font-semibold">Locks</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleLockAll}
className="btn btn-sm bg-status-success/20 text-status-success hover:bg-status-success/30 border-status-success/30"
>
Lock All
</button>
<button
onClick={closeLocksOverlay}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-3 max-h-[70vh] overflow-y-auto">
{locks.map((lock) => (
<LockControl key={lock.entityId} config={lock} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { useLock } from '@/hooks/useEntity';
import { lockServices } from '@/services/homeAssistant';
import { useSettingsStore, LockConfig } from '@/stores/settingsStore';
function LockControl({ config }: { config: LockConfig }) {
const lock = useLock(config.entityId);
const [confirming, setConfirming] = useState(false);
const handleToggle = async () => {
if (lock?.isLocked) {
setConfirming(true);
} else {
await lockServices.lock(config.entityId);
}
};
const handleConfirmUnlock = async () => {
await lockServices.unlock(config.entityId);
setConfirming(false);
};
const handleCancelUnlock = () => {
setConfirming(false);
};
if (!lock) {
return (
<div className="compact-row animate-pulse">
<span className="text-gray-500 text-sm">{config.name}</span>
<div className="w-16 h-6 bg-dark-elevated rounded-lg" />
</div>
);
}
const isLocked = lock.isLocked;
if (confirming) {
return (
<div className="compact-row border border-status-warning">
<span className="text-sm font-medium">{config.name}</span>
<div className="flex gap-1.5">
<button
onClick={handleConfirmUnlock}
className="text-xs px-2 py-1 rounded bg-status-error text-white"
>
Unlock
</button>
<button
onClick={handleCancelUnlock}
className="text-xs px-2 py-1 rounded bg-dark-tertiary text-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div className="compact-row">
<div className="flex items-center gap-2.5">
<div className={isLocked ? 'text-status-success' : 'text-status-warning'}>
{isLocked ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</div>
<div>
<span className="text-sm">{config.name}</span>
<span className={`ml-2 text-xs ${isLocked ? 'text-status-success' : 'text-status-warning'}`}>
{isLocked ? 'Locked' : 'Unlocked'}
</span>
</div>
</div>
<button
onClick={handleToggle}
disabled={lock.isLocking || lock.isUnlocking}
className={`text-xs px-2.5 py-1 rounded-lg font-medium transition-colors ${
isLocked
? 'bg-status-warning/20 text-status-warning hover:bg-status-warning/30'
: 'bg-status-success/20 text-status-success hover:bg-status-success/30'
} disabled:opacity-50`}
>
{isLocked ? 'Unlock' : 'Lock'}
</button>
</div>
);
}
export function LocksWidget() {
const locks = useSettingsStore((state) => state.config.locks);
const handleLockAll = async () => {
await Promise.all(
locks.map((l) => lockServices.lock(l.entityId))
);
};
return (
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Locks
<button
onClick={handleLockAll}
className="ml-auto text-[0.65rem] px-2 py-0.5 rounded bg-status-success/20 text-status-success hover:bg-status-success/30 transition-colors"
aria-label="Lock all doors"
>
Lock All
</button>
</div>
<div className="widget-content space-y-1.5">
{locks.map((lock) => (
<LockControl key={lock.entityId} config={lock} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { LocksWidget } from './LocksWidget';
export { LocksOverlay } from './LocksOverlay';

View File

@@ -0,0 +1,239 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { VirtualKeyboard } from '@/components/keyboard';
interface JellyfinAuthResponse {
AccessToken: string;
User: {
Id: string;
Name: string;
};
}
export function JellyfinOverlay() {
const closeMediaOverlay = useUIStore((state) => state.closeMediaOverlay);
const jellyfinUrl = useSettingsStore((state) => state.config.jellyfinUrl);
const [showLogin, setShowLogin] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
const [error, setError] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(() => {
return localStorage.getItem('jellyfin_access_token');
});
const iframeRef = useRef<HTMLIFrameElement>(null);
// Build URL with access token if available
const buildUrl = () => {
const baseUrl = `${jellyfinUrl}/web/index.html#!/home.html`;
if (accessToken) {
// Jellyfin web client can use token via api_key param
return `${baseUrl}?api_key=${accessToken}`;
}
return baseUrl;
};
const handleLogin = useCallback(async () => {
setError('');
try {
const response = await fetch(`${jellyfinUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `MediaBrowser Client="Imperial Command Center", Device="Kitchen Panel", DeviceId="kitchen-panel-1", Version="1.0.0"`,
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data: JellyfinAuthResponse = await response.json();
const token = data.AccessToken;
// Store the token
localStorage.setItem('jellyfin_access_token', token);
setAccessToken(token);
setShowLogin(false);
setUsername('');
setPassword('');
// Reload iframe with new token
if (iframeRef.current) {
iframeRef.current.src = buildUrl();
}
} catch (err) {
setError('Login failed. Check username and password.');
}
}, [jellyfinUrl, username, password]);
const handleKeyPress = useCallback((key: string) => {
if (!activeField) return;
const setter = activeField === 'username' ? setUsername : setPassword;
const value = activeField === 'username' ? username : password;
if (key === 'Backspace') {
setter(value.slice(0, -1));
} else {
setter(value + key);
}
}, [activeField, username, password]);
const handleLogout = useCallback(() => {
localStorage.removeItem('jellyfin_access_token');
setAccessToken(null);
if (iframeRef.current) {
iframeRef.current.src = buildUrl();
}
}, [jellyfinUrl]);
// Reload iframe when access token changes
useEffect(() => {
if (iframeRef.current && accessToken) {
iframeRef.current.src = buildUrl();
}
}, [accessToken]);
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
{/* Header */}
<div className="h-12 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
<h2 className="text-lg font-semibold">Jellyfin</h2>
</div>
<div className="flex items-center gap-2">
{/* Login/Logout button */}
{accessToken ? (
<button
onClick={handleLogout}
className="btn btn-sm text-gray-400 hover:text-white"
aria-label="Logout"
>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</button>
) : (
<button
onClick={() => setShowLogin(true)}
className="btn btn-sm"
aria-label="Login"
>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
Login
</button>
)}
{/* Close button */}
<button
onClick={closeMediaOverlay}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors touch-manipulation"
aria-label="Close"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Login Modal */}
{showLogin && (
<div className="absolute inset-0 z-10 bg-black/80 flex items-center justify-center">
<div className="bg-dark-secondary rounded-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-xl font-semibold mb-4">Jellyfin Login</h3>
{error && (
<div className="bg-status-error/20 text-status-error rounded-lg p-3 mb-4 text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Username</label>
<input
type="text"
value={username}
readOnly
onFocus={() => setActiveField('username')}
className={`w-full bg-dark-tertiary rounded-lg px-4 py-3 text-white outline-none cursor-pointer ${
activeField === 'username' ? 'ring-2 ring-accent' : ''
}`}
placeholder="Tap to enter username"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Password</label>
<input
type="password"
value={password}
readOnly
onFocus={() => setActiveField('password')}
className={`w-full bg-dark-tertiary rounded-lg px-4 py-3 text-white outline-none cursor-pointer ${
activeField === 'password' ? 'ring-2 ring-accent' : ''
}`}
placeholder="Tap to enter password"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => {
setShowLogin(false);
setActiveField(null);
setUsername('');
setPassword('');
setError('');
}}
className="btn flex-1 bg-dark-tertiary"
>
Cancel
</button>
<button
onClick={handleLogin}
className="btn btn-primary flex-1"
disabled={!username}
>
Login
</button>
</div>
</div>
</div>
</div>
)}
{/* Jellyfin iframe */}
<div className="flex-1 relative">
<iframe
ref={iframeRef}
src={buildUrl()}
className="absolute inset-0 w-full h-full border-0"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title="Jellyfin"
/>
</div>
{/* Virtual Keyboard for login */}
{showLogin && activeField && (
<VirtualKeyboard
onKeyPress={handleKeyPress}
onClose={() => setActiveField(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { JellyfinOverlay } from './JellyfinOverlay';

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useHAStore } from '@/stores/haStore';
import { useUIStore } from '@/stores/uiStore';
export function ConnectionModal() {
const connect = useHAStore((state) => state.connect);
const closeSettings = useUIStore((state) => state.closeSettings);
const [token, setToken] = useState('');
const [haUrl, setHaUrl] = useState(localStorage.getItem('ha_url') || 'http://192.168.1.50:8123');
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConnect = async () => {
if (!token.trim()) {
setError('Please enter a token');
return;
}
setIsConnecting(true);
setError(null);
try {
// Save token and URL to localStorage
localStorage.setItem('ha_access_token', token.trim());
localStorage.setItem('ha_url', haUrl.trim());
// Connect
await connect(token.trim());
closeSettings();
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
localStorage.removeItem('ha_access_token');
} finally {
setIsConnecting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-dark-secondary border border-dark-border rounded-2xl shadow-2xl w-full max-w-md p-6">
<h2 className="text-lg font-semibold mb-4">Connect to Home Assistant</h2>
{error && (
<div className="mb-4 p-3 bg-status-error/20 border border-status-error rounded-lg text-sm text-status-error">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Home Assistant URL</label>
<input
type="text"
value={haUrl}
onChange={(e) => setHaUrl(e.target.value)}
placeholder="http://192.168.1.50:8123"
className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Long-Lived Access Token</label>
<textarea
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your token here..."
rows={3}
className="w-full px-3 py-2 bg-dark-tertiary border border-dark-border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
Get this from Home Assistant Profile Long-Lived Access Tokens
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={closeSettings}
className="flex-1 px-4 py-2 bg-dark-tertiary hover:bg-dark-hover border border-dark-border rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleConnect}
disabled={isConnecting || !token.trim()}
className="flex-1 px-4 py-2 bg-accent hover:bg-accent/80 text-white rounded-lg transition-colors disabled:opacity-50"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,683 @@
import { useState, useMemo } from 'react';
import { useHAStore } from '@/stores/haStore';
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
type TabType = 'climate' | 'lights' | 'locks' | 'alarm' | 'calendar' | 'todo' | 'people' | 'sensors' | 'cameras';
interface EntityOption {
entityId: string;
name: string;
state: string;
}
function EntityCheckbox({
entity,
checked,
onChange,
showState = true,
}: {
entity: EntityOption;
checked: boolean;
onChange: (checked: boolean) => void;
showState?: boolean;
}) {
return (
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer transition-colors">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-dark-border text-accent focus:ring-accent focus:ring-offset-0 bg-dark-secondary"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{entity.name}</div>
<div className="text-xs text-gray-500 truncate">{entity.entityId}</div>
</div>
{showState && (
<span className="text-xs px-2 py-0.5 rounded bg-dark-tertiary text-gray-400">
{entity.state}
</span>
)}
</label>
);
}
function EntityRadio({
entity,
checked,
onChange,
name,
}: {
entity: EntityOption;
checked: boolean;
onChange: () => void;
name: string;
}) {
return (
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer transition-colors">
<input
type="radio"
name={name}
checked={checked}
onChange={onChange}
className="w-4 h-4 border-dark-border text-accent focus:ring-accent focus:ring-offset-0 bg-dark-secondary"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{entity.name}</div>
<div className="text-xs text-gray-500 truncate">{entity.entityId}</div>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-dark-tertiary text-gray-400">
{entity.state}
</span>
</label>
);
}
function ClimateTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setThermostats = useSettingsStore((state) => state.setThermostats);
const climateEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('climate.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
const selectedIds = new Set(config.thermostats.map((t) => t.entityId));
const handleToggle = (entity: EntityOption, checked: boolean) => {
if (checked) {
setThermostats([...config.thermostats, { entityId: entity.entityId, name: entity.name }]);
} else {
setThermostats(config.thermostats.filter((t) => t.entityId !== entity.entityId));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select thermostats to display on the dashboard.</p>
{climateEntities.length === 0 ? (
<p className="text-sm text-gray-500">No climate entities found</p>
) : (
climateEntities.map((entity) => (
<EntityCheckbox
key={entity.entityId}
entity={entity}
checked={selectedIds.has(entity.entityId)}
onChange={(checked) => handleToggle(entity, checked)}
/>
))
)}
</div>
);
}
function LightsTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setLights = useSettingsStore((state) => state.setLights);
const [roomInput, setRoomInput] = useState<Record<string, string>>({});
const lightEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('light.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
const selectedMap = new Map(config.lights.map((l) => [l.entityId, l]));
const handleToggle = (entity: EntityOption, checked: boolean) => {
if (checked) {
const room = roomInput[entity.entityId] || 'Uncategorized';
setLights([...config.lights, { entityId: entity.entityId, name: entity.name, room }]);
} else {
setLights(config.lights.filter((l) => l.entityId !== entity.entityId));
}
};
const handleRoomChange = (entityId: string, room: string) => {
setRoomInput((prev) => ({ ...prev, [entityId]: room }));
const existing = config.lights.find((l) => l.entityId === entityId);
if (existing) {
setLights(config.lights.map((l) => (l.entityId === entityId ? { ...l, room } : l)));
}
};
const rooms = useMemo(() => {
const uniqueRooms = new Set(config.lights.map((l) => l.room));
return Array.from(uniqueRooms).sort();
}, [config.lights]);
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">
Select lights and assign rooms. Common rooms: {rooms.length > 0 ? rooms.join(', ') : 'Living Room, Kitchen, Bedroom'}
</p>
{lightEntities.length === 0 ? (
<p className="text-sm text-gray-500">No light entities found</p>
) : (
lightEntities.map((entity) => {
const selected = selectedMap.get(entity.entityId);
return (
<div key={entity.entityId} className="flex items-center gap-2 p-2 rounded-lg hover:bg-dark-hover">
<input
type="checkbox"
checked={!!selected}
onChange={(e) => handleToggle(entity, e.target.checked)}
className="w-4 h-4 rounded border-dark-border text-accent focus:ring-accent bg-dark-secondary"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{entity.name}</div>
<div className="text-xs text-gray-500 truncate">{entity.entityId}</div>
</div>
{selected && (
<input
type="text"
value={selected.room}
onChange={(e) => handleRoomChange(entity.entityId, e.target.value)}
placeholder="Room"
className="w-28 px-2 py-1 text-xs bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
)}
<span className={`text-xs px-2 py-0.5 rounded ${entity.state === 'on' ? 'bg-status-success/20 text-status-success' : 'bg-dark-tertiary text-gray-400'}`}>
{entity.state}
</span>
</div>
);
})
)}
</div>
);
}
function LocksTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setLocks = useSettingsStore((state) => state.setLocks);
const lockEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('lock.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
const selectedIds = new Set(config.locks.map((l) => l.entityId));
const handleToggle = (entity: EntityOption, checked: boolean) => {
if (checked) {
setLocks([...config.locks, { entityId: entity.entityId, name: entity.name }]);
} else {
setLocks(config.locks.filter((l) => l.entityId !== entity.entityId));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select locks to display on the dashboard.</p>
{lockEntities.length === 0 ? (
<p className="text-sm text-gray-500">No lock entities found</p>
) : (
lockEntities.map((entity) => (
<EntityCheckbox
key={entity.entityId}
entity={entity}
checked={selectedIds.has(entity.entityId)}
onChange={(checked) => handleToggle(entity, checked)}
/>
))
)}
</div>
);
}
function AlarmTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setAlarm = useSettingsStore((state) => state.setAlarm);
const alarmEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('alarm_control_panel.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select your alarm panel (Alarmo recommended).</p>
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer">
<input
type="radio"
name="alarm"
checked={config.alarm === null}
onChange={() => setAlarm(null)}
className="w-4 h-4 border-dark-border text-accent focus:ring-accent bg-dark-secondary"
/>
<span className="text-sm text-gray-400">None</span>
</label>
{alarmEntities.map((entity) => (
<EntityRadio
key={entity.entityId}
entity={entity}
checked={config.alarm === entity.entityId}
onChange={() => setAlarm(entity.entityId)}
name="alarm"
/>
))}
</div>
);
}
function CalendarTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setCalendar = useSettingsStore((state) => state.setCalendar);
const calendarEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('calendar.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select your calendar entity from Home Assistant.</p>
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer">
<input
type="radio"
name="calendar"
checked={config.calendar === null}
onChange={() => setCalendar(null)}
className="w-4 h-4 border-dark-border text-accent focus:ring-accent bg-dark-secondary"
/>
<span className="text-sm text-gray-400">None</span>
</label>
{calendarEntities.map((entity) => (
<EntityRadio
key={entity.entityId}
entity={entity}
checked={config.calendar === entity.entityId}
onChange={() => setCalendar(entity.entityId)}
name="calendar"
/>
))}
</div>
);
}
function TodoTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setTodoList = useSettingsStore((state) => state.setTodoList);
const todoEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('todo.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select your to-do list entity.</p>
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer">
<input
type="radio"
name="todo"
checked={config.todoList === null}
onChange={() => setTodoList(null)}
className="w-4 h-4 border-dark-border text-accent focus:ring-accent bg-dark-secondary"
/>
<span className="text-sm text-gray-400">None</span>
</label>
{todoEntities.map((entity) => (
<EntityRadio
key={entity.entityId}
entity={entity}
checked={config.todoList === entity.entityId}
onChange={() => setTodoList(entity.entityId)}
name="todo"
/>
))}
</div>
);
}
function PeopleTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setPeople = useSettingsStore((state) => state.setPeople);
const personEntities = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('person.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
const selectedIds = new Set(config.people.map((p) => p.entityId));
const handleToggle = (entity: EntityOption, checked: boolean) => {
if (checked) {
setPeople([...config.people, { entityId: entity.entityId, name: entity.name }]);
} else {
setPeople(config.people.filter((p) => p.entityId !== entity.entityId));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-gray-400 mb-3">Select people to show in the header (shows home/away status).</p>
{personEntities.length === 0 ? (
<p className="text-sm text-gray-500">No person entities found</p>
) : (
personEntities.map((entity) => (
<EntityCheckbox
key={entity.entityId}
entity={entity}
checked={selectedIds.has(entity.entityId)}
onChange={(checked) => handleToggle(entity, checked)}
/>
))
)}
</div>
);
}
function SensorsTab() {
const entities = useHAStore((state) => state.entities);
const config = useSettingsStore((state) => state.config);
const setPackageSensor = useSettingsStore((state) => state.setPackageSensor);
const binarySensors = useMemo(() => {
return Object.values(entities)
.filter((e) => e.entity_id.startsWith('binary_sensor.'))
.map((e) => ({
entityId: e.entity_id,
name: e.attributes.friendly_name || e.entity_id.split('.')[1],
state: e.state,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [entities]);
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">Package Detection Sensor</h4>
<p className="text-xs text-gray-400 mb-3">
Select a binary sensor that indicates when a package is detected (e.g., from Frigate).
A package icon will appear in the header when this sensor is "on".
</p>
<div className="max-h-64 overflow-y-auto space-y-1 border border-dark-border rounded-lg p-2">
<label className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-hover cursor-pointer">
<input
type="radio"
name="package"
checked={config.packageSensor === null}
onChange={() => setPackageSensor(null)}
className="w-4 h-4 border-dark-border text-accent focus:ring-accent bg-dark-secondary"
/>
<span className="text-sm text-gray-400">None</span>
</label>
{binarySensors.map((entity) => (
<EntityRadio
key={entity.entityId}
entity={entity}
checked={config.packageSensor === entity.entityId}
onChange={() => setPackageSensor(entity.entityId)}
name="package"
/>
))}
</div>
</div>
</div>
);
}
function CamerasTab() {
const config = useSettingsStore((state) => state.config);
const updateConfig = useSettingsStore((state) => state.updateConfig);
const [newCamera, setNewCamera] = useState({ name: '', displayName: '', stream: '' });
const handleAddCamera = () => {
if (!newCamera.name || !newCamera.stream) return;
updateConfig({
cameras: [
...config.cameras,
{
name: newCamera.name,
displayName: newCamera.displayName || newCamera.name,
go2rtcStream: newCamera.stream,
frigateCamera: newCamera.name,
},
],
});
setNewCamera({ name: '', displayName: '', stream: '' });
};
const handleRemoveCamera = (name: string) => {
updateConfig({
cameras: config.cameras.filter((c) => c.name !== name),
});
};
return (
<div className="space-y-4">
<div>
<p className="text-xs text-gray-400 mb-3">
Configure cameras from your go2rtc server. Enter the stream name as configured in go2rtc.
</p>
<div className="space-y-2 mb-4">
<input
type="text"
value={config.go2rtcUrl}
onChange={(e) => updateConfig({ go2rtcUrl: e.target.value })}
placeholder="go2rtc URL"
className="w-full px-3 py-2 text-sm bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
<input
type="text"
value={config.frigateUrl}
onChange={(e) => updateConfig({ frigateUrl: e.target.value })}
placeholder="Frigate URL"
className="w-full px-3 py-2 text-sm bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="border-t border-dark-border pt-4">
<h4 className="text-sm font-medium mb-3">Cameras ({config.cameras.length})</h4>
{config.cameras.length > 0 && (
<div className="space-y-2 mb-4">
{config.cameras.map((camera) => (
<div key={camera.name} className="flex items-center gap-2 p-2 bg-dark-tertiary rounded-lg">
<div className="flex-1">
<div className="text-sm font-medium">{camera.displayName}</div>
<div className="text-xs text-gray-500">Stream: {camera.go2rtcStream}</div>
</div>
<button
onClick={() => handleRemoveCamera(camera.name)}
className="p-1 text-gray-400 hover:text-status-error transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={newCamera.stream}
onChange={(e) => setNewCamera((p) => ({ ...p, stream: e.target.value, name: e.target.value }))}
placeholder="Stream name (from go2rtc)"
className="flex-1 px-3 py-2 text-sm bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
<input
type="text"
value={newCamera.displayName}
onChange={(e) => setNewCamera((p) => ({ ...p, displayName: e.target.value }))}
placeholder="Display name"
className="flex-1 px-3 py-2 text-sm bg-dark-secondary border border-dark-border rounded focus:border-accent focus:outline-none"
/>
</div>
<button
onClick={handleAddCamera}
disabled={!newCamera.stream}
className="w-full py-2 text-sm bg-accent/20 hover:bg-accent/30 text-accent rounded transition-colors disabled:opacity-50"
>
+ Add Camera
</button>
</div>
</div>
</div>
);
}
const tabs: { id: TabType; label: string; icon: string }[] = [
{ id: 'climate', label: 'Climate', icon: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707' },
{ id: 'lights', label: 'Lights', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
{ id: 'locks', label: 'Locks', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ id: 'alarm', label: 'Alarm', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
{ id: 'calendar', label: 'Calendar', icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z' },
{ id: 'todo', label: 'To-Do', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
{ id: 'people', label: 'People', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'sensors', label: 'Sensors', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
{ id: 'cameras', label: 'Cameras', icon: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' },
];
export function SettingsPanel() {
const closeSettings = useUIStore((state) => state.closeSettings);
const setSetupCompleted = useSettingsStore((state) => state.setSetupCompleted);
const [activeTab, setActiveTab] = useState<TabType>('climate');
const handleSave = () => {
setSetupCompleted(true);
closeSettings();
};
const renderTabContent = () => {
switch (activeTab) {
case 'climate':
return <ClimateTab />;
case 'lights':
return <LightsTab />;
case 'locks':
return <LocksTab />;
case 'alarm':
return <AlarmTab />;
case 'calendar':
return <CalendarTab />;
case 'todo':
return <TodoTab />;
case 'people':
return <PeopleTab />;
case 'sensors':
return <SensorsTab />;
case 'cameras':
return <CamerasTab />;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-dark-secondary border border-dark-border rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold">Dashboard Settings</h2>
<button
onClick={closeSettings}
className="p-2 hover:bg-dark-hover rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar Tabs */}
<div className="w-48 border-r border-dark-border p-2 space-y-1 overflow-y-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
activeTab === tab.id
? 'bg-accent text-white'
: 'hover:bg-dark-hover text-gray-300'
}`}
>
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={tab.icon} />
</svg>
{tab.label}
</button>
))}
</div>
{/* Content Area */}
<div className="flex-1 p-4 overflow-y-auto">
{renderTabContent()}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-dark-border">
<button
onClick={closeSettings}
className="px-4 py-2 text-sm bg-dark-tertiary hover:bg-dark-hover rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-accent hover:bg-accent/80 text-white rounded-lg transition-colors"
>
Save & Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { SettingsPanel } from './SettingsPanel';
export { ConnectionModal } from './ConnectionModal';

View File

@@ -0,0 +1,195 @@
import { useState, useCallback } from 'react';
import { useTodo, TodoItem } from '@/hooks/useTodo';
import { VirtualKeyboard } from '@/components/keyboard';
function TodoItemRow({ item, onComplete, onRemove }: {
item: TodoItem;
onComplete: (uid: string) => void;
onRemove: (uid: string) => void;
}) {
const isCompleted = item.status === 'completed';
return (
<div
className={`compact-row group ${isCompleted ? 'opacity-50' : ''}`}
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<button
onClick={() => onComplete(item.uid)}
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors touch-manipulation ${
isCompleted
? 'bg-status-success border-status-success'
: 'border-dark-border-light hover:border-accent'
}`}
aria-label={isCompleted ? 'Mark as incomplete' : 'Mark as complete'}
>
{isCompleted && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={`text-sm truncate ${isCompleted ? 'line-through text-gray-500' : ''}`}>
{item.summary}
</span>
</div>
<button
onClick={() => onRemove(item.uid)}
className="p-1 opacity-0 group-hover:opacity-100 text-gray-500 hover:text-status-error transition-all touch-manipulation flex-shrink-0"
aria-label="Remove item"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
export function TodoWidget() {
const {
activeItems,
completedItems,
isLoading,
error,
addItem,
completeItem,
uncompleteItem,
removeItem,
clearError,
} = useTodo();
const [newItemText, setNewItemText] = useState('');
const [showKeyboard, setShowKeyboard] = useState(false);
const handleSubmit = async () => {
if (!newItemText.trim()) return;
try {
await addItem(newItemText.trim());
setNewItemText('');
setShowKeyboard(false);
} catch {
// Error handled by hook
}
};
const handleKeyPress = useCallback((key: string) => {
if (key === 'Backspace') {
setNewItemText(prev => prev.slice(0, -1));
} else {
setNewItemText(prev => prev + key);
}
}, []);
const handleComplete = async (uid: string) => {
const item = activeItems.find((i) => i.uid === uid);
if (item) {
await completeItem(uid);
} else {
await uncompleteItem(uid);
}
};
// Limit displayed items to prevent overflow
const displayedActiveItems = activeItems.slice(0, 5);
const hiddenCount = activeItems.length - displayedActiveItems.length;
return (
<>
<div className="widget">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
To-Do
{activeItems.length > 0 && (
<span className="ml-auto text-xs bg-accent px-1.5 py-0.5 rounded-full">
{activeItems.length}
</span>
)}
</div>
<div className="widget-content flex flex-col">
{/* Add Item Form - Compact */}
<div className="flex gap-1.5 mb-2">
<button
onClick={() => setShowKeyboard(true)}
className={`flex-1 bg-dark-tertiary border border-dark-border rounded-lg px-2.5 py-1.5 text-sm text-left ${
newItemText ? 'text-white' : 'text-gray-500'
} ${showKeyboard ? 'ring-2 ring-accent' : ''}`}
>
{newItemText || 'Tap to add item...'}
</button>
<button
onClick={handleSubmit}
disabled={!newItemText.trim() || isLoading}
className="btn btn-sm btn-primary disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* Error Display */}
{error && (
<button
onClick={clearError}
className="mb-2 p-1.5 bg-status-error/20 border border-status-error rounded-lg text-xs text-status-error text-center"
>
{error}
</button>
)}
{/* Active Items - Limited */}
<div className="space-y-1">
{isLoading && activeItems.length === 0 ? (
<div className="text-center text-gray-500 py-2 text-sm">Loading...</div>
) : displayedActiveItems.length === 0 ? (
<div className="text-center text-gray-500 py-2 text-sm">No items</div>
) : (
<>
{displayedActiveItems.map((item) => (
<TodoItemRow
key={item.uid}
item={item}
onComplete={handleComplete}
onRemove={removeItem}
/>
))}
{hiddenCount > 0 && (
<div className="text-xs text-gray-500 text-center py-1">
+{hiddenCount} more
</div>
)}
</>
)}
</div>
{/* Completed count */}
{completedItems.length > 0 && (
<div className="mt-2 pt-2 border-t border-dark-border text-xs text-gray-500 text-center">
{completedItems.length} completed
</div>
)}
</div>
</div>
{/* Virtual Keyboard */}
{showKeyboard && (
<VirtualKeyboard
onKeyPress={handleKeyPress}
onClose={() => {
setShowKeyboard(false);
if (newItemText.trim()) {
handleSubmit();
}
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1 @@
export { TodoWidget } from './TodoWidget';

149
src/config/entities.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Home Assistant Entity Configuration
*
* Configure your actual HA entity IDs here.
* These are placeholder values - update them to match your Home Assistant setup.
*/
export interface ThermostatConfig {
entityId: string;
name: string;
location: string;
}
export interface LightConfig {
entityId: string;
name: string;
room: string;
}
export interface LockConfig {
entityId: string;
name: string;
location: string;
}
export interface CameraConfig {
name: string;
displayName: string;
go2rtcStream: string;
frigateCamera?: string;
}
export interface PersonConfig {
entityId: string;
name: string;
avatarUrl?: string;
}
export interface EntitiesConfig {
thermostats: ThermostatConfig[];
lights: LightConfig[];
locks: LockConfig[];
alarm: string;
packageSensor: string;
todoList: string;
cameras: CameraConfig[];
personDetectionEntities: string[];
people: PersonConfig[];
}
// Default configuration - customize these for your setup
export const entitiesConfig: EntitiesConfig = {
// Thermostats (Nest with heat_cool mode)
thermostats: [
{
entityId: 'climate.upstairs_thermostat',
name: 'Upstairs',
location: 'upstairs',
},
{
entityId: 'climate.downstairs_thermostat',
name: 'Downstairs',
location: 'downstairs',
},
],
// Lights - grouped by room
lights: [
// Living Room
{ entityId: 'light.living_room_main', name: 'Main Light', room: 'Living Room' },
{ entityId: 'light.living_room_lamp', name: 'Lamp', room: 'Living Room' },
// Kitchen
{ entityId: 'light.kitchen_main', name: 'Main Light', room: 'Kitchen' },
{ entityId: 'light.kitchen_under_cabinet', name: 'Under Cabinet', room: 'Kitchen' },
// Bedroom
{ entityId: 'light.bedroom_main', name: 'Main Light', room: 'Bedroom' },
{ entityId: 'light.bedroom_lamp', name: 'Lamp', room: 'Bedroom' },
// Bathroom
{ entityId: 'light.bathroom_main', name: 'Main Light', room: 'Bathroom' },
// Hallway
{ entityId: 'light.hallway', name: 'Hallway', room: 'Hallway' },
// Outdoor
{ entityId: 'light.porch_light', name: 'Porch', room: 'Outdoor' },
{ entityId: 'light.garage_light', name: 'Garage', room: 'Outdoor' },
],
// Door Locks
locks: [
{ entityId: 'lock.front_door', name: 'Front Door', location: 'front' },
{ entityId: 'lock.back_door', name: 'Back Door', location: 'back' },
{ entityId: 'lock.garage_door', name: 'Garage Door', location: 'garage' },
],
// Alarmo alarm control panel
alarm: 'alarm_control_panel.alarmo',
// Package detection binary sensor
packageSensor: 'binary_sensor.package_detected',
// HA built-in to-do list
todoList: 'todo.shopping_list',
// Cameras - configured to use go2rtc streams
cameras: [
{ name: 'FPE', displayName: 'Front Porch Entry', go2rtcStream: 'FPE', frigateCamera: 'FPE' },
{ name: 'Porch_Downstairs', displayName: 'Porch Downstairs', go2rtcStream: 'Porch_Downstairs', frigateCamera: 'Porch_Downstairs' },
{ name: 'Front_Porch', displayName: 'Front Porch', go2rtcStream: 'Front_Porch', frigateCamera: 'Front_Porch' },
{ name: 'Driveway_door', displayName: 'Driveway Door', go2rtcStream: 'Driveway_door', frigateCamera: 'Driveway_door' },
{ name: 'Street_side', displayName: 'Street Side', go2rtcStream: 'Street_side', frigateCamera: 'Street_side' },
{ name: 'Backyard', displayName: 'Backyard', go2rtcStream: 'Backyard', frigateCamera: 'Backyard' },
{ name: 'House_side', displayName: 'House Side', go2rtcStream: 'House_side', frigateCamera: 'House_side' },
{ name: 'Driveway', displayName: 'Driveway', go2rtcStream: 'Driveway', frigateCamera: 'Driveway' },
{ name: 'WyzePanV3', displayName: 'Wyze Pan V3', go2rtcStream: 'WyzePanV3', frigateCamera: 'WyzePanV3' },
],
// Frigate person detection entities - triggers full-screen alert
personDetectionEntities: [
'binary_sensor.fpe_person_occupancy',
'binary_sensor.porch_downstairs_person_occupancy',
],
// People tracking - device_tracker or person entities
// Set avatarUrl to a URL of an image, or leave empty to show initials
people: [
{
entityId: 'person.user1',
name: 'User 1',
},
{
entityId: 'person.user2',
name: 'User 2',
},
],
};
// Helper functions
export function getLightsByRoom(lights: LightConfig[]): Record<string, LightConfig[]> {
return lights.reduce((acc, light) => {
if (!acc[light.room]) {
acc[light.room] = [];
}
acc[light.room].push(light);
return acc;
}, {} as Record<string, LightConfig[]>);
}
export function getCameraByName(cameras: CameraConfig[], name: string): CameraConfig | undefined {
return cameras.find((c) => c.name === name);
}

30
src/config/environment.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Environment configuration
* Values are loaded from .env file via Vite
*/
export const env = {
// Home Assistant
haUrl: import.meta.env.VITE_HA_URL || 'http://192.168.1.50:8123',
haWsUrl: import.meta.env.VITE_HA_WS_URL || 'ws://192.168.1.50:8123/api/websocket',
// Frigate & go2rtc
// Use empty string to proxy through same origin (nginx), or set explicit URL
frigateUrl: import.meta.env.VITE_FRIGATE_URL || 'http://192.168.1.241:5000',
go2rtcUrl: import.meta.env.VITE_GO2RTC_URL || 'http://192.168.1.241:1985',
go2rtcRtsp: import.meta.env.VITE_GO2RTC_RTSP || 'rtsp://192.168.1.241:8600',
// Google Calendar
googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID || '',
// Screen management
screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10),
// Presence detection
presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true',
presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'),
// Frigate streaming
frigateStreamEnabled: import.meta.env.VITE_FRIGATE_STREAM_ENABLED === 'true',
frigateRtspOutput: import.meta.env.VITE_FRIGATE_RTSP_OUTPUT || 'rtsp://192.168.1.241:8554/command_center',
} as const;

6
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { useHomeAssistant } from './useHomeAssistant';
export { useEntity, useEntityStateValue, useEntityAttributeValue, useClimate, useLight, useLock, useAlarm, useBinarySensor } from './useEntity';
export { useAlarmo, type AlarmoAction } from './useAlarmo';
export { useTodo, type TodoItem } from './useTodo';
export { useCalendar } from './useCalendar';
export { useLocalPresence } from './useLocalPresence';

81
src/hooks/useAlarmo.ts Normal file
View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from 'react';
import { useAlarm } from './useEntity';
import { alarmServices } from '@/services/homeAssistant';
import { useSettingsStore } from '@/stores/settingsStore';
export type AlarmoAction = 'arm_home' | 'arm_away' | 'arm_night' | 'disarm';
export function useAlarmo() {
const alarmEntityId = useSettingsStore((state) => state.config.alarm);
const alarm = useAlarm(alarmEntityId || '');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const executeAction = useCallback(async (action: AlarmoAction, code?: string) => {
if (!alarmEntityId) {
setError('No alarm configured');
return;
}
setIsLoading(true);
setError(null);
try {
const entityId = alarmEntityId;
switch (action) {
case 'arm_home':
await alarmServices.armHome(entityId, code);
break;
case 'arm_away':
await alarmServices.armAway(entityId, code);
break;
case 'arm_night':
await alarmServices.armNight(entityId, code);
break;
case 'disarm':
if (!code) {
throw new Error('Code is required to disarm');
}
await alarmServices.disarm(entityId, code);
break;
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to execute alarm action';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [alarmEntityId]);
const armHome = useCallback((code?: string) => executeAction('arm_home', code), [executeAction]);
const armAway = useCallback((code?: string) => executeAction('arm_away', code), [executeAction]);
const armNight = useCallback((code?: string) => executeAction('arm_night', code), [executeAction]);
const disarm = useCallback((code: string) => executeAction('disarm', code), [executeAction]);
const clearError = useCallback(() => setError(null), []);
return {
// State
alarm,
isLoading,
error,
// Computed
state: alarm?.state,
isDisarmed: alarm?.isDisarmed ?? false,
isArmed: !alarm?.isDisarmed && !alarm?.isPending,
isPending: alarm?.isPending ?? false,
isTriggered: alarm?.isTriggered ?? false,
codeRequired: alarm?.codeRequired ?? true,
// Actions
armHome,
armAway,
armNight,
disarm,
executeAction,
clearError,
};
}

259
src/hooks/useCalendar.ts Normal file
View File

@@ -0,0 +1,259 @@
import { useState, useEffect, useCallback } from 'react';
import { startOfMonth, endOfMonth, format } from 'date-fns';
import { calendarServices, CalendarEvent, CreateEventParams } from '@/services/homeAssistant/services';
import { haConnection } from '@/services/homeAssistant/connection';
import { useSettingsStore } from '@/stores/settingsStore';
export interface CalendarEventDisplay {
id: string;
summary: string;
description?: string;
location?: string;
start: {
dateTime?: string;
date?: string;
};
end: {
dateTime?: string;
date?: string;
};
allDay: boolean;
}
function convertHAEventToDisplay(event: CalendarEvent): CalendarEventDisplay {
// HA returns:
// - All-day events: "2026-02-09" (date only, no T)
// - Timed events: "2026-02-09T09:00:00-06:00" (full ISO with timezone)
const isAllDay = !event.start.includes('T');
return {
id: event.uid || `${event.summary}-${event.start}`,
summary: event.summary,
description: event.description,
location: event.location,
start: isAllDay
? { date: event.start }
: { dateTime: event.start },
end: isAllDay
? { date: event.end }
: { dateTime: event.end },
allDay: isAllDay,
};
}
export function formatEventTime(event: CalendarEventDisplay): string {
if (event.allDay) {
return 'All day';
}
if (event.start.dateTime) {
const date = new Date(event.start.dateTime);
return format(date, 'h:mm a');
}
return '';
}
export function useCalendar() {
const calendarEntityId = useSettingsStore((state) => state.config.calendar);
const [events, setEvents] = useState<CalendarEventDisplay[]>([]);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(haConnection.isConnected());
// Check connection status
useEffect(() => {
const checkConnection = () => {
setIsConnected(haConnection.isConnected());
};
// Check periodically
const interval = setInterval(checkConnection, 1000);
return () => clearInterval(interval);
}, []);
const fetchEvents = useCallback(async () => {
if (!calendarEntityId) {
return;
}
if (!haConnection.isConnected()) {
setError('Not connected to Home Assistant');
return;
}
setIsLoading(true);
setError(null);
try {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
// Format as ISO string for HA
const startDateTime = monthStart.toISOString();
const endDateTime = monthEnd.toISOString();
const response = await calendarServices.getEvents(
calendarEntityId,
startDateTime,
endDateTime
);
const rawEvents = response?.events || [];
if (!Array.isArray(rawEvents)) {
console.error('Calendar: events is not an array:', typeof rawEvents);
setEvents([]);
return;
}
const displayEvents = rawEvents.map(convertHAEventToDisplay);
// Sort by start time
displayEvents.sort((a, b) => {
const aStart = a.start.dateTime || a.start.date || '';
const bStart = b.start.dateTime || b.start.date || '';
return aStart.localeCompare(bStart);
});
setEvents(displayEvents);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch events';
console.error('Calendar fetch error:', err);
setError(message);
} finally {
setIsLoading(false);
}
}, [calendarEntityId, currentMonth]);
useEffect(() => {
if (isConnected) {
fetchEvents();
// Poll every 30 seconds for calendar updates
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}
}, [fetchEvents, isConnected]);
const createEvent = useCallback(async (params: {
summary: string;
startDateTime: Date;
endDateTime: Date;
description?: string;
location?: string;
allDay?: boolean;
}): Promise<void> => {
if (!calendarEntityId) {
throw new Error('No calendar configured');
}
if (!haConnection.isConnected()) {
throw new Error('Not connected to Home Assistant');
}
const serviceParams: CreateEventParams = {
summary: params.summary,
description: params.description,
location: params.location,
};
if (params.allDay) {
// For all-day events, use date only (YYYY-MM-DD)
serviceParams.start_date = format(params.startDateTime, 'yyyy-MM-dd');
serviceParams.end_date = format(params.endDateTime, 'yyyy-MM-dd');
} else {
// For timed events, use full datetime
serviceParams.start_date_time = format(params.startDateTime, "yyyy-MM-dd HH:mm:ss");
serviceParams.end_date_time = format(params.endDateTime, "yyyy-MM-dd HH:mm:ss");
}
await calendarServices.createEvent(calendarEntityId, serviceParams);
// Give HA time to sync, then refresh events
await new Promise(resolve => setTimeout(resolve, 1000));
await fetchEvents();
}, [calendarEntityId, fetchEvents]);
const nextMonth = useCallback(() => {
setCurrentMonth((prev) => {
const next = new Date(prev);
next.setMonth(next.getMonth() + 1);
return next;
});
}, []);
const prevMonth = useCallback(() => {
setCurrentMonth((prev) => {
const next = new Date(prev);
next.setMonth(next.getMonth() - 1);
return next;
});
}, []);
const goToToday = useCallback(() => {
setCurrentMonth(new Date());
}, []);
const getEventsForDate = useCallback(
(date: Date) => {
const dateStr = format(date, 'yyyy-MM-dd');
return events.filter((event) => {
if (event.allDay) {
// All-day event: check if date falls within the event range
const startDate = event.start.date || '';
const endDate = event.end.date || '';
if (!endDate || endDate <= startDate) {
// Single-day all-day event (end same as start or missing)
return dateStr === startDate;
}
// Multi-day all-day event
// End date is exclusive in iCal/HA convention (end is day AFTER last day)
return dateStr >= startDate && dateStr < endDate;
} else if (event.start.dateTime) {
// Timed event: check if the date falls within the event's date span
const eventStart = new Date(event.start.dateTime);
const eventStartStr = format(eventStart, 'yyyy-MM-dd');
if (event.end.dateTime) {
const eventEnd = new Date(event.end.dateTime);
const eventEndStr = format(eventEnd, 'yyyy-MM-dd');
// Multi-day timed event: show on all days from start to end (inclusive)
return dateStr >= eventStartStr && dateStr <= eventEndStr;
}
// Single timed event: just compare start date
return dateStr === eventStartStr;
}
return false;
});
},
[events]
);
return {
// HA calendar is "authenticated" when connected to HA and calendar is configured
isAuthenticated: isConnected && !!calendarEntityId,
events,
currentMonth,
isLoading,
error,
// No separate auth needed - uses HA connection
startAuth: () => {},
handleAuthCallback: async () => {},
signOut: () => {},
nextMonth,
prevMonth,
goToToday,
getEventsForDate,
refresh: fetchEvents,
createEvent,
};
}
export type { CalendarEventDisplay as CalendarEvent };

139
src/hooks/useEntity.ts Normal file
View File

@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import { useEntity as useEntityFromStore, useEntityState, useEntityAttribute } from '@/stores/haStore';
import { HassEntity } from 'home-assistant-js-websocket';
/**
* Hook for accessing a single Home Assistant entity
*/
export function useEntity(entityId: string): HassEntity | undefined {
return useEntityFromStore(entityId);
}
/**
* Hook for accessing entity state value
*/
export function useEntityStateValue(entityId: string): string | undefined {
return useEntityState(entityId);
}
/**
* Hook for accessing a specific entity attribute
*/
export function useEntityAttributeValue<T>(entityId: string, attribute: string): T | undefined {
return useEntityAttribute<T>(entityId, attribute);
}
/**
* Hook for climate/thermostat entities
*/
export function useClimate(entityId: string) {
const entity = useEntity(entityId);
return useMemo(() => {
if (!entity) return null;
return {
state: entity.state,
currentTemperature: entity.attributes.current_temperature as number | undefined,
targetTemperature: entity.attributes.temperature as number | undefined,
targetTempHigh: entity.attributes.target_temp_high as number | undefined,
targetTempLow: entity.attributes.target_temp_low as number | undefined,
hvacMode: entity.state as string,
hvacModes: entity.attributes.hvac_modes as string[] | undefined,
hvacAction: entity.attributes.hvac_action as string | undefined,
minTemp: entity.attributes.min_temp as number | undefined,
maxTemp: entity.attributes.max_temp as number | undefined,
targetTempStep: entity.attributes.target_temp_step as number | undefined,
friendlyName: entity.attributes.friendly_name as string | undefined,
};
}, [entity]);
}
/**
* Hook for light entities
*/
export function useLight(entityId: string) {
const entity = useEntity(entityId);
return useMemo(() => {
if (!entity) return null;
return {
state: entity.state,
isOn: entity.state === 'on',
brightness: entity.attributes.brightness as number | undefined,
brightnessPct: entity.attributes.brightness
? Math.round((entity.attributes.brightness as number) / 255 * 100)
: undefined,
colorMode: entity.attributes.color_mode as string | undefined,
rgbColor: entity.attributes.rgb_color as [number, number, number] | undefined,
friendlyName: entity.attributes.friendly_name as string | undefined,
};
}, [entity]);
}
/**
* Hook for lock entities
*/
export function useLock(entityId: string) {
const entity = useEntity(entityId);
return useMemo(() => {
if (!entity) return null;
return {
state: entity.state,
isLocked: entity.state === 'locked',
isUnlocked: entity.state === 'unlocked',
isJammed: entity.state === 'jammed',
isLocking: entity.state === 'locking',
isUnlocking: entity.state === 'unlocking',
friendlyName: entity.attributes.friendly_name as string | undefined,
};
}, [entity]);
}
/**
* Hook for alarm control panel entities (Alarmo)
*/
export function useAlarm(entityId: string) {
const entity = useEntity(entityId);
return useMemo(() => {
if (!entity) return null;
return {
state: entity.state,
isDisarmed: entity.state === 'disarmed',
isArmedHome: entity.state === 'armed_home',
isArmedAway: entity.state === 'armed_away',
isArmedNight: entity.state === 'armed_night',
isPending: entity.state === 'pending' || entity.state === 'arming',
isTriggered: entity.state === 'triggered',
codeRequired: entity.attributes.code_arm_required as boolean | undefined,
codeFormat: entity.attributes.code_format as string | undefined,
changedBy: entity.attributes.changed_by as string | undefined,
openSensors: entity.attributes.open_sensors as Record<string, string> | undefined,
friendlyName: entity.attributes.friendly_name as string | undefined,
};
}, [entity]);
}
/**
* Hook for binary sensor entities
*/
export function useBinarySensor(entityId: string) {
const entity = useEntity(entityId);
return useMemo(() => {
if (!entity) return null;
return {
state: entity.state,
isOn: entity.state === 'on',
isOff: entity.state === 'off',
deviceClass: entity.attributes.device_class as string | undefined,
friendlyName: entity.attributes.friendly_name as string | undefined,
};
}, [entity]);
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useCallback } from 'react';
import { useHAStore, useConnectionState } from '@/stores/haStore';
/**
* Hook for managing Home Assistant connection
*/
export function useHomeAssistant() {
const connectionState = useConnectionState();
const connect = useHAStore((state) => state.connect);
const disconnect = useHAStore((state) => state.disconnect);
const accessToken = useHAStore((state) => state.accessToken);
// Auto-connect if token is available
useEffect(() => {
if (accessToken && connectionState === 'disconnected') {
connect(accessToken);
}
return () => {
// Don't disconnect on unmount - let the store manage lifecycle
};
}, [accessToken, connectionState, connect]);
const reconnect = useCallback(() => {
if (accessToken) {
disconnect();
setTimeout(() => {
connect(accessToken);
}, 1000);
}
}, [accessToken, connect, disconnect]);
return {
connectionState,
isConnected: connectionState === 'connected',
isConnecting: connectionState === 'connecting',
isError: connectionState === 'error',
connect,
disconnect,
reconnect,
};
}

View File

@@ -0,0 +1,170 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSettingsStore } from '@/stores/settingsStore';
interface UseLocalPresenceOptions {
enabled?: boolean;
confidenceThreshold?: number;
checkIntervalMs?: number;
onPersonDetected?: () => void;
onPersonCleared?: () => void;
}
/**
* Hook for local presence detection using TensorFlow.js COCO-SSD model.
* Uses the Kitchen_Panel go2rtc stream instead of direct webcam access
* (since mediamtx uses the webcam for streaming).
*/
export function useLocalPresence({
enabled = true,
confidenceThreshold = 0.5,
checkIntervalMs = 2000,
onPersonDetected,
onPersonCleared,
}: UseLocalPresenceOptions = {}) {
const [isDetecting, setIsDetecting] = useState(false);
const [hasPersonPresent, setHasPersonPresent] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const modelRef = useRef<any>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const noPersonCountRef = useRef(0);
const wasPersonPresentRef = useRef(false);
const go2rtcUrl = useSettingsStore((state) => state.config.go2rtcUrl);
const NO_PERSON_THRESHOLD = 3; // Frames without person before clearing
const detectPerson = useCallback(async () => {
if (!modelRef.current || !canvasRef.current || !imgRef.current) return;
const ctx = canvasRef.current.getContext('2d');
if (!ctx) return;
try {
// Fetch a frame from go2rtc MJPEG snapshot
const snapshotUrl = `${go2rtcUrl}/api/frame.jpeg?src=Kitchen_Panel&t=${Date.now()}`;
// Load image
await new Promise<void>((resolve, reject) => {
if (!imgRef.current) return reject('No image element');
imgRef.current.crossOrigin = 'anonymous';
imgRef.current.onload = () => resolve();
imgRef.current.onerror = () => reject('Failed to load frame');
imgRef.current.src = snapshotUrl;
});
// Draw to canvas
ctx.drawImage(imgRef.current, 0, 0, 320, 240);
// Run detection
const predictions = await modelRef.current.detect(canvasRef.current);
// Check for person with sufficient confidence
const personDetection = predictions.find(
(p: any) => p.class === 'person' && p.score >= confidenceThreshold
);
if (personDetection) {
noPersonCountRef.current = 0;
if (!wasPersonPresentRef.current) {
wasPersonPresentRef.current = true;
setHasPersonPresent(true);
console.log('Local presence: Person detected via go2rtc stream');
onPersonDetected?.();
}
} else {
noPersonCountRef.current++;
if (wasPersonPresentRef.current && noPersonCountRef.current >= NO_PERSON_THRESHOLD) {
wasPersonPresentRef.current = false;
setHasPersonPresent(false);
console.log('Local presence: Person cleared');
onPersonCleared?.();
}
}
} catch (err) {
// Silently fail on individual frame errors - stream might be briefly unavailable
console.debug('Detection frame error:', err);
}
}, [go2rtcUrl, confidenceThreshold, onPersonDetected, onPersonCleared]);
const startDetection = useCallback(async () => {
if (isDetecting) return;
try {
setError(null);
// Dynamically import TensorFlow.js and COCO-SSD
const tf = await import('@tensorflow/tfjs');
const cocoSsd = await import('@tensorflow-models/coco-ssd');
// Set backend
await tf.setBackend('webgl');
await tf.ready();
// Load model (lite version for speed)
console.log('Loading COCO-SSD model for presence detection...');
modelRef.current = await cocoSsd.load({
base: 'lite_mobilenet_v2',
});
console.log('Model loaded');
// Create hidden image element for loading frames
imgRef.current = document.createElement('img');
// Create canvas for processing
canvasRef.current = document.createElement('canvas');
canvasRef.current.width = 320;
canvasRef.current.height = 240;
// Start detection loop
setIsDetecting(true);
intervalRef.current = setInterval(detectPerson, checkIntervalMs);
console.log('Local presence detection started (using go2rtc Kitchen_Panel stream)');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start presence detection';
console.error('Presence detection error:', message);
setError(message);
}
}, [isDetecting, checkIntervalMs, detectPerson]);
const stopDetection = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
canvasRef.current = null;
imgRef.current = null;
modelRef.current = null;
setIsDetecting(false);
setHasPersonPresent(false);
wasPersonPresentRef.current = false;
noPersonCountRef.current = 0;
console.log('Local presence detection stopped');
}, []);
// Start/stop based on enabled prop
useEffect(() => {
if (enabled) {
startDetection();
} else {
stopDetection();
}
return () => {
stopDetection();
};
}, [enabled, startDetection, stopDetection]);
return {
isDetecting,
hasPersonPresent,
error,
startDetection,
stopDetection,
};
}

View File

@@ -0,0 +1,203 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSettingsStore } from '@/stores/settingsStore';
interface UseSimpleMotionOptions {
enabled?: boolean;
sensitivityThreshold?: number; // 0-100, higher = more sensitive
checkIntervalMs?: number;
onMotionDetected?: () => void;
}
/**
* Lightweight motion detection using frame differencing.
* Compares consecutive frames from go2rtc stream to detect movement.
* Much lighter than TensorFlow.js - just pixel comparison.
*/
export function useSimpleMotion({
enabled = true,
sensitivityThreshold = 15, // % of pixels that must change
checkIntervalMs = 3000,
onMotionDetected,
}: UseSimpleMotionOptions = {}) {
const [isDetecting, setIsDetecting] = useState(false);
const [hasMotion, setHasMotion] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const prevImageDataRef = useRef<ImageData | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const noMotionCountRef = useRef(0);
const wasMotionRef = useRef(false);
const isProcessingRef = useRef(false); // Prevent overlapping requests
const abortControllerRef = useRef<AbortController | null>(null);
const go2rtcUrl = useSettingsStore((state) => state.config.go2rtcUrl);
const NO_MOTION_THRESHOLD = 3; // Frames without motion before clearing
const detectMotion = useCallback(async () => {
// Skip if already processing (prevents memory buildup from overlapping requests)
if (isProcessingRef.current) {
return;
}
if (!canvasRef.current) return;
const ctx = canvasRef.current.getContext('2d');
if (!ctx) return;
isProcessingRef.current = true;
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
// Fetch a frame from go2rtc MJPEG snapshot
const snapshotUrl = `${go2rtcUrl}/api/frame.jpeg?src=Kitchen_Panel&t=${Date.now()}`;
const response = await fetch(snapshotUrl, {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error('Failed to fetch frame');
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
// Draw new frame (scaled down for faster processing)
ctx.drawImage(bitmap, 0, 0, 160, 120);
bitmap.close(); // Release bitmap memory immediately
// Get current frame data
const currentData = ctx.getImageData(0, 0, 160, 120);
if (!prevImageDataRef.current) {
// First frame - just store and skip detection
prevImageDataRef.current = currentData;
isProcessingRef.current = false;
return;
}
// Compare frames
const prevData = prevImageDataRef.current;
let changedPixels = 0;
const totalPixels = 160 * 120;
const threshold = 30; // Per-pixel difference threshold
for (let i = 0; i < currentData.data.length; i += 4) {
const rDiff = Math.abs(currentData.data[i] - prevData.data[i]);
const gDiff = Math.abs(currentData.data[i + 1] - prevData.data[i + 1]);
const bDiff = Math.abs(currentData.data[i + 2] - prevData.data[i + 2]);
if (rDiff > threshold || gDiff > threshold || bDiff > threshold) {
changedPixels++;
}
}
// Store current as previous for next comparison
prevImageDataRef.current = currentData;
const changePercent = (changedPixels / totalPixels) * 100;
const motionDetected = changePercent > sensitivityThreshold;
if (motionDetected) {
noMotionCountRef.current = 0;
if (!wasMotionRef.current) {
wasMotionRef.current = true;
setHasMotion(true);
console.log(`[Motion] DETECTED: ${changePercent.toFixed(1)}% - waking screen`);
onMotionDetected?.();
}
} else {
noMotionCountRef.current++;
if (wasMotionRef.current && noMotionCountRef.current >= NO_MOTION_THRESHOLD) {
wasMotionRef.current = false;
setHasMotion(false);
}
}
} catch (err) {
// Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') {
return;
}
// Silently fail on individual frame errors
console.debug('Motion detection frame error:', err);
} finally {
isProcessingRef.current = false;
}
}, [go2rtcUrl, sensitivityThreshold, onMotionDetected]);
const startDetection = useCallback(() => {
if (isDetecting) return;
try {
setError(null);
// Create single canvas for processing (small for speed)
canvasRef.current = document.createElement('canvas');
canvasRef.current.width = 160;
canvasRef.current.height = 120;
prevImageDataRef.current = null;
isProcessingRef.current = false;
// Start detection loop
setIsDetecting(true);
intervalRef.current = setInterval(detectMotion, checkIntervalMs);
console.log('Motion detection started');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start motion detection';
console.error('Motion detection error:', message);
setError(message);
}
}, [isDetecting, checkIntervalMs, detectMotion]);
const stopDetection = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
canvasRef.current = null;
prevImageDataRef.current = null;
setIsDetecting(false);
setHasMotion(false);
wasMotionRef.current = false;
noMotionCountRef.current = 0;
isProcessingRef.current = false;
console.log('Motion detection stopped');
}, []);
// Start/stop based on enabled prop
useEffect(() => {
if (enabled) {
startDetection();
} else {
stopDetection();
}
return () => {
stopDetection();
};
}, [enabled, startDetection, stopDetection]);
return {
isDetecting,
hasMotion,
error,
startDetection,
stopDetection,
};
}

209
src/hooks/useTodo.ts Normal file
View File

@@ -0,0 +1,209 @@
import { useCallback, useState, useEffect } from 'react';
import { useEntity } from './useEntity';
import { haConnection } from '@/services/homeAssistant';
import { useSettingsStore } from '@/stores/settingsStore';
export interface TodoItem {
uid: string;
summary: string;
status: 'needs_action' | 'completed';
due?: string;
description?: string;
}
export function useTodo() {
const todoEntityId = useSettingsStore((state) => state.config.todoList);
const entity = useEntity(todoEntityId || '');
const [items, setItems] = useState<TodoItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
if (!todoEntityId) return;
setIsLoading(true);
setError(null);
try {
const connection = haConnection.getConnection();
if (!connection) {
throw new Error('Not connected to Home Assistant');
}
// Use the todo.get_items service
const result = await connection.sendMessagePromise<Record<string, unknown>>({
type: 'call_service',
domain: 'todo',
service: 'get_items',
target: { entity_id: todoEntityId },
return_response: true,
});
// Extract items from response - handle different structures
let entityItems: TodoItem[] = [];
// Structure 1: { response: { "todo.entity": { items: [...] } } }
const respWrapper = result?.response as Record<string, { items?: TodoItem[] }> | undefined;
if (respWrapper?.[todoEntityId]?.items) {
entityItems = respWrapper[todoEntityId].items;
}
// Structure 2: { "todo.entity": { items: [...] } }
else if ((result?.[todoEntityId] as { items?: TodoItem[] })?.items) {
entityItems = (result[todoEntityId] as { items: TodoItem[] }).items;
}
setItems(entityItems);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch todo items';
setError(message);
console.error('Failed to fetch todo items:', err);
} finally {
setIsLoading(false);
}
}, [todoEntityId]);
// Fetch items when entity changes and poll every 30 seconds
useEffect(() => {
if (entity) {
fetchItems();
// Poll every 30 seconds for updates
const interval = setInterval(fetchItems, 30000);
return () => clearInterval(interval);
}
}, [entity, fetchItems]);
const addItem = useCallback(async (summary: string) => {
if (!todoEntityId) return;
setIsLoading(true);
setError(null);
try {
await haConnection.callService('todo', 'add_item', { item: summary }, { entity_id: todoEntityId });
// Refresh items
await fetchItems();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add todo item';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [todoEntityId, fetchItems]);
const completeItem = useCallback(async (uid: string) => {
if (!todoEntityId) return;
setIsLoading(true);
setError(null);
try {
// Find the item to get its summary
const item = items.find((i) => i.uid === uid);
if (!item) {
throw new Error('Item not found');
}
await haConnection.callService(
'todo',
'update_item',
{ item: item.summary, status: 'completed' },
{ entity_id: todoEntityId }
);
// Refresh items
await fetchItems();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to complete todo item';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [todoEntityId, items, fetchItems]);
const uncompleteItem = useCallback(async (uid: string) => {
if (!todoEntityId) return;
setIsLoading(true);
setError(null);
try {
const item = items.find((i) => i.uid === uid);
if (!item) {
throw new Error('Item not found');
}
await haConnection.callService(
'todo',
'update_item',
{ item: item.summary, status: 'needs_action' },
{ entity_id: todoEntityId }
);
// Refresh items
await fetchItems();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to uncomplete todo item';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [todoEntityId, items, fetchItems]);
const removeItem = useCallback(async (uid: string) => {
if (!todoEntityId) return;
setIsLoading(true);
setError(null);
try {
const item = items.find((i) => i.uid === uid);
if (!item) {
throw new Error('Item not found');
}
await haConnection.callService(
'todo',
'remove_item',
{ item: item.summary },
{ entity_id: todoEntityId }
);
// Refresh items
await fetchItems();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove todo item';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [todoEntityId, items, fetchItems]);
const clearError = useCallback(() => setError(null), []);
// Computed values
const activeItems = items.filter((item) => item.status === 'needs_action');
const completedItems = items.filter((item) => item.status === 'completed');
return {
// State
items,
activeItems,
completedItems,
isLoading,
error,
// Actions
fetchItems,
addItem,
completeItem,
uncompleteItem,
removeItem,
clearError,
};
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1 @@
export { Go2RTCWebRTC, Go2RTCMSE, getGo2RTCStreams, getStreamInfo } from './webrtc';

View File

@@ -0,0 +1,220 @@
import { env } from '@/config/environment';
export interface WebRTCStreamConfig {
stream: string;
iceServers?: RTCIceServer[];
}
export class Go2RTCWebRTC {
private peerConnection: RTCPeerConnection | null = null;
private mediaStream: MediaStream | null = null;
private streamName: string;
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
constructor(streamName: string) {
this.streamName = streamName;
}
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
this.onTrackCallback = onTrack;
// Create peer connection with STUN servers
const config: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
],
};
this.peerConnection = new RTCPeerConnection(config);
// Handle incoming tracks
this.peerConnection.ontrack = (event) => {
console.log(`Received track for ${this.streamName}:`, event.track.kind);
if (event.streams && event.streams[0]) {
this.mediaStream = event.streams[0];
this.onTrackCallback?.(this.mediaStream);
}
};
// Handle ICE candidates
this.peerConnection.onicecandidate = async (event) => {
if (event.candidate) {
// Send ICE candidate to go2rtc
await this.sendCandidate(event.candidate);
}
};
// Handle connection state changes
this.peerConnection.onconnectionstatechange = () => {
console.log(`WebRTC connection state for ${this.streamName}:`, this.peerConnection?.connectionState);
};
// Add transceiver for video (receive only)
this.peerConnection.addTransceiver('video', { direction: 'recvonly' });
this.peerConnection.addTransceiver('audio', { direction: 'recvonly' });
// Create and set local description
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
// Send offer to go2rtc and get answer
const answer = await this.sendOffer(offer);
await this.peerConnection.setRemoteDescription(answer);
}
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
},
body: offer.sdp,
});
if (!response.ok) {
throw new Error(`Failed to get WebRTC answer: ${response.status} ${response.statusText}`);
}
const answerSdp = await response.text();
return {
type: 'answer',
sdp: answerSdp,
};
}
private async sendCandidate(candidate: RTCIceCandidate): Promise<void> {
// go2rtc handles ICE candidates internally through the initial exchange
// Most setups don't need explicit candidate forwarding
console.log(`ICE candidate for ${this.streamName}:`, candidate.candidate);
}
disconnect(): void {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
this.onTrackCallback = null;
}
getMediaStream(): MediaStream | null {
return this.mediaStream;
}
isConnected(): boolean {
return this.peerConnection?.connectionState === 'connected';
}
}
/**
* Alternative: MSE (Media Source Extensions) streaming
* This provides lower latency than HLS but requires more browser support
*/
export class Go2RTCMSE {
private mediaSource: MediaSource | null = null;
private sourceBuffer: SourceBuffer | null = null;
private websocket: WebSocket | null = null;
private streamName: string;
private videoElement: HTMLVideoElement | null = null;
constructor(streamName: string) {
this.streamName = streamName;
}
async connect(videoElement: HTMLVideoElement): Promise<void> {
this.videoElement = videoElement;
// Create MediaSource
this.mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(this.mediaSource);
await new Promise<void>((resolve) => {
this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true });
});
// Connect to go2rtc WebSocket for MSE stream
const wsUrl = `${env.go2rtcUrl.replace('http', 'ws')}/api/ws?src=${encodeURIComponent(this.streamName)}`;
this.websocket = new WebSocket(wsUrl);
this.websocket.binaryType = 'arraybuffer';
this.websocket.onmessage = (event) => {
if (typeof event.data === 'string') {
// Codec info
const msg = JSON.parse(event.data);
if (msg.type === 'mse' && msg.value) {
this.initSourceBuffer(msg.value);
}
} else if (this.sourceBuffer && !this.sourceBuffer.updating) {
// Media data
this.sourceBuffer.appendBuffer(event.data);
}
};
this.websocket.onerror = (error) => {
console.error(`MSE WebSocket error for ${this.streamName}:`, error);
};
}
private initSourceBuffer(codec: string): void {
if (!this.mediaSource || this.sourceBuffer) return;
try {
this.sourceBuffer = this.mediaSource.addSourceBuffer(codec);
this.sourceBuffer.mode = 'segments';
} catch (error) {
console.error(`Failed to create source buffer for ${this.streamName}:`, error);
}
}
disconnect(): void {
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
if (this.mediaSource && this.mediaSource.readyState === 'open') {
try {
this.mediaSource.endOfStream();
} catch {
// Ignore errors during cleanup
}
}
if (this.videoElement) {
this.videoElement.src = '';
this.videoElement = null;
}
this.sourceBuffer = null;
this.mediaSource = null;
}
}
/**
* Get available streams from go2rtc
*/
export async function getGo2RTCStreams(): Promise<Record<string, unknown>> {
const response = await fetch(`${env.go2rtcUrl}/api/streams`);
if (!response.ok) {
throw new Error(`Failed to get streams: ${response.status}`);
}
return response.json();
}
/**
* Get stream info from go2rtc
*/
export async function getStreamInfo(streamName: string): Promise<unknown> {
const response = await fetch(`${env.go2rtcUrl}/api/streams?src=${encodeURIComponent(streamName)}`);
if (!response.ok) {
throw new Error(`Failed to get stream info: ${response.status}`);
}
return response.json();
}

View File

@@ -0,0 +1,143 @@
import { googleCalendarAuth } from './auth';
import { startOfMonth, endOfMonth, format } from 'date-fns';
const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
export interface CalendarEvent {
id: string;
summary: string;
description?: string;
location?: string;
start: {
dateTime?: string;
date?: string;
timeZone?: string;
};
end: {
dateTime?: string;
date?: string;
timeZone?: string;
};
status: string;
colorId?: string;
}
export interface Calendar {
id: string;
summary: string;
description?: string;
primary?: boolean;
backgroundColor?: string;
foregroundColor?: string;
}
export interface CalendarListResponse {
items: Calendar[];
}
export interface EventsListResponse {
items: CalendarEvent[];
nextPageToken?: string;
}
async function fetchWithAuth(url: string): Promise<Response> {
const token = await googleCalendarAuth.getAccessToken();
if (!token) {
throw new Error('Not authenticated with Google Calendar');
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 401) {
// Token might be invalid, clear and throw
googleCalendarAuth.clearTokens();
throw new Error('Authentication expired, please re-authenticate');
}
if (!response.ok) {
throw new Error(`Calendar API error: ${response.status}`);
}
return response;
}
export async function getCalendarList(): Promise<Calendar[]> {
const response = await fetchWithAuth(`${CALENDAR_API_BASE}/users/me/calendarList`);
const data: CalendarListResponse = await response.json();
return data.items;
}
export async function getEventsForMonth(
calendarId: string = 'primary',
date: Date = new Date()
): Promise<CalendarEvent[]> {
const timeMin = startOfMonth(date).toISOString();
const timeMax = endOfMonth(date).toISOString();
const params = new URLSearchParams({
timeMin,
timeMax,
singleEvents: 'true',
orderBy: 'startTime',
maxResults: '100',
});
const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
const response = await fetchWithAuth(url);
const data: EventsListResponse = await response.json();
return data.items;
}
export async function getEventsForDay(
calendarId: string = 'primary',
date: Date = new Date()
): Promise<CalendarEvent[]> {
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
const params = new URLSearchParams({
timeMin: dayStart.toISOString(),
timeMax: dayEnd.toISOString(),
singleEvents: 'true',
orderBy: 'startTime',
});
const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
const response = await fetchWithAuth(url);
const data: EventsListResponse = await response.json();
return data.items;
}
export function getEventTime(event: CalendarEvent): { start: Date; end: Date; allDay: boolean } {
const allDay = !!event.start.date;
let start: Date;
let end: Date;
if (allDay) {
start = new Date(event.start.date!);
end = new Date(event.end.date!);
} else {
start = new Date(event.start.dateTime!);
end = new Date(event.end.dateTime!);
}
return { start, end, allDay };
}
export function formatEventTime(event: CalendarEvent): string {
const { start, allDay } = getEventTime(event);
if (allDay) {
return 'All day';
}
return format(start, 'h:mm a');
}

View File

@@ -0,0 +1,146 @@
import { env } from '@/config/environment';
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
const REDIRECT_URI = 'http://localhost:5173/oauth/callback';
interface TokenData {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
scope: string;
}
class GoogleCalendarAuth {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private expiresAt: number = 0;
constructor() {
// Load saved tokens from localStorage
this.loadTokens();
}
private loadTokens(): void {
try {
const stored = localStorage.getItem('google_calendar_tokens');
if (stored) {
const data = JSON.parse(stored);
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
this.expiresAt = data.expiresAt;
}
} catch {
// Ignore errors
}
}
private saveTokens(): void {
try {
localStorage.setItem(
'google_calendar_tokens',
JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiresAt: this.expiresAt,
})
);
} catch {
// Ignore errors
}
}
getAuthUrl(): string {
const params = new URLSearchParams({
client_id: env.googleClientId,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: SCOPES.join(' '),
access_type: 'offline',
prompt: 'consent',
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
async handleCallback(code: string): Promise<void> {
// Note: In a production app, the token exchange should happen on a backend
// to protect the client secret. For this kiosk app, we'll use a simpler flow.
// You would need to set up a small backend or use a service like Firebase Auth.
// For now, we'll assume tokens are exchanged via a backend proxy
const response = await fetch('/api/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, redirect_uri: REDIRECT_URI }),
});
if (!response.ok) {
throw new Error('Failed to exchange authorization code');
}
const data: TokenData = await response.json();
this.setTokens(data);
}
setTokens(data: TokenData): void {
this.accessToken = data.access_token;
if (data.refresh_token) {
this.refreshToken = data.refresh_token;
}
this.expiresAt = Date.now() + data.expires_in * 1000;
this.saveTokens();
}
async getAccessToken(): Promise<string | null> {
if (!this.accessToken) {
return null;
}
// Check if token is expired or about to expire (5 min buffer)
if (Date.now() >= this.expiresAt - 300000) {
await this.refreshAccessToken();
}
return this.accessToken;
}
private async refreshAccessToken(): Promise<void> {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
// Again, this should be done via backend in production
const response = await fetch('/api/oauth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: this.refreshToken }),
});
if (!response.ok) {
// Clear tokens on refresh failure
this.clearTokens();
throw new Error('Failed to refresh access token');
}
const data: TokenData = await response.json();
this.setTokens(data);
}
clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
localStorage.removeItem('google_calendar_tokens');
}
isAuthenticated(): boolean {
return !!this.accessToken && !!this.refreshToken;
}
}
export const googleCalendarAuth = new GoogleCalendarAuth();

View File

@@ -0,0 +1,10 @@
export { googleCalendarAuth } from './auth';
export {
getCalendarList,
getEventsForMonth,
getEventsForDay,
getEventTime,
formatEventTime,
type CalendarEvent,
type Calendar,
} from './api';

View File

@@ -0,0 +1,159 @@
import {
createConnection,
subscribeEntities,
callService,
Connection,
HassEntities,
HassEntity,
Auth,
createLongLivedTokenAuth,
} from 'home-assistant-js-websocket';
import { env } from '@/config/environment';
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
export interface HAConnectionEvents {
onStateChange: (state: ConnectionState) => void;
onEntitiesChange: (entities: HassEntities) => void;
onError: (error: Error) => void;
}
class HomeAssistantConnection {
private connection: Connection | null = null;
private auth: Auth | null = null;
private entities: HassEntities = {};
private state: ConnectionState = 'disconnected';
private events: HAConnectionEvents | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 5000;
private unsubscribeEntities: (() => void) | null = null;
async connect(accessToken: string, events: HAConnectionEvents): Promise<void> {
if (this.state === 'connecting' || this.state === 'connected') {
return;
}
this.events = events;
this.setState('connecting');
try {
// Get HA URL from localStorage or fall back to env
const storedUrl = localStorage.getItem('ha_url') || env.haUrl;
// Remove trailing slash - library handles WebSocket conversion internally
const hassUrl = storedUrl.replace(/\/$/, '');
console.log('Connecting to Home Assistant at:', hassUrl);
// Create authentication - pass HTTP URL, library converts to WebSocket
this.auth = createLongLivedTokenAuth(hassUrl, accessToken);
// Create connection
this.connection = await createConnection({ auth: this.auth });
// Set up connection event handlers
this.connection.addEventListener('ready', () => {
this.reconnectAttempts = 0;
this.setState('connected');
});
this.connection.addEventListener('disconnected', () => {
this.setState('disconnected');
this.scheduleReconnect();
});
this.connection.addEventListener('reconnect-error', () => {
this.setState('error');
this.scheduleReconnect();
});
// Subscribe to entity state changes
this.unsubscribeEntities = subscribeEntities(this.connection, (entities) => {
this.entities = entities;
this.events?.onEntitiesChange(entities);
});
this.setState('connected');
} catch (error) {
console.error('Failed to connect to Home Assistant:', error);
this.setState('error');
this.events?.onError(error instanceof Error ? error : new Error(String(error)));
this.scheduleReconnect();
}
}
disconnect(): void {
if (this.unsubscribeEntities) {
this.unsubscribeEntities();
this.unsubscribeEntities = null;
}
if (this.connection) {
this.connection.close();
this.connection = null;
}
this.auth = null;
this.entities = {};
this.setState('disconnected');
}
private setState(state: ConnectionState): void {
this.state = state;
this.events?.onStateChange(state);
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
setTimeout(() => {
if (this.state !== 'connected' && this.auth) {
this.connect(this.auth.data.access_token, this.events!);
}
}, delay);
}
getState(): ConnectionState {
return this.state;
}
getEntities(): HassEntities {
return this.entities;
}
getEntity(entityId: string): HassEntity | undefined {
return this.entities[entityId];
}
isConnected(): boolean {
return this.state === 'connected' && this.connection !== null;
}
async callService(
domain: string,
service: string,
serviceData?: Record<string, unknown>,
target?: { entity_id: string | string[] }
): Promise<void> {
if (!this.connection) {
throw new Error('Not connected to Home Assistant');
}
await callService(this.connection, domain, service, serviceData, target);
}
getConnection(): Connection | null {
return this.connection;
}
}
// Singleton instance
export const haConnection = new HomeAssistantConnection();

View File

@@ -0,0 +1,2 @@
export { haConnection, type ConnectionState, type HAConnectionEvents } from './connection';
export { climateServices, lightServices, lockServices, alarmServices, todoServices } from './services';

View File

@@ -0,0 +1,208 @@
import { haConnection } from './connection';
/**
* Climate/Thermostat Services
*/
export const climateServices = {
async setTemperature(entityId: string, temperature: number): Promise<void> {
await haConnection.callService('climate', 'set_temperature', { temperature }, { entity_id: entityId });
},
async setTemperatureRange(entityId: string, targetTempLow: number, targetTempHigh: number): Promise<void> {
await haConnection.callService('climate', 'set_temperature', {
target_temp_low: targetTempLow,
target_temp_high: targetTempHigh,
}, { entity_id: entityId });
},
async setHvacMode(entityId: string, mode: 'off' | 'heat' | 'cool' | 'heat_cool' | 'auto'): Promise<void> {
await haConnection.callService('climate', 'set_hvac_mode', { hvac_mode: mode }, { entity_id: entityId });
},
async turnOn(entityId: string): Promise<void> {
await haConnection.callService('climate', 'turn_on', {}, { entity_id: entityId });
},
async turnOff(entityId: string): Promise<void> {
await haConnection.callService('climate', 'turn_off', {}, { entity_id: entityId });
},
};
/**
* Light Services
*/
export const lightServices = {
async turnOn(entityId: string, brightness?: number): Promise<void> {
const serviceData: Record<string, unknown> = {};
if (brightness !== undefined) {
serviceData.brightness_pct = brightness;
}
await haConnection.callService('light', 'turn_on', serviceData, { entity_id: entityId });
},
async turnOff(entityId: string): Promise<void> {
await haConnection.callService('light', 'turn_off', {}, { entity_id: entityId });
},
async toggle(entityId: string): Promise<void> {
await haConnection.callService('light', 'toggle', {}, { entity_id: entityId });
},
async setBrightness(entityId: string, brightness: number): Promise<void> {
await haConnection.callService('light', 'turn_on', { brightness_pct: brightness }, { entity_id: entityId });
},
};
/**
* Lock Services
*/
export const lockServices = {
async lock(entityId: string): Promise<void> {
await haConnection.callService('lock', 'lock', {}, { entity_id: entityId });
},
async unlock(entityId: string): Promise<void> {
await haConnection.callService('lock', 'unlock', {}, { entity_id: entityId });
},
};
/**
* Alarm Control Panel Services (Alarmo)
*/
export const alarmServices = {
async armHome(entityId: string, code?: string): Promise<void> {
const serviceData: Record<string, unknown> = {};
if (code) serviceData.code = code;
await haConnection.callService('alarm_control_panel', 'alarm_arm_home', serviceData, { entity_id: entityId });
},
async armAway(entityId: string, code?: string): Promise<void> {
const serviceData: Record<string, unknown> = {};
if (code) serviceData.code = code;
await haConnection.callService('alarm_control_panel', 'alarm_arm_away', serviceData, { entity_id: entityId });
},
async armNight(entityId: string, code?: string): Promise<void> {
const serviceData: Record<string, unknown> = {};
if (code) serviceData.code = code;
await haConnection.callService('alarm_control_panel', 'alarm_arm_night', serviceData, { entity_id: entityId });
},
async disarm(entityId: string, code: string): Promise<void> {
await haConnection.callService('alarm_control_panel', 'alarm_disarm', { code }, { entity_id: entityId });
},
async trigger(entityId: string): Promise<void> {
await haConnection.callService('alarm_control_panel', 'alarm_trigger', {}, { entity_id: entityId });
},
};
/**
* Todo List Services
*/
export const todoServices = {
async getItems(entityId: string): Promise<void> {
// This uses a different API - we'll handle it through the connection
const connection = haConnection.getConnection();
if (!connection) throw new Error('Not connected');
// Use the todo.get_items service or state attributes
await connection.sendMessagePromise({
type: 'call_service',
domain: 'todo',
service: 'get_items',
target: { entity_id: entityId },
});
},
async addItem(entityId: string, item: string): Promise<void> {
await haConnection.callService('todo', 'add_item', { item }, { entity_id: entityId });
},
async updateItem(entityId: string, item: string, status: 'needs_action' | 'completed'): Promise<void> {
await haConnection.callService('todo', 'update_item', { item, status }, { entity_id: entityId });
},
async removeItem(entityId: string, item: string): Promise<void> {
await haConnection.callService('todo', 'remove_item', { item }, { entity_id: entityId });
},
};
/**
* Calendar Services
*/
export interface CalendarEvent {
start: string;
end: string;
summary: string;
description?: string;
location?: string;
uid?: string;
recurrence_id?: string;
rrule?: string;
}
export interface CreateEventParams {
summary: string;
start_date_time?: string;
end_date_time?: string;
start_date?: string;
end_date?: string;
description?: string;
location?: string;
[key: string]: string | undefined;
}
export const calendarServices = {
async getEvents(
entityId: string,
startDateTime: string,
endDateTime: string
): Promise<{ events: CalendarEvent[] }> {
const connection = haConnection.getConnection();
if (!connection) throw new Error('Not connected');
const response = await connection.sendMessagePromise<Record<string, unknown>>({
type: 'call_service',
domain: 'calendar',
service: 'get_events',
target: { entity_id: entityId },
service_data: {
start_date_time: startDateTime,
end_date_time: endDateTime,
},
return_response: true,
});
// Extract events from HA WebSocket response
// HA returns: { context: {...}, response: { "calendar.entity_id": { events: [...] } } }
let events: CalendarEvent[] = [];
const wsResp = response?.response as Record<string, { events?: CalendarEvent[] }> | undefined;
if (wsResp?.[entityId]?.events) {
events = wsResp[entityId].events;
}
// Fallback: direct response structure { "calendar.entity_id": { events: [...] } }
else if ((response?.[entityId] as { events?: CalendarEvent[] })?.events) {
events = (response[entityId] as { events: CalendarEvent[] }).events;
}
if (events.length === 0) {
console.log('Calendar: no events found for', entityId, 'keys:', Object.keys(response || {}));
}
return { events };
},
async createEvent(entityId: string, params: CreateEventParams): Promise<void> {
await haConnection.callService('calendar', 'create_event', params, { entity_id: entityId });
},
async deleteEvent(entityId: string, uid: string, recurrenceId?: string): Promise<void> {
const serviceData: Record<string, string> = { uid };
if (recurrenceId) {
serviceData.recurrence_id = recurrenceId;
}
await haConnection.callService('calendar', 'delete_event', serviceData, { entity_id: entityId });
},
};

75
src/stores/haStore.ts Normal file
View File

@@ -0,0 +1,75 @@
import { create } from 'zustand';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { haConnection, ConnectionState } from '@/services/homeAssistant';
interface HAState {
// Connection state
connectionState: ConnectionState;
accessToken: string | null;
// Entities
entities: HassEntities;
// Actions
connect: (accessToken: string) => Promise<void>;
disconnect: () => void;
setAccessToken: (token: string) => void;
// Selectors (computed values as functions)
getEntity: (entityId: string) => HassEntity | undefined;
getEntityState: (entityId: string) => string | undefined;
getEntityAttribute: <T>(entityId: string, attribute: string) => T | undefined;
}
export const useHAStore = create<HAState>((set, get) => ({
connectionState: 'disconnected',
accessToken: null,
entities: {},
connect: async (accessToken: string) => {
set({ accessToken });
await haConnection.connect(accessToken, {
onStateChange: (state) => {
set({ connectionState: state });
},
onEntitiesChange: (entities) => {
set({ entities });
},
onError: (error) => {
console.error('Home Assistant connection error:', error);
},
});
},
disconnect: () => {
haConnection.disconnect();
set({
connectionState: 'disconnected',
entities: {},
});
},
setAccessToken: (token: string) => {
set({ accessToken: token });
},
getEntity: (entityId: string) => {
return get().entities[entityId];
},
getEntityState: (entityId: string) => {
return get().entities[entityId]?.state;
},
getEntityAttribute: <T>(entityId: string, attribute: string) => {
return get().entities[entityId]?.attributes?.[attribute] as T | undefined;
},
}));
// Selector hooks for React components
export const useConnectionState = () => useHAStore((state) => state.connectionState);
export const useEntity = (entityId: string) => useHAStore((state) => state.entities[entityId]);
export const useEntityState = (entityId: string) => useHAStore((state) => state.entities[entityId]?.state);
export const useEntityAttribute = <T>(entityId: string, attribute: string) =>
useHAStore((state) => state.entities[entityId]?.attributes?.[attribute] as T | undefined);

15
src/stores/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export { useHAStore, useConnectionState, useEntity, useEntityState, useEntityAttribute } from './haStore';
export { useUIStore, useCameraOverlay, usePersonAlert, useAlarmoKeypad } from './uiStore';
export {
useSettingsStore,
useConfig,
useThermostats,
useLights,
useLocks,
useAlarmEntity,
useCalendarEntity,
useTodoEntity,
usePeople,
useCameras,
useLightsByRoom,
} from './settingsStore';

259
src/stores/settingsStore.ts Normal file
View File

@@ -0,0 +1,259 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Increment this when default cameras/config changes to force refresh
const CONFIG_VERSION = 8;
export interface ThermostatConfig {
entityId: string;
name: string;
location?: string;
}
export interface LightConfig {
entityId: string;
name: string;
room: string;
}
export interface LockConfig {
entityId: string;
name: string;
}
export interface PersonConfig {
entityId: string;
name: string;
avatarUrl?: string;
}
export interface CameraConfig {
name: string;
displayName: string;
go2rtcStream: string;
frigateCamera?: string;
}
export interface DashboardConfig {
// Selected entities
thermostats: ThermostatConfig[];
lights: LightConfig[];
locks: LockConfig[];
alarm: string | null;
calendar: string | null;
todoList: string | null;
people: PersonConfig[];
packageSensor: string | null;
// Cameras (manual config for now since they come from go2rtc, not HA)
cameras: CameraConfig[];
personDetectionEntities: string[];
// Dashboard settings
go2rtcUrl: string;
frigateUrl: string;
jellyfinUrl: string;
jellyfinApiKey: string | null;
// Setup completed flag
setupCompleted: boolean;
// Config version - used to detect when defaults change
configVersion: number;
}
interface SettingsState {
config: DashboardConfig;
// Actions
setThermostats: (thermostats: ThermostatConfig[]) => void;
setLights: (lights: LightConfig[]) => void;
setLocks: (locks: LockConfig[]) => void;
setAlarm: (alarm: string | null) => void;
setCalendar: (calendar: string | null) => void;
setTodoList: (todoList: string | null) => void;
setPeople: (people: PersonConfig[]) => void;
setPackageSensor: (sensor: string | null) => void;
setCameras: (cameras: CameraConfig[]) => void;
setPersonDetectionEntities: (entities: string[]) => void;
setGo2rtcUrl: (url: string) => void;
setFrigateUrl: (url: string) => void;
setJellyfinUrl: (url: string) => void;
setJellyfinApiKey: (key: string | null) => void;
setSetupCompleted: (completed: boolean) => void;
// Bulk update
updateConfig: (partial: Partial<DashboardConfig>) => void;
resetConfig: () => void;
}
const defaultConfig: DashboardConfig = {
thermostats: [
{ entityId: 'climate.kitchen_side', name: 'Kitchen side' },
{ entityId: 'climate.master_side', name: 'Master side' },
],
lights: [
{ entityId: 'light.back_porch_master', name: 'Back porch master', room: 'Outside' },
{ entityId: 'light.master_light', name: 'Bedroom light', room: 'Master' },
{ entityId: 'light.chris_lamp', name: 'Chris lamp', room: 'Master' },
{ entityId: 'light.front_floods', name: 'Front flood lights', room: 'Outside' },
],
locks: [],
alarm: null,
calendar: 'calendar.family',
todoList: 'todo.shopping_list',
people: [],
packageSensor: 'binary_sensor.package_detected',
cameras: [
// Online cameras
{ name: 'FPE', displayName: 'Front Porch Entry', go2rtcStream: 'FPE', frigateCamera: 'FPE' },
{ name: 'Porch_Downstairs', displayName: 'Porch Downstairs', go2rtcStream: 'Porch_Downstairs', frigateCamera: 'Porch_Downstairs' },
{ name: 'Front_Porch', displayName: 'Front Porch', go2rtcStream: 'Front_Porch', frigateCamera: 'Front_Porch' },
{ name: 'Driveway_door', displayName: 'Driveway Door', go2rtcStream: 'Driveway_door', frigateCamera: 'Driveway_door' },
{ name: 'Street_side', displayName: 'Street Side', go2rtcStream: 'Street_side', frigateCamera: 'Street_side' },
{ name: 'Backyard', displayName: 'Backyard', go2rtcStream: 'Backyard', frigateCamera: 'Backyard' },
{ name: 'House_side', displayName: 'House Side', go2rtcStream: 'House_side', frigateCamera: 'House_side' },
{ name: 'Driveway', displayName: 'Driveway', go2rtcStream: 'Driveway', frigateCamera: 'Driveway' },
{ name: 'WyzePanV3', displayName: 'Wyze Pan V3', go2rtcStream: 'WyzePanV3', frigateCamera: 'WyzePanV3' },
// Thingino cameras
{ name: 'BackDoor', displayName: 'Back Door', go2rtcStream: 'BackDoor', frigateCamera: 'BackDoor' },
{ name: 'Parlor', displayName: 'Parlor', go2rtcStream: 'Parlor', frigateCamera: 'Parlor' },
{ name: 'Livingroom', displayName: 'Living Room', go2rtcStream: 'Livingroom', frigateCamera: 'Livingroom' },
],
personDetectionEntities: [
'binary_sensor.fpe_person_occupancy',
'binary_sensor.porch_downstairs_person_occupancy',
'binary_sensor.front_porch_person_occupancy',
'binary_sensor.driveway_door_person_occupancy',
'binary_sensor.driveway_person_occupancy',
'binary_sensor.backyard_person_occupancy',
'binary_sensor.street_side_person_occupancy',
'binary_sensor.house_side_person_occupancy',
],
go2rtcUrl: 'http://192.168.1.241:1985',
frigateUrl: 'http://192.168.1.241:5000',
jellyfinUrl: 'http://192.168.1.49:8096',
jellyfinApiKey: null,
setupCompleted: false,
configVersion: CONFIG_VERSION,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
config: { ...defaultConfig },
setThermostats: (thermostats) =>
set((state) => ({ config: { ...state.config, thermostats } })),
setLights: (lights) =>
set((state) => ({ config: { ...state.config, lights } })),
setLocks: (locks) =>
set((state) => ({ config: { ...state.config, locks } })),
setAlarm: (alarm) =>
set((state) => ({ config: { ...state.config, alarm } })),
setCalendar: (calendar) =>
set((state) => ({ config: { ...state.config, calendar } })),
setTodoList: (todoList) =>
set((state) => ({ config: { ...state.config, todoList } })),
setPeople: (people) =>
set((state) => ({ config: { ...state.config, people } })),
setPackageSensor: (sensor) =>
set((state) => ({ config: { ...state.config, packageSensor: sensor } })),
setCameras: (cameras) =>
set((state) => ({ config: { ...state.config, cameras } })),
setPersonDetectionEntities: (entities) =>
set((state) => ({ config: { ...state.config, personDetectionEntities: entities } })),
setGo2rtcUrl: (url) =>
set((state) => ({ config: { ...state.config, go2rtcUrl: url } })),
setFrigateUrl: (url) =>
set((state) => ({ config: { ...state.config, frigateUrl: url } })),
setJellyfinUrl: (url) =>
set((state) => ({ config: { ...state.config, jellyfinUrl: url } })),
setJellyfinApiKey: (key) =>
set((state) => ({ config: { ...state.config, jellyfinApiKey: key } })),
setSetupCompleted: (completed) =>
set((state) => ({ config: { ...state.config, setupCompleted: completed } })),
updateConfig: (partial) =>
set((state) => ({ config: { ...state.config, ...partial } })),
resetConfig: () =>
set({ config: defaultConfig }),
}),
{
name: 'dashboard-settings',
version: CONFIG_VERSION,
migrate: (persistedState: unknown, _version: number) => {
const state = persistedState as { config: DashboardConfig } | undefined;
// If no persisted state, use defaults
if (!state?.config) {
return { config: { ...defaultConfig } };
}
// Merge: start with defaults, overlay user config, then force-update certain fields
const userConfig = state.config;
return {
config: {
...defaultConfig,
// Preserve user-configured entities if they exist
thermostats: userConfig.thermostats?.length > 0 ? userConfig.thermostats : defaultConfig.thermostats,
lights: userConfig.lights?.length > 0 ? userConfig.lights : defaultConfig.lights,
locks: userConfig.locks?.length > 0 ? userConfig.locks : defaultConfig.locks,
people: userConfig.people?.length > 0 ? userConfig.people : defaultConfig.people,
alarm: userConfig.alarm ?? defaultConfig.alarm,
calendar: userConfig.calendar ?? defaultConfig.calendar,
todoList: userConfig.todoList ?? defaultConfig.todoList,
packageSensor: userConfig.packageSensor ?? defaultConfig.packageSensor,
go2rtcUrl: userConfig.go2rtcUrl || defaultConfig.go2rtcUrl,
frigateUrl: userConfig.frigateUrl || defaultConfig.frigateUrl,
jellyfinUrl: userConfig.jellyfinUrl || defaultConfig.jellyfinUrl,
jellyfinApiKey: userConfig.jellyfinApiKey ?? defaultConfig.jellyfinApiKey,
// Always use latest camera defaults (user can't edit these in UI anyway)
cameras: defaultConfig.cameras,
personDetectionEntities: userConfig.personDetectionEntities?.length > 0
? userConfig.personDetectionEntities
: defaultConfig.personDetectionEntities,
setupCompleted: userConfig.setupCompleted ?? false,
configVersion: CONFIG_VERSION,
},
};
},
}
)
);
// Selector hooks
export const useConfig = () => useSettingsStore((state) => state.config);
export const useThermostats = () => useSettingsStore((state) => state.config.thermostats);
export const useLights = () => useSettingsStore((state) => state.config.lights);
export const useLocks = () => useSettingsStore((state) => state.config.locks);
export const useAlarmEntity = () => useSettingsStore((state) => state.config.alarm);
export const useCalendarEntity = () => useSettingsStore((state) => state.config.calendar);
export const useTodoEntity = () => useSettingsStore((state) => state.config.todoList);
export const usePeople = () => useSettingsStore((state) => state.config.people);
export const useCameras = () => useSettingsStore((state) => state.config.cameras);
// Helper to get lights grouped by room
export const useLightsByRoom = () => {
const lights = useLights();
return lights.reduce((acc, light) => {
if (!acc[light.room]) {
acc[light.room] = [];
}
acc[light.room].push(light);
return acc;
}, {} as Record<string, LightConfig[]>);
};

169
src/stores/uiStore.ts Normal file
View File

@@ -0,0 +1,169 @@
import { create } from 'zustand';
interface UIState {
// Overlays
cameraOverlayOpen: boolean;
selectedCamera: string | null;
lightsOverlayOpen: boolean;
locksOverlayOpen: boolean;
thermostatsOverlayOpen: boolean;
mediaOverlayOpen: boolean;
personAlertActive: boolean;
personAlertCamera: string | null;
// Alarmo
alarmoKeypadOpen: boolean;
alarmoAction: 'arm_home' | 'arm_away' | 'arm_night' | 'disarm' | null;
// Settings
settingsOpen: boolean;
// Screen state
screenOn: boolean;
// Virtual keyboard
keyboardOpen: boolean;
keyboardNumpad: boolean;
// Actions
openCameraOverlay: (camera?: string) => void;
closeCameraOverlay: () => void;
selectCamera: (camera: string | null) => void;
openLightsOverlay: () => void;
closeLightsOverlay: () => void;
openLocksOverlay: () => void;
closeLocksOverlay: () => void;
openThermostatsOverlay: () => void;
closeThermostatsOverlay: () => void;
openMediaOverlay: () => void;
closeMediaOverlay: () => void;
showPersonAlert: (camera: string) => void;
dismissPersonAlert: () => void;
openAlarmoKeypad: (action: 'arm_home' | 'arm_away' | 'arm_night' | 'disarm') => void;
closeAlarmoKeypad: () => void;
openSettings: () => void;
closeSettings: () => void;
setScreenOn: (on: boolean) => void;
openKeyboard: (numpad?: boolean) => void;
closeKeyboard: () => void;
}
export const useUIStore = create<UIState>((set) => ({
// Initial state
cameraOverlayOpen: false,
selectedCamera: null,
lightsOverlayOpen: false,
locksOverlayOpen: false,
thermostatsOverlayOpen: false,
mediaOverlayOpen: false,
personAlertActive: false,
personAlertCamera: null,
alarmoKeypadOpen: false,
alarmoAction: null,
settingsOpen: false,
screenOn: true,
keyboardOpen: false,
keyboardNumpad: false,
// Camera overlay
openCameraOverlay: (camera) =>
set({
cameraOverlayOpen: true,
selectedCamera: camera || null,
}),
closeCameraOverlay: () =>
set({
cameraOverlayOpen: false,
selectedCamera: null,
}),
selectCamera: (camera) =>
set({
selectedCamera: camera,
}),
// Lights overlay
openLightsOverlay: () => set({ lightsOverlayOpen: true }),
closeLightsOverlay: () => set({ lightsOverlayOpen: false }),
// Locks overlay
openLocksOverlay: () => set({ locksOverlayOpen: true }),
closeLocksOverlay: () => set({ locksOverlayOpen: false }),
// Thermostats overlay
openThermostatsOverlay: () => set({ thermostatsOverlayOpen: true }),
closeThermostatsOverlay: () => set({ thermostatsOverlayOpen: false }),
// Media overlay
openMediaOverlay: () => set({ mediaOverlayOpen: true }),
closeMediaOverlay: () => set({ mediaOverlayOpen: false }),
// Person detection alert
showPersonAlert: (camera) =>
set({
personAlertActive: true,
personAlertCamera: camera,
}),
dismissPersonAlert: () =>
set({
personAlertActive: false,
personAlertCamera: null,
}),
// Alarmo keypad
openAlarmoKeypad: (action) =>
set({
alarmoKeypadOpen: true,
alarmoAction: action,
}),
closeAlarmoKeypad: () =>
set({
alarmoKeypadOpen: false,
alarmoAction: null,
}),
// Settings
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
// Screen
setScreenOn: (on) => set({ screenOn: on }),
// Keyboard
openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }),
closeKeyboard: () => set({ keyboardOpen: false }),
}));
// Selector hooks
export const useCameraOverlay = () =>
useUIStore((state) => ({
isOpen: state.cameraOverlayOpen,
selectedCamera: state.selectedCamera,
open: state.openCameraOverlay,
close: state.closeCameraOverlay,
selectCamera: state.selectCamera,
}));
export const usePersonAlert = () =>
useUIStore((state) => ({
isActive: state.personAlertActive,
camera: state.personAlertCamera,
show: state.showPersonAlert,
dismiss: state.dismissPersonAlert,
}));
export const useAlarmoKeypad = () =>
useUIStore((state) => ({
isOpen: state.alarmoKeypadOpen,
action: state.alarmoAction,
open: state.openAlarmoKeypad,
close: state.closeAlarmoKeypad,
}));

View File

@@ -0,0 +1,60 @@
export const imperialTheme = {
colors: {
background: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
tertiary: '#2a2a2a',
elevated: '#3a3a3a',
},
accent: {
primary: '#cc0000',
dark: '#990000',
light: '#ff3333',
},
text: {
primary: '#ffffff',
secondary: '#b0b0b0',
muted: '#707070',
},
status: {
armed: '#cc0000',
disarmed: '#00cc00',
pending: '#ff8800',
locked: '#00cc00',
unlocked: '#ff8800',
error: '#ff0000',
offline: '#666666',
},
border: {
default: '#3a3a3a',
accent: '#cc0000',
},
},
fonts: {
display: "'Orbitron', sans-serif",
body: "'Inter', sans-serif",
},
spacing: {
touch: '44px',
touchLg: '56px',
gap: '16px',
gapSm: '8px',
gapLg: '24px',
},
borderRadius: {
default: '4px',
lg: '8px',
},
transitions: {
fast: '150ms ease-out',
normal: '200ms ease-out',
slow: '300ms ease-out',
},
shadows: {
imperial: '0 0 10px rgba(204, 0, 0, 0.3)',
imperialGlow: '0 0 20px rgba(204, 0, 0, 0.5)',
card: '0 4px 20px rgba(0, 0, 0, 0.5)',
},
} as const;
export type ImperialTheme = typeof imperialTheme;

259
src/styles/index.css Normal file
View File

@@ -0,0 +1,259 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-dark-primary text-white;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-dark-border-light;
}
}
@layer components {
/* Button base */
.btn {
@apply flex items-center justify-center gap-2 px-4 py-2.5
bg-dark-tertiary border border-dark-border rounded-xl
font-medium text-sm text-white
transition-all duration-150 ease-out
hover:bg-dark-hover hover:border-dark-border-light
active:scale-[0.98] touch-manipulation;
}
.btn-primary {
@apply btn bg-accent border-accent text-white
hover:bg-accent-light hover:border-accent-light;
}
.btn-sm {
@apply px-2.5 py-1 text-xs rounded-lg;
}
.btn-icon {
@apply btn p-2.5;
}
/* Widget */
.widget {
@apply bg-dark-secondary border border-dark-border rounded-2xl p-4
flex flex-col overflow-hidden;
}
.widget-title {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wide
text-gray-400 mb-3;
}
.widget-title svg {
@apply w-4 h-4 opacity-70;
}
.widget-content {
@apply flex-1 overflow-hidden;
}
/* Toggle switch */
.toggle {
@apply relative w-9 h-5 bg-dark-elevated rounded-full cursor-pointer
transition-colors duration-200;
}
.toggle.active {
@apply bg-accent;
}
.toggle-thumb {
@apply absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full
transition-transform duration-200 shadow-sm;
}
.toggle.active .toggle-thumb {
@apply translate-x-4;
}
/* Status badge */
.status-badge {
@apply flex items-center gap-2 px-3.5 py-2 bg-dark-tertiary rounded-full
text-sm text-gray-400;
}
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-dot.connected {
@apply bg-status-success;
box-shadow: 0 0 8px theme('colors.status.success');
}
.status-dot.disconnected {
@apply bg-status-error animate-pulse;
}
.status-dot.connecting {
@apply bg-status-warning animate-pulse;
}
/* Person status */
.person-status {
@apply flex flex-col items-center gap-0.5;
}
.person-avatar {
@apply w-8 h-8 rounded-full overflow-hidden border-2 transition-all;
}
.person-avatar.home {
@apply border-status-success;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.3);
}
.person-avatar.away {
@apply border-gray-500 opacity-70;
}
.person-avatar.work {
@apply border-accent;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3);
}
.person-avatar img {
@apply w-full h-full object-cover;
}
.person-location {
@apply text-[0.55rem] font-medium uppercase tracking-wide text-gray-500;
}
.person-location.home {
@apply text-status-success;
}
.person-location.work {
@apply text-accent;
}
/* Status icon */
.status-icon {
@apply relative w-8 h-8 rounded-lg flex items-center justify-center
cursor-pointer transition-transform hover:scale-110;
}
.status-icon.package {
@apply bg-status-warning/20 text-status-warning;
}
.status-icon.package::after {
content: '';
@apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full
border-2 border-dark-tertiary animate-pulse;
}
/* Keypad */
.keypad-btn {
@apply w-14 h-14 rounded-xl bg-dark-tertiary border border-dark-border
text-xl font-medium text-white
transition-all duration-150
hover:border-accent active:bg-accent active:scale-95
touch-manipulation;
}
/* Temperature */
.temp-display {
@apply text-5xl font-light tracking-tight;
}
.temp-setpoint {
@apply text-center px-2.5 py-1.5 bg-dark-tertiary rounded-lg;
}
.temp-setpoint-label {
@apply text-[0.5rem] text-gray-500 uppercase tracking-wide mb-0.5;
}
.temp-setpoint-value {
@apply text-lg font-semibold;
}
.temp-setpoint-value.heat {
@apply text-orange-400;
}
.temp-setpoint-value.cool {
@apply text-sky-400;
}
.temp-btn {
@apply w-8 h-8 rounded-full bg-dark-tertiary border border-dark-border
flex items-center justify-center text-base
transition-all hover:bg-dark-hover;
}
.temp-btn.heat {
@apply border-orange-400 text-orange-400 hover:bg-orange-400/15;
}
.temp-btn.cool {
@apply border-sky-400 text-sky-400 hover:bg-sky-400/15;
}
/* Overlay */
.overlay-full {
@apply fixed inset-0 z-50 bg-dark-primary/95 backdrop-blur-sm
flex flex-col animate-fade-in;
}
/* Compact rows */
.compact-row {
@apply flex items-center justify-between px-2.5 py-2 bg-dark-tertiary rounded-lg
transition-colors hover:bg-dark-hover;
}
}
@layer utilities {
.touch-manipulation {
touch-action: manipulation;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
}

51
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_HA_URL: string;
readonly VITE_HA_WS_URL: string;
readonly VITE_FRIGATE_URL: string;
readonly VITE_GO2RTC_URL: string;
readonly VITE_GO2RTC_RTSP: string;
readonly VITE_GOOGLE_CLIENT_ID: string;
readonly VITE_SCREEN_IDLE_TIMEOUT: string;
readonly VITE_PRESENCE_DETECTION_ENABLED: string;
readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string;
readonly VITE_FRIGATE_STREAM_ENABLED: string;
readonly VITE_FRIGATE_RTSP_OUTPUT: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
interface ElectronAPI {
screen: {
wake: () => Promise<boolean>;
sleep: () => Promise<boolean>;
setIdleTimeout: (timeout: number) => Promise<boolean>;
activity: () => Promise<boolean>;
};
presence: {
start: () => Promise<boolean>;
stop: () => Promise<boolean>;
onDetected: (callback: () => void) => () => void;
onCleared: (callback: () => void) => () => void;
};
frigate: {
startStream: (rtspUrl: string) => Promise<boolean>;
stopStream: () => Promise<boolean>;
};
app: {
quit: () => void;
toggleFullscreen: () => void;
toggleDevTools: () => void;
};
config: {
getStoredToken: () => Promise<string | null>;
getJellyfinApiKey: () => Promise<string | null>;
};
}
interface Window {
electronAPI?: ElectronAPI;
}