From a4972b32516b000e54f5eb26aadb585df45d266c Mon Sep 17 00:00:00 2001 From: Arun Date: Wed, 8 Jul 2026 14:14:03 +0530 Subject: [PATCH] Implement patient vital signs monitoring system with Contec CMS7000PLUS integration and real-time dashboard --- fiveparaminte-main/.gitignore | 32 + fiveparaminte-main/README.md | 43 + fiveparaminte-main/api_client.py | 38 + fiveparaminte-main/background_tasks.py | 67 + fiveparaminte-main/build.bat | 29 + fiveparaminte-main/build.sh | 28 + fiveparaminte-main/config.json | 12 + fiveparaminte-main/config.py | 31 + fiveparaminte-main/contec_discovery.py | 246 +++ fiveparaminte-main/contec_parser.py | 381 +++++ fiveparaminte-main/contec_server.py | 247 +++ fiveparaminte-main/database.py | 17 + fiveparaminte-main/main.py | 169 ++ fiveparaminte-main/models.py | 41 + fiveparaminte-main/requirements.txt | 9 + fiveparaminte-main/routers/__init__.py | 1 + fiveparaminte-main/routers/dashboard.py | 163 ++ fiveparaminte-main/run.sh | 21 + fiveparaminte-main/schemas.py | 35 + .../scratch/check_active_connections.py | 19 + fiveparaminte-main/scratch/scan_monitor.py | 50 + fiveparaminte-main/scratch/udp_heartbeat.py | 36 + fiveparaminte-main/scratch/udp_sniff_now.py | 48 + fiveparaminte-main/static/dashboard.html | 1434 +++++++++++++++++ fiveparaminte-main/terminal_display.py | 246 +++ 25 files changed, 3443 insertions(+) create mode 100644 fiveparaminte-main/.gitignore create mode 100644 fiveparaminte-main/README.md create mode 100644 fiveparaminte-main/api_client.py create mode 100644 fiveparaminte-main/background_tasks.py create mode 100644 fiveparaminte-main/build.bat create mode 100644 fiveparaminte-main/build.sh create mode 100644 fiveparaminte-main/config.json create mode 100644 fiveparaminte-main/config.py create mode 100644 fiveparaminte-main/contec_discovery.py create mode 100644 fiveparaminte-main/contec_parser.py create mode 100644 fiveparaminte-main/contec_server.py create mode 100644 fiveparaminte-main/database.py create mode 100644 fiveparaminte-main/main.py create mode 100644 fiveparaminte-main/models.py create mode 100644 fiveparaminte-main/requirements.txt create mode 100644 fiveparaminte-main/routers/__init__.py create mode 100644 fiveparaminte-main/routers/dashboard.py create mode 100644 fiveparaminte-main/run.sh create mode 100644 fiveparaminte-main/schemas.py create mode 100644 fiveparaminte-main/scratch/check_active_connections.py create mode 100644 fiveparaminte-main/scratch/scan_monitor.py create mode 100644 fiveparaminte-main/scratch/udp_heartbeat.py create mode 100644 fiveparaminte-main/scratch/udp_sniff_now.py create mode 100644 fiveparaminte-main/static/dashboard.html create mode 100644 fiveparaminte-main/terminal_display.py diff --git a/fiveparaminte-main/.gitignore b/fiveparaminte-main/.gitignore new file mode 100644 index 0000000..e4715eb --- /dev/null +++ b/fiveparaminte-main/.gitignore @@ -0,0 +1,32 @@ +# 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/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store diff --git a/fiveparaminte-main/README.md b/fiveparaminte-main/README.md new file mode 100644 index 0000000..1f18e1d --- /dev/null +++ b/fiveparaminte-main/README.md @@ -0,0 +1,43 @@ +# Mindray uMEC 100 HL7 Forwarder + +This application listens for HL7 messages from Mindray uMEC 100 patient monitors over TCP/IP, parses the vital signs, stores them in a local SQLite database, and immediately forwards them to a configured REST API endpoint. It includes a FastAPI-based dashboard to view status and readings. + +## Requirements +- Python 3.12 (if running from source) +- Dependencies in `requirements.txt` + +## Running the Application +The easiest way to start the application is using the provided `run.sh` script. This script will automatically set up the Python virtual environment, install dependencies, and start the application. + +1. Install Python 3.12. +2. Edit `config.json` with your settings (target API URL, API token, etc.). +3. Run the following command in your terminal: + ```bash + ./run.sh + ``` + +## Configuration (`config.json`) +- `hl7_host`: The IP address to listen on for HL7 connections (default: `0.0.0.0` for all interfaces). +- `hl7_port`: The TCP port to listen on (default: `6060`). +- `target_api_url`: The REST API URL to POST normalized vitals to. +- `api_token`: Bearer token for authenticating with the target API. +- `retry_interval_seconds`: How often to retry forwarding failed transmissions. +- `log_file`: Name of the log file. +- `database_url`: SQLite database URI. + +## Building for Linux +To package the application as a standalone executable for Linux: +1. Ensure you have installed the requirements (`pip install -r requirements.txt`). +2. Run `./build.sh` from your terminal. +3. The standalone executable `MindrayHL7Forwarder` will be generated in the `dist/` directory. + +### Running the Linux Executable +Simply copy the `MindrayHL7Forwarder` binary and `config.json` to the target Linux machine and run `./MindrayHL7Forwarder`. It does not require Python to be installed. The Dashboard API will be available at `http://localhost:8000/docs`. + +## Dashboard APIs +When the application is running, it starts a web server on port `8000`. You can access the following endpoints: +- `GET /api/health` +- `GET /api/status` +- `GET /api/devices` +- `GET /api/latest-readings` +- `GET /api/transmission-logs` diff --git a/fiveparaminte-main/api_client.py b/fiveparaminte-main/api_client.py new file mode 100644 index 0000000..643b444 --- /dev/null +++ b/fiveparaminte-main/api_client.py @@ -0,0 +1,38 @@ +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. + Returns True if successful, False otherwise. + """ + url = settings.target_api_url + headers = {"Content-Type": "application/json"} + + if settings.api_token: + headers["Authorization"] = f"Bearer {settings.api_token}" + + 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 + + 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 diff --git a/fiveparaminte-main/background_tasks.py b/fiveparaminte-main/background_tasks.py new file mode 100644 index 0000000..6130a38 --- /dev/null +++ b/fiveparaminte-main/background_tasks.py @@ -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) diff --git a/fiveparaminte-main/build.bat b/fiveparaminte-main/build.bat new file mode 100644 index 0000000..69cda06 --- /dev/null +++ b/fiveparaminte-main/build.bat @@ -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 diff --git a/fiveparaminte-main/build.sh b/fiveparaminte-main/build.sh new file mode 100644 index 0000000..646d542 --- /dev/null +++ b/fiveparaminte-main/build.sh @@ -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." diff --git a/fiveparaminte-main/config.json b/fiveparaminte-main/config.json new file mode 100644 index 0000000..13d28c4 --- /dev/null +++ b/fiveparaminte-main/config.json @@ -0,0 +1,12 @@ +{ + "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": "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" +} diff --git a/fiveparaminte-main/config.py b/fiveparaminte-main/config.py new file mode 100644 index 0000000..3b9c60a --- /dev/null +++ b/fiveparaminte-main/config.py @@ -0,0 +1,31 @@ +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" + 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: + config_file = "config.json" + if os.path.exists(config_file): + with open(config_file, "r") as f: + try: + data = json.load(f) + return Settings(**data) + except json.JSONDecodeError: + pass + return Settings() + +settings = get_settings() diff --git a/fiveparaminte-main/contec_discovery.py b/fiveparaminte-main/contec_discovery.py new file mode 100644 index 0000000..8d6b1f6 --- /dev/null +++ b/fiveparaminte-main/contec_discovery.py @@ -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.") diff --git a/fiveparaminte-main/contec_parser.py b/fiveparaminte-main/contec_parser.py new file mode 100644 index 0000000..106e5c1 --- /dev/null +++ b/fiveparaminte-main/contec_parser.py @@ -0,0 +1,381 @@ +import logging +from datetime import datetime, timezone +from typing import Optional, List +import struct + +from schemas import NormalizedVitals + +logger = logging.getLogger(__name__) + +# Contec CMS7000PLUS / CMS8000 / CMS9200PLUS OBX identifiers mapping to schema fields +CONTEC_PARAM_MAP = { + # Heart Rate / Pulse Rate + "HR": "heart_rate", + "HEART_RATE": "heart_rate", + "ECG_HR": "heart_rate", + "PR": "heart_rate", + "PULSE_RATE": "heart_rate", + "SPO2_PR": "heart_rate", + "ECGHR": "heart_rate", + "ECG-HR": "heart_rate", + "PULSERATE": "heart_rate", + + # SpO2 + "SPO2": "spo2", + "SAO2": "spo2", + "SPO2_PCT": "spo2", + "OXYGEN_SAT": "spo2", + "SPO2_VAL": "spo2", + "O2SAT": "spo2", + + # Blood Pressure (NIBP) + "NIBP_SYS": "systolic_bp", + "NIBP_DIA": "diastolic_bp", + "NIBP_MEAN": "map_bp", + "NIBP_MAP": "map_bp", + "SYS": "systolic_bp", + "DIA": "diastolic_bp", + "MAP": "map_bp", + "NBP_S": "systolic_bp", + "NBP_D": "diastolic_bp", + "NBP_M": "map_bp", + "NIBP-S": "systolic_bp", + "NIBP-D": "diastolic_bp", + "NIBP-M": "map_bp", + "BP_SYS": "systolic_bp", + "BP_DIA": "diastolic_bp", + "BP_MEAN": "map_bp", + + # Respiratory Rate + "RESP": "respiratory_rate", + "RESP_RATE": "respiratory_rate", + "RR": "respiratory_rate", + "BR": "respiratory_rate", + "RESPRATE": "respiratory_rate", + "RESP-RATE": "respiratory_rate", + + # Temperature + "TEMP": "temperature", + "TEMP1": "temperature", + "TEMP2": "temperature", + "T1": "temperature", + "T2": "temperature", + "BODY_TEMP": "temperature", + "TEMP-1": "temperature", + "TEMP-2": "temperature", + "BODYTEMP": "temperature", +} + +# Sentinel value used by Contec to mark an invalid/disconnected parameter +INVALID_SENTINEL_U16 = 9999 # 0x270F +INVALID_SENTINEL_F32 = 9999.0 + + +def safe_float(value: str) -> Optional[float]: + try: + return float(value) + except (ValueError, TypeError): + return None + + +def is_valid_u16(val: Optional[int]) -> bool: + return val is not None and val != INVALID_SENTINEL_U16 + + +def is_valid_f32(val: Optional[float]) -> bool: + return val is not None and abs(val - INVALID_SENTINEL_F32) > 1.0 + + +def read_u16_le(buf: bytes, offset: int) -> Optional[int]: + if offset + 2 <= len(buf): + return struct.unpack_from(' Optional[float]: + if offset + 4 <= len(buf): + return struct.unpack_from(' Optional[NormalizedVitals]: + """ + Lightweight, native parser for Contec HL7 messages. + Splits segments and fields manually, avoiding third-party library dependencies. + """ + try: + # Strip MLLP framing if present (VT=0x0b, FS=0x1c, CR=0x0d) + clean_text = raw_text.strip() + if clean_text.startswith("\x0b"): + clean_text = clean_text[1:] + if "\x1c\x0d" in clean_text: + clean_text = clean_text.split("\x1c\x0d")[0] + + # Split into segments by CR or LF + segments = [seg.strip() for seg in clean_text.replace("\n", "\r").split("\r") if seg.strip()] + + if not segments or not segments[0].startswith("MSH"): + return None + + # Parse MSH segment + msh_fields = segments[0].split("|") + device_id = msh_fields[2] if len(msh_fields) > 2 and msh_fields[2] else f"CMS7000_{client_ip}" + + patient_id = "UNKNOWN" + vitals_dict = { + "device_id": device_id, + "patient_id": patient_id, + "timestamp": datetime.now(timezone.utc), + } + + found_any = False + + # Parse PID and OBX segments + for seg in segments[1:]: + fields = seg.split("|") + if not fields: + continue + + seg_name = fields[0] + + if seg_name == "PID": + if len(fields) > 3 and fields[3]: + patient_id = fields[3].split("^")[0] + vitals_dict["patient_id"] = patient_id + + elif seg_name == "OBX": + if len(fields) > 5: + obs_id = fields[3].split("^")[0].strip().upper() + obs_val = fields[5].strip() + + if not obs_val or obs_val == "---": + continue + + val_float = safe_float(obs_val) + if val_float is None: + continue + + for param_key, mapped_field in CONTEC_PARAM_MAP.items(): + if obs_id == param_key or param_key in obs_id: + vitals_dict[mapped_field] = val_float + found_any = True + logger.info(f"Parsed Contec HL7 Vital: {mapped_field} = {val_float} (from {obs_id})") + break + + return NormalizedVitals(**vitals_dict) if found_any else None + + except Exception as e: + logger.error(f"Error parsing Contec HL7 text: {e}") + return None + + +def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[NormalizedVitals]: + """ + Extract vitals from a Contec vitals block starting at the given offset. + + Known Contec CMS7000PLUS binary vitals block layout (confirmed from packet captures): + + PACKET TYPE B (header bytes [xx][00][04][46] or [xx][yy][04][46]): + ----------------------------------------------------------------------- + Bytes 8-11 : float32 LE → NIBP Systolic (9999.0 = invalid) + Bytes 12-15 : float32 LE → NIBP Diastolic (9999.0 = invalid) + Bytes 16-19 : float32 LE → NIBP MAP (9999.0 = invalid) + Bytes 20-23 : float32 LE → Temperature 1 (°C, 9999.0 = invalid) + Bytes 24-27 : float32 LE → Temperature 2 (°C, 9999.0 = invalid) + + Vitals summary block (at data[offset] position, found by scanning for SpO2+HR pattern): + Offset +0 : uint16 LE → SpO2 (%) (9999 = invalid) + Offset +2 : uint16 LE → Heart Rate (bpm)(9999 = invalid, ECG not connected) + Offset +4 : uint16 LE → Pulse Rate (bpm)(from SpO2 probe, also 9999 if invalid) + Offset +6 : uint16 LE → (SpO2 alarm/threshold) + Offset +8 : uint16 LE → (mode flags) + Offset +10 : uint16 LE → NIBP Systolic (9999 = invalid) + Offset +12 : uint16 LE → NIBP Diastolic (9999 = invalid) + """ + vitals_dict = { + "device_id": f"CMS7000PLUS_{client_ip}", + "patient_id": "UNKNOWN", + "timestamp": datetime.now(timezone.utc), + } + found = False + + spo2 = read_u16_le(data, offset) + hr_ecg = read_u16_le(data, offset + 2) + hr_spo2 = read_u16_le(data, offset + 4) + + # SpO2 value: valid range 50–100 + if spo2 is not None and 50 <= spo2 <= 100: + vitals_dict["spo2"] = float(spo2) + found = True + + # Heart rate: prefer ECG HR; fall back to SpO2-derived PR if ECG invalid + if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300: + vitals_dict["heart_rate"] = float(hr_ecg) + found = True + elif is_valid_u16(hr_spo2) and 20 <= hr_spo2 <= 300: + vitals_dict["heart_rate"] = float(hr_spo2) + found = True + + return NormalizedVitals(**vitals_dict) if found else None + + +def _parse_float_vitals(data: bytes, base_offset: int, vitals_dict: dict) -> bool: + """ + Parse float32 vitals (NIBP, Temperature) from packet starting at base_offset. + Returns True if any valid vitals found. + """ + found = False + + nibp_sys = read_f32_le(data, base_offset) + nibp_dia = read_f32_le(data, base_offset + 4) + nibp_map = read_f32_le(data, base_offset + 8) + temp1 = read_f32_le(data, base_offset + 12) + temp2 = read_f32_le(data, base_offset + 16) + + if nibp_sys is not None and is_valid_f32(nibp_sys) and 50.0 < nibp_sys < 300.0: + vitals_dict["systolic_bp"] = round(nibp_sys, 1) + found = True + + if nibp_dia is not None and is_valid_f32(nibp_dia) and 20.0 < nibp_dia < 200.0: + vitals_dict["diastolic_bp"] = round(nibp_dia, 1) + found = True + + if nibp_map is not None and is_valid_f32(nibp_map) and 20.0 < nibp_map < 250.0: + vitals_dict["map_bp"] = round(nibp_map, 1) + found = True + + if temp1 is not None and is_valid_f32(temp1) and 30.0 < temp1 < 45.0: + vitals_dict["temperature"] = round(temp1, 1) + found = True + elif temp2 is not None and is_valid_f32(temp2) and 30.0 < temp2 < 45.0: + vitals_dict["temperature"] = round(temp2, 1) + found = True + + return found + + +def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]: + """ + Scan the data stream for Contec CMS7000PLUS sub-packets. + + Contec packets follow this framing pattern: + [len_lo] [len_hi] [04] [46] [sub_type] [00] [sub_len_lo] [00] ...payload... + + OR the data is a concatenation of multiple sub-packets separated by + [xx][xx][04][47] (end marker?) + + The key is to find [04][46] marker bytes and parse the packet. + """ + vitals_dict = { + "device_id": f"CMS7000PLUS_{client_ip}", + "patient_id": "UNKNOWN", + "timestamp": datetime.now(timezone.utc), + } + found_any = False + + # Scan through the buffer looking for [04 46] packet markers + i = 0 + while i < len(data) - 8: + # Look for the [04][46] marker which is the packet type indicator + if data[i + 2] == 0x04 and data[i + 3] == 0x46: + pkt_len = struct.unpack_from(' 50: + # The summary block is consistently found around offset 322 in 397-byte packets + # Relative to packet start: around pkt_len - 75 + summary_offset = i + max(4, pkt_len - 80) + if summary_offset + 10 < len(data): + # Scan in the last ~100 bytes of the packet for SpO2 pattern + scan_end = min(i + pkt_len, len(data) - 4) + scan_start = max(i + 4, scan_end - 100) + for j in range(scan_start, scan_end - 4, 2): + spo2 = read_u16_le(data, j) + hr = read_u16_le(data, j + 2) + pr = read_u16_le(data, j + 4) + + # Check if this looks like [SpO2][HR/sentinel][PR] + spo2_ok = spo2 is not None and 50 <= spo2 <= 100 + # HR can be 9999 (invalid/ECG disconnected) or valid 20-300 + hr_or_sentinel = hr is not None and (hr == INVALID_SENTINEL_U16 or 20 <= hr <= 300) + pr_ok = pr is not None and (pr == INVALID_SENTINEL_U16 or 20 <= pr <= 300) + + if spo2_ok and hr_or_sentinel and pr_ok: + vitals_dict["spo2"] = float(spo2) + found_any = True + + # Use ECG HR if valid, else use SpO2 PR + if hr is not None and hr != INVALID_SENTINEL_U16 and 20 <= hr <= 300: + vitals_dict["heart_rate"] = float(hr) + elif pr is not None and pr != INVALID_SENTINEL_U16 and 20 <= pr <= 300: + vitals_dict["heart_rate"] = float(pr) + + logger.info( + f"Parsed vitals block at offset {j}: " + f"SpO2={spo2}, HR={hr}, PR={pr}" + ) + break + + # Move past this packet + i += max(pkt_len, 4) + else: + i += 1 + + return NormalizedVitals(**vitals_dict) if found_any else None + + +def parse_contec_binary_packet(data: bytes, client_ip: str) -> Optional[NormalizedVitals]: + """ + Decodes the Contec CMS7000PLUS proprietary binary protocol. + + The monitor sends two alternating TCP packets: + - Packet A (991 bytes): ECG waveform + SpO2 waveform + vitals footer + - Packet B (397 bytes): Resp waveform + vitals summary + param block + + Both have the structure: [len_lo][len_hi][04][46][subtype][00][sublen][00]...[payload]... + """ + try: + return _find_vitals_block_in_stream(data, client_ip) + except Exception as e: + logger.error(f"Error parsing Contec binary packet: {e}") + return None + + +def parse_contec_data(raw_data: bytes, client_ip: str) -> Optional[NormalizedVitals]: + """ + Unified entry point for parsing data from a Contec CMS7000PLUS monitor. + Attempts HL7 text parsing first, falling back to proprietary binary parsing. + """ + if not raw_data: + return None + + # Attempt 1: HL7 Text Decoding + try: + text = raw_data.decode("utf-8", errors="ignore").strip() + if "MSH" in text or "\x0bMSH" in text: + vitals = parse_contec_hl7_text(text, client_ip) + if vitals: + return vitals + except Exception: + pass + + # Attempt 2: Binary Parsing (Contec proprietary protocol) + vitals = parse_contec_binary_packet(raw_data, client_ip) + if vitals: + return vitals + + # Logging fallback + logger.warning( + f"Data packet from {client_ip} unrecognized. " + f"Len: {len(raw_data)}, Hex head: {raw_data[:32].hex()}" + ) + return None diff --git a/fiveparaminte-main/contec_server.py b/fiveparaminte-main/contec_server.py new file mode 100644 index 0000000..7412f48 --- /dev/null +++ b/fiveparaminte-main/contec_server.py @@ -0,0 +1,247 @@ +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: + 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 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 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 + + +async def process_vitals(vitals): + """ + Log, database, WebSocket broadcast, and REST forward parsed vitals. + """ + display = get_terminal_display() + + # Update console display banner + 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}" + ) + + # Broadcast to live WebSockets dashboard + 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}") + + # Save to SQLite database + 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="unknown") + db.add(device) + else: + 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() diff --git a/fiveparaminte-main/database.py b/fiveparaminte-main/database.py new file mode 100644 index 0000000..cbde1b6 --- /dev/null +++ b/fiveparaminte-main/database.py @@ -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() diff --git a/fiveparaminte-main/main.py b/fiveparaminte-main/main.py new file mode 100644 index 0000000..ce54c2f --- /dev/null +++ b/fiveparaminte-main/main.py @@ -0,0 +1,169 @@ +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]) + logger.info(f" Active listener ports: {active_ports}") + else: + logger.error("No servers could be started! Check port availability and permissions.") + + # === Print startup banner === + 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:8000/api/dashboard") + print(f" API Docs: http://localhost:8000/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 = 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 +) + +# Include dashboard routes +app.include_router(dashboard.router, prefix="/api") + + +# === 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 for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060) + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/fiveparaminte-main/models.py b/fiveparaminte-main/models.py new file mode 100644 index 0000000..f1fa727 --- /dev/null +++ b/fiveparaminte-main/models.py @@ -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)) diff --git a/fiveparaminte-main/requirements.txt b/fiveparaminte-main/requirements.txt new file mode 100644 index 0000000..58c658e --- /dev/null +++ b/fiveparaminte-main/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +sqlalchemy +pydantic +pydantic-settings +httpx +pyinstaller +rich +pyserial diff --git a/fiveparaminte-main/routers/__init__.py b/fiveparaminte-main/routers/__init__.py new file mode 100644 index 0000000..5f24585 --- /dev/null +++ b/fiveparaminte-main/routers/__init__.py @@ -0,0 +1 @@ +# Python package marker diff --git a/fiveparaminte-main/routers/dashboard.py b/fiveparaminte-main/routers/dashboard.py new file mode 100644 index 0000000..bd68c70 --- /dev/null +++ b/fiveparaminte-main/routers/dashboard.py @@ -0,0 +1,163 @@ +import asyncio +import json +import logging +import socket +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +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="

