commit 97a7912eae8a2e5350db0d20e3f4843d4d7aed94 Author: root Date: Wed Feb 25 23:01:20 2026 -0600 Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e83d5d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..128683a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100755 index 0000000..b631c35 --- /dev/null +++ b/README.md @@ -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 diff --git a/SETUP.md b/SETUP.md new file mode 100755 index 0000000..720a12d --- /dev/null +++ b/SETUP.md @@ -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 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 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 diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..b05c20f --- /dev/null +++ b/electron/main.ts @@ -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); +}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..043b806 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,74 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; + +export interface ElectronAPI { + screen: { + wake: () => Promise; + sleep: () => Promise; + setIdleTimeout: (timeout: number) => Promise; + activity: () => Promise; + }; + presence: { + start: () => Promise; + stop: () => Promise; + onDetected: (callback: () => void) => () => void; + onCleared: (callback: () => void) => () => void; + }; + frigate: { + startStream: (rtspUrl: string) => Promise; + stopStream: () => Promise; + }; + app: { + quit: () => void; + toggleFullscreen: () => void; + toggleDevTools: () => void; + }; + config: { + getStoredToken: () => Promise; + getJellyfinApiKey: () => Promise; + }; +} + +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; + } +} diff --git a/electron/services/FrigateStreamer.ts b/electron/services/FrigateStreamer.ts new file mode 100644 index 0000000..2cc9c0a --- /dev/null +++ b/electron/services/FrigateStreamer.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/electron/services/MotionDetector.ts b/electron/services/MotionDetector.ts new file mode 100644 index 0000000..71014a0 --- /dev/null +++ b/electron/services/MotionDetector.ts @@ -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 { + 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 { + // 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; + } +} diff --git a/electron/services/PresenceDetector.ts b/electron/services/PresenceDetector.ts new file mode 100644 index 0000000..c10b451 --- /dev/null +++ b/electron/services/PresenceDetector.ts @@ -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> | 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 { + 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 { + 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((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 { + 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 { + 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; + } +} diff --git a/electron/services/ScreenManager.ts b/electron/services/ScreenManager.ts new file mode 100644 index 0000000..fe06039 --- /dev/null +++ b/electron/services/ScreenManager.ts @@ -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 { + // 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 (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 { + 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; + } + } +} diff --git a/imperial-command-center.service b/imperial-command-center.service new file mode 100755 index 0000000..949b801 --- /dev/null +++ b/imperial-command-center.service @@ -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 diff --git a/index.html b/index.html new file mode 100755 index 0000000..1dac5de --- /dev/null +++ b/index.html @@ -0,0 +1,45 @@ + + + + + + + + + Imperial Command Center + + + +
+ + + diff --git a/package.json b/package.json new file mode 100755 index 0000000..b21207b --- /dev/null +++ b/package.json @@ -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 + } + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100755 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/preview-modern.html b/preview-modern.html new file mode 100755 index 0000000..129ce4e --- /dev/null +++ b/preview-modern.html @@ -0,0 +1,1395 @@ + + + + + + Home Command Center - Modern Preview + + + + + +
+
+ +

Home Command Center

+
+ +
+
14:32
+
Tuesday, February 4, 2025
+
+ +
+ +
+ +
+ + + +
Package at door
+
+ + +
+
+ John +
+ Home +
+ + +
+
+ Sarah +
+ Work +
+
+ +
+
+ Connected +
+ + +
+
+ + +
+ +
+
+
+ + + + Calendar +
+
+
+ February 2025 +
+ + +
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+ +
26
+
27
+
28
+
29
+
30
+
31
+
1
+ +
2
+
3
Team Mtg
+
4
Doctor
+
5
+
6
+
7
Dinner
+
8
+ +
9
+
10
+
11
+
12
Birthday
+
13
+
14
V-Day
+
15
+ +
16
+
17
+
18
+
19
+
20
+
21
+
22
+ +
23
+
24
+
25
+
26
+
27
+
28
+
1
+
+ +
+
Tuesday, February 4
+
+ 10:00 AM + Doctor Appointment +
+
+
+
+
+ + +
+ +
+
+
+ + + + Upstairs +
+
+
Current
+
72°
+
Heating to 70°
+
+ +
+
+
Heat
+
70°
+
+ +
+
Cool
+
76°
+
+
+ +
+
+ + Heat • Cool +
+
+
+ +
+
+ + + + Downstairs +
+
+
Current
+
73°
+
Idle
+
+ +
+
+
Heat
+
68°
+
+ +
+
Cool
+
75°
+
+
+ +
+
+ + Heat • Cool +
+
+
+
+ + +
+
+ + + + Security +
+
+
+ + + +
+
Disarmed
+
+ + + +
+
+
+
+ + +
+ +
+
+
+ + + + Lights +
+ +
+
+ +
+
+ Living Room +
+ + +
+
+
+
+
+ Main Light +
+
+
+
+
+
+ Floor Lamp +
+
+
+
+ + +
+
+ Kitchen +
+ + +
+
+
+
+
+ Main Light +
+
+
+
+
+
+ Under Cabinet +
+
+
+
+
+
+ + +
+
+
+ + + + Locks +
+ +
+
+
+
+
+ + + +
+
+
Front Door
+
Locked
+
+
+ +
+
+
+
+ + + +
+
+
Back Door
+
Locked
+
+
+ +
+
+
+ + +
+
+ + + + To-Do + 3 +
+
+
+ + +
+
+
+ Buy groceries +
+
+
+ Schedule HVAC maintenance +
+
+
+ Update firewall rules +
+
+
+
+
+ + + + diff --git a/preview.html b/preview.html new file mode 100755 index 0000000..6aaf54b --- /dev/null +++ b/preview.html @@ -0,0 +1,968 @@ + + + + + + Imperial Command Center - Preview + + + + + +
+
+ +

Imperial Command Center

+
+ +
+
14:32
+
Tuesday, February 4, 2025
+
+ +
+
+
+ Connected +
+ +
+
+ + +
+ +
+
+
+ + + + Calendar +
+ + February 2025 + +
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+ +
26
+
27
+
28
+
29
+
30
+
31
+
1
+ +
2
+
3
Team Meeting
+
4
Doctor Appt
+
5
+
6
+
7
Dinner
+
8
+ +
9
+
10
+
11
+
12
Birthday
+
13
+
14
Valentine's
+
15
+ +
16
+
17
+
18
+
19
+
20
+
21
+
22
+ +
23
+
24
+
25
+
26
+
27
+
28
+
1
+
+ + +
+

Tuesday, February 4

+
+
+ 10:00 AM + Doctor Appointment +
+
+
+
+
+
+ + +
+ +
+
+
+ + + + Upstairs +
+
+
Current
+
72°F
+
Heating
+
+ +
+
Target
+
70°
+
+ +
+
+
+ +
+
+ + + + Downstairs +
+
+
Current
+
68°F
+
Idle
+
+ +
+
Target
+
68°
+
+ +
+
+
+
+ + +
+
+ + + + Alarmo +
+
+
+ + + +
+
Disarmed
+
+ + + +
+
+
+ + +
+
+ + + +
+
+
Package Detected
+
A package has been detected at your door
+
+
+
+ + +
+ +
+
+
+ + + + Lights +
+ +
+
+ +
+
+ Living Room +
+ + +
+
+
+
+
+ Main Light +
+
+
+
+
+
+ Lamp +
+
+
+
+ + +
+
+ Kitchen +
+ + +
+
+
+
+
+ Main Light +
+
+
+
+
+
+ Under Cabinet +
+
+
+
+
+
+ + +
+
+
+ + + + Door Locks +
+ +
+
+
+
+ + + +
+
Front Door
+
Locked
+
+
+ +
+
+
+ + + +
+
Back Door
+
Locked
+
+
+ +
+
+
+ + +
+
+ + + + To-Do List + 3 +
+
+
+ + +
+
+
+ Buy groceries +
+
+
+ Schedule HVAC maintenance +
+
+
+ Update firewall rules +
+
+
+
+
+ + + + diff --git a/scripts/deploy-linux.sh b/scripts/deploy-linux.sh new file mode 100755 index 0000000..05357f3 --- /dev/null +++ b/scripts/deploy-linux.sh @@ -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 "" diff --git a/scripts/kiosk-session.sh b/scripts/kiosk-session.sh new file mode 100755 index 0000000..0c6e27c --- /dev/null +++ b/scripts/kiosk-session.sh @@ -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 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..11d7f73 --- /dev/null +++ b/src/App.tsx @@ -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 ( +
+
+
+
+

+ Person Detected - Front Porch +

+
+ +
+
+ +
+
+ ); +} + +// Simple thermostat temp display +function ThermostatTemp({ entityId }: { entityId: string }) { + const currentTemp = useEntityAttribute(entityId, 'current_temperature'); + return <>{currentTemp?.toFixed(0) ?? '--'}°; +} + +function ConnectionPrompt() { + const openSettings = useUIStore((state) => state.openSettings); + + return ( +
+
+
+ + + +
+

