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

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Home Assistant Configuration
VITE_HA_URL=http://192.168.1.50:8123
VITE_HA_WS_URL=ws://192.168.1.50:8123/api/websocket
# Frigate & go2rtc Configuration
VITE_FRIGATE_URL=http://192.168.1.241:5000
VITE_GO2RTC_URL=http://192.168.1.241:1985
VITE_GO2RTC_RTSP=rtsp://192.168.1.241:8600
# Google Calendar
VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# Screen Management
VITE_SCREEN_IDLE_TIMEOUT=300000
# Presence Detection
VITE_PRESENCE_DETECTION_ENABLED=true
VITE_PRESENCE_CONFIDENCE_THRESHOLD=0.6
# Frigate Streaming from built-in camera
VITE_FRIGATE_STREAM_ENABLED=true
VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build outputs
dist/
dist-electron/
release/
# Environment files
.env
.env.local
.env.*.local
# Editor directories
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Secrets
*.pem
*.key
credentials.json
token.json

188
README.md Executable file
View File

@@ -0,0 +1,188 @@
# Imperial Command Center
A full-screen touchscreen kiosk dashboard for Home Assistant control with a Star Wars Imperial/dark theme.
## Features
- **Google Calendar** - Month view with event details
- **To-Do List** - HA built-in todo integration
- **Alarmo** - Arm/disarm panel with numeric keypad
- **Thermostats** - Temperature display and control (supports 2 thermostats)
- **Lights** - Grouped by room, individual toggles, master on/off
- **Door Locks** - Status indicators, lock/unlock with confirmation
- **Camera Overlay** - All cameras via go2rtc WebRTC
- **Person Detection Alert** - Full-screen popup on Frigate detection
- **Package Detection** - Binary sensor status display
- **Presence Wake** - Screen wakes on approach (TensorFlow.js person detection)
- **Screen Sleep** - Auto-sleep after idle timeout
## Tech Stack
- **Electron** - Desktop shell with kiosk mode
- **React 18** + **TypeScript**
- **Tailwind CSS** - Imperial dark theme
- **Zustand** - State management
- **home-assistant-js-websocket** - HA connection
- **go2rtc WebRTC** - Camera streaming
- **TensorFlow.js COCO-SSD** - Presence detection
## Quick Start
### Prerequisites
- Node.js 18+
- npm or yarn
- Home Assistant with long-lived access token
- go2rtc server (for camera feeds)
### Installation
```bash
# Clone the repository
git clone https://github.com/your-repo/imperial-command-center.git
cd imperial-command-center
# Install dependencies
npm install
# Copy environment file
cp .env.example .env
# Edit .env with your settings
nano .env
```
### Configuration
Edit `.env` with your Home Assistant and service URLs:
```env
VITE_HA_URL=http://192.168.1.50:8123
VITE_HA_WS_URL=ws://192.168.1.50:8123/api/websocket
VITE_GO2RTC_URL=http://192.168.1.241:1985
VITE_FRIGATE_URL=http://192.168.1.241:5000
```
Edit `src/config/entities.ts` to configure your Home Assistant entity IDs.
### Development
```bash
# Start development server (React only)
npm run dev
# Start with Electron
npm run electron:dev
```
### Building
```bash
# Build for Linux (AppImage)
npm run build:linux
# Build for Windows (NSIS installer)
npm run build:win
# Build all platforms
npm run electron:build
```
## Deployment
### Linux Kiosk Setup
1. Build the application:
```bash
npm run build:linux
```
2. Run the deployment script:
```bash
sudo ./scripts/deploy-linux.sh
```
3. Configure autologin for the kiosk user (for LightDM):
```ini
# /etc/lightdm/lightdm.conf
[Seat:*]
autologin-user=kiosk
autologin-session=openbox
```
4. Start the service:
```bash
sudo systemctl start imperial-command-center
```
### Manual Service Setup
1. Copy the AppImage to `/opt/imperial-command-center/`
2. Copy the service file:
```bash
sudo cp imperial-command-center.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable imperial-command-center
```
## Home Assistant Setup
### Long-Lived Access Token
1. Go to your Home Assistant profile
2. Scroll to "Long-Lived Access Tokens"
3. Create a new token
4. Enter it in the dashboard settings
### Required Entities
Configure these in `src/config/entities.ts`:
- 2 climate entities (thermostats)
- 10 light entities (grouped by room)
- 3 lock entities
- 1 alarm_control_panel entity (Alarmo)
- 1 binary_sensor for package detection
- 1 todo entity
### Alarmo Setup
The dashboard expects Alarmo to be installed and configured in Home Assistant.
## Camera Integration
The dashboard uses go2rtc for camera streaming:
1. Ensure go2rtc is running and configured with your cameras
2. Update camera names in `src/config/entities.ts` to match go2rtc stream names
3. Camera feeds use WebRTC for low-latency streaming
## Keyboard Shortcuts (Development)
- `F11` - Toggle fullscreen
- `Ctrl+Shift+I` - Open DevTools
- `Escape` - Exit fullscreen (when not in kiosk mode)
## Troubleshooting
### Connection Issues
- Verify Home Assistant URL is correct
- Check access token is valid
- Ensure HA allows WebSocket connections from the kiosk IP
### Camera Feed Issues
- Verify go2rtc is accessible from the kiosk
- Check stream names match between go2rtc and entity config
- Ensure WebRTC is not blocked by firewall
### Screen Wake/Sleep
- Requires X11 (Wayland may not work)
- Install `xdotool` and `unclutter` for full functionality
- Check `xset` commands work manually
## License
MIT

715
SETUP.md Executable file
View File