Dashboard not found

static/dashboard.html is missing

", 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(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() + + return { + "total_devices": total_devices, + "active_devices": active_devices, + "total_readings_stored": total_readings, + "pending_transmissions": pending_transmissions + } + +@router.get("/devices") +def list_devices(db: Session = Depends(get_db)): + devices = db.query(Device).all() + return devices + +@router.get("/latest-readings") +def get_latest_readings(limit: int = 10, db: Session = Depends(get_db)): + readings = db.query(VitalReading).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" + ] + } diff --git a/fiveparaminte-main/run.sh b/fiveparaminte-main/run.sh new file mode 100644 index 0000000..58e9f3e --- /dev/null +++ b/fiveparaminte-main/run.sh @@ -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 diff --git a/fiveparaminte-main/schemas.py b/fiveparaminte-main/schemas.py new file mode 100644 index 0000000..ea135ad --- /dev/null +++ b/fiveparaminte-main/schemas.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class NormalizedVitals(BaseModel): + device_id: str + patient_id: str + timestamp: datetime + 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 + +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 diff --git a/fiveparaminte-main/scratch/check_active_connections.py b/fiveparaminte-main/scratch/check_active_connections.py new file mode 100644 index 0000000..f02ddd0 --- /dev/null +++ b/fiveparaminte-main/scratch/check_active_connections.py @@ -0,0 +1,19 @@ +import psutil +import time + +print("Monitoring TCP connections for traffic from 202.114.4.114 for 15 seconds...") +monitor_ip = "202.114.4.114" +start = time.time() +found_any = False + +while time.time() - start < 15: + connections = psutil.net_connections(kind='tcp') + for conn in connections: + # Check if remote address matches the monitor IP + if conn.raddr and conn.raddr.ip == monitor_ip: + print(f"Connection detected: Local={conn.laddr.ip}:{conn.laddr.port}, Remote={conn.raddr.ip}:{conn.raddr.port}, Status={conn.status}") + found_any = True + time.sleep(0.5) + +if not found_any: + print("No TCP connections from 202.114.4.114 were detected during the 15-second window.") diff --git a/fiveparaminte-main/scratch/scan_monitor.py b/fiveparaminte-main/scratch/scan_monitor.py new file mode 100644 index 0000000..a37eb03 --- /dev/null +++ b/fiveparaminte-main/scratch/scan_monitor.py @@ -0,0 +1,50 @@ +import socket +import sys + +TARGET_IP = "202.114.4.114" +COMMON_PORTS = [ + 511, 515, 516, 517, 518, 519, 520, 1000, 2000, 3000, 4000, 5000, + 6060, 7000, 8000, 8001, 8002, 8080, 8300, 9000, 9100, 9200, 9500, 10008 +] + +print(f"Scanning target monitor {TARGET_IP} for open TCP ports...") + +open_ports = [] + +for port in COMMON_PORTS: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(0.5) + result = s.connect_ex((TARGET_IP, port)) + if result == 0: + print(f" [OPEN] Port {port} is open!") + open_ports.append(port) + s.close() + except Exception as e: + pass + +# Also do a quick scan of port range 500-600 (very common for Contec) +print("Scanning port range 500-600...") +for port in range(500, 601): + if port in COMMON_PORTS: + continue + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(0.1) + result = s.connect_ex((TARGET_IP, port)) + if result == 0: + print(f" [OPEN] Port {port} is open!") + open_ports.append(port) + s.close() + except Exception as e: + pass + +print("\nScan complete.") +if open_ports: + print(f"Found open TCP ports on monitor: {open_ports}") +else: + print("No open TCP ports found on the monitor. It is not acting as a TCP server on these ports.") + +with open("C:\\Users\\Dell\\.gemini\\antigravity-ide\\brain\\17f86d9d-29a1-4f54-b185-045db1a407b4\\scratch\\monitor_scan_results.txt", "w") as f: + f.write(f"Monitor scan results for {TARGET_IP}:\n") + f.write(f"Open ports: {open_ports}\n") diff --git a/fiveparaminte-main/scratch/udp_heartbeat.py b/fiveparaminte-main/scratch/udp_heartbeat.py new file mode 100644 index 0000000..09bc49e --- /dev/null +++ b/fiveparaminte-main/scratch/udp_heartbeat.py @@ -0,0 +1,36 @@ +import socket +import time + +TARGET_SUBNETS = ["202.114.4.255", "255.255.255.255"] +PORTS = [511, 1000, 2000, 5000, 8000, 8300, 9000, 10008, 12345] + +# Common Contec discovery payloads +PAYLOADS = [ + b"CMS", + b"CMS_SEARCH", + b"CMS_REGISTER", + b"CMS_HEARTBEAT", + b"\x55\xaa\x01\x00\x01", # sync header + command + b"\xaa\x55\x01\x00\x01", + b"\x00\x00\x00\x00", + b"\x01" +] + +print("Sending UDP discovery heartbeats to trigger the monitor...") + +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + +for subnet in TARGET_SUBNETS: + for port in PORTS: + for payload in PAYLOADS: + try: + # Send broadcast packet + s.sendto(payload, (subnet, port)) + print(f"Sent {len(payload)} bytes to {subnet}:{port}") + except Exception as e: + pass + time.sleep(0.01) + +s.close() +print("Discovery heartbeats sent successfully.") diff --git a/fiveparaminte-main/scratch/udp_sniff_now.py b/fiveparaminte-main/scratch/udp_sniff_now.py new file mode 100644 index 0000000..d267235 --- /dev/null +++ b/fiveparaminte-main/scratch/udp_sniff_now.py @@ -0,0 +1,48 @@ +import socket +import time + +PORT_LIST = [511, 515, 516, 517, 518, 519, 520, 1000, 2000, 5000, 6060, 8000, 8300, 9000, 9200, 10008, 12345] +TIMEOUT = 30.0 # seconds + +print("Starting background UDP sniffer on ports:", PORT_LIST) +print("Listening for 30 seconds...") + +sockets = [] +for port in PORT_LIST: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("0.0.0.0", port)) + s.setblocking(False) + sockets.append((s, port)) + except Exception as e: + print(f"Could not bind to UDP port {port}: {e}") + +start_time = time.time() +captured = [] + +while time.time() - start_time < TIMEOUT: + for s, port in sockets: + try: + data, addr = s.recvfrom(4096) + # Log from any sender that is not local loopback + if addr[0] != "127.0.0.1": + event = f"[UDP Port {port}] Received {len(data)} bytes from {addr[0]}:{addr[1]} - Hex: {data.hex()}" + print(event) + captured.append(event) + except BlockingIOError: + pass + except Exception as e: + pass + time.sleep(0.01) + +for s, port in sockets: + s.close() + +print("Finished UDP sniff.") +with open("C:\\Users\\Dell\\.gemini\\antigravity-ide\\brain\\17f86d9d-29a1-4f54-b185-045db1a407b4\\scratch\\udp_sniff_now_results.txt", "w") as f: + f.write("UDP Sniffing results:\n") + if not captured: + f.write("No UDP packets captured.\n") + for event in captured: + f.write(event + "\n") diff --git a/fiveparaminte-main/static/dashboard.html b/fiveparaminte-main/static/dashboard.html new file mode 100644 index 0000000..54755bd --- /dev/null +++ b/fiveparaminte-main/static/dashboard.html @@ -0,0 +1,1434 @@ + + + + + + Patient Vital Signs Monitor — Contec CMS7000PLUS + + + + + + + +
+
+ +
+ + + + +
+
+
+ 📡 + CMS7000PLUS Setup Guide — Configure Your Monitor +
+
▾ Collapse
+
+
+
+
1
+
+

Connect via Ethernet

+

Connect your CMS7000PLUS to this PC using an Ethernet cable (direct or through a network switch).

+
+
+
+
2
+
+

Set PC IP Address

+

Set your PC's Ethernet adapter to a static IP, e.g. 192.168.1.50, subnet 255.255.255.0.

+
+
+
+
3
+
+

Configure Monitor CMS Settings

+

On your CMS7000PLUS: System Setup → Network → CMS Settings. Set the Server IP to your PC's IP.

+
+
+
+
4
+
+

Set Server Port

+

Set Server Port to 511 (default). If running without admin, use a port >1024 like 6060.

+
+
+
+
5
+
+

Enable CMS Connection

+

Enable the CMS/Central Monitor connection on your CMS7000PLUS. Set the sending interval (e.g., 5 seconds).

+
+
+
+
6
+
+

Data Will Appear Automatically

+

Once connected, vital signs will appear on this dashboard in real-time via WebSocket or polling.

+
+
+
+
+
🖥️ Your PC's Detected IP Address (set this on the monitor)
+
+
Detecting...
+
+
+
+ + +
+ +
+
+
+
❤️
+ Heart Rate +
+ No Data +
+
+
---
+
+
+ +
+ +
+ + +
+
+
+
🫁
+ SpO₂ +
+ No Data +
+
+
---
+
+
+ +
+ +
+ + +
+
+
+
🩸
+ Blood Pressure +
+ No Data +
+
+
---
+
+
+ +
+ +
+ + +
+
+
+
🌬️
+ Respiratory Rate +
+ No Data +
+
+
---
+
+
+ +
+ +
+ + +
+
+
+
🌡️
+ Temperature +
+ No Data +
+
+
---
+
+
+ +
+ +
+ + +
+
+
+
💓
+ Mean Arterial Pressure +
+ No Data +
+
+
---
+
+
+ +
+ +
+
+ + +
+
+ Model: + CMS7000PLUS +
+
+
+ Device: + Waiting... +
+
+
+ Patient: + Waiting... +
+
+
+ Readings: + 0 +
+
+
+ Last Update: + Never +
+
+
+ API: + Checking... +
+
+
+ + + + diff --git a/fiveparaminte-main/terminal_display.py b/fiveparaminte-main/terminal_display.py new file mode 100644 index 0000000..fb04af9 --- /dev/null +++ b/fiveparaminte-main/terminal_display.py @@ -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