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, sync_cloud_state 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): # Protect against Windows IOCP Proactor crashes (WinError 64 / 121 / 10054 on accept) import sys if sys.platform == "win32": try: loop = asyncio.get_running_loop() def silence_windows_iocp_errors(loop, context): msg = str(context.get("message", "")) exc = context.get("exception") winerr = getattr(exc, "winerror", None) if winerr in (64, 121, 10054) or "Accept failed on a socket" in msg or "The specified network name is no longer available" in msg: return loop.default_exception_handler(context) loop.set_exception_handler(silence_windows_iocp_errors) except Exception: pass 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}/dashboard") print(f" 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 background retry and cloud state sync tasks retry_task = asyncio.create_task(retry_failed_transmissions()) cloud_sync_task = asyncio.create_task(sync_cloud_state()) # Startup: Start bore tunnel for remote monitors if available and not already running bore_proc = None bore_exe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bore.exe") if os.path.exists(bore_exe): import subprocess try: check_out = subprocess.check_output('tasklist /FI "IMAGENAME eq bore.exe"', shell=True).decode() if "bore.exe" not in check_out: bore_proc = subprocess.Popen( [bore_exe, "local", "511", "--to", "159.223.110.159", "--port", "61501"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) logger.info(" [OK] Bore tunnel active for remote hotspot monitors (159.223.110.159:61501)") else: logger.info(" [INFO] Bore tunnel is already running") except Exception as e: logger.debug(f"Could not check/start bore tunnel: {e}") yield # Shutdown logger.info("Shutting down servers...") if bore_proc: try: bore_proc.terminate() except Exception: pass retry_task.cancel() cloud_sync_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") # Also serve dashboard at root and /dashboard directly for convenience from fastapi.responses import HTMLResponse @app.get("/", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse) def serve_dashboard_root(): return dashboard.serve_dashboard() # Serve static folder from fastapi.staticfiles import StaticFiles app.mount("/static", StaticFiles(directory="static"), name="static") from fastapi.responses import Response @app.get("/favicon.ico", include_in_schema=False) async def favicon(): return Response(status_code=204, media_type="image/x-icon") # === 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)