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:
|
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempts to POST the normalized vitals to the configured target API.
|
Attempts to POST the normalized vitals to the configured target API
|
||||||
Returns True if successful, False otherwise.
|
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"}
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
if settings.api_token:
|
# 1. Forward to primary target API (e.g. Injomo Dev Workflow)
|
||||||
headers["Authorization"] = f"Bearer {settings.api_token}"
|
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:
|
# 2. Forward to Render Cloud Server
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
|
||||||
response = await client.post(
|
render_success = False
|
||||||
url,
|
|
||||||
json=vitals.model_dump(mode="json"),
|
# Detect if we are already running on Render to prevent an infinite loop
|
||||||
headers=headers
|
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
|
||||||
)
|
|
||||||
response.raise_for_status()
|
if not is_on_render:
|
||||||
logger.info(f"Successfully forwarded vitals for device {vitals.device_id}")
|
try:
|
||||||
return True
|
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:
|
return primary_success or render_success
|
||||||
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
|
|
||||||
|
|||||||
@@ -143,9 +143,23 @@ app = FastAPI(
|
|||||||
lifespan=lifespan
|
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
|
# Include dashboard routes
|
||||||
app.include_router(dashboard.router, prefix="/api")
|
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 ===
|
# === WebSocket endpoint for live vitals ===
|
||||||
@app.websocket("/ws/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