feat: add CORS support, public JS SDK, and live Render vitals forwarding
This commit is contained in:
@@ -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
|
||||
|
||||
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}")
|
||||
# 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", "")
|
||||
|
||||
return False
|
||||
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}")
|
||||
|
||||
return primary_success or render_success
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user