Initial commit
This commit is contained in:
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
DATABASE_URL="file:./dev.db"
|
||||
LOG_LEVEL=info
|
||||
15
backend/.eslintrc.json
Normal file
15
backend/.eslintrc.json
Normal 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
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.DS_Store
|
||||
7
backend/.prettierrc.json
Normal file
7
backend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal 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
11
backend/jest.config.js
Normal 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
7389
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
backend/package.json
Normal file
52
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
backend/prisma/migrations/20251208105610_init/migration.sql
Normal file
44
backend/prisma/migrations/20251208105610_init/migration.sql
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Schedule" ADD COLUMN "endCronExpression" TEXT;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
57
backend/prisma/schema.prisma
Normal file
57
backend/prisma/schema.prisma
Normal 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
36
backend/src/app.ts
Normal 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;
|
||||
}
|
||||
5
backend/src/config/index.ts
Normal file
5
backend/src/config/index.ts
Normal 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',
|
||||
};
|
||||
205
backend/src/routes/devices.ts
Normal file
205
backend/src/routes/devices.ts
Normal 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;
|
||||
173
backend/src/routes/groups.ts
Normal file
173
backend/src/routes/groups.ts
Normal 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;
|
||||
176
backend/src/routes/schedules.ts
Normal file
176
backend/src/routes/schedules.ts
Normal 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
54
backend/src/server.ts
Normal 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();
|
||||
221
backend/src/services/deviceService.ts
Normal file
221
backend/src/services/deviceService.ts
Normal 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();
|
||||
298
backend/src/services/groupService.ts
Normal file
298
backend/src/services/groupService.ts
Normal 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();
|
||||
153
backend/src/services/scheduleService.ts
Normal file
153
backend/src/services/scheduleService.ts
Normal 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();
|
||||
140
backend/src/services/schedulerService.ts
Normal file
140
backend/src/services/schedulerService.ts
Normal 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();
|
||||
3
backend/src/utils/prisma.ts
Normal file
3
backend/src/utils/prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
120
backend/src/wled/client.ts
Normal file
120
backend/src/wled/client.ts
Normal 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
116
backend/src/wled/types.ts
Normal 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
20
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user