diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1f2f8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*.db +*.sqlite3 +*.log +*.bin +.env +venv/ +.venv/ +build/ +dist/ +*.spec +scratch/ +captures/ +.git/ +.idea/ +.vscode/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9bc693 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d6b037 --- /dev/null +++ b/Dockerfile @@ -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} diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..e20a7fe --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..7882a8a --- /dev/null +++ b/api_client.py @@ -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 diff --git a/background_tasks.py b/background_tasks.py new file mode 100644 index 0000000..6130a38 --- /dev/null +++ b/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/build.bat b/build.bat new file mode 100644 index 0000000..69cda06 --- /dev/null +++ b/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/build.sh b/build.sh new file mode 100644 index 0000000..646d542 --- /dev/null +++ b/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/config.json b/config.json new file mode 100644 index 0000000..115508f --- /dev/null +++ b/config.json @@ -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" +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..eb095e8 --- /dev/null +++ b/config.py @@ -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() diff --git a/contec_discovery.py b/contec_discovery.py new file mode 100644 index 0000000..8d6b1f6 --- /dev/null +++ b/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/contec_server.py b/contec_server.py new file mode 100644 index 0000000..675f4b6 --- /dev/null +++ b/contec_server.py @@ -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() diff --git a/database.py b/database.py new file mode 100644 index 0000000..cbde1b6 --- /dev/null +++ b/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/main.py b/main.py new file mode 100644 index 0000000..617f971 --- /dev/null +++ b/main.py @@ -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) diff --git a/models.py b/models.py new file mode 100644 index 0000000..f1fa727 --- /dev/null +++ b/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/render.yaml b/render.yaml new file mode 100644 index 0000000..c2862c9 --- /dev/null +++ b/render.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b76f540 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +sqlalchemy +pydantic +pydantic-settings +httpx +gunicorn +rich +websockets diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..5f24585 --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1 @@ +# Python package marker diff --git a/routers/dashboard.py b/routers/dashboard.py new file mode 100644 index 0000000..01bdf90 --- /dev/null +++ b/routers/dashboard.py @@ -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="
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(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)}") diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..58e9f3e --- /dev/null +++ b/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/runtime.txt b/runtime.txt new file mode 100644 index 0000000..44f8fbe --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.12.0 diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..6a8e69e --- /dev/null +++ b/schemas.py @@ -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 diff --git a/setup_service.sh b/setup_service.sh new file mode 100755 index 0000000..ea3a2e6 --- /dev/null +++ b/setup_service.sh @@ -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}" <Wi-Fi: Connect the monitor (e.g. CMS8500) and your PC to the same Wi-Fi network.
Ethernet: Connect using an Ethernet cable (direct or switch).
Find your PC's IP address on the network (shown in the box below). If using Wi-Fi, use your PC's Wi-Fi IP address.
+On your monitor: Go to System Setup → Network → CMS Settings. Set Server IP (or CMS IP) to your PC's IP address.
Set Server Port on your monitor to one of the listening ports: Detecting... (usually 511 or 518).
Enable the CMS/Central Monitor connection in the monitor settings. Set the sending interval (e.g. 5 seconds) to start transmitting.
+Once connected, the monitor will show up in the "Select Monitor" list, and its vitals will display in real-time.
+