Compare commits

15 Commits
master ... dev

Author SHA1 Message Date
root
edd8cd01c1 Group climate controls together on Controls page
Thermostats now render in their own grid row at the top, side-by-side
with equal min-height. Alarm, lights, and locks follow in a separate
grid below.
2026-04-17 15:04:47 -05:00
root
a74a2ddd7f Support dash separator in chore name parsing (Chris - Dishes) 2026-04-16 23:10:28 -05:00
root
81236d908c Add weather badge, chore chart, fix controls grid
- Header now shows weather icon + temp from weather.forecast_home_2 next
  to the connection badge (right side of nav bar)
- New ChoreChart widget below TodoWidget on Home view; reads from
  todo.chores HA entity; items prefixed with a name (Becca: / Chris: /
  Arabella:) get per-person color coding matching the calendar palette;
  tap toggles completion
- Controls overlay simplified from grid-cols-12 auto-rows-min to a plain
  grid-cols-2 — removes the uneven gaps between widgets
2026-04-16 22:35:00 -05:00
root
f5461db97d Fix case mismatch in PersonAlert camera lookup
cameraName from MQTT was compared against lowercased frigateCamera
without being lowercased itself — Front_Porch !== front_porch.
2026-04-16 22:07:22 -05:00
root
3b38a78295 Person detection via direct MQTT instead of HA entities
- Electron main process subscribes to Frigate's MQTT topics
  (frigate/<camera>/person and frigate/events) directly via mqtt.js,
  bypassing the broken HA MQTT integration
- Watched cameras: Front_Porch, FPE, Porch_Downstairs, Driveway_door
- On person detection, exits photo-frame idle and shows full-screen
  camera feed for 30 seconds
- Removed HA entity-based person detection code (entityToCameraName,
  personDetectionEntities config dependency)
- Deleted unused useFrigateDetection HTTP polling hook (superseded)
2026-04-16 21:46:28 -05:00
root
55dd117520 Suppress photo-frame idle while Media overlay is open
Jellyfin runs in a cross-origin iframe so we can't observe the video
element's play state directly — using overlay-open as the proxy. Any
user activity still resets the timer, and Controls / Cameras / Settings
overlays still fall to idle as before.
2026-04-16 06:40:53 -05:00
root
9315374944 Swap calendar coloring to per-person keyword match; teal UI accent
- Events now color by name match in the summary: Becca=teal, Chris=green,
  Arabella=pink. Anything without a match gets a neutral slate. Replaces
  the hash-to-Google-palette logic.
- Main accent swapped from blue to pastel teal (#14b8a6) so the UI feels
  cohesive with the event palette.
2026-04-15 08:06:09 -05:00
root
7b36551c32 Cool-light theme, Google event colors, photo frame contain+lower-right clock
- Main app swapped from warm cream/sage to cool slate light theme with
  blue accent; dark-* tokens and text-white/gray overrides remapped in
  tailwind.config.js and index.css
- Calendar events now render in Google's 11-color palette (Tomato through
  Graphite), deterministically hashed from the event summary so the same
  event always gets the same color
- PhotoFrame uses object-contain (whole photo shown, letterboxed) instead
  of object-cover; clock + date moved to lower-right, same white color,
  text-shadow for readability over any photo
- EMAIL_UPLOAD.md / PHOTO_FRAME.md / iCloud sync script and systemd timer
  remain unchanged
2026-04-15 02:18:43 -05:00
root
d0db8c55b3 Tighten Home calendar/todo split to 80/20 2026-04-14 19:28:33 -05:00
root
a5803f70e3 Add localhost screenshot endpoint and email-upload guide
- Electron main now exposes http://127.0.0.1:9990/screenshot that returns
  a PNG via CDP Page.captureScreenshot (GNOME Wayland blocks external
  capture, and webContents.capturePage() hangs on this compositor). The
  endpoint is localhost-only.
- EMAIL_UPLOAD.md documents the n8n IMAP -> Samba share workflow for
  Skylight-style email-to-photo-frame uploads.
2026-04-14 18:59:24 -05:00
root
cede430dc9 Keep monitor always on and switch Home layout to horizontal 90/10
- Removed ScreenManager idle sleep entirely (constructor no longer starts
  the idle monitor, setIdleTimeout is now a no-op, unused fields and the
  private startIdleMonitor method removed). Display never sleeps; the
  photo frame provides the idle visual instead.
- DashboardContent now uses flex-row: calendar 90% wide, todo 10% wide.
2026-04-14 16:29:58 -05:00
root
7886e72f38 Add Controls tab with lights, locks, alarm, thermostats overlay
The five-tab nav now includes a Controls tab between Home and Media.
Opens a full-screen overlay with the alarm panel, each configured
thermostat, lights, and locks tiled in a responsive 2-column grid.
2026-04-14 14:06:13 -05:00
root
1dd32c6afe Simplify home view and drop motion detection
- Top nav tabs (Home/Media/Cameras/Settings) replace the right-side button cluster
- Home view now shows calendar 90% / todo 10% vertically; lights, locks,
  alarm, thermostats removed from the dashboard since the photo frame now
  owns the idle space and the nav covers the remaining sections
- Motion detection deleted: the go2rtc-based Kitchen_Panel poll was only
  there to wake the screen before idle timeout, which photo-frame exit on
  touch replaces
2026-04-14 13:27:20 -05:00
root
5fe7bc71ef Add photo frame idle mode and switch to Skylight-style theme
- After 5 min of no touch/motion, dashboard hides behind a fullscreen
  photo slideshow with centered time and date overlay
- Photos loaded from PHOTOS_PATH env var (defaults to ~/Pictures/dashboard)
  via IPC + file:// URLs; traversal-guarded, recursive up to 2 levels
- Motion or touch exits idle back to dashboard
- Theme repainted warm cream / sage / stone ink with Nunito font and
  rounded cards; dark tokens kept so component classes still resolve
- Adds PHOTO_FRAME.md with Samba cifs mount + systemd env instructions
2026-04-14 10:44:51 -05:00
root
58ebd3e239 Fix camera feed freezing and person detection alerts
- Add WebRTC auto-reconnect with exponential backoff when streams
  disconnect or fail, preventing permanent freezes in grid view
- Replace hard-coded Front Porch person alert with generic system
  that monitors all configured personDetectionEntities
- Map Frigate person_occupancy entities to cameras dynamically
- Show correct camera name and feed in alert overlay
- Bump config version to refresh detection entity defaults
2026-02-26 15:33:25 -06:00
30 changed files with 9525 additions and 324 deletions

View File

@@ -20,3 +20,13 @@ VITE_PRESENCE_CONFIDENCE_THRESHOLD=0.6
# Frigate Streaming from built-in camera # Frigate Streaming from built-in camera
VITE_FRIGATE_STREAM_ENABLED=true VITE_FRIGATE_STREAM_ENABLED=true
VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center
# Photo Frame (shown after inactivity)
# Idle timeout in ms before photo frame takes over (default 5 min)
VITE_PHOTO_FRAME_IDLE_TIMEOUT=300000
# Seconds between photo transitions
VITE_PHOTO_FRAME_INTERVAL=15000
# Path to photos directory on the kiosk (set via PHOTOS_PATH env var for the
# Electron process, not via Vite). Typically a mounted Samba share. Example:
# PHOTOS_PATH=/mnt/family-photos
# If unset, defaults to ~/Pictures/dashboard on the kiosk user's account.

83
EMAIL_UPLOAD.md Normal file
View File

