feat: implement Contec patient monitor discovery tool and server framework
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user