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

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,
};
}