Initial commit

This commit is contained in:
Oli Passey
2025-12-10 18:07:21 +00:00
commit 1fb43156e8
58 changed files with 15656 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
*.db-journal
.env
.DS_Store

338
README.md Normal file
View File

@@ -0,0 +1,338 @@
# WLED Central Controller
A web-based central controller for multiple WLED instances, allowing you to discover, manage, group, and schedule actions across your WLED devices.
## Features
- **Device Management**: Add, edit, and monitor multiple WLED devices
- **Grouping**: Organize devices into logical groups (e.g., "Living Room", "Garden")
- **Group Actions**: Apply presets and playlists to individual devices or groups
- **Scheduling**: Automate preset/playlist changes with cron-based schedules
- **UK Timezone Support**: All times displayed in Europe/London timezone (dd/MM/yyyy HH:mm format)
- **RESTful API**: Complete backend API for integration with other systems
- **Modern UI**: Clean, responsive web interface built with React
## Tech Stack
### Backend
- Node.js v20+ with TypeScript
- Express.js for HTTP API
- SQLite via Prisma ORM
- node-cron for scheduling
- Zod for validation
### Frontend
- React 18 with TypeScript
- Vite build tool
- React Router for navigation
- Luxon for date/time handling
## Quick Start with Docker
The easiest way to run the WLED Controller is using Docker Compose:
```bash
# Clone the repository
cd wled-controller
# Start the services
docker-compose up -d
# Access the web UI
# Open http://localhost in your browser
```
The backend API will be available at `http://localhost:3000/api`.
## Manual Setup
### Backend Setup
```bash
cd backend
# Copy environment file
cp .env.example .env
# Install dependencies
npm install
# Generate Prisma Client
npm run prisma:generate
# Run database migrations
npm run prisma:migrate
# Development mode
npm run dev
# Production build
npm run build
npm start
```
### Frontend Setup
```bash
cd frontend
# Copy environment file
cp .env.example .env
# Install dependencies
npm install
# Development mode
npm run dev
# Production build
npm run build
npm run preview
```
## Configuration
### Backend Environment Variables
Create a `.env` file in the `backend` directory:
```env
PORT=3000
DATABASE_URL="file:./dev.db"
LOG_LEVEL=info
```
### Frontend Environment Variables
Create a `.env` file in the `frontend` directory:
```env
VITE_API_BASE_URL=http://localhost:3000/api
```
## Usage Guide
### 1. Add Devices
1. Navigate to the **Devices** page
2. Click **Add Device**
3. Enter device details:
- Name (e.g., "Kitchen Strip")
- IP Address (e.g., "192.168.1.50")
- Port (default: 80)
- Enabled checkbox
4. Use **Ping** to verify connectivity
### 2. Create Groups
1. Navigate to the **Groups** page
2. Click **Create Group**
3. Enter group name
4. Select devices to include
5. Save
### 3. Control Groups
1. Click **Control** on any group
2. Apply a preset:
- Enter preset ID
- Click **Apply Preset**
3. Or apply a playlist:
- Enter comma-separated preset IDs
- Configure duration, transition, repeat options
- Click **Apply Playlist**
### 4. Schedule Actions
1. Navigate to the **Schedules** page
2. Click **Create Schedule**
3. Configure:
- Name
- Group
- Type (Preset or Playlist)
- Cron expression (e.g., `30 18 * * *` for 6:30 PM daily)
- Timezone (default: Europe/London)
- Action payload (preset ID or playlist config)
4. Enable/disable schedules with the toggle
## API Documentation
### Devices
- `GET /api/devices` - List all devices
- `POST /api/devices` - Create device
- `GET /api/devices/:id` - Get device
- `PUT /api/devices/:id` - Update device
- `DELETE /api/devices/:id` - Delete device
- `POST /api/devices/:id/ping` - Ping device
### Groups
- `GET /api/groups` - List all groups
- `POST /api/groups` - Create group
- `GET /api/groups/:id` - Get group
- `PUT /api/groups/:id` - Update group
- `DELETE /api/groups/:id` - Delete group
- `POST /api/groups/:id/preset` - Apply preset to group
- `POST /api/groups/:id/playlist` - Apply playlist to group
### Schedules
- `GET /api/schedules` - List all schedules
- `POST /api/schedules` - Create schedule
- `GET /api/schedules/:id` - Get schedule
- `PUT /api/schedules/:id` - Update schedule
- `DELETE /api/schedules/:id` - Delete schedule
## WLED Integration
The controller communicates with WLED devices over HTTP using the JSON API:
- `/json/info` - Device information
- `/json/state` - Device state and control
### Preset Application
```json
POST /json/state
{
"ps": 5
}
```
### Playlist Application
```json
POST /json/state
{
"playlist": {
"ps": [1, 2, 3],
"dur": [30, 30, 30],
"transition": 0,
"repeat": 0,
"end": 1
}
}
```
## Cron Expression Format
Schedules use standard cron syntax:
```
* * * * *
┬ ┬ ┬ ┬ ┬
│ │ │ │ │
│ │ │ │ └─ day of week (0-7, 0 and 7 are Sunday)
│ │ │ └────── month (1-12)
│ │ └─────────── day of month (1-31)
│ └──────────────── hour (0-23)
└───────────────────── minute (0-59)
```
### Examples
- `30 18 * * *` - Every day at 6:30 PM
- `0 9 * * 1-5` - Weekdays at 9:00 AM
- `*/15 * * * *` - Every 15 minutes
- `0 22 * * 0` - Sundays at 10:00 PM
## Development
### Backend Tests
```bash
cd backend
npm test
```
### Code Formatting
```bash
# Backend
cd backend
npm run format
# Frontend
cd frontend
npm run format
```
### Linting
```bash
# Backend
cd backend
npm run lint
# Frontend
cd frontend
npm run lint
```
## Deployment
### Behind a Reverse Proxy
The application is designed to work behind HTTPS reverse proxies. Configure your reverse proxy to:
1. Serve the frontend on your domain
2. Proxy `/api/*` requests to the backend
3. Set appropriate CORS headers if needed
Example Nginx configuration:
```nginx
server {
listen 443 ssl;
server_name wled.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:80;
}
location /api {
proxy_pass http://localhost:3000;
}
}
```
## Troubleshooting
### Device Not Reachable
- Verify the device IP address is correct
- Ensure the device is on the same network
- Check firewall rules
- Use the **Ping** button to test connectivity
### Schedule Not Triggering
- Verify the cron expression is correct
- Check the schedule is enabled
- Ensure the group has enabled devices
- Review backend logs for errors
### Database Issues
```bash
cd backend
# Reset database
rm dev.db
npm run prisma:migrate
# View database
npm run prisma:studio
```
## License
MIT
## Contributing
Contributions welcome! Please open an issue or pull request.

3
backend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3000
DATABASE_URL="file:./dev.db"
LOG_LEVEL=info

15
backend/.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off"
}
}

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
*.db-journal
.env
.DS_Store

7
backend/.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Generate Prisma Client
RUN npx prisma generate
# Copy source
COPY . .
# Build
RUN npm run build
# Expose port
EXPOSE 3000
# Start
CMD ["npm", "start"]

11
backend/jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
],
};

7389
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
backend/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "wled-controller-backend",
"version": "1.0.0",
"description": "WLED Central Controller Backend",
"main": "dist/server.js",
"scripts": {
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"test": "jest",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
},
"keywords": [
"wled",
"controller",
"home-automation"
],
"author": "",
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.8.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"luxon": "^3.4.4",
"node-cron": "^3.0.3",
"p-limit": "^5.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/luxon": "^3.3.7",
"@types/node": "^20.10.6",
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"nodemon": "^3.1.11",
"prettier": "^3.1.1",
"prisma": "^5.8.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"ipAddress" TEXT NOT NULL,
"port" INTEGER NOT NULL DEFAULT 80,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastSeenAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "GroupDevice" (
"groupId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
PRIMARY KEY ("groupId", "deviceId"),
CONSTRAINT "GroupDevice_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "GroupDevice_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Schedule" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"cronExpression" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'Europe/London',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"actionPayload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Schedule_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Schedule" ADD COLUMN "endCronExpression" TEXT;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -0,0 +1,57 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Device {
id String @id @default(uuid())
name String
ipAddress String
port Int @default(80)
enabled Boolean @default(true)
lastSeenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
groups GroupDevice[]
}
model Group {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices GroupDevice[]
schedules Schedule[]
}
model GroupDevice {
groupId String
deviceId String
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@id([groupId, deviceId])
}
model Schedule {
id String @id @default(uuid())
name String
groupId String
type String // 'PRESET' | 'PLAYLIST'
cronExpression String
endCronExpression String? // Optional: turn off lights at this time
timezone String @default("Europe/London")
enabled Boolean @default(true)
actionPayload String // JSON string
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id])
}

36
backend/src/app.ts Normal file
View File

@@ -0,0 +1,36 @@
import express from 'express';
import cors from 'cors';
import devicesRouter from './routes/devices';
import groupsRouter from './routes/groups';
import schedulesRouter from './routes/schedules';
export function createApp() {
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Request logging
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Routes
app.use('/api/devices', devicesRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/schedules', schedulesRouter);
// 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' });
});
return app;
}

View File

@@ -0,0 +1,5 @@
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
databaseUrl: process.env.DATABASE_URL || 'file:./dev.db',
logLevel: process.env.LOG_LEVEL || 'info',
};

View File

@@ -0,0 +1,205 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { deviceService } from '../services/deviceService';
const router = Router();
const createDeviceSchema = z.object({
name: z.string().min(1, 'Name is required'),
ipAddress: z.string().min(1, 'IP address is required'),
port: z.number().int().positive().optional(),
enabled: z.boolean().optional(),
});
const updateDeviceSchema = z.object({
name: z.string().min(1).optional(),
ipAddress: z.string().min(1).optional(),
port: z.number().int().positive().optional(),
enabled: z.boolean().optional(),
});
// GET /api/devices
router.get('/', async (req: Request, res: Response) => {
try {
const devices = await deviceService.getAllDevices();
res.json(devices);
} catch (error) {
console.error('Error fetching devices:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch devices' });
}
});
// GET /api/devices/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
const device = await deviceService.getDeviceById(req.params.id);
if (!device) {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
res.json(device);
} catch (error) {
console.error('Error fetching device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch device' });
}
});
// POST /api/devices
router.post('/', async (req: Request, res: Response) => {
try {
const body = createDeviceSchema.parse(req.body);
const device = await deviceService.createDevice(body);
res.status(201).json(device);
} 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 device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to create device' });
}
});
// PUT /api/devices/:id
router.put('/:id', async (req: Request, res: Response) => {
try {
const body = updateDeviceSchema.parse(req.body);
const device = await deviceService.updateDevice(req.params.id, body);
res.json(device);
} 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: 'Device not found' });
}
console.error('Error updating device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to update device' });
}
});
// DELETE /api/devices/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
await deviceService.deleteDevice(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: 'Device not found' });
}
console.error('Error deleting device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to delete device' });
}
});
// POST /api/devices/:id/ping
router.post('/:id/ping', async (req: Request, res: Response) => {
try {
const result = await deviceService.pingDevice(req.params.id);
if (result.status === 'error') {
return res.status(502).json(result);
}
res.json(result);
} catch (error) {
if (error instanceof Error && error.message === 'Device not found') {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
console.error('Error pinging device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to ping device' });
}
});
// GET /api/devices/:id/details
router.get('/:id/details', async (req: Request, res: Response) => {
try {
const details = await deviceService.getDeviceDetails(req.params.id);
res.json(details);
} catch (error) {
if (error instanceof Error && error.message === 'Device not found') {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
console.error('Error fetching device details:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch device details' });
}
});
// GET /api/devices/:id/presets
router.get('/:id/presets', async (req: Request, res: Response) => {
try {
const presets = await deviceService.getDevicePresets(req.params.id);
res.json(presets);
} catch (error) {
if (error instanceof Error && error.message === 'Device not found') {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
console.error('Error fetching device presets:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' });
}
});
// GET /api/devices/status/all
router.get('/status/all', async (req: Request, res: Response) => {
try {
const devices = await deviceService.getAllDevicesWithStatus();
res.json(devices);
} catch (error) {
console.error('Error fetching devices with status:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch devices status' });
}
});
// POST /api/devices/:id/turn-on
router.post('/:id/turn-on', async (req: Request, res: Response) => {
try {
await deviceService.turnOnDevice(req.params.id);
res.json({ status: 'ok', message: 'Device turned on' });
} catch (error) {
if (error instanceof Error && error.message === 'Device not found') {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
console.error('Error turning on device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to turn on device' });
}
});
// POST /api/devices/:id/turn-off
router.post('/:id/turn-off', async (req: Request, res: Response) => {
try {
await deviceService.turnOffDevice(req.params.id);
res.json({ status: 'ok', message: 'Device turned off' });
} catch (error) {
if (error instanceof Error && error.message === 'Device not found') {
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
}
console.error('Error turning off device:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to turn off device' });
}
});
// POST /api/devices/all/turn-on
router.post('/all/turn-on', async (req: Request, res: Response) => {
try {
const result = await deviceService.turnOnAllDevices();
res.json(result);
} catch (error) {
console.error('Error turning on all devices:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to turn on all devices' });
}
});
// POST /api/devices/all/turn-off
router.post('/all/turn-off', async (req: Request, res: Response) => {
try {
const result = await deviceService.turnOffAllDevices();
res.json(result);
} catch (error) {
console.error('Error turning off all devices:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to turn off all devices' });
}
});
export default router;

View File

@@ -0,0 +1,173 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { groupService } from '../services/groupService';
import { WledPlaylist } from '../wled/types';
const router = Router();
const createGroupSchema = z.object({
name: z.string().min(1, 'Name is required'),
deviceIds: z.array(z.string()).min(1, 'At least one device is required'),
});
const updateGroupSchema = z.object({
name: z.string().min(1).optional(),
deviceIds: z.array(z.string()).optional(),
});
const applyPresetSchema = z.object({
presetId: z.number().int().positive('Preset ID must be a positive number'),
});
const applyPlaylistSchema = z.object({
ps: z.array(z.number().int()).min(1, 'At least one preset is required'),
dur: z.union([z.number().int(), z.array(z.number().int())]).optional(),
transition: z.union([z.number().int(), z.array(z.number().int())]).optional(),
repeat: z.number().int().optional(),
end: z.number().int().optional(),
});
// GET /api/groups
router.get('/', async (req: Request, res: Response) => {
try {
const groups = await groupService.getAllGroups();
res.json(groups);
} catch (error) {
console.error('Error fetching groups:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch groups' });
}
});
// GET /api/groups/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
const group = await groupService.getGroupById(req.params.id);
if (!group) {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
res.json(group);
} catch (error) {
console.error('Error fetching group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch group' });
}
});
// POST /api/groups
router.post('/', async (req: Request, res: Response) => {
try {
const body = createGroupSchema.parse(req.body);
const group = await groupService.createGroup(body);
res.status(201).json(group);
} 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(400).json({ error: 'ValidationError', message: error.message });
}
console.error('Error creating group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to create group' });
}
});
// PUT /api/groups/:id
router.put('/:id', async (req: Request, res: Response) => {
try {
const body = updateGroupSchema.parse(req.body);
const group = await groupService.updateGroup(req.params.id, body);
res.json(group);
} 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(400).json({ error: 'ValidationError', message: error.message });
}
if (error instanceof Error && error.message.includes('Record to update not found')) {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error updating group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to update group' });
}
});
// DELETE /api/groups/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
await groupService.deleteGroup(req.params.id);
res.status(204).send();
} catch (error) {
if (error instanceof Error && error.message.includes('active schedules')) {
return res.status(409).json({ error: 'Conflict', message: error.message });
}
if (error instanceof Error && error.message.includes('Record to delete does not exist')) {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error deleting group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to delete group' });
}
});
// POST /api/groups/:id/preset
router.post('/:id/preset', async (req: Request, res: Response) => {
try {
const body = applyPresetSchema.parse(req.body);
const result = await groupService.applyPresetToGroup(req.params.id, body.presetId);
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 === 'Group not found') {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error applying preset to group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to apply preset' });
}
});
// POST /api/groups/:id/playlist
router.post('/:id/playlist', async (req: Request, res: Response) => {
try {
const body = applyPlaylistSchema.parse(req.body) as WledPlaylist;
const result = await groupService.applyPlaylistToGroup(req.params.id, body);
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 === 'Group not found') {
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
}
console.error('Error applying playlist to group:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to apply playlist' });
}
});
// 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,176 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { scheduleService, ScheduleType } from '../services/scheduleService';
import { schedulerService } from '../services/schedulerService';
const router = Router();
const presetPayloadSchema = z.object({
presetId: z.number().int().positive('Preset ID must be a positive number'),
});
const playlistPayloadSchema = z.object({
ps: z.array(z.number().int()).min(1, 'At least one preset is required'),
dur: z.union([z.number().int(), z.array(z.number().int())]).optional(),
transition: z.union([z.number().int(), z.array(z.number().int())]).optional(),
repeat: z.number().int().optional(),
end: z.number().int().optional(),
});
const createScheduleSchema = z.object({
name: z.string().min(1, 'Name is required'),
groupId: z.string().min(1, 'Group ID is required'),
type: z.enum(['PRESET', 'PLAYLIST']),
cronExpression: z.string().min(1, 'Cron expression is required'),
endCronExpression: z.string().optional(),
timezone: z.string().optional(),
enabled: z.boolean().optional(),
actionPayload: z.union([presetPayloadSchema, playlistPayloadSchema]),
});
const updateScheduleSchema = z.object({
name: z.string().min(1).optional(),
groupId: z.string().min(1).optional(),
type: z.enum(['PRESET', 'PLAYLIST']).optional(),
cronExpression: z.string().min(1).optional(),
endCronExpression: z.string().optional(),
timezone: z.string().optional(),
enabled: z.boolean().optional(),
actionPayload: z.union([presetPayloadSchema, playlistPayloadSchema]).optional(),
});
// GET /api/schedules
router.get('/', async (req: Request, res: Response) => {
try {
const schedules = await scheduleService.getAllSchedules();
// Parse actionPayload for each schedule
const schedulesWithParsedPayload = schedules.map((s) => ({
...s,
actionPayload: scheduleService.parseActionPayload(s),
}));
res.json(schedulesWithParsedPayload);
} catch (error) {
console.error('Error fetching schedules:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch schedules' });
}
});
// GET /api/schedules/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
const schedule = await scheduleService.getScheduleById(req.params.id);
if (!schedule) {
return res.status(404).json({ error: 'NotFound', message: 'Schedule not found' });
}
res.json({
...schedule,
actionPayload: scheduleService.parseActionPayload(schedule),
});
} catch (error) {
console.error('Error fetching schedule:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch schedule' });
}
});
// POST /api/schedules
router.post('/', async (req: Request, res: Response) => {
try {
const body = createScheduleSchema.parse(req.body);
// Validate actionPayload based on type
if (body.type === 'PRESET') {
presetPayloadSchema.parse(body.actionPayload);
} else if (body.type === 'PLAYLIST') {
playlistPayloadSchema.parse(body.actionPayload);
}
const schedule = await scheduleService.createSchedule({
...body,
type: body.type as ScheduleType,
});
// Register with scheduler immediately
await schedulerService.registerSchedule(schedule.id);
res.status(201).json({
...schedule,
actionPayload: scheduleService.parseActionPayload(schedule),
});
} 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(400).json({ error: 'ValidationError', message: error.message });
}
console.error('Error creating schedule:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to create schedule' });
}
});
// PUT /api/schedules/:id
router.put('/:id', async (req: Request, res: Response) => {
try {
const body = updateScheduleSchema.parse(req.body);
// Validate actionPayload based on type if both are provided
if (body.type && body.actionPayload) {
if (body.type === 'PRESET') {
presetPayloadSchema.parse(body.actionPayload);
} else if (body.type === 'PLAYLIST') {
playlistPayloadSchema.parse(body.actionPayload);
}
}
const schedule = await scheduleService.updateSchedule(req.params.id, {
...body,
type: body.type as ScheduleType | undefined,
});
// Update scheduler
await schedulerService.registerSchedule(schedule.id);
res.json({
...schedule,
actionPayload: scheduleService.parseActionPayload(schedule),
});
} 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(400).json({ error: 'ValidationError', message: error.message });
}
if (error instanceof Error && error.message.includes('Record to update not found')) {
return res.status(404).json({ error: 'NotFound', message: 'Schedule not found' });
}
console.error('Error updating schedule:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to update schedule' });
}
});
// DELETE /api/schedules/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
await scheduleService.deleteSchedule(req.params.id);
// Unregister from scheduler
schedulerService.unregisterSchedule(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: 'Schedule not found' });
}
console.error('Error deleting schedule:', error);
res.status(500).json({ error: 'InternalError', message: 'Failed to delete schedule' });
}
});
export default router;

54
backend/src/server.ts Normal file
View File

@@ -0,0 +1,54 @@
import { createApp } from './app';
import { config } from './config';
import { schedulerService } from './services/schedulerService';
import { prisma } from './utils/prisma';
async function main() {
try {
console.log('Starting WLED Controller Backend...');
// Initialize scheduler
await schedulerService.initialize();
// Create Express app
const app = createApp();
// Start server
const server = app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
console.log(`Health check: http://localhost:${config.port}/health`);
});
// Graceful shutdown
const shutdown = async () => {
console.log('\nShutting down gracefully...');
// Stop scheduler
await schedulerService.shutdown();
// Close database connection
await prisma.$disconnect();
// Close server
server.close(() => {
console.log('Server closed');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} catch (error) {
console.error('Failed to start server:', error);
await prisma.$disconnect();
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,221 @@
import { Device } from '@prisma/client';
import { prisma } from '../utils/prisma';
import { WledClient } from '../wled/client';
import { DateTime } from 'luxon';
export interface CreateDeviceInput {
name: string;
ipAddress: string;
port?: number;
enabled?: boolean;
}
export interface UpdateDeviceInput {
name?: string;
ipAddress?: string;
port?: number;
enabled?: boolean;
}
export class DeviceService {
async getAllDevices(): Promise<Device[]> {
return prisma.device.findMany({
orderBy: { name: 'asc' },
});
}
async getDeviceById(id: string): Promise<Device | null> {
return prisma.device.findUnique({
where: { id },
});
}
async createDevice(input: CreateDeviceInput): Promise<Device> {
return prisma.device.create({
data: {
name: input.name,
ipAddress: input.ipAddress,
port: input.port ?? 80,
enabled: input.enabled ?? true,
},
});
}
async updateDevice(id: string, input: UpdateDeviceInput): Promise<Device> {
return prisma.device.update({
where: { id },
data: input,
});
}
async deleteDevice(id: string): Promise<void> {
await prisma.device.delete({
where: { id },
});
}
async pingDevice(id: string): Promise<{ status: 'ok' | 'error'; info?: unknown; error?: string }> {
const device = await this.getDeviceById(id);
if (!device) {
throw new Error('Device not found');
}
const client = new WledClient(device.ipAddress, device.port);
try {
const info = await client.getInfo();
// Update lastSeenAt
await prisma.device.update({
where: { id },
data: { lastSeenAt: DateTime.now().toJSDate() },
});
return { status: 'ok', info };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { status: 'error', error: errorMessage };
}
}
async getDeviceDetails(id: string): Promise<{
device: Device;
info: unknown;
state: unknown;
presets: unknown;
effects: string[];
palettes: string[];
}> {
const device = await this.getDeviceById(id);
if (!device) {
throw new Error('Device not found');
}
const client = new WledClient(device.ipAddress, device.port);
const [info, state, presets, effects, palettes] = await Promise.all([
client.getInfo().catch(() => null),
client.getState().catch(() => null),
client.getPresets().catch(() => ({})),
client.getEffects().catch(() => []),
client.getPalettes().catch(() => []),
]);
// Update lastSeenAt if we got a response
if (info || state) {
await this.updateLastSeen(id);
}
return {
device,
info,
state,
presets,
effects,
palettes,
};
}
async getDevicePresets(id: string): Promise<unknown> {
const device = await this.getDeviceById(id);
if (!device) {
throw new Error('Device not found');
}
const client = new WledClient(device.ipAddress, device.port);
const presets = await client.getPresets();
await this.updateLastSeen(id);
return presets;
}
async getAllDevicesWithStatus(): Promise<Array<Device & { currentState?: unknown; info?: unknown }>> {
const devices = await this.getAllDevices();
const devicesWithStatus = await Promise.all(
devices.map(async (device) => {
if (!device.enabled) {
return device;
}
try {
const client = new WledClient(device.ipAddress, device.port);
const [state, info] = await Promise.allSettled([
client.getState(),
client.getInfo(),
]);
return {
...device,
currentState: state.status === 'fulfilled' ? state.value : undefined,
info: info.status === 'fulfilled' ? info.value : undefined,
};
} catch {
return device;
}
})
);
return devicesWithStatus;
}
async turnOnDevice(id: string): Promise<void> {
const device = await this.getDeviceById(id);
if (!device) {
throw new Error('Device not found');
}
const client = new WledClient(device.ipAddress, device.port);
await client.turnOn();
await this.updateLastSeen(id);
}
async turnOffDevice(id: string): Promise<void> {
const device = await this.getDeviceById(id);
if (!device) {
throw new Error('Device not found');
}
const client = new WledClient(device.ipAddress, device.port);
await client.turnOff();
await this.updateLastSeen(id);
}
async turnOnAllDevices(): Promise<{ success: number; failed: number }> {
const devices = await this.getAllDevices();
const enabledDevices = devices.filter(d => d.enabled);
const results = await Promise.allSettled(
enabledDevices.map(device => this.turnOnDevice(device.id))
);
const success = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
return { success, failed };
}
async turnOffAllDevices(): Promise<{ success: number; failed: number }> {
const devices = await this.getAllDevices();
const enabledDevices = devices.filter(d => d.enabled);
const results = await Promise.allSettled(
enabledDevices.map(device => this.turnOffDevice(device.id))
);
const success = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
return { success, failed };
}
async updateLastSeen(id: string): Promise<void> {
await prisma.device.update({
where: { id },
data: { lastSeenAt: DateTime.now().toJSDate() },
});
}
}
export const deviceService = new DeviceService();

View File

@@ -0,0 +1,298 @@
import { Group, Device } from '@prisma/client';
import { prisma } from '../utils/prisma';
import { WledClient } from '../wled/client';
import { WledPlaylist } from '../wled/types';
import pLimit from 'p-limit';
export interface GroupWithDevices extends Group {
devices: Device[];
}
export interface CreateGroupInput {
name: string;
deviceIds: string[];
}
export interface UpdateGroupInput {
name?: string;
deviceIds?: string[];
}
export interface GroupActionResult {
status: 'ok';
groupId: string;
results: {
success: string[];
failed: Array<{ deviceId: string; error: string }>;
};
}
export class GroupService {
async getAllGroups(): Promise<GroupWithDevices[]> {
const groups = await prisma.group.findMany({
include: {
devices: {
include: {
device: true,
},
},
},
orderBy: { name: 'asc' },
});
return groups.map((group) => ({
...group,
devices: group.devices.map((gd) => gd.device),
}));
}
async getGroupById(id: string): Promise<GroupWithDevices | null> {
const group = await prisma.group.findUnique({
where: { id },
include: {
devices: {
include: {
device: true,
},
},
},
});
if (!group) return null;
return {
...group,
devices: group.devices.map((gd) => gd.device),
};
}
async createGroup(input: CreateGroupInput): Promise<GroupWithDevices> {
// Verify all devices exist
const devices = await prisma.device.findMany({
where: { id: { in: input.deviceIds } },
});
if (devices.length !== input.deviceIds.length) {
throw new Error('One or more device IDs not found');
}
const group = await prisma.group.create({
data: {
name: input.name,
devices: {
create: input.deviceIds.map((deviceId) => ({ deviceId })),
},
},
include: {
devices: {
include: {
device: true,
},
},
},
});
return {
...group,
devices: group.devices.map((gd) => gd.device),
};
}
async updateGroup(id: string, input: UpdateGroupInput): Promise<GroupWithDevices> {
// If deviceIds provided, verify they exist
if (input.deviceIds) {
const devices = await prisma.device.findMany({
where: { id: { in: input.deviceIds } },
});
if (devices.length !== input.deviceIds.length) {
throw new Error('One or more device IDs not found');
}
// Replace membership
await prisma.groupDevice.deleteMany({
where: { groupId: id },
});
await prisma.groupDevice.createMany({
data: input.deviceIds.map((deviceId) => ({ groupId: id, deviceId })),
});
}
const group = await prisma.group.update({
where: { id },
data: {
name: input.name,
},
include: {
devices: {
include: {
device: true,
},
},
},
});
return {
...group,
devices: group.devices.map((gd) => gd.device),
};
}
async deleteGroup(id: string): Promise<void> {
// Check for active schedules
const schedules = await prisma.schedule.findMany({
where: { groupId: id },
});
if (schedules.length > 0) {
throw new Error('Cannot delete group with active schedules');
}
await prisma.group.delete({
where: { id },
});
}
async applyPresetToGroup(
groupId: string,
presetId: number
): 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); // Limit concurrency to 10
const results = await Promise.allSettled(
enabledDevices.map((device) =>
limit(async () => {
const client = new WledClient(device.ipAddress, device.port);
await client.applyPreset(presetId);
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 applyPlaylistToGroup(
groupId: string,
playlist: WledPlaylist
): 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); // Limit concurrency to 10
const results = await Promise.allSettled(
enabledDevices.map((device) =>
limit(async () => {
const client = new WledClient(device.ipAddress, device.port);
await client.applyPlaylist(playlist);
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 turnOffGroup(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.turnOff();
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) {
return {};
}
const client = new WledClient(firstEnabledDevice.ipAddress, firstEnabledDevice.port);
try {
return await client.getPresets();
} catch {
return {};
}
}
}
export const groupService = new GroupService();

View File

@@ -0,0 +1,153 @@
import { Schedule } from '@prisma/client';
import { prisma } from '../utils/prisma';
import { WledPlaylist } from '../wled/types';
export type ScheduleType = 'PRESET' | 'PLAYLIST';
export interface PresetActionPayload {
presetId: number;
}
export interface PlaylistActionPayload extends WledPlaylist {}
export type ActionPayload = PresetActionPayload | PlaylistActionPayload;
export interface CreateScheduleInput {
name: string;
groupId: string;
type: ScheduleType;
cronExpression: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload: ActionPayload;
}
export interface UpdateScheduleInput {
name?: string;
groupId?: string;
type?: ScheduleType;
cronExpression?: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload?: ActionPayload;
}
export interface ScheduleWithGroup extends Omit<Schedule, 'group'> {
group: {
id: string;
name: string;
};
}
export class ScheduleService {
async getAllSchedules(): Promise<ScheduleWithGroup[]> {
return prisma.schedule.findMany({
include: {
group: {
select: {
id: true,
name: true,
},
},
},
orderBy: { name: 'asc' },
});
}
async getScheduleById(id: string): Promise<ScheduleWithGroup | null> {
return prisma.schedule.findUnique({
where: { id },
include: {
group: {
select: {
id: true,
name: true,
},
},
},
});
}
async createSchedule(input: CreateScheduleInput): Promise<ScheduleWithGroup> {
// Verify group exists
const group = await prisma.group.findUnique({
where: { id: input.groupId },
});
if (!group) {
throw new Error('Group not found');
}
return prisma.schedule.create({
data: {
name: input.name,
groupId: input.groupId,
type: input.type,
cronExpression: input.cronExpression,
endCronExpression: input.endCronExpression,
timezone: input.timezone ?? 'Europe/London',
enabled: input.enabled ?? true,
actionPayload: JSON.stringify(input.actionPayload),
},
include: {
group: {
select: {
id: true,
name: true,
},
},
},
});
}
async updateSchedule(id: string, input: UpdateScheduleInput): Promise<ScheduleWithGroup> {
// If groupId changed, verify it exists
if (input.groupId) {
const group = await prisma.group.findUnique({
where: { id: input.groupId },
});
if (!group) {
throw new Error('Group not found');
}
}
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.groupId !== undefined) updateData.groupId = input.groupId;
if (input.type !== undefined) updateData.type = input.type;
if (input.cronExpression !== undefined) updateData.cronExpression = input.cronExpression;
if (input.endCronExpression !== undefined) updateData.endCronExpression = input.endCronExpression;
if (input.timezone !== undefined) updateData.timezone = input.timezone;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.actionPayload !== undefined)
updateData.actionPayload = JSON.stringify(input.actionPayload);
return prisma.schedule.update({
where: { id },
data: updateData,
include: {
group: {
select: {
id: true,
name: true,
},
},
},
});
}
async deleteSchedule(id: string): Promise<void> {
await prisma.schedule.delete({
where: { id },
});
}
parseActionPayload(schedule: Schedule): ActionPayload {
return JSON.parse(schedule.actionPayload);
}
}
export const scheduleService = new ScheduleService();

View File

@@ -0,0 +1,140 @@
import cron from 'node-cron';
import { scheduleService, PresetActionPayload } from './scheduleService';
import { groupService } from './groupService';
import { WledPlaylist } from '../wled/types';
interface ScheduledTask {
startTask: cron.ScheduledTask;
endTask?: cron.ScheduledTask;
scheduleId: string;
}
export class SchedulerService {
private tasks: Map<string, ScheduledTask> = new Map();
async initialize(): Promise<void> {
console.log('Initializing scheduler...');
const schedules = await scheduleService.getAllSchedules();
const enabledSchedules = schedules.filter((s) => s.enabled);
console.log(`Found ${enabledSchedules.length} enabled schedules`);
for (const schedule of enabledSchedules) {
this.registerSchedule(schedule.id);
}
}
async registerSchedule(scheduleId: string): Promise<void> {
// Remove existing task if any
this.unregisterSchedule(scheduleId);
const schedule = await scheduleService.getScheduleById(scheduleId);
if (!schedule || !schedule.enabled) {
return;
}
try {
// Register start task
const startTask = cron.schedule(
schedule.cronExpression,
async () => {
console.log(`Executing schedule: ${schedule.name} (${schedule.id})`);
await this.executeSchedule(scheduleId);
},
{
timezone: schedule.timezone,
scheduled: true,
}
);
const scheduledTask: ScheduledTask = {
startTask,
scheduleId
};
// Register end task if endCronExpression exists
if (schedule.endCronExpression) {
const endTask = cron.schedule(
schedule.endCronExpression,
async () => {
console.log(`Executing end schedule (turning off): ${schedule.name} (${schedule.id})`);
await this.turnOffGroup(schedule.groupId);
},
{
timezone: schedule.timezone,
scheduled: true,
}
);
scheduledTask.endTask = endTask;
console.log(
`Registered schedule: ${schedule.name} with start: ${schedule.cronExpression}, end: ${schedule.endCronExpression} (${schedule.timezone})`
);
} else {
console.log(
`Registered schedule: ${schedule.name} with cron: ${schedule.cronExpression} (${schedule.timezone})`
);
}
this.tasks.set(scheduleId, scheduledTask);
} catch (error) {
console.error(`Failed to register schedule ${scheduleId}:`, error);
}
}
unregisterSchedule(scheduleId: string): void {
const existing = this.tasks.get(scheduleId);
if (existing) {
existing.startTask.stop();
if (existing.endTask) {
existing.endTask.stop();
}
this.tasks.delete(scheduleId);
console.log(`Unregistered schedule: ${scheduleId}`);
}
}
private async executeSchedule(scheduleId: string): Promise<void> {
try {
const schedule = await scheduleService.getScheduleById(scheduleId);
if (!schedule) {
console.error(`Schedule ${scheduleId} not found`);
return;
}
const actionPayload = scheduleService.parseActionPayload(schedule);
if (schedule.type === 'PRESET') {
const payload = actionPayload as PresetActionPayload;
console.log(`Applying preset ${payload.presetId} to group ${schedule.groupId}`);
const result = await groupService.applyPresetToGroup(schedule.groupId, payload.presetId);
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}`);
const result = await groupService.applyPlaylistToGroup(schedule.groupId, payload);
console.log(`Playlist applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
}
} catch (error) {
console.error(`Error executing schedule ${scheduleId}:`, error);
}
}
private async turnOffGroup(groupId: string): Promise<void> {
try {
console.log(`Turning off group ${groupId}`);
const result = await groupService.turnOffGroup(groupId);
console.log(`Group turned off. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} catch (error) {
console.error(`Error turning off group ${groupId}:`, error);
}
}
async shutdown(): Promise<void> {
console.log('Shutting down scheduler...');
for (const [scheduleId] of this.tasks) {
this.unregisterSchedule(scheduleId);
}
}
}
export const schedulerService = new SchedulerService();

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

120
backend/src/wled/client.ts Normal file
View File

@@ -0,0 +1,120 @@
import { WledError, WledInfo, WledPlaylist, WledState, WledPresets, WledEffect, WledPalette } from './types';
export class WledClient {
private baseUrl: string;
constructor(
private ipAddress: string,
private port: number = 80
) {
this.baseUrl = `http://${ipAddress}:${port}`;
}
async getState(): Promise<WledState> {
const url = `${this.baseUrl}/json/state`;
return this.fetch<WledState>(url);
}
async getInfo(): Promise<WledInfo> {
const url = `${this.baseUrl}/json/info`;
return this.fetch<WledInfo>(url);
}
async getPresets(): Promise<WledPresets> {
const url = `${this.baseUrl}/presets.json`;
return this.fetch<WledPresets>(url);
}
async getEffects(): Promise<string[]> {
const url = `${this.baseUrl}/json/effects`;
return this.fetch<string[]>(url);
}
async getPalettes(): Promise<string[]> {
const url = `${this.baseUrl}/json/palettes`;
return this.fetch<string[]>(url);
}
async applyPreset(presetId: number): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { ps: presetId });
}
async applyPlaylist(playlist: WledPlaylist): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { playlist });
}
async turnOn(): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { on: true });
}
async turnOff(): Promise<void> {
const url = `${this.baseUrl}/json/state`;
await this.post(url, { on: false });
}
private async fetch<T>(url: string): Promise<T> {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 second timeout
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new WledError(
`HTTP ${response.status} from WLED device`,
url,
response.status,
text
);
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof WledError) {
throw error;
}
if (error instanceof Error) {
throw new WledError(error.message, url);
}
throw new WledError('Unknown error', url);
}
}
private async post(url: string, body: unknown): Promise<void> {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000), // 5 second timeout
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new WledError(
`HTTP ${response.status} from WLED device`,
url,
response.status,
text
);
}
} catch (error) {
if (error instanceof WledError) {
throw error;
}
if (error instanceof Error) {
throw new WledError(error.message, url);
}
throw new WledError('Unknown error', url);
}
}
}

116
backend/src/wled/types.ts Normal file
View File

@@ -0,0 +1,116 @@
export interface WledPlaylist {
ps: number[]; // preset IDs
dur?: number[] | number; // tenths of seconds
transition?: number[] | number;
repeat?: number; // 0 = infinite
end?: number; // preset to set when finished
}
export interface WledSegment {
id: number;
start: number;
stop: number;
len: number;
grp?: number;
spc?: number;
of?: number;
on: boolean;
bri: number;
col: number[][];
fx: number;
sx: number;
ix: number;
pal: number;
sel: boolean;
rev: boolean;
mi: boolean;
n?: string;
}
export interface WledState {
on?: boolean;
bri?: number;
ps?: number;
pl?: number;
transition?: number;
mainseg?: number;
seg?: WledSegment[];
playlist?: WledPlaylist;
[key: string]: unknown;
}
export interface WledInfo {
ver?: string;
vid?: number;
leds?: {
count?: number;
maxpwr?: number;
maxseg?: number;
seglc?: number[];
lc?: number;
rgbw?: boolean;
wv?: number;
cct?: number;
[key: string]: unknown;
};
name?: string;
udpport?: number;
live?: boolean;
fxcount?: number;
palcount?: number;
wifi?: {
bssid?: string;
rssi?: number;
signal?: number;
channel?: number;
};
fs?: {
u?: number;
t?: number;
pmt?: number;
};
[key: string]: unknown;
}
export interface WledPreset {
n?: string;
ps?: number;
on?: boolean;
bri?: number;
transition?: number;
mainseg?: number;
seg?: Partial<WledSegment>[];
win?: string;
ib?: boolean;
o?: boolean;
v?: boolean;
rb?: boolean;
time?: number;
[key: string]: unknown;
}
export interface WledPresets {
[key: string]: WledPreset | string;
}
export interface WledEffect {
id: number;
name: string;
}
export interface WledPalette {
id: number;
name: string;
}
export class WledError extends Error {
constructor(
message: string,
public url: string,
public statusCode?: number,
public responseText?: string
) {
super(message);
this.name = 'WledError';
}
}

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
services:
backend:
build: ./backend
container_name: wled-backend
ports:
- "3000:3000"
environment:
- PORT=3000
- DATABASE_URL=file:/data/wled.db
- LOG_LEVEL=info
volumes:
- wled-data:/data
restart: unless-stopped
frontend:
build: ./frontend
container_name: wled-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
volumes:
wled-data:

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000/api

15
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}

4
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env.local
.DS_Store

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Build
RUN npm run build
# Production image
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WLED Controller</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

3503
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "wled-controller-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"luxon": "^3.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/luxon": "^3.3.7",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

303
frontend/src/App.css Normal file
View File

@@ -0,0 +1,303 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
}
.app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 250px;
background-color: #2c3e50;
color: white;
padding: 20px;
}
.sidebar h1 {
font-size: 24px;
margin-bottom: 30px;
}
.sidebar nav ul {
list-style: none;
}
.sidebar nav li {
margin-bottom: 10px;
}
.sidebar nav a {
color: white;
text-decoration: none;
display: block;
padding: 10px;
border-radius: 4px;
transition: background-color 0.2s;
}
.sidebar nav a:hover,
.sidebar nav a.active {
background-color: #34495e;
}
.main-content {
flex: 1;
padding: 30px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-header h2 {
font-size: 28px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
table {
width: 100%;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
thead {
background-color: #34495e;
color: white;
}
th, td {
padding: 12px;
text-align: left;
}
tbody tr {
border-bottom: 1px solid #ecf0f1;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.toggle {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
.toggle input:checked + .toggle-slider {
background-color: #2ecc71;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(26px);
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 30px;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 24px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #95a5a6;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
}
.success-message {
background-color: #efe;
color: #3c3;
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #95a5a6;
}
.card {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card h3 {
margin-bottom: 15px;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-item input[type="checkbox"] {
width: auto;
}

46
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import { DashboardPage } from './pages/DashboardPage';
import { DevicesPage } from './pages/DevicesPage';
import { GroupsPage } from './pages/GroupsPage';
import { SchedulesPage } from './pages/SchedulesPage';
import './App.css';
function App() {
return (
<BrowserRouter>
<div className="app">
<aside className="sidebar">
<h1>WLED Controller</h1>
<nav>
<ul>
<li>
<Link to="/dashboard">Dashboard</Link>
</li>
<li>
<Link to="/devices">Devices</Link>
</li>
<li>
<Link to="/groups">Groups</Link>
</li>
<li>
<Link to="/schedules">Schedules</Link>
</li>
</ul>
</nav>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,65 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (response.status === 204) {
return undefined as T;
}
const data = await response.json();
if (!response.ok) {
throw new ApiError(
data.message || `HTTP ${response.status}`,
response.status,
data
);
}
return data as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError('Network error', 0);
}
}
export const apiClient = {
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,39 @@
import { apiClient } from './client';
import { Device, PingResult } from './types';
export interface CreateDeviceInput {
name: string;
ipAddress: string;
port?: number;
enabled?: boolean;
}
export interface UpdateDeviceInput {
name?: string;
ipAddress?: string;
port?: number;
enabled?: boolean;
}
export const deviceApi = {
getAll: () => apiClient.get<Device[]>('/devices'),
getById: (id: string) => apiClient.get<Device>(`/devices/${id}`),
create: (data: CreateDeviceInput) => apiClient.post<Device>('/devices', data),
update: (id: string, data: UpdateDeviceInput) =>
apiClient.put<Device>(`/devices/${id}`, data),
delete: (id: string) => apiClient.delete(`/devices/${id}`),
ping: (id: string) => apiClient.post<PingResult>(`/devices/${id}/ping`),
turnOn: (id: string) => apiClient.post(`/devices/${id}/turn-on`),
turnOff: (id: string) => apiClient.post(`/devices/${id}/turn-off`),
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
};

View File

@@ -0,0 +1,31 @@
import { apiClient } from './client';
import { Group, GroupActionResult, PlaylistActionPayload } from './types';
export interface CreateGroupInput {
name: string;
deviceIds: string[];
}
export interface UpdateGroupInput {
name?: string;
deviceIds?: string[];
}
export const groupApi = {
getAll: () => apiClient.get<Group[]>('/groups'),
getById: (id: string) => apiClient.get<Group>(`/groups/${id}`),
create: (data: CreateGroupInput) => apiClient.post<Group>('/groups', data),
update: (id: string, data: UpdateGroupInput) =>
apiClient.put<Group>(`/groups/${id}`, data),
delete: (id: string) => apiClient.delete(`/groups/${id}`),
applyPreset: (groupId: string, presetId: number) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/preset`, { presetId }),
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
};

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
import { Schedule, PresetActionPayload, PlaylistActionPayload } from './types';
export interface CreateScheduleInput {
name: string;
groupId: string;
type: 'PRESET' | 'PLAYLIST';
cronExpression: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload: PresetActionPayload | PlaylistActionPayload;
}
export interface UpdateScheduleInput {
name?: string;
groupId?: string;
type?: 'PRESET' | 'PLAYLIST';
cronExpression?: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload?: PresetActionPayload | PlaylistActionPayload;
}
export const scheduleApi = {
getAll: () => apiClient.get<Schedule[]>('/schedules'),
getById: (id: string) => apiClient.get<Schedule>(`/schedules/${id}`),
create: (data: CreateScheduleInput) => apiClient.post<Schedule>('/schedules', data),
update: (id: string, data: UpdateScheduleInput) =>
apiClient.put<Schedule>(`/schedules/${id}`, data),
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
};

63
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface Device {
id: string;
name: string;
ipAddress: string;
port: number;
enabled: boolean;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface Group {
id: string;
name: string;
devices: Device[];
createdAt: string;
updatedAt: string;
}
export interface Schedule {
id: string;
name: string;
groupId: string;
type: 'PRESET' | 'PLAYLIST';
cronExpression: string;
endCronExpression?: string;
timezone: string;
enabled: boolean;
actionPayload: PresetActionPayload | PlaylistActionPayload;
createdAt: string;
updatedAt: string;
group: {
id: string;
name: string;
};
}
export interface PresetActionPayload {
presetId: number;
}
export interface PlaylistActionPayload {
ps: number[];
dur?: number[] | number;
transition?: number[] | number;
repeat?: number;
end?: number;
}
export interface GroupActionResult {
status: 'ok';
groupId: string;
results: {
success: string[];
failed: Array<{ deviceId: string; error: string }>;
};
}
export interface PingResult {
status: 'ok' | 'error';
info?: unknown;
error?: string;
}

View File

@@ -0,0 +1,255 @@
import { useState, useEffect } from 'react';
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
interface TimePickerProps {
value: string; // cron expression
onChange: (cron: string) => void;
}
export function TimePicker({ value, onChange }: TimePickerProps) {
const [mode, setMode] = useState<'simple' | 'advanced'>('simple');
const [timeValue, setTimeValue] = useState<TimePickerValue>(() => {
const parsed = cronToTimePicker(value);
return parsed || { hour: 18, minute: 30, daysOfWeek: [0, 1, 2, 3, 4, 5, 6] };
});
const handleTimeChange = (newValue: Partial<TimePickerValue>) => {
const updated = { ...timeValue, ...newValue };
setTimeValue(updated);
onChange(timePickerToCron(updated));
};
const toggleDay = (day: number) => {
const days = timeValue.daysOfWeek.includes(day)
? timeValue.daysOfWeek.filter(d => d !== day)
: [...timeValue.daysOfWeek, day].sort((a, b) => a - b);
handleTimeChange({ daysOfWeek: days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6] });
};
const selectAllDays = () => {
handleTimeChange({ daysOfWeek: [0, 1, 2, 3, 4, 5, 6] });
};
const selectWeekdays = () => {
handleTimeChange({ daysOfWeek: [1, 2, 3, 4, 5] });
};
const selectWeekends = () => {
handleTimeChange({ daysOfWeek: [0, 6] });
};
return (
<div>
<div style={{ marginBottom: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="radio"
checked={mode === 'simple'}
onChange={() => setMode('simple')}
/>
Simple Time Picker
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="radio"
checked={mode === 'advanced'}
onChange={() => setMode('advanced')}
/>
Advanced (Cron Expression)
</label>
</div>
{mode === 'simple' ? (
<div>
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px', alignItems: 'center' }}>
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Hour</label>
<input
type="number"
min="0"
max="23"
value={timeValue.hour}
onChange={(e) => handleTimeChange({ hour: parseInt(e.target.value) })}
style={{ width: '70px', padding: '8px' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Minute</label>
<input
type="number"
min="0"
max="59"
value={timeValue.minute}
onChange={(e) => handleTimeChange({ minute: parseInt(e.target.value) })}
style={{ width: '70px', padding: '8px' }}
/>
</div>
<div style={{ marginLeft: '20px', fontSize: '24px', fontWeight: 'bold' }}>
{String(timeValue.hour).padStart(2, '0')}:{String(timeValue.minute).padStart(2, '0')}
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>Days of Week</label>
<div style={{ display: 'flex', gap: '5px', marginBottom: '10px' }}>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectAllDays}
>
All Days
</button>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectWeekdays}
>
Weekdays
</button>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectWeekends}
>
Weekends
</button>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{DAYS_OF_WEEK.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
style={{
padding: '8px 16px',
border: '2px solid',
borderColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : '#ddd',
backgroundColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : 'white',
color: timeValue.daysOfWeek.includes(day.value) ? 'white' : '#333',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: '500',
}}
>
{day.label}
</button>
))}
</div>
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#7f8c8d' }}>
Cron: {timePickerToCron(timeValue)}
</div>
</div>
) : (
<div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="0 18 * * *"
style={{ width: '100%' }}
/>
<div style={{ marginTop: '5px', fontSize: '12px', color: '#7f8c8d' }}>
Format: minute hour day month dayofweek
</div>
</div>
)}
</div>
);
}
interface PresetSelectorProps {
groupId: string;
selectedPresets: number[];
onChange: (presets: number[]) => void;
mode: 'single' | 'multiple';
}
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
const [presets, setPresets] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (groupId) {
loadPresets();
}
}, [groupId]);
const loadPresets = async () => {
try {
setLoading(true);
const response = await fetch(`/api/groups/${groupId}/presets`);
const data = await response.json();
setPresets(data);
} catch (error) {
console.error('Failed to load presets:', error);
} finally {
setLoading(false);
}
};
const handleToggle = (presetId: number) => {
if (mode === 'single') {
onChange([presetId]);
} else {
if (selectedPresets.includes(presetId)) {
onChange(selectedPresets.filter(id => id !== presetId));
} else {
onChange([...selectedPresets, presetId]);
}
}
};
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
const presetEntries = Object.entries(presets)
.filter(([key]) => key !== '0')
.map(([key, value]) => {
const id = parseInt(key);
const name = typeof value === 'object' && value.n ? value.n : `Preset ${id}`;
return { id, name };
})
.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={{
maxHeight: '300px',
overflowY: 'auto',
border: '1px solid #ddd',
borderRadius: '4px',
padding: '10px'
}}>
{presetEntries.map(({ id, name }) => (
<div
key={id}
onClick={() => handleToggle(id)}
style={{
padding: '10px',
marginBottom: '5px',
border: '2px solid',
borderColor: selectedPresets.includes(id) ? '#3498db' : '#ecf0f1',
backgroundColor: selectedPresets.includes(id) ? '#ebf5fb' : 'white',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>
<strong>#{id}</strong> {name}
</span>
{mode === 'multiple' && selectedPresets.includes(id) && (
<span style={{ color: '#3498db' }}></span>
)}
</div>
))}
</div>
);
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { formatDateTime } from '../utils/dateTime';
import { deviceApi } from '../api/devices';
interface DeviceStatus {
id: string;
name: string;
ipAddress: string;
port: number;
enabled: boolean;
lastSeenAt: string | null;
currentState?: {
on?: boolean;
bri?: number;
ps?: number;
seg?: Array<{
id: number;
start: number;
stop: number;
len: number;
on: boolean;
bri: number;
n?: string;
}>;
};
info?: {
ver?: string;
leds?: {
count?: number;
maxseg?: number;
};
name?: string;
};
}
export function DashboardPage() {
const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
useEffect(() => {
loadDevices();
}, []);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
loadDevices();
}, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}, [autoRefresh]);
const loadDevices = async () => {
try {
const response = await fetch('/api/devices/status/all');
const data = await response.json();
setDevices(data);
} catch (error) {
console.error('Failed to load device status:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading">Loading dashboard...</div>;
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
const totalEnabled = devices.filter(d => d.enabled).length;
return (
<div>
<div className="page-header">
<h2>Dashboard</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
Auto-refresh
</label>
<button className="btn btn-secondary" onClick={loadDevices}>
Refresh Now
</button>
<button
className="btn btn-primary"
onClick={async () => {
try {
await deviceApi.turnOnAll();
setTimeout(loadDevices, 500);
} catch (err) {
alert('Failed to turn on all devices');
}
}}
>
Turn All On
</button>
<button
className="btn btn-danger"
onClick={async () => {
try {
await deviceApi.turnOffAll();
setTimeout(loadDevices, 500);
} catch (err) {
alert('Failed to turn off all devices');
}
}}
>
Turn All Off
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
<div className="card">
<h3>Total Devices</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#3498db' }}>{devices.length}</div>
</div>
<div className="card">
<h3>Online</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#2ecc71' }}>
{onlineDevices} / {totalEnabled}
</div>
</div>
<div className="card">
<h3>Total LEDs</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#9b59b6' }}>
{devices.reduce((sum, d) => sum + (d.info?.leds?.count || 0), 0)}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
{devices.map((device) => (
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
))}
</div>
</div>
);
}
interface DeviceCardProps {
device: DeviceStatus;
onRefresh: () => void;
}
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
const isOnline = device.enabled && device.currentState !== undefined;
const isOn = device.currentState?.on ?? false;
const [busy, setBusy] = useState(false);
const handleTurnOn = async () => {
try {
setBusy(true);
await deviceApi.turnOn(device.id);
setTimeout(onRefresh, 500);
} catch (err) {
alert('Failed to turn on device');
} finally {
setBusy(false);
}
};
const handleTurnOff = async () => {
try {
setBusy(true);
await deviceApi.turnOff(device.id);
setTimeout(onRefresh, 500);
} catch (err) {
alert('Failed to turn off device');
} finally {
setBusy(false);
}
};
return (
<div className="card" style={{ position: 'relative' }}>
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
}}
title={isOnline ? 'Online' : 'Offline'}
/>
</div>
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
{device.ipAddress}:{device.port}
</div>
{isOnline && (
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || isOn}
style={{ flex: 1 }}
>
Turn On
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || !isOn}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
)}
{isOnline ? (
<>
<div style={{ marginBottom: '10px' }}>
<strong>Status:</strong>{' '}
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
{isOn ? 'ON' : 'OFF'}
</span>
</div>
{device.currentState?.bri !== undefined && (
<div style={{ marginBottom: '10px' }}>
<strong>Brightness:</strong> {Math.round((device.currentState.bri / 255) * 100)}%
</div>
)}
{device.currentState?.ps !== undefined && device.currentState.ps > 0 && (
<div style={{ marginBottom: '10px' }}>
<strong>Preset:</strong> #{device.currentState.ps}
</div>
)}
{device.info?.leds?.count !== undefined && (
<div style={{ marginBottom: '10px' }}>
<strong>LEDs:</strong> {device.info.leds.count}
</div>
)}
{device.info?.ver && (
<div style={{ marginBottom: '10px' }}>
<strong>Version:</strong> {device.info.ver}
</div>
)}
{device.currentState?.seg && device.currentState.seg.length > 0 && (
<div style={{ marginTop: '15px' }}>
<strong>Segments ({device.currentState.seg.length}):</strong>
<div style={{ marginTop: '8px' }}>
{device.currentState.seg.map((seg) => (
<div
key={seg.id}
style={{
fontSize: '12px',
padding: '6px',
backgroundColor: '#f8f9fa',
marginBottom: '4px',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>
{seg.n || `Segment ${seg.id}`} ({seg.len} LEDs)
</span>
<span style={{ color: seg.on ? '#2ecc71' : '#95a5a6' }}>
{seg.on ? 'ON' : 'OFF'}
</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<div style={{ color: '#95a5a6', fontStyle: 'italic' }}>
{device.enabled ? 'Device offline or unreachable' : 'Device disabled'}
</div>
)}
{device.lastSeenAt && (
<div style={{ marginTop: '15px', fontSize: '12px', color: '#95a5a6', borderTop: '1px solid #ecf0f1', paddingTop: '10px' }}>
Last seen: {formatDateTime(device.lastSeenAt)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import { deviceApi } from '../api/devices';
import { Device } from '../api/types';
import { formatDateTime } from '../utils/dateTime';
export function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
try {
setLoading(true);
const data = await deviceApi.getAll();
setDevices(data);
setError(null);
} catch (err) {
setError('Failed to load devices');
} finally {
setLoading(false);
}
};
const handlePing = async (id: string) => {
try {
const result = await deviceApi.ping(id);
if (result.status === 'ok') {
alert('Device is reachable!');
loadDevices();
} else {
alert(`Device error: ${result.error}`);
}
} catch (err) {
alert('Failed to ping device');
}
};
const handleToggleEnabled = async (device: Device) => {
try {
await deviceApi.update(device.id, { enabled: !device.enabled });
loadDevices();
} catch (err) {
alert('Failed to update device');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this device?')) return;
try {
await deviceApi.delete(id);
loadDevices();
} catch (err) {
alert('Failed to delete device');
}
};
const openCreateModal = () => {
setEditingDevice(null);
setShowModal(true);
};
const openEditModal = (device: Device) => {
setEditingDevice(device);
setShowModal(true);
};
if (loading) return <div className="loading">Loading devices...</div>;
return (
<div>
<div className="page-header">
<h2>Devices</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>IP Address</th>
<th>Port</th>
<th>Enabled</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr key={device.id}>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
<td>{device.port}</td>
<td>
<label className="toggle">
<input
type="checkbox"
checked={device.enabled}
onChange={() => handleToggleEnabled(device)}
/>
<span className="toggle-slider"></span>
</label>
</td>
<td>{formatDateTime(device.lastSeenAt)}</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => handlePing(device.id)}
style={{ marginRight: '5px' }}
>
Ping
</button>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(device)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(device.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<DeviceModal
device={editingDevice}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadDevices();
}}
/>
)}
</div>
);
}
interface DeviceModalProps {
device: Device | null;
onClose: () => void;
onSave: () => void;
}
function DeviceModal({ device, onClose, onSave }: DeviceModalProps) {
const [name, setName] = useState(device?.name || '');
const [ipAddress, setIpAddress] = useState(device?.ipAddress || '');
const [port, setPort] = useState(device?.port || 80);
const [enabled, setEnabled] = useState(device?.enabled ?? true);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
if (device) {
await deviceApi.update(device.id, { name, ipAddress, port, enabled });
} else {
await deviceApi.create({ name, ipAddress, port, enabled });
}
onSave();
} catch (err) {
alert('Failed to save device');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{device ? 'Edit Device' : 'Add Device'}</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)}
required
/>
</div>
<div className="form-group">
<label>IP Address *</label>
<input
type="text"
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Port</label>
<input
type="number"
value={port}
onChange={(e) => setPort(parseInt(e.target.value))}
min="1"
max="65535"
/>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{' '}Enabled
</label>
</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>
);
}

View File

@@ -0,0 +1,360 @@
import { useState, useEffect } from 'react';
import { groupApi } from '../api/groups';
import { deviceApi } from '../api/devices';
import { Group, Device } from '../api/types';
export function GroupsPage() {
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoading(true);
const data = await groupApi.getAll();
setGroups(data);
setError(null);
} catch (err) {
setError('Failed to load groups');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this group?')) return;
try {
await groupApi.delete(id);
loadGroups();
} catch (err) {
alert('Failed to delete group. It may have active schedules.');
}
};
const openCreateModal = () => {
setEditingGroup(null);
setShowModal(true);
};
const openEditModal = (group: Group) => {
setEditingGroup(group);
setShowModal(true);
};
if (loading) return <div className="loading">Loading groups...</div>;
return (
<div>
<div className="page-header">
<h2>Groups</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Group
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>Devices</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr key={group.id}>
<td>{group.name}</td>
<td>{group.devices.length} device(s)</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => setSelectedGroup(group)}
style={{ marginRight: '5px' }}
>
Control
</button>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(group)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(group.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<GroupModal
group={editingGroup}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadGroups();
}}
/>
)}
{selectedGroup && (
<GroupControlModal
group={selectedGroup}
onClose={() => setSelectedGroup(null)}
/>
)}
</div>
);
}
interface GroupModalProps {
group: Group | null;
onClose: () => void;
onSave: () => void;
}
function GroupModal({ group, onClose, onSave }: GroupModalProps) {
const [name, setName] = useState(group?.name || '');
const [devices, setDevices] = useState<Device[]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>(
group?.devices.map(d => d.id) || []
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
const data = await deviceApi.getAll();
setDevices(data);
};
const handleToggleDevice = (deviceId: string) => {
setSelectedDeviceIds(prev =>
prev.includes(deviceId)
? prev.filter(id => id !== deviceId)
: [...prev, deviceId]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedDeviceIds.length === 0) {
alert('Please select at least one device');
return;
}
try {
setSubmitting(true);
if (group) {
await groupApi.update(group.id, { name, deviceIds: selectedDeviceIds });
} else {
await groupApi.create({ name, deviceIds: selectedDeviceIds });
}
onSave();
} catch (err) {
alert('Failed to save group');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{group ? 'Edit Group' : 'Create Group'}</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)}
required
/>
</div>
<div className="form-group">
<label>Devices *</label>
<div className="checkbox-group">
{devices.map(device => (
<label key={device.id} className="checkbox-item">
<input
type="checkbox"
checked={selectedDeviceIds.includes(device.id)}
onChange={() => handleToggleDevice(device.id)}
/>
{device.name} ({device.ipAddress})
</label>
))}
</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>
);
}
interface GroupControlModalProps {
group: Group;
onClose: () => void;
}
function GroupControlModal({ group, onClose }: GroupControlModalProps) {
const [presetId, setPresetId] = useState('');
const [playlistPresets, setPlaylistPresets] = useState('');
const [playlistDur, setPlaylistDur] = useState('');
const [playlistTransition, setPlaylistTransition] = useState('');
const [playlistRepeat, setPlaylistRepeat] = useState('0');
const [playlistEnd, setPlaylistEnd] = useState('');
const handleApplyPreset = async () => {
if (!presetId) {
alert('Please enter a preset ID');
return;
}
try {
const result = await groupApi.applyPreset(group.id, parseInt(presetId));
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} catch (err) {
alert('Failed to apply preset');
}
};
const handleApplyPlaylist = async () => {
if (!playlistPresets) {
alert('Please enter preset IDs');
return;
}
try {
const ps = playlistPresets.split(',').map(s => parseInt(s.trim()));
const dur = playlistDur ? playlistDur.split(',').map(s => parseInt(s.trim())) : undefined;
const transition = playlistTransition ? playlistTransition.split(',').map(s => parseInt(s.trim())) : undefined;
const result = await groupApi.applyPlaylist(group.id, {
ps,
dur: dur && dur.length === 1 ? dur[0] : dur,
transition: transition && transition.length === 1 ? transition[0] : transition,
repeat: parseInt(playlistRepeat),
end: playlistEnd ? parseInt(playlistEnd) : undefined,
});
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} catch (err) {
alert('Failed to apply playlist');
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Control Group: {group.name}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="card">
<h3>Apply Preset</h3>
<div className="form-group">
<label>Preset ID</label>
<input
type="number"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
placeholder="e.g., 1"
/>
</div>
<button className="btn btn-primary" onClick={handleApplyPreset}>
Apply Preset
</button>
</div>
<div className="card">
<h3>Apply Playlist</h3>
<div className="form-group">
<label>Preset IDs (comma-separated) *</label>
<input
type="text"
value={playlistPresets}
onChange={(e) => setPlaylistPresets(e.target.value)}
placeholder="e.g., 1,2,3"
/>
</div>
<div className="form-group">
<label>Duration (tenths of seconds, comma-separated or single value)</label>
<input
type="text"
value={playlistDur}
onChange={(e) => setPlaylistDur(e.target.value)}
placeholder="e.g., 30,30,30 or 30"
/>
</div>
<div className="form-group">
<label>Transition (comma-separated or single value)</label>
<input
type="text"
value={playlistTransition}
onChange={(e) => setPlaylistTransition(e.target.value)}
placeholder="e.g., 0"
/>
</div>
<div className="form-group">
<label>Repeat (0 = infinite)</label>
<input
type="number"
value={playlistRepeat}
onChange={(e) => setPlaylistRepeat(e.target.value)}
/>
</div>
<div className="form-group">
<label>End Preset ID</label>
<input
type="number"
value={playlistEnd}
onChange={(e) => setPlaylistEnd(e.target.value)}
placeholder="Optional"
/>
</div>
<button className="btn btn-primary" onClick={handleApplyPlaylist}>
Apply Playlist
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,395 @@
import { useState, useEffect } from 'react';
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[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
useEffect(() => {
loadSchedules();
}, []);
const loadSchedules = async () => {
try {
setLoading(true);
const data = await scheduleApi.getAll();
setSchedules(data);
setError(null);
} catch (err) {
setError('Failed to load schedules');
} finally {
setLoading(false);
}
};
const handleToggleEnabled = async (schedule: Schedule) => {
try {
await scheduleApi.update(schedule.id, { enabled: !schedule.enabled });
loadSchedules();
} catch (err) {
alert('Failed to update schedule');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
await scheduleApi.delete(id);
loadSchedules();
} catch (err) {
alert('Failed to delete schedule');
}
};
const openCreateModal = () => {
setEditingSchedule(null);
setShowModal(true);
};
const openEditModal = (schedule: Schedule) => {
setEditingSchedule(schedule);
setShowModal(true);
};
if (loading) return <div className="loading">Loading schedules...</div>;
return (
<div>
<div className="page-header">
<h2>Schedules</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Schedule
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>Group</th>
<th>Type</th>
<th>Time</th>
<th>Timezone</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{schedules.map((schedule) => (
<tr key={schedule.id}>
<td>{schedule.name}</td>
<td>{schedule.group.name}</td>
<td>{schedule.type}</td>
<td style={{ fontFamily: 'monospace', fontSize: '12px' }}>{schedule.cronExpression}</td>
<td>{schedule.timezone}</td>
<td>
<label className="toggle">
<input
type="checkbox"
checked={schedule.enabled}
onChange={() => handleToggleEnabled(schedule)}
/>
<span className="toggle-slider"></span>
</label>
</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(schedule)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(schedule.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<ScheduleModal
schedule={editingSchedule}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadSchedules();
}}
/>
)}
</div>
);
}
interface ScheduleModalProps {
schedule: Schedule | null;
onClose: () => void;
onSave: () => void;
}
function ScheduleModal({ schedule, onClose, onSave }: ScheduleModalProps) {
const [name, setName] = useState(schedule?.name || '');
const [groups, setGroups] = useState<Group[]>([]);
const [groupId, setGroupId] = useState(schedule?.groupId || '');
const [type, setType] = useState<'PRESET' | 'PLAYLIST'>(schedule?.type || 'PRESET');
const [cronExpression, setCronExpression] = useState(schedule?.cronExpression || '30 18 * * *');
const [endCronExpression, setEndCronExpression] = useState(schedule?.endCronExpression || '');
const [timezone, setTimezone] = useState(schedule?.timezone || 'Europe/London');
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
// Preset fields
const [selectedPresets, setSelectedPresets] = useState<number[]>(() => {
if (!schedule) return [];
if (schedule.type === 'PRESET') {
return [(schedule.actionPayload as PresetActionPayload).presetId];
}
if (schedule.type === 'PLAYLIST') {
return (schedule.actionPayload as PlaylistActionPayload).ps;
}
return [];
});
// Playlist fields
const [playlistDur, setPlaylistDur] = useState('30');
const [playlistTransition, setPlaylistTransition] = useState('0');
const [playlistRepeat, setPlaylistRepeat] = useState('0');
const [playlistEnd, setPlaylistEnd] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
const data = await groupApi.getAll();
setGroups(data);
if (data.length > 0 && !groupId) {
setGroupId(data[0].id);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!groupId) {
alert('Please select a group');
return;
}
if (selectedPresets.length === 0) {
alert('Please select at least one preset');
return;
}
let actionPayload: PresetActionPayload | PlaylistActionPayload;
if (type === 'PRESET') {
actionPayload = { presetId: selectedPresets[0] };
} else {
const dur = parseInt(playlistDur);
const transition = parseInt(playlistTransition);
actionPayload = {
ps: selectedPresets,
dur: isNaN(dur) ? 30 : dur,
transition: isNaN(transition) ? 0 : transition,
repeat: parseInt(playlistRepeat) || 0,
end: playlistEnd ? parseInt(playlistEnd) : undefined,
};
}
try {
setSubmitting(true);
if (schedule) {
await scheduleApi.update(schedule.id, {
name,
groupId,
type,
cronExpression,
endCronExpression: endCronExpression || undefined,
timezone,
enabled,
actionPayload,
});
} else {
await scheduleApi.create({
name,
groupId,
type,
cronExpression,
endCronExpression: endCronExpression || undefined,
timezone,
enabled,
actionPayload,
});
}
onSave();
} catch (err) {
alert('Failed to save schedule');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className="modal-header">
<h3>{schedule ? 'Edit Schedule' : 'Create Schedule'}</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)}
required
/>
</div>
<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>
<div className="form-group">
<label>Type *</label>
<select value={type} onChange={(e) => setType(e.target.value as 'PRESET' | 'PLAYLIST')} required>
<option value="PRESET">Single Preset</option>
<option value="PLAYLIST">Playlist (Multiple Presets)</option>
</select>
</div>
<div className="form-group">
<label>Schedule Time *</label>
<TimePicker value={cronExpression} onChange={setCronExpression} />
</div>
<div className="form-group">
<label>End Time (Turn Off) - Optional</label>
<TimePicker value={endCronExpression} onChange={setEndCronExpression} />
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Leave empty if you don't want lights to automatically turn off
</div>
</div>
<div className="form-group">
<label>Timezone</label>
<select value={timezone} onChange={(e) => setTimezone(e.target.value)}>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/Paris">Europe/Paris</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{' '}Enabled
</label>
</div>
<div className="form-group">
<label>{type === 'PRESET' ? 'Select Preset *' : 'Select Presets for Playlist *'}</label>
{groupId ? (
<PresetSelector
groupId={groupId}
selectedPresets={selectedPresets}
onChange={setSelectedPresets}
mode={type === 'PRESET' ? 'single' : 'multiple'}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
Please select a group first
</div>
)}
</div>
{type === 'PLAYLIST' && (
<>
<div className="form-group">
<label>Duration (tenths of seconds per preset)</label>
<input
type="number"
value={playlistDur}
onChange={(e) => setPlaylistDur(e.target.value)}
placeholder="30"
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Default 30 = 3 seconds per preset
</div>
</div>
<div className="form-group">
<label>Transition</label>
<input
type="number"
value={playlistTransition}
onChange={(e) => setPlaylistTransition(e.target.value)}
placeholder="0"
/>
</div>
<div className="form-group">
<label>Repeat (0 = infinite)</label>
<input
type="number"
value={playlistRepeat}
onChange={(e) => setPlaylistRepeat(e.target.value)}
/>
</div>
<div className="form-group">
<label>End Preset ID (optional)</label>
<input
type="number"
value={playlistEnd}
onChange={(e) => setPlaylistEnd(e.target.value)}
placeholder="Optional"
/>
</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>
);
}

View File

@@ -0,0 +1,15 @@
import { DateTime } from 'luxon';
export function formatDateTime(dateString: string | null): string {
if (!dateString) return '-';
const dt = DateTime.fromISO(dateString, { zone: 'Europe/London' });
return dt.toFormat('dd/MM/yyyy HH:mm');
}
export function formatDateTimeFromDate(date: Date | null): string {
if (!date) return '-';
const dt = DateTime.fromJSDate(date, { zone: 'Europe/London' });
return dt.toFormat('dd/MM/yyyy HH:mm');
}

View File

@@ -0,0 +1,61 @@
export interface TimePickerValue {
hour: number;
minute: number;
daysOfWeek: number[]; // 0-6, 0 = Sunday
}
export function timePickerToCron(value: TimePickerValue): string {
const { hour, minute, daysOfWeek } = value;
if (daysOfWeek.length === 0 || daysOfWeek.length === 7) {
// Every day
return `${minute} ${hour} * * *`;
}
// Specific days
const days = daysOfWeek.sort((a, b) => a - b).join(',');
return `${minute} ${hour} * * ${days}`;
}
export function cronToTimePicker(cron: string): TimePickerValue | null {
try {
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return null;
const [minutePart, hourPart, , , dayPart] = parts;
const minute = parseInt(minutePart);
const hour = parseInt(hourPart);
if (isNaN(minute) || isNaN(hour)) return null;
let daysOfWeek: number[] = [];
if (dayPart === '*') {
// Every day
daysOfWeek = [0, 1, 2, 3, 4, 5, 6];
} else if (dayPart.includes(',')) {
// Specific days
daysOfWeek = dayPart.split(',').map(d => parseInt(d)).filter(d => !isNaN(d) && d >= 0 && d <= 6);
} else {
const day = parseInt(dayPart);
if (!isNaN(day) && day >= 0 && day <= 6) {
daysOfWeek = [day];
}
}
return { hour, minute, daysOfWeek };
} catch {
return null;
}
}
export const DAYS_OF_WEEK = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
];

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
frontend/tsconfig.json Normal file
View File

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

View File

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

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

50
setup.ps1 Normal file
View File

@@ -0,0 +1,50 @@
# WLED Controller Setup Script
Write-Host "Setting up WLED Controller..." -ForegroundColor Green
# Backend setup
Write-Host "`nSetting up backend..." -ForegroundColor Cyan
Set-Location backend
if (Test-Path ".env") {
Write-Host "Backend .env file already exists" -ForegroundColor Yellow
} else {
Copy-Item .env.example .env
Write-Host "Created backend .env file" -ForegroundColor Green
}
Write-Host "Installing backend dependencies..."
npm install
Write-Host "Generating Prisma client..."
npm run prisma:generate
Write-Host "Running database migrations..."
npm run prisma:migrate
# Frontend setup
Write-Host "`nSetting up frontend..." -ForegroundColor Cyan
Set-Location ../frontend
if (Test-Path ".env") {
Write-Host "Frontend .env file already exists" -ForegroundColor Yellow
} else {
Copy-Item .env.example .env
Write-Host "Created frontend .env file" -ForegroundColor Green
}
Write-Host "Installing frontend dependencies..."
npm install
Set-Location ..
Write-Host "`n========================================" -ForegroundColor Green
Write-Host "Setup complete!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host "`nTo start the backend (in backend directory):"
Write-Host " npm run dev" -ForegroundColor Cyan
Write-Host "`nTo start the frontend (in frontend directory):"
Write-Host " npm run dev" -ForegroundColor Cyan
Write-Host "`nOr use Docker Compose:"
Write-Host " docker-compose up -d" -ForegroundColor Cyan
Write-Host ""