Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal 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
30
.gitignore
vendored
Normal 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
188
README.md
Executable 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
715
SETUP.md
Executable 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
229
electron/main.ts
Normal 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
74
electron/preload.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
177
electron/services/FrigateStreamer.ts
Normal file
177
electron/services/FrigateStreamer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
137
electron/services/MotionDetector.ts
Normal file
137
electron/services/MotionDetector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
170
electron/services/PresenceDetector.ts
Normal file
170
electron/services/PresenceDetector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
229
electron/services/ScreenManager.ts
Normal file
229
electron/services/ScreenManager.ts
Normal 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
39
imperial-command-center.service
Executable 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
45
index.html
Executable 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
72
package.json
Executable 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
6
postcss.config.js
Executable file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1395
preview-modern.html
Executable file
1395
preview-modern.html
Executable file
File diff suppressed because it is too large
Load Diff
968
preview.html
Executable file
968
preview.html
Executable 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
136
scripts/deploy-linux.sh
Executable 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
34
scripts/kiosk-session.sh
Executable 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
328
src/App.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
src/components/alarm/AlarmoPanel.tsx
Normal file
266
src/components/alarm/AlarmoPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/alarm/KeyPad.tsx
Normal file
128
src/components/alarm/KeyPad.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/alarm/index.ts
Normal file
2
src/components/alarm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AlarmoPanel } from './AlarmoPanel';
|
||||
export { KeyPad } from './KeyPad';
|
||||
37
src/components/alerts/PackageStatus.tsx
Normal file
37
src/components/alerts/PackageStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/alerts/PersonDetectionAlert.tsx
Normal file
100
src/components/alerts/PersonDetectionAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/alerts/index.ts
Normal file
2
src/components/alerts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PersonDetectionAlert } from './PersonDetectionAlert';
|
||||
export { PackageStatus } from './PackageStatus';
|
||||
471
src/components/calendar/CalendarWidget.tsx
Normal file
471
src/components/calendar/CalendarWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/calendar/index.ts
Normal file
1
src/components/calendar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CalendarWidget } from './CalendarWidget';
|
||||
122
src/components/cameras/CameraFeed.tsx
Normal file
122
src/components/cameras/CameraFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/cameras/CameraOverlay.tsx
Normal file
82
src/components/cameras/CameraOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/cameras/index.ts
Normal file
2
src/components/cameras/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CameraFeed } from './CameraFeed';
|
||||
export { CameraOverlay } from './CameraOverlay';
|
||||
188
src/components/climate/ThermostatOverlay.tsx
Normal file
188
src/components/climate/ThermostatOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/components/climate/ThermostatWidget.tsx
Normal file
191
src/components/climate/ThermostatWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/climate/index.ts
Normal file
2
src/components/climate/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ThermostatWidget } from './ThermostatWidget';
|
||||
export { ThermostatOverlay } from './ThermostatOverlay';
|
||||
57
src/components/keyboard/GlobalKeyboard.tsx
Normal file
57
src/components/keyboard/GlobalKeyboard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/components/keyboard/KeyboardInput.tsx
Normal file
58
src/components/keyboard/KeyboardInput.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
src/components/keyboard/VirtualKeyboard.tsx
Normal file
111
src/components/keyboard/VirtualKeyboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/keyboard/index.ts
Normal file
3
src/components/keyboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { VirtualKeyboard } from './VirtualKeyboard';
|
||||
export { KeyboardInput } from './KeyboardInput';
|
||||
export { GlobalKeyboard } from './GlobalKeyboard';
|
||||
39
src/components/layout/Dashboard.tsx
Normal file
39
src/components/layout/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/components/layout/Header.tsx
Normal file
206
src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/layout/index.ts
Normal file
2
src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Dashboard } from './Dashboard';
|
||||
export { Header } from './Header';
|
||||
137
src/components/lights/LightsOverlay.tsx
Normal file
137
src/components/lights/LightsOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/lights/LightsWidget.tsx
Normal file
112
src/components/lights/LightsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/lights/index.ts
Normal file
2
src/components/lights/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LightsWidget } from './LightsWidget';
|
||||
export { LightsOverlay } from './LightsOverlay';
|
||||
154
src/components/locks/LocksOverlay.tsx
Normal file
154
src/components/locks/LocksOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/components/locks/LocksWidget.tsx
Normal file
129
src/components/locks/LocksWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/locks/index.ts
Normal file
2
src/components/locks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LocksWidget } from './LocksWidget';
|
||||
export { LocksOverlay } from './LocksOverlay';
|
||||
239
src/components/media/JellyfinOverlay.tsx
Normal file
239
src/components/media/JellyfinOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/media/index.ts
Normal file
1
src/components/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { JellyfinOverlay } from './JellyfinOverlay';
|
||||
94
src/components/settings/ConnectionModal.tsx
Normal file
94
src/components/settings/ConnectionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
683
src/components/settings/SettingsPanel.tsx
Normal file
683
src/components/settings/SettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/settings/index.ts
Normal file
2
src/components/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { ConnectionModal } from './ConnectionModal';
|
||||
195
src/components/todo/TodoWidget.tsx
Normal file
195
src/components/todo/TodoWidget.tsx
Normal 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/todo/index.ts
Normal file
1
src/components/todo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TodoWidget } from './TodoWidget';
|
||||
149
src/config/entities.ts
Normal file
149
src/config/entities.ts
Normal 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
30
src/config/environment.ts
Normal 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
6
src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useHomeAssistant } from './useHomeAssistant';
|
||||
export { useEntity, useEntityStateValue, useEntityAttributeValue, useClimate, useLight, useLock, useAlarm, useBinarySensor } from './useEntity';
|
||||
export { useAlarmo, type AlarmoAction } from './useAlarmo';
|
||||
export { useTodo, type TodoItem } from './useTodo';
|
||||
export { useCalendar } from './useCalendar';
|
||||
export { useLocalPresence } from './useLocalPresence';
|
||||
81
src/hooks/useAlarmo.ts
Normal file
81
src/hooks/useAlarmo.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAlarm } from './useEntity';
|
||||
import { alarmServices } from '@/services/homeAssistant';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
export type AlarmoAction = 'arm_home' | 'arm_away' | 'arm_night' | 'disarm';
|
||||
|
||||
export function useAlarmo() {
|
||||
const alarmEntityId = useSettingsStore((state) => state.config.alarm);
|
||||
const alarm = useAlarm(alarmEntityId || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const executeAction = useCallback(async (action: AlarmoAction, code?: string) => {
|
||||
if (!alarmEntityId) {
|
||||
setError('No alarm configured');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const entityId = alarmEntityId;
|
||||
|
||||
switch (action) {
|
||||
case 'arm_home':
|
||||
await alarmServices.armHome(entityId, code);
|
||||
break;
|
||||
case 'arm_away':
|
||||
await alarmServices.armAway(entityId, code);
|
||||
break;
|
||||
case 'arm_night':
|
||||
await alarmServices.armNight(entityId, code);
|
||||
break;
|
||||
case 'disarm':
|
||||
if (!code) {
|
||||
throw new Error('Code is required to disarm');
|
||||
}
|
||||
await alarmServices.disarm(entityId, code);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to execute alarm action';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [alarmEntityId]);
|
||||
|
||||
const armHome = useCallback((code?: string) => executeAction('arm_home', code), [executeAction]);
|
||||
const armAway = useCallback((code?: string) => executeAction('arm_away', code), [executeAction]);
|
||||
const armNight = useCallback((code?: string) => executeAction('arm_night', code), [executeAction]);
|
||||
const disarm = useCallback((code: string) => executeAction('disarm', code), [executeAction]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
// State
|
||||
alarm,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
state: alarm?.state,
|
||||
isDisarmed: alarm?.isDisarmed ?? false,
|
||||
isArmed: !alarm?.isDisarmed && !alarm?.isPending,
|
||||
isPending: alarm?.isPending ?? false,
|
||||
isTriggered: alarm?.isTriggered ?? false,
|
||||
codeRequired: alarm?.codeRequired ?? true,
|
||||
|
||||
// Actions
|
||||
armHome,
|
||||
armAway,
|
||||
armNight,
|
||||
disarm,
|
||||
executeAction,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
259
src/hooks/useCalendar.ts
Normal file
259
src/hooks/useCalendar.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { startOfMonth, endOfMonth, format } from 'date-fns';
|
||||
import { calendarServices, CalendarEvent, CreateEventParams } from '@/services/homeAssistant/services';
|
||||
import { haConnection } from '@/services/homeAssistant/connection';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
export interface CalendarEventDisplay {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start: {
|
||||
dateTime?: string;
|
||||
date?: string;
|
||||
};
|
||||
end: {
|
||||
dateTime?: string;
|
||||
date?: string;
|
||||
};
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
function convertHAEventToDisplay(event: CalendarEvent): CalendarEventDisplay {
|
||||
// HA returns:
|
||||
// - All-day events: "2026-02-09" (date only, no T)
|
||||
// - Timed events: "2026-02-09T09:00:00-06:00" (full ISO with timezone)
|
||||
const isAllDay = !event.start.includes('T');
|
||||
|
||||
return {
|
||||
id: event.uid || `${event.summary}-${event.start}`,
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: isAllDay
|
||||
? { date: event.start }
|
||||
: { dateTime: event.start },
|
||||
end: isAllDay
|
||||
? { date: event.end }
|
||||
: { dateTime: event.end },
|
||||
allDay: isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEventTime(event: CalendarEventDisplay): string {
|
||||
if (event.allDay) {
|
||||
return 'All day';
|
||||
}
|
||||
|
||||
if (event.start.dateTime) {
|
||||
const date = new Date(event.start.dateTime);
|
||||
return format(date, 'h:mm a');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function useCalendar() {
|
||||
const calendarEntityId = useSettingsStore((state) => state.config.calendar);
|
||||
const [events, setEvents] = useState<CalendarEventDisplay[]>([]);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(haConnection.isConnected());
|
||||
|
||||
// Check connection status
|
||||
useEffect(() => {
|
||||
const checkConnection = () => {
|
||||
setIsConnected(haConnection.isConnected());
|
||||
};
|
||||
|
||||
// Check periodically
|
||||
const interval = setInterval(checkConnection, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!calendarEntityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!haConnection.isConnected()) {
|
||||
setError('Not connected to Home Assistant');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
|
||||
// Format as ISO string for HA
|
||||
const startDateTime = monthStart.toISOString();
|
||||
const endDateTime = monthEnd.toISOString();
|
||||
|
||||
const response = await calendarServices.getEvents(
|
||||
calendarEntityId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
);
|
||||
|
||||
const rawEvents = response?.events || [];
|
||||
|
||||
if (!Array.isArray(rawEvents)) {
|
||||
console.error('Calendar: events is not an array:', typeof rawEvents);
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const displayEvents = rawEvents.map(convertHAEventToDisplay);
|
||||
|
||||
// Sort by start time
|
||||
displayEvents.sort((a, b) => {
|
||||
const aStart = a.start.dateTime || a.start.date || '';
|
||||
const bStart = b.start.dateTime || b.start.date || '';
|
||||
return aStart.localeCompare(bStart);
|
||||
});
|
||||
|
||||
setEvents(displayEvents);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch events';
|
||||
console.error('Calendar fetch error:', err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [calendarEntityId, currentMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
fetchEvents();
|
||||
|
||||
// Poll every 30 seconds for calendar updates
|
||||
const interval = setInterval(fetchEvents, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [fetchEvents, isConnected]);
|
||||
|
||||
const createEvent = useCallback(async (params: {
|
||||
summary: string;
|
||||
startDateTime: Date;
|
||||
endDateTime: Date;
|
||||
description?: string;
|
||||
location?: string;
|
||||
allDay?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (!calendarEntityId) {
|
||||
throw new Error('No calendar configured');
|
||||
}
|
||||
|
||||
if (!haConnection.isConnected()) {
|
||||
throw new Error('Not connected to Home Assistant');
|
||||
}
|
||||
|
||||
const serviceParams: CreateEventParams = {
|
||||
summary: params.summary,
|
||||
description: params.description,
|
||||
location: params.location,
|
||||
};
|
||||
|
||||
if (params.allDay) {
|
||||
// For all-day events, use date only (YYYY-MM-DD)
|
||||
serviceParams.start_date = format(params.startDateTime, 'yyyy-MM-dd');
|
||||
serviceParams.end_date = format(params.endDateTime, 'yyyy-MM-dd');
|
||||
} else {
|
||||
// For timed events, use full datetime
|
||||
serviceParams.start_date_time = format(params.startDateTime, "yyyy-MM-dd HH:mm:ss");
|
||||
serviceParams.end_date_time = format(params.endDateTime, "yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
await calendarServices.createEvent(calendarEntityId, serviceParams);
|
||||
|
||||
// Give HA time to sync, then refresh events
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await fetchEvents();
|
||||
}, [calendarEntityId, fetchEvents]);
|
||||
|
||||
const nextMonth = useCallback(() => {
|
||||
setCurrentMonth((prev) => {
|
||||
const next = new Date(prev);
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prevMonth = useCallback(() => {
|
||||
setCurrentMonth((prev) => {
|
||||
const next = new Date(prev);
|
||||
next.setMonth(next.getMonth() - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const goToToday = useCallback(() => {
|
||||
setCurrentMonth(new Date());
|
||||
}, []);
|
||||
|
||||
const getEventsForDate = useCallback(
|
||||
(date: Date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
|
||||
return events.filter((event) => {
|
||||
if (event.allDay) {
|
||||
// All-day event: check if date falls within the event range
|
||||
const startDate = event.start.date || '';
|
||||
const endDate = event.end.date || '';
|
||||
|
||||
if (!endDate || endDate <= startDate) {
|
||||
// Single-day all-day event (end same as start or missing)
|
||||
return dateStr === startDate;
|
||||
}
|
||||
|
||||
// Multi-day all-day event
|
||||
// End date is exclusive in iCal/HA convention (end is day AFTER last day)
|
||||
return dateStr >= startDate && dateStr < endDate;
|
||||
} else if (event.start.dateTime) {
|
||||
// Timed event: check if the date falls within the event's date span
|
||||
const eventStart = new Date(event.start.dateTime);
|
||||
const eventStartStr = format(eventStart, 'yyyy-MM-dd');
|
||||
|
||||
if (event.end.dateTime) {
|
||||
const eventEnd = new Date(event.end.dateTime);
|
||||
const eventEndStr = format(eventEnd, 'yyyy-MM-dd');
|
||||
|
||||
// Multi-day timed event: show on all days from start to end (inclusive)
|
||||
return dateStr >= eventStartStr && dateStr <= eventEndStr;
|
||||
}
|
||||
|
||||
// Single timed event: just compare start date
|
||||
return dateStr === eventStartStr;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
return {
|
||||
// HA calendar is "authenticated" when connected to HA and calendar is configured
|
||||
isAuthenticated: isConnected && !!calendarEntityId,
|
||||
events,
|
||||
currentMonth,
|
||||
isLoading,
|
||||
error,
|
||||
// No separate auth needed - uses HA connection
|
||||
startAuth: () => {},
|
||||
handleAuthCallback: async () => {},
|
||||
signOut: () => {},
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
goToToday,
|
||||
getEventsForDate,
|
||||
refresh: fetchEvents,
|
||||
createEvent,
|
||||
};
|
||||
}
|
||||
|
||||
export type { CalendarEventDisplay as CalendarEvent };
|
||||
139
src/hooks/useEntity.ts
Normal file
139
src/hooks/useEntity.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEntity as useEntityFromStore, useEntityState, useEntityAttribute } from '@/stores/haStore';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
|
||||
/**
|
||||
* Hook for accessing a single Home Assistant entity
|
||||
*/
|
||||
export function useEntity(entityId: string): HassEntity | undefined {
|
||||
return useEntityFromStore(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing entity state value
|
||||
*/
|
||||
export function useEntityStateValue(entityId: string): string | undefined {
|
||||
return useEntityState(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing a specific entity attribute
|
||||
*/
|
||||
export function useEntityAttributeValue<T>(entityId: string, attribute: string): T | undefined {
|
||||
return useEntityAttribute<T>(entityId, attribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for climate/thermostat entities
|
||||
*/
|
||||
export function useClimate(entityId: string) {
|
||||
const entity = useEntity(entityId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entity) return null;
|
||||
|
||||
return {
|
||||
state: entity.state,
|
||||
currentTemperature: entity.attributes.current_temperature as number | undefined,
|
||||
targetTemperature: entity.attributes.temperature as number | undefined,
|
||||
targetTempHigh: entity.attributes.target_temp_high as number | undefined,
|
||||
targetTempLow: entity.attributes.target_temp_low as number | undefined,
|
||||
hvacMode: entity.state as string,
|
||||
hvacModes: entity.attributes.hvac_modes as string[] | undefined,
|
||||
hvacAction: entity.attributes.hvac_action as string | undefined,
|
||||
minTemp: entity.attributes.min_temp as number | undefined,
|
||||
maxTemp: entity.attributes.max_temp as number | undefined,
|
||||
targetTempStep: entity.attributes.target_temp_step as number | undefined,
|
||||
friendlyName: entity.attributes.friendly_name as string | undefined,
|
||||
};
|
||||
}, [entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for light entities
|
||||
*/
|
||||
export function useLight(entityId: string) {
|
||||
const entity = useEntity(entityId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entity) return null;
|
||||
|
||||
return {
|
||||
state: entity.state,
|
||||
isOn: entity.state === 'on',
|
||||
brightness: entity.attributes.brightness as number | undefined,
|
||||
brightnessPct: entity.attributes.brightness
|
||||
? Math.round((entity.attributes.brightness as number) / 255 * 100)
|
||||
: undefined,
|
||||
colorMode: entity.attributes.color_mode as string | undefined,
|
||||
rgbColor: entity.attributes.rgb_color as [number, number, number] | undefined,
|
||||
friendlyName: entity.attributes.friendly_name as string | undefined,
|
||||
};
|
||||
}, [entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for lock entities
|
||||
*/
|
||||
export function useLock(entityId: string) {
|
||||
const entity = useEntity(entityId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entity) return null;
|
||||
|
||||
return {
|
||||
state: entity.state,
|
||||
isLocked: entity.state === 'locked',
|
||||
isUnlocked: entity.state === 'unlocked',
|
||||
isJammed: entity.state === 'jammed',
|
||||
isLocking: entity.state === 'locking',
|
||||
isUnlocking: entity.state === 'unlocking',
|
||||
friendlyName: entity.attributes.friendly_name as string | undefined,
|
||||
};
|
||||
}, [entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for alarm control panel entities (Alarmo)
|
||||
*/
|
||||
export function useAlarm(entityId: string) {
|
||||
const entity = useEntity(entityId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entity) return null;
|
||||
|
||||
return {
|
||||
state: entity.state,
|
||||
isDisarmed: entity.state === 'disarmed',
|
||||
isArmedHome: entity.state === 'armed_home',
|
||||
isArmedAway: entity.state === 'armed_away',
|
||||
isArmedNight: entity.state === 'armed_night',
|
||||
isPending: entity.state === 'pending' || entity.state === 'arming',
|
||||
isTriggered: entity.state === 'triggered',
|
||||
codeRequired: entity.attributes.code_arm_required as boolean | undefined,
|
||||
codeFormat: entity.attributes.code_format as string | undefined,
|
||||
changedBy: entity.attributes.changed_by as string | undefined,
|
||||
openSensors: entity.attributes.open_sensors as Record<string, string> | undefined,
|
||||
friendlyName: entity.attributes.friendly_name as string | undefined,
|
||||
};
|
||||
}, [entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for binary sensor entities
|
||||
*/
|
||||
export function useBinarySensor(entityId: string) {
|
||||
const entity = useEntity(entityId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entity) return null;
|
||||
|
||||
return {
|
||||
state: entity.state,
|
||||
isOn: entity.state === 'on',
|
||||
isOff: entity.state === 'off',
|
||||
deviceClass: entity.attributes.device_class as string | undefined,
|
||||
friendlyName: entity.attributes.friendly_name as string | undefined,
|
||||
};
|
||||
}, [entity]);
|
||||
}
|
||||
42
src/hooks/useHomeAssistant.ts
Normal file
42
src/hooks/useHomeAssistant.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useHAStore, useConnectionState } from '@/stores/haStore';
|
||||
|
||||
/**
|
||||
* Hook for managing Home Assistant connection
|
||||
*/
|
||||
export function useHomeAssistant() {
|
||||
const connectionState = useConnectionState();
|
||||
const connect = useHAStore((state) => state.connect);
|
||||
const disconnect = useHAStore((state) => state.disconnect);
|
||||
const accessToken = useHAStore((state) => state.accessToken);
|
||||
|
||||
// Auto-connect if token is available
|
||||
useEffect(() => {
|
||||
if (accessToken && connectionState === 'disconnected') {
|
||||
connect(accessToken);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Don't disconnect on unmount - let the store manage lifecycle
|
||||
};
|
||||
}, [accessToken, connectionState, connect]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
if (accessToken) {
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect(accessToken);
|
||||
}, 1000);
|
||||
}
|
||||
}, [accessToken, connect, disconnect]);
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
isConnected: connectionState === 'connected',
|
||||
isConnecting: connectionState === 'connecting',
|
||||
isError: connectionState === 'error',
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
170
src/hooks/useLocalPresence.ts
Normal file
170
src/hooks/useLocalPresence.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
interface UseLocalPresenceOptions {
|
||||
enabled?: boolean;
|
||||
confidenceThreshold?: number;
|
||||
checkIntervalMs?: number;
|
||||
onPersonDetected?: () => void;
|
||||
onPersonCleared?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for local presence detection using TensorFlow.js COCO-SSD model.
|
||||
* Uses the Kitchen_Panel go2rtc stream instead of direct webcam access
|
||||
* (since mediamtx uses the webcam for streaming).
|
||||
*/
|
||||
export function useLocalPresence({
|
||||
enabled = true,
|
||||
confidenceThreshold = 0.5,
|
||||
checkIntervalMs = 2000,
|
||||
onPersonDetected,
|
||||
onPersonCleared,
|
||||
}: UseLocalPresenceOptions = {}) {
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [hasPersonPresent, setHasPersonPresent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const modelRef = useRef<any>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const noPersonCountRef = useRef(0);
|
||||
const wasPersonPresentRef = useRef(false);
|
||||
|
||||
const go2rtcUrl = useSettingsStore((state) => state.config.go2rtcUrl);
|
||||
|
||||
const NO_PERSON_THRESHOLD = 3; // Frames without person before clearing
|
||||
|
||||
const detectPerson = useCallback(async () => {
|
||||
if (!modelRef.current || !canvasRef.current || !imgRef.current) return;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
// Fetch a frame from go2rtc MJPEG snapshot
|
||||
const snapshotUrl = `${go2rtcUrl}/api/frame.jpeg?src=Kitchen_Panel&t=${Date.now()}`;
|
||||
|
||||
// Load image
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!imgRef.current) return reject('No image element');
|
||||
imgRef.current.crossOrigin = 'anonymous';
|
||||
imgRef.current.onload = () => resolve();
|
||||
imgRef.current.onerror = () => reject('Failed to load frame');
|
||||
imgRef.current.src = snapshotUrl;
|
||||
});
|
||||
|
||||
// Draw to canvas
|
||||
ctx.drawImage(imgRef.current, 0, 0, 320, 240);
|
||||
|
||||
// Run detection
|
||||
const predictions = await modelRef.current.detect(canvasRef.current);
|
||||
|
||||
// Check for person with sufficient confidence
|
||||
const personDetection = predictions.find(
|
||||
(p: any) => p.class === 'person' && p.score >= confidenceThreshold
|
||||
);
|
||||
|
||||
if (personDetection) {
|
||||
noPersonCountRef.current = 0;
|
||||
if (!wasPersonPresentRef.current) {
|
||||
wasPersonPresentRef.current = true;
|
||||
setHasPersonPresent(true);
|
||||
console.log('Local presence: Person detected via go2rtc stream');
|
||||
onPersonDetected?.();
|
||||
}
|
||||
} else {
|
||||
noPersonCountRef.current++;
|
||||
if (wasPersonPresentRef.current && noPersonCountRef.current >= NO_PERSON_THRESHOLD) {
|
||||
wasPersonPresentRef.current = false;
|
||||
setHasPersonPresent(false);
|
||||
console.log('Local presence: Person cleared');
|
||||
onPersonCleared?.();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail on individual frame errors - stream might be briefly unavailable
|
||||
console.debug('Detection frame error:', err);
|
||||
}
|
||||
}, [go2rtcUrl, confidenceThreshold, onPersonDetected, onPersonCleared]);
|
||||
|
||||
const startDetection = useCallback(async () => {
|
||||
if (isDetecting) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Dynamically import TensorFlow.js and COCO-SSD
|
||||
const tf = await import('@tensorflow/tfjs');
|
||||
const cocoSsd = await import('@tensorflow-models/coco-ssd');
|
||||
|
||||
// Set backend
|
||||
await tf.setBackend('webgl');
|
||||
await tf.ready();
|
||||
|
||||
// Load model (lite version for speed)
|
||||
console.log('Loading COCO-SSD model for presence detection...');
|
||||
modelRef.current = await cocoSsd.load({
|
||||
base: 'lite_mobilenet_v2',
|
||||
});
|
||||
console.log('Model loaded');
|
||||
|
||||
// Create hidden image element for loading frames
|
||||
imgRef.current = document.createElement('img');
|
||||
|
||||
// Create canvas for processing
|
||||
canvasRef.current = document.createElement('canvas');
|
||||
canvasRef.current.width = 320;
|
||||
canvasRef.current.height = 240;
|
||||
|
||||
// Start detection loop
|
||||
setIsDetecting(true);
|
||||
intervalRef.current = setInterval(detectPerson, checkIntervalMs);
|
||||
|
||||
console.log('Local presence detection started (using go2rtc Kitchen_Panel stream)');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start presence detection';
|
||||
console.error('Presence detection error:', message);
|
||||
setError(message);
|
||||
}
|
||||
}, [isDetecting, checkIntervalMs, detectPerson]);
|
||||
|
||||
const stopDetection = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
canvasRef.current = null;
|
||||
imgRef.current = null;
|
||||
modelRef.current = null;
|
||||
setIsDetecting(false);
|
||||
setHasPersonPresent(false);
|
||||
wasPersonPresentRef.current = false;
|
||||
noPersonCountRef.current = 0;
|
||||
|
||||
console.log('Local presence detection stopped');
|
||||
}, []);
|
||||
|
||||
// Start/stop based on enabled prop
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
startDetection();
|
||||
} else {
|
||||
stopDetection();
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopDetection();
|
||||
};
|
||||
}, [enabled, startDetection, stopDetection]);
|
||||
|
||||
return {
|
||||
isDetecting,
|
||||
hasPersonPresent,
|
||||
error,
|
||||
startDetection,
|
||||
stopDetection,
|
||||
};
|
||||
}
|
||||
203
src/hooks/useSimpleMotion.ts
Normal file
203
src/hooks/useSimpleMotion.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
interface UseSimpleMotionOptions {
|
||||
enabled?: boolean;
|
||||
sensitivityThreshold?: number; // 0-100, higher = more sensitive
|
||||
checkIntervalMs?: number;
|
||||
onMotionDetected?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight motion detection using frame differencing.
|
||||
* Compares consecutive frames from go2rtc stream to detect movement.
|
||||
* Much lighter than TensorFlow.js - just pixel comparison.
|
||||
*/
|
||||
export function useSimpleMotion({
|
||||
enabled = true,
|
||||
sensitivityThreshold = 15, // % of pixels that must change
|
||||
checkIntervalMs = 3000,
|
||||
onMotionDetected,
|
||||
}: UseSimpleMotionOptions = {}) {
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [hasMotion, setHasMotion] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const prevImageDataRef = useRef<ImageData | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const noMotionCountRef = useRef(0);
|
||||
const wasMotionRef = useRef(false);
|
||||
const isProcessingRef = useRef(false); // Prevent overlapping requests
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const go2rtcUrl = useSettingsStore((state) => state.config.go2rtcUrl);
|
||||
|
||||
const NO_MOTION_THRESHOLD = 3; // Frames without motion before clearing
|
||||
|
||||
const detectMotion = useCallback(async () => {
|
||||
// Skip if already processing (prevents memory buildup from overlapping requests)
|
||||
if (isProcessingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canvasRef.current) return;
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
isProcessingRef.current = true;
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
// Fetch a frame from go2rtc MJPEG snapshot
|
||||
const snapshotUrl = `${go2rtcUrl}/api/frame.jpeg?src=Kitchen_Panel&t=${Date.now()}`;
|
||||
|
||||
const response = await fetch(snapshotUrl, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch frame');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
|
||||
// Draw new frame (scaled down for faster processing)
|
||||
ctx.drawImage(bitmap, 0, 0, 160, 120);
|
||||
bitmap.close(); // Release bitmap memory immediately
|
||||
|
||||
// Get current frame data
|
||||
const currentData = ctx.getImageData(0, 0, 160, 120);
|
||||
|
||||
if (!prevImageDataRef.current) {
|
||||
// First frame - just store and skip detection
|
||||
prevImageDataRef.current = currentData;
|
||||
isProcessingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare frames
|
||||
const prevData = prevImageDataRef.current;
|
||||
let changedPixels = 0;
|
||||
const totalPixels = 160 * 120;
|
||||
const threshold = 30; // Per-pixel difference threshold
|
||||
|
||||
for (let i = 0; i < currentData.data.length; i += 4) {
|
||||
const rDiff = Math.abs(currentData.data[i] - prevData.data[i]);
|
||||
const gDiff = Math.abs(currentData.data[i + 1] - prevData.data[i + 1]);
|
||||
const bDiff = Math.abs(currentData.data[i + 2] - prevData.data[i + 2]);
|
||||
|
||||
if (rDiff > threshold || gDiff > threshold || bDiff > threshold) {
|
||||
changedPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
// Store current as previous for next comparison
|
||||
prevImageDataRef.current = currentData;
|
||||
|
||||
const changePercent = (changedPixels / totalPixels) * 100;
|
||||
const motionDetected = changePercent > sensitivityThreshold;
|
||||
|
||||
if (motionDetected) {
|
||||
noMotionCountRef.current = 0;
|
||||
if (!wasMotionRef.current) {
|
||||
wasMotionRef.current = true;
|
||||
setHasMotion(true);
|
||||
console.log(`[Motion] DETECTED: ${changePercent.toFixed(1)}% - waking screen`);
|
||||
onMotionDetected?.();
|
||||
}
|
||||
} else {
|
||||
noMotionCountRef.current++;
|
||||
if (wasMotionRef.current && noMotionCountRef.current >= NO_MOTION_THRESHOLD) {
|
||||
wasMotionRef.current = false;
|
||||
setHasMotion(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore abort errors
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Silently fail on individual frame errors
|
||||
console.debug('Motion detection frame error:', err);
|
||||
} finally {
|
||||
isProcessingRef.current = false;
|
||||
}
|
||||
}, [go2rtcUrl, sensitivityThreshold, onMotionDetected]);
|
||||
|
||||
const startDetection = useCallback(() => {
|
||||
if (isDetecting) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Create single canvas for processing (small for speed)
|
||||
canvasRef.current = document.createElement('canvas');
|
||||
canvasRef.current.width = 160;
|
||||
canvasRef.current.height = 120;
|
||||
|
||||
prevImageDataRef.current = null;
|
||||
isProcessingRef.current = false;
|
||||
|
||||
// Start detection loop
|
||||
setIsDetecting(true);
|
||||
intervalRef.current = setInterval(detectMotion, checkIntervalMs);
|
||||
|
||||
console.log('Motion detection started');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start motion detection';
|
||||
console.error('Motion detection error:', message);
|
||||
setError(message);
|
||||
}
|
||||
}, [isDetecting, checkIntervalMs, detectMotion]);
|
||||
|
||||
const stopDetection = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
canvasRef.current = null;
|
||||
prevImageDataRef.current = null;
|
||||
setIsDetecting(false);
|
||||
setHasMotion(false);
|
||||
wasMotionRef.current = false;
|
||||
noMotionCountRef.current = 0;
|
||||
isProcessingRef.current = false;
|
||||
|
||||
console.log('Motion detection stopped');
|
||||
}, []);
|
||||
|
||||
// Start/stop based on enabled prop
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
startDetection();
|
||||
} else {
|
||||
stopDetection();
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopDetection();
|
||||
};
|
||||
}, [enabled, startDetection, stopDetection]);
|
||||
|
||||
return {
|
||||
isDetecting,
|
||||
hasMotion,
|
||||
error,
|
||||
startDetection,
|
||||
stopDetection,
|
||||
};
|
||||
}
|
||||
209
src/hooks/useTodo.ts
Normal file
209
src/hooks/useTodo.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useEntity } from './useEntity';
|
||||
import { haConnection } from '@/services/homeAssistant';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
export interface TodoItem {
|
||||
uid: string;
|
||||
summary: string;
|
||||
status: 'needs_action' | 'completed';
|
||||
due?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function useTodo() {
|
||||
const todoEntityId = useSettingsStore((state) => state.config.todoList);
|
||||
const entity = useEntity(todoEntityId || '');
|
||||
const [items, setItems] = useState<TodoItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
if (!todoEntityId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const connection = haConnection.getConnection();
|
||||
if (!connection) {
|
||||
throw new Error('Not connected to Home Assistant');
|
||||
}
|
||||
|
||||
// Use the todo.get_items service
|
||||
const result = await connection.sendMessagePromise<Record<string, unknown>>({
|
||||
type: 'call_service',
|
||||
domain: 'todo',
|
||||
service: 'get_items',
|
||||
target: { entity_id: todoEntityId },
|
||||
return_response: true,
|
||||
});
|
||||
|
||||
// Extract items from response - handle different structures
|
||||
let entityItems: TodoItem[] = [];
|
||||
|
||||
// Structure 1: { response: { "todo.entity": { items: [...] } } }
|
||||
const respWrapper = result?.response as Record<string, { items?: TodoItem[] }> | undefined;
|
||||
if (respWrapper?.[todoEntityId]?.items) {
|
||||
entityItems = respWrapper[todoEntityId].items;
|
||||
}
|
||||
// Structure 2: { "todo.entity": { items: [...] } }
|
||||
else if ((result?.[todoEntityId] as { items?: TodoItem[] })?.items) {
|
||||
entityItems = (result[todoEntityId] as { items: TodoItem[] }).items;
|
||||
}
|
||||
|
||||
setItems(entityItems);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch todo items';
|
||||
setError(message);
|
||||
console.error('Failed to fetch todo items:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [todoEntityId]);
|
||||
|
||||
// Fetch items when entity changes and poll every 30 seconds
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
fetchItems();
|
||||
|
||||
// Poll every 30 seconds for updates
|
||||
const interval = setInterval(fetchItems, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [entity, fetchItems]);
|
||||
|
||||
const addItem = useCallback(async (summary: string) => {
|
||||
if (!todoEntityId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await haConnection.callService('todo', 'add_item', { item: summary }, { entity_id: todoEntityId });
|
||||
|
||||
// Refresh items
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to add todo item';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [todoEntityId, fetchItems]);
|
||||
|
||||
const completeItem = useCallback(async (uid: string) => {
|
||||
if (!todoEntityId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Find the item to get its summary
|
||||
const item = items.find((i) => i.uid === uid);
|
||||
if (!item) {
|
||||
throw new Error('Item not found');
|
||||
}
|
||||
|
||||
await haConnection.callService(
|
||||
'todo',
|
||||
'update_item',
|
||||
{ item: item.summary, status: 'completed' },
|
||||
{ entity_id: todoEntityId }
|
||||
);
|
||||
|
||||
// Refresh items
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to complete todo item';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [todoEntityId, items, fetchItems]);
|
||||
|
||||
const uncompleteItem = useCallback(async (uid: string) => {
|
||||
if (!todoEntityId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const item = items.find((i) => i.uid === uid);
|
||||
if (!item) {
|
||||
throw new Error('Item not found');
|
||||
}
|
||||
|
||||
await haConnection.callService(
|
||||
'todo',
|
||||
'update_item',
|
||||
{ item: item.summary, status: 'needs_action' },
|
||||
{ entity_id: todoEntityId }
|
||||
);
|
||||
|
||||
// Refresh items
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to uncomplete todo item';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [todoEntityId, items, fetchItems]);
|
||||
|
||||
const removeItem = useCallback(async (uid: string) => {
|
||||
if (!todoEntityId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const item = items.find((i) => i.uid === uid);
|
||||
if (!item) {
|
||||
throw new Error('Item not found');
|
||||
}
|
||||
|
||||
await haConnection.callService(
|
||||
'todo',
|
||||
'remove_item',
|
||||
{ item: item.summary },
|
||||
{ entity_id: todoEntityId }
|
||||
);
|
||||
|
||||
// Refresh items
|
||||
await fetchItems();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to remove todo item';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [todoEntityId, items, fetchItems]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
// Computed values
|
||||
const activeItems = items.filter((item) => item.status === 'needs_action');
|
||||
const completedItems = items.filter((item) => item.status === 'completed');
|
||||
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
activeItems,
|
||||
completedItems,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchItems,
|
||||
addItem,
|
||||
completeItem,
|
||||
uncompleteItem,
|
||||
removeItem,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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>
|
||||
);
|
||||
1
src/services/go2rtc/index.ts
Normal file
1
src/services/go2rtc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Go2RTCWebRTC, Go2RTCMSE, getGo2RTCStreams, getStreamInfo } from './webrtc';
|
||||
220
src/services/go2rtc/webrtc.ts
Normal file
220
src/services/go2rtc/webrtc.ts
Normal 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();
|
||||
}
|
||||
143
src/services/googleCalendar/api.ts
Normal file
143
src/services/googleCalendar/api.ts
Normal 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');
|
||||
}
|
||||
146
src/services/googleCalendar/auth.ts
Normal file
146
src/services/googleCalendar/auth.ts
Normal 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();
|
||||
10
src/services/googleCalendar/index.ts
Normal file
10
src/services/googleCalendar/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { googleCalendarAuth } from './auth';
|
||||
export {
|
||||
getCalendarList,
|
||||
getEventsForMonth,
|
||||
getEventsForDay,
|
||||
getEventTime,
|
||||
formatEventTime,
|
||||
type CalendarEvent,
|
||||
type Calendar,
|
||||
} from './api';
|
||||
159
src/services/homeAssistant/connection.ts
Normal file
159
src/services/homeAssistant/connection.ts
Normal 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();
|
||||
2
src/services/homeAssistant/index.ts
Normal file
2
src/services/homeAssistant/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { haConnection, type ConnectionState, type HAConnectionEvents } from './connection';
|
||||
export { climateServices, lightServices, lockServices, alarmServices, todoServices } from './services';
|
||||
208
src/services/homeAssistant/services.ts
Normal file
208
src/services/homeAssistant/services.ts
Normal 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
75
src/stores/haStore.ts
Normal 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
15
src/stores/index.ts
Normal 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
259
src/stores/settingsStore.ts
Normal 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
169
src/stores/uiStore.ts
Normal 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,
|
||||
}));
|
||||
60
src/styles/imperial-theme.ts
Normal file
60
src/styles/imperial-theme.ts
Normal 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
259
src/styles/index.css
Normal 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
51
src/vite-env.d.ts
vendored
Normal 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
84
tailwind.config.js
Executable 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
18
tsconfig.electron.json
Executable 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
30
tsconfig.json
Executable 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
11
tsconfig.node.json
Executable 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
26
vite.config.ts
Executable 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user