feat: implement Contec patient monitor discovery tool and server framework

This commit is contained in:
2026-07-13 15:54:28 +05:30
parent 5d418c8eb0
commit 3a8710695d
26 changed files with 3881 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
__pycache__/
*.py[cod]
*.db
*.sqlite3
*.log
*.bin
.env
venv/
.venv/
build/
dist/
*.spec
scratch/
captures/
.git/
.idea/
.vscode/
+37
View File
@@ -0,0 +1,37 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# PyInstaller
build/
dist/
*.spec
# Databases
*.db
*.sqlite3
# Logs
*.log
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Scratch / debug
scratch/
captures/
*.bin
# IDE / OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Render sets PORT env var (default 10000)
EXPOSE 10000
CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-10000}
+1
View File
@@ -0,0 +1 @@
web: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
+63
View File
@@ -0,0 +1,63 @@
import httpx
import logging
from typing import Optional
from schemas import NormalizedVitals
from config import settings
logger = logging.getLogger(__name__)
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
"""
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.
"""
import os
headers = {"Content-Type": "application/json"}
# 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}")
# 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}")
return primary_success or render_success
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import logging
from database import SessionLocal
from models import VitalReading, TransmissionLog
from schemas import NormalizedVitals
from api_client import forward_vitals_to_api
from config import settings
logger = logging.getLogger(__name__)
async def retry_failed_transmissions():
"""
Background task that periodically checks for untransmitted vital readings
and attempts to forward them to the API.
"""
logger.info("Starting background task for retrying failed transmissions...")
while True:
try:
db = SessionLocal()
try:
# Get readings that haven't been transmitted
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).limit(50).all()
for reading in failed_readings:
vitals = NormalizedVitals(
device_id=reading.device_id,
patient_id=reading.patient_id,
timestamp=reading.timestamp,
heart_rate=reading.heart_rate,
spo2=reading.spo2,
systolic_bp=reading.systolic_bp,
diastolic_bp=reading.diastolic_bp,
map_bp=reading.map_bp,
respiratory_rate=reading.respiratory_rate,
temperature=reading.temperature
)
success = await forward_vitals_to_api(vitals)
if success:
reading.transmitted = True
log_entry = TransmissionLog(
reading_id=reading.id,
status="success",
response_code=200
)
db.add(log_entry)
logger.info(f"Successfully re-transmitted reading {reading.id}")
else:
# Log the failure but don't mark as transmitted
log_entry = TransmissionLog(
reading_id=reading.id,
status="failed",
error_message="Retry failed"
)
db.add(log_entry)
if failed_readings:
db.commit()
finally:
db.close()
except Exception as e:
logger.error(f"Error in retry background task: {e}")
await asyncio.sleep(settings.retry_interval_seconds)
+29
View File
@@ -0,0 +1,29 @@
@echo off
echo Building Mindray HL7 Forwarder Executable...
REM Clean previous builds
rmdir /s /q build
rmdir /s /q dist
REM Run PyInstaller with necessary hidden imports for FastAPI, Uvicorn, and SQLAlchemy
pyinstaller -F --clean ^
--name "MindrayHL7Forwarder" ^
--hidden-import uvicorn.logging ^
--hidden-import uvicorn.loops ^
--hidden-import uvicorn.loops.auto ^
--hidden-import uvicorn.protocols ^
--hidden-import uvicorn.protocols.http ^
--hidden-import uvicorn.protocols.http.auto ^
--hidden-import uvicorn.protocols.websockets ^
--hidden-import uvicorn.protocols.websockets.auto ^
--hidden-import uvicorn.lifespan ^
--hidden-import uvicorn.lifespan.on ^
--hidden-import uvicorn.lifespan.off ^
--hidden-import fastapi ^
--hidden-import pydantic ^
--hidden-import sqlalchemy ^
--hidden-import hl7 ^
main.py
echo Build complete! Executable is located in the dist/ folder.
pause
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
echo "Building Mindray HL7 Forwarder Executable for Linux..."
# Clean previous builds
rm -rf build
rm -rf dist
# Run PyInstaller with necessary hidden imports for FastAPI, Uvicorn, and SQLAlchemy
pyinstaller -F --clean \
--name "MindrayHL7Forwarder" \
--hidden-import uvicorn.logging \
--hidden-import uvicorn.loops \
--hidden-import uvicorn.loops.auto \
--hidden-import uvicorn.protocols \
--hidden-import uvicorn.protocols.http \
--hidden-import uvicorn.protocols.http.auto \
--hidden-import uvicorn.protocols.websockets \
--hidden-import uvicorn.protocols.websockets.auto \
--hidden-import uvicorn.lifespan \
--hidden-import uvicorn.lifespan.on \
--hidden-import uvicorn.lifespan.off \
--hidden-import fastapi \
--hidden-import pydantic \
--hidden-import sqlalchemy \
--hidden-import hl7 \
main.py
echo "Build complete! Executable is located in the dist/ folder."
+15
View File
@@ -0,0 +1,15 @@
{
"hl7_host": "0.0.0.0",
"hl7_port": 6060,
"contec_ports": [511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 8001, 8002, 8300, 9000, 9100, 9200, 10008, 12345],
"monitor_ip": "",
"monitor_model": "CMS8500",
"device_models": {
"192.168.100.120": "CMS7000PLUS"
},
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3",
"api_token": "",
"retry_interval_seconds": 60,
"log_file": "hl7_forwarder.log",
"database_url": "sqlite:///./hl7_forwarder.db"
}
+45
View File
@@ -0,0 +1,45 @@
import json
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
hl7_host: str = "0.0.0.0"
hl7_port: int = 6060
contec_ports: list = [511, 515, 516, 517, 518, 519, 520]
monitor_ip: str = ""
monitor_model: str = "CMS7000PLUS"
device_models: dict = {}
target_api_url: str = "https://api.example.com/vitals"
api_token: str = ""
retry_interval_seconds: int = 60
log_file: str = "hl7_forwarder.log"
database_url: str = "sqlite:///./hl7_forwarder.db"
class Config:
env_file = ".env"
def get_settings() -> Settings:
base_dir = os.path.dirname(os.path.abspath(__file__))
config_file = os.path.join(base_dir, "config.json")
settings_obj = Settings()
if os.path.exists(config_file):
with open(config_file, "r") as f:
try:
data = json.load(f)
for k, v in data.items():
setattr(settings_obj, k, v)
except json.JSONDecodeError:
pass
if settings_obj.database_url.startswith("sqlite:///./"):
db_name = settings_obj.database_url.split("sqlite:///./")[1]
settings_obj.database_url = f"sqlite:///{os.path.join(base_dir, db_name)}"
if not os.path.isabs(settings_obj.log_file):
settings_obj.log_file = os.path.join(base_dir, settings_obj.log_file)
return settings_obj
settings = get_settings()
+246
View File
@@ -0,0 +1,246 @@
"""
Contec CMS7000PLUS / CMS9200PLUS Discovery Tool
==================================
This script listens on multiple TCP ports simultaneously to discover
what data the CMS7000PLUS / CMS9200PLUS patient monitor sends over the network.
Usage:
python contec_discovery.py
It will listen on ports: 511, 515, 516, 517, 518, 519, 520, 6060
and display all received data in hex + ASCII format for protocol analysis.
IMPORTANT: Run as Administrator (required for port 511 on Windows)
"""
import asyncio
import logging
import sys
import os
from datetime import datetime
# --- Configuration ---
LISTEN_HOST = "0.0.0.0"
DISCOVERY_PORTS = [511, 515, 516, 517, 518, 519, 520, 6060]
LOG_DIR = "captures"
# --- Setup Logging ---
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("contec_discovery")
def hex_dump(data: bytes, width: int = 16) -> str:
"""Format bytes as a hex dump with ASCII representation."""
lines = []
for i in range(0, len(data), width):
chunk = data[i:i + width]
hex_part = ' '.join(f'{b:02x}' for b in chunk)
ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
lines.append(f" {i:04x} {hex_part:<{width * 3}} |{ascii_part}|")
return '\n'.join(lines)
def try_decode_hl7(data: bytes) -> str | None:
"""Attempt to decode the data as an HL7 message."""
try:
# Strip MLLP framing if present
cleaned = data
if cleaned.startswith(b'\x0b'):
cleaned = cleaned[1:]
if b'\x1c\x0d' in cleaned:
cleaned = cleaned[:cleaned.find(b'\x1c\x0d')]
text = cleaned.decode('utf-8', errors='ignore').strip()
if 'MSH|' in text or 'MSH' in text[:20]:
return text
except Exception:
pass
return None
async def handle_discovery_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
port: int,
capture_file
):
"""Handle a single incoming connection and dump all data."""
client_ip, client_port = writer.get_extra_info('peername')
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
print("\n" + "=" * 80)
print(f" NEW CONNECTION on port {port}")
print(f" From: {client_ip}:{client_port}")
print(f" Time: {timestamp}")
print("=" * 80)
capture_file.write(f"\n{'='*80}\n")
capture_file.write(f"Connection on port {port} from {client_ip}:{client_port} at {timestamp}\n")
capture_file.write(f"{'='*80}\n")
capture_file.flush()
packet_count = 0
try:
while True:
data = await asyncio.wait_for(reader.read(8192), timeout=30.0)
if not data:
break
packet_count += 1
recv_time = datetime.now().strftime("%H:%M:%S.%f")
print(f"\n--- Packet #{packet_count} on port {port} [{recv_time}] ({len(data)} bytes) ---")
print(hex_dump(data))
# Try to decode as HL7
hl7_text = try_decode_hl7(data)
if hl7_text:
print(f"\n >>> HL7 MESSAGE DETECTED <<<")
segments = hl7_text.replace('\n', '\r').split('\r')
for seg in segments:
seg = seg.strip()
if seg:
print(f" {seg}")
# Extract vital signs from OBX segments
if seg.startswith("OBX|"):
fields = seg.split('|')
if len(fields) > 5:
obs_id = fields[3] if len(fields) > 3 else "?"
obs_value = fields[5] if len(fields) > 5 else "?"
obs_unit = fields[6] if len(fields) > 6 else ""
print(f" -> Vital: {obs_id} = {obs_value} {obs_unit}")
# Also try plain text decode
try:
text = data.decode('utf-8', errors='ignore')
printable_ratio = sum(1 for c in text if c.isprintable() or c in '\r\n\t') / max(len(text), 1)
if printable_ratio > 0.7 and not hl7_text:
print(f"\n [Text representation]:")
for line in text.split('\r'):
line = line.strip()
if line:
print(f" {line}")
except Exception:
pass
# Write to capture file
capture_file.write(f"\nPacket #{packet_count} [{recv_time}] ({len(data)} bytes)\n")
capture_file.write(hex_dump(data) + "\n")
if hl7_text:
capture_file.write(f"HL7: {hl7_text}\n")
capture_file.write(f"Raw bytes: {data.hex()}\n")
capture_file.flush()
except asyncio.TimeoutError:
print(f" [Timeout - no data for 30s on port {port}]")
except asyncio.CancelledError:
pass
except ConnectionResetError:
print(f" [Connection reset by {client_ip} on port {port}]")
except Exception as e:
print(f" [Error on port {port}: {e}]")
finally:
print(f"\n Connection closed on port {port} from {client_ip} ({packet_count} packets received)")
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
async def start_port_listener(port: int, capture_file):
"""Start a TCP server on a specific port."""
async def client_handler(reader, writer):
await handle_discovery_client(reader, writer, port, capture_file)
try:
server = await asyncio.start_server(client_handler, LISTEN_HOST, port)
logger.info(f" ✓ Listening on port {port}")
return server
except PermissionError:
logger.error(f" ✗ Port {port} - Permission denied (try running as Administrator)")
return None
except OSError as e:
if "already in use" in str(e).lower() or e.errno == 10048:
logger.error(f" ✗ Port {port} - Already in use by another application")
else:
logger.error(f" ✗ Port {port} - {e}")
return None
async def main():
print(r"""
╔══════════════════════════════════════════════════════════════╗
║ Contec CMS7000PLUS / CMS9200PLUS Discovery Tool ║
║ ║
║ This tool listens for incoming data from your patient ║
║ monitor to discover its communication protocol. ║
║ ║
║ SETUP REQUIRED: ║
║ 1. Connect monitor to your PC via Ethernet ║
║ 2. Set your PC's Ethernet IP to: 192.168.1.50 ║
║ (or whatever subnet your monitor uses) ║
║ 3. On the monitor, set CMS/Server IP to YOUR PC's IP ║
║ 4. Run this script as Administrator ║
║ ║
║ Press Ctrl+C to stop ║
╚══════════════════════════════════════════════════════════════╝
""")
# Create captures directory
os.makedirs(LOG_DIR, exist_ok=True)
capture_filename = os.path.join(
LOG_DIR,
f"capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
)
capture_file = open(capture_filename, 'w', encoding='utf-8')
logger.info(f"Capture log: {capture_filename}")
# Show current network config hint
print("\nStarting listeners on all discovery ports...")
print("(If port 511 fails, run PowerShell as Administrator)\n")
servers = []
for port in DISCOVERY_PORTS:
server = await start_port_listener(port, capture_file)
if server:
servers.append(server)
if not servers:
logger.error("No ports could be opened! Check permissions and existing services.")
capture_file.close()
return
active_ports = [s.sockets[0].getsockname()[1] for s in servers if s.sockets]
print(f"\n{'' * 60}")
print(f" Active on ports: {active_ports}")
print(f" Waiting for data from CMS7000PLUS / CMS9200PLUS...")
print(f" Make sure the monitor's CMS/Server IP points to this PC")
print(f"{'' * 60}\n")
try:
# Keep running until Ctrl+C
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
finally:
print("\nShutting down...")
for server in servers:
server.close()
for server in servers:
await server.wait_closed()
capture_file.close()
print(f"Capture saved to: {capture_filename}")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n\nDiscovery stopped by user.")
+312
View File
@@ -0,0 +1,312 @@
import asyncio
import logging
from datetime import datetime, timezone
from contec_parser import parse_contec_data
from database import SessionLocal
from models import Device, Patient, VitalReading, TransmissionLog
from api_client import forward_vitals_to_api
from terminal_display import get_terminal_display
logger = logging.getLogger(__name__)
# Constants for MLLP framing (used on port 511 for Contec HL7 text)
VT = b'\x0b'
FS_CR = b'\x1c\x0d'
def generate_ack(received_msg: str) -> str:
"""Generates a simple HL7 ACK message to satisfy the monitor's TCP client."""
try:
segments = received_msg.split('\r')
msh = segments[0] if segments else ""
if not msh.startswith("MSH|"):
return ""
fields = msh.split('|')
if len(fields) < 10:
return ""
sending_app = fields[2]
sending_fac = fields[3]
rec_app = fields[4]
rec_fac = fields[5]
msg_control_id = fields[9]
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
ack_msh = f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}|{now_str}||ACK^R01|{msg_control_id}|P|2.3.1\r"
ack_msa = f"MSA|AA|{msg_control_id}\r"
return ack_msh + ack_msa
except Exception as e:
logger.debug(f"Could not generate ACK: {e}")
return ""
async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
"""
TCP server handler for Contec CMS7000PLUS client connections.
Directs incoming traffic to the unified parse_contec_data handler.
"""
client_ip, client_port = writer.get_extra_info('peername')
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
display = get_terminal_display()
display.connection_opened(client_ip, port)
buffer = b""
try:
while True:
data = await reader.read(8192)
if not data:
logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
break
buffer += data
logger.debug(f"Received {len(data)} bytes from {client_ip}:{client_port} on port {port} (buf={len(buffer)})")
# ---------------------------------------------------------------
# MLLP / HL7 text path (typically port 511)
# ---------------------------------------------------------------
if VT in buffer and FS_CR in buffer and b"MSH|" in buffer:
while True:
start_idx = buffer.find(VT)
end_idx = buffer.find(FS_CR)
if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
raw_hl7 = buffer[start_idx + 1:end_idx].decode('utf-8', errors='ignore')
buffer = buffer[end_idx + 2:]
logger.info(f"Contec MLLP message detected on port {port}")
vitals = parse_contec_data(raw_hl7.encode('utf-8'), client_ip)
if vitals:
await process_vitals(vitals)
ack_msg = generate_ack(raw_hl7)
if ack_msg:
writer.write(VT + ack_msg.encode('utf-8') + FS_CR)
await writer.drain()
else:
break
continue # Go back to reading more data
# ---------------------------------------------------------------
# Binary Contec proprietary protocol (port 514)
# Packets are framed as: [len_lo][len_hi][04][46] ... payload ...
# The monitor sends each packet as a complete TCP write,
# but we buffer anyway in case of TCP segmentation.
# ---------------------------------------------------------------
consumed = 0
while consumed < len(buffer) - 4:
b0 = buffer[consumed]
b1 = buffer[consumed + 1]
b2 = buffer[consumed + 2]
b3 = buffer[consumed + 3]
if (b2 == 0x04 or b2 == 0x01 or b2 == 0x00) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
pkt_end = consumed + pkt_len
if pkt_len < 5 or pkt_len > 8192:
# Bad length — skip one byte and keep scanning
consumed += 1
continue
if pkt_end > len(buffer):
# Don't have the full packet yet — wait for more data
break
# Parse the complete packet
pkt = buffer[consumed:pkt_end]
vitals = parse_contec_data(pkt, client_ip)
if vitals:
logger.info(
f"[PORT {port}] Vitals from {client_ip}: "
f"SpO2={vitals.spo2}%, HR={vitals.heart_rate}bpm "
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp} "
f"Temp={vitals.temperature}C"
)
await process_vitals(vitals)
consumed = pkt_end
elif (b2 == 0x04 or b2 == 0x01) and b3 == 0x47:
# End-of-frame marker — skip 4 bytes
consumed += 4
else:
# Not a recognised frame start — advance one byte
consumed += 1
# Discard consumed bytes from front of buffer
buffer = buffer[consumed:]
# Safety valve
if len(buffer) > 32768:
logger.warning(f"Buffer too large ({len(buffer)} bytes) on port {port}, resetting")
buffer = b""
except asyncio.CancelledError:
pass
except ConnectionResetError:
logger.info(f"Contec CMS7000PLUS connection reset from {client_ip} on port {port}")
except Exception as e:
logger.error(f"Error in Contec client handler (port {port}): {e}")
finally:
logger.info(f"Closing Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
display.connection_closed(client_ip, port)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
# Cache to hold merged vitals per device to prevent fragmentation
device_cache = {}
device_field_timestamps = {} # {device_id: {field_name: datetime}}
last_db_write = {}
async def process_vitals(vitals):
"""
Log, database, WebSocket broadcast, and REST forward parsed vitals.
"""
device_id = vitals.device_id
now = datetime.now(timezone.utc)
if device_id not in device_field_timestamps:
device_field_timestamps[device_id] = {}
# 1. Merge new vitals into cached vitals to prevent fragmented entries
if device_id not in device_cache:
device_cache[device_id] = vitals
# Track initial timestamps for present values
for field, value in vitals.model_dump().items():
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
continue
if value is not None:
device_field_timestamps[device_id][field] = now
else:
cached = device_cache[device_id]
# Determine active fields in incoming packet
active_fields = []
if vitals.present_fields is not None:
active_fields = vitals.present_fields
else:
for field, value in vitals.model_dump().items():
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
continue
if value is not None:
active_fields.append(field)
# Update cache & record timestamps
for field in active_fields:
val = getattr(vitals, field)
setattr(cached, field, val)
device_field_timestamps[device_id][field] = now
# Clean up stale fields (older than 15 seconds)
for field in list(device_field_timestamps[device_id].keys()):
last_updated = device_field_timestamps[device_id][field]
if (now - last_updated).total_seconds() > 15.0:
setattr(cached, field, None)
device_field_timestamps[device_id].pop(field, None)
cached.timestamp = vitals.timestamp
vitals = cached
# 2. Update console display banner
display = get_terminal_display()
display.update_vitals(vitals)
logger.info(
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id}: "
f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
)
# 3. Broadcast to live WebSockets dashboard in real-time
try:
from routers.dashboard import get_ws_manager
ws_manager = get_ws_manager()
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
now = datetime.now(timezone.utc)
should_write_db = False
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0:
should_write_db = True
last_db_write[device_id] = now
if should_write_db:
# Save to SQLite database and REST forward
db = SessionLocal()
try:
# 1. Device tracking
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
if not device:
device = Device(
device_id=vitals.device_id,
ip_address=vitals.ip_address or "unknown",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
if not patient:
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
db.add(patient)
# 3. Create Reading Entry
reading = VitalReading(
device_id=vitals.device_id,
patient_id=vitals.patient_id,
timestamp=vitals.timestamp,
heart_rate=vitals.heart_rate,
spo2=vitals.spo2,
systolic_bp=vitals.systolic_bp,
diastolic_bp=vitals.diastolic_bp,
map_bp=vitals.map_bp,
respiratory_rate=vitals.respiratory_rate,
temperature=vitals.temperature,
transmitted=False
)
db.add(reading)
db.commit()
db.refresh(reading)
# 4. REST forwarding
success = await forward_vitals_to_api(vitals)
if success:
reading.transmitted = True
log_entry = TransmissionLog(
reading_id=reading.id,
status="success",
response_code=200
)
else:
log_entry = TransmissionLog(
reading_id=reading.id,
status="failed",
error_message="Immediate REST forward failed"
)
db.add(log_entry)
db.commit()
except Exception as e:
logger.error(f"Database operation failed: {e}")
db.rollback()
finally:
db.close()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from config import settings
engine = create_engine(
settings.database_url, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+189
View File
@@ -0,0 +1,189 @@
import asyncio
import logging
import multiprocessing
import socket
from contextlib import asynccontextmanager
from functools import partial
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import uvicorn
from config import settings
from database import engine, Base
from contec_server import handle_contec_client
from background_tasks import retry_failed_transmissions
from routers import dashboard
from routers.dashboard import get_ws_manager
# Setup Logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(settings.log_file),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Create database tables if they don't exist
Base.metadata.create_all(bind=engine)
def _make_contec_handler(port: int):
"""Create a connection handler closure for a specific Contec port."""
async def handler(reader, writer):
await handle_contec_client(reader, writer, port)
return handler
def _get_local_ips():
"""Get all local IP addresses for this machine."""
ips = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in ips and not ip.startswith('127.'):
ips.append(ip)
except Exception:
pass
# Also try connecting to an external address to find the primary IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
primary_ip = s.getsockname()[0]
s.close()
if primary_ip not in ips:
ips.insert(0, primary_ip)
except Exception:
pass
return ips if ips else ['Could not detect — check ipconfig']
@asynccontextmanager
async def lifespan(app: FastAPI):
servers = []
# === Start Contec CMS7000PLUS TCP Servers ===
logger.info("Starting Contec CMS7000PLUS listeners...")
for port in settings.contec_ports:
try:
contec_server = await asyncio.start_server(
_make_contec_handler(port),
settings.hl7_host,
port
)
servers.append(contec_server)
logger.info(f" [OK] Contec listener on port {port}")
except PermissionError:
logger.warning(
f" [FAIL] Port {port} - Permission denied. "
f"Run as Administrator for ports below 1024."
)
except OSError as e:
if "already in use" in str(e).lower() or getattr(e, 'errno', 0) == 10048:
logger.warning(f" [FAIL] Port {port} - Already in use")
else:
logger.warning(f" [FAIL] Port {port} - {e}")
if servers:
active_ports = []
for s in servers:
for sock in s.sockets:
active_ports.append(sock.getsockname()[1])
app.state.active_ports = active_ports
logger.info(f" Active listener ports: {active_ports}")
else:
app.state.active_ports = []
logger.error("No servers could be started! Check port availability and permissions.")
# === Print startup banner ===
import os
port = int(os.environ.get("PORT", 8000))
local_ips = _get_local_ips()
model = settings.monitor_model
print("\n" + "=" * 65)
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
print("=" * 65)
print(f" DASHBOARD: http://localhost:{port}/api/dashboard")
print(f" API Docs: http://localhost:{port}/docs")
print(f" Contec Ports: {settings.contec_ports}")
print(f" Monitor Model: {model}")
print(f" API Target: {settings.target_api_url}")
print(f" Database: {settings.database_url}")
print(f" ---------------------------------------------------------")
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
for ip in local_ips:
print(f" -> {ip}")
print("=" * 65)
print(f" Waiting for {model} connections...")
print(f" On your monitor: System Setup > Network > CMS Settings")
print(f" Set Server IP = one of the IPs above, Port = {settings.contec_ports[0] if settings.contec_ports else 511}")
print("=" * 65 + "\n")
# Startup: Start the background retry task
retry_task = asyncio.create_task(retry_failed_transmissions())
yield
# Shutdown
logger.info("Shutting down servers...")
retry_task.cancel()
for server in servers:
server.close()
for server in servers:
await server.wait_closed()
# Initialize FastAPI app
app = FastAPI(
title="Contec CMS7000PLUS Vital Signs Forwarder",
description="Service to receive vital signs from Contec CMS7000PLUS patient monitors and forward them to a REST API",
version="2.0.0",
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")
async def websocket_vitals(websocket: WebSocket):
manager = get_ws_manager()
await manager.connect(websocket)
try:
while True:
# Keep connection alive, listen for any client messages
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception:
manager.disconnect(websocket)
if __name__ == "__main__":
# Required for Windows when building with PyInstaller using multiprocessing (uvicorn workers)
multiprocessing.freeze_support()
logger.info("Initializing Application...")
# Run the FastAPI server.
# Notice we run on port 8000 (or PORT env var) for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060)
import os
port = int(os.environ.get("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime
from datetime import datetime, timezone
from database import Base
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(String, unique=True, index=True)
ip_address = Column(String)
status = Column(String, default="active")
last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class Patient(Base):
__tablename__ = "patients"
id = Column(Integer, primary_key=True, index=True)
patient_id = Column(String, unique=True, index=True)
name = Column(String)
class VitalReading(Base):
__tablename__ = "vital_readings"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(String, index=True)
patient_id = Column(String, index=True)
timestamp = Column(DateTime, index=True)
heart_rate = Column(Float, nullable=True)
spo2 = Column(Float, nullable=True)
systolic_bp = Column(Float, nullable=True)
diastolic_bp = Column(Float, nullable=True)
map_bp = Column(Float, nullable=True)
respiratory_rate = Column(Float, nullable=True)
temperature = Column(Float, nullable=True)
transmitted = Column(Boolean, default=False, index=True)
class TransmissionLog(Base):
__tablename__ = "transmission_logs"
id = Column(Integer, primary_key=True, index=True)
reading_id = Column(Integer, index=True)
status = Column(String)
response_code = Column(Integer, nullable=True)
error_message = Column(String, nullable=True)
timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+15
View File
@@ -0,0 +1,15 @@
services:
- type: web
name: contec-monitor-api
runtime: python
buildCommand: pip install -r requirements.txt
startCommand: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
envVars:
- key: PORT
value: 10000
- key: PYTHON_VERSION
value: 3.12.0
- key: TARGET_API_URL
sync: false
- key: API_TOKEN
sync: false
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn
sqlalchemy
pydantic
pydantic-settings
httpx
gunicorn
rich
websockets
+1
View File
@@ -0,0 +1 @@
# Python package marker
+271
View File
@@ -0,0 +1,271 @@
import asyncio
import json
import logging
import socket
from typing import List
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from database import get_db
from models import Device, VitalReading, TransmissionLog
logger = logging.getLogger(__name__)
router = APIRouter()
# === WebSocket Manager for real-time vitals push ===
class VitalsWebSocketManager:
"""Manages WebSocket connections for live vitals streaming."""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
logger.info(f"WebSocket client connected ({len(self.active_connections)} active)")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"WebSocket client disconnected ({len(self.active_connections)} active)")
async def broadcast_vitals(self, vitals_data: dict):
"""Send vitals to all connected WebSocket clients."""
dead = []
for connection in self.active_connections:
try:
await connection.send_json(vitals_data)
except Exception:
dead.append(connection)
for d in dead:
self.disconnect(d)
# Global singleton
ws_manager = VitalsWebSocketManager()
def get_ws_manager() -> VitalsWebSocketManager:
return ws_manager
# === Dashboard HTML Route ===
@router.get("/dashboard", response_class=HTMLResponse)
def serve_dashboard():
"""Serve the vitals monitoring dashboard."""
import os
dashboard_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "dashboard.html")
try:
with open(dashboard_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
except FileNotFoundError:
return HTMLResponse(content="<h1>Dashboard not found</h1><p>static/dashboard.html is missing</p>", status_code=404)
# === API Endpoints ===
@router.get("/health")
def health_check():
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
@router.get("/status")
def get_system_status(request: Request, db: Session = Depends(get_db)):
total_devices = db.query(Device).count()
active_devices = db.query(Device).filter(Device.status == "active").count()
total_readings = db.query(VitalReading).count()
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
active_ports = getattr(request.app.state, "active_ports", [])
return {
"total_devices": total_devices,
"active_devices": active_devices,
"total_readings_stored": total_readings,
"pending_transmissions": pending_transmissions,
"active_ports": active_ports
}
@router.get("/devices")
def list_devices(db: Session = Depends(get_db)):
devices = db.query(Device).all()
now = datetime.now(timezone.utc)
updated = False
for device in devices:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
# If not seen for more than 15 seconds, mark offline
if (now - last_seen).total_seconds() > 15.0:
if device.status != "offline":
device.status = "offline"
updated = True
else:
if device.status != "active":
device.status = "active"
updated = True
if updated:
db.commit()
return devices
@router.get("/latest-readings")
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
query = db.query(VitalReading)
if device_id:
query = query.filter(VitalReading.device_id == device_id)
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
return readings
@router.get("/readings")
def get_readings(device_id: str = None, patient_id: str = None, limit: int = 50, db: Session = Depends(get_db)):
query = db.query(VitalReading)
if device_id:
query = query.filter(VitalReading.device_id == device_id)
if patient_id:
query = query.filter(VitalReading.patient_id == patient_id)
return query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
@router.get("/transmission-logs")
def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)):
logs = db.query(TransmissionLog).order_by(TransmissionLog.timestamp.desc()).limit(limit).all()
return logs
@router.post("/test-api")
def test_target_api_connection():
"""
Endpoint to trigger a test POST to the target API.
"""
from config import settings
return {
"message": "Test triggered",
"target_url": settings.target_api_url
}
@router.get("/network-info")
def get_network_info():
"""
Returns this PC's local IP addresses.
Useful for configuring the CMS7000PLUS monitor's CMS Server IP.
"""
from config import settings
ips = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in ips and not ip.startswith('127.'):
ips.append(ip)
except Exception:
pass
# Find primary IP
primary_ip = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
primary_ip = s.getsockname()[0]
s.close()
if primary_ip not in ips:
ips.insert(0, primary_ip)
except Exception:
pass
return {
"hostname": socket.gethostname(),
"ip_addresses": ips,
"primary_ip": primary_ip or (ips[0] if ips else None),
"monitor_model": settings.monitor_model,
"contec_ports": settings.contec_ports,
"instructions": [
f"1. Connect your {settings.monitor_model} to this PC via Ethernet",
f"2. On the monitor: System Setup → Network → CMS Settings",
f"3. Set Server IP to: {primary_ip or 'your PC IP'}",
f"4. Set Server Port to: {settings.contec_ports[0] if settings.contec_ports else 511}",
f"5. Enable the CMS connection",
f"6. The dashboard will show data automatically"
]
}
# === Push Vitals Endpoint (for local-to-cloud sync) ===
from schemas import NormalizedVitals
from models import Patient
@router.post("/push-vitals")
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
"""
Receive vitals pushed from a local instance and store + broadcast them.
This enables the 'split architecture' where:
- Local instance receives data from physical monitors via TCP
- Local instance POSTs vitals here for cloud storage & dashboard access
Optional: pass ?api_key=YOUR_KEY for basic authentication.
"""
from config import settings
# Optional API key check
if settings.api_token and api_key != settings.api_token:
if settings.api_token: # Only enforce if a token is configured
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
try:
# 1. Device tracking
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
if not device:
device = Device(
device_id=vitals.device_id,
ip_address=vitals.ip_address or "remote-push",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
if not patient:
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
db.add(patient)
# 3. Create vital reading
from models import VitalReading as VR
reading = VR(
device_id=vitals.device_id,
patient_id=vitals.patient_id,
timestamp=vitals.timestamp,
heart_rate=vitals.heart_rate,
spo2=vitals.spo2,
systolic_bp=vitals.systolic_bp,
diastolic_bp=vitals.diastolic_bp,
map_bp=vitals.map_bp,
respiratory_rate=vitals.respiratory_rate,
temperature=vitals.temperature,
transmitted=True # Already received = transmitted
)
db.add(reading)
db.commit()
db.refresh(reading)
# 4. Broadcast to WebSocket clients
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
logger.info(f"Push-vitals received for device {vitals.device_id}")
return {
"status": "ok",
"reading_id": reading.id,
"device_id": vitals.device_id,
"patient_id": vitals.patient_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Push-vitals error: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
echo "=== Mindray HL7 Forwarder ==="
# 1. Create a virtual environment if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating Python virtual environment..."
python3 -m venv venv
fi
# 2. Activate the virtual environment
source venv/bin/activate
# 3. Install dependencies quietly
echo "Checking and installing dependencies..."
pip install -r requirements.txt -q
# 4. Start the server
echo "Starting the application..."
python main.py
+1
View File
@@ -0,0 +1 @@
python-3.12.0
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class NormalizedVitals(BaseModel):
device_id: str
patient_id: str
timestamp: datetime
ip_address: Optional[str] = None
heart_rate: Optional[float] = None
spo2: Optional[float] = None
systolic_bp: Optional[float] = None
diastolic_bp: Optional[float] = None
map_bp: Optional[float] = None
respiratory_rate: Optional[float] = None
temperature: Optional[float] = None
present_fields: Optional[list] = None
class DeviceStatus(BaseModel):
device_id: str
ip_address: str
status: str
last_seen: datetime
class Config:
from_attributes = True
class TransmissionLogResponse(BaseModel):
id: int
reading_id: int
status: str
response_code: Optional[int]
error_message: Optional[str]
timestamp: datetime
class Config:
from_attributes = True
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Exit on error
set -e
SERVICE_NAME="contec-forwarder"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
WORKING_DIR="/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main"
PYTHON_EXEC="${WORKING_DIR}/venv/bin/python"
SCRIPT_PATH="${WORKING_DIR}/main.py"
echo "=== Creating systemd service file ==="
sudo bash -c "cat > ${SERVICE_FILE}" <<EOF
[Unit]
Description=Contec Patient Monitor Vital Signs Forwarder
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${WORKING_DIR}
Environment=PORT=8006
ExecStart=${PYTHON_EXEC} ${SCRIPT_PATH}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
echo "=== Reloading systemd daemon ==="
sudo systemctl daemon-reload
echo "=== Enabling ${SERVICE_NAME} to start on boot ==="
sudo systemctl enable ${SERVICE_NAME}
echo "=== Starting ${SERVICE_NAME} service ==="
sudo systemctl restart ${SERVICE_NAME}
echo "=== Service Status ==="
sudo systemctl status ${SERVICE_NAME} --no-pager
echo ""
echo "=========================================================="
echo "SUCCESS: Contec Forwarder is now running in the background!"
echo "To check live service logs, run:"
echo " sudo journalctl -u contec-forwarder -f"
echo "=========================================================="
+249
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
"""
Terminal Display for Live Vital Signs
======================================
Provides a rich, color-coded terminal dashboard showing real-time
vital signs received from patient monitors.
Works with both Mindray uMEC 100 and Contec CMS9200PLUS monitors.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from schemas import NormalizedVitals
logger = logging.getLogger(__name__)
# Try to use 'rich' for beautiful output, fall back to basic print
try:
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich.layout import Layout
from rich.live import Live
from rich import box
HAS_RICH = True
except ImportError:
HAS_RICH = False
logger.info("'rich' library not installed. Using basic terminal output.")
# === Normal Range Definitions ===
VITAL_RANGES = {
"heart_rate": {"low": 60, "high": 100, "critical_low": 40, "critical_high": 150, "unit": "bpm", "label": "Heart Rate"},
"spo2": {"low": 95, "high": 100, "critical_low": 90, "critical_high": 101, "unit": "%", "label": "SpO2"},
"systolic_bp": {"low": 90, "high": 140, "critical_low": 70, "critical_high": 180, "unit": "mmHg", "label": "BP Systolic"},
"diastolic_bp": {"low": 60, "high": 90, "critical_low": 40, "critical_high": 120, "unit": "mmHg", "label": "BP Diastolic"},
"map_bp": {"low": 70, "high": 105, "critical_low": 60, "critical_high": 130, "unit": "mmHg", "label": "MAP"},
"respiratory_rate": {"low": 12, "high": 20, "critical_low": 8, "critical_high": 30, "unit": "/min", "label": "Resp Rate"},
"temperature": {"low": 36.1,"high": 37.2,"critical_low": 35.0,"critical_high": 39.0,"unit": "C", "label": "Temperature"},
}
def get_vital_color(param_name: str, value: Optional[float]) -> str:
"""Return a color name based on whether the value is normal, warning, or critical."""
if value is None:
return "dim"
ranges = VITAL_RANGES.get(param_name)
if not ranges:
return "white"
if value <= ranges["critical_low"] or value >= ranges["critical_high"]:
return "bold red"
elif value < ranges["low"] or value > ranges["high"]:
return "yellow"
else:
return "bold green"
def format_vital_value(param_name: str, value: Optional[float]) -> str:
"""Format a vital sign value with its unit."""
if value is None:
return "---"
ranges = VITAL_RANGES.get(param_name, {})
unit = ranges.get("unit", "")
# Format based on typical precision
if param_name == "temperature":
return f"{value:.1f} {unit}"
elif param_name == "spo2":
return f"{value:.0f} {unit}"
else:
return f"{value:.0f} {unit}"
class TerminalDisplay:
"""Manages the live terminal display of vital signs."""
def __init__(self):
self._latest_vitals: dict[str, NormalizedVitals] = {} # keyed by device_id
self._last_update: Optional[datetime] = None
self._connection_count: int = 0
self._message_count: int = 0
if HAS_RICH:
self.console = Console()
else:
self.console = None
def update_vitals(self, vitals: NormalizedVitals):
"""Update the display with new vital signs data."""
self._latest_vitals[vitals.device_id] = vitals
self._last_update = datetime.now(timezone.utc)
self._message_count += 1
self._print_vitals(vitals)
def connection_opened(self, client_ip: str, port: int):
"""Track a new connection."""
self._connection_count += 1
if HAS_RICH:
self.console.print(
f" [bold cyan]Connected:[/] {client_ip} on port {port} "
f"(total connections: {self._connection_count})"
)
else:
print(f" Connected: {client_ip} on port {port}")
def connection_closed(self, client_ip: str, port: int):
"""Track a closed connection."""
if HAS_RICH:
self.console.print(f" [dim]Disconnected:[/dim] {client_ip} on port {port}")
else:
print(f" Disconnected: {client_ip} on port {port}")
def _print_vitals(self, vitals: NormalizedVitals):
"""Print vital signs to terminal."""
if HAS_RICH:
self._print_vitals_rich(vitals)
else:
self._print_vitals_basic(vitals)
def _print_vitals_rich(self, vitals: NormalizedVitals):
"""Print vital signs using rich library for beautiful output."""
table = Table(
title=f"Live Vitals -- Device: {vitals.device_id} | Patient: {vitals.patient_id}",
box=box.HEAVY_EDGE,
show_header=True,
header_style="bold white on dark_blue",
title_style="bold white",
border_style="blue",
padding=(0, 2),
)
table.add_column("Parameter", style="bold white", width=16)
table.add_column("Value", justify="right", width=14)
table.add_column("Status", justify="center", width=10)
table.add_column("Normal Range", justify="center", style="dim", width=14)
vital_params = [
("heart_rate", vitals.heart_rate),
("spo2", vitals.spo2),
("systolic_bp", vitals.systolic_bp),
("diastolic_bp", vitals.diastolic_bp),
("map_bp", vitals.map_bp),
("respiratory_rate", vitals.respiratory_rate),
("temperature", vitals.temperature),
]
for param_name, value in vital_params:
ranges = VITAL_RANGES.get(param_name, {})
color = get_vital_color(param_name, value)
formatted = format_vital_value(param_name, value)
label = ranges.get("label", param_name)
# Status indicator
if value is None:
status = "[dim]-[/dim]"
elif color == "bold green":
status = "[green]Normal[/green]"
elif color == "yellow":
status = "[yellow]Warning[/yellow]"
elif color == "bold red":
status = "[red]ALERT[/red]"
else:
status = "[dim]-[/dim]"
# Normal range
low = ranges.get("low", "?")
high = ranges.get("high", "?")
unit = ranges.get("unit", "")
range_str = f"{low}-{high} {unit}"
table.add_row(
label,
f"[{color}]{formatted}[/{color}]",
status,
range_str,
)
self.console.print()
self.console.print(table)
# Timestamp footer
time_str = vitals.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") if vitals.timestamp else "N/A"
self.console.print(
f" [dim]Last update: {time_str} | "
f"Messages received: {self._message_count} | "
f"Active connections: {self._connection_count}[/dim]"
)
def _print_vitals_basic(self, vitals: NormalizedVitals):
"""Fallback plain-text vital signs display."""
print("\n" + "=" * 65)
print(f" LIVE VITALS — Device: {vitals.device_id} | Patient: {vitals.patient_id}")
print("=" * 65)
vital_params = [
("Heart Rate", vitals.heart_rate, "bpm"),
("SpO2", vitals.spo2, "%"),
("BP Systolic", vitals.systolic_bp, "mmHg"),
("BP Diastolic",vitals.diastolic_bp, "mmHg"),
("MAP", vitals.map_bp, "mmHg"),
("Resp Rate", vitals.respiratory_rate, "/min"),
("Temperature", vitals.temperature, "C"),
]
for label, value, unit in vital_params:
if value is not None:
if "temp" in label.lower():
val_str = f"{value:.1f} {unit}"
else:
val_str = f"{value:.0f} {unit}"
# Simple text status
ranges = VITAL_RANGES.get(label.lower().replace(" ", "_"), {})
if ranges:
if value < ranges.get("critical_low", 0) or value > ranges.get("critical_high", 999):
status = " *** CRITICAL ***"
elif value < ranges.get("low", 0) or value > ranges.get("high", 999):
status = " ! WARNING"
else:
status = " OK"
else:
status = ""
print(f" {label:<16} {val_str:>12}{status}")
else:
print(f" {label:<16} {'---':>12}")
time_str = vitals.timestamp.strftime("%H:%M:%S") if vitals.timestamp else "N/A"
print(f"\n Time: {time_str} | Messages: {self._message_count}")
print("-" * 65)
# Global singleton
_display: Optional[TerminalDisplay] = None
def get_terminal_display() -> TerminalDisplay:
"""Get or create the global terminal display instance."""
global _display
if _display is None:
_display = TerminalDisplay()
return _display