@@ -0,0 +1,83 @@
# Email-to-Photo-Frame Upload
Photos emailed to a dedicated mailbox are automatically saved to the
photo-frame Samba share, where the kiosk picks them up on its next scan
(PhotoManager rescans every 10 minutes).
## Three moving parts
### 1. Mailbox to receive photos
Easiest path: a dedicated Gmail account (e.g. `deathstarhomephotos@gmail.com`).
- Enable 2FA on the Google account
- Generate an **app password** under Security → App passwords (needed for IMAP,
since n8n's IMAP node uses username/password auth)
- Share the address with family members
Lock it down in the n8n workflow by whitelisting sender addresses; reject
anything else so strangers can't spam the frame.
### 2. Samba share visible to n8n
n8n runs on 192.168.1.254 and needs to write to the same Samba share the
kiosk reads from. Two options:
**Option A — Mount the share inside the n8n container**
```bash
# On the n8n LXC host (192.168.1.254), install cifs-utils and mount:
apt install -y cifs-utils
mkdir -p /mnt/family-photos
echo '//SERVER/SHARE /mnt/family-photos cifs credentials=/etc/samba/credentials-n8n,uid=1000,gid=1000,rw,_netdev,x-systemd.automount 0 0' >> /etc/fstab
# credentials file with chmod 600
mount -a
```
Then bind-mount `/mnt/family-photos` into the n8n Docker container so the
Write Binary File node can target it at a path like `/data/family-photos/`.
**Option B — SSH to the fileshare LXC (192.168.1.193)**
Give n8n an SSH key to the fileshare host. Use the Execute Command node to
`scp` / `cat > file` onto the share. Simpler to configure than cifs, but
adds SSH surface area on the fileshare box.
### 3. n8n workflow
Pseudocode:
```
IMAP Email Trigger (mark read, download attachments, poll every 2 min)
→ IF From ∈ [your-email, wife-email]
→ Split attachments
→ IF mimeType startsWith "image/"
→ Rename: ${yyyyMMdd_HHmmss}_${randomHex}.${ext}
→ Write Binary File → /data/family-photos/${filename}
```
Nodes used:
- **Email Trigger (IMAP)** — reads Gmail (server `imap.gmail.com`, port 993, SSL)
- **IF** — sender whitelist guard
- **Item Lists / Split In Batches** — one item per attachment
- **IF** — filter on `$binary.attachment_0.mimeType`
- **Set** — build filename
- **Write Binary File** — write to the mount path
Kiosk picks them up on the next rescan (up to 10 min), or restart the app
to force an immediate scan.
## Hardening
- Limit attachment size (e.g. reject > 30 MB per file) to avoid filling
the share
- Strip EXIF if you don't want location data persisted on the share
- Keep an audit trail: add an extra branch that appends sender + filename
to a log file
## Credentials I'll need to build the workflow
- Gmail address + app password (or OAuth refresh token)
- Which option above for the Samba mount (A or B)
- If option B, SSH key + fileshare path to write to
- List of allowed sender emails

78
PHOTO_FRAME.md Normal file
View File

@@ -0,0 +1,78 @@
# Photo Frame (Digital Picture Frame)
After `VITE_PHOTO_FRAME_IDLE_TIMEOUT` ms of no touch/mouse/keyboard activity
(default 5 minutes), the dashboard is covered by a full-screen photo frame
that cycles images from a local directory with a clock overlay. Touching the
screen exits idle and returns to the dashboard. Motion detected by the
Electron `MotionDetector` also cancels idle.
## Photo source
The Electron main process reads photos from the directory set in the
`PHOTOS_PATH` environment variable. If unset, it falls back to
`~/Pictures/dashboard`. Files are served via a `photo://` custom protocol
so the renderer never exposes raw filesystem paths.
Supported extensions: jpg, jpeg, png, webp, gif, heic, heif. Subdirectories
are scanned up to two levels deep.
## Mounting the Samba share on the kiosk
On the kiosk (192.168.1.190):
```bash
# 1. Install cifs-utils
sudo apt install -y cifs-utils
# 2. Create a credentials file (chmod 600)
sudo tee /etc/samba/credentials-dashboard <<'EOF'
username=YOUR_SAMBA_USER
password=YOUR_SAMBA_PASSWORD
EOF
sudo chmod 600 /etc/samba/credentials-dashboard
# 3. Create mountpoint
sudo mkdir -p /mnt/family-photos
# 4. Add to /etc/fstab (replace server/share with actual values)
echo '//SERVER_IP/SHARE_NAME /mnt/family-photos cifs credentials=/etc/samba/credentials-dashboard,uid=1000,gid=1000,iocharset=utf8,vers=3.0,ro,_netdev,x-systemd.automount 0 0' | sudo tee -a /etc/fstab
# 5. Mount
sudo mount -a
ls /mnt/family-photos
```
## Pointing the app at the share
Option A — systemd unit (if you run the app as a service):
```ini
# /etc/systemd/system/imperial-command-center.service (append to [Service])
Environment=PHOTOS_PATH=/mnt/family-photos
```
Then `sudo systemctl daemon-reload && sudo systemctl restart imperial-command-center`.
Option B — desktop launcher (if started from the GUI):
Edit `~/Desktop/imperial-command-center.desktop` and set:
```
Exec=env PHOTOS_PATH=/mnt/family-photos /opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland
```
Option C — shell wrapper used by current deployment:
```bash
sudo -u chrisryn PHOTOS_PATH=/mnt/family-photos \
XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0 \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
/opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland
```
## Tuning
- `VITE_PHOTO_FRAME_IDLE_TIMEOUT` — idle timeout in ms (default 300000)
- `VITE_PHOTO_FRAME_INTERVAL` — ms between photo transitions (default 15000)
These are baked at build time.

View File

@@ -1,18 +1,28 @@
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron'; import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as http from 'http';
import { ScreenManager } from './services/ScreenManager'; import { ScreenManager } from './services/ScreenManager';
import { PresenceDetector } from './services/PresenceDetector'; import { PresenceDetector } from './services/PresenceDetector';
import { FrigateStreamer } from './services/FrigateStreamer'; import { FrigateStreamer } from './services/FrigateStreamer';
import { MotionDetector } from './services/MotionDetector'; import { PhotoManager } from './services/PhotoManager';
import { FrigateDetector } from './services/FrigateDetector';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let screenManager: ScreenManager | null = null; let screenManager: ScreenManager | null = null;
let presenceDetector: PresenceDetector | null = null; let presenceDetector: PresenceDetector | null = null;
let frigateStreamer: FrigateStreamer | null = null; let frigateStreamer: FrigateStreamer | null = null;
let motionDetector: MotionDetector | null = null; let photoManager: PhotoManager | null = null;
let frigateDetector: FrigateDetector | null = null;
let powerSaveBlockerId: number | null = null; let powerSaveBlockerId: number | null = null;
// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard
function resolvePhotosDir(): string {
const env = process.env.PHOTOS_PATH;
if (env && env.trim()) return env.trim();
return path.join(app.getPath('home'), 'Pictures', 'dashboard');
}
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server // 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 // When running from unpacked folder, app.isPackaged is false but we still want production behavior
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
@@ -30,7 +40,7 @@ function createWindow(): void {
kiosk: !isDev, // Kiosk mode in production kiosk: !isDev, // Kiosk mode in production
frame: false, frame: false,
autoHideMenuBar: true, autoHideMenuBar: true,
backgroundColor: '#0a0a0a', backgroundColor: '#faf6f0',
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, nodeIntegration: false,
@@ -54,22 +64,23 @@ function createWindow(): void {
// Initialize services // Initialize services
screenManager = new ScreenManager(mainWindow); screenManager = new ScreenManager(mainWindow);
// Initialize motion detector (runs in main process, not throttled by browser) // Photo frame slideshow source
// Uses file size comparison which is more reliable for JPEG streams photoManager = new PhotoManager(resolvePhotosDir());
motionDetector = new MotionDetector({ console.log(`PhotoManager: watching ${photoManager.getDir()}`);
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', () => { // Frigate person detection via MQTT (bypasses HA entities)
console.log('MotionDetector: Motion detected, waking screen'); frigateDetector = new FrigateDetector({
screenManager?.wakeScreen(); mqttUrl: 'mqtt://192.168.1.50:1883',
mainWindow?.webContents.send('motion:detected'); mqttUser: 'mqtt',
mqttPassword: '11xpfcryan',
topicPrefix: 'frigate',
cameras: ['Front_Porch', 'FPE', 'Porch_Downstairs', 'Driveway_door'],
}); });
frigateDetector.on('personDetected', (camera: string) => {
motionDetector.start(); console.log(`FrigateDetector: person on ${camera}`);
mainWindow?.webContents.send('frigate:personDetected', camera);
});
frigateDetector.start();
// Handle window close // Handle window close
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
@@ -174,6 +185,24 @@ function setupIpcHandlers(): void {
} }
}); });
// Photo frame
ipcMain.handle('photos:list', async () => {
return photoManager?.list() ?? [];
});
ipcMain.handle('photos:getDir', async () => {
return photoManager?.getDir() ?? '';
});
// Given a relative filename returned by photos:list, return an absolute
// file:// URL the renderer can drop into an <img src>. Returns null if the
// file is missing or resolves outside the photos dir (traversal guard).
ipcMain.handle('photos:getUrl', async (_event, rel: string) => {
const full = photoManager?.resolve(rel);
if (!full || !fs.existsSync(full)) return null;
return 'file://' + full.split(path.sep).map((p, i) => (i === 0 && p === '' ? '' : encodeURIComponent(p))).join('/');
});
// App control // App control
ipcMain.handle('app:quit', () => { ipcMain.handle('app:quit', () => {
app.quit(); app.quit();
@@ -212,13 +241,41 @@ app.on('window-all-closed', () => {
} }
presenceDetector?.stop(); presenceDetector?.stop();
frigateStreamer?.stop(); frigateStreamer?.stop();
motionDetector?.stop(); frigateDetector?.stop();
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
// Localhost-only HTTP endpoint for self-screenshots. GNOME Wayland blocks
// external screen capture, but webContents.capturePage() works from inside.
// Usage: curl -s http://127.0.0.1:9990/screenshot > out.png
http
.createServer(async (req, res) => {
if (req.url === '/screenshot' && mainWindow) {
try {
const wc = mainWindow.webContents;
if (!wc.debugger.isAttached()) wc.debugger.attach('1.3');
const result = (await wc.debugger.sendCommand('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: false,
})) as { data: string };
res.setHeader('Content-Type', 'image/png');
res.end(Buffer.from(result.data, 'base64'));
} catch (err) {
res.statusCode = 500;
res.end(String(err));
}
} else {
res.statusCode = 404;
res.end();
}
})
.listen(9990, '127.0.0.1', () => {
console.log('Screenshot endpoint on http://127.0.0.1:9990/screenshot');
});
// Handle uncaught exceptions // Handle uncaught exceptions
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error); console.error('Uncaught exception:', error);

View File

@@ -16,6 +16,7 @@ export interface ElectronAPI {
frigate: { frigate: {
startStream: (rtspUrl: string) => Promise<boolean>; startStream: (rtspUrl: string) => Promise<boolean>;
stopStream: () => Promise<boolean>; stopStream: () => Promise<boolean>;
onPersonDetected: (callback: (camera: string) => void) => () => void;
}; };
app: { app: {
quit: () => void; quit: () => void;
@@ -26,6 +27,11 @@ export interface ElectronAPI {
getStoredToken: () => Promise<string | null>; getStoredToken: () => Promise<string | null>;
getJellyfinApiKey: () => Promise<string | null>; getJellyfinApiKey: () => Promise<string | null>;
}; };
photos: {
list: () => Promise<string[]>;
getDir: () => Promise<string>;
getUrl: (relative: string) => Promise<string | null>;
};
} }
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
@@ -52,6 +58,11 @@ const electronAPI: ElectronAPI = {
frigate: { frigate: {
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl), startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
stopStream: () => ipcRenderer.invoke('frigate:stopStream'), stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
onPersonDetected: (callback: (camera: string) => void) => {
const handler = (_event: IpcRendererEvent, camera: string) => callback(camera);
ipcRenderer.on('frigate:personDetected', handler);
return () => ipcRenderer.removeListener('frigate:personDetected', handler);
},
}, },
app: { app: {
quit: () => ipcRenderer.invoke('app:quit'), quit: () => ipcRenderer.invoke('app:quit'),
@@ -62,6 +73,11 @@ const electronAPI: ElectronAPI = {
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'), getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'), getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
}, },
photos: {
list: () => ipcRenderer.invoke('photos:list'),
getDir: () => ipcRenderer.invoke('photos:getDir'),
getUrl: (relative: string) => ipcRenderer.invoke('photos:getUrl', relative),
},
}; };
contextBridge.exposeInMainWorld('electronAPI', electronAPI); contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -0,0 +1,106 @@
import { EventEmitter } from 'events';
import mqtt from 'mqtt';
interface FrigateDetectorConfig {
mqttUrl: string;
mqttUser?: string;
mqttPassword?: string;
topicPrefix?: string;
cameras: string[];
}
/**
* Subscribes to Frigate's MQTT events directly and emits 'personDetected'
* with the camera name when a new person event starts on a watched camera.
*/
export class FrigateDetector extends EventEmitter {
private client: mqtt.MqttClient | null = null;
private config: FrigateDetectorConfig;
private seenEvents = new Set<string>();
constructor(config: FrigateDetectorConfig) {
super();
this.config = config;
}
start(): void {
const { mqttUrl, mqttUser, mqttPassword, topicPrefix = 'frigate' } = this.config;
const opts: mqtt.IClientOptions = {
clientId: `icc-frigate-${Date.now()}`,
reconnectPeriod: 5000,
connectTimeout: 10000,
};
if (mqttUser) opts.username = mqttUser;
if (mqttPassword) opts.password = mqttPassword;
this.client = mqtt.connect(mqttUrl, opts);
this.client.on('connect', () => {
console.log('FrigateDetector: MQTT connected');
// Subscribe to per-camera person topic for each watched camera
for (const cam of this.config.cameras) {
this.client!.subscribe(`${topicPrefix}/${cam}/person`, { qos: 0 });
}
// Also subscribe to events topic for richer event data
this.client!.subscribe(`${topicPrefix}/events`, { qos: 0 });
});
this.client.on('message', (topic: string, payload: Buffer) => {
try {
const prefix = this.config.topicPrefix || 'frigate';
// frigate/<camera>/person → payload is a count (0 or 1+)
const personMatch = topic.match(new RegExp(`^${prefix}/(.+)/person$`));
if (personMatch) {
const cam = personMatch[1];
const count = parseInt(payload.toString(), 10);
if (count > 0) {
const key = `person-${cam}`;
if (!this.seenEvents.has(key)) {
this.seenEvents.add(key);
console.log(`FrigateDetector: person detected on ${cam}`);
this.emit('personDetected', cam);
// Clear after 60s so a new detection can trigger again
setTimeout(() => this.seenEvents.delete(key), 60000);
}
} else {
// Person left — allow re-trigger
this.seenEvents.delete(`person-${cam}`);
}
return;
}
// frigate/events → JSON with event details
if (topic === `${prefix}/events`) {
const data = JSON.parse(payload.toString());
const after = data?.after || data;
if (after?.label === 'person' && data?.type === 'new') {
const cam = after.camera;
if (this.config.cameras.some((c) => c.toLowerCase() === cam?.toLowerCase())) {
const key = `event-${after.id}`;
if (!this.seenEvents.has(key)) {
this.seenEvents.add(key);
this.emit('personDetected', cam);
}
}
}
}
} catch (err) {
console.error('FrigateDetector: parse error', err);
}
});
this.client.on('error', (err: Error) => {
console.error('FrigateDetector: MQTT error', err.message);
});
this.client.on('reconnect', () => {
console.log('FrigateDetector: MQTT reconnecting...');
});
}
stop(): void {
this.client?.end(true);
this.client = null;
}
}

