proxy backend

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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