proxy backend
This commit is contained in:
922
backend/package-lock.json
generated
922
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
132
backend/src/routes/quickActions.ts
Normal file
132
backend/src/routes/quickActions.ts
Normal 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;
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
168
backend/src/services/discoveryService.ts
Normal file
168
backend/src/services/discoveryService.ts
Normal 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();
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
151
backend/src/services/quickActionService.ts
Normal file
151
backend/src/services/quickActionService.ts
Normal 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();
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user