Update Contec monitor service, parser, dashboard and tunneling

This commit is contained in:
2026-09-12 19:56:21 +05:30
parent 2011544f53
commit 9d7c370836
72 changed files with 3966 additions and 10240 deletions
+357 -76
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import re
from datetime import datetime, timezone
from contec_parser import parse_contec_data
@@ -34,16 +35,195 @@ def generate_ack(received_msg: str) -> str:
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_msh = (
f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}"
f"|{now_str}||ACK|{now_str}|P|2.3\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 ""
TELEMETRY_WORDS = {
'sensor', 'off', 'lead', 'leads', 'fall', 'searching', 'pulse',
'le', 'le1', 'le2', 'le3', 'le4', 'le5', 'alarm', 'probe',
'cuff', 'error', 'leak', 'motion', 'weak', 'signal', 'learning', 'arr',
'asystole', 'vfib', 'vtac', 'pvc', 'brady', 'tachy', 'adult', 'pediatric',
'neonate', 'unknown', 'none', 'clear', 'default', 'demo', 't1', 't2',
'spo2', 'ecg', 'nibp', 'resp', 'temp', 'hr', 'pr', 'map', 'dia', 'sys'
}
def extract_demographics_from_packet(data: bytes, client_ip: str) -> dict:
"""
Extracts real patient demographics from Contec CMS8500 / CMS7000 binary packets.
Only extracts clean, legitimate patient fields — strictly rejects sensor alarms,
waveform noise, and hardware telemetry.
"""
clean = []
for m in re.finditer(rb'[a-zA-Z0-9_\-\.]{2,}', data):
s = m.group(0).decode('latin1', errors='ignore').strip()
if not s or len(s) < 2:
continue
s_lower = s.lower()
if any(kw in s_lower for kw in TELEMETRY_WORDS):
continue
if len(set(s)) == 1:
continue
if set(s).issubset({'.', '-', '_', '/', '\\'}):
continue
if len(s) > 8 and re.match(r'^[A-Za-z]+$', s):
diffs = [abs(ord(s[i]) - ord(s[i-1])) for i in range(1, len(s))]
if sum(1 for d in diffs if d <= 2) / len(diffs) > 0.6:
continue
if re.match(r'^\.\d+$', s):
continue
clean.append(s)
if not clean:
return None
name = None
mrn = None
dob = None
gender = None
for s in clean:
s_up = s.upper()
s_low = s.lower()
if s_low in TELEMETRY_WORDS:
continue
if s_up in ('MALE', 'FEMALE') and not gender:
gender = 'Male' if s_up == 'MALE' else 'Female'
continue
elif s_up in ('M', 'F') and not gender:
gender = 'Male' if s_up == 'M' else 'Female'
continue
# Date of birth: 4-digit birth year between 1920 and 2024, or YYYY-MM-DD
if re.match(r'^(19\d\d|20[01]\d|202[0-4])$', s) and not dob:
dob = s
continue
elif re.match(r'^\d{4}[-/]\d{2}[-/]\d{2}$', s) and not dob:
dob = s
continue
# Name candidate: strictly at least 3 letters, has a vowel, not telemetry
if re.search(r'^[A-Za-z\s\.\-]{3,30}$', s) and re.search(r'[aeiouyAEIOUY]', s) and not name:
if s_low not in TELEMETRY_WORDS:
name = s
continue
# MRN candidate: numeric or alphanumeric ID, at least 2 chars, not a year, not telemetry
if not mrn and re.match(r'^[A-Za-z0-9\-]{2,15}$', s) and s_low not in TELEMETRY_WORDS:
if not (s.isdigit() and 1920 <= int(s) <= 2030):
mrn = s
continue
# Only accept when BOTH real name and real MRN/DOB are present together
if name and (mrn or dob):
return {
"name": name,
"mrn": mrn or name,
"patient_id": mrn or name,
"dob": dob,
"gender": gender,
}
return None
async def apply_auto_demographics(client_ip: str, demo: dict):
from config import settings
model = settings.device_models.get(client_ip, settings.monitor_model)
device_id = f"{model}_{client_ip}"
name = demo.get("name")
mrn = demo.get("mrn")
dob = demo.get("dob")
gender = demo.get("gender")
current = device_patient_map.get(device_id, {})
if current.get("name") == name and current.get("mrn") == mrn and current.get("dob") == dob and current.get("gender") == gender:
return
logger.info(f"*** REAL PATIENT DEMOGRAPHICS DETECTED FROM MACHINE ({client_ip}): Name={name}, MRN={mrn}, DOB={dob}, Gender={gender} ***")
bed_id = "1" if "192.168.100.106" in client_ip else "2"
p_data = {
"patient_id": mrn,
"mrn": mrn,
"name": name,
"gender": gender,
"dob": dob,
"bed_id": bed_id
}
device_patient_map[device_id] = p_data
if device_id in device_cache:
cached = device_cache[device_id]
cached.patient_id = mrn
cached.patient_name = name
cached.patient_gender = gender
cached.patient_dob = dob
cached.bed_id = bed_id
db = SessionLocal()
try:
db.query(Patient).filter(Patient.device_id == device_id).update({"device_id": None})
patient = db.query(Patient).filter(Patient.mrn == mrn).first()
if not patient:
patient = Patient(
patient_id=mrn,
mrn=mrn,
name=name or "Unknown",
gender=gender,
dob=dob,
bed_id=bed_id,
device_id=device_id
)
db.add(patient)
else:
patient.patient_id = mrn
patient.mrn = mrn
if name:
patient.name = name
if gender:
patient.gender = gender
if dob:
patient.dob = dob
patient.bed_id = bed_id
patient.device_id = device_id
db.commit()
except Exception as e:
logger.error(f"Error persisting auto patient: {e}")
db.rollback()
finally:
db.close()
try:
from routers.dashboard import get_ws_manager
ws_manager = get_ws_manager()
await ws_manager.broadcast_vitals({
"event": "patient_assigned",
"device_id": device_id,
"patient_id": mrn,
"mrn": mrn,
"patient_name": name,
"name": name,
"patient_gender": gender,
"gender": gender,
"patient_dob": dob,
"dob": dob,
"bed_id": bed_id
})
except Exception as e:
logger.debug(f"Failed to broadcast auto patient: {e}")
async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
"""
TCP server handler for Contec CMS7000PLUS client connections.
@@ -52,6 +232,13 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
client_ip, client_port = writer.get_extra_info('peername')
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
sock = writer.get_extra_info('socket')
if sock:
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
display = get_terminal_display()
display.connection_opened(client_ip, port)
@@ -64,8 +251,10 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
break
if client_ip != "127.0.0.1":
logger.info(f"TCP READ from {client_ip}: {len(data)} bytes, hex: {data[:24].hex()}")
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)
@@ -166,6 +355,48 @@ device_cache = {}
device_field_timestamps = {} # {device_id: {field_name: datetime}}
last_db_write = {}
# In-memory mapping of device_id -> patient demographic dict:
# {"mrn": "...", "name": "...", "dob": "...", "gender": "...", "bed_id": "..."}
device_patient_map = {}
def get_assigned_patient(device_id: str):
"""Retrieve patient demographics assigned to device_id (cached or from DB)."""
if device_id in device_patient_map:
return device_patient_map[device_id]
db = SessionLocal()
try:
patient = db.query(Patient).filter(Patient.device_id == device_id).first()
if patient:
p_data = {
"patient_id": patient.mrn or patient.patient_id,
"mrn": patient.mrn or patient.patient_id,
"name": patient.name or "Unknown",
"gender": patient.gender,
"dob": patient.dob,
"bed_id": patient.bed_id,
}
device_patient_map[device_id] = p_data
return p_data
except Exception as e:
logger.debug(f"Error querying patient for device {device_id}: {e}")
finally:
db.close()
return None
def set_assigned_patient(device_id: str, patient_data: dict):
"""Update in-memory mapping and active device cache for device_id."""
device_patient_map[device_id] = patient_data
if device_id in device_cache:
cached = device_cache[device_id]
cached.patient_id = patient_data.get("mrn") or patient_data.get("patient_id") or "UNKNOWN"
cached.patient_name = patient_data.get("name")
cached.patient_gender = patient_data.get("gender")
cached.patient_dob = patient_data.get("dob")
cached.bed_id = patient_data.get("bed_id")
async def process_vitals(vitals):
"""
@@ -177,6 +408,25 @@ async def process_vitals(vitals):
if device_id not in device_field_timestamps:
device_field_timestamps[device_id] = {}
# Check for assigned patient demographics
assigned = get_assigned_patient(device_id)
if (vitals.patient_id == "UNKNOWN" or not vitals.patient_name or vitals.patient_name == "Unknown") and assigned:
vitals.patient_id = assigned.get("mrn") or assigned.get("patient_id") or "UNKNOWN"
vitals.patient_name = assigned.get("name")
vitals.patient_gender = assigned.get("gender")
vitals.patient_dob = assigned.get("dob")
vitals.bed_id = assigned.get("bed_id")
elif vitals.patient_name and vitals.patient_name != "Unknown":
# Monitor sent fresh demographics via HL7 PID segment!
device_patient_map[device_id] = {
"patient_id": vitals.patient_id,
"mrn": vitals.patient_id,
"name": vitals.patient_name,
"gender": vitals.patient_gender,
"dob": vitals.patient_dob,
"bed_id": vitals.bed_id,
}
# Check if incoming packet has fresh waveforms
has_fresh_waveforms = (vitals.ecg_wave is not None) or (vitals.pleth_wave is not None)
@@ -205,18 +455,34 @@ async def process_vitals(vitals):
# Update cache & record timestamps
for field in active_fields:
val = getattr(vitals, field)
val = getattr(vitals, field, None)
setattr(cached, field, val)
device_field_timestamps[device_id][field] = now
if val is not None:
device_field_timestamps[device_id][field] = now
else:
device_field_timestamps[device_id].pop(field, None)
# Clean up stale fields (older than 15 seconds)
# Clean up stale fields:
# Live vitals (HR, SpO2, Temp, RR) have a 60-second window because monitor cycles take ~24s
# NIBP (BP, MAP) has a 1-hour window because it is measured intermittently
for field in list(device_field_timestamps[device_id].keys()):
last_updated = device_field_timestamps[device_id][field]
if (now - last_updated).total_seconds() > 15.0:
timeout = 3600.0 if field in ("systolic_bp", "diastolic_bp", "map_bp") else 60.0
if (now - last_updated).total_seconds() > timeout:
setattr(cached, field, None)
device_field_timestamps[device_id].pop(field, None)
cached.timestamp = vitals.timestamp
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
cached.patient_id = vitals.patient_id
if vitals.patient_name:
cached.patient_name = vitals.patient_name
if vitals.patient_gender:
cached.patient_gender = vitals.patient_gender
if vitals.patient_dob:
cached.patient_dob = vitals.patient_dob
if vitals.bed_id:
cached.bed_id = vitals.bed_id
vitals = cached
# 2. Update console display banner
@@ -224,7 +490,7 @@ async def process_vitals(vitals):
display.update_vitals(vitals)
logger.info(
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id}: "
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id} ({vitals.patient_name}): "
f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
@@ -234,23 +500,40 @@ async def process_vitals(vitals):
try:
from routers.dashboard import get_ws_manager
ws_manager = get_ws_manager()
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
vitals_dict = vitals.model_dump(mode="json")
vitals_dict["mrn"] = vitals.patient_id
vitals_dict["name"] = vitals.patient_name
vitals_dict["gender"] = vitals.patient_gender
vitals_dict["dob"] = vitals.patient_dob
vitals_dict["bed_id"] = vitals.bed_id
await ws_manager.broadcast_vitals(vitals_dict)
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
# Note: If we have fresh waveforms, we bypass throttle for REST forwarding to keep cloud graphs real-time
# 4. Asynchronous background persistence and cloud forwarding (completely non-blocking, throttled to 1s)
now = datetime.now(timezone.utc)
should_write_db = False
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0:
should_write_db = True
should_persist = False
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 1.0:
should_persist = True
last_db_write[device_id] = now
if should_write_db:
# Save to SQLite database and REST forward
if should_persist:
asyncio.create_task(_persist_and_forward_bg(vitals, should_persist))
# Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets
if device_id in device_cache:
device_cache[device_id].ecg_wave = None
device_cache[device_id].pleth_wave = None
async def _persist_and_forward_bg(vitals, should_persist):
"""Background worker for DB persistence and external cloud forwarding."""
if not should_persist:
return
reading_id = None
if should_persist:
db = SessionLocal()
try:
# 1. Device tracking
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
if not device:
device = Device(
@@ -264,33 +547,36 @@ async def process_vitals(vitals):
device.ip_address = vitals.ip_address
device.status = "active"
device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking — store/update demographics from HL7
if vitals.patient_id == "UNKNOWN":
last_known_reading = (
db.query(VitalReading)
.filter(VitalReading.device_id == vitals.device_id)
.filter(VitalReading.patient_id != "UNKNOWN")
.order_by(VitalReading.timestamp.desc())
.first()
)
if last_known_reading:
vitals.patient_id = last_known_reading.patient_id
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
patient = None
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
patient = db.query(Patient).filter(
(Patient.mrn == vitals.patient_id) | (Patient.patient_id == vitals.patient_id)
).first()
if not patient and vitals.device_id:
patient = db.query(Patient).filter(
Patient.device_id == vitals.device_id,
Patient.patient_id != "UNKNOWN"
).first()
if not patient:
patient = Patient(
patient_id=vitals.patient_id,
mrn=vitals.patient_id,
name=vitals.patient_name or "Unknown",
gender=vitals.patient_gender,
dob=vitals.patient_dob,
bed_id=vitals.bed_id,
)
db.add(patient)
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
patient = Patient(
patient_id=vitals.patient_id,
mrn=vitals.patient_id,
name=vitals.patient_name or "Unknown",
gender=vitals.patient_gender,
dob=vitals.patient_dob,
bed_id=vitals.bed_id,
device_id=vitals.device_id
)
db.add(patient)
else:
# Refresh demographics whenever monitor sends updated PID fields
if vitals.patient_name:
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
patient.patient_id = vitals.patient_id
patient.mrn = vitals.patient_id
if vitals.patient_name and vitals.patient_name != "Unknown":
patient.name = vitals.patient_name
if vitals.patient_gender:
patient.gender = vitals.patient_gender
@@ -298,9 +584,9 @@ async def process_vitals(vitals):
patient.dob = vitals.patient_dob
if vitals.bed_id:
patient.bed_id = vitals.bed_id
if vitals.device_id:
patient.device_id = vitals.device_id
# 3. Create Reading Entry
reading = VitalReading(
device_id=vitals.device_id,
patient_id=vitals.patient_id,
@@ -317,40 +603,35 @@ async def process_vitals(vitals):
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()
reading_id = reading.id
except Exception as e:
logger.error(f"Database operation failed: {e}")
logger.error(f"Background database operation failed: {e}")
db.rollback()
finally:
db.close()
elif has_fresh_waveforms:
# Bypass throttle for REST forwarding to keep cloud graphs real-time
try:
await forward_vitals_to_api(vitals)
except Exception as e:
logger.error(f"Real-time REST forwarding failed: {e}")
# Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets
if device_id in device_cache:
device_cache[device_id].ecg_wave = None
device_cache[device_id].pleth_wave = None
# DB connection is now completely closed. Awaiting HTTP requests will never exhaust DB connections!
success = False
if should_persist:
try:
success = await forward_vitals_to_api(vitals)
except Exception as e:
logger.warning(f"Background REST forward failed: {type(e).__name__}: {e}")
# Record transmission log in a separate, brief connection
if reading_id is not None:
db = SessionLocal()
try:
log_entry = TransmissionLog(
reading_id=reading_id,
status="success" if success else "failed",
response_code=200 if success else 500,
error_message=None if success else "Immediate REST forward failed"
)
db.add(log_entry)
db.commit()
except Exception as e:
logger.error(f"Failed to record transmission log: {e}")
db.rollback()
finally:
db.close()