From 4ae5196ef1cc315bc64feb5a4f267306e5c5e3f6 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 18:56:34 +0700 Subject: [PATCH] feat: Add Swagger documentation support and restructure routes - Added @elysiajs/swagger dependency to package.json for API documentation. - Removed the old bed router and replaced it with a new history router. - Created a new state router to manage WebSocket connections and state updates. - Implemented a comprehensive state management system with the StateManager service. - Introduced AlarmManagement and BedService services for handling alarms and sensor readings. - Established a new MQTT service for managing MQTT connections and subscriptions. - Created an AlarmStateStore to manage volatile alerts and their states. - Defined FrontendState types for structured state management and WebSocket messaging. --- app.ts | 7 +- bun.lock | 47 +++++++ package.json | 1 + routes/{bed.ts => history.ts} | 0 routes/state.ts | 172 +++++++++++++++++++++++++ routes/swagger.ts | 19 +++ services/AlarmManagement.ts | 148 ++++++++++++++++++++++ services/BedService.ts | 228 +++++++++++++++++++++++++++++++++ services/StateManager.ts | 232 ++++++++++++++++++++++++++++++++++ services/mqttService.ts | 72 +++++++++++ store/AlarmStateStore.ts | 201 +++++++++++++++++++++++++++++ types/FrontendState.ts | 63 +++++++++ 12 files changed, 1189 insertions(+), 1 deletion(-) rename routes/{bed.ts => history.ts} (100%) create mode 100644 routes/state.ts create mode 100644 routes/swagger.ts create mode 100644 services/AlarmManagement.ts create mode 100644 services/BedService.ts create mode 100644 services/StateManager.ts create mode 100644 services/mqttService.ts create mode 100644 store/AlarmStateStore.ts create mode 100644 types/FrontendState.ts diff --git a/app.ts b/app.ts index d197cac..da774c8 100644 --- a/app.ts +++ b/app.ts @@ -3,6 +3,8 @@ import { cors } from '@elysiajs/cors'; import { createTopicLogger } from '~/utils/logger'; import env from './config/env'; +import stateRouter from './routes/state'; +import swaggerElysia from './routes/swagger'; async function initialize() { const logger = createTopicLogger({ topic: 'Initializer' }); @@ -12,7 +14,6 @@ async function initialize() { logger.error(`Initialization error: ${error}`); process.exit(1); } - } async function initializeElysia() { @@ -31,6 +32,10 @@ async function initializeElysia() { // Core Components .use(cors()) + .use(swaggerElysia) + + // State routes (includes WebSocket) + .use(stateRouter) // Start the server app.listen(PORT); diff --git a/bun.lock b/bun.lock index 2e8e5ca..295fcc3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,15 @@ "name": "elysia", "dependencies": { "@elysiajs/cors": "^1.3.3", + "@elysiajs/swagger": "^1.3.0", + "@prisma/client": "^6.10.1", "elysia": "latest", "envalid": "^8.0.0", "winston": "^3.17.0", }, "devDependencies": { "bun-types": "^1.2.17", + "prisma": "^6.10.1", }, }, }, @@ -21,6 +24,28 @@ "@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="], + "@elysiajs/swagger": ["@elysiajs/swagger@1.3.0", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-0fo3FWkDRPNYpowJvLz3jBHe9bFe6gruZUyf+feKvUEEMG9ZHptO1jolSoPE0ffFw1BgN1/wMsP19p4GRXKdfg=="], + + "@prisma/client": ["@prisma/client@6.10.1", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w=="], + + "@prisma/config": ["@prisma/config@6.10.1", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ=="], + + "@prisma/debug": ["@prisma/debug@6.10.1", "", {}, "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA=="], + + "@prisma/engines": ["@prisma/engines@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/fetch-engine": "6.10.1", "@prisma/get-platform": "6.10.1" } }, "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A=="], + + "@prisma/engines-version": ["@prisma/engines-version@6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "", {}, "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1", "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", "@prisma/get-platform": "6.10.1" } }, "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.10.1", "", { "dependencies": { "@prisma/debug": "6.10.1" } }, "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + + "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + + "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.35", "", {}, "sha512-C6ypdODf2VZkgRT6sFM8E1F8vR+HcffniX0Kp8MsU8PIfrlXbNCBz0jzj17GjdmjTx1OtZzdH8+iALL21UjF5A=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], @@ -31,6 +56,8 @@ "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], @@ -67,6 +94,8 @@ "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -75,16 +104,24 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "prisma": ["prisma@6.10.1", "", { "dependencies": { "@prisma/config": "6.10.1", "@prisma/engines": "6.10.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -107,6 +144,8 @@ "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="], @@ -118,5 +157,13 @@ "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], + + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], } } diff --git a/package.json b/package.json index 1e60494..e84e5a8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@elysiajs/cors": "^1.3.3", + "@elysiajs/swagger": "^1.3.0", "@prisma/client": "^6.10.1", "elysia": "latest", "envalid": "^8.0.0", diff --git a/routes/bed.ts b/routes/history.ts similarity index 100% rename from routes/bed.ts rename to routes/history.ts diff --git a/routes/state.ts b/routes/state.ts new file mode 100644 index 0000000..82285c3 --- /dev/null +++ b/routes/state.ts @@ -0,0 +1,172 @@ +import { Elysia, t } from "elysia"; +import { StateManager } from '../services/StateManager'; +import { BedService } from '../services/BedService'; +import { WebSocketMessage, StateUpdateEvent } from '../types/FrontendState'; + +// Define WebSocket client type +interface WSClient { + id: string; + send: (message: string) => void; + readyState: number; +} + +// Singleton instances +let stateManager: StateManager | null = null; +let bedService: BedService | null = null; +const clients = new Map(); + +// Initialize services +async function initializeServices() { + if (!bedService) { + bedService = new BedService(); + await bedService.initialize(); + } + + if (!stateManager) { + stateManager = new StateManager(bedService); + await stateManager.initializeState(); + + // Listen for state updates and broadcast to clients + stateManager.on('stateUpdate', (event: StateUpdateEvent) => { + broadcastStateUpdate(event); + }); + } +} + +// Broadcast state updates to all connected clients +function broadcastStateUpdate(event: StateUpdateEvent) { + const message: WebSocketMessage = { + type: 'STATE_UPDATE', + payload: event, + timestamp: new Date() + }; + + const messageStr = JSON.stringify(message); + const deadClients: string[] = []; + + clients.forEach((ws, id) => { + try { + if (ws.readyState === 1) { // WebSocket.OPEN + ws.send(messageStr); + } else { + deadClients.push(id); + } + } catch (error) { + console.error('Failed to send message to client:', error); + deadClients.push(id); + } + }); + + // Clean up dead clients + deadClients.forEach(id => { + clients.delete(id); + }); + + // Update connection count + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } +} + +// Send current state to a specific client +async function sendCurrentState(ws: WSClient) { + try { + await initializeServices(); + + if (stateManager) { + const state = stateManager.getState(); + const message: WebSocketMessage = { + type: 'STATE_UPDATE', + payload: { + type: 'FULL_STATE', + timestamp: new Date(), + data: state + }, + timestamp: new Date() + }; + + ws.send(JSON.stringify(message)); + } + } catch (error) { + console.error('Failed to send current state:', error); + } +} + +const stateRouter = new Elysia() + .ws('/ws', { + body: t.Object({ + type: t.Union([ + t.Literal('ACKNOWLEDGE_ALERT'), + t.Literal('SILENCE_ALERT'), + t.Literal('HEARTBEAT') + ]), + payload: t.Optional(t.Object({ + alertId: t.Optional(t.String()) + })) + }), + + open: async (ws) => { + console.log('WebSocket client connected:', ws.id); + clients.set(ws.id, ws); + + // Send current state to new client + await sendCurrentState(ws); + + // Update connection count + await initializeServices(); + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } + }, + + message: async (ws, message) => { + try { + await initializeServices(); + + switch (message.type) { + case 'ACKNOWLEDGE_ALERT': + if (message.payload?.alertId && stateManager) { + await stateManager.acknowledgeAlert(message.payload.alertId); + } + break; + + case 'SILENCE_ALERT': + if (message.payload?.alertId && stateManager) { + await stateManager.silenceAlert(message.payload.alertId); + } + break; + + case 'HEARTBEAT': + // Respond to heartbeat + ws.send(JSON.stringify({ + type: 'HEARTBEAT', + payload: { message: 'pong' }, + timestamp: new Date() + })); + break; + + default: + console.warn('Unknown WebSocket message type:', message.type); + } + } catch (error) { + console.error('Error handling client message:', error); + ws.send(JSON.stringify({ + type: 'ERROR', + payload: { message: 'Invalid message format' }, + timestamp: new Date() + })); + } + }, + + close: (ws) => { + console.log('WebSocket client disconnected:', ws.id); + clients.delete(ws.id); + + // Update connection count + if (stateManager) { + stateManager.updateConnectionCount(clients.size); + } + } + }); + +export default stateRouter; \ No newline at end of file diff --git a/routes/swagger.ts b/routes/swagger.ts new file mode 100644 index 0000000..4bcca0e --- /dev/null +++ b/routes/swagger.ts @@ -0,0 +1,19 @@ +import swagger from "@elysiajs/swagger"; +import Elysia from "elysia"; + +const swaggerElysia = new Elysia() +swaggerElysia.use(swagger({ + path: '/api/docs', + documentation: { + info: { + title: "Siwat System API Template", + description: "API documentation", + version: "1.0.0", + }, + tags: [ + // Define your tags here + ], + }, +})) + +export default swaggerElysia; \ No newline at end of file diff --git a/services/AlarmManagement.ts b/services/AlarmManagement.ts new file mode 100644 index 0000000..c608ef3 --- /dev/null +++ b/services/AlarmManagement.ts @@ -0,0 +1,148 @@ +import { EventEmitter } from 'events'; +import { AlarmStateStore, VolatileAlert } from '../store/AlarmStateStore'; +import { MeasurementPointState } from '../types/FrontendState'; + +export class AlarmManagement extends EventEmitter { + private alarmStore: AlarmStateStore; + private measurementPoints: Map = new Map(); + + constructor() { + super(); + this.alarmStore = new AlarmStateStore(); + this.setupEventListeners(); + } + + private setupEventListeners(): void { + // Forward alarm store events + this.alarmStore.on('alertCreated', (alert: VolatileAlert) => { + this.emit('alertCreated', alert); + }); + + this.alarmStore.on('alertUpdated', (alert: VolatileAlert) => { + this.emit('alertUpdated', alert); + }); + + this.alarmStore.on('alertRemoved', (alert: VolatileAlert) => { + this.emit('alertRemoved', alert); + }); + } + + // Update measurement points for reference + updateMeasurementPoints(measurementPoints: Record): void { + this.measurementPoints.clear(); + Object.values(measurementPoints).forEach(mp => { + this.measurementPoints.set(mp.id, mp); + }); + } + + // Process sensor reading and check for alerts + processSensorReading(sensorId: string, value: number, timestamp: Date): void { + // Find measurement point by sensorId + const measurementPoint = Array.from(this.measurementPoints.values()) + .find(mp => mp.sensorId === sensorId); + + if (!measurementPoint) { + console.warn(`No measurement point found for sensor: ${sensorId}`); + return; + } + + this.checkAlerts(measurementPoint, value); + } + + private checkAlerts(measurementPoint: MeasurementPointState, value: number): void { + const { id: pointId, warningThreshold, alarmThreshold, warningDelayMs, label, zone } = measurementPoint; + + // Check if value exceeds alarm threshold (immediate alarm) + if (value >= alarmThreshold) { + this.alarmStore.clearWarningTimer(pointId); + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'ALARM', + value, + alarmThreshold, + label, + zone + ); + return; + } + + // Check if value exceeds warning threshold + if (value >= warningThreshold) { + const existingAlert = this.alarmStore.getAlertByMeasurementPointId(pointId); + + if (!existingAlert) { + // Create warning alert + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'WARNING', + value, + warningThreshold, + label, + zone + ); + + // Set timer for warning to escalate to alarm + const timer = setTimeout(() => { + this.alarmStore.createAlert( + pointId, + measurementPoint.sensorId, + 'ALARM', + value, + warningThreshold, + label, + zone + ); + }, warningDelayMs); + + this.alarmStore.setWarningTimer(pointId, timer); + } + } else { + // Value is below warning threshold, clear any alerts for this point + this.alarmStore.clearWarningTimer(pointId); + this.alarmStore.removeAlertsByMeasurementPointId(pointId); + } + } + + // Get all active alerts + getActiveAlerts(): VolatileAlert[] { + return this.alarmStore.getAllAlerts(); + } + + // Get alerts in frontend state format + getAlertStates(): Record { + return this.alarmStore.toAlertStates(); + } + + // Acknowledge alert + acknowledgeAlert(alertId: string): boolean { + return this.alarmStore.acknowledgeAlert(alertId); + } + + // Silence alert + silenceAlert(alertId: string): boolean { + return this.alarmStore.silenceAlert(alertId); + } + + // Get alert by ID + getAlert(alertId: string): VolatileAlert | undefined { + return this.alarmStore.getAlert(alertId); + } + + // Get statistics + getStats() { + return this.alarmStore.getStats(); + } + + // Clear all alerts (for testing/reset) + clearAllAlerts(): void { + this.alarmStore.clearAll(); + } + + // Cleanup + cleanup(): void { + this.alarmStore.clearAll(); + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/services/BedService.ts b/services/BedService.ts new file mode 100644 index 0000000..1c02c31 --- /dev/null +++ b/services/BedService.ts @@ -0,0 +1,228 @@ +import { PrismaClient, type MeasurementPoint } from '../generated/prisma'; +import { EventEmitter } from 'events'; +import MQTT from '../adapter/mqtt'; +import { getMQTTClient, BASE_TOPIC } from './mqttService'; +import { AlarmManagement } from './AlarmManagement'; +import { VolatileAlert } from '../store/AlarmStateStore'; + +export interface SensorReading { + sensorId: string; + value: number; + timestamp: Date; +} + +export interface AlertConfig { + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; +} + +export class BedService extends EventEmitter { + private prisma: PrismaClient; + private mqtt: MQTT | null = null; + private alarmManagement: AlarmManagement; + private baseTopic = `${BASE_TOPIC}pressure`; + + constructor() { + super(); + this.prisma = new PrismaClient(); + this.alarmManagement = new AlarmManagement(); + this.setupAlarmEventListeners(); + } + + private setupAlarmEventListeners(): void { + // Forward alarm events + this.alarmManagement.on('alertCreated', (alert: VolatileAlert) => { + this.emit('alert', alert); + }); + + this.alarmManagement.on('alertUpdated', (alert: VolatileAlert) => { + this.emit('alert', alert); + }); + + this.alarmManagement.on('alertRemoved', (alert: VolatileAlert) => { + this.emit('alertRemoved', alert); + }); + } + + async initialize(mqttConfig?: { host?: string; port?: number; username?: string; password?: string }): Promise { + try { + // Use mqttService to get initialized MQTT client + this.mqtt = await getMQTTClient(mqttConfig); + + // Subscribe to sensor data topic + await this.mqtt.subscribe(`${this.baseTopic}/+/data`, (topic, message) => { + this.handleSensorData(topic, message); + }); + + console.log('BedService initialized successfully'); + this.emit('initialized'); + } catch (error) { + console.error('Failed to initialize BedService:', error); + throw error; + } + } + + private handleSensorData(topic: string, message: string): void { + try { + // Extract sensor ID from topic: bed/pressure/{sensorId}/data + const sensorId = topic.split('/')[2]; + const data = JSON.parse(message); + + const reading: SensorReading = { + sensorId, + value: data.value, + timestamp: new Date(data.timestamp || Date.now()) + }; + + this.processSensorReading(reading); + } catch (error) { + console.error('Error processing MQTT sensor data:', error); + this.emit('error', error); + } + } + private async processSensorReading(reading: SensorReading): Promise { + try { + // Find measurement point + const measurementPoint = await this.prisma.measurementPoint.findUnique({ + where: { sensorId: reading.sensorId } + }); + + if (!measurementPoint) { + console.warn(`Unknown sensor ID: ${reading.sensorId}`); + return; + } + + // Store sensor data + await this.prisma.measurementPointData.create({ + data: { + measurementPointId: measurementPoint.id, + value: reading.value, + timestamp: reading.timestamp, + time: reading.timestamp.toISOString() + } + }); + + // Let alarm management handle alerts + this.alarmManagement.processSensorReading(reading.sensorId, reading.value, reading.timestamp); + + this.emit('sensorReading', reading); + } catch (error) { + console.error('Error processing sensor reading:', error); + this.emit('error', error); + } + } + // Public API methods + async createMeasurementPoint(data: { + sensorId: string; + label: string; + zone: string; + x: number; + y: number; + pin: number; + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; + }): Promise { + return this.prisma.measurementPoint.create({ data }); + } + async getMeasurementPoints(): Promise { + const points = await this.prisma.measurementPoint.findMany({ + orderBy: { zone: 'asc' } + }); + + // Update alarm management with current measurement points + const pointsRecord: Record = {}; + + points.forEach(point => { + pointsRecord[point.id] = { + id: point.id, + sensorId: point.sensorId, + label: point.label, + zone: point.zone, x: point.x, + y: point.y, + pin: point.pin, + warningThreshold: point.warningThreshold, + alarmThreshold: point.alarmThreshold, + warningDelayMs: point.warningDelayMs, + currentValue: 0, + lastUpdateTime: new Date(), + status: 'offline' as const + }; + }); + + this.alarmManagement.updateMeasurementPoints(pointsRecord); + return points; + } + + async getMeasurementPointData(sensorId: string, limit = 100) { + const measurementPoint = await this.prisma.measurementPoint.findUnique({ + where: { sensorId } + }); + + if (!measurementPoint) { + throw new Error(`Measurement point not found for sensor: ${sensorId}`); + } + + return this.prisma.measurementPointData.findMany({ + where: { measurementPointId: measurementPoint.id }, + orderBy: { timestamp: 'desc' }, + take: limit + }); + } + + // Get active alerts from alarm management (volatile) + getActiveAlerts(): VolatileAlert[] { + return this.alarmManagement.getActiveAlerts(); + } + + // Acknowledge alert in volatile store + acknowledgeAlert(alertId: string): boolean { + return this.alarmManagement.acknowledgeAlert(alertId); + } + + // Silence alert in volatile store + silenceAlert(alertId: string): boolean { + return this.alarmManagement.silenceAlert(alertId); + } + + async updateAlertConfig(sensorId: string, config: AlertConfig): Promise { + return this.prisma.measurementPoint.update({ + where: { sensorId }, + data: { + warningThreshold: config.warningThreshold, + alarmThreshold: config.alarmThreshold, + warningDelayMs: config.warningDelayMs + } + }); + } + + async disconnect(): Promise { + // Cleanup alarm management + this.alarmManagement.cleanup(); + + // Disconnect MQTT + if (this.mqtt) { + await this.mqtt.disconnect(); + } + + // Disconnect Prisma + await this.prisma.$disconnect(); + + this.emit('disconnected'); + } +} \ No newline at end of file diff --git a/services/StateManager.ts b/services/StateManager.ts new file mode 100644 index 0000000..abd6029 --- /dev/null +++ b/services/StateManager.ts @@ -0,0 +1,232 @@ +import { EventEmitter } from 'events'; +import { FrontendState, MeasurementPointState, AlertState, SystemStatus, StateUpdateEvent } from '../types/FrontendState'; +import { BedService } from './BedService'; +import { VolatileAlert } from '../store/AlarmStateStore'; +import { PrismaClient } from '../generated/prisma'; + +export class StateManager extends EventEmitter { + private state: FrontendState; + private prisma: PrismaClient; + + constructor(private bedService: BedService) { + super(); + this.prisma = new PrismaClient(); // Initialize empty state + this.state = { + measurementPoints: {}, + alerts: {}, + system: { + mqttConnected: false, + databaseConnected: false, + lastHeartbeat: new Date(), + activeConnections: 0, + totalMeasurementPoints: 0, + activeSensors: 0 + } + }; + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + // Listen to BedService events + this.bedService.on('sensorReading', (reading) => { + this.updateSensorReading(reading.sensorId, reading.value, reading.timestamp); + }); + + this.bedService.on('alert', (alert) => { + this.updateAlert(alert); + }); + + this.bedService.on('initialized', () => { + this.updateSystemStatus({ mqttConnected: true }); + }); + + this.bedService.on('disconnected', () => { + this.updateSystemStatus({ mqttConnected: false }); + }); + } + + // Get current state (read-only) + getState(): Readonly { + return { ...this.state }; + } + + // Initialize state from database + async initializeState(): Promise { + try { // Load measurement points + const measurementPoints = await this.bedService.getMeasurementPoints(); + const measurementPointStates: Record = {}; + + for (const mp of measurementPoints) { measurementPointStates[mp.id] = { + id: mp.id, + sensorId: mp.sensorId, + label: mp.label, + zone: mp.zone, + x: mp.x ?? 0, + y: mp.y ?? 0, + pin: mp.pin ?? 0, + currentValue: 0, + lastUpdateTime: new Date(), + warningThreshold: mp.warningThreshold, + alarmThreshold: mp.alarmThreshold, + warningDelayMs: mp.warningDelayMs, + status: 'offline' + }; + } // Load active alerts + const alerts = await this.bedService.getActiveAlerts(); + const alertStates: Record = {}; + + for (const alert of alerts) { + const measurementPoint = measurementPointStates[alert.measurementPointId]; + alertStates[alert.id] = { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: alert.endTime ?? undefined, + sensorLabel: measurementPoint?.label || 'Unknown', + zone: measurementPoint?.zone || 'Unknown' + }; + } // Update state + this.state.measurementPoints = measurementPointStates; + this.state.alerts = alertStates; + this.state.system.totalMeasurementPoints = measurementPoints.length; + this.state.system.databaseConnected = true; + + this.emitStateUpdate('FULL_STATE', this.state); + } catch (error) { + console.error('Failed to initialize state:', error); + this.state.system.databaseConnected = false; + } + } + + // Update sensor reading + updateSensorReading(sensorId: string, value: number, timestamp: Date): void { + // Find measurement point by sensorId + const measurementPoint = Object.values(this.state.measurementPoints) + .find(mp => mp.sensorId === sensorId); + + if (!measurementPoint) return; + + // Determine status based on thresholds + let status: 'normal' | 'warning' | 'alarm' | 'offline' = 'normal'; + if (value >= measurementPoint.alarmThreshold) { + status = 'alarm'; + } else if (value >= measurementPoint.warningThreshold) { + status = 'warning'; + } // Update measurement point state + this.state.measurementPoints[measurementPoint.id] = { + ...measurementPoint, + currentValue: value, + lastUpdateTime: timestamp, + status + }; + + // Update system stats + this.updateActiveSensors(); + + this.emitStateUpdate('SENSOR_UPDATE', this.state.measurementPoints[measurementPoint.id]); + } + + // Update alert + updateAlert(alert: Alert & { measurementPoint: MeasurementPoint }): void { + const alertState: AlertState = { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: alert.endTime || undefined, + sensorLabel: alert.measurementPoint.label, + zone: alert.measurementPoint.zone + }; this.state.alerts[alert.id] = alertState; + + this.emitStateUpdate('ALERT_UPDATE', alertState); + } + // Remove alert (when closed) + removeAlert(alertId: string): void { + if (this.state.alerts[alertId]) { + delete this.state.alerts[alertId]; + this.emitStateUpdate('PARTIAL_UPDATE', { alerts: this.state.alerts }); + } + } + + // Update system status + updateSystemStatus(updates: Partial): void { + this.state.system = { + ...this.state.system, + ...updates, + lastHeartbeat: new Date() + }; + + this.emitStateUpdate('SYSTEM_UPDATE', this.state.system); + } + + // Update active sensors count + private updateActiveSensors(): void { + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + + const activeSensors = Object.values(this.state.measurementPoints) + .filter(mp => mp.lastUpdateTime > fiveMinutesAgo).length; + + this.state.system.activeSensors = activeSensors; + } + + // Update connection count (for WebSocket clients) + updateConnectionCount(count: number): void { + this.state.system.activeConnections = count; + this.emitStateUpdate('SYSTEM_UPDATE', this.state.system); + } + + // Acknowledge alert + async acknowledgeAlert(alertId: string): Promise { + try { + await this.bedService.acknowledgeAlert(alertId); + + if (this.state.alerts[alertId]) { + this.state.alerts[alertId].acknowledged = true; + this.emitStateUpdate('ALERT_UPDATE', this.state.alerts[alertId]); + } + } catch (error) { + console.error('Failed to acknowledge alert:', error); + } + } + + // Silence alert + async silenceAlert(alertId: string): Promise { + try { + await this.bedService.silenceAlert(alertId); + + if (this.state.alerts[alertId]) { + this.state.alerts[alertId].silenced = true; + this.emitStateUpdate('ALERT_UPDATE', this.state.alerts[alertId]); + } + } catch (error) { + console.error('Failed to silence alert:', error); + } + } + // Emit state update event + private emitStateUpdate(type: StateUpdateEvent['type'], data: StateUpdateEvent['data']): void { + const event: StateUpdateEvent = { + type, + timestamp: new Date(), + data + }; + + this.emit('stateUpdate', event); + } + + // Cleanup + async disconnect(): Promise { + await this.prisma.$disconnect(); + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/services/mqttService.ts b/services/mqttService.ts new file mode 100644 index 0000000..59c787e --- /dev/null +++ b/services/mqttService.ts @@ -0,0 +1,72 @@ +import MQTT, { MQTTConfig } from '../adapter/mqtt'; + +// Default MQTT configuration for HiveMQ broker +const defaultConfig: MQTTConfig = { + host: 'broker.hivemq.com', + port: 1883, + username: undefined, + password: undefined +}; + +export const BASE_TOPIC = '/Jtkcp2N/pressurebed/'; + +// Singleton MQTT client instance +let mqttInstance: MQTT | null = null; + +export class MQTTService { + private static instance: MQTT | null = null; + + static async initialize(config?: Partial): Promise { + if (!MQTTService.instance) { + const finalConfig = { ...defaultConfig, ...config }; + MQTTService.instance = new MQTT(); + await MQTTService.instance.initialize(finalConfig); + } + return MQTTService.instance; + } + + static getInstance(): MQTT | null { + return MQTTService.instance; + } + + static async disconnect(): Promise { + if (MQTTService.instance) { + await MQTTService.instance.disconnect(); + MQTTService.instance = null; + } + } +} + +// Factory function to get or create MQTT client +export async function getMQTTClient(config?: Partial): Promise { + if (!mqttInstance) { + mqttInstance = new MQTT(); + const finalConfig = { ...defaultConfig, ...config }; + await mqttInstance.initialize(finalConfig); + } + return mqttInstance; +} + +// Export the singleton instance getter +export function getMQTTInstance(): MQTT | null { + return mqttInstance; +} + +// Cleanup function +export async function disconnectMQTT(): Promise { + if (mqttInstance) { + await mqttInstance.disconnect(); + mqttInstance = null; + } +} + +// Export default configured client (lazy initialization) +const mqttService = { + async getClient(config?: Partial): Promise { + return getMQTTClient(config); + }, + getInstance: getMQTTInstance, + disconnect: disconnectMQTT +}; + +export default mqttService; \ No newline at end of file diff --git a/store/AlarmStateStore.ts b/store/AlarmStateStore.ts new file mode 100644 index 0000000..db97394 --- /dev/null +++ b/store/AlarmStateStore.ts @@ -0,0 +1,201 @@ +import { EventEmitter } from 'events'; +import { AlertState } from '../types/FrontendState'; + +export interface VolatileAlert { + id: string; + measurementPointId: string; + sensorId: string; + type: 'WARNING' | 'ALARM'; + value: number; + threshold: number; + acknowledged: boolean; + silenced: boolean; + startTime: Date; + sensorLabel: string; + zone: string; +} + +export class AlarmStateStore extends EventEmitter { + private alerts: Map = new Map(); + private warningTimers: Map = new Map(); + private alertIdCounter = 0; + + // Generate unique alert ID + private generateAlertId(): string { + return `alert_${Date.now()}_${++this.alertIdCounter}`; + } + + // Create a new alert + createAlert( + measurementPointId: string, + sensorId: string, + type: 'WARNING' | 'ALARM', + value: number, + threshold: number, + sensorLabel: string, + zone: string + ): VolatileAlert { + // Check if there's already an active alert for this measurement point + const existingAlert = this.getAlertByMeasurementPointId(measurementPointId); + + if (existingAlert) { + // If upgrading from WARNING to ALARM, update the existing alert + if (existingAlert.type === 'WARNING' && type === 'ALARM') { + existingAlert.type = 'ALARM'; + existingAlert.value = value; + existingAlert.threshold = threshold; + + this.emit('alertUpdated', existingAlert); + return existingAlert; + } + + // If it's the same type or downgrading, return existing + return existingAlert; + } + + const alert: VolatileAlert = { + id: this.generateAlertId(), + measurementPointId, + sensorId, + type, + value, + threshold, + acknowledged: false, + silenced: false, + startTime: new Date(), + sensorLabel, + zone + }; + + this.alerts.set(alert.id, alert); + this.emit('alertCreated', alert); + + return alert; + } + + // Get alert by measurement point ID + getAlertByMeasurementPointId(measurementPointId: string): VolatileAlert | undefined { + return Array.from(this.alerts.values()) + .find(alert => alert.measurementPointId === measurementPointId); + } + + // Remove alert + removeAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + this.alerts.delete(alertId); + this.emit('alertRemoved', alert); + return true; + } + return false; + } + + // Remove alerts by measurement point ID + removeAlertsByMeasurementPointId(measurementPointId: string): void { + const alertsToRemove = Array.from(this.alerts.values()) + .filter(alert => alert.measurementPointId === measurementPointId); + + alertsToRemove.forEach(alert => { + this.alerts.delete(alert.id); + this.emit('alertRemoved', alert); + }); + } + + // Acknowledge alert + acknowledgeAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + alert.acknowledged = true; + this.emit('alertUpdated', alert); + return true; + } + return false; + } + + // Silence alert + silenceAlert(alertId: string): boolean { + const alert = this.alerts.get(alertId); + if (alert) { + alert.silenced = true; + this.emit('alertUpdated', alert); + return true; + } + return false; + } + + // Set warning timer + setWarningTimer(measurementPointId: string, timer: NodeJS.Timeout): void { + // Clear existing timer if any + this.clearWarningTimer(measurementPointId); + this.warningTimers.set(measurementPointId, timer); + } + + // Clear warning timer + clearWarningTimer(measurementPointId: string): void { + const timer = this.warningTimers.get(measurementPointId); + if (timer) { + clearTimeout(timer); + this.warningTimers.delete(measurementPointId); + } + } + + // Get all active alerts + getAllAlerts(): VolatileAlert[] { + return Array.from(this.alerts.values()); + } + + // Get alert by ID + getAlert(alertId: string): VolatileAlert | undefined { + return this.alerts.get(alertId); + } + + // Convert to AlertState format for frontend + toAlertState(alert: VolatileAlert): AlertState { + return { + id: alert.id, + measurementPointId: alert.measurementPointId, + type: alert.type, + value: alert.value, + threshold: alert.threshold, + acknowledged: alert.acknowledged, + silenced: alert.silenced, + startTime: alert.startTime, + endTime: undefined, // Volatile alerts don't have end times + sensorLabel: alert.sensorLabel, + zone: alert.zone + }; + } + + // Convert all alerts to AlertState format + toAlertStates(): Record { + const result: Record = {}; + this.alerts.forEach((alert, id) => { + result[id] = this.toAlertState(alert); + }); + return result; + } + + // Clear all alerts + clearAll(): void { + // Clear all warning timers + this.warningTimers.forEach(timer => clearTimeout(timer)); + this.warningTimers.clear(); + + // Clear all alerts + this.alerts.clear(); + + this.emit('allAlertsCleared'); + } + + // Get statistics + getStats() { + const alerts = this.getAllAlerts(); + return { + total: alerts.length, + warnings: alerts.filter(a => a.type === 'WARNING').length, + alarms: alerts.filter(a => a.type === 'ALARM').length, + acknowledged: alerts.filter(a => a.acknowledged).length, + silenced: alerts.filter(a => a.silenced).length + }; + } +} \ No newline at end of file diff --git a/types/FrontendState.ts b/types/FrontendState.ts new file mode 100644 index 0000000..4740867 --- /dev/null +++ b/types/FrontendState.ts @@ -0,0 +1,63 @@ +export interface MeasurementPointState { + id: string; + sensorId: string; + label: string; + zone: string; + x: number; + y: number; + pin: number; + currentValue: number; + lastUpdateTime: Date; + warningThreshold: number; + alarmThreshold: number; + warningDelayMs: number; + status: 'normal' | 'warning' | 'alarm' | 'offline'; +} + +export interface AlertState { + id: string; + measurementPointId: string; + type: 'WARNING' | 'ALARM'; + value: number; + threshold: number; + acknowledged: boolean; + silenced: boolean; + startTime: Date; + endTime?: Date; + sensorLabel: string; + zone: string; +} + +export interface SystemStatus { + mqttConnected: boolean; + databaseConnected: boolean; + lastHeartbeat: Date; + activeConnections: number; + totalMeasurementPoints: number; + activeSensors: number; +} + +export interface FrontendState { + // Measurement points and sensor data + measurementPoints: Record; + + // Active alerts + alerts: Record; + + // System status + system: SystemStatus; +} + +// State update events +export interface StateUpdateEvent { + type: 'FULL_STATE' | 'PARTIAL_UPDATE' | 'SENSOR_UPDATE' | 'ALERT_UPDATE' | 'SYSTEM_UPDATE'; + timestamp: Date; + data: Partial | MeasurementPointState | AlertState | SystemStatus; +} + +// WebSocket message types +export interface WebSocketMessage { + type: 'STATE_UPDATE' | 'HEARTBEAT' | 'ERROR' | 'ACKNOWLEDGE_ALERT' | 'SILENCE_ALERT'; + payload: StateUpdateEvent | { alertId: string } | { message: string }; + timestamp: Date; +} \ No newline at end of file