Implement patient vital signs monitoring system with Contec CMS7000PLUS integration and real-time dashboard
This commit is contained in:
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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.")
|
||||||
@@ -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('<H', buf, offset)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_f32_le(buf: bytes, offset: int) -> Optional[float]:
|
||||||
|
if offset + 4 <= len(buf):
|
||||||
|
return struct.unpack_from('<f', buf, offset)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_contec_hl7_text(raw_text: str, client_ip: str) -> 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('<H', data, i)[0] # little-endian length
|
||||||
|
|
||||||
|
# Packet type B: small packets (< 600 bytes) with vitals summary
|
||||||
|
# These have float32 NIBP values at offset +8 relative to packet start
|
||||||
|
if 30 < pkt_len < 600 and i + 8 <= len(data):
|
||||||
|
# Read float32 vitals at offsets 8, 12, 16, 20, 24 from packet start
|
||||||
|
_parse_float_vitals(data, i + 8, vitals_dict)
|
||||||
|
|
||||||
|
# Find the vitals summary block in this packet
|
||||||
|
# It appears near the tail of the resp waveform data
|
||||||
|
# The pattern we look for: SpO2 byte (50-100), followed by various u16 values
|
||||||
|
# In packet B, the summary is 75 bytes before the end
|
||||||
|
if pkt_len > 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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
pydantic-settings
|
||||||
|
httpx
|
||||||
|
pyinstaller
|
||||||
|
rich
|
||||||
|
pyserial
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Python package marker
|
||||||
@@ -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="<h1>Dashboard not found</h1><p>static/dashboard.html is missing</p>", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
# === API Endpoints ===
|
||||||
|
@router.get("/health")
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def get_system_status(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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -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")
|
||||||
@@ -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.")
|
||||||
@@ -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")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
Reference in New Issue
Block a user