proxy backend

This commit is contained in:
Oli Passey
2025-12-21 16:54:13 +00:00
parent 1fb43156e8
commit 77bf4ffd05
30 changed files with 3010 additions and 79 deletions

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ dist/
*.db-journal
.env
.DS_Store
# Build artifacts
/backend/public/
/release/

136
BUILD.md Normal file
View File

@@ -0,0 +1,136 @@
# Building WLED Controller for Windows
This guide explains how to build a standalone Windows executable for WLED Controller.
## Prerequisites
- Node.js 20 or higher
- npm
## Build Steps
### 1. Install Dependencies
First, install dependencies for both frontend and backend:
```powershell
# Install backend dependencies
cd backend
npm install
# Install frontend dependencies
cd ../frontend
npm install
```
### 2. Run Database Migrations
Ensure your database is set up:
```powershell
cd backend
npm run prisma:migrate
npm run prisma:generate
```
### 3. Build the Executable
From the backend directory, run:
```powershell
cd backend
npm run package
```
This command will:
1. Build the frontend and copy it to `backend/public`
2. Build the backend TypeScript code to JavaScript
3. Package everything into a single Windows executable
The executable will be created at: `release/wled-controller.exe`
## Running the Executable
### First Time Setup
1. Copy the `wled-controller.exe` file to your desired location
2. In the same directory, create a `prisma` folder
3. Copy the `backend/prisma/dev.db` file (your database) to the same directory as the exe
Your directory structure should look like:
```
your-install-folder/
├── wled-controller.exe
└── prisma/
└── dev.db
```
### Running
Simply double-click `wled-controller.exe` or run from command line:
```powershell
.\wled-controller.exe
```
The application will start on port 3000 (configurable via PORT environment variable).
Access the web interface at: http://localhost:3000
## Configuration
You can set environment variables before running:
```powershell
# Set custom port
$env:PORT=8080
.\wled-controller.exe
# Set custom database path
$env:DATABASE_URL="file:./custom-path/wled.db"
.\wled-controller.exe
```
## Troubleshooting
### Database Not Found
If you see database errors, ensure:
- The `prisma` folder exists in the same directory as the exe
- The `dev.db` file is in the `prisma` folder
- File permissions allow reading/writing
### Port Already in Use
If port 3000 is already in use, set a different port:
```powershell
$env:PORT=3001
.\wled-controller.exe
```
### Missing Dependencies
The executable includes all Node.js dependencies, but requires:
- Windows 10 or higher (64-bit)
- No additional runtime needed
## Development vs Production
- **Development**: Use `npm run dev` in both frontend and backend directories
- **Production Build**: Use `npm run package` to create the executable
## File Sizes
The packaged executable will be approximately 80-100 MB due to:
- Node.js runtime
- All npm dependencies
- Frontend static files
- Prisma binaries
## Notes
- The executable is self-contained and includes the Node.js runtime
- All frontend files are served from the built-in web server
- Database file can be backed up by copying the `dev.db` file
- Scheduler tasks will run automatically when the executable starts

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"build:frontend": "cd ../frontend && npm run build && xcopy /E /I /Y dist ..\\backend\\public",
"build:full": "npm run build:frontend && npm run build",
"package": "npm run build:full && pkg . --targets node18-win-x64 --output ../release/wled-controller.exe",
"test": "jest",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
@@ -23,11 +26,12 @@
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.8.0",
"bonjour-service": "^1.3.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"luxon": "^3.4.4",
"node-cron": "^3.0.3",
"p-limit": "^5.0.0",
"p-limit": "^4.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -47,6 +51,20 @@
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"pkg": "^5.8.1"
},
"bin": "dist/server.js",
"pkg": {
"assets": [
"prisma/schema.prisma",
"node_modules/@prisma/client/**/*",
"node_modules/.prisma/**/*",
"public/**/*"
],
"outputPath": "../release",
"targets": [
"node18-win-x64"
]
}
}

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "QuickAction" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"icon" TEXT,
"groupId" TEXT,
"deviceId" TEXT,
"actionType" TEXT NOT NULL,
"actionPayload" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -55,3 +55,16 @@ model Schedule {
group Group @relation(fields: [groupId], references: [id])
}
model QuickAction {
id String @id @default(uuid())
name String
icon String? // Optional icon/emoji
groupId String? // Optional: if action targets a group
deviceId String? // Optional: if action targets a device
actionType String // 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'
actionPayload String // JSON string
order Int @default(0) // For sorting
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -1,8 +1,10 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import devicesRouter from './routes/devices';
import groupsRouter from './routes/groups';
import schedulesRouter from './routes/schedules';
import quickActionsRouter from './routes/quickActions';
export function createApp() {
const app = express();
@@ -17,19 +19,28 @@ export function createApp() {
next();
});
// Routes
// API Routes
app.use('/api/devices', devicesRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/schedules', schedulesRouter);
app.use('/api/quick-actions', quickActionsRouter);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'NotFound', message: 'Route not found' });
// Serve static frontend files in production
const frontendPath = path.join(__dirname, '..', 'public');
app.use(express.static(frontendPath));
// Serve index.html for all non-API routes (SPA support)
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(path.join(frontendPath, 'index.html'));
} else {
res.status(404).json({ error: 'NotFound', message: 'API route not found' });
}
});
return app;

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { deviceService } from '../services/deviceService';
import { discoveryService } from '../services/discoveryService';
const router = Router();
@@ -202,4 +203,112 @@ router.post('/all/turn-off', async (req: Request, res: Response) => {
}
});
// POST /api/devices/:id/sync
router.post('/:id/sync', async (req: Request, res: Response) => {
try {
const { targetIds } = z.object({
targetIds: z.array(z.string()).min(1, 'At least one target device is required'),
}).parse(req.body);
const result = await deviceService.syncDeviceState(req.params.id, targetIds);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'NotFound', message: error.message });
}
console.error('Error syncing device state:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to sync device state' });
}
});
// POST /api/devices/:sourceId/copy-to/:targetId
router.post('/:sourceId/copy-to/:targetId', async (req: Request, res: Response) => {
try {
await deviceService.copyDeviceConfig(req.params.sourceId, req.params.targetId);
res.json({ status: 'ok', message: 'Configuration copied successfully' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'NotFound', message: error.message });
}
console.error('Error copying device config:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to copy configuration' });
}
});
// POST /api/devices/discover/mdns
router.post('/discover/mdns', async (req: Request, res: Response) => {
try {
const { timeout } = z.object({
timeout: z.number().int().positive().optional(),
}).parse(req.body);
const discoveredDevices = await discoveryService.discoverDevices(timeout);
res.json({ devices: discoveredDevices, count: discoveredDevices.length });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
console.error('Error discovering devices:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to discover devices' });
}
});
// POST /api/devices/discover/scan
router.post('/discover/scan', async (req: Request, res: Response) => {
try {
const { baseIp, startRange, endRange } = z.object({
baseIp: z.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}$/, 'Invalid base IP format (e.g., 192.168.1)'),
startRange: z.number().int().min(1).max(254).optional(),
endRange: z.number().int().min(1).max(254).optional(),
}).parse(req.body);
const discoveredDevices = await discoveryService.scanIpRange(baseIp, startRange, endRange);
res.json({ devices: discoveredDevices, count: discoveredDevices.length });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
console.error('Error scanning IP range:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to scan IP range' });
}
});
// POST /api/devices/discover/verify
router.post('/discover/verify', async (req: Request, res: Response) => {
try {
const { ipAddress, port } = z.object({
ipAddress: z.string().min(1, 'IP address is required'),
port: z.number().int().positive().optional(),
}).parse(req.body);
const device = await discoveryService.verifyDevice(ipAddress, port);
if (device) {
res.json({ found: true, device });
} else {
res.json({ found: false, message: 'No WLED device found at this address' });
}
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
console.error('Error verifying device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to verify device' });
}
});
export default router;

