diff --git a/fiveparaminte-main/api_client.py b/fiveparaminte-main/api_client.py index 643b444..7882a8a 100644 --- a/fiveparaminte-main/api_client.py +++ b/fiveparaminte-main/api_client.py @@ -8,31 +8,56 @@ logger = logging.getLogger(__name__) async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool: """ - Attempts to POST the normalized vitals to the configured target API. - Returns True if successful, False otherwise. + Attempts to POST the normalized vitals to the configured target API + and to the Render cloud database. + Returns True if at least one forward succeeds. """ - url = settings.target_api_url + import os headers = {"Content-Type": "application/json"} - if settings.api_token: - headers["Authorization"] = f"Bearer {settings.api_token}" + # 1. Forward to primary target API (e.g. Injomo Dev Workflow) + primary_url = settings.target_api_url + primary_success = False + if primary_url: + try: + primary_headers = headers.copy() + if settings.api_token: + primary_headers["Authorization"] = f"Bearer {settings.api_token}" + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + primary_url, + json=vitals.model_dump(mode="json"), + headers=primary_headers + ) + response.raise_for_status() + logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}") + primary_success = True + except Exception as e: + logger.error(f"Error forwarding to primary API: {e}") - try: - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - url, - json=vitals.model_dump(mode="json"), - headers=headers - ) - response.raise_for_status() - logger.info(f"Successfully forwarded vitals for device {vitals.device_id}") - return True + # 2. Forward to Render Cloud Server + render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals" + render_success = False + + # Detect if we are already running on Render to prevent an infinite loop + is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "") + + if not is_on_render: + try: + url_with_key = render_url + if settings.api_token: + url_with_key = f"{render_url}?api_key={settings.api_token}" + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + url_with_key, + json=vitals.model_dump(mode="json"), + headers=headers + ) + response.raise_for_status() + logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}") + render_success = True + except Exception as e: + logger.error(f"Error forwarding to Render Cloud: {e}") - except httpx.HTTPStatusError as e: - logger.error(f"API returned HTTP {e.response.status_code}: {e.response.text}") - except httpx.RequestError as e: - logger.error(f"Request to API failed: {e}") - except Exception as e: - logger.error(f"Unexpected error forwarding to API: {e}") - - return False + return primary_success or render_success diff --git a/fiveparaminte-main/main.py b/fiveparaminte-main/main.py index 1bf42ef..617f971 100644 --- a/fiveparaminte-main/main.py +++ b/fiveparaminte-main/main.py @@ -143,9 +143,23 @@ app = FastAPI( lifespan=lifespan ) +# Enable CORS for cross-origin app integrations (e.g. Electron apps, local dev servers) +from fastapi.middleware.cors import CORSMiddleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Include dashboard routes app.include_router(dashboard.router, prefix="/api") +# Serve static folder +from fastapi.staticfiles import StaticFiles +app.mount("/static", StaticFiles(directory="static"), name="static") + # === WebSocket endpoint for live vitals === @app.websocket("/ws/vitals") diff --git a/fiveparaminte-main/static/contec-vitals-sdk.js b/fiveparaminte-main/static/contec-vitals-sdk.js new file mode 100644 index 0000000..8bb37f5 --- /dev/null +++ b/fiveparaminte-main/static/contec-vitals-sdk.js @@ -0,0 +1,249 @@ +/** + * 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; +}