Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
6
src/hooks/index.ts
Normal file
6
src/hooks/index.ts
Normal 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
81
src/hooks/useAlarmo.ts
Normal 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
259
src/hooks/useCalendar.ts
Normal 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
139
src/hooks/useEntity.ts
Normal 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]);
|
||||
}
|
||||
42
src/hooks/useHomeAssistant.ts
Normal file
42
src/hooks/useHomeAssistant.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
170
src/hooks/useLocalPresence.ts
Normal file
170
src/hooks/useLocalPresence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
203
src/hooks/useSimpleMotion.ts
Normal file
203
src/hooks/useSimpleMotion.ts
Normal 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
209
src/hooks/useTodo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user