View File

@@ -38,6 +38,20 @@ router.get('/', async (req: Request, res: Response) => {
}
});
// GET /api/groups/:id/presets - Must come before /:id to avoid matching
router.get('/:id/presets', async (req: Request, res: Response) => {
try {
const presets = await groupService.getGroupPresets(req.params.id);
res.json(presets);
} catch (error) {
if (error instanceof Error && error.message === 'Group not found') {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error fetching group presets:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' });
}
});
// GET /api/groups/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
@@ -156,18 +170,4 @@ router.post('/:id/playlist', async (req: Request, res: Response) => {
}
});
// GET /api/groups/:id/presets
router.get('/:id/presets', async (req: Request, res: Response) => {
try {
const presets = await groupService.getGroupPresets(req.params.id);
res.json(presets);
} catch (error) {
if (error instanceof Error && error.message === 'Group not found') {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error fetching group presets:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' });
}
});
export default router;

View File

@@ -0,0 +1,132 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { quickActionService, QuickActionType } from '../services/quickActionService';
const router = Router();
const createQuickActionSchema = z.object({
name: z.string().min(1, 'Name is required'),
icon: z.string().optional(),
groupId: z.string().optional(),
deviceId: z.string().optional(),
actionType: z.enum(['PRESET', 'PLAYLIST', 'TURN_ON', 'TURN_OFF', 'BRIGHTNESS']),
actionPayload: z.unknown().optional(),
order: z.number().int().optional(),
});
const updateQuickActionSchema = z.object({
name: z.string().min(1).optional(),
icon: z.string().optional(),
groupId: z.string().optional(),
deviceId: z.string().optional(),
actionType: z.enum(['PRESET', 'PLAYLIST', 'TURN_ON', 'TURN_OFF', 'BRIGHTNESS']).optional(),
actionPayload: z.unknown().optional(),
order: z.number().int().optional(),
});
// GET /api/quick-actions
router.get('/', async (req: Request, res: Response) => {
try {
const actions = await quickActionService.getAllQuickActions();
const actionsWithParsedPayload = actions.map((a) => ({
...a,
actionPayload: quickActionService.parseActionPayload(a),
}));
res.json(actionsWithParsedPayload);
} catch (error) {
console.error('Error fetching quick actions:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch quick actions' });
}
});
// GET /api/quick-actions/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
const action = await quickActionService.getQuickActionById(req.params.id);
if (!action) {
return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' });
}
res.json({
...action,
actionPayload: quickActionService.parseActionPayload(action),
});
} catch (error) {
console.error('Error fetching quick action:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch quick action' });
}
});
// POST /api/quick-actions
router.post('/', async (req: Request, res: Response) => {
try {
const body = createQuickActionSchema.parse(req.body);
const action = await quickActionService.createQuickAction(body);
res.status(201).json({
...action,
actionPayload: quickActionService.parseActionPayload(action),
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
console.error('Error creating quick action:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to create quick action' });
}
});
// PUT /api/quick-actions/:id
router.put('/:id', async (req: Request, res: Response) => {
try {
const body = updateQuickActionSchema.parse(req.body);
const action = await quickActionService.updateQuickAction(req.params.id, body);
res.json({
...action,
actionPayload: quickActionService.parseActionPayload(action),
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })),
});
}
if (error instanceof Error && error.message.includes('Record to update not found')) {
return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' });
}
console.error('Error updating quick action:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to update quick action' });
}
});
// DELETE /api/quick-actions/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
await quickActionService.deleteQuickAction(req.params.id);
res.status(204).send();
} catch (error) {
if (error instanceof Error && error.message.includes('Record to delete does not exist')) {
return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' });
}
console.error('Error deleting quick action:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to delete quick action' });
}
});
// POST /api/quick-actions/:id/execute
router.post('/:id/execute', async (req: Request, res: Response) => {
try {
const result = await quickActionService.executeQuickAction(req.params.id);
res.json(result);
} catch (error) {
if (error instanceof Error && error.message === 'Quick action not found') {
return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' });
}
console.error('Error executing quick action:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to execute quick action' });
}
});
export default router;

View File

