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