Files

250 lines
7.4 KiB
JavaScript

/**
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
* Works in Web, React, React Native, and Node.js environments.
*
* Features:
* - Real-time streaming via WebSockets
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
* - Exponential backoff auto-reconnection
* - Lifecycle callbacks for UI updates
*/
class ContecVitalsClient {
/**
* @param {Object} config - Client configuration options
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
* @param {function} [config.onVitals] - Callback when new vitals data arrives
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
* @param {function} [config.onError] - Callback on error
*/
constructor(config) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.deviceId = config.deviceId || null;
this.pollingInterval = config.pollingInterval || 3000;
// Callbacks
this.onVitals = config.onVitals || (() => {});
this.onStatusChange = config.onStatusChange || (() => {});
this.onError = config.onError || (() => {});
// State management
this.ws = null;
this.pollTimer = null;
this.status = 'idle';
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isStarted = false;
this.lastVitalsTime = 0;
}
/**
* Start listening for vital signs.
*/
start() {
if (this.isStarted) return;
this.isStarted = true;
this.reconnectAttempts = 0;
this._setStatus('connecting');
this._connectWebSocket();
}
/**
* Stop listening and clean up all connections/timers.
*/
stop() {
this.isStarted = false;
this._clearPollTimer();
this._closeWebSocket();
this._setStatus('idle');
}
/**
* Change current status and trigger callback.
* @private
*/
_setStatus(newStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
this.onStatusChange(newStatus);
}
}
/**
* Parse the base URL to construct the WS URL.
* @private
*/
_getWebSocketUrl() {
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
return `${wsProto}://${cleanUrl}/ws/vitals`;
}
/**
* Open WebSocket connection.
* @private
*/
_connectWebSocket() {
this._clearPollTimer();
const wsUrl = this._getWebSocketUrl();
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('ContecVitalsClient: WebSocket connected');
this._setStatus('connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this._processIncomingVitals(data);
} catch (err) {
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
}
};
this.ws.onerror = (err) => {
this.onError(err);
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isStarted) return;
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
// Attempt reconnection
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this._setStatus('connecting');
setTimeout(() => {
if (this.isStarted) this._connectWebSocket();
}, delay);
} else {
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
this._startPolling();
}
};
} catch (err) {
this.onError(err);
this._startPolling();
}
}
/**
* Close WebSocket connection cleanly.
* @private
*/
_closeWebSocket() {
if (this.ws) {
// Remove event listeners to prevent onclose reconnect logic
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
try {
this.ws.close();
} catch (e) {}
this.ws = null;
}
}
/**
* Start HTTP Polling.
* @private
*/
_startPolling() {
this._closeWebSocket();
this._setStatus('polling');
this._poll();
}
/**
* Perform single HTTP poll.
* @private
*/
async _poll() {
if (!this.isStarted || this.status !== 'polling') return;
try {
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const readings = await response.json();
if (Array.isArray(readings) && readings.length > 0) {
this._processIncomingVitals(readings[0]);
}
} catch (err) {
console.error('ContecVitalsClient polling error:', err.message);
this.onError(err);
}
// Schedule next poll
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
}
/**
* Clear the HTTP polling timer.
* @private
*/
_clearPollTimer() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/**
* Normalizes and emits the vitals data payload.
* @private
*/
_processIncomingVitals(data) {
if (!data) return;
// Filter by device if configured
if (this.deviceId && data.device_id !== this.deviceId) {
return;
}
// Normalize property names and types
const normalized = {
deviceId: data.device_id,
patientId: data.patient_id,
timestamp: data.timestamp,
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
};
// Calculate BP display string helper
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
? `${normalized.systolicBp}/${normalized.diastolicBp}`
: null;
this.onVitals(normalized);
}
}
// Export for ES6/Node compatibility
if (typeof module !== 'undefined' && module.exports) {
module.exports = ContecVitalsClient;
} else if (typeof window !== 'undefined') {
window.ContecVitalsClient = ContecVitalsClient;
}