@@ -14,7 +14,7 @@ async function main() {
const app = createApp();
// Start server
const server = app.listen(config.port, () => {
const server = app.listen(config.port, '0.0.0.0', () => {
console.log(`Server running on port ${config.port}`);
console.log(`Health check: http://localhost:${config.port}/health`);
});

View File

@@ -210,6 +210,63 @@ export class DeviceService {
return { success, failed };
}
async syncDeviceState(sourceId: string, targetIds: string[]): Promise<{ success: string[]; failed: Array<{ deviceId: string; error: string }> }> {
const sourceDevice = await this.getDeviceById(sourceId);
if (!sourceDevice) {
throw new Error('Source device not found');
}
// Get current state from source device
const sourceClient = new WledClient(sourceDevice.ipAddress, sourceDevice.port);
const sourceState = await sourceClient.getState();
const results = await Promise.allSettled(
targetIds.map(async (targetId) => {
const targetDevice = await this.getDeviceById(targetId);
if (!targetDevice) {
throw new Error('Target device not found');
}
const targetClient = new WledClient(targetDevice.ipAddress, targetDevice.port);
await targetClient.applyState(sourceState);
return targetId;
})
);
const success: string[] = [];
const failed: Array<{ deviceId: string; error: string }> = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
success.push(result.value);
} else {
failed.push({
deviceId: targetIds[index],
error: result.reason?.message || 'Unknown error'
});
}
});
return { success, failed };
}
async copyDeviceConfig(sourceId: string, targetId: string): Promise<void> {
const sourceDevice = await this.getDeviceById(sourceId);
const targetDevice = await this.getDeviceById(targetId);
if (!sourceDevice) throw new Error('Source device not found');
if (!targetDevice) throw new Error('Target device not found');
const sourceClient = new WledClient(sourceDevice.ipAddress, sourceDevice.port);
const [sourceState, sourcePresets] = await Promise.all([
sourceClient.getState(),
sourceClient.getPresets(),
]);
const targetClient = new WledClient(targetDevice.ipAddress, targetDevice.port);
await targetClient.applyState(sourceState);
// Note: Preset copying would require WLED API support for uploading presets
}
async updateLastSeen(id: string): Promise<void> {
await prisma.device.update({
where: { id },

View File

@@ -0,0 +1,168 @@
import Bonjour from 'bonjour-service';
import { WledClient } from '../wled/client';
export interface DiscoveredDevice {
name: string;
ipAddress: string;
port: number;
host: string;
type: string;
}
export class DiscoveryService {
private bonjour: Bonjour;
constructor() {
this.bonjour = new Bonjour();
}
/**
* Discover WLED devices on the network using mDNS
* @param timeout - How long to search for devices (in milliseconds)
* @returns Array of discovered devices
*/
async discoverDevices(timeout: number = 5000): Promise<DiscoveredDevice[]> {
return new Promise((resolve) => {
const discoveredDevices: DiscoveredDevice[] = [];
const seenAddresses = new Set<string>();
// Search for WLED devices using mDNS service discovery
// WLED devices advertise themselves as _http._tcp
const browser = this.bonjour.find({ type: 'http' });
browser.on('up', (service) => {
// WLED devices typically have "wled" in their name or txt records
const isWled =
service.name?.toLowerCase().includes('wled') ||
service.txt?.ver?.startsWith('0.') || // WLED version format
service.txt?.name !== undefined;
if (isWled && service.addresses && service.addresses.length > 0) {
// Use the first IPv4 address
const ipv4Address = service.addresses.find((addr: string) =>
addr.includes('.') && !addr.startsWith('169.254')
);
if (ipv4Address && !seenAddresses.has(ipv4Address)) {
seenAddresses.add(ipv4Address);
discoveredDevices.push({
name: service.name || service.host || 'Unknown WLED Device',
ipAddress: ipv4Address,
port: service.port || 80,
host: service.host || ipv4Address,
type: service.type || 'http',
});
}
}
});
// Stop searching after timeout
setTimeout(() => {
browser.stop();
resolve(discoveredDevices);
}, timeout);
});
}
/**
* Scan a range of IP addresses for WLED devices
* This is a fallback method when mDNS doesn't work
* @param baseIp - Base IP address (e.g., "192.168.1")
* @param startRange - Start of range (e.g., 1)
* @param endRange - End of range (e.g., 254)
* @returns Array of discovered devices
*/
async scanIpRange(
baseIp: string,
startRange: number = 1,
endRange: number = 254
): Promise<DiscoveredDevice[]> {
const discoveredDevices: DiscoveredDevice[] = [];
const promises: Promise<void>[] = [];
for (let i = startRange; i <= endRange; i++) {
const ipAddress = `${baseIp}.${i}`;
promises.push(
(async () => {
try {
const client = new WledClient(ipAddress, 80);
const info = await Promise.race([
client.getInfo(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 2000)
),
]) as any;
if (info && info.name) {
discoveredDevices.push({
name: info.name,
ipAddress,
port: 80,
host: ipAddress,
type: 'wled',
});
}
} catch (err) {
// Device not found or not responding, ignore
}
})()
);
// Process in batches of 10 to avoid overwhelming the network
if (promises.length >= 10) {
await Promise.all(promises);
promises.length = 0;
}
}
// Wait for remaining promises
if (promises.length > 0) {
await Promise.all(promises);
}
return discoveredDevices;
}
/**
* Verify a device at a specific IP address is a WLED device
* @param ipAddress - IP address to check
* @param port - Port number (default: 80)
* @returns Device info if it's a WLED device, null otherwise
*/
async verifyDevice(
ipAddress: string,
port: number = 80
): Promise<DiscoveredDevice | null> {
try {
const client = new WledClient(ipAddress, port);
const info = await Promise.race([
client.getInfo(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 3000)
),
]) as any;
if (info && info.name) {
return {
name: info.name,
ipAddress,
port,
host: ipAddress,
type: 'wled',
};
}
return null;
} catch (err) {
return null;
}
}
destroy() {
this.bonjour.destroy();
}
}
export const discoveryService = new DiscoveryService();

View File

@@ -274,24 +274,77 @@ export class GroupService {
};
}
async turnOnGroup(groupId: string): Promise<GroupActionResult> {
const group = await this.getGroupById(groupId);
if (!group) {
throw new Error('Group not found');
}
const enabledDevices = group.devices.filter((d) => d.enabled);
const limit = pLimit(10);
const results = await Promise.allSettled(
enabledDevices.map((device) =>
limit(async () => {
const client = new WledClient(device.ipAddress, device.port);
await client.turnOn();
return device.id;
})
)
);
const success: string[] = [];
const failed: Array<{ deviceId: string; error: string }> = [];
results.forEach((result, index) => {
const deviceId = enabledDevices[index].id;
if (result.status === 'fulfilled') {
success.push(deviceId);
} else {
failed.push({ deviceId, error: result.reason?.message || 'Unknown error' });
}
});
return {
status: 'ok',
groupId,
results: { success, failed },
};
}
async getGroupPresets(groupId: string): Promise<unknown> {
const group = await this.getGroupById(groupId);
if (!group) {
throw new Error('Group not found');
}
// Get presets from the first enabled device in the group
const firstEnabledDevice = group.devices.find(d => d.enabled);
if (!firstEnabledDevice) {
console.log(`[getGroupPresets] Group ${groupId} has ${group.devices.length} devices`);
// Try to get presets from enabled devices
const enabledDevices = group.devices.filter(d => d.enabled);
if (enabledDevices.length === 0) {
console.log('[getGroupPresets] No enabled devices in group');
return {};
}
const client = new WledClient(firstEnabledDevice.ipAddress, firstEnabledDevice.port);
try {
return await client.getPresets();
} catch {
return {};
// Try each enabled device until one succeeds
for (const device of enabledDevices) {
console.log(`[getGroupPresets] Trying device ${device.name} (${device.ipAddress}:${device.port})`);
const client = new WledClient(device.ipAddress, device.port);
try {
const presets = await client.getPresets();
console.log(`[getGroupPresets] Successfully got presets from ${device.name}:`, Object.keys(presets).length, 'presets');
return presets;
} catch (error) {
console.log(`[getGroupPresets] Failed to get presets from ${device.name}:`, error instanceof Error ? error.message : 'Unknown error');
// Continue to next device
}
}
// If all devices failed, return empty
console.log('[getGroupPresets] All devices failed to respond');
return {};
}
}

View File

@@ -0,0 +1,151 @@
import { QuickAction } from '@prisma/client';
import { prisma } from '../utils/prisma';
import { deviceService } from './deviceService';
import { groupService } from './groupService';
export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS';
export interface CreateQuickActionInput {
name: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType: QuickActionType;
actionPayload?: unknown;
order?: number;
}
export interface UpdateQuickActionInput {
name?: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType?: QuickActionType;
actionPayload?: unknown;
order?: number;
}
export class QuickActionService {
async getAllQuickActions(): Promise<QuickAction[]> {
return prisma.quickAction.findMany({
orderBy: { order: 'asc' },
});
}
async getQuickActionById(id: string): Promise<QuickAction | null> {
return prisma.quickAction.findUnique({
where: { id },
});
}
async createQuickAction(input: CreateQuickActionInput): Promise<QuickAction> {
return prisma.quickAction.create({
data: {
name: input.name,
icon: input.icon,
groupId: input.groupId,
deviceId: input.deviceId,
actionType: input.actionType,
actionPayload: JSON.stringify(input.actionPayload || {}),
order: input.order ?? 0,
},
});
}
async updateQuickAction(id: string, input: UpdateQuickActionInput): Promise<QuickAction> {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.icon !== undefined) updateData.icon = input.icon;
if (input.groupId !== undefined) updateData.groupId = input.groupId;
if (input.deviceId !== undefined) updateData.deviceId = input.deviceId;
if (input.actionType !== undefined) updateData.actionType = input.actionType;
if (input.actionPayload !== undefined) updateData.actionPayload = JSON.stringify(input.actionPayload);
if (input.order !== undefined) updateData.order = input.order;
return prisma.quickAction.update({
where: { id },
data: updateData,
});
}
async deleteQuickAction(id: string): Promise<void> {
await prisma.quickAction.delete({
where: { id },
});
}
async executeQuickAction(id: string): Promise<{ status: string; message: string }> {
const action = await this.getQuickActionById(id);
if (!action) {
throw new Error('Quick action not found');
}
const payload = JSON.parse(action.actionPayload);
switch (action.actionType) {
case 'TURN_ON':
if (action.deviceId) {
await deviceService.turnOnDevice(action.deviceId);
} else if (action.groupId) {
await groupService.turnOnGroup(action.groupId);
}
return { status: 'ok', message: 'Turned on successfully' };
case 'TURN_OFF':
if (action.deviceId) {
await deviceService.turnOffDevice(action.deviceId);
} else if (action.groupId) {
await groupService.turnOffGroup(action.groupId);
}
return { status: 'ok', message: 'Turned off successfully' };
case 'PRESET':
if (action.groupId) {
await groupService.applyPresetToGroup(action.groupId, payload.presetId);
} else if (action.deviceId) {
const device = await deviceService.getDeviceById(action.deviceId);
if (!device) throw new Error('Device not found');
const client = await import('../wled/client');
const wledClient = new client.WledClient(device.ipAddress, device.port);
await wledClient.applyPreset(payload.presetId);
}
return { status: 'ok', message: 'Preset applied successfully' };
case 'PLAYLIST':
if (action.groupId) {
await groupService.applyPlaylistToGroup(action.groupId, payload);
}
return { status: 'ok', message: 'Playlist applied successfully' };
case 'BRIGHTNESS':
if (action.deviceId) {
const device = await deviceService.getDeviceById(action.deviceId);
if (!device) throw new Error('Device not found');
const client = await import('../wled/client');
const wledClient = new client.WledClient(device.ipAddress, device.port);
await wledClient.setBrightness(payload.brightness);
} else if (action.groupId) {
// Apply brightness to all devices in group
const group = await groupService.getGroupById(action.groupId);
if (!group) throw new Error('Group not found');
await Promise.all(
group.devices.map(async (device) => {
const client = await import('../wled/client');
const wledClient = new client.WledClient(device.ipAddress, device.port);
await wledClient.setBrightness(payload.brightness);
})
);
}
return { status: 'ok', message: 'Brightness set successfully' };
default:
throw new Error('Unknown action type');
}
}
parseActionPayload(action: QuickAction): unknown {
return JSON.parse(action.actionPayload);
}
}
export const quickActionService = new QuickActionService();