View File

@@ -0,0 +1,53 @@
import * as fs from 'fs';
import * as path from 'path';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
/**
* Lists image files from the configured photos directory.
* Recurses one level deep so users can organise photos into subfolders.
*/
export class PhotoManager {
constructor(private photosDir: string) {}
setDir(dir: string): void {
this.photosDir = dir;
}
getDir(): string {
return this.photosDir;
}
list(): string[] {
if (!this.photosDir || !fs.existsSync(this.photosDir)) return [];
const files: string[] = [];
try {
this.walk(this.photosDir, files, 0);
} catch (err) {
console.error('PhotoManager: failed to list photos', err);
}
// Return paths relative to photosDir so the renderer can build photo:// URLs
return files.map((f) => path.relative(this.photosDir, f));
}
private walk(dir: string, out: string[], depth: number): void {
if (depth > 2) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
this.walk(full, out, depth + 1);
} else if (entry.isFile() && IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) {
out.push(full);
}
}
}
resolve(relative: string): string | null {
if (!this.photosDir) return null;
const safe = path.normalize(relative).replace(/^(\.\.[\/\\])+/, '');
const full = path.join(this.photosDir, safe);
if (!full.startsWith(this.photosDir)) return null;
return full;
}
}

View File

@@ -12,10 +12,8 @@ const DBUS_ENV = {
export class ScreenManager { export class ScreenManager {
private window: BrowserWindow; private window: BrowserWindow;
private idleTimeout: number = 300000; // 5 minutes default
private idleTimer: NodeJS.Timeout | null = null; private idleTimer: NodeJS.Timeout | null = null;
private isScreenOn: boolean = true; private isScreenOn: boolean = true;
private lastActivity: number = Date.now();
private screenControlAvailable: boolean = true; private screenControlAvailable: boolean = true;
private isWayland: boolean = false; private isWayland: boolean = false;
@@ -23,7 +21,8 @@ export class ScreenManager {
this.window = window; this.window = window;
this.detectDisplayServer(); this.detectDisplayServer();
this.setupActivityListeners(); this.setupActivityListeners();
this.startIdleMonitor(); // Idle monitor intentionally disabled: monitor stays on; photo frame
// handles the visual idle state instead.
} }
private detectDisplayServer(): void { private detectDisplayServer(): void {
@@ -44,31 +43,19 @@ export class ScreenManager {
// Called from renderer when touch/click detected // Called from renderer when touch/click detected
public handleUserActivity(): void { public handleUserActivity(): void {
this.lastActivity = Date.now();
// Always try to wake the screen on touch/click - user is actively interacting
if (!this.isScreenOn) { if (!this.isScreenOn) {
this.wakeScreen(); 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 { private resetIdleTimer(): void {
this.lastActivity = Date.now();
if (!this.isScreenOn) { if (!this.isScreenOn) {
this.wakeScreen(); this.wakeScreen();
} }
} }
setIdleTimeout(timeout: number): void { setIdleTimeout(_timeout: number): void {
this.idleTimeout = timeout; // no-op: idle monitor is disabled; monitor stays on
} }
async wakeScreen(): Promise<void> { async wakeScreen(): Promise<void> {
@@ -164,7 +151,6 @@ export class ScreenManager {
} }
this.isScreenOn = true; this.isScreenOn = true;
this.lastActivity = Date.now();
this.window.webContents.send('screen:woke'); this.window.webContents.send('screen:woke');
} catch (error) { } catch (error) {
console.error('Failed to wake screen:', error); console.error('Failed to wake screen:', error);

8091
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,26 +18,27 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "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", "@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.17.0",
"date-fns": "^3.3.1",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"date-fns": "^3.3.1" "googleapis": "^131.0.0",
"home-assistant-js-websocket": "^9.4.0",
"mqtt": "^5.15.1",
"zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^28.2.0", "electron": "^28.2.0",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -1,35 +1,40 @@
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Dashboard } from '@/components/layout'; import { Dashboard } from '@/components/layout';
import { ThermostatOverlay } from '@/components/climate'; import { ThermostatOverlay } from '@/components/climate';
import { LightsOverlay } from '@/components/lights'; import { LightsOverlay } from '@/components/lights';
import { LocksOverlay } from '@/components/locks'; import { LocksOverlay } from '@/components/locks';
import { AlarmoPanel } from '@/components/alarm'; import { ControlsOverlay } from '@/components/controls';
import { CalendarWidget } from '@/components/calendar'; import { CalendarWidget } from '@/components/calendar';
import { TodoWidget } from '@/components/todo'; import { TodoWidget } from '@/components/todo';
import { ChoreChart } from '@/components/chores';
import { SettingsPanel, ConnectionModal } from '@/components/settings'; import { SettingsPanel, ConnectionModal } from '@/components/settings';
import { CameraOverlay } from '@/components/cameras'; import { CameraOverlay } from '@/components/cameras';
import { CameraFeed } from '@/components/cameras/CameraFeed'; import { CameraFeed } from '@/components/cameras/CameraFeed';
import { JellyfinOverlay } from '@/components/media'; import { JellyfinOverlay } from '@/components/media';
import { GlobalKeyboard } from '@/components/keyboard'; import { GlobalKeyboard } from '@/components/keyboard';
import { PhotoFrame } from '@/components/photoframe';
import { useHomeAssistant } from '@/hooks'; import { useHomeAssistant } from '@/hooks';
// Motion detection now runs in Electron main process (MotionDetector.ts) import { useIdle } from '@/hooks/useIdle';
// import { useSimpleMotion } from '@/hooks/useSimpleMotion'; import { useHAStore } from '@/stores/haStore';
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
import { useUIStore, useCameraOverlay } from '@/stores/uiStore'; import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment'; import { env } from '@/config/environment';
// Front porch alert overlay - shows for 30 seconds when person detected
function FrontPorchAlert({ onClose }: { onClose: () => void }) { // Person detection alert overlay - shows for 30 seconds when person detected on any configured camera
function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) {
const cameras = useSettingsStore((state) => state.config.cameras); const cameras = useSettingsStore((state) => state.config.cameras);
const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch'); const cameraLower = cameraName.toLowerCase();
const camera = cameras.find(
(c) => c.frigateCamera?.toLowerCase() === cameraLower || c.name.toLowerCase() === cameraLower,
);
useEffect(() => { useEffect(() => {
const timer = setTimeout(onClose, 30000); // 30 seconds const timer = setTimeout(onClose, 30000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [onClose]); }, [onClose]);
if (!frontPorchCamera) return null; if (!camera) return null;
return ( return (
<div className="fixed inset-0 z-50 bg-black flex flex-col"> <div className="fixed inset-0 z-50 bg-black flex flex-col">
@@ -37,7 +42,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" /> <div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
<h2 className="text-lg font-semibold text-status-warning"> <h2 className="text-lg font-semibold text-status-warning">
Person Detected - Front Porch Person Detected - {camera.displayName}
</h2> </h2>
</div> </div>
<button <button
@@ -51,7 +56,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="flex-1 p-2"> <div className="flex-1 p-2">
<CameraFeed <CameraFeed
camera={frontPorchCamera} camera={camera}
className="w-full h-full" className="w-full h-full"
showLabel={false} showLabel={false}
/> />
@@ -60,12 +65,6 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
); );
} }
// Simple thermostat temp display
function ThermostatTemp({ entityId }: { entityId: string }) {
const currentTemp = useEntityAttribute<number>(entityId, 'current_temperature');
return <>{currentTemp?.toFixed(0) ?? '--'}°</>;
}
function ConnectionPrompt() { function ConnectionPrompt() {
const openSettings = useUIStore((state) => state.openSettings); const openSettings = useUIStore((state) => state.openSettings);
@@ -93,93 +92,43 @@ function ConnectionPrompt() {
function DashboardContent() { function DashboardContent() {
const config = useSettingsStore((state) => state.config); 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 ( return (
<> <div className="col-span-12 flex flex-row gap-3 min-h-0">
{/* Left Column - Calendar (spans 2 columns) */}
<div className="col-span-8 flex flex-col gap-4">
{config.calendar && ( {config.calendar && (
<div className="flex-1 min-h-0"> <div className="flex-[4] min-w-0">
<CalendarWidget /> <CalendarWidget />
</div> </div>
)} )}
</div> <div className="flex-1 min-w-0 flex flex-col gap-3">
{/* 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 && ( {config.todoList && (
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<TodoWidget /> <TodoWidget />
</div> </div>
)} )}
<div className="flex-1 min-h-0">
<ChoreChart />
</div>
</div>
</div> </div>
</>
); );
} }
export default function App() { export default function App() {
const { isConnected, connectionState } = useHomeAssistant(); const { connectionState } = useHomeAssistant();
const accessToken = useHAStore((state) => state.accessToken); const accessToken = useHAStore((state) => state.accessToken);
const connect = useHAStore((state) => state.connect); const connect = useHAStore((state) => state.connect);
const entities = useHAStore((state) => state.entities);
const settingsOpen = useUIStore((state) => state.settingsOpen); const settingsOpen = useUIStore((state) => state.settingsOpen);
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen); const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen); const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen); const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen); const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const controlsOverlayOpen = useUIStore((state) => state.controlsOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay(); const { isOpen: cameraOverlayOpen } = useCameraOverlay();
const isIdle = useIdle(env.photoFrameIdleTimeout);
// Front porch alert state // Person detection alert state (via MQTT from Electron main process)
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false); const [alertCamera, setAlertCamera] = useState<string | null>(null);
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 // Report touch/click activity to main process for screen wake on Wayland
useEffect(() => { useEffect(() => {
@@ -230,29 +179,19 @@ export default function App() {
initConfig(); initConfig();
}, [accessToken, connect]); }, [accessToken, connect]);
// Listen for Front Porch person detection - show full screen overlay for 30 seconds // Listen for person detection via MQTT (from Electron main process)
useEffect(() => { useEffect(() => {
if (!isConnected) return; const api = window.electronAPI;
if (!api?.frigate?.onPersonDetected) return;
const unsub = api.frigate.onPersonDetected((camera: string) => {
setAlertCamera(camera);
useUIStore.getState().setIdle(false);
});
return unsub;
}, []);
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy']; const closePersonAlert = useCallback(() => {
const isPersonDetected = frontPorchEntity?.state === 'on'; setAlertCamera(null);
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 // Set up screen idle timeout
@@ -319,9 +258,11 @@ export default function App() {
{locksOverlayOpen && <LocksOverlay />} {locksOverlayOpen && <LocksOverlay />}
{thermostatsOverlayOpen && <ThermostatOverlay />} {thermostatsOverlayOpen && <ThermostatOverlay />}
{mediaOverlayOpen && <JellyfinOverlay />} {mediaOverlayOpen && <JellyfinOverlay />}
{controlsOverlayOpen && <ControlsOverlay />}
{cameraOverlayOpen && <CameraOverlay />} {cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />} {settingsOpen && <SettingsPanel />}
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />} {isIdle && !alertCamera && <PhotoFrame intervalMs={env.photoFrameInterval} />}
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
<GlobalKeyboard /> <GlobalKeyboard />
</> </>
); );

View File

@@ -2,10 +2,15 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay, isToday } from 'date-fns'; import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay, isToday } from 'date-fns';
import { useCalendar, CalendarEvent, formatEventTime } from '@/hooks/useCalendar'; import { useCalendar, CalendarEvent, formatEventTime } from '@/hooks/useCalendar';
import { VirtualKeyboard } from '@/components/keyboard'; import { VirtualKeyboard } from '@/components/keyboard';
import { getEventColor } from './eventColors';
function EventItem({ event }: { event: CalendarEvent }) { function EventItem({ event }: { event: CalendarEvent }) {
const color = getEventColor(event.summary);
return ( return (
<div className="text-[0.6rem] truncate px-1 py-0.5 rounded bg-accent/20 text-accent-light"> <div
className="text-[0.6rem] truncate px-1 py-0.5 rounded"
style={{ backgroundColor: color.bg, color: color.text }}
>
{formatEventTime(event)} {event.summary} {formatEventTime(event)} {event.summary}
</div> </div>
); );
@@ -74,10 +79,16 @@ function EventDetails({ date, events, onAddEvent }: { date: Date; events: Calend
<p className="text-xs text-gray-500">No events</p> <p className="text-xs text-gray-500">No events</p>
) : ( ) : (
<div className="space-y-1.5 max-h-24 overflow-y-auto"> <div className="space-y-1.5 max-h-24 overflow-y-auto">
{events.map((event) => ( {events.map((event) => {
const color = getEventColor(event.summary);
return (
<div key={event.id} className="text-xs"> <div key={event.id} className="text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-accent font-medium"> <span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: color.bg }}
/>
<span className="font-medium" style={{ color: color.bg }}>
{formatEventTime(event)} {formatEventTime(event)}
</span> </span>
<span className="flex-1 truncate">{event.summary}</span> <span className="flex-1 truncate">{event.summary}</span>
@@ -88,7 +99,8 @@ function EventDetails({ date, events, onAddEvent }: { date: Date; events: Calend
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,28 @@
// Per-person event colors. Any event whose summary contains one of the
// configured names (case-insensitive) renders in that person's color.
// Events that don't match a name fall back to a neutral slate color.
export interface PersonColor {
name: string;
bg: string;
text: string;
}
export const PEOPLE: PersonColor[] = [
{ name: 'Becca', bg: '#14b8a6', text: '#ffffff' }, // teal-500
{ name: 'Chris', bg: '#22c55e', text: '#ffffff' }, // green-500
{ name: 'Arabella', bg: '#ec4899', text: '#ffffff' }, // pink-500
];
const FALLBACK: PersonColor = {
name: 'default',
bg: '#94a3b8', // slate-400 - muted pastel neutral
text: '#ffffff',
};
export function getEventColor(summary: string): PersonColor {
const lowered = summary.toLowerCase();
for (const p of PEOPLE) {
if (lowered.includes(p.name.toLowerCase())) return p;
}
return FALLBACK;
}

View File

@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { Go2RTCWebRTC } from '@/services/go2rtc'; import { Go2RTCWebRTC } from '@/services/go2rtc';
import { CameraConfig } from '@/config/entities'; import { CameraConfig } from '@/config/entities';
const RECONNECT_DELAY_MS = 3000;
const MAX_RECONNECT_DELAY_MS = 30000;
interface CameraFeedProps { interface CameraFeedProps {
camera: CameraConfig; camera: CameraConfig;
onClick?: () => void; onClick?: () => void;
@@ -21,50 +24,99 @@ export function CameraFeed({
}: CameraFeedProps) { }: CameraFeedProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const webrtcRef = useRef<Go2RTCWebRTC | null>(null); const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
const mountedRef = useRef(true);
const [isConnecting, setIsConnecting] = useState(true); const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Use substream for grid view (lower bandwidth) // Use substream for grid view (lower bandwidth)
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream; const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
useEffect(() => { const connect = useCallback(async () => {
let mounted = true; if (!mountedRef.current) return;
let timeoutId: ReturnType<typeof setTimeout>;
const connect = async () => {
try { try {
setIsConnecting(true); setIsConnecting(true);
setError(null); setError(null);
webrtcRef.current = new Go2RTCWebRTC(streamName); // Disconnect previous instance
webrtcRef.current?.disconnect();
await webrtcRef.current.connect((stream) => { const webrtc = new Go2RTCWebRTC(streamName);
if (mounted && videoRef.current) { webrtcRef.current = webrtc;
await webrtc.connect(
// onTrack
(stream) => {
if (mountedRef.current && videoRef.current) {
videoRef.current.srcObject = stream; videoRef.current.srcObject = stream;
setIsConnecting(false); setIsConnecting(false);
setError(null);
// Reset backoff on successful connection
reconnectDelayRef.current = RECONNECT_DELAY_MS;
} }
}); },
// onDisconnect - schedule reconnect
() => {
if (!mountedRef.current) return;
console.log(`WebRTC disconnected for ${streamName}, reconnecting in ${reconnectDelayRef.current}ms`);
setError('Stream disconnected');
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
}
}, reconnectDelayRef.current);
// Exponential backoff capped at max
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.5,
MAX_RECONNECT_DELAY_MS,
);
},
);
} catch (err) { } catch (err) {
if (mounted) { if (!mountedRef.current) return;
setError(err instanceof Error ? err.message : 'Failed to connect'); const msg = err instanceof Error ? err.message : 'Failed to connect';
setError(msg);
setIsConnecting(false); setIsConnecting(false);
// Retry on connection failure too
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
} }
}, reconnectDelayRef.current);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 1.5,
MAX_RECONNECT_DELAY_MS,
);
} }
}; }, [streamName]);
useEffect(() => {
mountedRef.current = true;
reconnectDelayRef.current = RECONNECT_DELAY_MS;
let initTimer: ReturnType<typeof setTimeout>;
// Stagger connections to avoid overwhelming go2rtc // Stagger connections to avoid overwhelming go2rtc
if (delayMs > 0) { if (delayMs > 0) {
timeoutId = setTimeout(connect, delayMs); initTimer = setTimeout(connect, delayMs);
} else { } else {
connect(); connect();
} }
return () => { return () => {
mounted = false; mountedRef.current = false;
if (timeoutId) clearTimeout(timeoutId); clearTimeout(initTimer);
clearTimeout(reconnectTimerRef.current);
webrtcRef.current?.disconnect(); webrtcRef.current?.disconnect();
}; };
}, [streamName, delayMs]); }, [connect, delayMs]);
return ( return (
<div <div
@@ -93,13 +145,14 @@ export function CameraFeed({
)} )}
{/* Error Overlay */} {/* Error Overlay */}
{error && ( {error && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80"> <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"> <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"> <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" /> <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> </svg>
<span className="text-sm text-gray-400">{error}</span> <span className="text-sm text-gray-400">{error}</span>
<span className="text-xs text-gray-500">Reconnecting...</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,171 @@
import { useEffect, useState, useCallback } from 'react';
import { useHAStore } from '@/stores/haStore';
import { haConnection } from '@/services/homeAssistant/connection';
// Person color mapping (matches calendar eventColors)
const PEOPLE: Record<string, { bg: string; text: string }> = {
becca: { bg: '#14b8a6', text: '#ffffff' },
chris: { bg: '#22c55e', text: '#ffffff' },
arabella: { bg: '#ec4899', text: '#ffffff' },
};
const FALLBACK_COLOR = { bg: '#94a3b8', text: '#ffffff' };
interface ChoreItem {
uid: string;
summary: string;
status: string;
person: string | null;
task: string;
color: { bg: string; text: string };
}
function parseChore(item: { uid: string; summary: string; status: string }): ChoreItem {
// Supports "Becca: Dishes", "Chris - Dishes", or just "Dishes"
let person: string | null = null;
let task = item.summary;
let color = FALLBACK_COLOR;
// Try splitting on ":" or " - "
for (const sep of [':', ' - ', ' ']) {
const idx = item.summary.indexOf(sep);
if (idx > 0 && idx < 20) {
const prefix = item.summary.slice(0, idx).trim().toLowerCase();
if (PEOPLE[prefix]) {
person = item.summary.slice(0, idx).trim();
task = item.summary.slice(idx + sep.length).trim();
color = PEOPLE[prefix];
break;
}
}
}
// Fallback: check if any person name appears anywhere
if (!person) {
for (const [name, c] of Object.entries(PEOPLE)) {
if (item.summary.toLowerCase().includes(name)) {
person = name.charAt(0).toUpperCase() + name.slice(1);
color = c;
break;
}
}
}
return { uid: item.uid, summary: item.summary, status: item.status, person, task, color };
}
const CHORE_ENTITY = 'todo.chores';
export function ChoreChart() {
const isConnected = useHAStore((s) => s.connectionState === 'connected');
const [chores, setChores] = useState<ChoreItem[]>([]);
const fetchChores = useCallback(async () => {
const conn = haConnection.getConnection();
if (!conn) return;
try {
const result = await conn.sendMessagePromise({
type: 'call_service',
domain: 'todo',
service: 'get_items',
target: { entity_id: CHORE_ENTITY },
return_response: true,
}) as { response?: Record<string, { items: Array<{ uid: string; summary: string; status: string }> }> };
const items = result?.response?.[CHORE_ENTITY]?.items ?? [];
setChores(items.map(parseChore));
} catch {
// Entity may not exist yet
}
}, []);
useEffect(() => {
if (!isConnected) return;
fetchChores();
const id = setInterval(fetchChores, 30000);
return () => clearInterval(id);
}, [isConnected, fetchChores]);
const toggleChore = useCallback(async (uid: string, currentStatus: string) => {
const conn = haConnection.getConnection();
if (!conn) return;
const newStatus = currentStatus === 'completed' ? 'needs_action' : 'completed';
try {
await conn.sendMessagePromise({
type: 'call_service',
domain: 'todo',
service: 'update_item',
target: { entity_id: CHORE_ENTITY },
service_data: { item: uid, status: newStatus },
});
fetchChores();
} catch {
// ignore
}
}, [fetchChores]);
if (!chores.length) {
return (
<div className="widget h-full">
<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>
Chores
</div>
<div className="widget-content flex items-center justify-center">
<p className="text-sm text-gray-500">
Add a <code>todo.chores</code> list in HA
</p>
</div>
</div>
);
}
return (
<div className="widget h-full">
<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>
Chores
</div>
<div className="widget-content overflow-y-auto space-y-1">
{chores.map((chore) => (
<button
key={chore.uid}
onClick={() => toggleChore(chore.uid, chore.status)}
className="compact-row w-full gap-2 touch-manipulation"
>
{/* Checkbox */}
<div
className="w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors"
style={{
borderColor: chore.color.bg,
backgroundColor: chore.status === 'completed' ? chore.color.bg : 'transparent',
}}
>
{chore.status === 'completed' && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* Person badge */}
{chore.person && (
<span
className="text-[0.6rem] font-bold px-1.5 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: chore.color.bg, color: chore.color.text }}
>
{chore.person}
</span>
)}
{/* Task text */}
<span className={`text-sm flex-1 text-left truncate ${chore.status === 'completed' ? 'line-through opacity-50' : ''}`}>
{chore.task}
</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ChoreChart } from './ChoreChart';

View File

@@ -0,0 +1,49 @@
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { LightsWidget } from '@/components/lights';
import { LocksWidget } from '@/components/locks';
import { ThermostatWidget } from '@/components/climate';
import { AlarmoPanel } from '@/components/alarm';
export function ControlsOverlay() {
const closeControlsOverlay = useUIStore((s) => s.closeControlsOverlay);
const config = useSettingsStore((s) => s.config);
return (
<div className="overlay-full">
<header className="h-16 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5 shrink-0">
<h2 className="text-2xl font-bold text-ink">Controls</h2>
<button
onClick={closeControlsOverlay}
className="btn btn-sm"
aria-label="Close controls"
>
<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>
Close
</button>
</header>
<div className="flex-1 overflow-auto p-5 space-y-5">
{/* Climate controls — grouped together, equal width */}
{config.thermostats.length > 0 && (
<div className="grid grid-cols-2 gap-4">
{config.thermostats.map((thermostat) => (
<div key={thermostat.entityId} className="min-h-[200px]">
<ThermostatWidget config={thermostat} />
</div>
))}
</div>
)}
{/* Alarm, Lights, Locks */}
<div className="grid grid-cols-2 gap-4">
{config.alarm && <AlarmoPanel />}
{config.lights.length > 0 && <LightsWidget />}
{config.locks.length > 0 && <LocksWidget />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ControlsOverlay } from './ControlsOverlay';

View File

@@ -1,4 +1,4 @@
import { useConnectionState, useEntityState, useEntityAttribute } from '@/stores/haStore'; import { useConnectionState, useEntityState, useEntityAttribute, useHAStore } from '@/stores/haStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment'; import { env } from '@/config/environment';
@@ -80,15 +80,104 @@ function PackageStatus() {
); );
} }
const WEATHER_ICONS: Record<string, string> = {
'clear-night': '🌙',
'cloudy': '☁️',
'exceptional': '⚠️',
'fog': '🌫️',
'hail': '🌨️',
'lightning': '⚡',
'lightning-rainy': '⛈️',
'partlycloudy': '⛅',
'pouring': '🌧️',
'rainy': '🌧️',
'snowy': '❄️',
'snowy-rainy': '🌨️',
'sunny': '☀️',
'windy': '💨',
'windy-variant': '💨',
};
function WeatherBadge() {
const weatherEntity = 'weather.forecast_home_2';
const condition = useEntityState(weatherEntity) ?? '';
const entities = useHAStore((s) => s.entities);
const attrs = entities[weatherEntity]?.attributes as Record<string, unknown> | undefined;
const temp = attrs?.temperature as number | undefined;
if (!temp) return null;
const icon = WEATHER_ICONS[condition] || '🌡️';
return (
<div className="flex items-center gap-1.5 text-ink">
<span className="text-lg">{icon}</span>
<span className="text-lg font-bold">{Math.round(temp)}°</span>
</div>
);
}
export function Header() { export function Header() {
const connectionState = useConnectionState(); const connectionState = useConnectionState();
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const controlsOverlayOpen = useUIStore((state) => state.controlsOverlayOpen);
const settingsOpen = useUIStore((state) => state.settingsOpen);
const openCameraOverlay = useUIStore((state) => state.openCameraOverlay); const openCameraOverlay = useUIStore((state) => state.openCameraOverlay);
const closeCameraOverlay = useUIStore((state) => state.closeCameraOverlay);
const openMediaOverlay = useUIStore((state) => state.openMediaOverlay); const openMediaOverlay = useUIStore((state) => state.openMediaOverlay);
const closeMediaOverlay = useUIStore((state) => state.closeMediaOverlay);
const openControlsOverlay = useUIStore((state) => state.openControlsOverlay);
const closeControlsOverlay = useUIStore((state) => state.closeControlsOverlay);
const openSettings = useUIStore((state) => state.openSettings); const openSettings = useUIStore((state) => state.openSettings);
const closeSettings = useUIStore((state) => state.closeSettings);
const people = useSettingsStore((state) => state.config.people); const people = useSettingsStore((state) => state.config.people);
const cameras = useSettingsStore((state) => state.config.cameras); const cameras = useSettingsStore((state) => state.config.cameras);
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const activeTab: 'home' | 'controls' | 'media' | 'cameras' | 'settings' =
settingsOpen
? 'settings'
: cameraOverlayOpen
? 'cameras'
: mediaOverlayOpen
? 'media'
: controlsOverlayOpen
? 'controls'
: 'home';
const closeAll = () => {
closeCameraOverlay();
closeMediaOverlay();
closeControlsOverlay();
closeSettings();
};
const goHome = () => {
closeAll();
};
const goControls = () => {
closeAll();
openControlsOverlay();
};
const goMedia = () => {
closeAll();
openMediaOverlay();
};
const goCameras = () => {
closeAll();
openCameraOverlay();
};
const goSettings = () => {
closeAll();
openSettings();
};
const tabClass = (name: typeof activeTab) =>
`flex items-center gap-2 px-4 py-2 rounded-2xl font-semibold text-sm transition-all ${
activeTab === name
? 'bg-accent text-white shadow-card'
: 'bg-transparent text-ink-muted hover:bg-dark-hover'
}`;
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setCurrentTime(new Date()); setCurrentTime(new Date());
@@ -122,23 +211,18 @@ export function Header() {
}; };
return ( return (
<header className="h-14 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5"> <header className="h-16 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5 gap-4">
{/* Left - Time and Date */} {/* Left - Time, Date, People */}
<div className="flex items-center gap-5 min-w-0">
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold text-white tracking-tight"> <span className="text-2xl font-bold text-ink tracking-tight">
{format(currentTime, 'h:mm')} {format(currentTime, 'h:mm')}
</span> </span>
<span className="text-sm text-gray-500"> <span className="text-sm font-semibold text-ink-muted">
{format(currentTime, 'EEE, MMM d')} {format(currentTime, 'EEE, MMM d')}
</span> </span>
</div> </div>
{/* Center - Status Icons */}
<div className="flex items-center gap-4">
{/* Package Status */}
<PackageStatus /> <PackageStatus />
{/* People */}
{people.length > 0 && ( {people.length > 0 && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{people.map((person) => ( {people.map((person) => (
@@ -153,53 +237,51 @@ export function Header() {
)} )}
</div> </div>
{/* Right - Connection Status, Cameras, Settings */} {/* Center - Tab Navigation */}
<div className="flex items-center gap-4"> <nav className="flex items-center gap-1 bg-dark-tertiary rounded-2xl p-1">
{/* Connection Status */} <button onClick={goHome} className={tabClass('home')}>
<div className="status-badge"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className={`status-dot ${getConnectionStatusClass()}`} /> <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" />
<span>{getConnectionText()}</span> </svg>
</div> Home
</button>
{/* Media Button */} <button onClick={goControls} className={tabClass('controls')}>
<button <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
onClick={openMediaOverlay} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
className="btn btn-sm" </svg>
aria-label="Open media" Controls
> </button>
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <button onClick={goMedia} className={tabClass('media')}>
<svg className="w-5 h-5" 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="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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
Media Media
</button> </button>
{/* Camera Button */}
{cameras.length > 0 && ( {cameras.length > 0 && (
<button <button onClick={goCameras} className={tabClass('cameras')}>
onClick={() => openCameraOverlay()} <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
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" /> <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> </svg>
Cameras Cameras
</button> </button>
)} )}
<button onClick={goSettings} className={tabClass('settings')}>
{/* Settings Button */} <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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="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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
Settings Settings
</button> </button>
</nav>
{/* Right - Weather + Connection */}
<div className="flex items-center gap-4">
<WeatherBadge />
<div className="status-badge">
<div className={`status-dot ${getConnectionStatusClass()}`} />
<span>{getConnectionText()}</span>
</div>
</div> </div>
</header> </header>
); );

View File

@@ -0,0 +1,172 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useUIStore } from '@/stores/uiStore';
interface PhotoFrameProps {
intervalMs?: number;
transitionMs?: number;
}
function shuffle<T>(arr: T[]): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
function useClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 10_000);
return () => clearInterval(id);
}, []);
return now;
}
export function PhotoFrame({ intervalMs = 15_000, transitionMs = 1_200 }: PhotoFrameProps) {
const setIdle = useUIStore((s) => s.setIdle);
const [files, setFiles] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [prevUrl, setPrevUrl] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const [emptyDir, setEmptyDir] = useState('');
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
const load = async () => {
const api = window.electronAPI;
if (!api?.photos) {
setEmpty(true);
return;
}
const list = await api.photos.list();
const dir = await api.photos.getDir();
if (!mountedRef.current) return;
if (!list.length) {
setEmpty(true);
setEmptyDir(dir);
return;
}
setFiles(shuffle(list));
setEmpty(false);
};
load();
// Re-scan every 10 min in case photos are added while idle
const id = setInterval(load, 10 * 60_000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!files.length) return;
const id = setInterval(() => {
setIndex((i) => (i + 1) % files.length);
}, intervalMs);
return () => clearInterval(id);
}, [files.length, intervalMs]);
// Resolve file:// URLs via IPC when the index changes
useEffect(() => {
if (!files.length) return;
const api = window.electronAPI;
if (!api?.photos?.getUrl) return;
let cancelled = false;
(async () => {
const url = await api.photos.getUrl(files[index]);
if (cancelled || !mountedRef.current) return;
setPrevUrl(currentUrl);
setCurrentUrl(url);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, files]);
const handleExit = () => setIdle(false);
const now = useClock();
const timeStr = useMemo(
() =>
now.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
}),
[now],
);
const dateStr = useMemo(
() =>
now.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
}),
[now],
);
const currentSrc = currentUrl;
const prevSrc = prevUrl;
return (
<div
className="fixed inset-0 z-[60] bg-black flex items-center justify-center overflow-hidden animate-fade-in"
onClick={handleExit}
onTouchStart={handleExit}
>
{empty ? (
<div className="text-center text-white/80 px-8">
<div className="text-6xl font-light mb-6">{timeStr}</div>
<div className="text-2xl mb-10 capitalize">{dateStr}</div>
<div className="text-lg opacity-60">
No photos found{emptyDir ? ` in ${emptyDir}` : ''}.
</div>
<div className="text-sm opacity-40 mt-2">Touch anywhere to exit</div>
</div>
) : (
<>
{prevSrc && (
<img
key={`prev-${prevSrc}`}
src={prevSrc}
alt=""
className="absolute inset-0 w-full h-full object-contain animate-ken-burns"
style={{ opacity: 1 }}
/>
)}
{currentSrc && (
<img
key={`cur-${index}`}
src={currentSrc}
alt=""
className="absolute inset-0 w-full h-full object-contain animate-ken-burns"
style={{
opacity: 0,
animation: `photo-fade-in ${transitionMs}ms ease-out forwards, ken-burns 20s ease-in-out infinite alternate`,
}}
/>
)}
{/* Lower-right clock + date overlay */}
<div className="pointer-events-none absolute bottom-8 right-10 text-right text-white leading-tight"
style={{ textShadow: '0 2px 12px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,0.7)' }}>
<div className="text-6xl md:text-7xl font-light tracking-tight">{timeStr}</div>
<div className="text-xl md:text-2xl mt-1 capitalize font-light">{dateStr}</div>
</div>
<style>{`
@keyframes photo-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { PhotoFrame } from './PhotoFrame';

View File

@@ -20,6 +20,10 @@ export const env = {
// Screen management // Screen management
screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10), screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10),
// Photo frame idle timeout (ms) - 5 minutes default
photoFrameIdleTimeout: parseInt(import.meta.env.VITE_PHOTO_FRAME_IDLE_TIMEOUT || '300000', 10),
photoFrameInterval: parseInt(import.meta.env.VITE_PHOTO_FRAME_INTERVAL || '15000', 10),
// Presence detection // Presence detection
presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true', presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true',
presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'), presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'),

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef, useCallback } from 'react';
import { env } from '@/config/environment';
interface FrigateEvent {
id: string;
camera: string;
label: string;
start_time: number;
end_time: number | null;
}
/**
* Polls Frigate's /api/events for active (end_time=null) person events
* on the specified cameras. Fires onPersonDetected with the camera name
* when a NEW event appears. Bypasses MQTT/HA entirely.
*/
export function useFrigateDetection({
cameras,
onPersonDetected,
pollIntervalMs = 5000,
}: {
cameras: string[];
onPersonDetected: (camera: string) => void;
pollIntervalMs?: number;
}) {
const seenRef = useRef<Set<string>>(new Set());
const onDetectRef = useRef(onPersonDetected);
onDetectRef.current = onPersonDetected;
const poll = useCallback(async () => {
if (!cameras.length) return;
try {
const url = `${env.frigateUrl}/api/events?labels=person&limit=5&has_clip=0&in_progress=1`;
const resp = await fetch(url);
if (!resp.ok) return;
const events: FrigateEvent[] = await resp.json();
for (const ev of events) {
if (ev.end_time !== null) continue; // already ended
if (!cameras.some((c) => c.toLowerCase() === ev.camera.toLowerCase())) continue;
if (seenRef.current.has(ev.id)) continue;
seenRef.current.add(ev.id);
onDetectRef.current(ev.camera);
break; // one at a time
}
// Prune old seen IDs (keep last 50)
if (seenRef.current.size > 50) {
const arr = [...seenRef.current];
seenRef.current = new Set(arr.slice(arr.length - 25));
}
} catch {
// Frigate unreachable — skip silently
}
}, [cameras]);
useEffect(() => {
poll();
const id = setInterval(poll, pollIntervalMs);
return () => clearInterval(id);
}, [poll, pollIntervalMs]);
}

55
src/hooks/useIdle.ts Normal file
View File

@@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { useUIStore } from '@/stores/uiStore';
/**
* Tracks touch/mouse/keyboard activity. After `timeoutMs` of no activity,
* flips the UI into idle mode (photo frame). Any activity exits idle.
* Idle is also suppressed while the Media overlay is open (Jellyfin runs
* in a cross-origin iframe, so we can't observe the video element's
* play state directly — the overlay being open is our proxy).
*/
export function useIdle(timeoutMs: number) {
const isIdle = useUIStore((s) => s.isIdle);
const setIdle = useUIStore((s) => s.setIdle);
useEffect(() => {
if (timeoutMs <= 0) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
// Re-check at fire time so "media overlay open" is always current
if (useUIStore.getState().mediaOverlayOpen) {
schedule();
return;
}
setIdle(true);
}, timeoutMs);
};
const reset = () => {
if (useUIStore.getState().isIdle) setIdle(false);
schedule();
};
const events: Array<keyof DocumentEventMap> = [
'touchstart',
'mousedown',
'mousemove',
'keydown',
'wheel',
];
for (const e of events) document.addEventListener(e, reset, { passive: true });
schedule();
return () => {
if (timer) clearTimeout(timer);
for (const e of events) document.removeEventListener(e, reset);
};
}, [timeoutMs, setIdle]);
return isIdle;
}

View File

@@ -10,13 +10,22 @@ export class Go2RTCWebRTC {
private mediaStream: MediaStream | null = null; private mediaStream: MediaStream | null = null;
private streamName: string; private streamName: string;
private onTrackCallback: ((stream: MediaStream) => void) | null = null; private onTrackCallback: ((stream: MediaStream) => void) | null = null;
private onDisconnectCallback: (() => void) | null = null;
private disposed = false;
constructor(streamName: string) { constructor(streamName: string) {
this.streamName = streamName; this.streamName = streamName;
} }
async connect(onTrack: (stream: MediaStream) => void): Promise<void> { async connect(
onTrack: (stream: MediaStream) => void,
onDisconnect?: () => void,
): Promise<void> {
this.onTrackCallback = onTrack; this.onTrackCallback = onTrack;
this.onDisconnectCallback = onDisconnect ?? null;
// Clean up any existing connection before reconnecting
this.cleanupConnection();
// Create peer connection with STUN servers // Create peer connection with STUN servers
const config: RTCConfiguration = { const config: RTCConfiguration = {
@@ -29,6 +38,7 @@ export class Go2RTCWebRTC {
// Handle incoming tracks // Handle incoming tracks
this.peerConnection.ontrack = (event) => { this.peerConnection.ontrack = (event) => {
if (this.disposed) return;
console.log(`Received track for ${this.streamName}:`, event.track.kind); console.log(`Received track for ${this.streamName}:`, event.track.kind);
if (event.streams && event.streams[0]) { if (event.streams && event.streams[0]) {
this.mediaStream = event.streams[0]; this.mediaStream = event.streams[0];
@@ -44,9 +54,13 @@ export class Go2RTCWebRTC {
} }
}; };
// Handle connection state changes // Handle connection state changes - notify on disconnect/failure
this.peerConnection.onconnectionstatechange = () => { this.peerConnection.onconnectionstatechange = () => {
console.log(`WebRTC connection state for ${this.streamName}:`, this.peerConnection?.connectionState); const state = this.peerConnection?.connectionState;
console.log(`WebRTC connection state for ${this.streamName}:`, state);
if (!this.disposed && (state === 'disconnected' || state === 'failed')) {
this.onDisconnectCallback?.();
}
}; };
// Add transceiver for video (receive only) // Add transceiver for video (receive only)
@@ -62,6 +76,20 @@ export class Go2RTCWebRTC {
await this.peerConnection.setRemoteDescription(answer); await this.peerConnection.setRemoteDescription(answer);
} }
private cleanupConnection(): void {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.ontrack = null;
this.peerConnection.onicecandidate = null;
this.peerConnection.onconnectionstatechange = null;
this.peerConnection.close();
this.peerConnection = null;
}
}
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> { private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`; const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
@@ -91,17 +119,10 @@ export class Go2RTCWebRTC {
} }
disconnect(): void { disconnect(): void {
if (this.mediaStream) { this.disposed = true;
this.mediaStream.getTracks().forEach((track) => track.stop()); this.cleanupConnection();
this.mediaStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
this.onTrackCallback = null; this.onTrackCallback = null;
this.onDisconnectCallback = null;
} }
getMediaStream(): MediaStream | null { getMediaStream(): MediaStream | null {

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
// Increment this when default cameras/config changes to force refresh // Increment this when default cameras/config changes to force refresh
const CONFIG_VERSION = 8; const CONFIG_VERSION = 9;
export interface ThermostatConfig { export interface ThermostatConfig {
entityId: string; entityId: string;

View File

@@ -8,6 +8,7 @@ interface UIState {
locksOverlayOpen: boolean; locksOverlayOpen: boolean;
thermostatsOverlayOpen: boolean; thermostatsOverlayOpen: boolean;
mediaOverlayOpen: boolean; mediaOverlayOpen: boolean;
controlsOverlayOpen: boolean;
personAlertActive: boolean; personAlertActive: boolean;
personAlertCamera: string | null; personAlertCamera: string | null;
@@ -21,6 +22,10 @@ interface UIState {
// Screen state // Screen state
screenOn: boolean; screenOn: boolean;
// Idle / photo frame
isIdle: boolean;
setIdle: (idle: boolean) => void;
// Virtual keyboard // Virtual keyboard
keyboardOpen: boolean; keyboardOpen: boolean;
keyboardNumpad: boolean; keyboardNumpad: boolean;
@@ -42,6 +47,9 @@ interface UIState {
openMediaOverlay: () => void; openMediaOverlay: () => void;
closeMediaOverlay: () => void; closeMediaOverlay: () => void;
openControlsOverlay: () => void;
closeControlsOverlay: () => void;
showPersonAlert: (camera: string) => void; showPersonAlert: (camera: string) => void;
dismissPersonAlert: () => void; dismissPersonAlert: () => void;
@@ -65,12 +73,14 @@ export const useUIStore = create<UIState>((set) => ({
locksOverlayOpen: false, locksOverlayOpen: false,
thermostatsOverlayOpen: false, thermostatsOverlayOpen: false,
mediaOverlayOpen: false, mediaOverlayOpen: false,
controlsOverlayOpen: false,
personAlertActive: false, personAlertActive: false,
personAlertCamera: null, personAlertCamera: null,
alarmoKeypadOpen: false, alarmoKeypadOpen: false,
alarmoAction: null, alarmoAction: null,
settingsOpen: false, settingsOpen: false,
screenOn: true, screenOn: true,
isIdle: false,
keyboardOpen: false, keyboardOpen: false,
keyboardNumpad: false, keyboardNumpad: false,
@@ -106,6 +116,10 @@ export const useUIStore = create<UIState>((set) => ({
openMediaOverlay: () => set({ mediaOverlayOpen: true }), openMediaOverlay: () => set({ mediaOverlayOpen: true }),
closeMediaOverlay: () => set({ mediaOverlayOpen: false }), closeMediaOverlay: () => set({ mediaOverlayOpen: false }),
// Controls overlay (lights / locks / alarm / thermostats)
openControlsOverlay: () => set({ controlsOverlayOpen: true }),
closeControlsOverlay: () => set({ controlsOverlayOpen: false }),
// Person detection alert // Person detection alert
showPersonAlert: (camera) => showPersonAlert: (camera) =>
set({ set({
@@ -137,6 +151,9 @@ export const useUIStore = create<UIState>((set) => ({
// Screen // Screen
setScreenOn: (on) => set({ screenOn: on }), setScreenOn: (on) => set({ screenOn: on }),
// Idle
setIdle: (idle) => set({ isIdle: idle }),
// Keyboard // Keyboard
openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }), openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }),
closeKeyboard: () => set({ keyboardOpen: false }), closeKeyboard: () => set({ keyboardOpen: false }),

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=Quicksand:wght@400;500;600;700&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -6,13 +6,13 @@
@layer base { @layer base {
html { html {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body { body {
@apply bg-dark-primary text-white; @apply bg-dark-primary text-ink;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@@ -20,8 +20,8 @@
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 6px;
height: 4px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -29,7 +29,7 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-dark-border rounded; @apply bg-dark-border rounded-full;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@@ -40,9 +40,10 @@
@layer components { @layer components {
/* Button base */ /* Button base */
.btn { .btn {
@apply flex items-center justify-center gap-2 px-4 py-2.5 @apply flex items-center justify-center gap-2 px-5 py-3
bg-dark-tertiary border border-dark-border rounded-xl bg-dark-secondary border border-dark-border rounded-2xl
font-medium text-sm text-white font-semibold text-base text-ink
shadow-card
transition-all duration-150 ease-out transition-all duration-150 ease-out
hover:bg-dark-hover hover:border-dark-border-light hover:bg-dark-hover hover:border-dark-border-light
active:scale-[0.98] touch-manipulation; active:scale-[0.98] touch-manipulation;
@@ -50,30 +51,31 @@
.btn-primary { .btn-primary {
@apply btn bg-accent border-accent text-white @apply btn bg-accent border-accent text-white
hover:bg-accent-light hover:border-accent-light; hover:bg-accent-dark hover:border-accent-dark;
} }
.btn-sm { .btn-sm {
@apply px-2.5 py-1 text-xs rounded-lg; @apply px-3 py-1.5 text-sm rounded-xl;
} }
.btn-icon { .btn-icon {
@apply btn p-2.5; @apply btn p-3;
} }
/* Widget */ /* Widget - Skylight-style rounded card */
.widget { .widget {
@apply bg-dark-secondary border border-dark-border rounded-2xl p-4 @apply bg-dark-secondary border border-dark-border rounded-3xl p-5
shadow-card
flex flex-col overflow-hidden; flex flex-col overflow-hidden;
} }
.widget-title { .widget-title {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wide @apply flex items-center gap-2 text-sm font-bold uppercase tracking-wide
text-gray-400 mb-3; text-ink-muted mb-3;
} }
.widget-title svg { .widget-title svg {
@apply w-4 h-4 opacity-70; @apply w-5 h-5 opacity-70;
} }
.widget-content { .widget-content {
@@ -82,36 +84,36 @@
/* Toggle switch */ /* Toggle switch */
.toggle { .toggle {
@apply relative w-9 h-5 bg-dark-elevated rounded-full cursor-pointer @apply relative w-11 h-6 bg-dark-tertiary border border-dark-border rounded-full cursor-pointer
transition-colors duration-200; transition-colors duration-200;
} }
.toggle.active { .toggle.active {
@apply bg-accent; @apply bg-accent border-accent;
} }
.toggle-thumb { .toggle-thumb {
@apply absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full @apply absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full
transition-transform duration-200 shadow-sm; transition-transform duration-200 shadow-sm;
} }
.toggle.active .toggle-thumb { .toggle.active .toggle-thumb {
@apply translate-x-4; @apply translate-x-5;
} }
/* Status badge */ /* Status badge */
.status-badge { .status-badge {
@apply flex items-center gap-2 px-3.5 py-2 bg-dark-tertiary rounded-full @apply flex items-center gap-2 px-4 py-2 bg-dark-tertiary rounded-full
text-sm text-gray-400; text-sm text-ink-muted;
} }
.status-dot { .status-dot {
@apply w-2 h-2 rounded-full; @apply w-2.5 h-2.5 rounded-full;
} }
.status-dot.connected { .status-dot.connected {
@apply bg-status-success; @apply bg-status-success;
box-shadow: 0 0 8px theme('colors.status.success'); box-shadow: 0 0 8px rgba(74, 157, 122, 0.5);
} }
.status-dot.disconnected { .status-dot.disconnected {
@@ -128,21 +130,21 @@
} }
.person-avatar { .person-avatar {
@apply w-8 h-8 rounded-full overflow-hidden border-2 transition-all; @apply w-9 h-9 rounded-full overflow-hidden border-2 transition-all;
} }
.person-avatar.home { .person-avatar.home {
@apply border-status-success; @apply border-status-success;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.3); box-shadow: 0 0 8px rgba(74, 157, 122, 0.35);
} }
.person-avatar.away { .person-avatar.away {
@apply border-gray-500 opacity-70; @apply border-dark-border-light opacity-70;
} }
.person-avatar.work { .person-avatar.work {
@apply border-accent; @apply border-accent;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3); box-shadow: 0 0 8px rgba(127, 168, 148, 0.35);
} }
.person-avatar img { .person-avatar img {
@@ -150,7 +152,7 @@
} }
.person-location { .person-location {
@apply text-[0.55rem] font-medium uppercase tracking-wide text-gray-500; @apply text-[0.6rem] font-semibold uppercase tracking-wide text-ink-subtle;
} }
.person-location.home { .person-location.home {
@@ -158,12 +160,12 @@
} }
.person-location.work { .person-location.work {
@apply text-accent; @apply text-accent-dark;
} }
/* Status icon */ /* Status icon */
.status-icon { .status-icon {
@apply relative w-8 h-8 rounded-lg flex items-center justify-center @apply relative w-9 h-9 rounded-xl flex items-center justify-center
cursor-pointer transition-transform hover:scale-110; cursor-pointer transition-transform hover:scale-110;
} }
@@ -174,55 +176,56 @@
.status-icon.package::after { .status-icon.package::after {
content: ''; content: '';
@apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full @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; border-2 border-dark-secondary animate-pulse;
} }
/* Keypad */ /* Keypad */
.keypad-btn { .keypad-btn {
@apply w-14 h-14 rounded-xl bg-dark-tertiary border border-dark-border @apply w-16 h-16 rounded-2xl bg-dark-secondary border border-dark-border
text-xl font-medium text-white text-2xl font-semibold text-ink
shadow-card
transition-all duration-150 transition-all duration-150
hover:border-accent active:bg-accent active:scale-95 hover:border-accent hover:bg-dark-hover active:bg-accent active:text-white active:scale-95
touch-manipulation; touch-manipulation;
} }
/* Temperature */ /* Temperature */
.temp-display { .temp-display {
@apply text-5xl font-light tracking-tight; @apply text-5xl font-light tracking-tight text-ink;
} }
.temp-setpoint { .temp-setpoint {
@apply text-center px-2.5 py-1.5 bg-dark-tertiary rounded-lg; @apply text-center px-3 py-2 bg-dark-tertiary rounded-xl;
} }
.temp-setpoint-label { .temp-setpoint-label {
@apply text-[0.5rem] text-gray-500 uppercase tracking-wide mb-0.5; @apply text-[0.55rem] text-ink-subtle uppercase tracking-wide mb-0.5 font-semibold;
} }
.temp-setpoint-value { .temp-setpoint-value {
@apply text-lg font-semibold; @apply text-lg font-bold;
} }
.temp-setpoint-value.heat { .temp-setpoint-value.heat {
@apply text-orange-400; @apply text-status-warning;
} }
.temp-setpoint-value.cool { .temp-setpoint-value.cool {
@apply text-sky-400; @apply text-accent-dark;
} }
.temp-btn { .temp-btn {
@apply w-8 h-8 rounded-full bg-dark-tertiary border border-dark-border @apply w-9 h-9 rounded-full bg-dark-secondary border border-dark-border
flex items-center justify-center text-base flex items-center justify-center text-base
transition-all hover:bg-dark-hover; transition-all hover:bg-dark-hover;
} }
.temp-btn.heat { .temp-btn.heat {
@apply border-orange-400 text-orange-400 hover:bg-orange-400/15; @apply border-status-warning text-status-warning hover:bg-status-warning/15;
} }
.temp-btn.cool { .temp-btn.cool {
@apply border-sky-400 text-sky-400 hover:bg-sky-400/15; @apply border-accent-dark text-accent-dark hover:bg-accent/15;
} }
/* Overlay */ /* Overlay */
@@ -233,9 +236,14 @@
/* Compact rows */ /* Compact rows */
.compact-row { .compact-row {
@apply flex items-center justify-between px-2.5 py-2 bg-dark-tertiary rounded-lg @apply flex items-center justify-between px-3 py-2.5 bg-dark-tertiary rounded-xl
transition-colors hover:bg-dark-hover; transition-colors hover:bg-dark-hover;
} }
/* Legacy imperial card for ConnectionPrompt */
.card-imperial {
@apply bg-dark-secondary border border-dark-border rounded-3xl p-8 shadow-card-lg;
}
} }
@layer utilities { @layer utilities {
@@ -256,4 +264,23 @@
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }
/*
* Theme remap (cool light): existing components use text-white and
* text-gray-* for dark-theme text. Rebind to cool slate ink so the UI
* is readable on the slate-100 bg without touching every component.
*/
.text-white { color: #0f172a; }
.text-gray-300 { color: #1e293b; }
.text-gray-400 { color: #475569; }
.text-gray-500 { color: #64748b; }
.text-gray-600 { color: #94a3b8; }
.bg-imperial-black { background-color: #f1f5f9; }
.bg-black { background-color: #0f172a; }
.text-ink { color: #0f172a; }
.text-ink-muted { color: #475569; }
.text-ink-subtle { color: #64748b; }
.text-ink-faint { color: #94a3b8; }
} }

8
src/vite-env.d.ts vendored
View File

@@ -12,6 +12,8 @@ interface ImportMetaEnv {
readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string; readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string;
readonly VITE_FRIGATE_STREAM_ENABLED: string; readonly VITE_FRIGATE_STREAM_ENABLED: string;
readonly VITE_FRIGATE_RTSP_OUTPUT: string; readonly VITE_FRIGATE_RTSP_OUTPUT: string;
readonly VITE_PHOTO_FRAME_IDLE_TIMEOUT: string;
readonly VITE_PHOTO_FRAME_INTERVAL: string;
} }
interface ImportMeta { interface ImportMeta {
@@ -34,6 +36,7 @@ interface ElectronAPI {
frigate: { frigate: {
startStream: (rtspUrl: string) => Promise<boolean>; startStream: (rtspUrl: string) => Promise<boolean>;
stopStream: () => Promise<boolean>; stopStream: () => Promise<boolean>;
onPersonDetected: (callback: (camera: string) => void) => () => void;
}; };
app: { app: {
quit: () => void; quit: () => void;
@@ -44,6 +47,11 @@ interface ElectronAPI {
getStoredToken: () => Promise<string | null>; getStoredToken: () => Promise<string | null>;
getJellyfinApiKey: () => Promise<string | null>; getJellyfinApiKey: () => Promise<string | null>;
}; };
photos: {
list: () => Promise<string[]>;
getDir: () => Promise<string>;
getUrl: (relative: string) => Promise<string | null>;
};
} }
interface Window { interface Window {

View File

@@ -7,41 +7,49 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Modern dark theme // Cool light theme (slate-based neutrals + blue accent).
// Token names kept as "dark.*" so existing components don't need class changes.
dark: { dark: {
primary: '#0f0f0f', primary: '#f1f5f9', // page bg - slate-100
secondary: '#171717', secondary: '#ffffff', // card bg - white
tertiary: '#1f1f1f', tertiary: '#e2e8f0', // subtle panels - slate-200
elevated: '#262626', elevated: '#ffffff', // elevated cards
hover: '#2a2a2a', hover: '#e2e8f0',
border: '#2e2e2e', border: '#e2e8f0', // cool soft border
'border-light': '#3a3a3a', 'border-light': '#cbd5e1',
}, },
// Blue accent // Cool pastel teal accent (picks up Becca's event color)
accent: { accent: {
DEFAULT: '#3b82f6', DEFAULT: '#14b8a6', // teal-500
light: '#60a5fa', light: '#5eead4', // teal-300
dark: '#2563eb', dark: '#0d9488', // teal-600
},
// Cool slate ink text scale
ink: {
DEFAULT: '#0f172a', // slate-900
muted: '#475569', // slate-600
subtle: '#64748b', // slate-500
faint: '#94a3b8', // slate-400
}, },
// Status colors
status: { status: {
success: '#22c55e', success: '#16a34a', // green-600
warning: '#f59e0b', warning: '#d97706', // amber-600
error: '#ef4444', error: '#dc2626', // red-600
}, },
// Legacy imperial colors (for gradual migration) // Legacy imperial tokens retained so old references don't break
imperial: { imperial: {
black: '#0a0a0a', black: '#f1f5f9',
dark: '#1a1a1a', dark: '#e2e8f0',
medium: '#2a2a2a', medium: '#cbd5e1',
light: '#3a3a3a', light: '#94a3b8',
red: '#cc0000', red: '#dc2626',
'red-dark': '#990000', 'red-dark': '#b91c1c',
'red-light': '#ff3333', 'red-light': '#ef4444',
}, },
}, },
fontFamily: { fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'], sans: ['Nunito', 'Quicksand', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
display: ['Nunito', 'Quicksand', 'sans-serif'],
}, },
fontSize: { fontSize: {
'touch': '1.125rem', 'touch': '1.125rem',
@@ -51,18 +59,23 @@ module.exports = {
'touch-lg': '56px', 'touch-lg': '56px',
}, },
borderRadius: { borderRadius: {
'xl': '0.75rem', 'xl': '1rem',
'2xl': '1rem', '2xl': '1.5rem',
'3xl': '2rem',
}, },
boxShadow: { boxShadow: {
'glow-green': '0 0 8px rgba(34, 197, 94, 0.3)', 'card': '0 2px 8px rgba(58, 50, 42, 0.06), 0 1px 2px rgba(58, 50, 42, 0.04)',
'glow-blue': '0 0 8px rgba(59, 130, 246, 0.3)', 'card-lg': '0 8px 24px rgba(58, 50, 42, 0.08), 0 2px 6px rgba(58, 50, 42, 0.05)',
'glow-orange': '0 0 8px rgba(249, 115, 22, 0.3)', 'glow-green': '0 0 12px rgba(127, 168, 148, 0.35)',
'glow-blue': '0 0 12px rgba(127, 168, 148, 0.35)',
'glow-orange': '0 0 12px rgba(217, 154, 74, 0.35)',
}, },
animation: { animation: {
'fade-in': 'fade-in 0.2s ease-out', 'fade-in': 'fade-in 0.2s ease-out',
'fade-slow': 'fade-in 1.2s ease-out',
'slide-up': 'slide-up 0.3s ease-out', 'slide-up': 'slide-up 0.3s ease-out',
'pulse': 'pulse 2s ease-in-out infinite', 'pulse': 'pulse 2s ease-in-out infinite',
'ken-burns': 'ken-burns 20s ease-in-out infinite alternate',
}, },
keyframes: { keyframes: {
'fade-in': { 'fade-in': {
@@ -77,6 +90,10 @@ module.exports = {
'0%, 100%': { opacity: '1' }, '0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' }, '50%': { opacity: '0.5' },
}, },
'ken-burns': {
'0%': { transform: 'scale(1.0) translate(0, 0)' },
'100%': { transform: 'scale(1.08) translate(-1%, -1%)' },
},
}, },
}, },
}, },