diff --git a/.env b/.env new file mode 100644 index 0000000..45b2f31 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +DATABASE_URL="file:./data.db" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 87e5610..5d992ce 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,11 @@ yarn-error.log* **/*.tgz **/*.log package-lock.json -**/*.bun \ No newline at end of file +**/*.bun +/generated/prisma + +/generated/prisma + +/generated/prisma + +/data.db \ No newline at end of file diff --git a/adapter/mqtt.ts b/adapter/mqtt.ts new file mode 100644 index 0000000..93cf1e5 --- /dev/null +++ b/adapter/mqtt.ts @@ -0,0 +1,160 @@ +import mqtt, { type MqttClient } from "mqtt"; +import type { QoS } from "mqtt-packet"; + +export interface MQTTConfig { + host: string; + port: number; + username?: string; + password?: string; +} + +interface MQTTSubscription { + topic: string; + callback: (topic: string, message: string) => void; +} + +export interface MQTTMessage { + topic: string; + payload: string; + qos?: QoS; + retain?: boolean; +} + +class MQTT { + private client: MqttClient | null = null; + private config: MQTTConfig | null = null; + private readonly subscriptions: MQTTSubscription[] = []; + private isConnected: boolean = false; + private keepAliveTimer: NodeJS.Timeout | null = null; + + async keepAlive(): Promise { + if (this.isConnected) { + return; + } + console.log("MQTT client is not connected, attempting to reconnect..."); + await this.connectMQTT(); + } + + async initialize(config: MQTTConfig): Promise { + this.config = config; + this.connectMQTT(); + + // Start keep-alive timer + this.keepAliveTimer = setInterval(() => { + this.keepAlive(); + }, 5000); // Run every 5 seconds + } + + async connectMQTT(): Promise { + if (!this.config) { + throw new Error("MQTT configuration is not set."); + } + try { + this.client = mqtt.connect(`mqtt://${this.config.host}:${this.config.port}`, { + username: this.config?.username, + password: this.config?.password + }); + await this.setupHandler(); + } catch (error) { + console.error("Failed to connect to MQTT broker:", error); + } + } + + async publish(message: MQTTMessage): Promise { + if (!this.client || !this.isConnected) { + console.error("MQTT client is not connected, cannot publish message."); + return; + } + + const { topic, payload, qos = 0, retain = false } = message; + try { + await this.client.publishAsync(topic, payload, { qos, retain }); + } catch (error) { + console.error(`Failed to publish message to ${topic}:`, error); + } + } + + async subscribe(topic: string, callback: (topic: string, message: string) => void): Promise { + console.log(`Subscribing to topic: ${topic}`); + const subscription: MQTTSubscription = { topic, callback }; + this.subscriptions.push(subscription); + + if (this.client && this.isConnected) { + try { + await this.client.subscribeAsync(topic); + } catch (error) { + console.error(`Failed to subscribe to ${topic}:`, error); + } + } + } + + async setupHandler(): Promise { + if (!this.client) { + console.error("MQTT client is not initialized."); + return; + } + + this.client.on("connect", this.onConnect.bind(this)); + this.client.on("message", this.onMessage.bind(this)); + this.client.on("disconnect", this.onDisconnect.bind(this)); + } + + async disconnect(): Promise { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + + if (this.client) { + await this.client.endAsync(); + this.client = null; + } + this.isConnected = false; + } + + private async onConnect(): Promise { + console.log("MQTT connected successfully."); + for (const sub of this.subscriptions) { + if (this.client) { + try { + await this.client.subscribeAsync(sub.topic); + } catch (error) { + console.error(`Failed to subscribe to ${sub.topic}:`, error); + } + } + } + this.isConnected = true; + } + + private onDisconnect(): void { + console.log("MQTT disconnected."); + this.isConnected = false; + } + + private onMessage(topic: string, message: Buffer): void { + const msg = message.toString(); + console.log(`Received message on topic ${topic}: ${msg}`); + this.subscriptions.forEach(sub => { + if (this.matchTopic(sub.topic, topic)) { + sub.callback(topic, msg); + } + }); + } + + private matchTopic(subscriptionTopic: string, publishTopic: string): boolean { + // Exact match + if (subscriptionTopic === publishTopic) { + return true; + } + + // Convert MQTT wildcards to regex + const regexPattern = subscriptionTopic + .replace(/\+/g, '[^/]+') // + matches single level + .replace(/#$/, '.*'); // # matches multi-level (only at end) + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(publishTopic); + } +} + +export default MQTT; \ No newline at end of file diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..d197cac --- /dev/null +++ b/app.ts @@ -0,0 +1,39 @@ +import { Elysia } from 'elysia'; +import { cors } from '@elysiajs/cors'; + +import { createTopicLogger } from '~/utils/logger'; +import env from './config/env'; + +async function initialize() { + const logger = createTopicLogger({ topic: 'Initializer' }); + try { + await initializeElysia(); + } catch (error) { + logger.error(`Initialization error: ${error}`); + process.exit(1); + } + +} + +async function initializeElysia() { + const logger = createTopicLogger({ topic: 'Elysia' }); + const PORT = env.BACKEND_PORT; + + // Instantiate and configure Elysia + const app = new Elysia() + // Logging Messages + .onStart(() => { + logger.info(`API server starting on port ${PORT}`); + }) + .onStop(() => { + logger.info('API server shutting down'); + }) + + // Core Components + .use(cors()) + + // Start the server + app.listen(PORT); +} + +initialize(); \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2e8e5ca --- /dev/null +++ b/bun.lock @@ -0,0 +1,122 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "elysia", + "dependencies": { + "@elysiajs/cors": "^1.3.3", + "elysia": "latest", + "envalid": "^8.0.0", + "winston": "^3.17.0", + }, + "devDependencies": { + "bun-types": "^1.2.17", + }, + }, + }, + "packages": { + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="], + + "@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/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + + "color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], + + "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "elysia": ["elysia@1.3.5", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-XVIKXlKFwUT7Sta8GY+wO5reD9I0rqAEtaz1Z71UgJb61csYt8Q3W9al8rtL5RgumuRR8e3DNdzlUN9GkC4KDw=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "envalid": ["envalid@8.0.0", "", { "dependencies": { "tslib": "2.6.2" } }, "sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ=="], + + "exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "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=="], + + "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=="], + + "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-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strtok3": ["strtok3@10.3.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-3JWEZM6mfix/GCJBBUrkA8p2Id2pBkyTkVCJKto55w080QBKZ+8R171fGrbiSp+yMO/u6F8/yUh7K4V9K+YCnw=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + + "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=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "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=="], + } +} diff --git a/config/env.ts b/config/env.ts new file mode 100644 index 0000000..213028c --- /dev/null +++ b/config/env.ts @@ -0,0 +1,58 @@ +import { cleanEnv, str, bool, port, url, host } from "envalid"; + +const env = cleanEnv(process.env, { + BACKEND_PORT: port({ + desc: "The port for the backend server", + default: 3000, + }), + + DATABASE_URL: url({ + desc: "The URL for the database connection", + }), + + // Redis configuration + REDIS_HOST: host({ + desc: "The host for the Redis server", + default: "localhost", + }), + REDIS_PORT: port({ + desc: "The port for the Redis server", + default: 6379, + }), + REDIS_PASSWORD: str({ + desc: "The password for the Redis server", + }), + + // S3 configuration + S3_ENDPOINT: host({ + desc: "The endpoint for the S3 service", + default: "localhost", + }), + S3_BUCKET: str({ + desc: "The name of the S3 bucket", + default: "my-bucket", + }), + S3_PORT: port({ + desc: "The port for the S3 service", + default: 9000, + }), + S3_USE_SSL: bool({ + desc: "Use SSL for S3 service", + default: false, + }), + S3_ACCESS_KEY: str({ + desc: "Access key for the S3 service", + }), + S3_SECRET_KEY: str({ + desc: "Secret key for the S3 service", + }), + + // Log Level configuration + LOG_LEVEL: str({ + desc: "The log level for the application", + choices: ["debug", "info", "warn", "error", "silent"], + default: "info", + }), +}); + +export default env; \ No newline at end of file diff --git a/package.json b/package.json index 1e60d34..1e60494 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,19 @@ "version": "1.0.50", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun run --watch app.ts", + "start": "bun run app.ts" }, "dependencies": { - "elysia": "latest" + "@elysiajs/cors": "^1.3.3", + "@prisma/client": "^6.10.1", + "elysia": "latest", + "envalid": "^8.0.0", + "winston": "^3.17.0" }, "devDependencies": { - "bun-types": "latest" + "bun-types": "^1.2.17", + "prisma": "^6.10.1" }, "module": "src/index.js" -} \ No newline at end of file +} diff --git a/prisma/client.ts b/prisma/client.ts new file mode 100644 index 0000000..4ef0690 --- /dev/null +++ b/prisma/client.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "~/generated/prisma"; + +const db = new PrismaClient() + +export default db; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d00de9a --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,81 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model MeasurementPoint { + id String @id @default(cuid()) + sensorId String @unique // e.g., "head-1", "back-2" + label String // e.g., "Head Left", "Upper Back Center" + zone String // e.g., "head", "back", "shoulders" + x Int // X coordinate on bed layout + y Int // Y coordinate on bed layout + pin Int // Hardware pin number + + // Threshold configuration + warningThreshold Int // Pressure value that triggers warning + alarmThreshold Int // Pressure value that triggers alarm + warningDelayMs Int // Delay before warning escalates to alarm + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + data MeasurementPointData[] + alerts Alert[] +} + +model MeasurementPointData { + id String @id @default(cuid()) + measurementPointId String + + // Sensor reading data + value Int // Analog sensor value (0-4095) + + // Timestamps + timestamp DateTime @default(now()) + time String // Formatted time string + + // Relations + measurementPoint MeasurementPoint @relation(fields: [measurementPointId], references: [id], onDelete: Cascade) + + @@index([measurementPointId, timestamp]) +} + +enum AlertType { + WARNING + ALARM +} + +model Alert { + id String @id @default(cuid()) + measurementPointId String + + // Alert details + type AlertType + value Int // Sensor value that triggered alert + threshold Int // Threshold that was exceeded + + // Alert state + acknowledged Boolean @default(false) + silenced Boolean @default(false) + + // Timing + startTime DateTime @default(now()) + endTime DateTime? + + // Relations + measurementPoint MeasurementPoint @relation(fields: [measurementPointId], references: [id], onDelete: Cascade) + + @@index([measurementPointId, startTime]) + @@index([type, acknowledged]) +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..1e33e90 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,219 @@ +import db from "./client"; + +async function main() { + console.log('Seeding MeasurementPoints...'); + + // Delete existing measurement points + await db.measurementPoint.deleteMany(); + + // Create measurement points based on API sensor configuration + const measurementPoints = [ + // Head area - Higher thresholds due to critical nature, faster escalation + { + sensorId: "head-1", + label: "Head Left", + zone: "head", + x: 45, + y: 15, + pin: 2, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000 // 30 seconds + }, + { + sensorId: "head-2", + label: "Head Right", + zone: "head", + x: 55, + y: 15, + pin: 3, + warningThreshold: 3000, + alarmThreshold: 3500, + warningDelayMs: 30000 // 30 seconds + }, + + // Shoulder area - Moderate thresholds, medium escalation time + { + sensorId: "shoulder-1", + label: "Left Shoulder", + zone: "shoulders", + x: 35, + y: 25, + pin: 4, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000 // 45 seconds + }, + { + sensorId: "shoulder-2", + label: "Right Shoulder", + zone: "shoulders", + x: 65, + y: 25, + pin: 5, + warningThreshold: 2800, + alarmThreshold: 3200, + warningDelayMs: 45000 // 45 seconds + }, + + // Upper back - Moderate thresholds, 1 minute escalation + { + sensorId: "back-1", + label: "Upper Back Left", + zone: "back", + x: 40, + y: 35, + pin: 6, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + { + sensorId: "back-2", + label: "Upper Back Center", + zone: "back", + x: 50, + y: 35, + pin: 7, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + { + sensorId: "back-3", + label: "Upper Back Right", + zone: "back", + x: 60, + y: 35, + pin: 8, + warningThreshold: 2500, + alarmThreshold: 3000, + warningDelayMs: 60000 // 1 minute + }, + + // Lower back/Hip area - Lower thresholds, 90 second escalation + { + sensorId: "hip-1", + label: "Left Hip", + zone: "hips", + x: 35, + y: 50, + pin: 9, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + { + sensorId: "hip-2", + label: "Lower Back", + zone: "hips", + x: 50, + y: 50, + pin: 10, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + { + sensorId: "hip-3", + label: "Right Hip", + zone: "hips", + x: 65, + y: 50, + pin: 11, + warningThreshold: 2200, + alarmThreshold: 2800, + warningDelayMs: 90000 // 90 seconds + }, + + // Thigh area - Lower thresholds, 2 minute escalation + { + sensorId: "thigh-1", + label: "Left Thigh", + zone: "legs", + x: 40, + y: 65, + pin: 12, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000 // 2 minutes + }, + { + sensorId: "thigh-2", + label: "Right Thigh", + zone: "legs", + x: 60, + y: 65, + pin: 13, + warningThreshold: 2000, + alarmThreshold: 2500, + warningDelayMs: 120000 // 2 minutes + }, + + // Calf area (mock sensors - no pin) - Lower thresholds, 2.5 minute escalation + { + sensorId: "calf-1", + label: "Left Calf", + zone: "legs", + x: 40, + y: 75, + pin: null, + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000 // 2.5 minutes + }, + { + sensorId: "calf-2", + label: "Right Calf", + zone: "legs", + x: 60, + y: 75, + pin: null, + warningThreshold: 1800, + alarmThreshold: 2200, + warningDelayMs: 150000 // 2.5 minutes + }, + + // Feet (mock sensors - no pin) - Lowest thresholds, 3 minute escalation + { + sensorId: "feet-1", + label: "Left Foot", + zone: "feet", + x: 45, + y: 85, + pin: null, + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000 // 3 minutes + }, + { + sensorId: "feet-2", + label: "Right Foot", + zone: "feet", + x: 55, + y: 85, + pin: null, + warningThreshold: 1500, + alarmThreshold: 1800, + warningDelayMs: 180000 // 3 minutes + } + ]; + + // Insert all measurement points + for (const point of measurementPoints) { + await db.measurementPoint.create({ + data: point + }); + } + + console.log(`Created ${measurementPoints.length} measurement points`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await db.$disconnect(); + }); \ No newline at end of file diff --git a/routes/bed.ts b/routes/bed.ts new file mode 100644 index 0000000..a3a495b --- /dev/null +++ b/routes/bed.ts @@ -0,0 +1,5 @@ +import Elysia from "elysia"; + +const bedRouter = new Elysia() + +export default bedRouter; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 9c1f7a1..0000000 --- a/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Elysia } from "elysia"; - -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); - -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); diff --git a/tsconfig.json b/tsconfig.json index 1ca2350..380e6e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,8 +28,10 @@ "module": "ES2022", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": { + "~/*": ["./*"] + }, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..953e424 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,83 @@ +import { createLogger, format, transports, Logger, LeveledLogMethod } from 'winston'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Define log directory +const LOG_DIR = path.join(process.cwd(), 'logs'); + +// Ensure log directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +// Interface for topic logger options +interface TopicLoggerOptions { + topic: string; + level?: string; +} + +// Create the base logger +const createBaseLogger = (level: string = 'debug') => { + return createLogger({ + level, + format: format.combine( + format.timestamp(), + format.json() + ), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.printf(({ timestamp, level, message, topic, ...meta }) => { + const topicStr = topic ? `[${topic}] ` : ''; + return `${timestamp} ${level}: ${topicStr}${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`; + }) + ) + }), + new transports.File({ + filename: path.join(LOG_DIR, 'combined.log') + }) + ] + }); +}; + +// Main logger instance +const logger = createBaseLogger(); + +// Create a topic-specific logger +const createTopicLogger = (options: TopicLoggerOptions): Logger => { + const topicLogger = createBaseLogger(options.level); + + // Create a wrapper that adds topic to all log messages + const wrappedLogger = { + ...topicLogger, + }; + + // Wrap each log level method to include topic + (['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'] as const).forEach((level) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wrappedLogger[level] = ((...args: any[]) => { + // Handle different call patterns + if (typeof args[0] === 'string') { + const message = args[0]; + const meta = args[1] || {}; + return topicLogger[level]({ + message, + topic: options.topic, + ...meta + }); + } else { + // If first argument is an object, add topic to it + return topicLogger[level]({ + ...args[0], + topic: options.topic + }); + } + }) as LeveledLogMethod; + }); + + return wrappedLogger as Logger; +}; + +export { logger, createTopicLogger }; +export default logger;