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 />
</>
);
}