Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

60 changed files with 1782 additions and 4460 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"

32
.gitignore vendored
View file

@ -3,12 +3,7 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.pnp.js
# testing
/coverage
@ -28,16 +23,27 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun
/generated/prisma
/data
/generated/prisma
/generated/prisma
/data.db

View file

@ -1,36 +1,15 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Elysia with Bun runtime
## Getting Started
First, run the development server:
To get started with this template, simply paste this command into your terminal:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
bun create elysia ./elysia-example
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Development
To start the development server run:
```bash
bun run dev
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Open http://localhost:3000/ with your browser to see the result.

View file

@ -75,6 +75,7 @@ class MQTT {
}
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);
@ -132,7 +133,7 @@ class MQTT {
private onMessage(topic: string, message: Buffer): void {
const msg = message.toString();
console.log(`Received message on topic ${topic}:`, msg);
console.log(`Received message on topic ${topic}: ${msg}`);
this.subscriptions.forEach(sub => {
if (this.matchTopic(sub.topic, topic)) {
sub.callback(topic, msg);

44
app.ts Normal file
View file

@ -0,0 +1,44 @@
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { createTopicLogger } from '~/utils/logger';
import env from './config/env';
import stateRouter from './routes/state';
import swaggerElysia from './routes/swagger';
async function initialize() {
const logger = createTopicLogger({ topic: 'Initializer' });
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())
.use(swaggerElysia)
// State routes (includes WebSocket)
.use(stateRouter)
// Start the server
app.listen(PORT);
}
initialize();

View file

@ -1,225 +0,0 @@
import { NextResponse } from 'next/server';
import { SensorConfig } from '@/types/sensor';
// Sensor configuration that matches the API route
const SENSOR_CONFIG: SensorConfig[] = [
// Head area - Higher thresholds due to critical nature, faster escalation
{
id: "head-1",
x: 45,
y: 15,
zone: "head",
label: "Head Left",
pin: 2,
warningThreshold: 3000,
alarmThreshold: 3500,
warningDelayMs: 30000, // 30 seconds - fast escalation for head
baseNoise: 200
},
{
id: "head-2",
x: 55,
y: 15,
zone: "head",
label: "Head Right",
pin: 3,
warningThreshold: 3000,
alarmThreshold: 3500,
warningDelayMs: 30000, // 30 seconds
baseNoise: 150
},
// Shoulder area - Moderate thresholds, medium escalation time
{
id: "shoulder-1",
x: 35,
y: 25,
zone: "shoulders",
label: "Left Shoulder",
pin: 4,
warningThreshold: 2800,
alarmThreshold: 3200,
warningDelayMs: 45000, // 45 seconds
baseNoise: 250
},
{
id: "shoulder-2",
x: 65,
y: 25,
zone: "shoulders",
label: "Right Shoulder",
pin: 5,
warningThreshold: 2800,
alarmThreshold: 3200,
warningDelayMs: 45000, // 45 seconds
baseNoise: 220
},
// Upper back - Moderate thresholds, 1 minute escalation
{
id: "back-1",
x: 40,
y: 35,
zone: "back",
label: "Upper Back Left",
pin: 6,
warningThreshold: 2500,
alarmThreshold: 3000,
warningDelayMs: 60000, // 1 minute
baseNoise: 300
},
{
id: "back-2",
x: 50,
y: 35,
zone: "back",
label: "Upper Back Center",
pin: 7,
warningThreshold: 2500,
alarmThreshold: 3000,
warningDelayMs: 60000, // 1 minute
baseNoise: 350
},
{
id: "back-3",
x: 60,
y: 35,
zone: "back",
label: "Upper Back Right",
pin: 8,
warningThreshold: 2500,
alarmThreshold: 3000,
warningDelayMs: 60000, // 1 minute
baseNoise: 280
},
// Lower back/Hip area - Lower thresholds, 90 second escalation
{
id: "hip-1",
x: 35,
y: 50,
zone: "hips",
label: "Left Hip",
pin: 9,
warningThreshold: 2200,
alarmThreshold: 2800,
warningDelayMs: 90000, // 90 seconds
baseNoise: 400
},
{
id: "hip-2",
x: 50,
y: 50,
zone: "hips",
label: "Lower Back",
pin: 10,
warningThreshold: 2200,
alarmThreshold: 2800,
warningDelayMs: 90000, // 90 seconds
baseNoise: 450
},
{
id: "hip-3",
x: 65,
y: 50,
zone: "hips",
label: "Right Hip",
pin: 11,
warningThreshold: 2200,
alarmThreshold: 2800,
warningDelayMs: 90000, // 90 seconds
baseNoise: 380
},
// Thigh area - Lower thresholds, 2 minute escalation
{
id: "thigh-1",
x: 40,
y: 65,
zone: "legs",
label: "Left Thigh",
pin: 12,
warningThreshold: 2000,
alarmThreshold: 2500,
warningDelayMs: 120000, // 2 minutes
baseNoise: 320
},
{
id: "thigh-2",
x: 60,
y: 65,
zone: "legs",
label: "Right Thigh",
pin: 13,
warningThreshold: 2000,
alarmThreshold: 2500,
warningDelayMs: 120000, // 2 minutes
baseNoise: 300
},
// Calf area (mock data) - Lower thresholds, 2.5 minute escalation
{
id: "calf-1",
x: 40,
y: 75,
zone: "legs",
label: "Left Calf",
warningThreshold: 1800,
alarmThreshold: 2200,
warningDelayMs: 150000, // 2.5 minutes
baseNoise: 200
},
{
id: "calf-2",
x: 60,
y: 75,
zone: "legs",
label: "Right Calf",
warningThreshold: 1800,
alarmThreshold: 2200,
warningDelayMs: 150000, // 2.5 minutes
baseNoise: 220
},
// Feet (mock data) - Lowest thresholds, 3 minute escalation
{
id: "feet-1",
x: 45,
y: 85,
zone: "feet",
label: "Left Foot",
warningThreshold: 1500,
alarmThreshold: 1800,
warningDelayMs: 180000, // 3 minutes
baseNoise: 150
},
{
id: "feet-2",
x: 55,
y: 85,
zone: "feet",
label: "Right Foot",
warningThreshold: 1500,
alarmThreshold: 1800,
warningDelayMs: 180000, // 3 minutes
baseNoise: 160
},
];
export async function GET() {
try {
return NextResponse.json({
success: true,
sensors: SENSOR_CONFIG,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Sensor config API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to get sensor configuration',
sensors: [],
timestamp: new Date().toISOString()
}, { status: 500 });
}
}

View file

@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { SensorDataStorage } from '@/services/SensorDataStorage';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sensorId = searchParams.get('sensorId');
const timespan = parseInt(searchParams.get('timespan') || '86400000'); // Default 24 hours in ms
if (!sensorId) {
return NextResponse.json({
success: false,
error: 'sensorId parameter is required'
}, { status: 400 });
}
const sensorDataStorage = SensorDataStorage.getInstance();
const sensorData = await sensorDataStorage.getDataForSensor(sensorId, timespan);
const timeSeriesData = sensorDataStorage.generateTimeSeriesData(sensorData, timespan);
return NextResponse.json({
success: true,
sensorId,
timespan,
dataPoints: sensorData.length,
data: timeSeriesData,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Sensor history API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to get sensor history data'
}, { status: 500 });
}
}

View file

@ -1,255 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { bedHardwareInstance, PinState, PinChange } from '@/services/BedHardware';
import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage';
import { SensorConfig } from '@/types/sensor';
// Complete sensor configuration with positions, pin mappings, and thresholds
const SENSOR_CONFIG: SensorConfig[] = [
// Head area
{ id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 200, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
{ id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 150, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
// Shoulder area
{ id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 250, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
{ id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 220, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
// Upper back
{ id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 300, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
{ id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 350, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
{ id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 280, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
// Lower back/Hip area
{ id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 400, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
{ id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 450, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
{ id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 380, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
// Thigh area
{ id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 320, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
{ id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 300, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
];
// Create pin mapping from sensor config
const PIN_SENSOR_MAP: Record<number, typeof SENSOR_CONFIG[0]> = {};
SENSOR_CONFIG.forEach(sensor => {
if (sensor.pin) {
PIN_SENSOR_MAP[sensor.pin] = sensor;
}
});
const sensorDataStorage = SensorDataStorage.getInstance();
const sensorData: Record<string, {
id: string;
x: number;
y: number;
label: string;
zone: string;
value: number; // Changed from pressure to value (0-4095)
pin?: number;
digitalState?: number;
timestamp: string;
source: 'hardware' | 'mock';
data: Array<{ time: string; timestamp: number; value: number }>;
status: string;
warningStartTime?: number; // Track when warning state started
warningThreshold: number;
alarmThreshold: number;
warningDelayMs: number;
}> = {};
let isHardwareConnected = false;
// Initialize all sensor data
function initializeSensorData() {
SENSOR_CONFIG.forEach(sensor => {
if (!sensorData[sensor.id] && sensor.pin) { // Only initialize sensors with pins (real hardware)
sensorData[sensor.id] = {
id: sensor.id,
x: sensor.x,
y: sensor.y,
label: sensor.label,
zone: sensor.zone,
value: 0, // Start with zero until real data arrives
pin: sensor.pin,
timestamp: new Date().toISOString(),
source: 'hardware',
data: [], // Start with empty data array
status: 'normal',
warningThreshold: sensor.warningThreshold,
alarmThreshold: sensor.alarmThreshold,
warningDelayMs: sensor.warningDelayMs
};
}
});
}
// Initialize hardware connection
async function initializeHardware() {
if (isHardwareConnected) return;
try {
bedHardwareInstance.on('connected', () => {
console.log('BedHardware connected');
isHardwareConnected = true;
});
bedHardwareInstance.on('disconnected', () => {
console.log('BedHardware disconnected');
isHardwareConnected = false;
});
bedHardwareInstance.on('pinChanged', (change: PinChange) => {
updateSensorFromPin(change.pin, change.currentState);
});
bedHardwareInstance.on('pinInitialized', (pinState: PinState) => {
updateSensorFromPin(pinState.pin, pinState.state);
});
bedHardwareInstance.on('error', (error) => {
console.error('BedHardware error:', error);
isHardwareConnected = false;
});
await bedHardwareInstance.connect(); } catch (error) {
console.warn('Failed to connect to hardware, system will wait for real hardware data:', error);
isHardwareConnected = false;
}
}
// Update sensor data from pin change
async function updateSensorFromPin(pin: number, value: number) {
const mapping = PIN_SENSOR_MAP[pin];
if (!mapping) return;
const timestamp = Date.now();
const time = new Date(timestamp).toLocaleTimeString("en-US", { hour12: false });
// Save to persistent storage
const dataPoint: SensorDataPoint = {
sensorId: mapping.id,
value,
timestamp,
time,
source: 'hardware',
pin,
digitalState: value
};
await sensorDataStorage.addDataPoint(dataPoint);
if (sensorData[mapping.id]) {
// Update existing sensor data
const currentData = sensorData[mapping.id];
// Determine status based on thresholds
let status = 'normal';
let warningStartTime = currentData.warningStartTime;
if (value >= mapping.alarmThreshold) {
status = 'alarm';
warningStartTime = undefined; // Clear warning timer for immediate alarm
} else if (value >= mapping.warningThreshold) {
status = 'warning';
if (!warningStartTime) {
warningStartTime = timestamp; // Start warning timer
} else if (timestamp - warningStartTime >= mapping.warningDelayMs) {
status = 'alarm'; // Escalate to alarm after delay
}
} else {
warningStartTime = undefined; // Clear warning timer
}
sensorData[mapping.id] = {
...currentData,
value,
digitalState: value,
timestamp: new Date().toISOString(),
source: 'hardware',
data: [
...currentData.data.slice(-287), // Keep last ~24 hours (288 points at 5min intervals)
{
time,
timestamp,
value: value,
}
],
status,
warningStartTime
};
}
}
export async function GET() {
try {
// Initialize sensor data if not already done
if (Object.keys(sensorData).length === 0) {
initializeSensorData();
} // Initialize hardware if not already done
if (!isHardwareConnected) {
await initializeHardware();
}
// If hardware is connected, get current pin states
if (isHardwareConnected) {
const pinStates = bedHardwareInstance.getAllPinStates();
for (const pinState of pinStates) {
await updateSensorFromPin(pinState.pin, pinState.state);
}
}
// Return all sensor data
return NextResponse.json({
success: true,
connected: isHardwareConnected,
sensors: Object.values(sensorData),
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Sensor API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to get sensor data',
connected: false,
sensors: [],
timestamp: new Date().toISOString()
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (body.action === 'connect') {
await initializeHardware();
return NextResponse.json({
success: true,
connected: isHardwareConnected,
message: isHardwareConnected ? 'Hardware connected' : 'Hardware connection failed'
});
}
if (body.action === 'disconnect') {
if (bedHardwareInstance) {
await bedHardwareInstance.disconnect();
isHardwareConnected = false;
}
return NextResponse.json({
success: true,
connected: false,
message: 'Hardware disconnected'
});
}
return NextResponse.json({
success: false,
error: 'Invalid action'
}, { status: 400 });
} catch (error) {
console.error('Sensor API POST error:', error);
return NextResponse.json({
success: false,
error: 'Failed to process request'
}, { status: 500 });
}
}

View file

@ -1,60 +0,0 @@
import { NextResponse } from 'next/server';
import { ZONE_CONFIGS, validateSensorConfig } from '@/utils/sensorConfig';
export async function GET() {
try {
return NextResponse.json({
success: true,
zones: ZONE_CONFIGS,
description: 'Available sensor zones with default threshold configurations',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Zone config API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to get zone configurations',
zones: {},
timestamp: new Date().toISOString()
}, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const sensorConfigs = await request.json();
if (!Array.isArray(sensorConfigs)) {
return NextResponse.json({
success: false,
error: 'Expected array of sensor configurations'
}, { status: 400 });
}
const validationResults = sensorConfigs.map(config => ({
sensorId: config.id,
...validateSensorConfig(config)
}));
const hasErrors = validationResults.some(result => !result.isValid);
return NextResponse.json({
success: !hasErrors,
validationResults,
summary: {
total: sensorConfigs.length,
valid: validationResults.filter(r => r.isValid).length,
withWarnings: validationResults.filter(r => r.warnings.length > 0).length,
withErrors: validationResults.filter(r => !r.isValid).length
},
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Sensor validation API error:', error);
return NextResponse.json({
success: false,
error: 'Failed to validate sensor configurations'
}, { status: 500 });
}
}

View file

@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
// API endpoint disabled - only real MQTT data is used
export async function POST() {
return NextResponse.json({
success: false,
error: 'Test scenarios have been removed. System uses real MQTT data only.'
}, { status: 410 });
}
export async function GET() {
return NextResponse.json({
success: false,
error: 'Test scenarios have been removed. System uses real MQTT data only.'
}, { status: 410 });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,122 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View file

@ -1,37 +0,0 @@
"use client"
import { BedPressureHeader } from "@/components/bed-pressure/BedPressureHeader"
import { StatsCards } from "@/components/bed-pressure/StatsCards"
import { BedVisualization } from "@/components/bed-pressure/BedVisualization"
import { AlertsPanel } from "@/components/bed-pressure/AlertsPanel"
import { AlarmDashboard } from "@/components/bed-pressure/AlarmDashboard"
import { SensorDetailModal } from "@/components/bed-pressure/SensorDetailModal"
import { useBedPressureData } from "@/hooks/useBedPressureData"
export default function Page() {
useBedPressureData()
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto space-y-6">
<BedPressureHeader />
<StatsCards />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<BedVisualization />
</div>
<div className="space-y-6">
<AlertsPanel />
</div>
</div>
{/* New Alarm Dashboard Section */}
<AlarmDashboard />
<SensorDetailModal />
</div>
</div>
)
}

1118
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -1,40 +0,0 @@
"use client"
import { BedPressureHeader } from "./bed-pressure/BedPressureHeader"
import { StatsCards } from "./bed-pressure/StatsCards"
import { BedVisualization } from "./bed-pressure/BedVisualization"
import { AlertsPanel } from "./bed-pressure/AlertsPanel"
import { SensorDetailModal } from "./bed-pressure/SensorDetailModal"
import { useBedPressureData } from "@/hooks/useBedPressureData"
export default function Component() {
// Initialize data fetching
useBedPressureData()
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<BedPressureHeader />
{/* Stats Cards */}
<StatsCards />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Bed Visualization */}
<div className="lg:col-span-2">
<BedVisualization />
</div>
{/* Alerts Panel */}
<div className="space-y-6">
<AlertsPanel />
</div>
</div>
{/* Sensor Detail Modal */}
<SensorDetailModal />
</div>
</div>
)
}

View file

@ -1,197 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { AlertTriangle, VolumeX, CheckCircle, Bell } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect, useState } from "react"
export function AlarmDashboard() {
const {
activeAlarms,
isConnected,
acknowledgeAlarm,
silenceAllAlarms
} = useBedPressureStore()
const [unsilencedAlarms, setUnsilencedAlarms] = useState(0)
// Update alarm counts
useEffect(() => {
const unsilenced = activeAlarms.filter(alarm => !alarm.silenced).length
setUnsilencedAlarms(unsilenced)
}, [activeAlarms])
const getSystemStatus = () => {
const alarmCount = activeAlarms.filter(a => a.type === 'alarm' && !a.silenced).length
const warningCount = activeAlarms.filter(a => a.type === 'warning' && !a.silenced).length
if (alarmCount > 0) return { status: 'ALARM', color: 'text-red-600 bg-red-50 border-red-200', count: alarmCount }
if (warningCount > 0) return { status: 'WARNING', color: 'text-yellow-600 bg-yellow-50 border-yellow-200', count: warningCount }
return { status: 'NORMAL', color: 'text-green-600 bg-green-50 border-green-200', count: 0 }
}
const systemStatus = getSystemStatus()
return (
<div className="space-y-4">
{/* System Status Overview */}
<Card className={`border-2 ${systemStatus.color}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${systemStatus.color}`}>
{systemStatus.status === 'ALARM' ? (
<AlertTriangle className="w-6 h-6 animate-pulse" />
) : systemStatus.status === 'WARNING' ? (
<AlertTriangle className="w-6 h-6" />
) : (
<CheckCircle className="w-6 h-6" />
)}
</div>
<div>
<CardTitle className={`text-lg ${systemStatus.status === 'ALARM' ? 'text-red-700' : systemStatus.status === 'WARNING' ? 'text-yellow-700' : 'text-green-700'}`}>
SYSTEM STATUS: {systemStatus.status}
</CardTitle> <p className="text-sm text-gray-600">
{isConnected ? 'Hardware Connected' : 'Hardware Offline'}
{activeAlarms.length} Active Alarms
{unsilencedAlarms} Unsilenced
</p>
</div>
</div>
<div className="flex items-center gap-2">
{systemStatus.count > 0 && (
<Badge variant="destructive" className="text-lg px-3 py-1">
{systemStatus.count}
</Badge>
)}
{unsilencedAlarms > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => silenceAllAlarms(300000)}
className="text-orange-600 hover:text-orange-700"
>
<VolumeX className="w-4 h-4 mr-1" />
Silence All (5m)
</Button>
)}
</div>
</div>
</CardHeader>
</Card>
{/* Active Alarms Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4 text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm font-medium text-gray-600">Critical Alarms</span>
</div>
<p className="text-2xl font-bold text-red-600">
{activeAlarms.filter(a => a.type === 'alarm' && !a.silenced).length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<AlertTriangle className="w-5 h-5 text-yellow-600" />
<span className="text-sm font-medium text-gray-600">Warnings</span>
</div>
<p className="text-2xl font-bold text-yellow-600">
{activeAlarms.filter(a => a.type === 'warning' && !a.silenced).length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<VolumeX className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium text-gray-600">Silenced</span>
</div>
<p className="text-2xl font-bold text-gray-600">
{activeAlarms.filter(a => a.silenced).length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium text-gray-600">Acknowledged</span>
</div>
<p className="text-2xl font-bold text-green-600">
{activeAlarms.filter(a => a.acknowledged).length}
</p>
</CardContent> </Card>
</div>
{/* Recent Alarm Activity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Recent Alarm Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{activeAlarms.length === 0 ? (
<div className="text-center py-4">
<CheckCircle className="w-8 h-8 text-green-500 mx-auto mb-2" />
<p className="text-sm text-gray-500">No active alarms</p>
</div>
) : (
activeAlarms.slice(0, 5).map((alarm) => (
<div
key={alarm.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded border"
>
<div className="flex items-center gap-2">
<AlertTriangle className={`w-4 h-4 ${
alarm.type === 'alarm' ? 'text-red-600' : 'text-yellow-600'
}`} />
<div>
<p className="text-sm font-medium">{alarm.sensorLabel}</p>
<p className="text-xs text-gray-600">
{alarm.type.toUpperCase()} Value: {alarm.value.toFixed(0)} {alarm.time}
</p>
</div>
</div>
<div className="flex items-center gap-1">
{alarm.silenced && (
<Badge variant="secondary" className="text-xs">
<VolumeX className="w-3 h-3 mr-1" />
Silenced
</Badge>
)}
{alarm.acknowledged && (
<Badge variant="outline" className="text-xs">
<CheckCircle className="w-3 h-3 mr-1" />
ACK
</Badge>
)}
{!alarm.acknowledged && (
<Button
variant="outline"
size="sm"
onClick={() => acknowledgeAlarm(alarm.id)}
className="text-xs h-6 px-2"
>
ACK
</Button>
)}
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -1,193 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { AlertTriangle, VolumeX, Clock, CheckCircle } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect } from "react"
export function AlertsPanel() {
const {
alerts,
activeAlarms,
alarmManager,
acknowledgeAlarm,
silenceAlarm,
silenceAllAlarms
} = useBedPressureStore()
// Update active alarms periodically
useEffect(() => {
const interval = setInterval(() => {
// The store will handle updating alarms automatically
}, 1000)
return () => clearInterval(interval)
}, [alarmManager])
const handleAcknowledge = (alarmId: string) => {
acknowledgeAlarm(alarmId)
}
const handleSilence = (alarmId: string) => {
silenceAlarm(alarmId, 300000) // Silence for 5 minutes
}
const handleSilenceAll = () => {
silenceAllAlarms(300000) // Silence all for 5 minutes
}
const getAlarmIcon = (type: 'warning' | 'alarm') => {
return type === 'alarm' ? (
<AlertTriangle className="w-4 h-4 text-red-600 animate-pulse" />
) : (
<AlertTriangle className="w-4 h-4 text-yellow-600" />
)
}
const getAlarmBgColor = (type: 'warning' | 'alarm', silenced: boolean) => {
if (silenced) return "bg-gray-100 border-gray-300"
return type === 'alarm' ? "bg-red-50 border-red-200" : "bg-yellow-50 border-yellow-200"
}
// Filter out warnings when there's an alarm with the same sensor name
const filteredAlarms = activeAlarms.filter(alarm => {
if (alarm.type === 'alarm') return true
// For warnings, only keep if there's no alarm with the same sensor name
return !activeAlarms.some(other =>
other.type === 'alarm' && other.sensorLabel === alarm.sensorLabel
)
})
const hasActiveAlarms = filteredAlarms.some(alarm => !alarm.silenced)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${hasActiveAlarms ? "text-red-600 animate-pulse" : "text-gray-400"}`} />
Active Alarms ({filteredAlarms.length})
</CardTitle>
{activeAlarms.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleSilenceAll}
className="text-orange-600 hover:text-orange-700"
>
<VolumeX className="w-4 h-4 mr-1" />
Silence All
</Button>
)}
</div>
</CardHeader> <CardContent>
<div className="space-y-3 max-h-80 overflow-y-auto">
{filteredAlarms.length === 0 ? (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
<p className="text-sm text-gray-500">No active alarms</p>
<p className="text-xs text-gray-400">System monitoring normally</p>
</div>
) : (
filteredAlarms.map((alarm) => (
<div
key={alarm.id}
className={`p-3 rounded-lg border ${getAlarmBgColor(alarm.type, alarm.silenced)}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{getAlarmIcon(alarm.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className={`text-sm font-medium ${
alarm.type === 'alarm' ? 'text-red-800' : 'text-yellow-800'
}`}>
{alarm.sensorLabel}
</p>
<span className={`text-xs px-2 py-1 rounded ${
alarm.type === 'alarm'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{alarm.type.toUpperCase()}
</span>
{alarm.silenced && (
<span className="text-xs px-2 py-1 rounded bg-gray-100 text-gray-600 flex items-center gap-1">
<VolumeX className="w-3 h-3" />
SILENCED
</span>
)}
</div>
<p className={`text-xs mb-2 ${
alarm.type === 'alarm' ? 'text-red-700' : 'text-yellow-700'
}`}>
Value: {alarm.value.toFixed(0)} (Threshold: {alarm.threshold})
</p>
<div className="flex items-center gap-2 text-xs text-gray-600">
<Clock className="w-3 h-3" />
<span>{alarm.time}</span>
{alarm.acknowledged && (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
ACK
</span>
)}
{alarm.silenced && alarm.silencedUntil && (
<span className="text-gray-500">
Until {new Date(alarm.silencedUntil).toLocaleTimeString()}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-1 ml-2">
{!alarm.acknowledged && (
<Button
variant="outline"
size="sm"
onClick={() => handleAcknowledge(alarm.id)}
className="text-xs h-6 px-2"
>
ACK
</Button>
)}
{!alarm.silenced && (
<Button
variant="outline"
size="sm"
onClick={() => handleSilence(alarm.id)}
className="text-xs h-6 px-2 text-orange-600 hover:text-orange-700"
>
<VolumeX className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
{/* Legacy Alerts Section */}
{alerts.length > 0 && (
<div className="mt-6 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-3">Recent Alerts</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{alerts.slice(0, 3).map((alert) => (
<div
key={alert.id}
className="flex items-start gap-2 p-2 bg-blue-50 rounded border border-blue-200"
>
<AlertTriangle className="w-3 h-3 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-blue-800">{alert.message}</p>
<p className="text-xs text-blue-600">{alert.time}</p>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -1,92 +0,0 @@
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Activity, Pause, Play, Clock, AlertTriangle } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect, useState } from "react"
export function BedPressureHeader() {
const { isMonitoring, setIsMonitoring, sensorData } = useBedPressureStore()
const [countdowns, setCountdowns] = useState<Record<string, number>>({})
// Update countdowns every second
useEffect(() => {
const interval = setInterval(() => {
const newCountdowns: Record<string, number> = {}
const now = Date.now()
Object.values(sensorData).forEach(sensor => {
if (sensor.status === 'warning' && sensor.warningStartTime && sensor.warningDelayMs) {
const elapsed = now - sensor.warningStartTime
const remaining = Math.max(0, sensor.warningDelayMs - elapsed)
if (remaining > 0) {
newCountdowns[sensor.id] = remaining
}
}
})
setCountdowns(newCountdowns)
}, 1000)
return () => clearInterval(interval)
}, [sensorData])
const formatCountdown = (ms: number) => {
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const warningCountdowns = Object.entries(countdowns).filter(([, time]) => time > 0)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Activity className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
</div>
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
{isMonitoring ? "Live" : "Paused"}
</Badge>
{/* Warning Countdown Indicators */}
{warningCountdowns.length > 0 && (
<div className="flex items-center gap-2">
{warningCountdowns.slice(0, 3).map(([sensorId, timeRemaining]) => {
const sensor = sensorData[sensorId]
return (
<div
key={sensorId}
className="flex items-center gap-1 px-2 py-1 bg-yellow-100 border border-yellow-300 rounded-md text-sm"
>
<AlertTriangle className="w-4 h-4 text-yellow-600" />
<span className="text-yellow-800 font-medium">
{sensor?.label}
</span>
<div className="flex items-center gap-1 text-yellow-700">
<Clock className="w-3 h-3" />
<span className="font-mono text-xs">
{formatCountdown(timeRemaining)}
</span>
</div>
</div>
)
})}
{warningCountdowns.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{warningCountdowns.length - 3} more
</Badge>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{isMonitoring ? "Pause" : "Resume"}
</Button>
</div>
</div>
)
}

View file

@ -1,79 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useBedPressureStore } from "@/stores/bedPressureStore"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
export function BedVisualization() {
const { sensorConfig, sensorData, setSelectedSensor, setIsModalOpen } = useBedPressureStore()
const handleSensorClick = (sensorId: string) => {
setSelectedSensor(sensorId)
setIsModalOpen(true)
}
return (
<Card>
<CardHeader>
<CardTitle>Sensor Value Distribution Map</CardTitle>
<p className="text-sm text-gray-600">Click on any sensor point to view detailed value graphs</p>
</CardHeader>
<CardContent>
<div className="relative">
{/* Bed outline */}
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
{/* Bed frame */}
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
{/* Pillow area */}
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
{/* Pressure sensors */}
{sensorConfig.map((sensor) => {
const sensorInfo = sensorData[sensor.id]
if (!sensorInfo) return null
return (
<circle
key={sensor.id}
cx={sensor.x}
cy={sensor.y}
r="3"
fill={getValueColor(sensorInfo.currentValue)}
stroke="white"
strokeWidth="1"
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
onClick={() => handleSensorClick(sensor.id)}
/>
)
})}
</svg>
{/* Value Legend */}
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span>Low ({"<"}1500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<span>Medium (1500-2500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
<span>High (2500-3500)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span>Critical ({">"}3500)</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -1,229 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { Download, Clock } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
import { useEffect } from "react"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
const getValueLevel = (value: number) => {
if (value < 1500) return "Low"
if (value < 2500) return "Medium"
if (value < 3500) return "High"
return "Critical"
}
export function SensorDetailModal() {
const {
isModalOpen,
setIsModalOpen,
selectedSensor,
sensorData,
selectedTimespan,
setSelectedTimespan,
fetchSensorHistory
} = useBedPressureStore()
const sensor = selectedSensor && sensorData[selectedSensor] ? sensorData[selectedSensor] : null
// Fetch historical data when modal opens or timespan changes
useEffect(() => {
if (isModalOpen && selectedSensor && sensor?.source === 'hardware') {
fetchSensorHistory(selectedSensor, selectedTimespan)
}
}, [isModalOpen, selectedSensor, selectedTimespan, fetchSensorHistory, sensor?.source])
const handleTimespanChange = (value: string) => {
const newTimespan = parseInt(value)
setSelectedTimespan(newTimespan)
}
const getTimespanLabel = (timespan: number) => {
if (timespan < 60000) return `${timespan / 1000}s`
if (timespan < 3600000) return `${timespan / 60000}m`
if (timespan < 86400000) return `${timespan / 3600000}h`
return `${timespan / 86400000}d`
}
if (!isModalOpen || !selectedSensor || !sensor) {
return null
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">{sensor.label}</h2>
<p className="text-gray-600">Pressure Monitoring Details</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsModalOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Current Value</p>
<p
className="text-3xl font-bold"
style={{ color: getValueColor(sensor.currentValue) }}
>
{sensor.currentValue.toFixed(0)}
</p>
<p className="text-sm text-gray-500">ADC Units</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Status Level</p>
<Badge
className="text-lg px-3 py-1 mt-2"
variant={
sensor.status === "critical" || sensor.status === "alarm"
? "destructive"
: sensor.status === "warning"
? "secondary"
: "default"
}
>
{getValueLevel(sensor.currentValue)}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Body Zone</p>
<p className="text-xl font-semibold capitalize mt-2">{sensor.zone}</p>
</CardContent>
</Card>
</div>
{/* Large Value Chart */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Value Trend</CardTitle>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-500" />
<Select value={selectedTimespan.toString()} onValueChange={handleTimespanChange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000">5m</SelectItem>
<SelectItem value="600000">10m</SelectItem>
<SelectItem value="1800000">30m</SelectItem>
<SelectItem value="3600000">1h</SelectItem>
<SelectItem value="7200000">2h</SelectItem>
<SelectItem value="21600000">6h</SelectItem>
<SelectItem value="43200000">12h</SelectItem>
<SelectItem value="86400000">24h</SelectItem>
<SelectItem value="259200000">3d</SelectItem>
<SelectItem value="604800000">7d</SelectItem>
</SelectContent>
</Select>
</div>
</div> <p className="text-sm text-gray-600">
Showing data for the last {getTimespanLabel(selectedTimespan)}
{sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Hardware offline - no data)'}
</p>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sensor.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
label={{ value: "ADC Value", angle: -90, position: "insideLeft" }}
/>
<Tooltip
formatter={(value: number) => [`${value.toFixed(0)}`, "Value"]}
labelFormatter={(label) => `Time: ${label}`}
/>
<Line
type="monotone"
dataKey="value"
stroke={getValueColor(sensor.currentValue)}
strokeWidth={3}
dot={false}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Additional Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Max Value</p>
<p className="text-lg font-bold text-red-600">
{Math.max(...sensor.data.map(d => d.value)).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Min Value</p>
<p className="text-lg font-bold text-green-600">
{Math.min(...sensor.data.map(d => d.value)).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Average</p>
<p className="text-lg font-bold text-blue-600">
{(sensor.data.reduce((sum, d) => sum + d.value, 0) / sensor.data.length).toFixed(0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-sm text-gray-600">Data Points</p>
<p className="text-lg font-bold text-gray-600">{sensor.data.length}</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
Close
</Button>
<Button>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,68 +0,0 @@
import { Card, CardContent } from "@/components/ui/card"
import { AlertTriangle, User } from "lucide-react"
import { useBedPressureStore } from "@/stores/bedPressureStore"
const getValueColor = (value: number) => {
if (value < 1500) return "#22c55e" // Green - Low value
if (value < 2500) return "#eab308" // Yellow - Medium value
if (value < 3500) return "#f97316" // Orange - High value
return "#ef4444" // Red - Very high value
}
export function StatsCards() {
const { sensorData, averageValue, criticalSensors } = useBedPressureStore()
const avgValue = averageValue()
const criticalCount = criticalSensors()
const activeSensors = Object.keys(sensorData).length
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm text-gray-600">Patient</p>
<p className="font-semibold">John Doe - Room 204</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Average Value</p>
<p className="text-2xl font-bold" style={{ color: getValueColor(avgValue) }}>
{avgValue.toFixed(0)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600">Active Sensors</p>
<p className="text-2xl font-bold text-green-600">{activeSensors}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${criticalCount > 0 ? "text-red-600" : "text-gray-400"}`} />
<div>
<p className="text-sm text-gray-600">Critical Alerts</p>
<p className={`text-2xl font-bold ${criticalCount > 0 ? "text-red-600" : "text-gray-600"}`}>
{criticalCount}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -1,59 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -1,185 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

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

@ -1,16 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View file

@ -1,54 +0,0 @@
import { useEffect } from 'react'
import { useBedPressureStore, SensorData } from '@/stores/bedPressureStore'
export function useBedPressureData() {
const {
sensorConfig,
sensorData,
isMonitoring,
fetchSensorConfig,
fetchSensorData,
setSensorData
} = useBedPressureStore()
// Initialize sensor configuration
useEffect(() => {
fetchSensorConfig()
}, [fetchSensorConfig])
// Initialize sensor data
useEffect(() => {
if (sensorConfig.length === 0) return
const initialData: Record<string, SensorData> = {}
sensorConfig.forEach((sensor) => {
initialData[sensor.id] = {
...sensor,
currentValue: 0,
data: [],
status: "normal",
}
})
setSensorData(initialData)
}, [sensorConfig, setSensorData])
// Fetch sensor data from API
useEffect(() => {
if (!isMonitoring) return
// Initial fetch
fetchSensorData()
// Set up polling
const interval = setInterval(fetchSensorData, 2000)
return () => clearInterval(interval)
}, [isMonitoring, fetchSensorData])
return {
sensorData,
sensorConfig,
isMonitoring
}
}

View file

@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View file

@ -1,41 +1,22 @@
{
"name": "m2-inno-bedpressure",
"version": "0.1.0",
"private": true,
"name": "elysia",
"version": "1.0.50",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch app.ts",
"start": "bun run app.ts"
},
"dependencies": {
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@types/serialport": "^10.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.519.0",
"mqtt": "^5.13.1",
"next": "15.3.4",
"port": "^0.8.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.4",
"serial": "^0.0.9",
"serialport": "^13.0.0",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.5"
"@elysiajs/cors": "^1.3.3",
"@elysiajs/swagger": "^1.3.0",
"@prisma/client": "^6.10.1",
"elysia": "latest",
"envalid": "^8.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@types/node": "^20.19.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.29.0",
"eslint-config-next": "15.3.4",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3"
}
"bun-types": "^1.2.17",
"prisma": "^6.10.1"
},
"module": "src/index.js"
}

View file

@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

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

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

5
routes/history.ts Normal file
View file

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

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

View file

@ -1,207 +0,0 @@
export interface AlarmEvent {
id: string;
sensorId: string;
sensorLabel: string;
type: 'warning' | 'alarm';
value: number;
threshold: number;
timestamp: number;
time: string;
acknowledged: boolean;
silenced: boolean;
silencedUntil?: number;
}
export class AlarmManager {
private static instance: AlarmManager;
private activeAlarms: Map<string, AlarmEvent> = new Map();
private alarmHistory: AlarmEvent[] = [];
private alarmCallbacks: ((alarm: AlarmEvent) => void)[] = [];
private audioContext: AudioContext | null = null;
private alarmSound: AudioBuffer | null = null;
private isPlayingAlarm = false;
private constructor() {
this.initializeAudio();
}
static getInstance(): AlarmManager {
if (!AlarmManager.instance) {
AlarmManager.instance = new AlarmManager();
}
return AlarmManager.instance;
}
private async initializeAudio() {
try {
// Check if we're in browser environment
if (typeof window !== 'undefined') {
this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
// Generate alarm sound programmatically
await this.generateAlarmSound();
}
} catch (error) {
console.warn('Audio context not available:', error);
}
}
private async generateAlarmSound() {
if (!this.audioContext) return;
const sampleRate = this.audioContext.sampleRate;
const duration = 1; // 1 second
const buffer = this.audioContext.createBuffer(1, sampleRate * duration, sampleRate);
const data = buffer.getChannelData(0);
// Generate a beeping sound (sine wave with modulation)
for (let i = 0; i < data.length; i++) {
const t = i / sampleRate;
const frequency = 800; // Hz
const envelope = Math.sin(t * Math.PI * 4) > 0 ? 1 : 0; // Beeping pattern
data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope * 0.3;
}
this.alarmSound = buffer;
}
private async playAlarmSound() {
if (!this.audioContext || !this.alarmSound || this.isPlayingAlarm) return;
try {
// Resume audio context if suspended
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
const source = this.audioContext.createBufferSource();
source.buffer = this.alarmSound;
source.connect(this.audioContext.destination);
source.start();
this.isPlayingAlarm = true;
source.onended = () => {
this.isPlayingAlarm = false;
};
} catch (error) {
console.warn('Failed to play alarm sound:', error);
}
}
addAlarm(sensorId: string, sensorLabel: string, type: 'warning' | 'alarm', value: number, threshold: number) {
const alarmId = `${sensorId}-${type}`;
const timestamp = Date.now();
const alarm: AlarmEvent = {
id: alarmId,
sensorId,
sensorLabel,
type,
value,
threshold,
timestamp,
time: new Date(timestamp).toLocaleTimeString(),
acknowledged: false,
silenced: false
};
// Check if alarm is already active and silenced
const existingAlarm = this.activeAlarms.get(alarmId);
if (existingAlarm?.silenced && existingAlarm.silencedUntil && timestamp < existingAlarm.silencedUntil) {
// Update values but keep silenced state
alarm.silenced = true;
alarm.silencedUntil = existingAlarm.silencedUntil;
}
this.activeAlarms.set(alarmId, alarm);
this.alarmHistory.unshift(alarm);
// Keep only last 100 history items
if (this.alarmHistory.length > 100) {
this.alarmHistory = this.alarmHistory.slice(0, 100);
}
// Play sound for new alarms
if (type === 'alarm' && !alarm.silenced) {
this.playAlarmSound();
}
// Notify callbacks
this.alarmCallbacks.forEach(callback => callback(alarm));
return alarm;
}
clearAlarm(sensorId: string, type?: 'warning' | 'alarm') {
if (type) {
const alarmId = `${sensorId}-${type}`;
this.activeAlarms.delete(alarmId);
} else {
// Clear all alarms for this sensor
this.activeAlarms.delete(`${sensorId}-warning`);
this.activeAlarms.delete(`${sensorId}-alarm`);
}
}
acknowledgeAlarm(alarmId: string) {
const alarm = this.activeAlarms.get(alarmId);
if (alarm) {
alarm.acknowledged = true;
this.activeAlarms.set(alarmId, alarm);
}
}
silenceAlarm(alarmId: string, durationMs: number = 300000) { // Default 5 minutes
const alarm = this.activeAlarms.get(alarmId);
if (alarm) {
alarm.silenced = true;
alarm.silencedUntil = Date.now() + durationMs;
this.activeAlarms.set(alarmId, alarm);
}
}
silenceAllAlarms(durationMs: number = 300000) {
const timestamp = Date.now();
this.activeAlarms.forEach((alarm, alarmId) => {
alarm.silenced = true;
alarm.silencedUntil = timestamp + durationMs;
this.activeAlarms.set(alarmId, alarm);
});
}
getActiveAlarms(): AlarmEvent[] {
const now = Date.now();
const activeAlarms: AlarmEvent[] = [];
this.activeAlarms.forEach((alarm, alarmId) => {
// Check if silence period has expired
if (alarm.silenced && alarm.silencedUntil && now >= alarm.silencedUntil) {
alarm.silenced = false;
alarm.silencedUntil = undefined;
this.activeAlarms.set(alarmId, alarm);
}
activeAlarms.push(alarm);
});
return activeAlarms.sort((a, b) => b.timestamp - a.timestamp);
}
getAlarmHistory(): AlarmEvent[] {
return this.alarmHistory;
}
hasUnacknowledgedAlarms(): boolean {
return Array.from(this.activeAlarms.values()).some(alarm => !alarm.acknowledged);
}
onAlarm(callback: (alarm: AlarmEvent) => void) {
this.alarmCallbacks.push(callback);
}
offAlarm(callback: (alarm: AlarmEvent) => void) {
const index = this.alarmCallbacks.indexOf(callback);
if (index > -1) {
this.alarmCallbacks.splice(index, 1);
}
}
}

View file

@ -1,107 +0,0 @@
import { EventEmitter } from 'events';
import { IBedHardware, PinState, PinChange, BedHardwareConfig } from '../types/bedhardware';
import { BedHardwareMQTT } from './BedHardwareMQTT';
/**
* BedHardware - MQTT-based bed hardware implementation
*
* Usage:
* MQTT (connects to broker.hivemq.com with base topic /Jtkcp2N/pressurebed/)
* const hardware = BedHardware.createSimpleMQTT();
*
* With custom topics
* const hardware = BedHardware.createMQTT({
* topics: {
* pinState: '/custom/pin/state',
* pinChange: '/custom/pin/change',
* initialization: '/custom/init'
* }
* });
*/
export class BedHardware extends EventEmitter implements IBedHardware {
private implementation: IBedHardware;
constructor(config: BedHardwareConfig) {
super();
if (config.type === 'mqtt') {
this.implementation = new BedHardwareMQTT({
topics: config.mqtt?.topics
});
} else {
throw new Error(`Unsupported hardware type: ${config.type}`);
}
// Forward all events from the implementation
this.forwardEvents();
}
private forwardEvents(): void {
const events = [
'connected',
'disconnected',
'error',
'initialized',
'pinInitialized',
'pinChanged'
];
events.forEach(event => {
this.implementation.on(event, (...args: unknown[]) => {
this.emit(event, ...args);
});
});
// Forward dynamic pin events (pin{number}Changed)
this.implementation.on('pinChanged', (pinChange: PinChange) => {
this.emit(`pin${pinChange.pin}Changed`, pinChange);
});
}
async connect(): Promise<void> {
return this.implementation.connect();
}
async disconnect(): Promise<void> {
return this.implementation.disconnect();
}
getPinState(pin: number): PinState | undefined {
return this.implementation.getPinState(pin);
}
getAllPinStates(): PinState[] {
return this.implementation.getAllPinStates();
}
isConnected(): boolean {
return this.implementation.isConnected();
}
// Static factory methods for convenience
static createMQTT(config?: {
topics?: {
pinState: string;
pinChange: string;
initialization: string;
};
}): BedHardware {
return new BedHardware({
type: 'mqtt',
mqtt: config
});
}
static createSimpleMQTT(): BedHardware {
return new BedHardware({
type: 'mqtt',
mqtt: {}
});
}
}
// Create and export a default MQTT instance
export const bedHardwareInstance = new BedHardware({
type: 'mqtt',
mqtt: {}
});
export * from '../types/bedhardware';

View file

@ -1,139 +0,0 @@
import { EventEmitter } from 'events';
import { IBedHardware, PinState, PinChange } from '../types/bedhardware';
import MQTT from '../adapter/mqtt';
import { getMQTTClient, BASE_TOPIC } from './mqttService';
export interface MqttConfig {
topics?: {
pinState: string;
pinChange: string;
initialization: string;
};
}
export class BedHardwareMQTT extends EventEmitter implements IBedHardware {
private client: MQTT | null = null;
private pinStates: Map<number, PinState> = new Map();
private connectionState: boolean = false;
private topics: {
pinState: string;
pinChange: string;
initialization: string;
};
constructor(private config: MqttConfig = {}) {
super();
this.topics = config.topics || {
pinState: `${BASE_TOPIC}pin/state`,
pinChange: `${BASE_TOPIC}pin/change`,
initialization: `${BASE_TOPIC}init`
};
}
async connect(): Promise<void> {
try {
// Use the MQTT service to get a client connected to HiveMQ
this.client = await getMQTTClient();
// Subscribe to topics - adapter handles reconnection/resubscription
await this.client.subscribe(this.topics.initialization, (topic, message) => {
this.handleMqttMessage(topic, message);
});
await this.client.subscribe(this.topics.pinState, (topic, message) => {
this.handleMqttMessage(topic, message);
});
await this.client.subscribe(this.topics.pinChange, (topic, message) => {
this.handleMqttMessage(topic, message);
});
this.connectionState = true;
this.emit('connected');
console.log('BedHardware MQTT connected to HiveMQ');
} catch (error) {
const errorMsg = `Failed to connect to MQTT broker: ${error}`;
console.error(errorMsg);
this.emit('error', new Error(errorMsg));
throw new Error(errorMsg);
}
} async disconnect(): Promise<void> {
if (this.client) {
await this.client.disconnect();
}
this.client = null;
this.connectionState = false;
this.emit('disconnected');
}
private handleMqttMessage(topic: string, message: string): void {
try {
const data = JSON.parse(message);
if (topic === this.topics.initialization) {
if (data.type === 'START') {
this.emit('initialized');
console.log('MQTT initialization started');
} else if (data.type === 'PIN_INIT' && data.pin !== undefined && data.state !== undefined) {
const pinState: PinState = {
pin: data.pin,
state: data.state,
name: data.name || `PIN${data.pin}`,
timestamp: new Date(data.timestamp || Date.now())
};
this.pinStates.set(data.pin, pinState);
this.emit('pinInitialized', pinState);
} } else if (topic === this.topics.pinChange) {
if (data.pin !== undefined && data.previousValue !== undefined && data.currentValue !== undefined) {
const pinChange: PinChange = {
pin: data.pin,
previousState: data.previousValue,
currentState: data.currentValue,
timestamp: new Date(data.timestamp || Date.now())
};
// Update stored pin state
const pinState: PinState = {
pin: data.pin,
state: data.currentValue,
name: data.name || `PIN${data.pin}`,
timestamp: new Date(data.timestamp || Date.now())
};
this.pinStates.set(data.pin, pinState);
this.emit('pinChanged', pinChange);
this.emit(`pin${data.pin}Changed`, pinChange);
}
} else if (topic === this.topics.pinState) {
if (data.pin !== undefined && data.state !== undefined) {
const pinState: PinState = {
pin: data.pin,
state: data.state,
name: data.name || `PIN${data.pin}`,
timestamp: new Date(data.timestamp || Date.now())
};
this.pinStates.set(data.pin, pinState);
this.emit('pinInitialized', pinState);
}
}
} catch (error) {
console.error('Error parsing MQTT message:', error);
this.emit('error', new Error(`Failed to parse MQTT message: ${error}`));
}
}
getPinState(pin: number): PinState | undefined {
return this.pinStates.get(pin);
}
getAllPinStates(): PinState[] {
return Array.from(this.pinStates.values());
}
isConnected(): boolean {
return this.connectionState;
}
}

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');
}
}

View file

@ -1,116 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const SENSOR_DATA_FILE = path.join(DATA_DIR, 'sensor-data.json');
export interface SensorDataPoint {
sensorId: string;
value: number; // Changed from pressure to value (0-4095)
timestamp: number;
time: string;
source: 'hardware';
pin?: number;
digitalState?: number;
}
export class SensorDataStorage {
private static instance: SensorDataStorage;
private dataCache: SensorDataPoint[] = [];
private constructor() {
this.initializeStorage();
}
static getInstance(): SensorDataStorage {
if (!SensorDataStorage.instance) {
SensorDataStorage.instance = new SensorDataStorage();
}
return SensorDataStorage.instance;
}
private async initializeStorage() {
try {
// Ensure data directory exists
await fs.mkdir(DATA_DIR, { recursive: true });
// Load existing data
await this.loadData();
} catch (error) {
console.warn('Failed to initialize sensor data storage:', error);
}
}
private async loadData() {
try {
const data = await fs.readFile(SENSOR_DATA_FILE, 'utf8');
this.dataCache = JSON.parse(data);
console.log(`Loaded ${this.dataCache.length} sensor data points from storage`);
} catch {
// File doesn't exist or is corrupted, start with empty cache
this.dataCache = [];
console.log('Starting with empty sensor data cache');
}
}
private async saveData() {
try {
await fs.writeFile(SENSOR_DATA_FILE, JSON.stringify(this.dataCache, null, 2));
} catch (error) {
console.error('Failed to save sensor data:', error);
}
}
async addDataPoint(dataPoint: SensorDataPoint) {
this.dataCache.push(dataPoint);
// Keep only last 7 days of data to prevent unlimited storage growth
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
this.dataCache = this.dataCache.filter(point => point.timestamp > sevenDaysAgo);
// Save to disk every 10 data points to reduce I/O
if (this.dataCache.length % 10 === 0) {
await this.saveData();
}
}
async getDataForSensor(
sensorId: string,
timespan: number = 24 * 60 * 60 * 1000 // Default 24 hours in milliseconds
): Promise<SensorDataPoint[]> {
const cutoffTime = Date.now() - timespan;
return this.dataCache
.filter(point => point.sensorId === sensorId && point.timestamp > cutoffTime)
.sort((a, b) => a.timestamp - b.timestamp);
}
async getAllRecentData(timespan: number = 24 * 60 * 60 * 1000): Promise<SensorDataPoint[]> {
const cutoffTime = Date.now() - timespan;
return this.dataCache
.filter(point => point.timestamp > cutoffTime)
.sort((a, b) => a.timestamp - b.timestamp);
}
async forceSave() {
await this.saveData();
} // Generate time series data for a specific timespan
generateTimeSeriesData(
sensorData: SensorDataPoint[],
timespan: number = 24 * 60 * 60 * 1000
): Array<{ time: string; timestamp: number; value: number }> {
if (sensorData.length === 0) {
// Return empty array if no real data exists
return [];
}
// Filter data by timespan
const cutoffTime = Date.now() - timespan;
const filteredData = sensorData.filter(point => point.timestamp > cutoffTime);
return filteredData.map(point => ({
time: point.time,
timestamp: point.timestamp,
value: point.value
}));
}
}

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

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

View file

@ -1,332 +0,0 @@
import { create } from 'zustand'
import { AlarmManager, AlarmEvent } from '@/services/AlarmManager'
import { SensorConfig } from '@/types/sensor'
export interface SensorData {
id: string;
x: number;
y: number;
zone: string;
label: string;
currentValue: number; // Changed from currentPressure to currentValue (0-4095)
data: Array<{ time: string; timestamp: number; value: number }>; // Changed from pressure to value
status: string;
source?: 'hardware';
pin?: number;
digitalState?: number;
warningThreshold?: number;
alarmThreshold?: number;
warningDelayMs?: number;
warningStartTime?: number; // Track when warning state started
}
export interface Alert {
id: string;
message: string;
time: string;
}
interface BedPressureStore {
// State
sensorData: Record<string, SensorData>;
sensorConfig: SensorConfig[];
selectedSensor: string | null;
isModalOpen: boolean;
isMonitoring: boolean;
alerts: Alert[];
isConnected: boolean;
selectedTimespan: number; // in milliseconds
activeAlarms: AlarmEvent[];
alarmManager: AlarmManager;
// Actions
setSensorData: (data: Record<string, SensorData>) => void;
updateSensorData: (updater: (prev: Record<string, SensorData>) => Record<string, SensorData>) => void;
setSensorConfig: (config: SensorConfig[]) => void;
setSelectedSensor: (sensorId: string | null) => void;
setIsModalOpen: (isOpen: boolean) => void;
setIsMonitoring: (isMonitoring: boolean) => void;
addAlerts: (newAlerts: Alert[]) => void;
setIsConnected: (isConnected: boolean) => void;
setSelectedTimespan: (timespan: number) => void;
setActiveAlarms: (alarms: AlarmEvent[]) => void;
// Computed values
averageValue: () => number; // Changed from averagePressure
criticalSensors: () => number;
// API actions
fetchSensorConfig: () => Promise<void>;
fetchSensorData: () => Promise<void>;
fetchSensorHistory: (sensorId: string, timespan?: number) => Promise<void>;
// Alarm actions
acknowledgeAlarm: (alarmId: string) => void;
silenceAlarm: (alarmId: string, durationMs?: number) => void;
silenceAllAlarms: (durationMs?: number) => void;
}
export const useBedPressureStore = create<BedPressureStore>((set, get) => ({
// Initial state
sensorData: {},
sensorConfig: [],
selectedSensor: null,
isModalOpen: false,
isMonitoring: true,
alerts: [],
isConnected: false,
selectedTimespan: 24 * 60 * 60 * 1000, // Default 24 hours
activeAlarms: [],
alarmManager: AlarmManager.getInstance(),
// Actions
setSensorData: (data) => set({ sensorData: data }),
updateSensorData: (updater) => set((state) => ({
sensorData: updater(state.sensorData)
})),
setSensorConfig: (config) => set({ sensorConfig: config }),
setSelectedSensor: (sensorId) => set({ selectedSensor: sensorId }),
setIsModalOpen: (isOpen) => set({ isModalOpen: isOpen }),
setIsMonitoring: (isMonitoring) => set({ isMonitoring }),
addAlerts: (newAlerts) => set((state) => ({
alerts: [...newAlerts, ...state.alerts].slice(0, 10)
})),
setIsConnected: (isConnected) => set({ isConnected }),
setSelectedTimespan: (timespan) => set({ selectedTimespan: timespan }),
setActiveAlarms: (alarms) => set({ activeAlarms: alarms }),
// Computed values
averageValue: () => {
const { sensorData } = get();
const sensors = Object.values(sensorData) as SensorData[];
// Only calculate average for sensors with hardware data
const hardwareSensors = sensors.filter(sensor => sensor.source === 'hardware');
if (hardwareSensors.length === 0) return 0;
return hardwareSensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / hardwareSensors.length;
},
criticalSensors: () => {
const { sensorData } = get();
return (Object.values(sensorData) as SensorData[]).filter((sensor: SensorData) =>
sensor.status === 'alarm' || sensor.status === 'critical'
).length;
},
// API actions
fetchSensorConfig: async () => {
try {
const response = await fetch('/api/sensors/config');
const data = await response.json();
if (data.success && data.sensors) {
set({ sensorConfig: data.sensors });
}
} catch (error) {
console.error('Failed to fetch sensor config:', error);
}
},
fetchSensorData: async () => {
try {
const response = await fetch('/api/sensors');
const data = await response.json();
if (data.success && data.sensors) {
const { sensorData, sensorConfig, alarmManager } = get();
const updated = { ...sensorData };
const newAlerts: Alert[] = [];
// Only process hardware sensors, no mock data
data.sensors.forEach((sensor: {
id: string;
label: string;
zone: string;
value: number;
source: 'hardware';
pin?: number;
digitalState?: number;
warningThreshold?: number;
alarmThreshold?: number;
warningDelayMs?: number;
status?: string;
}) => {
// Only process hardware data
if (sensor.source !== 'hardware') {
return;
}
const currentSensor = updated[sensor.id];
const newValue = sensor.value;
const config = sensorConfig.find(s => s.id === sensor.id);
// Get thresholds from sensor data or config
const warningThreshold = sensor.warningThreshold || config?.warningThreshold || 2500;
const alarmThreshold = sensor.alarmThreshold || config?.alarmThreshold || 3500;
const warningDelayMs = sensor.warningDelayMs || config?.warningDelayMs || 60000;
// Determine status and handle alarm logic
let status = 'normal';
let warningStartTime = currentSensor?.warningStartTime;
const now = Date.now();
if (newValue >= alarmThreshold) {
status = 'alarm';
warningStartTime = undefined; // Clear warning timer for immediate alarm
// Add alarm
alarmManager.addAlarm(
sensor.id,
sensor.label,
'alarm',
newValue,
alarmThreshold
);
newAlerts.push({
id: `${sensor.id}-${Date.now()}`,
message: `ALARM: High value detected at ${sensor.label} (${newValue})`,
time: new Date().toLocaleTimeString(),
});
} else if (newValue >= warningThreshold) {
status = 'warning';
if (!warningStartTime) {
warningStartTime = now; // Start warning timer
} else if (now - warningStartTime >= warningDelayMs) {
status = 'alarm'; // Escalate to alarm after delay
// Add escalated alarm
alarmManager.addAlarm(
sensor.id,
sensor.label,
'alarm',
newValue,
warningThreshold
);
newAlerts.push({
id: `${sensor.id}-${Date.now()}`,
message: `ALARM: Warning escalated for ${sensor.label} (${newValue})`,
time: new Date().toLocaleTimeString(),
});
} else {
// Add warning alarm
alarmManager.addAlarm(
sensor.id,
sensor.label,
'warning',
newValue,
warningThreshold
);
}
} else {
warningStartTime = undefined; // Clear warning timer
alarmManager.clearAlarm(sensor.id); // Clear any existing alarms
} // Update sensor data
updated[sensor.id] = {
...sensorConfig.find(s => s.id === sensor.id),
id: sensor.id,
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
zone: sensor.zone,
label: sensor.label,
currentValue: newValue,
data: currentSensor ? [
...currentSensor.data.slice(-100), // Keep only last 100 points
{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
value: newValue,
},
] : [{
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
timestamp: Date.now(),
value: newValue,
}],
status,
source: sensor.source,
pin: sensor.pin,
digitalState: sensor.digitalState,
warningThreshold,
alarmThreshold,
warningDelayMs,
warningStartTime
};
}); set({
sensorData: updated,
isConnected: data.connected || false,
activeAlarms: alarmManager.getActiveAlarms()
});
if (newAlerts.length > 0) {
get().addAlerts(newAlerts);
}
} else { // No sensor data available - set as disconnected
const { alarmManager } = get();
set({
isConnected: false,
activeAlarms: alarmManager.getActiveAlarms()
});
} } catch (error) {
console.error('Failed to fetch sensor data:', error);
// Set as disconnected on error
const { alarmManager } = get();
set({
isConnected: false,
activeAlarms: alarmManager.getActiveAlarms()
});
}
},
fetchSensorHistory: async (sensorId: string, timespan?: number) => {
try {
const { selectedTimespan } = get();
const timespanToUse = timespan || selectedTimespan;
const response = await fetch(`/api/sensors/history?sensorId=${sensorId}&timespan=${timespanToUse}`);
const data = await response.json();
if (data.success && data.data) {
const { sensorData } = get();
const updated = { ...sensorData };
if (updated[sensorId]) {
updated[sensorId] = {
...updated[sensorId],
data: data.data
};
set({ sensorData: updated });
}
}
} catch (error) {
console.error('Failed to fetch sensor history:', error);
}
},
// Alarm actions
acknowledgeAlarm: (alarmId: string) => {
const { alarmManager } = get();
alarmManager.acknowledgeAlarm(alarmId);
set({ activeAlarms: alarmManager.getActiveAlarms() });
},
silenceAlarm: (alarmId: string, durationMs?: number) => {
const { alarmManager } = get();
alarmManager.silenceAlarm(alarmId, durationMs);
set({ activeAlarms: alarmManager.getActiveAlarms() });
},
silenceAllAlarms: (durationMs?: number) => {
const { alarmManager } = get();
alarmManager.silenceAllAlarms(durationMs);
set({ activeAlarms: alarmManager.getActiveAlarms() });
}
}));

View file

@ -1,28 +1,105 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noImplicitAny": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"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. */
// "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. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "services/BedHardwareSerial.ts.disabled"],
"exclude": ["node_modules"]
}

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

View file

@ -1,53 +0,0 @@
export interface PinState {
pin: number;
state: number;
name: string;
timestamp: Date;
}
export interface PinChange {
pin: number;
previousState: number;
currentState: number;
timestamp: Date;
}
export interface BedHardwareConfig {
type: 'serial' | 'mqtt';
serial?: {
portPath: string;
baudRate?: number;
};
mqtt?: {
topics?: {
pinState: string;
pinChange: string;
initialization: string;
};
};
}
export interface IBedHardware {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPinState(pin: number): PinState | undefined;
getAllPinStates(): PinState[];
isConnected(): boolean;
// Event emitter methods on(event: 'connected', listener: () => void): this;
on(event: 'disconnected', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'initialized', listener: () => void): this;
on(event: 'pinInitialized', listener: (pinState: PinState) => void): this;
on(event: 'pinChanged', listener: (pinChange: PinChange) => void): this;
on(event: string, listener: (...args: unknown[]) => void): this;
emit(event: 'connected'): boolean;
emit(event: 'disconnected'): boolean;
emit(event: 'error', error: Error): boolean;
emit(event: 'initialized'): boolean;
emit(event: 'pinInitialized', pinState: PinState): boolean;
emit(event: 'pinChanged', pinChange: PinChange): boolean;
emit(event: string, ...args: unknown[]): boolean;
}
export type BedHardwareType = 'serial' | 'mqtt';

View file

@ -1,24 +0,0 @@
export interface SensorConfig {
id: string;
x: number;
y: number;
zone: string;
label: string;
pin?: number;
warningThreshold: number;
alarmThreshold: number;
warningDelayMs: number; // Duration in milliseconds before warning escalates to alarm
baseNoise?: number; // For hardware sensors - noise level for analog conversion
}
export interface SensorThresholds {
warningThreshold: number;
alarmThreshold: number;
warningDelayMs: number;
}
export interface SensorZoneConfig {
zone: string;
defaultThresholds: SensorThresholds;
description: string;
}

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;

View file

@ -1,152 +0,0 @@
import { SensorConfig, SensorZoneConfig } from '@/types/sensor';
// Define zone-based default configurations
export const ZONE_CONFIGS: Record<string, SensorZoneConfig> = {
head: {
zone: 'head',
defaultThresholds: {
warningThreshold: 3000,
alarmThreshold: 3500,
warningDelayMs: 30000 // 30 seconds - critical area needs fast response
},
description: 'Head area - most critical, fastest escalation'
},
shoulders: {
zone: 'shoulders',
defaultThresholds: {
warningThreshold: 2800,
alarmThreshold: 3200,
warningDelayMs: 45000 // 45 seconds
},
description: 'Shoulder area - high priority, moderate escalation'
},
back: {
zone: 'back',
defaultThresholds: {
warningThreshold: 2500,
alarmThreshold: 3000,
warningDelayMs: 60000 // 1 minute
},
description: 'Back area - moderate priority, standard escalation'
},
hips: {
zone: 'hips',
defaultThresholds: {
warningThreshold: 2200,
alarmThreshold: 2800,
warningDelayMs: 90000 // 90 seconds
},
description: 'Hip area - moderate priority, longer escalation'
},
legs: {
zone: 'legs',
defaultThresholds: {
warningThreshold: 2000,
alarmThreshold: 2500,
warningDelayMs: 120000 // 2 minutes
},
description: 'Leg area - lower priority, extended escalation'
},
feet: {
zone: 'feet',
defaultThresholds: {
warningThreshold: 1500,
alarmThreshold: 1800,
warningDelayMs: 180000 // 3 minutes
},
description: 'Feet area - lowest priority, longest escalation'
}
};
/**
* Validates sensor configuration against zone defaults
*/
export function validateSensorConfig(config: SensorConfig): {
isValid: boolean;
warnings: string[];
errors: string[];
} {
const warnings: string[] = [];
const errors: string[] = [];
// Check if zone exists
const zoneConfig = ZONE_CONFIGS[config.zone];
if (!zoneConfig) {
errors.push(`Unknown zone: ${config.zone}`);
return { isValid: false, warnings, errors };
}
// Validate threshold values
if (config.alarmThreshold <= config.warningThreshold) {
errors.push(`Alarm threshold (${config.alarmThreshold}) must be greater than warning threshold (${config.warningThreshold})`);
}
if (config.warningThreshold <= 0 || config.alarmThreshold <= 0) {
errors.push('Thresholds must be positive values');
}
if (config.warningDelayMs <= 0) {
errors.push('Warning delay must be positive');
}
// Check against zone defaults (warnings only)
const defaults = zoneConfig.defaultThresholds;
const tolerance = 0.2; // 20% tolerance
if (Math.abs(config.warningThreshold - defaults.warningThreshold) / defaults.warningThreshold > tolerance) {
warnings.push(`Warning threshold (${config.warningThreshold}) differs significantly from zone default (${defaults.warningThreshold})`);
}
if (Math.abs(config.alarmThreshold - defaults.alarmThreshold) / defaults.alarmThreshold > tolerance) {
warnings.push(`Alarm threshold (${config.alarmThreshold}) differs significantly from zone default (${defaults.alarmThreshold})`);
}
if (Math.abs(config.warningDelayMs - defaults.warningDelayMs) / defaults.warningDelayMs > tolerance) {
warnings.push(`Warning delay (${config.warningDelayMs}ms) differs significantly from zone default (${defaults.warningDelayMs}ms)`);
}
return { isValid: errors.length === 0, warnings, errors };
}
/**
* Creates a sensor config with zone defaults as base
*/
export function createSensorConfig(
id: string,
position: { x: number; y: number },
zone: string,
label: string,
overrides: Partial<SensorConfig> = {}
): SensorConfig {
const zoneConfig = ZONE_CONFIGS[zone];
if (!zoneConfig) {
throw new Error(`Unknown zone: ${zone}`);
}
return {
id,
x: position.x,
y: position.y,
zone,
label,
...zoneConfig.defaultThresholds,
...overrides
};
}
/**
* Gets human-readable duration string
*/
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}