@@ -0,0 +1,715 @@
# Command Center - Complete Setup Guide
A Home Assistant touchscreen dashboard for Windows and Linux.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Project Installation](#2-project-installation)
3. [Home Assistant Setup](#3-home-assistant-setup)
4. [Google Calendar Setup](#4-google-calendar-setup-optional)
5. [Camera Setup](#5-camera-setup)
6. [Entity Configuration](#6-entity-configuration)
7. [Building the Application](#7-building-the-application)
8. [Windows Deployment](#8-windows-deployment)
9. [Linux Deployment](#9-linux-deployment)
10. [First Launch](#10-first-launch)
11. [Troubleshooting](#11-troubleshooting)
---
## 1. Prerequisites
### Development Machine (to build the app)
| Requirement | Windows | Linux |
|-------------|---------|-------|
| Node.js | v18+ from [nodejs.org](https://nodejs.org) | `sudo apt install nodejs npm` |
| Git | [git-scm.com](https://git-scm.com) | `sudo apt install git` |
| Code Editor | VS Code recommended | VS Code recommended |
### Touchscreen Device
**Supported Platforms:**
- Windows 10/11 PC or tablet
- Linux PC (Ubuntu, Debian, Raspberry Pi OS)
- Raspberry Pi 4 (4GB+ recommended)
**Requirements:**
- Touchscreen display (or mouse for testing)
- Network access to Home Assistant
- Network access to camera streams (if using cameras)
---
## 2. Project Installation
### Windows
```powershell
# Open PowerShell and navigate to your projects folder
cd C:\Projects
# Clone or copy the project
git clone <your-repo-url> command-center
cd command-center
# Install dependencies
npm install
```
### Linux
```bash
# Navigate to your projects folder
cd ~/projects
# Clone or copy the project
git clone <your-repo-url> command-center
cd command-center
# Install dependencies
npm install
```
### Verify Installation
```bash
npm run dev
```
Open http://localhost:5173 in a browser. You should see the dashboard (it won't have data yet).
---
## 3. Home Assistant Setup
### Step 1: Create a Long-Lived Access Token
The app needs a token to communicate with Home Assistant.
1. **Open Home Assistant** in your browser
2. **Click your username** (bottom-left corner of the sidebar)
3. **Scroll down** to "Long-Lived Access Tokens" section
4. **Click "Create Token"**
5. **Enter a name**: `Command Center`
6. **Click "OK"**
7. **IMPORTANT: Copy the token immediately!** You won't be able to see it again.
Save this token somewhere safe - you'll need it when you first launch the app.
### Step 2: Find Your Entity IDs
You need to know the exact entity IDs for your devices.
1. **Go to Developer Tools** (sidebar) → **States**
2. **Search for your entities** using the filter box
3. **Note down the entity IDs** for:
- Thermostats (e.g., `climate.living_room_thermostat`)
- Lights (e.g., `light.kitchen_main`)
- Locks (e.g., `lock.front_door`)
- Alarm panel (e.g., `alarm_control_panel.alarmo`)
- Person entities (e.g., `person.john`)
- Todo list (e.g., `todo.shopping_list`)
**Tip:** Entity IDs are case-sensitive!
### Step 3: Set Up Alarmo (Optional)
If you want alarm panel functionality:
1. **Install HACS** if you haven't: [hacs.xyz](https://hacs.xyz)
2. **Install Alarmo** from HACS
3. **Configure Alarmo**: Settings → Devices & Services → Alarmo
4. **Set up a PIN code** for arming/disarming
5. **Add sensors** to your alarm zones
6. **Note the entity ID** (usually `alarm_control_panel.alarmo`)
---
## 4. Google Calendar Setup (Optional)
If you want to display your Google Calendar events.
### Step 1: Create a Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. **Sign in** with your Google account
3. Click **"Select a project"** (top bar) → **"New Project"**
4. **Project name**: `Command Center`
5. Click **"Create"**
6. Wait for the project to be created, then select it
### Step 2: Enable the Google Calendar API
1. In the left sidebar, click **"APIs & Services"** → **"Library"**
2. Search for **"Google Calendar API"**
3. Click on **"Google Calendar API"**
4. Click **"Enable"**
### Step 3: Configure OAuth Consent Screen
1. Go to **"APIs & Services"** → **"OAuth consent screen"**
2. Select **"External"** → Click **"Create"**
3. Fill in the required fields:
- **App name**: `Command Center`
- **User support email**: Your email
- **Developer contact email**: Your email
4. Click **"Save and Continue"**
5. On **Scopes** page, click **"Add or Remove Scopes"**
6. Find and check: `https://www.googleapis.com/auth/calendar.readonly`
7. Click **"Update"** → **"Save and Continue"**
8. On **Test users** page, click **"Add Users"**
9. **Add your Google email address**
10. Click **"Save and Continue"** → **"Back to Dashboard"**
### Step 4: Create OAuth Credentials
1. Go to **"APIs & Services"** → **"Credentials"**
2. Click **"Create Credentials"** → **"OAuth client ID"**
3. **Application type**: Select **"Desktop app"**
4. **Name**: `Command Center Desktop`
5. Click **"Create"**
6. **Copy the "Client ID"** (looks like: `123456789-xxxxx.apps.googleusercontent.com`)
### Step 5: Add to Environment File
Add to your `.env` file:
```env
VITE_GOOGLE_CLIENT_ID=123456789-xxxxx.apps.googleusercontent.com
```
---
## 5. Camera Setup
You have **two options** for viewing cameras:
### Option A: Use go2rtc (Recommended)
If you already have go2rtc running (e.g., with Frigate), you can stream cameras directly.
**In your `.env` file:**
```env
VITE_GO2RTC_URL=http://192.168.1.241:1985
```
**In `src/config/entities.ts`, configure your cameras:**
```typescript
cameras: [
{ name: 'FPE', displayName: 'Front Porch', go2rtcStream: 'FPE' },
{ name: 'Backyard', displayName: 'Backyard', go2rtcStream: 'Backyard' },
// Stream names must match your go2rtc.yaml configuration
],
```
**Verify go2rtc is working:**
- Open `http://your-go2rtc-ip:1985` in a browser
- You should see a list of your camera streams
- Click a stream to verify video plays
### Option B: Use External Camera Viewer App
If you have a separate camera viewer application (like the one we created previously), you can:
1. **Disable the built-in camera overlay** by leaving the cameras array empty:
```typescript
cameras: [],
```
2. **Open your camera viewer app** separately when needed
3. **Or modify the camera button** in `Header.tsx` to launch your external app
### Option C: Use Home Assistant Camera Entities
If your cameras are integrated into Home Assistant:
1. The app can display camera snapshots via HA camera entities
2. Configure camera entity IDs in `entities.ts`
3. Note: This provides snapshots, not live video streams
---
## 6. Entity Configuration
### Step 1: Create Environment File
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
notepad .env
```
**Linux:**
```bash
cp .env.example .env
nano .env
```
### Step 2: Edit .env File
```env
# Home Assistant - UPDATE THESE
VITE_HA_URL=http://192.168.1.50:8123
VITE_HA_WS_URL=ws://192.168.1.50:8123/api/websocket
# Frigate (optional - for person detection alerts)
VITE_FRIGATE_URL=http://192.168.1.241:5000
# go2rtc (optional - for camera streams)
VITE_GO2RTC_URL=http://192.168.1.241:1985
# Google Calendar (optional - from Step 4)
VITE_GOOGLE_CLIENT_ID=
# Screen timeout in milliseconds (5 minutes = 300000)
VITE_SCREEN_IDLE_TIMEOUT=300000
```
### Step 3: Edit Entity Configuration
Edit `src/config/entities.ts` with your actual entity IDs:
```typescript
export const entitiesConfig: EntitiesConfig = {
// THERMOSTATS - Update with your thermostat entity IDs
thermostats: [
{
entityId: 'climate.downstairs', // <-- Your entity ID
name: 'Downstairs',
location: 'downstairs',
},
{
entityId: 'climate.upstairs', // <-- Your entity ID
name: 'Upstairs',
location: 'upstairs',
},
],
// LIGHTS - Group by room, update entity IDs
lights: [
// Living Room
{ entityId: 'light.living_room_ceiling', name: 'Ceiling', room: 'Living Room' },
{ entityId: 'light.living_room_lamp', name: 'Lamp', room: 'Living Room' },
// Kitchen
{ entityId: 'light.kitchen_main', name: 'Main', room: 'Kitchen' },
// Add more rooms and lights...
],
// DOOR LOCKS - Update entity IDs
locks: [
{ entityId: 'lock.front_door_lock', name: 'Front Door', location: 'front' },
{ entityId: 'lock.back_door_lock', name: 'Back Door', location: 'back' },
{ entityId: 'lock.garage_entry', name: 'Garage', location: 'garage' },
],
// ALARM - Alarmo entity ID
alarm: 'alarm_control_panel.alarmo',
// PACKAGE DETECTION - Binary sensor for package notifications
packageSensor: 'binary_sensor.package_detected',
// TODO LIST - Home Assistant todo entity
todoList: 'todo.shopping_list',
// CAMERAS - go2rtc stream names (see Camera Setup section)
cameras: [
{ name: 'FPE', displayName: 'Front Porch', go2rtcStream: 'FPE', frigateCamera: 'FPE' },
{ name: 'Backyard', displayName: 'Backyard', go2rtcStream: 'Backyard', frigateCamera: 'Backyard' },
// Add your cameras...
],
// PERSON DETECTION - Frigate binary sensors that trigger alerts
personDetectionEntities: [
'binary_sensor.front_porch_person_occupancy',
'binary_sensor.backyard_person_occupancy',
],
// PEOPLE TRACKING - Person entities with optional avatars
people: [
{
entityId: 'person.john',
name: 'John',
avatarUrl: '/avatars/john.jpg', // Place image in public/avatars/
},
{
entityId: 'person.jane',
name: 'Jane',
avatarUrl: '/avatars/jane.jpg',
},
],
};
```
### Step 4: Add Avatar Images (Optional)
**Windows:**
```powershell
mkdir public\avatars
# Copy your avatar images to public\avatars\
```
**Linux:**
```bash
mkdir -p public/avatars
cp ~/photos/john.jpg public/avatars/
cp ~/photos/jane.jpg public/avatars/
```
---
## 7. Building the Application
### Test in Development Mode First
```bash
npm run dev
```
Open http://localhost:5173 and verify everything works.
### Build for Production
**For Windows:**
```bash
npm run build:win
```
Output: `dist/Command Center Setup-1.0.0.exe`
**For Linux:**
```bash
npm run build:linux
```
Output: `dist/command-center-1.0.0.AppImage`
**For Current Platform:**
```bash
npm run build
```
---
## 8. Windows Deployment
### Option A: Run the Installer
1. Copy `dist/Command Center Setup-1.0.0.exe` to your Windows touchscreen device
2. Run the installer
3. The app will be installed to Program Files
4. Launch from Start Menu
### Option B: Portable Mode
1. Copy the entire `dist/win-unpacked` folder to your device
2. Run `Command Center.exe` directly
### Auto-Start on Windows
1. Press `Win + R`, type `shell:startup`, press Enter
2. Create a shortcut to the app in this folder
3. The app will start automatically on login
### Kiosk Mode on Windows
**Using Windows Assigned Access (Windows 10/11 Pro):**
1. Create a dedicated user account for the kiosk
2. Go to Settings → Accounts → Family & other users
3. Set up a kiosk (Assigned Access)
4. Select the Command Center app
**Using a startup script:**
Create `start-kiosk.bat`:
```batch
@echo off
start "" "C:\Program Files\Command Center\Command Center.exe" --kiosk
```
### Disable Screen Timeout (Windows)
1. Settings → System → Power & sleep
2. Set "Screen" and "Sleep" to "Never"
Or via PowerShell:
```powershell
powercfg /change monitor-timeout-ac 0
powercfg /change standby-timeout-ac 0
```
---
## 9. Linux Deployment
### Copy the App
```bash
# From your build machine
scp dist/command-center-1.0.0.AppImage user@touchscreen-device:~/
# On the touchscreen device
chmod +x ~/command-center-1.0.0.AppImage
```
### Install Dependencies
```bash
sudo apt update
sudo apt install -y libgtk-3-0 libnotify4 libnss3 libxss1 libasound2 unclutter
```
### Create Startup Script
```bash
nano ~/start-kiosk.sh
```
```bash
#!/bin/bash
# Wait for display
sleep 3
# Disable screen blanking
xset s off
xset -dpms
xset s noblank
# Hide cursor when idle
unclutter -idle 3 -root &
# Start the app
~/command-center-1.0.0.AppImage --no-sandbox
```
```bash
chmod +x ~/start-kiosk.sh
```
### Auto-Start with Systemd
```bash
sudo nano /etc/systemd/system/command-center.service
```
```ini
[Unit]
Description=Command Center Kiosk
After=graphical.target
[Service]
Type=simple
User=YOUR_USERNAME
Environment=DISPLAY=:0
ExecStart=/home/YOUR_USERNAME/start-kiosk.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical.target
```
**Replace `YOUR_USERNAME` with your actual username!**
```bash
sudo systemctl enable command-center.service
sudo systemctl start command-center.service
```
### Raspberry Pi Specific Setup
**Enable auto-login:**
```bash
sudo raspi-config
# → System Options → Boot / Auto Login → Desktop Autologin
```
**Disable screen blanking:**
```bash
sudo nano /etc/xdg/lxsession/LXDE-pi/autostart
```
Add:
```
@xset s off
@xset -dpms
@xset s noblank
```
---
## 10. First Launch
### Step 1: Start the App
Launch the application on your touchscreen device.
### Step 2: Enter Home Assistant Token
1. The app will show "Disconnected" status
2. Look for a **Settings** button (gear icon) or the connection status
3. Click to open settings
4. **Paste your Home Assistant token** (from Step 3.1)
5. Click **"Save & Connect"**
### Step 3: Verify Connection
- Status should change to **"Connected"** (green)
- Your thermostat temperatures should appear
- Lights and locks should show their states
### Step 4: Connect Google Calendar (Optional)
1. Click **"Connect Google Calendar"** in the calendar widget
2. A browser window will open
3. Sign in with your Google account
4. Click **"Allow"** to grant calendar access
5. Close the browser window
6. Calendar events should now appear
---
## 11. Troubleshooting
### "Disconnected" - Can't Connect to Home Assistant
| Check | Solution |
|-------|----------|
| URL correct? | Verify `VITE_HA_URL` matches your HA address |
| Token valid? | Create a new token in HA if expired |
| Network? | Ensure device can reach HA (try ping) |
| Firewall? | Port 8123 must be accessible |
| HTTPS? | If HA uses HTTPS, update URL to `https://` |
### Cameras Not Loading
| Check | Solution |
|-------|----------|
| go2rtc running? | Open `http://go2rtc-ip:1985` in browser |
| Stream names match? | Names in config must match go2rtc exactly |
| Network access? | Device must reach go2rtc server |
| CORS issues? | go2rtc should allow cross-origin requests |
### Entities Show "Loading..."
| Check | Solution |
|-------|----------|
| Entity ID correct? | Check exact ID in HA Developer Tools |
| Entity exists? | Search for it in HA States |
| Case sensitive! | `climate.Living_Room` ≠ `climate.living_room` |
### Google Calendar Won't Connect
| Check | Solution |
|-------|----------|
| Client ID set? | Check `.env` has `VITE_GOOGLE_CLIENT_ID` |
| Test user added? | Your email must be in OAuth test users |
| API enabled? | Google Calendar API must be enabled |
| Popup blocked? | Allow popups for the app |
### App Crashes on Linux
```bash
# Check logs
journalctl -u command-center.service -f
# Run manually to see errors
DISPLAY=:0 ~/command-center-1.0.0.AppImage --no-sandbox 2>&1
```
Common fixes:
- Add `--no-sandbox` flag
- Install missing dependencies: `sudo apt install libgtk-3-0 libnss3`
### Screen Goes Black / Sleeps
**Windows:**
- Settings → Power & sleep → Never
**Linux:**
```bash
xset s off && xset -dpms && xset s noblank
```
### Touch Not Responding (Linux)
```bash
# Check touch device detected
xinput list
# Calibrate touchscreen
sudo apt install xinput-calibrator
xinput_calibrator
```
---
## Quick Reference
### Commands
| Task | Command |
|------|---------|
| Development mode | `npm run dev` |
| Build for Windows | `npm run build:win` |
| Build for Linux | `npm run build:linux` |
| Build for current OS | `npm run build` |
### Linux Service Commands
| Task | Command |
|------|---------|
| Check status | `sudo systemctl status command-center` |
| Start | `sudo systemctl start command-center` |
| Stop | `sudo systemctl stop command-center` |
| Restart | `sudo systemctl restart command-center` |
| View logs | `journalctl -u command-center -f` |
### File Locations
| File | Purpose |
|------|---------|
| `.env` | Environment variables (URLs, API keys) |
| `src/config/entities.ts` | Entity ID configuration |
| `public/avatars/` | Person avatar images |
---
## Network Architecture
```
┌─────────────────────┐
│ Touchscreen │
│ (Command Center) │
└──────────┬──────────┘
┌─────┴─────┐
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Home │ │ go2rtc │ (Optional)
│Assistant│ │ Cameras │
│ :8123 │ │ :1985 │
└─────────┘ └─────────┘
┌─────────────────────┐
│ Smart Devices │
│ Lights, Locks, etc. │
└─────────────────────┘
```
---
## Support
If you encounter issues:
1. Check the [Troubleshooting](#11-troubleshooting) section
2. Verify all entity IDs match exactly
3. Check browser console for errors (F12 → Console)
4. Review Home Assistant logs for connection issues

229
electron/main.ts Normal file
View File

@@ -0,0 +1,229 @@
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { ScreenManager } from './services/ScreenManager';
import { PresenceDetector } from './services/PresenceDetector';
import { FrigateStreamer } from './services/FrigateStreamer';
import { MotionDetector } from './services/MotionDetector';
let mainWindow: BrowserWindow | null = null;
let screenManager: ScreenManager | null = null;
let presenceDetector: PresenceDetector | null = null;
let frigateStreamer: FrigateStreamer | null = null;
let motionDetector: MotionDetector | null = null;
let powerSaveBlockerId: number | null = null;
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server
// When running from unpacked folder, app.isPackaged is false but we still want production behavior
const isDev = process.env.NODE_ENV === 'development';
function createWindow(): void {
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.workAreaSize;
mainWindow = new BrowserWindow({
width,
height,
x: 0,
y: 0,
fullscreen: true,
kiosk: !isDev, // Kiosk mode in production
frame: false,
autoHideMenuBar: true,
backgroundColor: '#0a0a0a',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false, // Required for some electron features
},
});
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
// __dirname is dist-electron, so go up one level to reach dist
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
// Prevent power save mode
powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep');
// Initialize services
screenManager = new ScreenManager(mainWindow);
// Initialize motion detector (runs in main process, not throttled by browser)
// Uses file size comparison which is more reliable for JPEG streams
motionDetector = new MotionDetector({
go2rtcUrl: 'http://192.168.1.241:1985',
cameraName: 'Kitchen_Panel',
sensitivityThreshold: 5, // % file size change to trigger (5% = significant motion)
checkIntervalMs: 2000, // Check every 2 seconds for responsiveness
});
motionDetector.on('motion', () => {
console.log('MotionDetector: Motion detected, waking screen');
screenManager?.wakeScreen();
mainWindow?.webContents.send('motion:detected');
});
motionDetector.start();
// Handle window close
mainWindow.on('closed', () => {
mainWindow = null;
});
// Prevent accidental navigation
mainWindow.webContents.on('will-navigate', (event) => {
event.preventDefault();
});
// Handle external links
mainWindow.webContents.setWindowOpenHandler(() => {
return { action: 'deny' };
});
}
// IPC handlers
function setupIpcHandlers(): void {
// Screen management
ipcMain.handle('screen:wake', async () => {
await screenManager?.wakeScreen();
return true;
});
ipcMain.handle('screen:sleep', async () => {
await screenManager?.sleepScreen();
return true;
});
ipcMain.handle('screen:setIdleTimeout', async (_event, timeout: number) => {
screenManager?.setIdleTimeout(timeout);
return true;
});
// Handle touch/click activity from renderer (for Wayland touch support)
ipcMain.handle('screen:activity', async () => {
screenManager?.handleUserActivity();
return true;
});
// Presence detection control
ipcMain.handle('presence:start', async () => {
if (!presenceDetector) {
presenceDetector = new PresenceDetector();
presenceDetector.on('personDetected', () => {
mainWindow?.webContents.send('presence:detected');
screenManager?.wakeScreen();
});
presenceDetector.on('noPersonDetected', () => {
mainWindow?.webContents.send('presence:cleared');
});
}
await presenceDetector.start();
return true;
});
ipcMain.handle('presence:stop', async () => {
await presenceDetector?.stop();
return true;
});
// Frigate streaming control
ipcMain.handle('frigate:startStream', async (_event, rtspUrl: string) => {
if (!frigateStreamer) {
frigateStreamer = new FrigateStreamer();
}
await frigateStreamer.start(rtspUrl);
return true;
});
ipcMain.handle('frigate:stopStream', async () => {
await frigateStreamer?.stop();
return true;
});
// Config - read stored token from file
ipcMain.handle('config:getStoredToken', async () => {
try {
const tokenPath = path.join(app.getPath('userData'), 'ha_token.txt');
if (fs.existsSync(tokenPath)) {
const token = fs.readFileSync(tokenPath, 'utf-8').trim();
return token || null;
}
return null;
} catch {
return null;
}
});
// Config - read Jellyfin API key from file
ipcMain.handle('config:getJellyfinApiKey', async () => {
try {
const keyPath = path.join(app.getPath('userData'), 'jellyfin_api_key.txt');
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath, 'utf-8').trim();
return key || null;
}
return null;
} catch {
return null;
}
});
// App control
ipcMain.handle('app:quit', () => {
app.quit();
});
ipcMain.handle('app:toggleFullscreen', () => {
if (mainWindow) {
const isFullScreen = mainWindow.isFullScreen();
mainWindow.setFullScreen(!isFullScreen);
}
});
ipcMain.handle('app:toggleDevTools', () => {
if (isDev && mainWindow) {
mainWindow.webContents.toggleDevTools();
}
});
}
// App lifecycle
app.whenReady().then(() => {
setupIpcHandlers();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
// Clean up
if (powerSaveBlockerId !== null) {
powerSaveBlocker.stop(powerSaveBlockerId);
}
presenceDetector?.stop();
frigateStreamer?.stop();
motionDetector?.stop();
if (process.platform !== 'darwin') {
app.quit();
}
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
});

74
electron/preload.ts Normal file
View File

@@ -0,0 +1,74 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export 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>;
};
}
const electronAPI: ElectronAPI = {
screen: {
wake: () => ipcRenderer.invoke('screen:wake'),
sleep: () => ipcRenderer.invoke('screen:sleep'),
setIdleTimeout: (timeout: number) => ipcRenderer.invoke('screen:setIdleTimeout', timeout),
activity: () => ipcRenderer.invoke('screen:activity'),
},
presence: {
start: () => ipcRenderer.invoke('presence:start'),
stop: () => ipcRenderer.invoke('presence:stop'),
onDetected: (callback: () => void) => {
const handler = (_event: IpcRendererEvent) => callback();
ipcRenderer.on('presence:detected', handler);
return () => ipcRenderer.removeListener('presence:detected', handler);
},
onCleared: (callback: () => void) => {
const handler = (_event: IpcRendererEvent) => callback();
ipcRenderer.on('presence:cleared', handler);
return () => ipcRenderer.removeListener('presence:cleared', handler);
},
},
frigate: {
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
},
app: {
quit: () => ipcRenderer.invoke('app:quit'),
toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'),
toggleDevTools: () => ipcRenderer.invoke('app:toggleDevTools'),
},
config: {
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Type declaration for renderer
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

View File

@@ -0,0 +1,177 @@
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
export class FrigateStreamer extends EventEmitter {
private ffmpegProcess: ChildProcess | null = null;
private isStreaming: boolean = false;
private restartAttempts: number = 0;
private readonly MAX_RESTART_ATTEMPTS = 5;
private restartTimeout: NodeJS.Timeout | null = null;
async start(rtspOutputUrl: string): Promise<void> {
if (this.isStreaming) {
console.log('Already streaming to Frigate');
return;
}
try {
// Get camera devices
const videoDevices = await this.getVideoDevices();
if (videoDevices.length === 0) {
throw new Error('No video devices found');
}
const videoDevice = videoDevices[0]; // Use first available camera
console.log(`Using video device: ${videoDevice}`);
// Build FFmpeg command
const ffmpegArgs = this.buildFfmpegArgs(videoDevice, rtspOutputUrl);
// Start FFmpeg process
this.ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.ffmpegProcess.stdout?.on('data', (data) => {
console.log(`FFmpeg stdout: ${data}`);
});
this.ffmpegProcess.stderr?.on('data', (data) => {
// FFmpeg logs to stderr by default
const message = data.toString();
if (message.includes('error') || message.includes('Error')) {
console.error(`FFmpeg error: ${message}`);
this.emit('error', message);
}
});
this.ffmpegProcess.on('close', (code) => {
console.log(`FFmpeg process exited with code ${code}`);
this.isStreaming = false;
if (code !== 0 && this.restartAttempts < this.MAX_RESTART_ATTEMPTS) {
this.restartAttempts++;
console.log(`Restarting FFmpeg (attempt ${this.restartAttempts}/${this.MAX_RESTART_ATTEMPTS})`);
this.restartTimeout = setTimeout(() => {
this.start(rtspOutputUrl);
}, 5000);
} else if (this.restartAttempts >= this.MAX_RESTART_ATTEMPTS) {
this.emit('maxRestartsReached');
}
});
this.ffmpegProcess.on('error', (error) => {
console.error('FFmpeg process error:', error);
this.emit('error', error);
});
this.isStreaming = true;
this.restartAttempts = 0;
this.emit('started');
console.log(`Streaming to ${rtspOutputUrl}`);
} catch (error) {
console.error('Failed to start Frigate stream:', error);
throw error;
}
}
private async getVideoDevices(): Promise<string[]> {
return new Promise((resolve) => {
if (process.platform === 'linux') {
// On Linux, check for /dev/video* devices
const { exec } = require('child_process');
exec('ls /dev/video* 2>/dev/null', (error: Error | null, stdout: string) => {
if (error) {
resolve([]);
} else {
const devices = stdout.trim().split('\n').filter(Boolean);
resolve(devices);
}
});
} else if (process.platform === 'win32') {
// On Windows, use DirectShow device listing
// For simplicity, return default device name
resolve(['video=Integrated Camera']);
} else if (process.platform === 'darwin') {
// On macOS, use AVFoundation
resolve(['0']); // Device index
} else {
resolve([]);
}
});
}
private buildFfmpegArgs(videoDevice: string, rtspOutputUrl: string): string[] {
const baseArgs: string[] = [];
if (process.platform === 'linux') {
baseArgs.push(
'-f', 'v4l2',
'-input_format', 'mjpeg',
'-framerate', '15',
'-video_size', '640x480',
'-i', videoDevice
);
} else if (process.platform === 'win32') {
baseArgs.push(
'-f', 'dshow',
'-framerate', '15',
'-video_size', '640x480',
'-i', videoDevice
);
} else if (process.platform === 'darwin') {
baseArgs.push(
'-f', 'avfoundation',
'-framerate', '15',
'-video_size', '640x480',
'-i', videoDevice
);
}
// Output settings for RTSP
baseArgs.push(
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-tune', 'zerolatency',
'-profile:v', 'baseline',
'-level', '3.1',
'-pix_fmt', 'yuv420p',
'-g', '30', // Keyframe interval
'-b:v', '1M',
'-bufsize', '1M',
'-f', 'rtsp',
'-rtsp_transport', 'tcp',
rtspOutputUrl
);
return baseArgs;
}
async stop(): Promise<void> {
if (this.restartTimeout) {
clearTimeout(this.restartTimeout);
this.restartTimeout = null;
}
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGTERM');
// Force kill if still running after 5 seconds
setTimeout(() => {
if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
this.ffmpegProcess.kill('SIGKILL');
}
}, 5000);
this.ffmpegProcess = null;
}
this.isStreaming = false;
this.restartAttempts = 0;
this.emit('stopped');
}
isActive(): boolean {
return this.isStreaming;
}
}

View File

@@ -0,0 +1,137 @@
import { EventEmitter } from 'events';
interface MotionDetectorOptions {
go2rtcUrl: string;
cameraName: string;
sensitivityThreshold: number; // % size change to trigger
checkIntervalMs: number;
}
/**
* Motion detection running in the main process (not throttled by browser).
* Uses JPEG file size changes as a proxy for motion - more reliable than byte comparison.
* When significant motion occurs, the JPEG compresses differently and size changes.
*/
export class MotionDetector extends EventEmitter {
private options: MotionDetectorOptions;
private intervalId: NodeJS.Timeout | null = null;
private prevSize: number = 0;
private baselineSize: number = 0;
private sizeHistory: number[] = [];
private isProcessing = false;
private noMotionCount = 0;
constructor(options: MotionDetectorOptions) {
super();
this.options = options;
}
start(): void {
if (this.intervalId) return;
console.log(`MotionDetector: Starting detection on ${this.options.cameraName}`);
this.intervalId = setInterval(() => {
this.checkMotion();
}, this.options.checkIntervalMs);
// Do initial checks to establish baseline
this.checkMotion();
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.prevSize = 0;
this.baselineSize = 0;
this.sizeHistory = [];
console.log('MotionDetector: Stopped');
}
private async checkMotion(): Promise<void> {
if (this.isProcessing) return;
this.isProcessing = true;
try {
const frameData = await this.fetchFrame();
if (!frameData) {
this.isProcessing = false;
return;
}
const currentSize = frameData.length;
// Build up history for baseline (first 5 frames)
if (this.sizeHistory.length < 5) {
this.sizeHistory.push(currentSize);
if (this.sizeHistory.length === 5) {
// Calculate baseline as average of first 5 frames
this.baselineSize = this.sizeHistory.reduce((a, b) => a + b, 0) / 5;
console.log(`MotionDetector: Baseline established at ${this.baselineSize} bytes`);
}
this.prevSize = currentSize;
this.isProcessing = false;
return;
}
// Compare to previous frame AND baseline
const prevDiff = Math.abs(currentSize - this.prevSize);
const baselineDiff = Math.abs(currentSize - this.baselineSize);
const prevChangePercent = (prevDiff / this.prevSize) * 100;
const baselineChangePercent = (baselineDiff / this.baselineSize) * 100;
// Motion detected if EITHER:
// 1. Frame-to-frame change is significant (something just moved)
// 2. Deviation from baseline is significant (scene has changed)
const motionDetected = prevChangePercent > this.options.sensitivityThreshold ||
baselineChangePercent > (this.options.sensitivityThreshold * 2);
if (motionDetected) {
this.noMotionCount = 0;
console.log(`MotionDetector: Motion detected (prev=${prevChangePercent.toFixed(1)}% baseline=${baselineChangePercent.toFixed(1)}%)`);
this.emit('motion');
} else {
this.noMotionCount++;
// Update baseline slowly when no motion (adapt to lighting changes)
if (this.noMotionCount > 10) {
this.baselineSize = this.baselineSize * 0.95 + currentSize * 0.05;
}
}
this.prevSize = currentSize;
} catch (error) {
console.error('MotionDetector: Error checking motion:', error);
} finally {
this.isProcessing = false;
}
}
private async fetchFrame(): Promise<Buffer | null> {
// Try up to 2 times with a short delay
for (let attempt = 0; attempt < 2; attempt++) {
try {
const url = `${this.options.go2rtcUrl}/api/frame.jpeg?src=${this.options.cameraName}&t=${Date.now()}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
continue;
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch {
if (attempt === 0) {
await new Promise(r => setTimeout(r, 500));
}
}
}
return null;
}
}

View File

@@ -0,0 +1,170 @@
import { EventEmitter } from 'events';
// TensorFlow.js imports - will be loaded dynamically
let tf: typeof import('@tensorflow/tfjs') | null = null;
let cocoSsd: typeof import('@tensorflow-models/coco-ssd') | null = null;
interface Detection {
class: string;
score: number;
bbox: [number, number, number, number];
}
export class PresenceDetector extends EventEmitter {
private model: Awaited<ReturnType<typeof import('@tensorflow-models/coco-ssd').load>> | null = null;
private video: HTMLVideoElement | null = null;
private canvas: HTMLCanvasElement | null = null;
private context: CanvasRenderingContext2D | null = null;
private stream: MediaStream | null = null;
private detectionInterval: NodeJS.Timeout | null = null;
private isRunning: boolean = false;
private confidenceThreshold: number = 0.6;
private personDetected: boolean = false;
private noPersonCount: number = 0;
private readonly NO_PERSON_THRESHOLD = 5; // Frames without person before clearing
constructor(confidenceThreshold: number = 0.6) {
super();
this.confidenceThreshold = confidenceThreshold;
}
async start(): Promise<void> {
if (this.isRunning) return;
try {
// Dynamically import TensorFlow.js
tf = await import('@tensorflow/tfjs');
cocoSsd = await import('@tensorflow-models/coco-ssd');
// Set backend
await tf.setBackend('webgl');
await tf.ready();
// Load COCO-SSD model
console.log('Loading COCO-SSD model...');
this.model = await cocoSsd.load({
base: 'lite_mobilenet_v2', // Faster, lighter model
});
console.log('Model loaded');
// Set up video capture
await this.setupCamera();
// Start detection loop
this.isRunning = true;
this.startDetectionLoop();
} catch (error) {
console.error('Failed to start presence detection:', error);
throw error;
}
}
private async setupCamera(): Promise<void> {
try {
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'user',
},
audio: false,
});
// Create video element
this.video = document.createElement('video');
this.video.srcObject = this.stream;
this.video.autoplay = true;
this.video.playsInline = true;
// Create canvas for processing
this.canvas = document.createElement('canvas');
this.canvas.width = 640;
this.canvas.height = 480;
this.context = this.canvas.getContext('2d');
// Wait for video to be ready
await new Promise<void>((resolve) => {
if (this.video) {
this.video.onloadedmetadata = () => {
this.video?.play();
resolve();
};
}
});
} catch (error) {
console.error('Failed to setup camera:', error);
throw error;
}
}
private startDetectionLoop(): void {
// Run detection every 500ms
this.detectionInterval = setInterval(async () => {
await this.detectPerson();
}, 500);
}
private async detectPerson(): Promise<void> {
if (!this.model || !this.video || !this.context || !this.canvas) return;
try {
// Draw current frame to canvas
this.context.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
// Run detection
const predictions: Detection[] = await this.model.detect(this.canvas);
// Check for person with sufficient confidence
const personDetection = predictions.find(
(p) => p.class === 'person' && p.score >= this.confidenceThreshold
);
if (personDetection) {
this.noPersonCount = 0;
if (!this.personDetected) {
this.personDetected = true;
this.emit('personDetected', personDetection);
}
} else {
this.noPersonCount++;
if (this.personDetected && this.noPersonCount >= this.NO_PERSON_THRESHOLD) {
this.personDetected = false;
this.emit('noPersonDetected');
}
}
} catch (error) {
console.error('Detection error:', error);
}
}
async stop(): Promise<void> {
this.isRunning = false;
if (this.detectionInterval) {
clearInterval(this.detectionInterval);
this.detectionInterval = null;
}
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
if (this.video) {
this.video.srcObject = null;
this.video = null;
}
this.canvas = null;
this.context = null;
this.model = null;
}
isDetecting(): boolean {
return this.isRunning;
}
hasPersonPresent(): boolean {
return this.personDetected;
}
}

View File

@@ -0,0 +1,229 @@
import { BrowserWindow } from 'electron';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// DBus environment for GNOME Wayland - needed to communicate with session services
const DBUS_ENV = {
XDG_RUNTIME_DIR: '/run/user/1000',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
};
export class ScreenManager {
private window: BrowserWindow;
private idleTimeout: number = 300000; // 5 minutes default
private idleTimer: NodeJS.Timeout | null = null;
private isScreenOn: boolean = true;
private lastActivity: number = Date.now();
private screenControlAvailable: boolean = true;
private isWayland: boolean = false;
constructor(window: BrowserWindow) {
this.window = window;
this.detectDisplayServer();
this.setupActivityListeners();
this.startIdleMonitor();
}
private detectDisplayServer(): void {
// Check if running under Wayland
this.isWayland = !!(process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland');
console.log(`ScreenManager: Display server detected - ${this.isWayland ? 'Wayland' : 'X11'}`);
}
private setupActivityListeners(): void {
// Monitor keyboard and mouse activity
this.window.webContents.on('before-input-event', () => {
this.resetIdleTimer();
});
// Also listen for any cursor/pointer events via IPC from renderer
// Touch events on Wayland may not trigger before-input-event
}
// Called from renderer when touch/click detected
public handleUserActivity(): void {
this.lastActivity = Date.now();
// Always try to wake the screen on touch/click - user is actively interacting
if (!this.isScreenOn) {
this.wakeScreen();
}
}
private startIdleMonitor(): void {
this.idleTimer = setInterval(() => {
const idleTime = Date.now() - this.lastActivity;
if (idleTime >= this.idleTimeout && this.isScreenOn && this.screenControlAvailable) {
this.sleepScreen();
}
}, 30000); // Check every 30 seconds (not 10) to reduce spam
}
private resetIdleTimer(): void {
this.lastActivity = Date.now();
if (!this.isScreenOn) {
this.wakeScreen();
}
}
setIdleTimeout(timeout: number): void {
this.idleTimeout = timeout;
}
async wakeScreen(): Promise<void> {
// Always attempt to wake - the screen may have been externally put to sleep by GNOME
if (!this.screenControlAvailable) {
this.isScreenOn = true;
return;
}
try {
if (process.platform === 'linux') {
if (this.isWayland) {
// On GNOME Wayland, we need multiple approaches because:
// 1. The screensaver/lock screen blocks DBus SetActive calls
// 2. We need to wake DPMS separately from screensaver
// 3. Simulating input is the most reliable way to wake
let woke = false;
// Method 1: Simulate mouse movement with ydotool (most reliable on Wayland)
// This bypasses all the GNOME screensaver/DPMS complications
try {
// ydotool syntax: mousemove <x> <y> (relative movement)
await execAsync('ydotool mousemove 1 0 && ydotool mousemove -- -1 0', { env: { ...process.env, ...DBUS_ENV } });
console.log('ScreenManager: Screen woke via ydotool mouse movement');
woke = true;
} catch (e) {
console.log('ScreenManager: ydotool failed:', e);
}
// Method 2: loginctl unlock-session (unlocks GNOME lock screen)
if (!woke) {
try {
await execAsync('loginctl unlock-session', { env: { ...process.env, ...DBUS_ENV } });
console.log('ScreenManager: Session unlocked via loginctl');
woke = true;
} catch (e) {
console.log('ScreenManager: loginctl unlock failed:', e);
}
}
// Method 3: gnome-screensaver-command (older GNOME)
if (!woke) {
try {
await execAsync('gnome-screensaver-command --deactivate', { env: { ...process.env, ...DBUS_ENV } });
console.log('ScreenManager: Screen woke via gnome-screensaver-command');
woke = true;
} catch {
console.log('ScreenManager: gnome-screensaver-command not available');
}
}
// Method 4: DBus SetActive (works when screen is blanked but not locked)
if (!woke) {
try {
await execAsync('dbus-send --session --dest=org.gnome.ScreenSaver --type=method_call /org/gnome/ScreenSaver org.gnome.ScreenSaver.SetActive boolean:false', { env: { ...process.env, ...DBUS_ENV } });
console.log('ScreenManager: Screen woke via dbus-send');
woke = true;
} catch {
console.log('ScreenManager: dbus-send SetActive failed');
}
}
// Method 5: Wake DPMS via wlr-randr or gnome-randr
try {
await execAsync('gnome-randr modify --on DP-1 2>/dev/null || wlr-randr --output DP-1 --on 2>/dev/null || true', { env: { ...process.env, ...DBUS_ENV } });
} catch {
// Ignore - display names vary
}
if (!woke) {
console.log('ScreenManager: All wake methods failed');
}
} else {
// X11 methods
try {
await execAsync('xset dpms force on');
} catch {
try {
await execAsync('xdotool key shift');
} catch {
console.log('ScreenManager: No X11 screen control available, disabling feature');
this.screenControlAvailable = false;
}
}
}
} else if (process.platform === 'win32') {
await execAsync(
'powershell -Command "(Add-Type -MemberDefinition \'[DllImport(\\"user32.dll\\")]public static extern int SendMessage(int hWnd,int hMsg,int wParam,int lParam);\' -Name a -Pas)::SendMessage(-1,0x0112,0xF170,-1)"'
);
} else if (process.platform === 'darwin') {
await execAsync('caffeinate -u -t 1');
}
this.isScreenOn = true;
this.lastActivity = Date.now();
this.window.webContents.send('screen:woke');
} catch (error) {
console.error('Failed to wake screen:', error);
this.isScreenOn = true; // Assume screen is on to prevent retry loops
}
}
async sleepScreen(): Promise<void> {
if (!this.isScreenOn) return;
if (!this.screenControlAvailable) return;
try {
if (process.platform === 'linux') {
if (this.isWayland) {
// Use busctl with DBUS session for GNOME on Wayland
try {
await execAsync('busctl --user call org.gnome.ScreenSaver /org/gnome/ScreenSaver org.gnome.ScreenSaver SetActive b true', { env: { ...process.env, ...DBUS_ENV } });
console.log('ScreenManager: Screen slept via GNOME ScreenSaver DBus');
} catch {
try {
// Fallback: Try wlopm
await execAsync('wlopm --off \\*', { env: { ...process.env, ...DBUS_ENV } });
} catch {
// No working method - disable screen control
console.log('ScreenManager: Wayland screen sleep not available, disabling feature');
this.screenControlAvailable = false;
return;
}
}
} else {
try {
await execAsync('xset dpms force off');
} catch {
console.log('ScreenManager: X11 screen sleep not available, disabling feature');
this.screenControlAvailable = false;
return;
}
}
} else if (process.platform === 'win32') {
await execAsync(
'powershell -Command "(Add-Type -MemberDefinition \'[DllImport(\\"user32.dll\\")]public static extern int SendMessage(int hWnd,int hMsg,int wParam,int lParam);\' -Name a -Pas)::SendMessage(-1,0x0112,0xF170,2)"'
);
} else if (process.platform === 'darwin') {
await execAsync('pmset displaysleepnow');
}
this.isScreenOn = false;
this.window.webContents.send('screen:slept');
} catch (error) {
console.error('Failed to sleep screen:', error);
// Disable feature to prevent spam
this.screenControlAvailable = false;
}
}
destroy(): void {
if (this.idleTimer) {
clearInterval(this.idleTimer);
this.idleTimer = null;
}
}
}

39
imperial-command-center.service Executable file
View File

@@ -0,0 +1,39 @@
[Unit]
Description=Imperial Command Center - Home Assistant Kiosk Dashboard
Documentation=https://github.com/your-repo/imperial-command-center
After=graphical.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=kiosk
Group=kiosk
# Environment
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/kiosk/.Xauthority
Environment=NODE_ENV=production
# Working directory (contains .env file)
WorkingDirectory=/opt/imperial-command-center
# Start command
ExecStart=/opt/imperial-command-center/imperial-command-center.AppImage --no-sandbox
# Restart policy
Restart=always
RestartSec=5
StartLimitInterval=60
StartLimitBurst=3
# Security settings
NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
# Resource limits
MemoryMax=2G
CPUQuota=80%
[Install]
WantedBy=graphical.target

45
index.html Executable file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Imperial Command Center</title>
<style>
/* Prevent flash of unstyled content */
html, body {
background-color: #0a0a0a;
color: #ffffff;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Loading state before React hydrates */
#root:empty::before {
content: '';
display: block;
width: 48px;
height: 48px;
border: 4px solid #cc0000;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@keyframes spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

72
package.json Executable file
View File

@@ -0,0 +1,72 @@
{
"name": "imperial-command-center",
"version": "1.0.0",
"description": "Full-screen touchscreen kiosk dashboard for Home Assistant with Star Wars Imperial theme",
"main": "dist-electron/main.js",
"author": "Homelab",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && tsc -p tsconfig.electron.json",
"build:web": "./node_modules/.bin/tsc && ./node_modules/.bin/vite build",
"preview": "vite preview",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "npm run build && electron-builder",
"build:linux": "npm run build && electron-builder --linux AppImage",
"build:win": "npm run build && electron-builder --win nsis",
"lint": "eslint src --ext ts,tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"home-assistant-js-websocket": "^9.4.0",
"zustand": "^4.5.0",
"googleapis": "^131.0.0",
"@tensorflow/tfjs": "^4.17.0",
"@tensorflow-models/coco-ssd": "^2.2.3",
"electron-store": "^8.1.0",
"date-fns": "^3.3.1"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.homelab.imperial-command-center",
"productName": "Imperial Command Center",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"linux": {
"target": "AppImage",
"category": "Utility"
},
"win": {
"target": "nsis"
},
"nsis": {
"oneClick": true,
"perMachine": true
}
}
}

6
postcss.config.js Executable file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1395
preview-modern.html Executable file

File diff suppressed because it is too large Load Diff

968
preview.html Executable file
View File

@@ -0,0 +1,968 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imperial Command Center - Preview</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--imperial-black: #0a0a0a;
--imperial-dark: #1a1a1a;
--imperial-medium: #2a2a2a;
--imperial-light: #3a3a3a;
--imperial-red: #cc0000;
--imperial-red-light: #ff3333;
--status-armed: #cc0000;
--status-disarmed: #00cc00;
--status-pending: #ff8800;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--imperial-black);
color: #ffffff;
overflow: hidden;
height: 100vh;
}
.font-display {
font-family: 'Orbitron', sans-serif;
}
/* Header */
.header {
height: 64px;
background: var(--imperial-dark);
border-bottom: 1px solid var(--imperial-medium);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.logo {
width: 40px;
height: 40px;
background: var(--imperial-red);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-family: 'Orbitron', sans-serif;
font-size: 1.25rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.header-center {
text-align: center;
}
.time {
font-family: 'Orbitron', sans-serif;
font-size: 1.875rem;
letter-spacing: 0.1em;
}
.date {
font-size: 0.875rem;
color: #9ca3af;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--status-disarmed);
border-radius: 50%;
}
.btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--imperial-medium);
border: 1px solid var(--imperial-light);
border-radius: 4px;
color: white;
font-family: 'Orbitron', sans-serif;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
}
.btn:hover {
border-color: var(--imperial-red);
}
.btn-primary {
background: var(--imperial-red);
border-color: var(--imperial-red);
}
/* Main Content */
.main {
height: calc(100vh - 64px);
padding: 16px;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
/* Widget Base */
.widget {
background: var(--imperial-dark);
border: 1px solid var(--imperial-medium);
border-radius: 4px;
padding: 16px;
display: flex;
flex-direction: column;
}
.widget-title {
font-family: 'Orbitron', sans-serif;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--imperial-red);
border-bottom: 1px solid var(--imperial-medium);
padding-bottom: 8px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.widget-content {
flex: 1;
overflow: auto;
}
/* Calendar Column */
.calendar-col {
grid-column: span 4;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.calendar-day-header {
text-align: center;
font-size: 0.75rem;
color: #6b7280;
padding: 4px;
}
.calendar-day {
aspect-ratio: 1;
background: var(--imperial-medium);
border: 1px solid var(--imperial-light);
padding: 4px;
font-size: 0.75rem;
min-height: 60px;
}
.calendar-day.today {
border-color: var(--imperial-red);
}
.calendar-day.today .day-num {
background: var(--imperial-red);
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-day.other-month {
opacity: 0.3;
}
.calendar-event {
font-size: 0.625rem;
background: rgba(204, 0, 0, 0.3);
color: var(--imperial-red-light);
padding: 2px 4px;
border-radius: 2px;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Middle Column */
.middle-col {
grid-column: span 4;
display: flex;
flex-direction: column;
gap: 16px;
}
.thermostat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.thermostat {
text-align: center;
}
.temp-current-label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.temp-current {
font-family: 'Orbitron', sans-serif;
font-size: 3rem;
color: #f97316;
}
.temp-unit {
font-size: 1.5rem;
color: #9ca3af;
}
.temp-action {
font-size: 0.875rem;
color: #f97316;
margin-bottom: 16px;
}
.temp-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.temp-btn {
width: 48px;
height: 48px;
background: var(--imperial-medium);
border: 1px solid var(--imperial-light);
border-radius: 4px;
color: white;
font-size: 1.5rem;
cursor: pointer;
}
.temp-target {
text-align: center;
}
.temp-target-label {
font-size: 0.625rem;
color: #9ca3af;
text-transform: uppercase;
}
.temp-target-value {
font-family: 'Orbitron', sans-serif;
font-size: 1.5rem;
}
/* Alarmo */
.alarm-status {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
}
.alarm-circle {
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--status-disarmed);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 0 20px rgba(0, 204, 0, 0.3);
}
.alarm-state {
font-family: 'Orbitron', sans-serif;
font-size: 1.25rem;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 24px;
}
.alarm-buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
}
.alarm-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: var(--imperial-medium);
border: 1px solid var(--imperial-light);
border-radius: 4px;
color: white;
cursor: pointer;
}
.alarm-btn:hover {
border-color: var(--imperial-red);
}
.alarm-btn-label {
font-size: 0.75rem;
text-transform: uppercase;
}
/* Right Column */
.right-col {
grid-column: span 4;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Lights */
.lights-widget {
flex: 1;
min-height: 0;
}
.room-section {
margin-bottom: 16px;
}
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.room-name {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.room-controls {
display: flex;
gap: 8px;
}
.room-btn {
font-size: 0.625rem;
padding: 4px 8px;
background: var(--imperial-medium);
border-radius: 4px;
color: #9ca3af;
border: none;
cursor: pointer;
}
.light-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--imperial-medium);
border-radius: 4px;
margin-bottom: 8px;
}
.light-info {
display: flex;
align-items: center;
gap: 12px;
}
.light-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.light-dot.on {
background: #facc15;
box-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
}
.light-dot.off {
background: #4b5563;
}
.toggle {
width: 48px;
height: 24px;
background: var(--imperial-light);
border-radius: 12px;
position: relative;
cursor: pointer;
}
.toggle.on {
background: var(--imperial-red);
}
.toggle-thumb {
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.toggle.on .toggle-thumb {
transform: translateX(24px);
}
/* Locks */
.lock-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--imperial-medium);
border-radius: 4px;
margin-bottom: 8px;
}
.lock-info {
display: flex;
align-items: center;
gap: 12px;
}
.lock-icon {
color: var(--status-disarmed);
}
.lock-status {
font-size: 0.875rem;
color: var(--status-disarmed);
}
.lock-btn {
padding: 8px 16px;
background: var(--status-armed);
border: none;
border-radius: 4px;
color: white;
font-family: 'Orbitron', sans-serif;
text-transform: uppercase;
font-size: 0.75rem;
cursor: pointer;
}
/* Todo */
.todo-input {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-input input {
flex: 1;
background: var(--imperial-medium);
border: 1px solid var(--imperial-light);
border-radius: 4px;
padding: 8px 12px;
color: white;
}
.todo-input input:focus {
outline: none;
border-color: var(--imperial-red);
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--imperial-medium);
border-radius: 4px;
margin-bottom: 8px;
}
.todo-checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--imperial-light);
border-radius: 4px;
cursor: pointer;
}
.todo-checkbox.checked {
background: var(--status-disarmed);
border-color: var(--status-disarmed);
}
/* SVG Icons */
svg {
width: 24px;
height: 24px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.icon-sm svg {
width: 20px;
height: 20px;
}
/* Package Alert */
.package-alert {
background: rgba(255, 136, 0, 0.2);
border: 1px solid var(--status-pending);
border-radius: 4px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.package-icon {
width: 40px;
height: 40px;
background: var(--status-pending);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.package-icon svg {
color: var(--imperial-black);
}
.package-text {
font-family: 'Orbitron', sans-serif;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--status-pending);
}
.package-desc {
font-size: 0.75rem;
color: #9ca3af;
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 24 24" style="width: 24px; height: 24px; fill: white; stroke: none;">
<circle cx="12" cy="12" r="4"/>
</svg>
</div>
<h1 class="header-title">Imperial Command Center</h1>
</div>
<div class="header-center">
<div class="time" id="time">14:32</div>
<div class="date" id="date">Tuesday, February 4, 2025</div>
</div>
<div class="header-right">
<div style="display: flex; align-items: center; gap: 8px; font-size: 0.875rem; color: #9ca3af;">
<div class="status-dot"></div>
Connected
</div>
<button class="btn">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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>
</div>
</header>
<!-- Main Dashboard -->
<main class="main">
<!-- Left Column - Calendar -->
<div class="calendar-col">
<div class="widget" style="height: 100%;">
<div class="widget-title">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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 style="margin-left: auto; display: flex; align-items: center; gap: 8px;">
<button style="background: none; border: none; color: white; cursor: pointer;"></button>
<span style="font-size: 0.875rem;">February 2025</span>
<button style="background: none; border: none; color: white; cursor: pointer;"></button>
</div>
</div>
<div class="widget-content">
<div class="calendar-grid">
<div class="calendar-day-header">Sun</div>
<div class="calendar-day-header">Mon</div>
<div class="calendar-day-header">Tue</div>
<div class="calendar-day-header">Wed</div>
<div class="calendar-day-header">Thu</div>
<div class="calendar-day-header">Fri</div>
<div class="calendar-day-header">Sat</div>
<div class="calendar-day other-month"><div class="day-num">26</div></div>
<div class="calendar-day other-month"><div class="day-num">27</div></div>
<div class="calendar-day other-month"><div class="day-num">28</div></div>
<div class="calendar-day other-month"><div class="day-num">29</div></div>
<div class="calendar-day other-month"><div class="day-num">30</div></div>
<div class="calendar-day other-month"><div class="day-num">31</div></div>
<div class="calendar-day"><div class="day-num">1</div></div>
<div class="calendar-day"><div class="day-num">2</div></div>
<div class="calendar-day"><div class="day-num">3</div><div class="calendar-event">Team Meeting</div></div>
<div class="calendar-day today"><div class="day-num">4</div><div class="calendar-event">Doctor Appt</div></div>
<div class="calendar-day"><div class="day-num">5</div></div>
<div class="calendar-day"><div class="day-num">6</div></div>
<div class="calendar-day"><div class="day-num">7</div><div class="calendar-event">Dinner</div></div>
<div class="calendar-day"><div class="day-num">8</div></div>
<div class="calendar-day"><div class="day-num">9</div></div>
<div class="calendar-day"><div class="day-num">10</div></div>
<div class="calendar-day"><div class="day-num">11</div></div>
<div class="calendar-day"><div class="day-num">12</div><div class="calendar-event">Birthday</div></div>
<div class="calendar-day"><div class="day-num">13</div></div>
<div class="calendar-day"><div class="day-num">14</div><div class="calendar-event">Valentine's</div></div>
<div class="calendar-day"><div class="day-num">15</div></div>
<div class="calendar-day"><div class="day-num">16</div></div>
<div class="calendar-day"><div class="day-num">17</div></div>
<div class="calendar-day"><div class="day-num">18</div></div>
<div class="calendar-day"><div class="day-num">19</div></div>
<div class="calendar-day"><div class="day-num">20</div></div>
<div class="calendar-day"><div class="day-num">21</div></div>
<div class="calendar-day"><div class="day-num">22</div></div>
<div class="calendar-day"><div class="day-num">23</div></div>
<div class="calendar-day"><div class="day-num">24</div></div>
<div class="calendar-day"><div class="day-num">25</div></div>
<div class="calendar-day"><div class="day-num">26</div></div>
<div class="calendar-day"><div class="day-num">27</div></div>
<div class="calendar-day"><div class="day-num">28</div></div>
<div class="calendar-day other-month"><div class="day-num">1</div></div>
</div>
<!-- Selected Day Details -->
<div style="margin-top: 16px; padding: 16px; background: var(--imperial-medium); border-radius: 4px;">
<h4 style="font-family: 'Orbitron', sans-serif; font-size: 0.875rem; color: var(--imperial-red); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px;">Tuesday, February 4</h4>
<div style="font-size: 0.875rem;">
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<span style="color: var(--imperial-red);">10:00 AM</span>
<span>Doctor Appointment</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Middle Column -->
<div class="middle-col">
<!-- Thermostats -->
<div class="thermostat-row">
<div class="widget">
<div class="widget-title">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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>
Upstairs
</div>
<div class="thermostat">
<div class="temp-current-label">Current</div>
<div class="temp-current">72<span class="temp-unit">°F</span></div>
<div class="temp-action">Heating</div>
<div class="temp-controls">
<button class="temp-btn"></button>
<div class="temp-target">
<div class="temp-target-label">Target</div>
<div class="temp-target-value">70°</div>
</div>
<button class="temp-btn">+</button>
</div>
</div>
</div>
<div class="widget">
<div class="widget-title">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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>
Downstairs
</div>
<div class="thermostat">
<div class="temp-current-label">Current</div>
<div class="temp-current" style="color: #9ca3af;">68<span class="temp-unit">°F</span></div>
<div class="temp-action" style="color: #9ca3af;">Idle</div>
<div class="temp-controls">
<button class="temp-btn"></button>
<div class="temp-target">
<div class="temp-target-label">Target</div>
<div class="temp-target-value">68°</div>
</div>
<button class="temp-btn">+</button>
</div>
</div>
</div>
</div>
<!-- Alarmo -->
<div class="widget">
<div class="widget-title">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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 class="alarm-status">
<div class="alarm-circle">
<svg viewBox="0 0 24 24" style="width: 48px; height: 48px; color: var(--imperial-black);">
<path 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 class="alarm-state">Disarmed</div>
<div class="alarm-buttons">
<button class="alarm-btn">
<svg viewBox="0 0 24 24" style="width: 32px; height: 32px;">
<path 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 class="alarm-btn-label">Home</span>
</button>
<button class="alarm-btn">
<svg viewBox="0 0 24 24" style="width: 32px; height: 32px;">
<path 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 class="alarm-btn-label">Away</span>
</button>
<button class="alarm-btn">
<svg viewBox="0 0 24 24" style="width: 32px; height: 32px;">
<path 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 class="alarm-btn-label">Night</span>
</button>
</div>
</div>
</div>
<!-- Package Alert -->
<div class="package-alert">
<div class="package-icon">
<svg viewBox="0 0 24 24" style="width: 24px; height: 24px;">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<div>
<div class="package-text">Package Detected</div>
<div class="package-desc">A package has been detected at your door</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="right-col">
<!-- Lights -->
<div class="widget lights-widget">
<div class="widget-title" style="justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px;">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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
</div>
<button class="btn-primary" style="font-size: 0.625rem; padding: 4px 12px;">All Off</button>
</div>
<div class="widget-content">
<!-- Living Room -->
<div class="room-section">
<div class="room-header">
<span class="room-name">Living Room</span>
<div class="room-controls">
<button class="room-btn">All On</button>
<button class="room-btn">All Off</button>
</div>
</div>
<div class="light-row">
<div class="light-info">
<div class="light-dot on"></div>
<span>Main Light</span>
</div>
<div class="toggle on"><div class="toggle-thumb"></div></div>
</div>
<div class="light-row">
<div class="light-info">
<div class="light-dot off"></div>
<span style="color: #9ca3af;">Lamp</span>
</div>
<div class="toggle"><div class="toggle-thumb"></div></div>
</div>
</div>
<!-- Kitchen -->
<div class="room-section">
<div class="room-header">
<span class="room-name">Kitchen</span>
<div class="room-controls">
<button class="room-btn">All On</button>
<button class="room-btn">All Off</button>
</div>
</div>
<div class="light-row">
<div class="light-info">
<div class="light-dot on"></div>
<span>Main Light</span>
</div>
<div class="toggle on"><div class="toggle-thumb"></div></div>
</div>
<div class="light-row">
<div class="light-info">
<div class="light-dot on"></div>
<span>Under Cabinet</span>
</div>
<div class="toggle on"><div class="toggle-thumb"></div></div>
</div>
</div>
</div>
</div>
<!-- Locks -->
<div class="widget">
<div class="widget-title" style="justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px;">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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>
Door Locks
</div>
<button style="font-size: 0.625rem; padding: 4px 12px; background: var(--status-disarmed); border: none; border-radius: 4px; color: var(--imperial-black); cursor: pointer;">Lock All</button>
</div>
<div class="widget-content">
<div class="lock-row">
<div class="lock-info">
<svg class="lock-icon" viewBox="0 0 24 24" style="width: 24px; height: 24px;">
<path 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>
<div>Front Door</div>
<div class="lock-status">Locked</div>
</div>
</div>
<button class="lock-btn">Unlock</button>
</div>
<div class="lock-row">
<div class="lock-info">
<svg class="lock-icon" viewBox="0 0 24 24" style="width: 24px; height: 24px;">
<path 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>
<div>Back Door</div>
<div class="lock-status">Locked</div>
</div>
</div>
<button class="lock-btn">Unlock</button>
</div>
</div>
</div>
<!-- Todo -->
<div class="widget">
<div class="widget-title">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path 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 List
<span style="margin-left: auto; background: var(--imperial-red); padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem;">3</span>
</div>
<div class="widget-content">
<div class="todo-input">
<input type="text" placeholder="Add new item...">
<button class="btn-primary" style="padding: 8px 12px;">+</button>
</div>
<div class="todo-item">
<div class="todo-checkbox"></div>
<span>Buy groceries</span>
</div>
<div class="todo-item">
<div class="todo-checkbox"></div>
<span>Schedule HVAC maintenance</span>
</div>
<div class="todo-item">
<div class="todo-checkbox"></div>
<span>Update firewall rules</span>
</div>
</div>
</div>
</div>
</main>
<script>
// Update time
function updateTime() {
const now = new Date();
document.getElementById('time').textContent = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
document.getElementById('date').textContent = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
updateTime();
setInterval(updateTime, 1000);
</script>
</body>
</html>

136
scripts/deploy-linux.sh Executable file
View File

@@ -0,0 +1,136 @@
#!/bin/bash
# Imperial Command Center - Linux Deployment Script
# This script builds and deploys the kiosk application
set -e
# Configuration
APP_NAME="imperial-command-center"
INSTALL_DIR="/opt/${APP_NAME}"
SERVICE_NAME="${APP_NAME}"
KIOSK_USER="kiosk"
echo "========================================="
echo "Imperial Command Center - Linux Deployer"
echo "========================================="
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo)"
exit 1
fi
# Build the application
echo ""
echo "[1/6] Building application..."
npm run build:linux
# Find the AppImage
APPIMAGE=$(find release -name "*.AppImage" -type f | head -n 1)
if [ -z "$APPIMAGE" ]; then
echo "Error: AppImage not found in release directory"
exit 1
fi
echo "Found AppImage: $APPIMAGE"
# Create installation directory
echo ""
echo "[2/6] Installing to ${INSTALL_DIR}..."
mkdir -p "${INSTALL_DIR}"
cp "$APPIMAGE" "${INSTALL_DIR}/${APP_NAME}.AppImage"
chmod +x "${INSTALL_DIR}/${APP_NAME}.AppImage"
# Copy environment file if it exists
if [ -f ".env" ]; then
cp .env "${INSTALL_DIR}/.env"
echo "Copied .env file"
fi
# Create kiosk user if it doesn't exist
echo ""
echo "[3/6] Setting up kiosk user..."
if ! id "${KIOSK_USER}" &>/dev/null; then
useradd -m -s /bin/bash "${KIOSK_USER}"
echo "Created user: ${KIOSK_USER}"
else
echo "User ${KIOSK_USER} already exists"
fi
# Set permissions
chown -R "${KIOSK_USER}:${KIOSK_USER}" "${INSTALL_DIR}"
# Create systemd service
echo ""
echo "[4/6] Creating systemd service..."
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit]
Description=Imperial Command Center Kiosk
After=graphical.target network.target
Wants=graphical.target
[Service]
Type=simple
User=${KIOSK_USER}
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/${KIOSK_USER}/.Xauthority
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/${APP_NAME}.AppImage --no-sandbox
Restart=always
RestartSec=5
[Install]
WantedBy=graphical.target
EOF
# Create X session script for autologin
echo ""
echo "[5/6] Configuring kiosk session..."
mkdir -p /home/${KIOSK_USER}/.config/autostart
cat > "/home/${KIOSK_USER}/.xinitrc" << EOF
#!/bin/bash
# Disable screen blanking
xset s off
xset -dpms
xset s noblank
# Hide cursor after 5 seconds of inactivity
unclutter -idle 5 &
# Start the application
exec ${INSTALL_DIR}/${APP_NAME}.AppImage --no-sandbox
EOF
chmod +x "/home/${KIOSK_USER}/.xinitrc"
chown "${KIOSK_USER}:${KIOSK_USER}" "/home/${KIOSK_USER}/.xinitrc"
# Enable and start the service
echo ""
echo "[6/6] Enabling service..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
echo ""
echo "========================================="
echo "Deployment complete!"
echo "========================================="
echo ""
echo "The application has been installed to: ${INSTALL_DIR}"
echo ""
echo "To start the application manually:"
echo " sudo systemctl start ${SERVICE_NAME}"
echo ""
echo "To view logs:"
echo " sudo journalctl -u ${SERVICE_NAME} -f"
echo ""
echo "To configure autologin for kiosk mode, edit:"
echo " /etc/lightdm/lightdm.conf (for LightDM)"
echo " or /etc/gdm3/custom.conf (for GDM)"
echo ""
echo "Add these lines for LightDM autologin:"
echo " [Seat:*]"
echo " autologin-user=${KIOSK_USER}"
echo " autologin-session=openbox"
echo ""

34
scripts/kiosk-session.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Imperial Command Center - Kiosk Session Starter
# This script sets up the display environment and launches the kiosk app
# Configuration
APP_DIR="/opt/imperial-command-center"
# Disable screen saver and power management
xset s off
xset -dpms
xset s noblank
# Disable screen blanking
xset b off
# Hide cursor after 5 seconds of inactivity (requires unclutter)
if command -v unclutter &> /dev/null; then
unclutter -idle 5 -root &
fi
# Set display resolution (uncomment and modify as needed)
# xrandr --output HDMI-1 --mode 1920x1080
# Wait for network (useful for WiFi connections)
sleep 2
# Change to app directory (for .env file loading)
cd "$APP_DIR"
# Launch the application
# --no-sandbox is required when running as root or in some container environments
# Remove this flag if running as a regular user with proper permissions
exec "${APP_DIR}/imperial-command-center.AppImage" --no-sandbox

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

84
tailwind.config.js Executable file
View File

@@ -0,0 +1,84 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Modern dark theme
dark: {
primary: '#0f0f0f',
secondary: '#171717',
tertiary: '#1f1f1f',
elevated: '#262626',
hover: '#2a2a2a',
border: '#2e2e2e',
'border-light': '#3a3a3a',
},
// Blue accent
accent: {
DEFAULT: '#3b82f6',
light: '#60a5fa',
dark: '#2563eb',
},
// Status colors
status: {
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
// Legacy imperial colors (for gradual migration)
imperial: {
black: '#0a0a0a',
dark: '#1a1a1a',
medium: '#2a2a2a',
light: '#3a3a3a',
red: '#cc0000',
'red-dark': '#990000',
'red-light': '#ff3333',
},
},
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
},
fontSize: {
'touch': '1.125rem',
},
spacing: {
'touch': '44px',
'touch-lg': '56px',
},
borderRadius: {
'xl': '0.75rem',
'2xl': '1rem',
},
boxShadow: {
'glow-green': '0 0 8px rgba(34, 197, 94, 0.3)',
'glow-blue': '0 0 8px rgba(59, 130, 246, 0.3)',
'glow-orange': '0 0 8px rgba(249, 115, 22, 0.3)',
},
animation: {
'fade-in': 'fade-in 0.2s ease-out',
'slide-up': 'slide-up 0.3s ease-out',
'pulse': 'pulse 2s ease-in-out infinite',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'pulse': {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' },
},
},
},
},
plugins: [],
};

18
tsconfig.electron.json Executable file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "dist-electron",
"rootDir": "electron",
"declaration": false
},
"include": ["electron/**/*"]
}

30
tsconfig.json Executable file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/hooks/*": ["src/hooks/*"],
"@/services/*": ["src/services/*"],
"@/config/*": ["src/config/*"],
"@/styles/*": ["src/styles/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Executable file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Executable file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
base: './',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@/components': path.resolve(__dirname, './src/components'),
'@/hooks': path.resolve(__dirname, './src/hooks'),
'@/services': path.resolve(__dirname, './src/services'),
'@/config': path.resolve(__dirname, './src/config'),
'@/styles': path.resolve(__dirname, './src/styles'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
});