View File

@@ -30,9 +30,20 @@ export class SchedulerService {
const schedule = await scheduleService.getScheduleById(scheduleId);
if (!schedule || !schedule.enabled) {
console.log(`[registerSchedule] Schedule ${scheduleId} is ${!schedule ? 'not found' : 'disabled'}`);
return;
}
console.log(`[registerSchedule] Registering schedule:`, {
id: schedule.id,
name: schedule.name,
type: schedule.type,
cron: schedule.cronExpression,
endCron: schedule.endCronExpression,
timezone: schedule.timezone,
actionPayload: schedule.actionPayload
});
try {
// Register start task
const startTask = cron.schedule(
@@ -101,7 +112,16 @@ export class SchedulerService {
return;
}
console.log(`[executeSchedule] Schedule details:`, {
id: schedule.id,
name: schedule.name,
type: schedule.type,
groupId: schedule.groupId,
actionPayload: schedule.actionPayload
});
const actionPayload = scheduleService.parseActionPayload(schedule);
console.log(`[executeSchedule] Parsed action payload:`, actionPayload);
if (schedule.type === 'PRESET') {
const payload = actionPayload as PresetActionPayload;
@@ -110,7 +130,7 @@ export class SchedulerService {
console.log(`Preset applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} else if (schedule.type === 'PLAYLIST') {
const payload = actionPayload as WledPlaylist;
console.log(`Applying playlist to group ${schedule.groupId}`);
console.log(`Applying playlist to group ${schedule.groupId}`, payload);
const result = await groupService.applyPlaylistToGroup(schedule.groupId, payload);
console.log(`Playlist applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
}

View File

@@ -37,12 +37,12 @@ export class WledClient {
async applyPreset(presetId: number): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { ps: presetId });
await this.post(url, { ps: presetId, on: true });
}
async applyPlaylist(playlist: WledPlaylist): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { playlist });
await this.post(url, { playlist, on: true });
}
async turnOn(): Promise<void> {
@@ -55,6 +55,16 @@ export class WledClient {
await this.post(url, { on: false });
}
async applyState(state: Partial<WledState>): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, state);
}
async setBrightness(brightness: number): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { bri: brightness });
}
private async fetch<T>(url: string): Promise<T> {
try {
const response = await fetch(url, {

50
build.bat Normal file
View File

@@ -0,0 +1,50 @@
@echo off
echo Building WLED Controller for Windows...
echo.
cd backend
echo [1/4] Installing backend dependencies...
call npm install
if errorlevel 1 goto error
echo.
echo [2/4] Installing frontend dependencies...
cd ..\frontend
call npm install
if errorlevel 1 goto error
echo.
echo [3/4] Running Prisma generate...
cd ..\backend
call npm run prisma:generate
if errorlevel 1 goto error
echo.
echo [4/4] Building and packaging executable...
call npm run package
if errorlevel 1 goto error
echo.
echo ========================================
echo Build complete!
echo Executable location: release\wled-controller.exe
echo ========================================
echo.
echo To run:
echo 1. Copy release\wled-controller.exe to your desired location
echo 2. Copy backend\prisma\dev.db to the same folder (create prisma subfolder)
echo 3. Run wled-controller.exe
echo.
pause
goto end
:error
echo.
echo ========================================
echo Build failed! Please check the errors above.
echo ========================================
pause
exit /b 1
:end

View File

@@ -3,6 +3,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { DevicesPage } from './pages/DevicesPage';
import { GroupsPage } from './pages/GroupsPage';
import { SchedulesPage } from './pages/SchedulesPage';
import { QuickActionsPage } from './pages/QuickActionsPage';
import './App.css';
function App() {
@@ -25,6 +26,9 @@ function App() {
<li>
<Link to="/schedules">Schedules</Link>
</li>
<li>
<Link to="/quick-actions">Quick Actions</Link>
</li>
</ul>
</nav>
</aside>
@@ -36,6 +40,7 @@ function App() {
<Route path="/devices" element={<DevicesPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
<Route path="/quick-actions" element={<QuickActionsPage />} />
</Routes>
</main>
</div>

View File

@@ -1,4 +1,10 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// Use relative path for API calls - works for both local and network access
// When accessed from network, API calls will go to same host as frontend
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (
import.meta.env.DEV
? '/api' // In dev mode, use Vite proxy
: '/api' // In production, use relative path (backend serves frontend)
);
export class ApiError extends Error {
constructor(

View File

@@ -15,6 +15,25 @@ export interface UpdateDeviceInput {
enabled?: boolean;
}
export interface DiscoveredDevice {
name: string;
ipAddress: string;
port: number;
host: string;
type: string;
}
export interface DiscoveryResult {
devices: DiscoveredDevice[];
count: number;
}
export interface VerifyResult {
found: boolean;
device?: DiscoveredDevice;
message?: string;
}
export const deviceApi = {
getAll: () => apiClient.get<Device[]>('/devices'),
@@ -36,4 +55,19 @@ export const deviceApi = {
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
syncState: (sourceId: string, targetIds: string[]) =>
apiClient.post(`/devices/${sourceId}/sync`, { targetIds }),
copyConfig: (sourceId: string, targetId: string) =>
apiClient.post(`/devices/${sourceId}/copy-to/${targetId}`),
discoverMdns: (timeout?: number) =>
apiClient.post<DiscoveryResult>('/devices/discover/mdns', { timeout }),
scanIpRange: (baseIp: string, startRange?: number, endRange?: number) =>
apiClient.post<DiscoveryResult>('/devices/discover/scan', { baseIp, startRange, endRange }),
verifyDevice: (ipAddress: string, port?: number) =>
apiClient.post<VerifyResult>('/devices/discover/verify', { ipAddress, port }),
};

View File

@@ -28,4 +28,7 @@ export const groupApi = {
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
getPresets: (groupId: string) =>
apiClient.get<Record<string, any>>(`/groups/${groupId}/presets`),
};

View File

@@ -0,0 +1,51 @@
import { apiClient } from './client';
export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS';
export interface QuickAction {
id: string;
name: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType: QuickActionType;
actionPayload: unknown;
order: number;
createdAt: string;
updatedAt: string;
}
export interface CreateQuickActionInput {
name: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType: QuickActionType;
actionPayload: unknown;
order?: number;
}
export interface UpdateQuickActionInput {
name?: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType?: QuickActionType;
actionPayload?: unknown;
order?: number;
}
export const quickActionApi = {
getAll: () => apiClient.get<QuickAction[]>('/quick-actions'),
getById: (id: string) => apiClient.get<QuickAction>(`/quick-actions/${id}`),
create: (data: CreateQuickActionInput) => apiClient.post<QuickAction>('/quick-actions', data),
update: (id: string, data: UpdateQuickActionInput) =>
apiClient.put<QuickAction>(`/quick-actions/${id}`, data),
delete: (id: string) => apiClient.delete(`/quick-actions/${id}`),
execute: (id: string) => apiClient.post(`/quick-actions/${id}/execute`),
};

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { quickActionApi, QuickAction } from '../api/quickActions';
export function QuickActionsPanel() {
const [actions, setActions] = useState<QuickAction[]>([]);
const [loading, setLoading] = useState(true);
const [executing, setExecuting] = useState<string | null>(null);
useEffect(() => {
loadActions();
}, []);
const loadActions = async () => {
try {
const data = await quickActionApi.getAll();
setActions(data);
} catch (error) {
console.error('Failed to load quick actions:', error);
} finally {
setLoading(false);
}
};
const executeAction = async (id: string) => {
try {
setExecuting(id);
await quickActionApi.execute(id);
} catch (error) {
alert('Failed to execute action');
} finally {
setExecuting(null);
}
};
if (loading) return <div>Loading quick actions...</div>;
if (actions.length === 0) {
return (
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}></div>
<div>No quick actions configured</div>
<div style={{ fontSize: '14px', marginTop: '5px' }}>
Quick actions let you trigger presets, playlists, or control devices with one click
</div>
</div>
);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '15px' }}>
{actions.map((action) => (
<button
key={action.id}
className="btn btn-large"
onClick={() => executeAction(action.id)}
disabled={executing === action.id}
style={{
height: '100px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
backgroundColor: getActionColor(action.actionType),
color: 'white',
border: 'none',
position: 'relative',
}}
title={getActionDescription(action)}
>
<div style={{ fontSize: '32px' }}>
{action.icon || getDefaultIcon(action.actionType)}
</div>
<div style={{ textAlign: 'center', lineHeight: '1.2' }}>
{action.name}
</div>
{executing === action.id && (
<div style={{
position: 'absolute',
top: '5px',
right: '5px',
fontSize: '16px',
}}>
</div>
)}
</button>
))}
</div>
);
}
function getActionColor(actionType: string): string {
switch (actionType) {
case 'TURN_ON':
return '#2ecc71';
case 'TURN_OFF':
return '#e74c3c';
case 'PRESET':
return '#3498db';
case 'PLAYLIST':
return '#9b59b6';
case 'BRIGHTNESS':
return '#f39c12';
default:
return '#95a5a6';
}
}
function getDefaultIcon(actionType: string): string {
switch (actionType) {
case 'TURN_ON':
return '💡';
case 'TURN_OFF':
return '🌙';
case 'PRESET':
return '🎨';
case 'PLAYLIST':
return '🎬';
case 'BRIGHTNESS':
return '☀️';
default:
return '⚡';
}
}
function getActionDescription(action: QuickAction): string {
const target = action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All';
return `${action.actionType} - ${target}`;
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
import { groupApi } from '../api/groups';
interface TimePickerProps {
value: string; // cron expression
@@ -170,6 +171,7 @@ interface PresetSelectorProps {
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
const [presets, setPresets] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (groupId) {
@@ -180,11 +182,13 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
const loadPresets = async () => {
try {
setLoading(true);
const response = await fetch(`/api/groups/${groupId}/presets`);
const data = await response.json();
setError(null);
const data = await groupApi.getPresets(groupId);
setPresets(data);
} catch (error) {
console.error('Failed to load presets:', error);
setError(error instanceof Error ? error.message : 'Failed to load presets');
setPresets({});
} finally {
setLoading(false);
}
@@ -204,6 +208,21 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#e74c3c' }}>
<div>Error loading presets: {error}</div>
<button
onClick={loadPresets}
className="btn btn-small btn-secondary"
style={{ marginTop: '10px' }}
>
Retry
</button>
</div>
);
}
const presetEntries = Object.entries(presets)
.filter(([key]) => key !== '0')
.map(([key, value]) => {
@@ -214,7 +233,14 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
.sort((a, b) => a.id - b.id);
if (presetEntries.length === 0) {
return <div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>No presets available</div>;
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
<div>No presets available</div>
<div style={{ fontSize: '12px', marginTop: '5px' }}>
Make sure the group has devices and at least one device has presets configured
</div>
</div>
);
}
return (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { formatDateTime } from '../utils/dateTime';
import { deviceApi } from '../api/devices';
import { QuickActionsPanel } from '../components/QuickActionsPanel';
interface DeviceStatus {
id: string;
@@ -33,10 +34,16 @@ interface DeviceStatus {
};
}
interface DeviceWithStatus extends DeviceStatus {
connectionStatus: 'online' | 'warning' | 'offline';
lastSuccessfulPing?: number;
}
export function DashboardPage() {
const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [devices, setDevices] = useState<DeviceWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [deviceHistory, setDeviceHistory] = useState<Map<string, number>>(new Map());
useEffect(() => {
loadDevices();
@@ -55,8 +62,42 @@ export function DashboardPage() {
const loadDevices = async () => {
try {
const response = await fetch('/api/devices/status/all');
const data = await response.json();
setDevices(data);
const data = await response.json() as DeviceStatus[];
const now = Date.now();
const updatedHistory = new Map(deviceHistory);
// Update devices with connection status based on current state and history
const devicesWithStatus: DeviceWithStatus[] = data.map(device => {
const hasCurrentState = device.enabled && device.currentState !== undefined;
const lastSuccess = updatedHistory.get(device.id);
// Update last successful ping time
if (hasCurrentState) {
updatedHistory.set(device.id, now);
}
// Determine connection status
let connectionStatus: 'online' | 'warning' | 'offline';
if (hasCurrentState) {
connectionStatus = 'online';
} else if (lastSuccess && (now - lastSuccess) < 60000) {
// Device was online within the last 60 seconds - show as warning
connectionStatus = 'warning';
} else {
// Device hasn't responded in over 60 seconds - show as offline
connectionStatus = 'offline';
}
return {
...device,
connectionStatus,
lastSuccessfulPing: updatedHistory.get(device.id)
};
});
setDeviceHistory(updatedHistory);
setDevices(devicesWithStatus);
} catch (error) {
console.error('Failed to load device status:', error);
} finally {
@@ -66,7 +107,7 @@ export function DashboardPage() {
if (loading) return <div className="loading">Loading dashboard...</div>;
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
const onlineDevices = devices.filter(d => d.enabled && d.connectionStatus === 'online').length;
const totalEnabled = devices.filter(d => d.enabled).length;
return (
@@ -114,6 +155,11 @@ export function DashboardPage() {
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h3 style={{ marginBottom: '15px' }}>Quick Actions</h3>
<QuickActionsPanel />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
<div className="card">
<h3>Total Devices</h3>
@@ -133,9 +179,10 @@ export function DashboardPage() {
</div>
</div>
<h3 style={{ marginBottom: '15px' }}>Devices</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
{devices.map((device) => (
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} allDevices={devices} />
))}
</div>
</div>
@@ -143,14 +190,24 @@ export function DashboardPage() {
}
interface DeviceCardProps {
device: DeviceStatus;
device: DeviceWithStatus;
onRefresh: () => void;
allDevices: DeviceWithStatus[];
}
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
const isOnline = device.enabled && device.currentState !== undefined;
function DeviceCard({ device, onRefresh, allDevices }: DeviceCardProps) {
const isOnline = device.connectionStatus === 'online';
const isWarning = device.connectionStatus === 'warning';
const isOn = device.currentState?.on ?? false;
const [busy, setBusy] = useState(false);
const [showSync, setShowSync] = useState(false);
// Get status color
const getStatusColor = () => {
if (device.connectionStatus === 'online') return '#2ecc71'; // Green
if (device.connectionStatus === 'warning') return '#f39c12'; // Orange
return '#e74c3c'; // Red
};
const handleTurnOn = async () => {
try {
@@ -177,47 +234,105 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
};
return (
<div className="card" style={{ position: 'relative' }}>
<div className="card" style={{ position: 'relative', opacity: device.connectionStatus === 'offline' ? 0.6 : 1 }}>
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
backgroundColor: getStatusColor(),
}}
title={isOnline ? 'Online' : 'Offline'}
title={
device.connectionStatus === 'online'
? 'Online'
: device.connectionStatus === 'warning'
? 'Connection unstable'
: 'Offline'
}
/>
</div>
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
{device.ipAddress}:{device.port}
<div style={{ fontSize: '12px', marginBottom: '15px' }}>
<a
href={`http://${device.ipAddress}:${device.port}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#3498db', textDecoration: 'none' }}
onMouseOver={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseOut={(e) => e.currentTarget.style.textDecoration = 'none'}
>
{device.ipAddress}:{device.port}
</a>
</div>
{isOnline && (
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
{/* Always show controls for enabled devices, even if temporarily offline */}
{device.enabled && (
<>
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || (isOnline && isOn)}
style={{ flex: 1 }}
>
Turn On
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || (isOnline && !isOn)}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || isOn}
style={{ flex: 1 }}
className="btn btn-small btn-secondary"
onClick={() => setShowSync(!showSync)}
style={{ width: '100%', marginBottom: '15px' }}
disabled={!isOnline}
>
Turn On
{showSync ? 'Hide' : 'Sync to Other Devices'}
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || !isOn}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
{showSync && isOnline && (
<SyncPanel
sourceDevice={device}
allDevices={allDevices.filter(d => d.id !== device.id && d.enabled)}
onSync={async (targetIds) => {
try {
setBusy(true);
await deviceApi.syncState(device.id, targetIds);
setTimeout(onRefresh, 500);
alert('State synced successfully!');
} catch (err) {
alert('Failed to sync state');
} finally {
setBusy(false);
}
}}
/>
)}
</>
)}
{isOnline ? (
{/* Show device info if we have current state OR recent successful ping */}
{(isOnline || isWarning) && device.currentState ? (
<>
{isWarning && (
<div style={{
backgroundColor: '#fff3cd',
color: '#856404',
padding: '8px',
borderRadius: '4px',
marginBottom: '10px',
fontSize: '12px'
}}>
Connection unstable - retrying...
</div>
)}
<div style={{ marginBottom: '10px' }}>
<strong>Status:</strong>{' '}
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
@@ -292,3 +407,101 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
</div>
);
}
interface SyncPanelProps {
sourceDevice: DeviceStatus;
allDevices: DeviceStatus[];
onSync: (targetIds: string[]) => Promise<void>;
}
function SyncPanel({ sourceDevice, allDevices, onSync }: SyncPanelProps) {
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const toggleDevice = (deviceId: string) => {
setSelectedDevices(prev =>
prev.includes(deviceId)
? prev.filter(id => id !== deviceId)
: [...prev, deviceId]
);
};
const selectAll = () => {
setSelectedDevices(allDevices.map(d => d.id));
};
const selectNone = () => {
setSelectedDevices([]);
};
return (
<div style={{
padding: '10px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
marginBottom: '15px'
}}>
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '8px' }}>
Copy current state from "{sourceDevice.name}" to:
</div>
<div style={{ marginBottom: '8px', display: 'flex', gap: '5px' }}>
<button
className="btn btn-small btn-secondary"
onClick={selectAll}
style={{ fontSize: '11px', padding: '3px 8px' }}
>
Select All
</button>
<button
className="btn btn-small btn-secondary"
onClick={selectNone}
style={{ fontSize: '11px', padding: '3px 8px' }}
>
Clear
</button>
</div>
<div style={{ maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
{allDevices.length === 0 ? (
<div style={{ fontSize: '12px', color: '#95a5a6', padding: '5px' }}>
No other devices available
</div>
) : (
allDevices.map(device => (
<label
key={device.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px',
fontSize: '12px',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={() => toggleDevice(device.id)}
/>
<span>{device.name}</span>
<span style={{
marginLeft: 'auto',
fontSize: '10px',
color: device.currentState ? '#2ecc71' : '#e74c3c'
}}>
{device.currentState ? '●' : '○'}
</span>
</label>
))
)}
</div>
<button
className="btn btn-small btn-primary"
onClick={() => onSync(selectedDevices)}
disabled={selectedDevices.length === 0}
style={{ width: '100%' }}
>
Sync to {selectedDevices.length} Device{selectedDevices.length !== 1 ? 's' : ''}
</button>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { deviceApi } from '../api/devices';
import { deviceApi, DiscoveredDevice } from '../api/devices';
import { Device } from '../api/types';
import { formatDateTime } from '../utils/dateTime';
@@ -9,6 +9,8 @@ export function DevicesPage() {
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
const [showDiscovery, setShowDiscovery] = useState(false);
useEffect(() => {
loadDevices();
@@ -73,13 +75,33 @@ export function DevicesPage() {
if (loading) return <div className="loading">Loading devices...</div>;
const filteredDevices = devices.filter(device => {
if (filter === 'enabled') return device.enabled;
if (filter === 'disabled') return !device.enabled;
return true;
});
return (
<div>
<div className="page-header">
<h2>Devices</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
<h2>Devices ({devices.length})</h2>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
style={{ padding: '8px' }}
>
<option value="all">All Devices ({devices.length})</option>
<option value="enabled">Enabled ({devices.filter(d => d.enabled).length})</option>
<option value="disabled">Disabled ({devices.filter(d => !d.enabled).length})</option>
</select>
<button className="btn btn-secondary" onClick={() => setShowDiscovery(true)}>
🔍 Discover Devices
</button>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
@@ -96,7 +118,7 @@ export function DevicesPage() {
</tr>
</thead>
<tbody>
{devices.map((device) => (
{filteredDevices.map((device) => (
<tr key={device.id}>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
@@ -149,6 +171,223 @@ export function DevicesPage() {
}}
/>
)}
{showDiscovery && (
<DiscoveryModal
onClose={() => setShowDiscovery(false)}
onDeviceAdded={() => {
setShowDiscovery(false);
loadDevices();
}}
/>
)}
</div>
);
}
interface DiscoveryModalProps {
onClose: () => void;
onDeviceAdded: () => void;
}
function DiscoveryModal({ onClose, onDeviceAdded }: DiscoveryModalProps) {
const [discoveryMethod, setDiscoveryMethod] = useState<'mdns' | 'scan'>('mdns');
const [discovering, setDiscovering] = useState(false);
const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredDevice[]>([]);
const [baseIp, setBaseIp] = useState('192.168.1');
const [startRange, setStartRange] = useState(1);
const [endRange, setEndRange] = useState(254);
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const handleDiscover = async () => {
try {
setDiscovering(true);
setDiscoveredDevices([]);
setSelectedDevices(new Set());
let result;
if (discoveryMethod === 'mdns') {
result = await deviceApi.discoverMdns(5000);
} else {
result = await deviceApi.scanIpRange(baseIp, startRange, endRange);
}
setDiscoveredDevices(result.devices);
if (result.devices.length === 0) {
alert('No WLED devices found');
}
} catch (err) {
alert('Failed to discover devices');
} finally {
setDiscovering(false);
}
};
const toggleDeviceSelection = (ipAddress: string) => {
const newSelection = new Set(selectedDevices);
if (newSelection.has(ipAddress)) {
newSelection.delete(ipAddress);
} else {
newSelection.add(ipAddress);
}
setSelectedDevices(newSelection);
};
const handleAddSelected = async () => {
const devicesToAdd = discoveredDevices.filter(d => selectedDevices.has(d.ipAddress));
if (devicesToAdd.length === 0) {
alert('Please select at least one device to add');
return;
}
try {
for (const device of devicesToAdd) {
await deviceApi.create({
name: device.name,
ipAddress: device.ipAddress,
port: device.port,
enabled: true,
});
}
alert(`Successfully added ${devicesToAdd.length} device(s)`);
onDeviceAdded();
} catch (err) {
alert('Failed to add devices');
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '700px' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Discover WLED Devices</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div style={{ marginBottom: '20px' }}>
<div className="form-group">
<label>Discovery Method</label>
<select
value={discoveryMethod}
onChange={(e) => setDiscoveryMethod(e.target.value as 'mdns' | 'scan')}
disabled={discovering}
>
<option value="mdns">mDNS (Automatic - Recommended)</option>
<option value="scan">IP Range Scan</option>
</select>
</div>
{discoveryMethod === 'scan' && (
<>
<div className="form-group">
<label>Base IP Address (e.g., 192.168.1)</label>
<input
type="text"
value={baseIp}
onChange={(e) => setBaseIp(e.target.value)}
placeholder="192.168.1"
disabled={discovering}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-group">
<label>Start Range</label>
<input
type="number"
value={startRange}
onChange={(e) => setStartRange(parseInt(e.target.value))}
min="1"
max="254"
disabled={discovering}
/>
</div>
<div className="form-group">
<label>End Range</label>
<input
type="number"
value={endRange}
onChange={(e) => setEndRange(parseInt(e.target.value))}
min="1"
max="254"
disabled={discovering}
/>
</div>
</div>
</>
)}
<button
className="btn btn-primary"
onClick={handleDiscover}
disabled={discovering}
style={{ width: '100%' }}
>
{discovering ? 'Discovering...' : '🔍 Start Discovery'}
</button>
</div>
{discoveredDevices.length > 0 && (
<>
<h4>Found {discoveredDevices.length} device(s)</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', marginBottom: '20px' }}>
<table>
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
checked={selectedDevices.size === discoveredDevices.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedDevices(new Set(discoveredDevices.map(d => d.ipAddress)));
} else {
setSelectedDevices(new Set());
}
}}
/>
</th>
<th>Name</th>
<th>IP Address</th>
<th>Port</th>
</tr>
</thead>
<tbody>
{discoveredDevices.map((device) => (
<tr key={device.ipAddress}>
<td>
<input
type="checkbox"
checked={selectedDevices.has(device.ipAddress)}
onChange={() => toggleDeviceSelection(device.ipAddress)}
/>
</td>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
<td>{device.port}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-actions">
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleAddSelected}
disabled={selectedDevices.size === 0}
>
Add Selected ({selectedDevices.size})
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useState, useEffect } from 'react';
import { quickActionApi, QuickAction, CreateQuickActionInput } from '../api/quickActions';
import { groupApi } from '../api/groups';
import { deviceApi } from '../api/devices';
import { Group, Device } from '../api/types';
export function QuickActionsPage() {
const [actions, setActions] = useState<QuickAction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingAction, setEditingAction] = useState<QuickAction | null>(null);
useEffect(() => {
loadActions();
}, []);
const loadActions = async () => {
try {
setLoading(true);
const data = await quickActionApi.getAll();
setActions(data);
setError(null);
} catch (err) {
setError('Failed to load quick actions');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this quick action?')) return;
try {
await quickActionApi.delete(id);
loadActions();
} catch (err) {
alert('Failed to delete quick action');
}
};
const openCreateModal = () => {
setEditingAction(null);
setShowModal(true);
};
const openEditModal = (action: QuickAction) => {
setEditingAction(action);
setShowModal(true);
};
if (loading) return <div className="loading">Loading quick actions...</div>;
return (
<div>
<div className="page-header">
<h2>Quick Actions ({actions.length})</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Quick Action
</button>
</div>
{error && <div className="error-message">{error}</div>}
{actions.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}></div>
<div style={{ marginBottom: '10px' }}>No quick actions configured</div>
<div style={{ fontSize: '14px', marginBottom: '20px' }}>
Quick actions let you trigger presets, playlists, or control devices with one click from the dashboard
</div>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Your First Quick Action
</button>
</div>
) : (
<table>
<thead>
<tr>
<th>Order</th>
<th>Name</th>
<th>Icon</th>
<th>Type</th>
<th>Target</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.order}</td>
<td>{action.name}</td>
<td style={{ fontSize: '24px' }}>{action.icon || '⚡'}</td>
<td>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
backgroundColor: getActionColor(action.actionType),
color: 'white'
}}>
{action.actionType}
</span>
</td>
<td>
{action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All Devices'}
</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(action)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(action.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{showModal && (
<QuickActionModal
action={editingAction}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadActions();
}}
/>
)}
</div>
);
}
interface QuickActionModalProps {
action: QuickAction | null;
onClose: () => void;
onSave: () => void;
}
function QuickActionModal({ action, onClose, onSave }: QuickActionModalProps) {
const [name, setName] = useState(action?.name || '');
const [icon, setIcon] = useState(action?.icon || '');
const [actionType, setActionType] = useState<'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'>(
action?.actionType || 'TURN_ON'
);
const [targetType, setTargetType] = useState<'group' | 'device' | 'all'>(
action?.groupId ? 'group' : action?.deviceId ? 'device' : 'all'
);
const [groupId, setGroupId] = useState(action?.groupId || '');
const [deviceId, setDeviceId] = useState(action?.deviceId || '');
const [presetId, setPresetId] = useState('1');
const [brightness, setBrightness] = useState('128');
const [order, setOrder] = useState(action?.order?.toString() || '0');
const [groups, setGroups] = useState<Group[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadGroupsAndDevices();
if (action?.actionPayload) {
const payload = action.actionPayload as any;
if (payload.presetId) setPresetId(payload.presetId.toString());
if (payload.brightness) setBrightness(payload.brightness.toString());
}
}, []);
const loadGroupsAndDevices = async () => {
const [groupsData, devicesData] = await Promise.all([
groupApi.getAll(),
deviceApi.getAll(),
]);
setGroups(groupsData);
setDevices(devicesData);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let actionPayload: any = {};
if (actionType === 'PRESET') {
actionPayload = { presetId: parseInt(presetId) };
} else if (actionType === 'BRIGHTNESS') {
actionPayload = { brightness: parseInt(brightness) };
}
const data: CreateQuickActionInput = {
name,
icon: icon || undefined,
actionType,
groupId: targetType === 'group' ? groupId : undefined,
deviceId: targetType === 'device' ? deviceId : undefined,
actionPayload,
order: parseInt(order),
};
try {
setSubmitting(true);
if (action) {
await quickActionApi.update(action.id, data);
} else {
await quickActionApi.create(data);
}
onSave();
} catch (err) {
alert('Failed to save quick action');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '600px' }}>
<div className="modal-header">
<h3>{action ? 'Edit Quick Action' : 'Create Quick Action'}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Bedtime Lights"
required
/>
</div>
<div className="form-group">
<label>Icon (Emoji)</label>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="e.g., 🌙 💡 🎨"
maxLength={2}
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Leave empty for default icon
</div>
</div>
<div className="form-group">
<label>Action Type *</label>
<select value={actionType} onChange={(e) => setActionType(e.target.value as any)} required>
<option value="TURN_ON">Turn On</option>
<option value="TURN_OFF">Turn Off</option>
<option value="PRESET">Apply Preset</option>
<option value="BRIGHTNESS">Set Brightness</option>
</select>
</div>
{actionType === 'PRESET' && (
<div className="form-group">
<label>Preset ID *</label>
<input
type="number"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
min="1"
required
/>
</div>
)}
{actionType === 'BRIGHTNESS' && (
<div className="form-group">
<label>Brightness (0-255) *</label>
<input
type="number"
value={brightness}
onChange={(e) => setBrightness(e.target.value)}
min="0"
max="255"
required
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
{Math.round((parseInt(brightness) / 255) * 100)}%
</div>
</div>
)}
<div className="form-group">
<label>Target *</label>
<select value={targetType} onChange={(e) => setTargetType(e.target.value as any)} required>
<option value="all">All Devices</option>
<option value="group">Specific Group</option>
<option value="device">Specific Device</option>
</select>
</div>
{targetType === 'group' && (
<div className="form-group">
<label>Group *</label>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)} required>
<option value="">Select a group</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
)}
{targetType === 'device' && (
<div className="form-group">
<label>Device *</label>
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)} required>
<option value="">Select a device</option>
{devices.map(device => (
<option key={device.id} value={device.id}>{device.name}</option>
))}
</select>
</div>
)}
<div className="form-group">
<label>Display Order</label>
<input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
min="0"
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Lower numbers appear first
</div>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
function getActionColor(actionType: string): string {
switch (actionType) {
case 'TURN_ON': return '#2ecc71';
case 'TURN_OFF': return '#e74c3c';
case 'PRESET': return '#3498db';
case 'PLAYLIST': return '#9b59b6';
case 'BRIGHTNESS': return '#f39c12';
default: return '#95a5a6';
}
}

View File

@@ -3,7 +3,6 @@ import { scheduleApi } from '../api/schedules';
import { groupApi } from '../api/groups';
import { Schedule, Group, PresetActionPayload, PlaylistActionPayload } from '../api/types';
import { TimePicker, PresetSelector } from '../components/ScheduleComponents';
import { timePickerToCron } from '../utils/timePicker';
export function SchedulesPage() {
const [schedules, setSchedules] = useState<Schedule[]>([]);

View File

@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {