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

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"]
}