Compare commits
No commits in common. "main" and "master" have entirely different histories.
60 changed files with 1782 additions and 4460 deletions
7
.env
Normal file
7
.env
Normal 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
32
.gitignore
vendored
|
@ -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
|
39
README.md
39
README.md
|
@ -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.
|
|
@ -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
44
app.ts
Normal 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();
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
122
app/globals.css
122
app/globals.css
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
37
app/page.tsx
37
app/page.tsx
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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,
|
||||
}
|
|
@ -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
58
config/env.ts
Normal 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;
|
|
@ -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;
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
49
package.json
49
package.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
5
prisma/client.ts
Normal file
5
prisma/client.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { PrismaClient } from "~/generated/prisma";
|
||||
|
||||
const db = new PrismaClient()
|
||||
|
||||
export default db;
|
81
prisma/schema.prisma
Normal file
81
prisma/schema.prisma
Normal 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
219
prisma/seed.ts
Normal 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();
|
||||
});
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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
5
routes/history.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Elysia from "elysia";
|
||||
|
||||
const bedRouter = new Elysia()
|
||||
|
||||
export default bedRouter;
|
172
routes/state.ts
Normal file
172
routes/state.ts
Normal 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
19
routes/swagger.ts
Normal 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
148
services/AlarmManagement.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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
228
services/BedService.ts
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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
232
services/StateManager.ts
Normal 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
201
store/AlarmStateStore.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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}×pan=${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() });
|
||||
}
|
||||
}));
|
123
tsconfig.json
123
tsconfig.json
|
@ -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
63
types/FrontendState.ts
Normal 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;
|
||||
}
|
|
@ -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';
|
|
@ -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
83
utils/logger.ts
Normal 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;
|
|
@ -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`;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue