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()