250 lines
7.4 KiB
JavaScript
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;
|
|
}
|