feat: add CORS support, public JS SDK, and live Render vitals forwarding

This commit is contained in:
2026-07-13 14:59:37 +05:30
parent b92a33d69f
commit c086bf5d27
3 changed files with 311 additions and 23 deletions
+48 -23
View File
@@ -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
+14
View File
@@ -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")
@@ -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;
}