dynamic graph

This commit is contained in:
Siwat Sirichai 2025-06-21 12:55:27 +07:00
parent a606796d9e
commit 5e029ff99c
17 changed files with 1707 additions and 569 deletions

207
services/AlarmManager.ts Normal file
View file

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

View file

@ -0,0 +1,131 @@
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' | 'mock';
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) {
// Generate mock data if no real data exists
return this.generateMockTimeSeriesData(timespan);
}
return sensorData.map(point => ({
time: point.time,
timestamp: point.timestamp,
value: point.value
}));
}
private generateMockTimeSeriesData(timespan: number): Array<{ time: string; timestamp: number; value: number }> {
const data = [];
const now = Date.now();
const interval = Math.max(1000, timespan / 288); // At least 1 second intervals, up to 288 points
for (let i = timespan; i >= 0; i -= interval) {
const timestamp = now - i;
const time = new Date(timestamp);
data.push({
time: time.toLocaleTimeString("en-US", { hour12: false }),
timestamp: timestamp,
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60000) * 500 + 2000), // 0-4095 range
});
}
return data;
}
}