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.
This commit is contained in:
Siwat Sirichai 2025-06-21 18:56:34 +07:00
parent a767dc3635
commit 4ae5196ef1
12 changed files with 1189 additions and 1 deletions

7
app.ts
View file

@ -3,6 +3,8 @@ import { cors } from '@elysiajs/cors';
import { createTopicLogger } from '~/utils/logger'; import { createTopicLogger } from '~/utils/logger';
import env from './config/env'; import env from './config/env';
import stateRouter from './routes/state';
import swaggerElysia from './routes/swagger';
async function initialize() { async function initialize() {
const logger = createTopicLogger({ topic: 'Initializer' }); const logger = createTopicLogger({ topic: 'Initializer' });
@ -12,7 +14,6 @@ async function initialize() {
logger.error(`Initialization error: ${error}`); logger.error(`Initialization error: ${error}`);
process.exit(1); process.exit(1);
} }
} }
async function initializeElysia() { async function initializeElysia() {
@ -31,6 +32,10 @@ async function initializeElysia() {
// Core Components // Core Components
.use(cors()) .use(cors())
.use(swaggerElysia)
// State routes (includes WebSocket)
.use(stateRouter)
// Start the server // Start the server
app.listen(PORT); app.listen(PORT);

View file

@ -5,12 +5,15 @@
"name": "elysia", "name": "elysia",
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.3.3", "@elysiajs/cors": "^1.3.3",
"@elysiajs/swagger": "^1.3.0",
"@prisma/client": "^6.10.1",
"elysia": "latest", "elysia": "latest",
"envalid": "^8.0.0", "envalid": "^8.0.0",
"winston": "^3.17.0", "winston": "^3.17.0",
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.2.17", "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/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=="], "@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=="], "@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=="], "@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=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], "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=="], "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=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="],
} }
} }

View file

@ -8,6 +8,7 @@
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.3.3", "@elysiajs/cors": "^1.3.3",
"@elysiajs/swagger": "^1.3.0",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.10.1",
"elysia": "latest", "elysia": "latest",
"envalid": "^8.0.0", "envalid": "^8.0.0",

172
routes/state.ts Normal file
View file

@ -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<string, WSClient>();
// 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;

19
routes/swagger.ts Normal file
View file

@ -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;

148
services/AlarmManagement.ts Normal file
View file

@ -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<string, MeasurementPointState> = 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<string, MeasurementPointState>): 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<string, import('../types/FrontendState').AlertState> {
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();
}
}

228
services/BedService.ts Normal file
View file

@ -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<void> {
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<void> {
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<MeasurementPoint> {
return this.prisma.measurementPoint.create({ data });
}
async getMeasurementPoints(): Promise<MeasurementPoint[]> {
const points = await this.prisma.measurementPoint.findMany({
orderBy: { zone: 'asc' }
});
// Update alarm management with current measurement points
const pointsRecord: Record<string, {
id: string;
sensorId: string;
label: string;
zone: string;
x: number;
y: number;
pin: number;
warningThreshold: number;
alarmThreshold: number;
warningDelayMs: number;
currentValue: number;
lastUpdateTime: Date;
status: 'offline';
}> = {};
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<MeasurementPoint> {
return this.prisma.measurementPoint.update({
where: { sensorId },
data: {
warningThreshold: config.warningThreshold,
alarmThreshold: config.alarmThreshold,
warningDelayMs: config.warningDelayMs
}
});
}
async disconnect(): Promise<void> {
// Cleanup alarm management
this.alarmManagement.cleanup();
// Disconnect MQTT
if (this.mqtt) {
await this.mqtt.disconnect();
}
// Disconnect Prisma
await this.prisma.$disconnect();
this.emit('disconnected');
}
}

232
services/StateManager.ts Normal file
View file

@ -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<FrontendState> {
return { ...this.state };
}
// Initialize state from database
async initializeState(): Promise<void> {
try { // Load measurement points
const measurementPoints = await this.bedService.getMeasurementPoints();
const measurementPointStates: Record<string, MeasurementPointState> = {};
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<string, AlertState> = {};
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<SystemStatus>): 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<void> {
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<void> {
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<void> {
await this.prisma.$disconnect();
this.removeAllListeners();
}
}

72
services/mqttService.ts Normal file
View file

@ -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<MQTTConfig>): Promise<MQTT> {
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<void> {
if (MQTTService.instance) {
await MQTTService.instance.disconnect();
MQTTService.instance = null;
}
}
}
// Factory function to get or create MQTT client
export async function getMQTTClient(config?: Partial<MQTTConfig>): Promise<MQTT> {
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<void> {
if (mqttInstance) {
await mqttInstance.disconnect();
mqttInstance = null;
}
}
// Export default configured client (lazy initialization)
const mqttService = {
async getClient(config?: Partial<MQTTConfig>): Promise<MQTT> {
return getMQTTClient(config);
},
getInstance: getMQTTInstance,
disconnect: disconnectMQTT
};
export default mqttService;

201
store/AlarmStateStore.ts Normal file
View file

@ -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<string, VolatileAlert> = new Map();
private warningTimers: Map<string, NodeJS.Timeout> = 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<string, AlertState> {
const result: Record<string, AlertState> = {};
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
};
}
}

63
types/FrontendState.ts Normal file
View file

@ -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<string, MeasurementPointState>;
// Active alerts
alerts: Record<string, AlertState>;
// 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<FrontendState> | 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;
}