+ Imperial Command Center +

+

+ Enter your Home Assistant long-lived access token to connect. +

+ +
+
+ ); +} + +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) */} +
+ {config.calendar && ( +
+ +
+ )} +
+ + {/* Right Column - Controls, Alarm, Todo */} +
+ {/* Control Buttons Row - Lights, Locks, Thermostats */} +
+ {config.lights.length > 0 && ( + + )} + {config.locks.length > 0 && ( + + )} + {config.thermostats.map((thermostat) => ( + + ))} +
+ + {/* Alarm Panel */} + {config.alarm && } + + {/* Todo List */} + {config.todoList && ( +
+ +
+ )} +
+ + ); +} + +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 ( + <> + + {settingsOpen && } + + ); + } + + // Show loading state + if (connectionState === 'connecting') { + return ( +
+
+
+

+ Connecting to Home Assistant... +

+
+
+ ); + } + + // Show error state + if (connectionState === 'error') { + return ( +
+
+
+ + + +
+

+ Connection Error +

+

+ Failed to connect to Home Assistant. Please check your configuration. +

+ +
+
+ ); + } + + return ( + <> + + + + {lightsOverlayOpen && } + {locksOverlayOpen && } + {thermostatsOverlayOpen && } + {mediaOverlayOpen && } + {cameraOverlayOpen && } + {settingsOpen && } + {showFrontPorchAlert && } + + + ); +} diff --git a/src/components/alarm/AlarmoPanel.tsx b/src/components/alarm/AlarmoPanel.tsx new file mode 100644 index 0000000..7ac3947 --- /dev/null +++ b/src/components/alarm/AlarmoPanel.tsx @@ -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(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 ( +
+
+ + + + Alarm +
+
+ Loading... +
+
+ ); + } + + return ( +
+
+ + + + Alarmo +
+ +
+ {/* Keypad Modal - shown when alarm is armed */} + {keypad.isOpen ? ( +
+ + {(localError || error) && ( +
+ {localError || error} +
+ )} +
+ ) : ( + <> + {/* Status Display */} +
+
+ {isTriggered ? ( + + + + ) : isArmed ? ( + + + + ) : ( + + + + )} +
+
+ {getStateText()} +
+
+ + {/* Arm Buttons */} + {isDisarmed && ( +
+ + + +
+ )} + + {/* Disarm Button - shows keypad when clicked */} + {(isArmed || isPending || isTriggered) && ( + + )} + + {/* Error Display */} + {(localError || error) && ( + + )} + + )} +
+
+ ); +} diff --git a/src/components/alarm/KeyPad.tsx b/src/components/alarm/KeyPad.tsx new file mode 100644 index 0000000..94e887e --- /dev/null +++ b/src/components/alarm/KeyPad.tsx @@ -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 ( +
+ {/* Title */} +

+ {title} +

+ + {/* Code Display */} +
+ {Array.from({ length: maxLength }).map((_, i) => ( +
+ ))} +
+ + {/* Keypad Grid */} +
+ {digits.map((digit, index) => ( + digit ? ( + + ) : ( +
+ ) + ))} +
+ + {/* Action Buttons */} +
+ + +
+ + {/* Submit/Cancel */} +
+ + +
+
+ ); +} diff --git a/src/components/alarm/index.ts b/src/components/alarm/index.ts new file mode 100644 index 0000000..dcaac83 --- /dev/null +++ b/src/components/alarm/index.ts @@ -0,0 +1,2 @@ +export { AlarmoPanel } from './AlarmoPanel'; +export { KeyPad } from './KeyPad'; diff --git a/src/components/alerts/PackageStatus.tsx b/src/components/alerts/PackageStatus.tsx new file mode 100644 index 0000000..c1575ea --- /dev/null +++ b/src/components/alerts/PackageStatus.tsx @@ -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 ( +
+
+
+ + + +
+
+
+ Package Detected +
+
+ A package has been detected at your door +
+
+
+
+ ); +} diff --git a/src/components/alerts/PersonDetectionAlert.tsx b/src/components/alerts/PersonDetectionAlert.tsx new file mode 100644 index 0000000..bc7bd21 --- /dev/null +++ b/src/components/alerts/PersonDetectionAlert.tsx @@ -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 ( +
+ {/* Alert Header */} +
+
+
+ + + +
+
+

+ Person Detected +

+

+ {cameraConfig.displayName} +

+
+
+ +
+ {/* Countdown */} +
+
Auto-dismiss
+
{countdown}s
+
+ + {/* Dismiss Button */} + +
+
+ + {/* Camera Feed */} +
+ +
+ + {/* Quick Actions */} +
+ +
+
+ ); +} diff --git a/src/components/alerts/index.ts b/src/components/alerts/index.ts new file mode 100644 index 0000000..0a1af2e --- /dev/null +++ b/src/components/alerts/index.ts @@ -0,0 +1,2 @@ +export { PersonDetectionAlert } from './PersonDetectionAlert'; +export { PackageStatus } from './PackageStatus'; diff --git a/src/components/calendar/CalendarWidget.tsx b/src/components/calendar/CalendarWidget.tsx new file mode 100644 index 0000000..97d3984 --- /dev/null +++ b/src/components/calendar/CalendarWidget.tsx @@ -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 ( +
+ {formatEventTime(event)} {event.summary} +
+ ); +} + +function DayCell({ + date, + isCurrentMonth, + events, + isSelected, + onSelect, +}: { + date: Date; + isCurrentMonth: boolean; + events: CalendarEvent[]; + isSelected: boolean; + onSelect: (date: Date) => void; +}) { + const today = isToday(date); + + return ( + + ); +} + +function EventDetails({ date, events, onAddEvent }: { date: Date; events: CalendarEvent[]; onAddEvent: () => void }) { + return ( +
+
+

+ {format(date, 'EEEE, MMM d')} +

+ +
+ {events.length === 0 ? ( +

No events

+ ) : ( +
+ {events.map((event) => ( +
+
+ + {formatEventTime(event)} + + {event.summary} +
+ {event.location && ( +
+ {event.location} +
+ )} +
+ ))} +
+ )} +
+ ); +} + +interface AddEventFormProps { + initialDate: Date; + onSubmit: (data: { + summary: string; + startDateTime: Date; + endDateTime: Date; + description?: string; + location?: string; + allDay?: boolean; + }) => Promise; + 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(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 ( + <> +
+
+

New Event

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Event Title */} + + + {/* Date and All Day */} +
+ 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" + /> + +
+ + {/* Time Selection */} + {!allDay && ( +
+ 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" + /> + to + 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" + /> +
+ )} + + {/* Location */} + + + {/* Buttons */} +
+ + +
+
+ + {/* Virtual Keyboard */} + {activeField && ( + setActiveField(null)} + /> + )} + + ); +} + +export function CalendarWidget() { + const { + isAuthenticated, + currentMonth, + isLoading, + error, + nextMonth, + prevMonth, + goToToday, + getEventsForDate, + createEvent, + } = useCalendar(); + + const [selectedDate, setSelectedDate] = useState(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[0]) => { + await createEvent(data); + setShowAddForm(false); + }; + + if (!isAuthenticated) { + return ( +
+
+ + + + Calendar +
+
+

+ Waiting for Home Assistant connection... +

+

+ Calendar will load automatically when connected +

+
+
+ ); + } + + return ( +
+
+ + + + Calendar + + {/* Month Navigation */} +
+ + + +
+
+ +
+ {/* Error Display */} + {error && ( +
+ {error} +
+ )} + + {/* Loading Indicator */} + {isLoading && ( +
+
+
+ )} + + {/* Day Headers */} +
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {calendarDays.map((date) => ( + { + setSelectedDate(d); + setShowAddForm(false); + }} + /> + ))} +
+ + {/* Selected Day Details or Add Form */} + {showAddForm && selectedDate ? ( + setShowAddForm(false)} + /> + ) : selectedDate ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 0000000..37da0a1 --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1 @@ +export { CalendarWidget } from './CalendarWidget'; diff --git a/src/components/cameras/CameraFeed.tsx b/src/components/cameras/CameraFeed.tsx new file mode 100644 index 0000000..28bad23 --- /dev/null +++ b/src/components/cameras/CameraFeed.tsx @@ -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(null); + const webrtcRef = useRef(null); + const [isConnecting, setIsConnecting] = useState(true); + const [error, setError] = useState(null); + + // Use substream for grid view (lower bandwidth) + const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream; + + useEffect(() => { + let mounted = true; + let timeoutId: ReturnType; + + 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 ( +
+ {/* Video Element */} +