feat: restructure backend with Elysia framework and add MQTT adapter

- Updated .gitignore to exclude generated files and database
- Modified package.json to change dev script and add new dependencies
- Removed src/index.ts and created app.ts for Elysia server initialization
- Added environment variable configuration in config/env.ts
- Implemented MQTT adapter in adapter/mqtt.ts for message handling
- Created Prisma client in prisma/client.ts and defined schema in prisma/schema.prisma
- Added seeding script in prisma/seed.ts for measurement points
- Established logging utility in utils/logger.ts for structured logging
- Created bed router in routes/bed.ts for handling bed-related routes
This commit is contained in:
Siwat Sirichai 2025-06-21 18:24:54 +07:00
parent b76d6b99ee
commit a767dc3635
14 changed files with 801 additions and 14 deletions

7
.env Normal file
View file

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

7
.gitignore vendored
View file

@ -40,3 +40,10 @@ yarn-error.log*
**/*.log
package-lock.json
**/*.bun
/generated/prisma
/generated/prisma
/generated/prisma
/data.db

160
adapter/mqtt.ts Normal file
View file

@ -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<void> {
if (this.isConnected) {
return;
}
console.log("MQTT client is not connected, attempting to reconnect...");
await this.connectMQTT();
}
async initialize(config: MQTTConfig): Promise<void> {
this.config = config;
this.connectMQTT();
// Start keep-alive timer
this.keepAliveTimer = setInterval(() => {
this.keepAlive();
}, 5000); // Run every 5 seconds
}
async connectMQTT(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;

39
app.ts Normal file
View file

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

122
bun.lock Normal file
View file

@ -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=="],
}
}

58
config/env.ts Normal file
View file

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

View file

@ -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"
}

5
prisma/client.ts Normal file
View file

@ -0,0 +1,5 @@
import { PrismaClient } from "~/generated/prisma";
const db = new PrismaClient()
export default db;

81
prisma/schema.prisma Normal file
View file

@ -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])
}

219
prisma/seed.ts Normal file
View file

@ -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();
});

5
routes/bed.ts Normal file
View file

@ -0,0 +1,5 @@
import Elysia from "elysia";
const bedRouter = new Elysia()
export default bedRouter;

View file

@ -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}`
);

View file

@ -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. */

83
utils/logger.ts Normal file
View file

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