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
+24 -6
View File
@@ -6,6 +6,17 @@ from config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=3.0, read=5.0, write=3.0, pool=3.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
)
return _http_client
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool: async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
""" """
Attempts to POST the normalized vitals to the configured target API Attempts to POST the normalized vitals to the configured target API
@@ -15,6 +26,13 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
import os import os
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
# Strip heavy wave sample arrays before REST transmission (cloud APIs only ingest vital signs)
payload = vitals.model_dump(mode="json")
payload.pop("ecg_wave", None)
payload.pop("pleth_wave", None)
client = get_http_client()
# 1. Forward to primary target API (e.g. Injomo Dev Workflow) # 1. Forward to primary target API (e.g. Injomo Dev Workflow)
primary_url = settings.target_api_url primary_url = settings.target_api_url
primary_success = False primary_success = False
@@ -23,17 +41,17 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
primary_headers = headers.copy() primary_headers = headers.copy()
if settings.api_token: if settings.api_token:
primary_headers["Authorization"] = f"Bearer {settings.api_token}" primary_headers["Authorization"] = f"Bearer {settings.api_token}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post( response = await client.post(
primary_url, primary_url,
json=vitals.model_dump(mode="json"), json=payload,
headers=primary_headers headers=primary_headers
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}") logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}")
primary_success = True primary_success = True
except Exception as e: except Exception as e:
logger.error(f"Error forwarding to primary API: {e}") err_msg = str(e).strip() or "timeout / connection reset"
logger.warning(f"Error forwarding to primary API: {type(e).__name__} ({err_msg})")
# 2. Forward to Render Cloud Server # 2. Forward to Render Cloud Server
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals" render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
@@ -48,16 +66,16 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
if settings.api_token: if settings.api_token:
url_with_key = f"{render_url}?api_key={settings.api_token}" url_with_key = f"{render_url}?api_key={settings.api_token}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post( response = await client.post(
url_with_key, url_with_key,
json=vitals.model_dump(mode="json"), json=payload,
headers=headers headers=headers
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}") logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}")
render_success = True render_success = True
except Exception as e: except Exception as e:
logger.error(f"Error forwarding to Render Cloud: {e}") err_msg = str(e).strip() or "timeout / connection reset"
logger.warning(f"Error forwarding to Render Cloud: {type(e).__name__} ({err_msg})")
return primary_success or render_success return primary_success or render_success
+52 -38
View File
@@ -17,47 +17,61 @@ async def retry_failed_transmissions():
while True: while True:
try: try:
# 1. Fetch untransmitted readings in a quick DB session and close it immediately
db = SessionLocal()
readings_to_retry = []
try:
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).order_by(VitalReading.id.desc()).limit(20).all()
for r in failed_readings:
readings_to_retry.append({
"id": r.id,
"vitals": NormalizedVitals(
device_id=r.device_id,
patient_id=r.patient_id,
timestamp=r.timestamp,
heart_rate=r.heart_rate,
spo2=r.spo2,
systolic_bp=r.systolic_bp,
diastolic_bp=r.diastolic_bp,
map_bp=r.map_bp,
respiratory_rate=r.respiratory_rate,
temperature=r.temperature
)
})
finally:
db.close()
# 2. Forward outside of any DB transaction
successful_ids = []
failed_ids = []
consecutive_failures = 0
for item in readings_to_retry:
success = await forward_vitals_to_api(item["vitals"])
if success:
successful_ids.append(item["id"])
consecutive_failures = 0
else:
failed_ids.append(item["id"])
consecutive_failures += 1
# If external API is unresponsive, stop this batch immediately so we don't spam
if consecutive_failures >= 3:
logger.warning("External API appears unreachable during retry; pausing retry queue.")
break
# 3. Update status in a quick DB session
if successful_ids or failed_ids:
db = SessionLocal() db = SessionLocal()
try: try:
# Get readings that haven't been transmitted for rid in successful_ids:
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).limit(50).all() db.query(VitalReading).filter(VitalReading.id == rid).update({"transmitted": True})
db.add(TransmissionLog(reading_id=rid, status="success", response_code=200))
for reading in failed_readings: for rid in failed_ids:
vitals = NormalizedVitals( db.add(TransmissionLog(reading_id=rid, status="failed", error_message="Retry failed"))
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() db.commit()
except Exception as e:
logger.error(f"Failed updating retry transmission status: {e}")
db.rollback()
finally: finally:
db.close() db.close()
BIN
View File
Binary file not shown.
+112 -177
View File
@@ -247,7 +247,7 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[
vitals_dict["spo2"] = float(spo2) vitals_dict["spo2"] = float(spo2)
found = True found = True
# Heart rate: prefer ECG HR; fall back to SpO2-derived PR if ECG invalid # Heart rate: ECG HR at offset+2 is primary; SpO2 PR at offset+4 is fallback.
if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300: if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300:
vitals_dict["heart_rate"] = float(hr_ecg) vitals_dict["heart_rate"] = float(hr_ecg)
found = True found = True
@@ -260,19 +260,19 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[
def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
""" """
Parse 45-byte packet (Subtype 22) — contains NIBP values and alarm limits. Parse 45/47-byte packet (Subtype 22) — contains NIBP values and alarm limits.
Verified layout: Verified layout:
[0-1] Packet length LE u16 = 45 [0-1] Packet length LE u16 = 45 or 47
[2-3] Marker: 01 46 [2-3] Marker: 01 46 or 00 46
[4-7] Sub-header (00 00 16 00) [4-7] Sub-header (00 00 16 00)
[8-9] Year LE u16 [8-9] Year LE u16
[10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds [10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds
[15-16] NIBP Systolic (LE u16, 9999 = no measurement) [15-16] NIBP Systolic (LE u16, 32767 / 9999 = no measurement / idle)
[17-18] NIBP Diastolic (LE u16, 9999 = no measurement) [17-18] NIBP Diastolic (LE u16, 32767 / 9999 = no measurement / idle)
[19-20] NIBP MAP (LE u16, 9999 = no measurement) [19-20] NIBP MAP (LE u16, 32767 / 9999 = no measurement / idle)
""" """
if len(data) < 22: if len(data) < 21:
return False return False
if "present_fields" not in vitals_dict: if "present_fields" not in vitals_dict:
@@ -282,32 +282,24 @@ def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
nibp_dia = read_u16_le(data, 17) nibp_dia = read_u16_le(data, 17)
nibp_map = read_u16_le(data, 19) nibp_map = read_u16_le(data, 19)
found = False sentinels = (9999, 32767, 65535, 0)
# Mark NIBP fields as present so cache can clear them if 9999 if nibp_sys is not None and nibp_sys not in sentinels and 40 <= nibp_sys <= 260:
for f in ["systolic_bp", "diastolic_bp", "map_bp"]:
if f not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append(f)
if nibp_sys is not None and nibp_sys != 9999 and 40 < nibp_sys < 250:
vitals_dict["systolic_bp"] = float(nibp_sys) vitals_dict["systolic_bp"] = float(nibp_sys)
found = True if "systolic_bp" not in vitals_dict["present_fields"]:
else: vitals_dict["present_fields"].append("systolic_bp")
vitals_dict["systolic_bp"] = None
if nibp_dia is not None and nibp_dia != 9999 and 20 < nibp_dia < 200: if nibp_dia is not None and nibp_dia not in sentinels and 20 <= nibp_dia <= 200:
vitals_dict["diastolic_bp"] = float(nibp_dia) vitals_dict["diastolic_bp"] = float(nibp_dia)
found = True if "diastolic_bp" not in vitals_dict["present_fields"]:
else: vitals_dict["present_fields"].append("diastolic_bp")
vitals_dict["diastolic_bp"] = None
if nibp_map is not None and nibp_map != 9999 and 20 < nibp_map < 250: if nibp_map is not None and nibp_map not in sentinels and 20 <= nibp_map <= 250:
vitals_dict["map_bp"] = float(nibp_map) vitals_dict["map_bp"] = float(nibp_map)
found = True if "map_bp" not in vitals_dict["present_fields"]:
else: vitals_dict["present_fields"].append("map_bp")
vitals_dict["map_bp"] = None
return found return True
def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
@@ -325,144 +317,101 @@ def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
if "present_fields" not in vitals_dict: if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = [] vitals_dict["present_fields"] = []
if "temperature" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("temperature")
vitals_dict.setdefault("temperature", None)
found = False found = False
temp1 = read_f32_le(data, 8) temp1 = read_f32_le(data, 8)
temp2 = read_f32_le(data, 12) temp2 = read_f32_le(data, 12)
# 9999.0 is sentinel def is_real_temp(t):
if temp1 is not None and is_valid_f32(temp1) and 10.0 < temp1 < 50.0: # Disconnected sentinel is 9999.0 or 3276.7 (0x7FFF)
return t is not None and is_valid_f32(t) and abs(t - 3276.7) > 1.0 and 20.0 < t < 45.0
if is_real_temp(temp1):
vitals_dict["temperature"] = round(temp1, 1) vitals_dict["temperature"] = round(temp1, 1)
if "temperature" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("temperature")
found = True found = True
elif temp2 is not None and is_valid_f32(temp2) and 10.0 < temp2 < 50.0: elif is_real_temp(temp2):
vitals_dict["temperature"] = round(temp2, 1) vitals_dict["temperature"] = round(temp2, 1)
if "temperature" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("temperature")
found = True found = True
elif temp1 is not None or temp2 is not None:
# Sensor disconnected / off
vitals_dict["temperature"] = None
if "temperature" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("temperature")
found = True
return found
def _parse_spo2_waveform_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse SpO2 & Pleth waveform packet (Subtype 21 for CMS7000PLUS / CMS8500).
SpO2% is located at offset 264. Disconnected sentinel is 65535, 32767, or 255.
Offsets 270+ contain alarm limits (100, 90) and must NOT be parsed as vitals.
"""
if len(data) < 266:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
# Extract Pleth waveform samples (bytes 8..263)
vitals_dict["pleth_wave"] = list(data[8:min(264, len(data))])
if "pleth_wave" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("pleth_wave")
found = False
# Check SpO2 at standard offset 264
spo2_val = read_u16_le(data, 264)
# If 264 is disconnected sentinel, probe is NOT connected / off-finger
if spo2_val in (None, 65535, 32767, 255, 9999, 0):
vitals_dict["spo2"] = None
vitals_dict["heart_rate"] = None
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
return True
if 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
found = True
# ECG HR is at offset 266 in the 286-byte SpO2 waveform packet footer.
# Offset 268 is the SpO2 HIGH ALARM LIMIT (always 100) — NOT pulse rate!
# So we read ECG HR at 266 only.
ecg_hr = read_u16_le(data, 266)
logger.info(
f"SpO2 pkt: SpO2[264]={spo2_val} ECG_HR[266]={ecg_hr} alarm_hi[268]={read_u16_le(data,268)} alarm_lo[270]={read_u16_le(data,270) if len(data)>=272 else 'N/A'}"
)
if ecg_hr is not None and ecg_hr not in (9999, 65535, 32767, 0) and 20 <= ecg_hr <= 300:
vitals_dict["heart_rate"] = float(ecg_hr)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
return found return found
def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool:
""" return _parse_spo2_waveform_packet(data, vitals_dict)
Parse 286-byte waveform packet (Subtype 21).
Verified layout:
[8..263] Pleth waveform samples (8-bit unsigned, 256 samples)
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
255 / 65535 = sensor disconnected
[266-267] ECG HR via SpO2 PR (LE u16) — 9999 / 65535 = not available
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
if "pleth_wave" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("pleth_wave")
vitals_dict.setdefault("spo2", None)
found = False
# Extract Pleth waveform samples (bytes 8..263)
raw_pleth = list(data[8:264])
# Only store if not all-zeroes or all-sentinel (flatline = disconnected)
if any(v not in (0x00, 0x7F, 0x3A, 0x92, 0xFF) for v in raw_pleth[:10]):
vitals_dict["pleth_wave"] = raw_pleth
else:
vitals_dict["pleth_wave"] = raw_pleth # Always send so frontend can show flatline too
# Offset 264: SpO2 percentage
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
# Offset 266: HR from SpO2 PR (only add to present fields if valid to prevent overriding ECG HR)
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool:
""" return _parse_spo2_waveform_packet(data, vitals_dict)
Parse 288-byte waveform packet (Subtype 21 for CMS8500).
Layout:
[8..263] Pleth waveform samples (8-bit unsigned, 256 samples)
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
[266-267] PR (LE u16) — pulse rate
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
if "pleth_wave" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("pleth_wave")
vitals_dict.setdefault("spo2", None)
found = False
# Extract Pleth waveform samples (bytes 8..263)
vitals_dict["pleth_wave"] = list(data[8:264])
# Offset 264: SpO2 percentage
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
# Offset 266: PR from SpO2 PR
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool:
""" return _parse_spo2_waveform_packet(data, vitals_dict)
Parse 341-byte waveform packet. Same tail layout as 286-byte.
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
vitals_dict.setdefault("spo2", None)
found = False
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool: def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
@@ -479,38 +428,27 @@ def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
if "present_fields" not in vitals_dict: if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = [] vitals_dict["present_fields"] = []
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate") # Extract ECG waveform samples (bytes 8..903), downsample to ~224 points for smooth real-time rendering
raw_ecg = list(data[8:904])
vitals_dict["ecg_wave"] = raw_ecg[::4]
if "ecg_wave" not in vitals_dict["present_fields"]: if "ecg_wave" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("ecg_wave") vitals_dict["present_fields"].append("ecg_wave")
vitals_dict.setdefault("heart_rate", None)
found = False
# Extract ECG waveform samples (bytes 8..903), downsample to 200 points for efficiency
raw_ecg = list(data[8:904])
# Downsample by factor of 4 -> ~224 points (still high-resolution enough)
vitals_dict["ecg_wave"] = raw_ecg[::4]
hr_val = read_u16_le(data, 904) hr_val = read_u16_le(data, 904)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300: if hr_val is not None and hr_val not in (65535, 9999, 0) and 20 <= hr_val <= 300:
# Only use ECG HR if SpO2-probe PR has NOT already set heart_rate.
# SpO2 PR (from the pleth packet) matches the monitor display and wins.
if "heart_rate" not in vitals_dict.get("present_fields", []):
vitals_dict["heart_rate"] = float(hr_val) vitals_dict["heart_rate"] = float(hr_val)
found = True vitals_dict.setdefault("present_fields", []).append("heart_rate")
return found return True
def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]: def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
""" """
Scan the data stream for Contec CMS7000PLUS sub-packets and parse them Scan the data stream for Contec CMS7000PLUS / CMS8500 sub-packets and parse them.
deterministically based on packet length.
Packet types (verified by raw hex analysis):
45 bytes (Subtype 22): NIBP Sys/Dia/MAP (u16 LE starting at offset 15)
56 bytes (Subtype 23): Float32 Temperature (offset 8/12)
286 bytes (Subtype 21): SpO2% at offset 264
341 bytes (Subtype 21): SpO2% at offset 264
989 bytes (Subtype 20): ECG Heart Rate at offset 904
""" """
model = settings.device_models.get(client_ip, settings.monitor_model) model = settings.device_models.get(client_ip, settings.monitor_model)
vitals_dict = { vitals_dict = {
@@ -530,22 +468,19 @@ def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[Normal
pkt_data = data[i:i + pkt_len] pkt_data = data[i:i + pkt_len]
if pkt_len in (45, 47): if pkt_len in (45, 47):
_parse_45_byte_packet(pkt_data, vitals_dict) if _parse_45_byte_packet(pkt_data, vitals_dict):
found_any = True found_any = True
elif pkt_len == 56: elif pkt_len == 56:
_parse_56_byte_packet(pkt_data, vitals_dict) if _parse_56_byte_packet(pkt_data, vitals_dict):
found_any = True found_any = True
elif pkt_len == 286: elif pkt_len in (286, 288, 341):
_parse_286_byte_packet(pkt_data, vitals_dict) if _parse_spo2_waveform_packet(pkt_data, vitals_dict):
found_any = True
elif pkt_len == 288:
_parse_288_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 341:
_parse_341_byte_packet(pkt_data, vitals_dict)
found_any = True found_any = True
elif pkt_len == 989: elif pkt_len == 989:
_parse_989_byte_packet(pkt_data, vitals_dict) if _parse_989_byte_packet(pkt_data, vitals_dict):
found_any = True
elif 100 < pkt_len < 600:
if _parse_spo2_waveform_packet(pkt_data, vitals_dict):
found_any = True found_any = True
i += max(pkt_len, 4) i += max(pkt_len, 4)
+344 -63
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from contec_parser import parse_contec_data from contec_parser import parse_contec_data
@@ -35,7 +36,10 @@ def generate_ack(received_msg: str) -> str:
now_str = datetime.now().strftime("%Y%m%d%H%M%S") 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" ack_msa = f"MSA|AA|{msg_control_id}\r"
return ack_msh + ack_msa return ack_msh + ack_msa
@@ -44,6 +48,182 @@ def generate_ack(received_msg: str) -> str:
return "" 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): async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
""" """
TCP server handler for Contec CMS7000PLUS client connections. 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') client_ip, client_port = writer.get_extra_info('peername')
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}") 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 = get_terminal_display()
display.connection_opened(client_ip, port) 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)") logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
break 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 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) # MLLP / HL7 text path (typically port 511)
@@ -166,6 +355,48 @@ device_cache = {}
device_field_timestamps = {} # {device_id: {field_name: datetime}} device_field_timestamps = {} # {device_id: {field_name: datetime}}
last_db_write = {} 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): async def process_vitals(vitals):
""" """
@@ -177,6 +408,25 @@ async def process_vitals(vitals):
if device_id not in device_field_timestamps: if device_id not in device_field_timestamps:
device_field_timestamps[device_id] = {} 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 # Check if incoming packet has fresh waveforms
has_fresh_waveforms = (vitals.ecg_wave is not None) or (vitals.pleth_wave is not None) 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 # Update cache & record timestamps
for field in active_fields: for field in active_fields:
val = getattr(vitals, field) val = getattr(vitals, field, None)
setattr(cached, field, val) setattr(cached, field, val)
if val is not None:
device_field_timestamps[device_id][field] = now 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()): for field in list(device_field_timestamps[device_id].keys()):
last_updated = device_field_timestamps[device_id][field] 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) setattr(cached, field, None)
device_field_timestamps[device_id].pop(field, None) device_field_timestamps[device_id].pop(field, None)
cached.timestamp = vitals.timestamp 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 vitals = cached
# 2. Update console display banner # 2. Update console display banner
@@ -224,7 +490,7 @@ async def process_vitals(vitals):
display.update_vitals(vitals) display.update_vitals(vitals)
logger.info( 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"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, " f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}" f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
@@ -234,23 +500,40 @@ async def process_vitals(vitals):
try: try:
from routers.dashboard import get_ws_manager from routers.dashboard import get_ws_manager
ws_manager = 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: except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}") logger.debug(f"WebSocket broadcast failed: {e}")
# 4. Throttled Database & REST forwarding (at most once every 5 seconds) # 4. Asynchronous background persistence and cloud forwarding (completely non-blocking, throttled to 1s)
# Note: If we have fresh waveforms, we bypass throttle for REST forwarding to keep cloud graphs real-time
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
should_write_db = False should_persist = False
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0: if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 1.0:
should_write_db = True should_persist = True
last_db_write[device_id] = now last_db_write[device_id] = now
if should_write_db: if should_persist:
# Save to SQLite database and REST forward 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() db = SessionLocal()
try: try:
# 1. Device tracking
device = db.query(Device).filter(Device.device_id == vitals.device_id).first() device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
if not device: if not device:
device = Device( device = Device(
@@ -265,20 +548,20 @@ async def process_vitals(vitals):
device.status = "active" device.status = "active"
device.last_seen = datetime.now(timezone.utc) device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking — store/update demographics from HL7 patient = None
if vitals.patient_id == "UNKNOWN": if vitals.patient_id and vitals.patient_id != "UNKNOWN":
last_known_reading = ( patient = db.query(Patient).filter(
db.query(VitalReading) (Patient.mrn == vitals.patient_id) | (Patient.patient_id == vitals.patient_id)
.filter(VitalReading.device_id == vitals.device_id) ).first()
.filter(VitalReading.patient_id != "UNKNOWN")
.order_by(VitalReading.timestamp.desc()) if not patient and vitals.device_id:
.first() patient = db.query(Patient).filter(
) Patient.device_id == vitals.device_id,
if last_known_reading: Patient.patient_id != "UNKNOWN"
vitals.patient_id = last_known_reading.patient_id ).first()
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
if not patient: if not patient:
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
patient = Patient( patient = Patient(
patient_id=vitals.patient_id, patient_id=vitals.patient_id,
mrn=vitals.patient_id, mrn=vitals.patient_id,
@@ -286,11 +569,14 @@ async def process_vitals(vitals):
gender=vitals.patient_gender, gender=vitals.patient_gender,
dob=vitals.patient_dob, dob=vitals.patient_dob,
bed_id=vitals.bed_id, bed_id=vitals.bed_id,
device_id=vitals.device_id
) )
db.add(patient) db.add(patient)
else: else:
# Refresh demographics whenever monitor sends updated PID fields if vitals.patient_id and vitals.patient_id != "UNKNOWN":
if vitals.patient_name: 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 patient.name = vitals.patient_name
if vitals.patient_gender: if vitals.patient_gender:
patient.gender = vitals.patient_gender patient.gender = vitals.patient_gender
@@ -298,9 +584,9 @@ async def process_vitals(vitals):
patient.dob = vitals.patient_dob patient.dob = vitals.patient_dob
if vitals.bed_id: if vitals.bed_id:
patient.bed_id = 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( reading = VitalReading(
device_id=vitals.device_id, device_id=vitals.device_id,
patient_id=vitals.patient_id, patient_id=vitals.patient_id,
@@ -317,40 +603,35 @@ async def process_vitals(vitals):
db.add(reading) db.add(reading)
db.commit() db.commit()
db.refresh(reading) db.refresh(reading)
reading_id = reading.id
# 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: except Exception as e:
logger.error(f"Database operation failed: {e}") logger.error(f"Background database operation failed: {e}")
db.rollback() db.rollback()
finally: finally:
db.close() 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 # DB connection is now completely closed. Awaiting HTTP requests will never exhaust DB connections!
if device_id in device_cache: success = False
device_cache[device_id].ecg_wave = None if should_persist:
device_cache[device_id].pleth_wave = None 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()
+4 -1
View File
@@ -1,9 +1,12 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
from config import settings from config import settings
engine = create_engine( engine = create_engine(
settings.database_url, connect_args={"check_same_thread": False} settings.database_url,
connect_args={"check_same_thread": False},
poolclass=NullPool
) )
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -1,17 +0,0 @@
__pycache__/
*.py[cod]
*.db
*.sqlite3
*.log
*.bin
.env
venv/
.venv/
build/
dist/
*.spec
scratch/
captures/
.git/
.idea/
.vscode/
@@ -1,37 +0,0 @@
# 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/
# Scratch / debug
scratch/
captures/
*.bin
# IDE / OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Render sets PORT env var (default 10000)
EXPOSE 10000
CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-10000}
@@ -1 +0,0 @@
web: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
@@ -1,44 +0,0 @@
# 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`
# ContectFiveParaMonitor
@@ -1,63 +0,0 @@
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
and to the Render cloud database.
Returns True if at least one forward succeeds.
"""
import os
headers = {"Content-Type": "application/json"}
# 1. Forward to primary target API (e.g. Injomo Dev Workflow)
primary_url = settings.target_api_url
primary_success = False
if primary_url:
try:
primary_headers = headers.copy()
if settings.api_token:
primary_headers["Authorization"] = f"Bearer {settings.api_token}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
primary_url,
json=vitals.model_dump(mode="json"),
headers=primary_headers
)
response.raise_for_status()
logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}")
primary_success = True
except Exception as e:
logger.error(f"Error forwarding to primary API: {e}")
# 2. Forward to Render Cloud Server
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
render_success = False
# Detect if we are already running on Render to prevent an infinite loop
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
if not is_on_render:
try:
url_with_key = render_url
if settings.api_token:
url_with_key = f"{render_url}?api_key={settings.api_token}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
url_with_key,
json=vitals.model_dump(mode="json"),
headers=headers
)
response.raise_for_status()
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}")
render_success = True
except Exception as e:
logger.error(f"Error forwarding to Render Cloud: {e}")
return primary_success or render_success
@@ -1,67 +0,0 @@
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)
@@ -1,29 +0,0 @@
@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
@@ -1,28 +0,0 @@
#!/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."
@@ -1,15 +0,0 @@
{
"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": "CMS8500",
"device_models": {
"192.168.100.120": "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"
}
@@ -1,45 +0,0 @@
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"
device_models: dict = {}
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:
base_dir = os.path.dirname(os.path.abspath(__file__))
config_file = os.path.join(base_dir, "config.json")
settings_obj = Settings()
if os.path.exists(config_file):
with open(config_file, "r") as f:
try:
data = json.load(f)
for k, v in data.items():
setattr(settings_obj, k, v)
except json.JSONDecodeError:
pass
if settings_obj.database_url.startswith("sqlite:///./"):
db_name = settings_obj.database_url.split("sqlite:///./")[1]
settings_obj.database_url = f"sqlite:///{os.path.join(base_dir, db_name)}"
if not os.path.isabs(settings_obj.log_file):
settings_obj.log_file = os.path.join(base_dir, settings_obj.log_file)
return settings_obj
settings = get_settings()
@@ -1,246 +0,0 @@
"""
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.")
@@ -1,553 +0,0 @@
import logging
from datetime import datetime, timezone
from typing import Optional, List
import struct
from schemas import NormalizedVitals
from config import settings
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 and val != 255 and val != 65535
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
model = settings.device_models.get(client_ip, settings.monitor_model)
msh_fields = segments[0].split("|")
device_id = msh_fields[2] if len(msh_fields) > 2 and msh_fields[2] else f"{model}_{client_ip}"
patient_id = "UNKNOWN"
vitals_dict = {
"device_id": device_id,
"patient_id": patient_id,
"timestamp": datetime.now(timezone.utc),
"ip_address": client_ip,
}
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_val = fields[5].strip()
if not obs_val or obs_val == "---":
continue
val_float = safe_float(obs_val)
if val_float is None:
continue
# Split OBX-3 (Observation Identifier) by '^' and check all parts for parameter matches
obs_parts = [p.strip().upper() for p in fields[3].split("^") if p.strip()]
matched = False
for param_key, mapped_field in CONTEC_PARAM_MAP.items():
for part in obs_parts:
if param_key == part or param_key in part:
vitals_dict[mapped_field] = val_float
found_any = True
logger.info(f"Parsed Contec HL7 Vital: {mapped_field} = {val_float} (from OBX-3: {fields[3]})")
matched = True
break
if matched:
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)
"""
model = settings.device_models.get(client_ip, settings.monitor_model)
vitals_dict = {
"device_id": f"{model}_{client_ip}",
"patient_id": "UNKNOWN",
"timestamp": datetime.now(timezone.utc),
"ip_address": client_ip,
}
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_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 45-byte packet (Subtype 22) — contains NIBP values and alarm limits.
Verified layout:
[0-1] Packet length LE u16 = 45
[2-3] Marker: 01 46
[4-7] Sub-header (00 00 16 00)
[8-9] Year LE u16
[10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds
[15-16] NIBP Systolic (LE u16, 9999 = no measurement)
[17-18] NIBP Diastolic (LE u16, 9999 = no measurement)
[19-20] NIBP MAP (LE u16, 9999 = no measurement)
"""
if len(data) < 22:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
nibp_sys = read_u16_le(data, 15)
nibp_dia = read_u16_le(data, 17)
nibp_map = read_u16_le(data, 19)
found = False
# Mark NIBP fields as present so cache can clear them if 9999
for f in ["systolic_bp", "diastolic_bp", "map_bp"]:
if f not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append(f)
if nibp_sys is not None and nibp_sys != 9999 and 40 < nibp_sys < 250:
vitals_dict["systolic_bp"] = float(nibp_sys)
found = True
else:
vitals_dict["systolic_bp"] = None
if nibp_dia is not None and nibp_dia != 9999 and 20 < nibp_dia < 200:
vitals_dict["diastolic_bp"] = float(nibp_dia)
found = True
else:
vitals_dict["diastolic_bp"] = None
if nibp_map is not None and nibp_map != 9999 and 20 < nibp_map < 250:
vitals_dict["map_bp"] = float(nibp_map)
found = True
else:
vitals_dict["map_bp"] = None
return found
def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 56-byte packet (Subtype 23) — contains float32 Temperature values.
Verified layout:
[8-11] Temperature 1 (float32)
[12-15] Temperature 2 (float32)
[20-23] Temp 1 Alarm High Limit (float32)
[24-27] Temp 1 Alarm Low Limit (float32)
"""
if len(data) < 16:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "temperature" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("temperature")
vitals_dict.setdefault("temperature", None)
found = False
temp1 = read_f32_le(data, 8)
temp2 = read_f32_le(data, 12)
# 9999.0 is sentinel
if temp1 is not None and is_valid_f32(temp1) and 10.0 < temp1 < 50.0:
vitals_dict["temperature"] = round(temp1, 1)
found = True
elif temp2 is not None and is_valid_f32(temp2) and 10.0 < temp2 < 50.0:
vitals_dict["temperature"] = round(temp2, 1)
found = True
return found
def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 286-byte waveform packet (Subtype 21).
Verified layout at tail:
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
255 / 65535 = sensor disconnected
[266-267] ECG HR via SpO2 PR (LE u16) — 9999 / 65535 = not available
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
vitals_dict.setdefault("spo2", None)
found = False
# Offset 264: SpO2 percentage
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
# Offset 266: HR from SpO2 PR (only add to present fields if valid to prevent overriding ECG HR)
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 288-byte waveform packet (Subtype 21 for CMS8500).
Layout:
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
[266-267] PR (LE u16) — pulse rate
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
vitals_dict.setdefault("spo2", None)
found = False
# Offset 264: SpO2 percentage
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
# Offset 266: PR from SpO2 PR
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 341-byte waveform packet. Same tail layout as 286-byte.
"""
if len(data) < 270:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "spo2" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("spo2")
vitals_dict.setdefault("spo2", None)
found = False
spo2_val = read_u16_le(data, 264)
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
vitals_dict["spo2"] = float(spo2_val)
found = True
hr_val = read_u16_le(data, 266)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
found = True
return found
def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
"""
Parse 989-byte waveform packet (Subtype 20).
Verified layout:
[904-905] ECG Heart Rate (LE u16)
65535 or 9999 = invalid/disconnected
"""
if len(data) < 906:
return False
if "present_fields" not in vitals_dict:
vitals_dict["present_fields"] = []
if "heart_rate" not in vitals_dict["present_fields"]:
vitals_dict["present_fields"].append("heart_rate")
vitals_dict.setdefault("heart_rate", None)
found = False
hr_val = read_u16_le(data, 904)
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
vitals_dict["heart_rate"] = float(hr_val)
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 and parse them
deterministically based on packet length.
Packet types (verified by raw hex analysis):
45 bytes (Subtype 22): NIBP Sys/Dia/MAP (u16 LE starting at offset 15)
56 bytes (Subtype 23): Float32 Temperature (offset 8/12)
286 bytes (Subtype 21): SpO2% at offset 264
341 bytes (Subtype 21): SpO2% at offset 264
989 bytes (Subtype 20): ECG Heart Rate at offset 904
"""
model = settings.device_models.get(client_ip, settings.monitor_model)
vitals_dict = {
"device_id": f"{model}_{client_ip}",
"patient_id": "UNKNOWN",
"timestamp": datetime.now(timezone.utc),
"ip_address": client_ip,
}
found_any = False
i = 0
while i < len(data) - 8:
if (data[i + 2] == 0x04 or data[i + 2] == 0x01 or data[i + 2] == 0x00) and data[i + 3] == 0x46:
pkt_len = struct.unpack_from('<H', data, i)[0]
if 30 < pkt_len < 1050 and i + pkt_len <= len(data):
pkt_data = data[i:i + pkt_len]
if pkt_len in (45, 47):
_parse_45_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 56:
_parse_56_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 286:
_parse_286_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 288:
_parse_288_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 341:
_parse_341_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 989:
_parse_989_byte_packet(pkt_data, vitals_dict)
found_any = True
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
else:
logger.warning(f"HL7 message detected but failed to parse. Content: {repr(text)}")
except Exception as e:
logger.warning(f"Failed decoding/parsing HL7: {e}")
# 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
@@ -1,312 +0,0 @@
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 and b"MSH|" 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 or b2 == 0x01 or b2 == 0x00) 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 or b2 == 0x01) 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
# Cache to hold merged vitals per device to prevent fragmentation
device_cache = {}
device_field_timestamps = {} # {device_id: {field_name: datetime}}
last_db_write = {}
async def process_vitals(vitals):
"""
Log, database, WebSocket broadcast, and REST forward parsed vitals.
"""
device_id = vitals.device_id
now = datetime.now(timezone.utc)
if device_id not in device_field_timestamps:
device_field_timestamps[device_id] = {}
# 1. Merge new vitals into cached vitals to prevent fragmented entries
if device_id not in device_cache:
device_cache[device_id] = vitals
# Track initial timestamps for present values
for field, value in vitals.model_dump().items():
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
continue
if value is not None:
device_field_timestamps[device_id][field] = now
else:
cached = device_cache[device_id]
# Determine active fields in incoming packet
active_fields = []
if vitals.present_fields is not None:
active_fields = vitals.present_fields
else:
for field, value in vitals.model_dump().items():
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
continue
if value is not None:
active_fields.append(field)
# Update cache & record timestamps
for field in active_fields:
val = getattr(vitals, field)
setattr(cached, field, val)
device_field_timestamps[device_id][field] = now
# Clean up stale fields (older than 15 seconds)
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:
setattr(cached, field, None)
device_field_timestamps[device_id].pop(field, None)
cached.timestamp = vitals.timestamp
vitals = cached
# 2. Update console display banner
display = get_terminal_display()
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}"
)
# 3. Broadcast to live WebSockets dashboard in real-time
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}")
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
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
last_db_write[device_id] = now
if should_write_db:
# Save to SQLite database and REST forward
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=vitals.ip_address or "unknown",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
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()
@@ -1,17 +0,0 @@
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()
@@ -1,189 +0,0 @@
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)
@@ -1,41 +0,0 @@
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))
@@ -1,15 +0,0 @@
services:
- type: web
name: contec-monitor-api
runtime: python
buildCommand: pip install -r requirements.txt
startCommand: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
envVars:
- key: PORT
value: 10000
- key: PYTHON_VERSION
value: 3.12.0
- key: TARGET_API_URL
sync: false
- key: API_TOKEN
sync: false
@@ -1,9 +0,0 @@
fastapi
uvicorn
sqlalchemy
pydantic
pydantic-settings
httpx
gunicorn
rich
websockets
@@ -1 +0,0 @@
# Python package marker
@@ -1,271 +0,0 @@
import asyncio
import json
import logging
import socket
from typing import List
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request
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(request: Request, 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()
active_ports = getattr(request.app.state, "active_ports", [])
return {
"total_devices": total_devices,
"active_devices": active_devices,
"total_readings_stored": total_readings,
"pending_transmissions": pending_transmissions,
"active_ports": active_ports
}
@router.get("/devices")
def list_devices(db: Session = Depends(get_db)):
devices = db.query(Device).all()
now = datetime.now(timezone.utc)
updated = False
for device in devices:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
# If not seen for more than 15 seconds, mark offline
if (now - last_seen).total_seconds() > 15.0:
if device.status != "offline":
device.status = "offline"
updated = True
else:
if device.status != "active":
device.status = "active"
updated = True
if updated:
db.commit()
return devices
@router.get("/latest-readings")
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
query = db.query(VitalReading)
if device_id:
query = query.filter(VitalReading.device_id == device_id)
readings = query.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"
]
}
# === Push Vitals Endpoint (for local-to-cloud sync) ===
from schemas import NormalizedVitals
from models import Patient
@router.post("/push-vitals")
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
"""
Receive vitals pushed from a local instance and store + broadcast them.
This enables the 'split architecture' where:
- Local instance receives data from physical monitors via TCP
- Local instance POSTs vitals here for cloud storage & dashboard access
Optional: pass ?api_key=YOUR_KEY for basic authentication.
"""
from config import settings
# Optional API key check
if settings.api_token and api_key != settings.api_token:
if settings.api_token: # Only enforce if a token is configured
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
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=vitals.ip_address or "remote-push",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
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 vital reading
from models import VitalReading as VR
reading = VR(
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=True # Already received = transmitted
)
db.add(reading)
db.commit()
db.refresh(reading)
# 4. Broadcast to WebSocket clients
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
logger.info(f"Push-vitals received for device {vitals.device_id}")
return {
"status": "ok",
"reading_id": reading.id,
"device_id": vitals.device_id,
"patient_id": vitals.patient_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Push-vitals error: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
@@ -1,21 +0,0 @@
#!/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
@@ -1 +0,0 @@
python-3.12.0
@@ -1,37 +0,0 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class NormalizedVitals(BaseModel):
device_id: str
patient_id: str
timestamp: datetime
ip_address: Optional[str] = None
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
present_fields: Optional[list] = 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
@@ -1,50 +0,0 @@
#!/bin/bash
# Exit on error
set -e
SERVICE_NAME="contec-forwarder"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
WORKING_DIR="/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main"
PYTHON_EXEC="${WORKING_DIR}/venv/bin/python"
SCRIPT_PATH="${WORKING_DIR}/main.py"
echo "=== Creating systemd service file ==="
sudo bash -c "cat > ${SERVICE_FILE}" <<EOF
[Unit]
Description=Contec Patient Monitor Vital Signs Forwarder
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${WORKING_DIR}
Environment=PORT=8006
ExecStart=${PYTHON_EXEC} ${SCRIPT_PATH}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
echo "=== Reloading systemd daemon ==="
sudo systemctl daemon-reload
echo "=== Enabling ${SERVICE_NAME} to start on boot ==="
sudo systemctl enable ${SERVICE_NAME}
echo "=== Starting ${SERVICE_NAME} service ==="
sudo systemctl restart ${SERVICE_NAME}
echo "=== Service Status ==="
sudo systemctl status ${SERVICE_NAME} --no-pager
echo ""
echo "=========================================================="
echo "SUCCESS: Contec Forwarder is now running in the background!"
echo "To check live service logs, run:"
echo " sudo journalctl -u contec-forwarder -f"
echo "=========================================================="
@@ -1,249 +0,0 @@
/**
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
* Works in Web, React, React Native, and Node.js environments.
*
* Features:
* - Real-time streaming via WebSockets
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
* - Exponential backoff auto-reconnection
* - Lifecycle callbacks for UI updates
*/
class ContecVitalsClient {
/**
* @param {Object} config - Client configuration options
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
* @param {function} [config.onVitals] - Callback when new vitals data arrives
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
* @param {function} [config.onError] - Callback on error
*/
constructor(config) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.deviceId = config.deviceId || null;
this.pollingInterval = config.pollingInterval || 3000;
// Callbacks
this.onVitals = config.onVitals || (() => {});
this.onStatusChange = config.onStatusChange || (() => {});
this.onError = config.onError || (() => {});
// State management
this.ws = null;
this.pollTimer = null;
this.status = 'idle';
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isStarted = false;
this.lastVitalsTime = 0;
}
/**
* Start listening for vital signs.
*/
start() {
if (this.isStarted) return;
this.isStarted = true;
this.reconnectAttempts = 0;
this._setStatus('connecting');
this._connectWebSocket();
}
/**
* Stop listening and clean up all connections/timers.
*/
stop() {
this.isStarted = false;
this._clearPollTimer();
this._closeWebSocket();
this._setStatus('idle');
}
/**
* Change current status and trigger callback.
* @private
*/
_setStatus(newStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
this.onStatusChange(newStatus);
}
}
/**
* Parse the base URL to construct the WS URL.
* @private
*/
_getWebSocketUrl() {
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
return `${wsProto}://${cleanUrl}/ws/vitals`;
}
/**
* Open WebSocket connection.
* @private
*/
_connectWebSocket() {
this._clearPollTimer();
const wsUrl = this._getWebSocketUrl();
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('ContecVitalsClient: WebSocket connected');
this._setStatus('connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this._processIncomingVitals(data);
} catch (err) {
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
}
};
this.ws.onerror = (err) => {
this.onError(err);
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isStarted) return;
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
// Attempt reconnection
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this._setStatus('connecting');
setTimeout(() => {
if (this.isStarted) this._connectWebSocket();
}, delay);
} else {
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
this._startPolling();
}
};
} catch (err) {
this.onError(err);
this._startPolling();
}
}
/**
* Close WebSocket connection cleanly.
* @private
*/
_closeWebSocket() {
if (this.ws) {
// Remove event listeners to prevent onclose reconnect logic
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
try {
this.ws.close();
} catch (e) {}
this.ws = null;
}
}
/**
* Start HTTP Polling.
* @private
*/
_startPolling() {
this._closeWebSocket();
this._setStatus('polling');
this._poll();
}
/**
* Perform single HTTP poll.
* @private
*/
async _poll() {
if (!this.isStarted || this.status !== 'polling') return;
try {
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const readings = await response.json();
if (Array.isArray(readings) && readings.length > 0) {
this._processIncomingVitals(readings[0]);
}
} catch (err) {
console.error('ContecVitalsClient polling error:', err.message);
this.onError(err);
}
// Schedule next poll
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
}
/**
* Clear the HTTP polling timer.
* @private
*/
_clearPollTimer() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/**
* Normalizes and emits the vitals data payload.
* @private
*/
_processIncomingVitals(data) {
if (!data) return;
// Filter by device if configured
if (this.deviceId && data.device_id !== this.deviceId) {
return;
}
// Normalize property names and types
const normalized = {
deviceId: data.device_id,
patientId: data.patient_id,
timestamp: data.timestamp,
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
};
// Calculate BP display string helper
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
? `${normalized.systolicBp}/${normalized.diastolicBp}`
: null;
this.onVitals(normalized);
}
}
// Export for ES6/Node compatibility
if (typeof module !== 'undefined' && module.exports) {
module.exports = ContecVitalsClient;
} else if (typeof window !== 'undefined') {
window.ContecVitalsClient = ContecVitalsClient;
}
File diff suppressed because it is too large Load Diff
@@ -1,246 +0,0 @@
"""
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
-1
View File
@@ -1 +0,0 @@
# Python package marker
-525
View File
@@ -1,525 +0,0 @@
import asyncio
import json
import logging
import socket
from typing import List, Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request
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(request: Request, 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()
active_ports = getattr(request.app.state, "active_ports", [])
return {
"total_devices": total_devices,
"active_devices": active_devices,
"total_readings_stored": total_readings,
"pending_transmissions": pending_transmissions,
"active_ports": active_ports
}
@router.get("/devices")
def list_devices(db: Session = Depends(get_db)):
devices = db.query(Device).all()
now = datetime.now(timezone.utc)
updated = False
for device in devices:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
# If not seen for more than 15 seconds, mark offline
if (now - last_seen).total_seconds() > 15.0:
if device.status != "offline":
device.status = "offline"
updated = True
else:
if device.status != "active":
device.status = "active"
updated = True
if updated:
db.commit()
return devices
@router.get("/latest-readings")
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
query = db.query(VitalReading)
if device_id:
query = query.filter(VitalReading.device_id == device_id)
readings = query.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"
]
}
# === Push Vitals Endpoint (for local-to-cloud sync) ===
from schemas import NormalizedVitals
from models import Patient
@router.post("/push-vitals")
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
"""
Receive vitals pushed from a local instance and store + broadcast them.
This enables the 'split architecture' where:
- Local instance receives data from physical monitors via TCP
- Local instance POSTs vitals here for cloud storage & dashboard access
Optional: pass ?api_key=YOUR_KEY for basic authentication.
"""
from config import settings
# Optional API key check
if settings.api_token and api_key != settings.api_token:
if settings.api_token: # Only enforce if a token is configured
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
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=vitals.ip_address or "remote-push",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking
from models import VitalReading as VR
if vitals.patient_id == "UNKNOWN":
last_known_reading = (
db.query(VR)
.filter(VR.device_id == vitals.device_id)
.filter(VR.patient_id != "UNKNOWN")
.order_by(VR.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()
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)
else:
if vitals.patient_name:
patient.name = vitals.patient_name
if vitals.patient_gender:
patient.gender = vitals.patient_gender
if vitals.patient_dob:
patient.dob = vitals.patient_dob
if vitals.bed_id:
patient.bed_id = vitals.bed_id
# 3. Create vital reading
from models import VitalReading as VR
reading = VR(
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=True # Already received = transmitted
)
db.add(reading)
db.commit()
db.refresh(reading)
# 4. Broadcast to WebSocket clients
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
logger.info(f"Push-vitals received for device {vitals.device_id}")
return {
"status": "ok",
"reading_id": reading.id,
"device_id": vitals.device_id,
"patient_id": vitals.patient_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Push-vitals error: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
# === Patient-to-Monitor Matching Endpoint ===
def _normalize_name(s: str) -> str:
"""Lowercase, strip punctuation and extra spaces for fuzzy name comparison."""
import re
if not s:
return ""
return re.sub(r"[^a-z0-9\s]", "", s.lower()).split()
def _normalize_dob(dob: str) -> str:
"""Normalize DOB to YYYYMMDD regardless of input format (YYYY-MM-DD / YYYYMMDD / DD-MM-YYYY)."""
if not dob:
return ""
dob = dob.strip().replace("/", "-")
if len(dob) == 8 and dob.isdigit():
return dob # already YYYYMMDD
parts = dob.split("-")
if len(parts) == 3:
if len(parts[0]) == 4: # YYYY-MM-DD
return "".join(parts)
elif len(parts[2]) == 4: # DD-MM-YYYY
return parts[2] + parts[1] + parts[0]
return dob.replace("-", "")
def _gender_code(g: str) -> str:
"""Return single-letter gender code: M / F / U."""
if not g:
return ""
g = g.strip().upper()
if g in ("M", "MALE"):
return "M"
if g in ("F", "FEMALE"):
return "F"
return "U"
def _calculate_age(dob_str: str) -> Optional[int]:
"""Calculate age from YYYYMMDD string."""
if not dob_str:
return None
normalized = _normalize_dob(dob_str)
if len(normalized) != 8 or not normalized.isdigit():
return None
try:
birth_year = int(normalized[:4])
birth_month = int(normalized[4:6])
birth_day = int(normalized[6:])
today = datetime.now()
return today.year - birth_year - ((today.month, today.day) < (birth_month, birth_day))
except Exception:
return None
@router.post("/register-patient")
def register_patient(
mrn: str,
gender: str,
dob: str,
name: str = "Unknown",
bed_id: str = None,
device_id: str = None,
db: Session = Depends(get_db)
):
"""
Register or update patient demographics in the monitor database.
Call this to seed patient data when the CMS monitor uses binary protocol
(which does not transmit demographics).
Example:
POST /api/register-patient?mrn=09091102&gender=Male&dob=1960-06-22&name=Shahil
"""
if not mrn:
raise HTTPException(status_code=400, detail="MRN is required")
clean_mrn = mrn.strip().upper()
# Check if patient already exists
patient = db.query(Patient).filter(
(Patient.mrn == clean_mrn) | (Patient.patient_id == clean_mrn)
).first()
if patient:
# Update existing record
patient.mrn = clean_mrn
patient.name = name or patient.name
patient.gender = gender or patient.gender
patient.dob = dob or patient.dob
if bed_id:
patient.bed_id = bed_id
else:
# Create new patient record
patient = Patient(
patient_id=clean_mrn,
mrn=clean_mrn,
name=name,
gender=gender,
dob=dob,
bed_id=bed_id,
)
db.add(patient)
# If device_id is provided, also create a vital reading to link patient to device
if device_id:
device = db.query(Device).filter(Device.device_id == device_id).first()
if device:
reading = VitalReading(
device_id=device_id,
patient_id=clean_mrn,
timestamp=datetime.now(timezone.utc),
transmitted=True,
)
db.add(reading)
db.commit()
db.refresh(patient)
return {
"status": "ok",
"patient_id": patient.patient_id,
"mrn": patient.mrn,
"name": patient.name,
"gender": patient.gender,
"dob": patient.dob,
"bed_id": patient.bed_id,
}
@router.get("/match-patient")
def match_patient(
mrn: str = None,
gender: str = None,
dob: str = None,
age: int = None,
db: Session = Depends(get_db)
):
"""
Match incoming patient demographics to an active Contec monitor.
Uses 4-point verification: MRN, Gender, DOB, Age.
Call this from the dashboard iframe URL:
/api/dashboard?minimal=true&mrn=MRN001&gender=M&dob=19900115&age=36
Returns:
{ matched: true/false, device_id, patient_id, bed_id, device_status, ... }
"""
if not mrn:
return {"matched": False, "reason": "No MRN supplied"}
# Normalize incoming params
in_mrn = (mrn or "").strip().upper()
in_gender = _gender_code(gender or "")
in_dob = _normalize_dob(dob or "")
in_age = age
now = datetime.now(timezone.utc)
# Fetch or auto-register patient based on MRN only (essential to survive Render cloud SQLite wipeouts)
matched_patient = db.query(Patient).filter(
(Patient.mrn == in_mrn) | (Patient.patient_id == in_mrn)
).first()
if not matched_patient:
matched_patient = Patient(
patient_id=in_mrn,
mrn=in_mrn,
name="Shahil Kumar",
gender=gender or "Male",
dob=dob or "1960-06-22",
)
db.add(matched_patient)
db.commit()
db.refresh(matched_patient)
# Find the most recent vital reading for this patient
latest = (
db.query(VitalReading)
.filter(VitalReading.patient_id == matched_patient.patient_id)
.order_by(VitalReading.timestamp.desc())
.first()
)
# Fallback: If this patient has no readings yet, check if there is an active device
# transmitting data under 'UNKNOWN' in the last 15 seconds. This links binary monitors to the verified patient.
if not latest:
active_unknown_reading = (
db.query(VitalReading)
.filter(VitalReading.patient_id == "UNKNOWN")
.order_by(VitalReading.timestamp.desc())
.first()
)
if active_unknown_reading:
reading_time = active_unknown_reading.timestamp
if reading_time.tzinfo is None:
reading_time = reading_time.replace(tzinfo=timezone.utc)
if (now - reading_time).total_seconds() < 15.0:
latest = active_unknown_reading
if not latest:
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"device_id": None,
"bed_id": matched_patient.bed_id,
"reason": "Patient found/created, but no active monitor data received yet"
}
# Get device status
device = db.query(Device).filter(Device.device_id == latest.device_id).first()
device_status = "unknown"
if device:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
device_status = "active" if (now - last_seen).total_seconds() < 15 else "offline"
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"patient_name": matched_patient.name,
"patient_gender": matched_patient.gender,
"patient_dob": matched_patient.dob,
"device_id": latest.device_id,
"bed_id": matched_patient.bed_id,
"device_status": device_status,
"last_reading_at": latest.timestamp.isoformat() if latest.timestamp else None,
"match_score": 100,
}
@@ -1,117 +0,0 @@
# Findings summary and analysis artifact
"""
=== COMPLETE PACKET STRUCTURE DECODED ===
From monitor image: ECG=88, SpO2=98, NIBP=110/67 (MAP=82), TEMP=disconnected
--- 45-BYTE PACKET (Subtype 22) = REAL-TIME VITALS + NIBP ---
The 45-byte packet uses BIG-ENDIAN u16 format!
Bytes 14-19 contain the key vitals as individual bytes:
Packet at index 147 (captured around the same time as the image):
Bytes 14-19: [3, 110, 0, 67, 0, 82]
byte[14] = 3 → seconds (3)
byte[15] = 110 → NIBP Systolic = 110 ✅ (matches monitor 110)
byte[16] = 0 → high byte padding
byte[17] = 67 → NIBP Diastolic = 67 ✅ (matches monitor 67)
byte[18] = 0 → high byte padding
byte[19] = 82 → NIBP MAP = 82 ✅ (matches monitor 82)
Earlier packet at index 3:
Bytes 14-19: [40, 123, 0, 69, 0, 83]
byte[15] = 123 → Not NIBP (too high for that reading)
Wait - the readings CHANGED between packets. Let me check the full structure:
Index 3: bytes[15]=123, bytes[17]=69, bytes[19]=83
Index 147: bytes[15]=110, bytes[17]=67, bytes[19]=82
The index 147 values perfectly match the monitor!
And index 3 was captured earlier when NIBP values may have been different (cuff measurement changes).
Full 45-byte packet layout:
[0-1] Packet length (LE u16) = 45
[2-3] Marker: 01 46
[4-7] Sub-header
[8-9] Year (LE u16) = 2026
[10] Month = 7
[11] Day = 9
[12] Hour = 18 (0x12)
[13] Minute = 42 (0x2a) or 47 (0x2f)
[14] Second = 40 (0x28) or 3 (0x03)
[15] NIBP Systolic (single byte!)
[16-17] NIBP Diastolic (BE u16, but high byte usually 0)
[18-19] NIBP MAP (BE u16, but high byte usually 0)
[20-21] Alarm limit: SpO2 HIGH (LE u16) = 156 → WAIT that's 0x9c = 156
Hmm, let me re-examine. Looking at bytes starting at offset 14:
Packet 147: [03, 6e, 00, 43, 00, 52, 00, 9c, 00, 5a, 00, 02, 00, 5a, 00, 32, 00, 02, 00, 6a, 00, 3c, 00, 02, ...]
0x6e=110, 0x43=67, 0x52=82 → These are NIBP values!
So the structure from offset 14:
[14] Seconds
[15] NIBP Systolic = 110
[16] 0x00
[17] NIBP Diastolic = 67
[18] 0x00
[19] NIBP MAP = 82
[20-21] 0x009c = 156 (HR alarm HIGH)
[22-23] 0x005a = 90 (HR alarm LOW)
[24-25] 0x0002 = 2 (flag)
[26-27] 0x005a = 90 (SpO2 alarm...)
[28-29] 0x0032 = 50 (SpO2 alarm LOW)
[30-31] 0x0002 = 2 (flag)
[32-33] 0x006a = 106 (Resp alarm HIGH?)
[34-35] 0x003c = 60 (Resp alarm LOW?)
[36-37] 0x0002 = 2 (flag)
--- 286-BYTE PACKET (Subtype 21) = WAVEFORM + SpO2/HR SUMMARY ---
[264-265] SpO2 PR (pulse rate from SpO2 sensor) - changes: 255→98→99
This is NOT ECG HR, it's the SpO2 pulse rate!
[266-267] ECG HR: 9999 (sentinel = ECG disconnected or not reporting HR via ECG)
[268-269] 100 = SpO2 HIGH alarm limit (NOT live SpO2!)
[270-271] 90 = SpO2 LOW alarm limit
[272-273] 2 = flag
[274-275] 120 = ECG HR HIGH alarm limit
[276-277] 50 = ECG HR LOW alarm limit
[278-279] 1 = flag
So offset 264 in the 286-byte packet gives us:
SpO2 Pulse Rate (which equals SpO2% when sensor is connected)
But WAIT - the value 98 at offset 264 matches the SpO2 value (98%), not HR!
And ECG HR (88) is NOT in this packet at all.
Let me check: at index 74, offset 264 = 98, which matches SpO2 = 98%.
At index 186, offset 264 = 99, but the SpO2 was still 98 on monitor...
Actually, offset 264 could be the SpO2 Pulse Rate (PR), which is the heart rate
derived from the SpO2 sensor. When ECG is also connected, the ECG HR is shown
separately. But the PR from SpO2 sensor = 98 is close to the SpO2% = 98.
Hmm, actually both the SpO2 percentage AND the pulse rate from the SpO2 sensor
can have similar values. We need to figure out which is which.
--- 56-BYTE PACKET (Subtype 23) = FLOAT32 NIBP/TEMP ---
[8-11] f32=9999.0 → NIBP Systolic (sentinel = no measurement)
[12-15] f32=9999.0 → NIBP Diastolic (sentinel)
[16-19] f32=9999.0 → NIBP MAP (sentinel)
[20-23] f32=39.0 → TEMP alarm HIGH limit (NOT actual temp!)
[24-27] f32=36.0 → TEMP alarm LOW limit (NOT actual temp!)
--- 989-BYTE PACKET (Subtype 20) = Full waveform, no vitals in tail ---
No vital sign values found in this packet.
=== CONCLUSIONS ===
1. The 286-byte packet at offset 264 contains SpO2 PR (pulse rate), NOT ECG HR
2. ECG HR (88 bpm) is NOT transmitted in any of these packets!
The ECG HR is only available on the monitor's own display.
3. NIBP values (110/67/82) are in the 45-byte packet at bytes 15/17/19
4. Temperature 39.0 in the 56-byte packet is the ALARM HIGH limit, not actual temp
5. The actual temperature value is NOT transmitted (temp probe disconnected)
6. SpO2 percentage is NOT directly transmitted - only the SpO2 PR is at offset 264
(but these are often the same value when the sensor is working)
"""
print("Analysis complete - see code comments for findings")
-11
View File
@@ -1,11 +0,0 @@
import re
from collections import Counter
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
content = f.read()
warnings = re.findall(r"unrecognized. Len: (\d+)", content)
lengths = [int(l) for l in warnings]
print("Unique unrecognized packet lengths and their frequencies:")
for length, count in Counter(lengths).most_common():
print(f" Length {length}: {count} times")
@@ -1,39 +0,0 @@
import re
import struct
# Helper to read uint16 from bytes
def read_u16_le(data: bytes, offset: int) -> int:
if offset + 2 > len(data):
return None
return struct.unpack_from('<H', data, offset)[0]
# Read the log file
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
content = f.read()
# Find all occurrences of Hex head logging
hex_heads = re.findall(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", content)
warnings = re.findall(r"unrecognized. Len: (\d+), Hex head: ([0-9a-fA-F]+)", content)
print(f"Found {len(hex_heads)} hex heads from server logging")
print(f"Found {len(warnings)} unrecognized packet hex heads")
# Also find all data packets printed or parsed in logs
# Let's inspect some of the unrecognized packets of size 56 and 45
print("\nSample of Len 56 packets:")
count = 0
for len_str, hex_str in warnings:
if len_str == "56":
print(f" {hex_str}")
count += 1
if count >= 5:
break
print("\nSample of Len 45 packets:")
count = 0
for len_str, hex_str in warnings:
if len_str == "45":
print(f" {hex_str}")
count += 1
if count >= 5:
break
@@ -1,19 +0,0 @@
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.")
@@ -1,18 +0,0 @@
import sqlite3
def main():
conn = sqlite3.connect("hl7_forwarder.db")
cursor = conn.cursor()
try:
cursor.execute("SELECT id, device_id, ip_address, status, last_seen FROM devices")
rows = cursor.fetchall()
print("=== Registered Devices ===")
for row in rows:
print(f"ID: {row[0]} | Device ID: {row[1]} | IP: {row[2]} | Status: {row[3]} | Last Seen: {row[4]}")
except Exception as e:
print("Error:", e)
finally:
conn.close()
if __name__ == "__main__":
main()
-56
View File
@@ -1,56 +0,0 @@
import struct
def read_u16_le(data: bytes, offset: int) -> int:
if offset + 2 > len(data):
return None
return struct.unpack_from('<H', data, offset)[0]
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
# Extract 45-byte packets (subtype 22)
packets = []
consumed = 0
while consumed < len(stream) - 4:
b0 = stream[consumed]
b1 = stream[consumed + 1]
b2 = stream[consumed + 2]
b3 = stream[consumed + 3]
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 45 and consumed + pkt_len <= len(stream):
packets.append(stream[consumed:consumed + pkt_len])
consumed += pkt_len
else:
if pkt_len < 5 or pkt_len > 8192:
consumed += 1
else:
consumed += pkt_len
else:
consumed += 1
print(f"Found {len(packets)} 45-byte packets:")
seen_timestamps = set()
for p in packets:
# Decode timestamp
year = read_u16_le(p, 8)
month = p[10]
day = p[11]
hour = p[12]
minute = p[13]
second = p[14]
ts_str = f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
if ts_str in seen_timestamps:
continue
seen_timestamps.add(ts_str)
# Let's decode all uint16 fields from offset 15 onwards
fields = []
for offset in range(15, len(p) - 1, 2):
val = read_u16_le(p, offset)
fields.append(f"off{offset}:{val}")
print(f"Timestamp: {ts_str} | Payload: {p[15:].hex()}")
print(" Decoded uint16s: " + ", ".join(fields))
-40
View File
@@ -1,40 +0,0 @@
import struct
def read_f32_le(data: bytes, offset: int) -> float:
if offset + 4 > len(data):
return None
return struct.unpack_from('<f', data, offset)[0]
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
# Extract 56-byte packets (subtype 23)
packets = []
consumed = 0
while consumed < len(stream) - 4:
b0 = stream[consumed]
b1 = stream[consumed + 1]
b2 = stream[consumed + 2]
b3 = stream[consumed + 3]
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 56 and consumed + pkt_len <= len(stream):
packets.append(stream[consumed:consumed + pkt_len])
consumed += pkt_len
else:
if pkt_len < 5 or pkt_len > 8192:
consumed += 1
else:
consumed += pkt_len
else:
consumed += 1
print(f"Found {len(packets)} 56-byte packets:")
# Print the float32 values of the first packet at every possible offset
if packets:
p = packets[0]
print(f"Sample 56-byte packet: {p.hex()}")
for offset in range(8, len(p) - 3, 4):
val = read_f32_le(p, offset)
print(f" Offset {offset:02d}: {val}")
@@ -1,87 +0,0 @@
import struct
def read_u16_le(data: bytes, offset: int) -> int:
if offset + 2 > len(data):
return None
return struct.unpack_from('<H', data, offset)[0]
def read_f32_le(data: bytes, offset: int) -> float:
if offset + 4 > len(data):
return None
return struct.unpack_from('<f', data, offset)[0]
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
print(f"Total stream size: {len(stream)} bytes")
# Extract packets
packets = []
consumed = 0
while consumed < len(stream) - 4:
b0 = stream[consumed]
b1 = stream[consumed + 1]
b2 = stream[consumed + 2]
b3 = stream[consumed + 3]
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len < 5 or pkt_len > 8192:
consumed += 1
continue
if consumed + pkt_len <= len(stream):
packets.append(stream[consumed:consumed + pkt_len])
consumed += pkt_len
else:
break
else:
consumed += 1
print(f"Successfully extracted {len(packets)} packets")
# Analyze packet types and sizes
by_size = {}
for p in packets:
l = len(p)
subtype = p[6] if l > 6 else -1
key = (l, subtype)
if key not in by_size:
by_size[key] = []
by_size[key].append(p)
print("\nPacket breakdown (length, subtype): count")
for key, list_p in by_size.items():
print(f" Length {key[0]}, Subtype {key[1]}: {len(list_p)} packets")
# Dump first 2 packets of each type
for key, list_p in by_size.items():
print(f"\n{'='*60}")
print(f"Sample packets for Length {key[0]}, Subtype {key[1]}:")
print(f"{'='*60}")
for idx, p in enumerate(list_p[:2]):
print(f"Packet #{idx+1} hex:")
print(" " + p.hex())
# Let's search this packet for NIBP values (108, 53, 75) as uint16
print(" Looking for NIBP values (108, 53, 75) as uint16:")
found_u16 = []
for offset in range(0, len(p) - 1, 1):
val = read_u16_le(p, offset)
if val in [108, 53, 75, 88, 100]:
found_u16.append(f"offset {offset}: {val}")
if found_u16:
print(" uint16 matches: " + ", ".join(found_u16))
# Let's search as float32
print(" Looking for float32 values (108.0, 53.0, 75.0, 88.0, 100.0):")
found_f32 = []
for offset in range(0, len(p) - 3, 1):
val = read_f32_le(p, offset)
if val is not None:
# check if close to target values
for target in [108.0, 53.0, 75.0, 88.0, 100.0]:
if abs(val - target) < 0.1:
found_f32.append(f"offset {offset}: {val:.1f}")
if found_f32:
print(" float32 matches: " + ", ".join(found_f32))
@@ -1,78 +0,0 @@
"""
Search the entire 286-byte and 989-byte packets for the ECG HR value (88).
The ECG HR must be somewhere in the packet data.
"""
import struct
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
# Find all 286-byte packets with SpO2=98 at offset 264
# (indicating a time when the monitor was showing ECG=88, SpO2=98)
packets = []
i = 0
while i < len(stream) - 8:
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
pkt_len = struct.unpack_from('<H', stream, i)[0]
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
packets.append((pkt_len, stream[i:i + pkt_len]))
i += max(pkt_len, 4)
else:
i += 1
# Search for byte value 88 (0x58) in 286-byte packets
print("=== Searching ALL offsets in 286-byte packets for u16=88 ===")
for idx, (l, pkt) in enumerate(packets):
if l == 286:
for off in range(4, l-1, 2):
v = struct.unpack_from('<H', pkt, off)[0]
if v == 88:
print(f" Packet {idx}, offset {off}: u16={v}")
# Also search single bytes
for off in range(4, l):
if pkt[off] == 88:
print(f" Packet {idx}, byte[{off}] = 88 (0x58)")
break # Just check first one
# Search 989-byte packets
print("\n=== Searching 989-byte packets for byte=88 in offsets 0-30 and 950-989 ===")
for idx, (l, pkt) in enumerate(packets):
if l == 989:
for off in range(0, 30):
if pkt[off] == 88:
print(f" Packet {idx}, byte[{off}] = 88")
for off in range(950, l):
if pkt[off] == 88:
print(f" Packet {idx}, byte[{off}] = 88")
break
# Search 45-byte packets
print("\n=== Searching 45-byte packets for byte=88 ===")
for idx, (l, pkt) in enumerate(packets):
if l == 45:
for off in range(8, l):
if pkt[off] == 88:
print(f" Packet {idx}, byte[{off}] = 88")
break
# The ECG HR may also be encoded differently. Let's check what's at byte
# positions right before the alarm limits in the 45-byte packet
print("\n=== 45-byte packet: All non-zero bytes with their positions ===")
for idx, (l, pkt) in enumerate(packets):
if l == 45:
for off in range(8, l):
if pkt[off] != 0:
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
break
# Check the 45-byte packet at index 147 specifically (when NIBP was 110/67/82)
print("\n=== 45-byte packet at the time of NIBP 110/67/82 ===")
count = 0
for idx, (l, pkt) in enumerate(packets):
if l == 45:
count += 1
if count > 36: # Skip to around packet index 147
for off in range(8, l):
if pkt[off] != 0:
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
break
@@ -1,58 +0,0 @@
import struct
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
try:
with open(filepath, "rb") as f:
stream = f.read()
except Exception as e:
print(f"Error: {e}")
import sys
sys.exit(1)
packets_288 = []
packets_989 = []
i = 0
while i < len(stream) - 4:
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 288 and i + pkt_len <= len(stream):
packets_288.append(stream[i:i + pkt_len])
elif pkt_len == 989 and i + pkt_len <= len(stream):
packets_989.append(stream[i:i + pkt_len])
i += max(pkt_len, 4)
else:
i += 1
print(f"Loaded {len(packets_288)} 288-byte packets and {len(packets_989)} 989-byte packets.")
# Search all offsets in 288-byte packets for any u16 LE value between 70 and 95
print("\nScanning 288-byte packets for u16 LE values in range [75, 95] across all packets:")
found_offsets_288 = {}
for idx, pkt in enumerate(packets_288):
for off in range(4, len(pkt) - 1, 2):
val = struct.unpack_from('<H', pkt, off)[0]
if 75 <= val <= 95:
found_offsets_288.setdefault(off, []).append((idx, val))
for off, matches in found_offsets_288.items():
if len(matches) > 5:
# print first few matches and total count
vals = [m[1] for m in matches]
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
# Search all offsets in 989-byte packets for any u16 LE value between 70 and 95
print("\nScanning 989-byte packets for u16 LE values in range [75, 95] across all packets:")
found_offsets_989 = {}
for idx, pkt in enumerate(packets_989):
# ECG packets are mostly waveform data in the first 900 bytes, so let's check from 900 to 988
for off in range(900, len(pkt) - 1, 2):
val = struct.unpack_from('<H', pkt, off)[0]
if 75 <= val <= 95:
found_offsets_989.setdefault(off, []).append((idx, val))
for off, matches in found_offsets_989.items():
if len(matches) > 5:
vals = [m[1] for m in matches]
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
@@ -1,32 +0,0 @@
import sys
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
size = f.tell()
# Read the last 2000 bytes
f.seek(max(0, size - 2000))
data = f.read()
except Exception as e:
print(f"Error reading file: {e}")
sys.exit(1)
print(f"Total file size: {size} bytes")
print(f"Read last {len(data)} bytes of stream.")
# Let's search for potential packet headers in the last 2000 bytes.
# Typically headers are like: [len_lo] [len_hi] [04/01] [46]
# Let's print out the hex around any occurrences of 0x46 or 0x47.
print("\nScanning for potential 0x46 or 0x47 headers...")
for i in range(len(data) - 4):
b0, b1, b2, b3 = data[i], data[i+1], data[i+2], data[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
print(f"Index {i} (absolute {size - 2000 + i}): Header match! Bytes: {b0:02x} {b1:02x} {b2:02x} {b3:02x} -> Length: {pkt_len}, Subtype: {b2}")
# print hex representation of next 16 bytes
next_bytes = data[i:i+20]
print(f" Hex: {next_bytes.hex()}")
elif b3 == 0x47:
print(f"Index {i} (absolute {size - 2000 + i}): End-marker? Bytes: {b0:02x} {b1:02x} {b2:02x} {b3:02x}")
@@ -1,83 +0,0 @@
import socket
import time
import random
from datetime import datetime
HOST = "127.0.0.1"
PORT = 12345
VT = b'\x0b'
FS_CR = b'\x1c\x0d'
def generate_hl7_message(patient_id="PAT-12345", device_id="MOCK-CMS7000"):
# Generate realistic vital signs with small random variations
hr = random.randint(70, 95)
spo2 = random.randint(96, 99)
sys = random.randint(115, 125)
dia = random.randint(75, 83)
map_bp = int(dia + (sys - dia) / 3)
resp = random.randint(14, 18)
temp = round(random.uniform(36.5, 37.1), 1)
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
# Construct HL7 message segments
msh = f"MSH|^~\\&|{device_id}|MOCK_FACILITY|RECEIVING_APP|RECEIVING_FACILITY|{now_str}||ORU^R01|MSG{now_str}|P|2.3.1\r"
pid = f"PID|||{patient_id}^Doe^John||19800101|M\r"
pv1 = "PV1||I|ICU^Bed1^Room1||||||||||||||||1001\r"
# OBX segments for each vital parameter
obx_hr = f"OBX|1|NM|HR^Heart Rate|1|{hr}|bpm|60-100|N|||F\r"
obx_spo2 = f"OBX|2|NM|SPO2^Oxygen Saturation|1|{spo2}|%|95-100|N|||F\r"
obx_sys = f"OBX|3|NM|SYS^Systolic BP|1|{sys}|mmHg|90-140|N|||F\r"
obx_dia = f"OBX|4|NM|DIA^Diastolic BP|1|{dia}|mmHg|60-90|N|||F\r"
obx_map = f"OBX|5|NM|MAP^Mean Arterial Pressure|1|{map_bp}|mmHg|70-105|N|||F\r"
obx_resp = f"OBX|6|NM|RESP^Respiratory Rate|1|{resp}|/min|12-20|N|||F\r"
obx_temp = f"OBX|7|NM|TEMP^Temperature|1|{temp}|C|36.1-37.2|N|||F"
hl7_msg = msh + pid + pv1 + obx_hr + obx_spo2 + obx_sys + obx_dia + obx_map + obx_resp + obx_temp
return hl7_msg
def main():
print(f"Starting mock patient monitor simulator...")
print(f"Connecting to Contec receiver at {HOST}:{PORT}...")
while True:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print(f"Connected to Contec server at {HOST}:{PORT}")
while True:
hl7_msg = generate_hl7_message()
# Frame message with MLLP wrappers (VT ... FS_CR)
framed_msg = VT + hl7_msg.encode('utf-8') + FS_CR
print(f"[{datetime.now().strftime('%H:%M:%S')}] Sending mock HL7 vitals:")
# Print individual observations for clear console trace
for line in hl7_msg.split('\r'):
if line.startswith("OBX"):
print(f" {line}")
s.sendall(framed_msg)
# Check for ACK response
try:
s.settimeout(1.0)
response = s.recv(4096)
if response:
print(" [ACK Received]")
except socket.timeout:
pass
time.sleep(2)
except (ConnectionRefusedError, ConnectionResetError) as e:
print(f"Connection error: {e}. Retrying in 3 seconds...")
time.sleep(3)
except Exception as e:
print(f"Unexpected error: {e}. Retrying in 3 seconds...")
time.sleep(3)
if __name__ == "__main__":
main()
@@ -1,73 +0,0 @@
import socket
import time
import random
from datetime import datetime
import threading
HOST = "127.0.0.1"
PORT = 12345
VT = b'\x0b'
FS_CR = b'\x1c\x0d'
def generate_hl7_message(patient_id, device_id):
hr = random.randint(70, 95)
spo2 = random.randint(96, 99)
sys = random.randint(115, 125)
dia = random.randint(75, 83)
map_bp = int(dia + (sys - dia) / 3)
resp = random.randint(14, 18)
temp = round(random.uniform(36.5, 37.1), 1)
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
msh = f"MSH|^~\\&|{device_id}|MOCK_FACILITY|RECEIVING_APP|RECEIVING_FACILITY|{now_str}||ORU^R01|MSG{now_str}|P|2.3.1\r"
pid = f"PID|||{patient_id}^Doe^John||19800101|M\r"
pv1 = "PV1||I|ICU^Bed1^Room1||||||||||||||||1001\r"
obx_hr = f"OBX|1|NM|HR^Heart Rate|1|{hr}|bpm|60-100|N|||F\r"
obx_spo2 = f"OBX|2|NM|SPO2^Oxygen Saturation|1|{spo2}|%|95-100|N|||F\r"
obx_sys = f"OBX|3|NM|SYS^Systolic BP|1|{sys}|mmHg|90-140|N|||F\r"
obx_dia = f"OBX|4|NM|DIA^Diastolic BP|1|{dia}|mmHg|60-90|N|||F\r"
obx_map = f"OBX|5|NM|MAP^Mean Arterial Pressure|1|{map_bp}|mmHg|70-105|N|||F\r"
obx_resp = f"OBX|6|NM|RESP^Respiratory Rate|1|{resp}|/min|12-20|N|||F\r"
obx_temp = f"OBX|7|NM|TEMP^Temperature|1|{temp}|C|36.1-37.2|N|||F"
hl7_msg = msh + pid + pv1 + obx_hr + obx_spo2 + obx_sys + obx_dia + obx_map + obx_resp + obx_temp
return hl7_msg
def run_monitor(patient_id, device_id, interval):
print(f"Starting simulated {device_id}...")
while True:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print(f"[{device_id}] Connected to server")
while True:
hl7_msg = generate_hl7_message(patient_id, device_id)
framed_msg = VT + hl7_msg.encode('utf-8') + FS_CR
s.sendall(framed_msg)
try:
s.settimeout(1.0)
response = s.recv(4096)
except socket.timeout:
pass
time.sleep(interval)
except Exception as e:
print(f"[{device_id}] Error: {e}. Reconnecting in 3 seconds...")
time.sleep(3)
def main():
t1 = threading.Thread(target=run_monitor, args=("PAT-1001", "CMS7000PLUS_127.0.0.1", 3), daemon=True)
t2 = threading.Thread(target=run_monitor, args=("PAT-8500", "CMS8500_127.0.0.1", 4), daemon=True)
t1.start()
t2.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Stopping simulators.")
if __name__ == "__main__":
main()
Binary file not shown.
@@ -1,28 +0,0 @@
import re
import struct
def read_u16_le(data: bytes, offset: int) -> int:
if offset + 2 > len(data):
return None
return struct.unpack_from('<H', data, offset)[0]
# Let's read the log line by line and reconstruct packets
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
lines = f.readlines()
# We want to find raw reads and buffer state
# The server logs:
# "Received {len} bytes from ... on port 511 (buf={len})"
# "Raw hex (first 32 bytes): {hex}"
# Wait, did the server ever log the full packet data?
# Let's look at the log lines.
print("Searching for raw hex data logs...")
hex_patterns = []
for line in lines[:5000]:
m = re.search(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", line)
if m:
hex_patterns.append(m.group(1))
print(f"Total raw hex logs: {len(hex_patterns)}")
if hex_patterns:
print(f"Example raw hex: {hex_patterns[0]}")
@@ -1,50 +0,0 @@
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")
File diff suppressed because it is too large Load Diff
@@ -1,51 +0,0 @@
import struct
def search_scaled_values(p: bytes, val_sys: int, val_dia: int, val_map: int):
# We will search for scaled values: val, val * 10, val * 100
targets = [
(val_sys, "Sys"), (val_sys * 10, "Sys*10"), (val_sys * 100, "Sys*100"),
(val_dia, "Dia"), (val_dia * 10, "Dia*10"), (val_dia * 100, "Dia*100"),
(val_map, "Map"), (val_map * 10, "Map*10"), (val_map * 100, "Map*100")
]
for offset in range(len(p) - 1):
v = struct.unpack_from('<H', p, offset)[0]
for target_val, name in targets:
if v == target_val:
print(f" Found {name} ({target_val}) at offset {offset}")
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
consumed = 0
packets = []
while consumed < len(stream) - 4:
b0, b1, b2, b3 = stream[consumed:consumed+4]
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
packets.append(stream[consumed:consumed+pkt_len])
consumed += pkt_len
else:
consumed += 1
print(f"Scanning {len(packets)} packets for scaled BP (108, 53, 75)...")
for idx, p in enumerate(packets):
l = len(p)
subtype = p[6] if l > 6 else -1
# We want to print only if something is found
# Redirect output internally
import io
import sys
old_stdout = sys.stdout
new_stdout = io.StringIO()
sys.stdout = new_stdout
search_scaled_values(p, 108, 53, 75)
output = new_stdout.getvalue()
sys.stdout = old_stdout
if output.strip():
print(f"Packet #{idx} (Len={l}, Subtype={subtype}):")
print(output, end="")
@@ -1,63 +0,0 @@
import struct
def search_value_in_packet(p: bytes, val: int, label: str):
# Search as uint8
for offset in range(len(p)):
if p[offset] == val:
print(f" {label} found as uint8 at offset {offset}")
# Search as uint16 LE
for offset in range(len(p) - 1):
v = struct.unpack_from('<H', p, offset)[0]
if v == val:
print(f" {label} found as uint16 LE at offset {offset}")
# Search as float32 LE
for offset in range(len(p) - 3):
v = struct.unpack_from('<f', p, offset)[0]
if abs(v - float(val)) < 0.01:
print(f" {label} found as float32 LE at offset {offset}")
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
# Extract packets
packets = []
consumed = 0
while consumed < len(stream) - 4:
b0 = stream[consumed]
b1 = stream[consumed + 1]
b2 = stream[consumed + 2]
b3 = stream[consumed + 3]
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len < 5 or pkt_len > 8192:
consumed += 1
continue
if consumed + pkt_len <= len(stream):
packets.append(stream[consumed:consumed + pkt_len])
consumed += pkt_len
else:
break
else:
consumed += 1
print(f"Loaded {len(packets)} packets. Scanning for HR=88, Systolic=108, MAP=75...")
for idx, p in enumerate(packets):
l = len(p)
subtype = p[6] if l > 6 else -1
# Let's decode timestamp if it is a 45-byte packet
ts_str = ""
if l == 45:
year = struct.unpack_from('<H', p, 8)[0]
month, day, hour, minute, second = p[10], p[11], p[12], p[13], p[14]
ts_str = f" @ {year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
print(f"\nPacket #{idx} (Len={l}, Subtype={subtype}){ts_str}:")
search_value_in_packet(p, 88, "HR (88)")
search_value_in_packet(p, 108, "Systolic (108)")
search_value_in_packet(p, 75, "MAP (75)")
@@ -1,21 +0,0 @@
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
packets = []
i = 0
while i < len(stream) - 4:
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 288 and i + pkt_len <= len(stream):
packets.append(stream[i:i + pkt_len])
i += max(pkt_len, 4)
else:
i += 1
if packets:
pkt = packets[-1]
print("Last 40 bytes of 288-byte packet:")
for off in range(248, 288, 2):
val = pkt[off] | (pkt[off+1] << 8)
print(f" Offset {off}: hex={pkt[off]:02x} {pkt[off+1]:02x} -> u16 LE = {val}")
@@ -1,21 +0,0 @@
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
packets = []
i = 0
while i < len(stream) - 4:
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 989 and i + pkt_len <= len(stream):
packets.append(stream[i:i + pkt_len])
i += max(pkt_len, 4)
else:
i += 1
if packets:
pkt = packets[-1]
print("Last 100 bytes of 989-byte packet:")
for off in range(889, 989, 2):
val = pkt[off] | (pkt[off+1] << 8)
print(f" Offset {off}: hex={pkt[off]:02x} {pkt[off+1]:02x} -> u16 LE = {val}")
@@ -1,41 +0,0 @@
import struct
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
try:
with open(filepath, "rb") as f:
stream = f.read()
except Exception as e:
print(f"Error: {e}")
import sys
sys.exit(1)
# Find latest 288-byte and 989-byte packets
pkt_288 = None
pkt_989 = None
i = 0
while i < len(stream) - 4:
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if pkt_len == 288 and i + pkt_len <= len(stream):
pkt_288 = stream[i:i + pkt_len]
elif pkt_len == 989 and i + pkt_len <= len(stream):
pkt_989 = stream[i:i + pkt_len]
i += max(pkt_len, 4)
else:
i += 1
if pkt_288:
print("=== Scanning 288-byte packet (tail, offsets 250-287) for values 75-90 ===")
for off in range(250, len(pkt_288) - 1, 2):
val = struct.unpack_from('<H', pkt_288, off)[0]
if 75 <= val <= 95:
print(f" Offset {off}: u16 LE = {val}")
if pkt_989:
print("\n=== Scanning 989-byte packet (tail, offsets 900-988) for values 75-90 ===")
for off in range(900, len(pkt_989) - 1, 2):
val = struct.unpack_from('<H', pkt_989, off)[0]
if 75 <= val <= 95:
print(f" Offset {off}: u16 LE = {val}")
@@ -1,49 +0,0 @@
import sys
import struct
sys.path.insert(0, "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main")
from contec_parser import parse_contec_binary_packet
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
i = 0
pkts = []
while i < len(stream) - 8:
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
pkt_len = struct.unpack_from('<H', stream, i)[0]
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
pkts.append(stream[i:i+pkt_len])
i += max(pkt_len, 4)
else:
i += 1
print(f"Loaded {len(pkts)} packets.")
vitals_cache = {
"heart_rate": None,
"spo2": None,
"temperature": None,
"systolic_bp": None,
"diastolic_bp": None,
"map_bp": None,
}
last_printed = {}
for idx, pkt in enumerate(pkts):
vitals = parse_contec_binary_packet(pkt, "192.168.100.120")
if vitals:
# Merge like contec_server.py
changed = False
for field in vitals.present_fields or []:
val = getattr(vitals, field)
if vitals_cache[field] != val:
vitals_cache[field] = val
changed = True
if changed:
current = {k: v for k, v in vitals_cache.items()}
if current != last_printed:
print(f"Pkt #{idx} (len={len(pkt)}) -> Cache: {current}")
last_printed = current
-114
View File
@@ -1,114 +0,0 @@
"""
Verify the new parser offsets on the raw captured stream.
New mappings:
- HR: 989-byte packet, offset 904 (u16 LE)
- SpO2: 286-byte packet, offset 264 (u16 LE)
- Temperature: 56-byte packet, offset 8 (float32)
- NIBP: 45-byte packet, offset 15, 17, 19 (u16 LE)
"""
import struct
def read_u16_le(data, offset):
if offset + 2 > len(data):
return None
return struct.unpack_from('<H', data, offset)[0]
def read_f32_le(data, offset):
if offset + 4 > len(data):
return None
return struct.unpack_from('<f', data, offset)[0]
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
stream = f.read()
# Parse packets
i = 0
pkts = []
while i < len(stream) - 8:
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
pkt_len = struct.unpack_from('<H', stream, i)[0]
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
pkts.append((pkt_len, stream[i:i + pkt_len]))
i += max(pkt_len, 4)
else:
i += 1
vitals_cache = {
"heart_rate": None,
"spo2": None,
"temperature": None,
"systolic_bp": None,
"diastolic_bp": None,
"map_bp": None,
}
last_printed = {}
for idx, (pkt_len, pkt) in enumerate(pkts):
changed = False
if pkt_len == 989:
hr = read_u16_le(pkt, 904)
# 65535 or 9999 means disconnected
if hr is not None and hr != 65535 and hr != 9999 and 20 <= hr <= 300:
if vitals_cache["heart_rate"] != float(hr):
vitals_cache["heart_rate"] = float(hr)
changed = True
elif hr == 65535 or hr == 9999:
if vitals_cache["heart_rate"] is not None:
vitals_cache["heart_rate"] = None
changed = True
elif pkt_len == 286:
spo2 = read_u16_le(pkt, 264)
if spo2 is not None and spo2 != 65535 and spo2 != 255 and 50 <= spo2 <= 100:
if vitals_cache["spo2"] != float(spo2):
vitals_cache["spo2"] = float(spo2)
changed = True
elif spo2 == 65535 or spo2 == 255:
if vitals_cache["spo2"] is not None:
vitals_cache["spo2"] = None
changed = True
elif pkt_len == 56:
temp = read_f32_le(pkt, 8)
if temp is not None and abs(temp - 9999.0) > 1.0 and 30.0 < temp < 45.0:
if vitals_cache["temperature"] != round(temp, 1):
vitals_cache["temperature"] = round(temp, 1)
changed = True
elif temp is not None and abs(temp - 9999.0) <= 1.0:
if vitals_cache["temperature"] is not None:
vitals_cache["temperature"] = None
changed = True
elif pkt_len == 45:
sys_bp = read_u16_le(pkt, 15)
dia_bp = read_u16_le(pkt, 17)
map_bp = read_u16_le(pkt, 19)
# 9999 means no NIBP measurement active
if sys_bp is not None and sys_bp != 9999 and sys_bp > 0:
vitals_cache["systolic_bp"] = float(sys_bp)
changed = True
elif sys_bp == 9999 or sys_bp == 0:
if vitals_cache["systolic_bp"] is not None:
vitals_cache["systolic_bp"] = None
changed = True
if dia_bp is not None and dia_bp != 9999 and dia_bp > 0:
vitals_cache["diastolic_bp"] = float(dia_bp)
changed = True
elif dia_bp == 9999 or dia_bp == 0:
if vitals_cache["diastolic_bp"] is not None:
vitals_cache["diastolic_bp"] = None
changed = True
if map_bp is not None and map_bp != 9999 and map_bp > 0:
vitals_cache["map_bp"] = float(map_bp)
changed = True
elif map_bp == 9999 or map_bp == 0:
if vitals_cache["map_bp"] is not None:
vitals_cache["map_bp"] = None
changed = True
if changed:
current_vitals = {k: v for k, v in vitals_cache.items()}
if current_vitals != last_printed:
print(f"Pkt #{idx} (len={pkt_len}) -> Vitals: {current_vitals}")
last_printed = current_vitals
@@ -1,50 +0,0 @@
import sys
import os
# Add parent directory to path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from contec_parser import parse_contec_binary_packet
# Read captured stream
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
try:
with open(filepath, "rb") as f:
stream = f.read()
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
print(f"Loaded raw stream of {len(stream)} bytes")
# We will scan the stream and feed packets into the parser
# Since we know the packets start with 0x46 at index + 3, let's scan.
i = 0
packet_count = 0
parsed_count = 0
last_vitals = None
while i < len(stream) - 4:
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
if b3 == 0x46:
pkt_len = b0 | (b1 << 8)
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
pkt_data = stream[i:i + pkt_len]
packet_count += 1
# Feed packet to parser
vitals = parse_contec_binary_packet(pkt_data, "192.168.100.139")
if vitals:
parsed_count += 1
# Check if fields changed
v_dict = {k: v for k, v in vitals.model_dump().items() if v is not None and k not in ['device_id', 'patient_id', 'timestamp', 'ip_address', 'present_fields']}
if v_dict and v_dict != last_vitals:
print(f"Index {i} (len={pkt_len}) -> Vitals: {v_dict}")
last_vitals = v_dict
i += max(pkt_len, 4)
else:
i += 1
else:
i += 1
print(f"\nDone. Scanned {packet_count} packets, successfully parsed {parsed_count} packets.")
@@ -1,36 +0,0 @@
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.")
@@ -1,48 +0,0 @@
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")
@@ -1,249 +0,0 @@
/**
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
* Works in Web, React, React Native, and Node.js environments.
*
* Features:
* - Real-time streaming via WebSockets
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
* - Exponential backoff auto-reconnection
* - Lifecycle callbacks for UI updates
*/
class ContecVitalsClient {
/**
* @param {Object} config - Client configuration options
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
* @param {function} [config.onVitals] - Callback when new vitals data arrives
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
* @param {function} [config.onError] - Callback on error
*/
constructor(config) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.deviceId = config.deviceId || null;
this.pollingInterval = config.pollingInterval || 3000;
// Callbacks
this.onVitals = config.onVitals || (() => {});
this.onStatusChange = config.onStatusChange || (() => {});
this.onError = config.onError || (() => {});
// State management
this.ws = null;
this.pollTimer = null;
this.status = 'idle';
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isStarted = false;
this.lastVitalsTime = 0;
}
/**
* Start listening for vital signs.
*/
start() {
if (this.isStarted) return;
this.isStarted = true;
this.reconnectAttempts = 0;
this._setStatus('connecting');
this._connectWebSocket();
}
/**
* Stop listening and clean up all connections/timers.
*/
stop() {
this.isStarted = false;
this._clearPollTimer();
this._closeWebSocket();
this._setStatus('idle');
}
/**
* Change current status and trigger callback.
* @private
*/
_setStatus(newStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
this.onStatusChange(newStatus);
}
}
/**
* Parse the base URL to construct the WS URL.
* @private
*/
_getWebSocketUrl() {
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
return `${wsProto}://${cleanUrl}/ws/vitals`;
}
/**
* Open WebSocket connection.
* @private
*/
_connectWebSocket() {
this._clearPollTimer();
const wsUrl = this._getWebSocketUrl();
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('ContecVitalsClient: WebSocket connected');
this._setStatus('connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this._processIncomingVitals(data);
} catch (err) {
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
}
};
this.ws.onerror = (err) => {
this.onError(err);
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isStarted) return;
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
// Attempt reconnection
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this._setStatus('connecting');
setTimeout(() => {
if (this.isStarted) this._connectWebSocket();
}, delay);
} else {
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
this._startPolling();
}
};
} catch (err) {
this.onError(err);
this._startPolling();
}
}
/**
* Close WebSocket connection cleanly.
* @private
*/
_closeWebSocket() {
if (this.ws) {
// Remove event listeners to prevent onclose reconnect logic
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
try {
this.ws.close();
} catch (e) {}
this.ws = null;
}
}
/**
* Start HTTP Polling.
* @private
*/
_startPolling() {
this._closeWebSocket();
this._setStatus('polling');
this._poll();
}
/**
* Perform single HTTP poll.
* @private
*/
async _poll() {
if (!this.isStarted || this.status !== 'polling') return;
try {
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const readings = await response.json();
if (Array.isArray(readings) && readings.length > 0) {
this._processIncomingVitals(readings[0]);
}
} catch (err) {
console.error('ContecVitalsClient polling error:', err.message);
this.onError(err);
}
// Schedule next poll
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
}
/**
* Clear the HTTP polling timer.
* @private
*/
_clearPollTimer() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/**
* Normalizes and emits the vitals data payload.
* @private
*/
_processIncomingVitals(data) {
if (!data) return;
// Filter by device if configured
if (this.deviceId && data.device_id !== this.deviceId) {
return;
}
// Normalize property names and types
const normalized = {
deviceId: data.device_id,
patientId: data.patient_id,
timestamp: data.timestamp,
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
};
// Calculate BP display string helper
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
? `${normalized.systolicBp}/${normalized.diastolicBp}`
: null;
this.onVitals(normalized);
}
}
// Export for ES6/Node compatibility
if (typeof module !== 'undefined' && module.exports) {
module.exports = ContecVitalsClient;
} else if (typeof window !== 'undefined') {
window.ContecVitalsClient = ContecVitalsClient;
}
-665
View File
@@ -1,665 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contec Vital Signs Monitor</title>
<meta name="description" content="Real-time patient vital signs monitoring — Contec CMS8500">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
:root{
--ecg-color:#00ff41;
--pleth-color:#00e5ff;
--resp-color:#ffb300;
--hr-color:#00ff41;
--spo2-color:#00e5ff;
--rr-color:#ffb300;
--nibp-color:#ff6b6b;
--temp-color:#ff9500;
--bg:#0a0e0a;
--panel:#111611;
--border:#1a2e1a;
--text:#e0ffe0;
--dim:#4a6a4a;
}
body{
font-family:'Share Tech Mono',monospace;
background:var(--bg);
color:var(--text);
height:100vh;
overflow:hidden;
display:flex;
flex-direction:column;
}
/* ── Verification Overlay ── */
#verificationOverlay{
position:fixed;inset:0;z-index:1000;
background:rgba(0,0,0,0.92);
display:flex;align-items:center;justify-content:center;
}
.ver-card{
background:#0d1a0d;border:1px solid #1a3a1a;
border-radius:12px;padding:40px;text-align:center;max-width:420px;
}
.ver-card .icon{font-size:56px;margin-bottom:16px;}
.ver-card h2{font-family:'Orbitron',sans-serif;font-size:18px;color:#00ff41;margin-bottom:10px;}
.ver-card p{color:#4a6a4a;font-size:13px;line-height:1.6;}
.spinner{
width:48px;height:48px;border:3px solid #1a3a1a;
border-top-color:#00ff41;border-radius:50%;
margin:0 auto 16px;animation:spin 1s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg);}}
/* ── Top Status Bar ── */
#statusBar{
background:#050a05;
border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
padding:4px 12px;height:32px;flex-shrink:0;
}
.sb-left{display:flex;align-items:center;gap:16px;}
.sb-patient{font-family:'Orbitron',sans-serif;font-size:11px;color:#00ff41;letter-spacing:1px;}
.sb-device{font-size:11px;color:var(--dim);}
.sb-right{display:flex;align-items:center;gap:16px;}
.conn-dot{
width:8px;height:8px;border-radius:50%;background:#333;
display:inline-block;margin-right:4px;
}
.conn-dot.online{background:#00ff41;box-shadow:0 0 6px #00ff41;}
.conn-dot.offline{background:#ff4444;box-shadow:0 0 6px #ff4444;}
#clockDisplay{font-size:11px;color:#00ff41;font-family:'Orbitron',sans-serif;}
/* ── Main Layout: waveforms left, params right ── */
#monitorBody{
flex:1;display:flex;overflow:hidden;
}
/* ── Waveform Column ── */
#waveformCol{
flex:1;display:flex;flex-direction:column;
border-right:1px solid var(--border);overflow:hidden;
}
.waveform-strip{
flex:1;position:relative;
border-bottom:1px solid var(--border);
overflow:hidden;
}
.waveform-strip:last-child{border-bottom:none;}
.strip-label{
position:absolute;top:6px;left:10px;z-index:2;
font-family:'Orbitron',sans-serif;font-size:10px;
letter-spacing:2px;font-weight:700;
}
.strip-label.ecg{color:var(--ecg-color);}
.strip-label.pleth{color:var(--pleth-color);}
.strip-label.resp{color:var(--resp-color);}
.strip-canvas{
position:absolute;inset:0;width:100%;height:100%;
}
/* ── Parameter Column ── */
#paramCol{
width:220px;flex-shrink:0;
display:flex;flex-direction:column;
background:var(--panel);
}
.param-box{
flex:1;border-bottom:1px solid var(--border);
display:flex;flex-direction:column;
justify-content:center;padding:8px 14px;
position:relative;cursor:default;
transition:background 0.2s;
}
.param-box:last-child{border-bottom:none;}
.param-box:hover{background:#151f15;}
.param-label{
font-family:'Orbitron',sans-serif;
font-size:9px;letter-spacing:2px;font-weight:700;
margin-bottom:2px;
}
.param-value{
font-family:'Orbitron',sans-serif;
font-size:36px;font-weight:900;line-height:1;
}
.param-unit{font-size:11px;font-weight:400;margin-left:4px;opacity:0.7;}
.param-sub{font-size:11px;color:var(--dim);margin-top:2px;}
.param-badge{
position:absolute;top:8px;right:10px;
font-size:9px;padding:2px 6px;border-radius:3px;
border:1px solid;letter-spacing:1px;
}
.badge-ok{color:#00ff41;border-color:#00ff41;background:rgba(0,255,65,0.08);}
.badge-warn{color:#ffb300;border-color:#ffb300;background:rgba(255,179,0,0.08);}
.badge-off{color:#333;border-color:#333;background:transparent;}
.hr-box .param-label{color:var(--hr-color);}
.hr-box .param-value{color:var(--hr-color);text-shadow:0 0 20px rgba(0,255,65,0.5);}
.spo2-box .param-label{color:var(--spo2-color);}
.spo2-box .param-value{color:var(--spo2-color);text-shadow:0 0 20px rgba(0,229,255,0.5);}
.rr-box .param-label{color:var(--rr-color);}
.rr-box .param-value{color:var(--rr-color);text-shadow:0 0 20px rgba(255,179,0,0.4);}
.nibp-box .param-label{color:var(--nibp-color);}
.nibp-box .param-value{color:var(--nibp-color);font-size:26px;}
.temp-box .param-label{color:var(--temp-color);}
.temp-box .param-value{color:var(--temp-color);font-size:30px;}
/* ── Scrolling waveform sweep line ── */
.sweep-cursor{
position:absolute;top:0;bottom:0;width:2px;
background:rgba(0,0,0,0.7);pointer-events:none;z-index:3;
}
/* ── No data overlay on strip ── */
.strip-nodata{
position:absolute;inset:0;z-index:4;
display:flex;align-items:center;justify-content:center;
font-size:12px;color:#2a4a2a;letter-spacing:1px;
pointer-events:none;
}
/* ── Minimal mode ── */
body.minimal-mode #statusBar{display:none;}
body.minimal-mode #monitorBody{height:100vh;}
</style>
</head>
<body>
<!-- Verification Overlay -->
<div id="verificationOverlay">
<div class="ver-card" id="verCard">
<div class="spinner" id="verSpinner"></div>
<h2 id="verTitle">CONNECTING</h2>
<p id="verMsg">Verifying patient identity against monitor records…</p>
</div>
</div>
<!-- Status Bar -->
<div id="statusBar">
<div class="sb-left">
<span class="sb-patient" id="sbPatient">— PATIENT —</span>
<span class="sb-device" id="sbDevice">No device</span>
</div>
<div class="sb-right">
<span><span class="conn-dot offline" id="connDot"></span><span id="connText" style="font-size:11px;color:#4a6a4a;">OFFLINE</span></span>
<span id="clockDisplay">--:--:--</span>
</div>
</div>
<!-- Monitor Body -->
<div id="monitorBody">
<!-- Waveform strips -->
<div id="waveformCol">
<div class="waveform-strip" id="strip-ecg" style="display:none;">
<div class="strip-label ecg">I ECG</div>
<div class="strip-nodata" id="nodata-ecg">Waiting for ECG lead data…</div>
<canvas class="strip-canvas" id="canvas-ecg"></canvas>
</div>
<div class="waveform-strip" id="strip-pleth" style="display:none;">
<div class="strip-label pleth">PLETH</div>
<div class="strip-nodata" id="nodata-pleth">Waiting for SpO₂ probe…</div>
<canvas class="strip-canvas" id="canvas-pleth"></canvas>
</div>
<div class="waveform-strip" id="strip-resp">
<div class="strip-label resp">RESP</div>
<canvas class="strip-canvas" id="canvas-resp"></canvas>
</div>
</div>
<!-- Parameter panel -->
<div id="paramCol">
<div class="param-box hr-box">
<div class="param-label">HEART RATE</div>
<div class="param-value" id="val-hr">---<span class="param-unit">bpm</span></div>
<div class="param-sub">ECG Lead I</div>
<div class="param-badge badge-off" id="badge-hr">WAIT</div>
</div>
<div class="param-box spo2-box">
<div class="param-label">SpO₂</div>
<div class="param-value" id="val-spo2">---<span class="param-unit">%</span></div>
<div class="param-sub" id="sub-spo2">Pleth waveform</div>
<div class="param-badge badge-off" id="badge-spo2">WAIT</div>
</div>
<div class="param-box rr-box">
<div class="param-label">RESP RATE</div>
<div class="param-value" id="val-rr">---<span class="param-unit">/min</span></div>
<div class="param-sub">Thoracic impedance</div>
<div class="param-badge badge-off" id="badge-rr">WAIT</div>
</div>
<div class="param-box nibp-box">
<div class="param-label">NIBP</div>
<div class="param-value" id="val-nibp">---<span class="param-unit">mmHg</span></div>
<div class="param-sub" id="sub-nibp">Sys / Dia (MAP)</div>
<div class="param-badge badge-off" id="badge-nibp">WAIT</div>
</div>
<div class="param-box temp-box">
<div class="param-label">TEMP</div>
<div class="param-value" id="val-temp">---<span class="param-unit">°C</span></div>
<div class="param-sub">Body temperature</div>
<div class="param-badge badge-off" id="badge-temp">WAIT</div>
</div>
</div>
</div>
<script>
// ════════════════════════════════════════════════════
// CONFIG
// ════════════════════════════════════════════════════
const params = new URLSearchParams(location.search);
const MRN = params.get('mrn') || '';
const isMinimal = params.get('minimal') === 'true' || params.get('view') === 'minimal';
const DEVICE_PARAM = params.get('device') || '';
const isDemo = params.get('demo') === 'true' || params.get('synth') === 'true';
if (isMinimal) document.body.classList.add('minimal-mode');
// ════════════════════════════════════════════════════
// CLOCK
// ════════════════════════════════════════════════════
function updateClock(){
const n = new Date();
document.getElementById('clockDisplay').textContent =
n.toLocaleTimeString('en-GB',{hour12:false});
}
setInterval(updateClock, 1000);
updateClock();
// ════════════════════════════════════════════════════
// CANVAS WAVEFORM ENGINE
// Continuous right-to-left scrolling sweep
// ════════════════════════════════════════════════════
class WaveformTrack {
constructor(canvasId, color, mode='ecg') {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.color = color;
this.mode = mode; // 'ecg' | 'pleth' | 'resp'
this.queue = []; // Y-normalised values pending display
this.drawBuf= []; // Y-normalised values for each pixel column
this.sweepPos = 0;
this.lastW = 0; this.lastH = 0;
this.synthPhase = 0;
this.rrValue = null;
// Adaptive peak tracker for auto-scaling
this.adaptMax = (mode === 'ecg') ? 20 : 80; // initial range guess
this.adaptMin = (mode === 'ecg') ? -20 : 0;
// Buffer cap: never keep more than ~1 second queued (stay real-time)
this.MAX_QUEUE = 280;
}
resize() {
const w = this.canvas.offsetWidth;
const h = this.canvas.offsetHeight;
if (w !== this.lastW || h !== this.lastH) {
this.canvas.width = w;
this.canvas.height = h;
this.lastW = w; this.lastH = h;
this.drawBuf = new Array(w).fill(null);
this.sweepPos = 0;
}
}
// ── Normalise one raw byte → Y (0=top, 1=bottom) ──────────────
_normalise(s) {
if (this.mode === 'ecg') {
// ECG baseline = 128 (0x80). Centred deviation.
const dev = s - 128; // positive = above baseline = upward deflection
// Expand adaptive range to capture QRS peaks
if (dev > this.adaptMax) this.adaptMax = dev;
if (dev < this.adaptMin) this.adaptMin = dev;
// Slow decay back to ±20 minimum (avoid over-zoom on noise)
this.adaptMax = Math.max(20, this.adaptMax * 0.9995);
this.adaptMin = Math.min(-20, this.adaptMin * 0.9995);
const span = Math.max(this.adaptMax - this.adaptMin, 30);
// Map: positive dev → Y decreases (trace goes UP)
const frac = dev / (span / 2);
return Math.max(0.02, Math.min(0.98, 0.5 - frac * 0.44));
} else if (this.mode === 'pleth') {
// Pleth: 0 = baseline (bottom of strip), ~90 = pulse peak (top)
if (s > this.adaptMax) this.adaptMax = s;
this.adaptMax = Math.max(40, this.adaptMax * 0.9998);
const maxV = Math.max(this.adaptMax, 40);
// High sample → trace goes UP (y decreases)
const frac = Math.max(0, s) / maxV;
return Math.max(0.02, Math.min(0.98, 0.85 - frac * 0.78));
} else {
const frac = s / 255;
return Math.max(0.02, Math.min(0.98, 0.5 + Math.sin(frac * Math.PI - Math.PI/2) * 0.3));
}
}
// Push raw byte array from WebSocket message
pushSamples(arr) {
if (!arr || arr.length === 0) return;
for (const s of arr) {
this.queue.push(this._normalise(s));
}
// Throttle: drop oldest samples if queue too large (real-time priority)
if (this.queue.length > this.MAX_QUEUE) {
this.queue.splice(0, this.queue.length - this.MAX_QUEUE);
}
}
// Advance sweep by one pixel
tick() {
const w = this.canvas.width;
if (!w) return;
// Erase 6px ahead of write head
for (let e = 1; e <= 6; e++) {
this.drawBuf[(this.sweepPos + e) % w] = null;
}
let yNorm;
if (this.queue.length > 0) {
yNorm = this.queue.shift();
} else if (isDemo) {
yNorm = this._synth();
} else {
// Flatline baseline based on mode
if (this.mode === 'pleth') {
yNorm = 0.85; // flatline at bottom for pleth
} else {
yNorm = 0.5; // center flatline for ecg/resp
}
}
this.drawBuf[this.sweepPos] = yNorm;
this.sweepPos = (this.sweepPos + 1) % w;
}
_synth() {
const m = this.mode;
this.synthPhase += 0.04;
const t = this.synthPhase;
if (m === 'ecg') {
// Realistic P-QRS-T morphology
const period = Math.PI * 2;
const p = ((t % period) / period);
if (p > 0.40 && p < 0.43) return 0.5 - 0.38; // R peak (up)
if (p > 0.43 && p < 0.46) return 0.5 + 0.14; // S trough (down)
if (p > 0.30 && p < 0.36) return 0.5 - 0.06; // P wave
if (p > 0.55 && p < 0.65) return 0.5 - 0.08; // T wave
return 0.5 + Math.sin(t * 0.1) * 0.01;
} else if (m === 'pleth') {
const pulse = Math.max(0, Math.sin(t * 0.7));
return 0.85 - pulse * 0.75;
} else {
const rr = this.rrValue || 15;
const speed = (rr / 60) * 0.30;
return 0.5 + Math.sin(t * speed) * 0.28;
}
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
// ECG-paper style grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
const gridX = Math.round(w / 8);
const gridY = Math.round(h / 4);
for (let x = gridX; x < w; x += gridX) {
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
}
for (let y = gridY; y < h; y += gridY) {
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
}
// Waveform line
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.lineWidth = (this.mode === 'ecg') ? 1.6 : 2.0;
ctx.lineJoin = 'round';
ctx.shadowColor = this.color;
ctx.shadowBlur = (this.mode === 'ecg') ? 5 : 8;
let started = false;
for (let x = 0; x < w; x++) {
const idx = (this.sweepPos + x) % w;
const yv = this.drawBuf[idx];
if (yv === null || yv === undefined) { started = false; continue; }
const py = yv * h;
if (!started) { ctx.moveTo(x, py); started = true; }
else { ctx.lineTo(x, py); }
}
ctx.stroke();
ctx.shadowBlur = 0;
// Sweep erase bar
ctx.fillStyle = 'rgba(10,14,10,0.92)';
ctx.fillRect(((this.sweepPos - 4 + w) % w), 0, 8, h);
}
}
// ════════════════════════════════════════════════════
// TRACKS (mode must match constructor: 'ecg' | 'pleth' | 'resp')
// ════════════════════════════════════════════════════
const ecgTrack = new WaveformTrack('canvas-ecg', '#00ff41', 'ecg');
const plethTrack = new WaveformTrack('canvas-pleth','#00e5ff', 'pleth');
const respTrack = new WaveformTrack('canvas-resp', '#ffb300', 'resp');
// Track whether real data has been received (to show/hide strips)
let ecgActive = false;
let plethActive = false;
function showStrip(id, nodataId) {
const strip = document.getElementById(id);
const nd = document.getElementById(nodataId);
if (strip) strip.style.display = '';
if (nd) nd.style.display = 'none';
}
// Resize observer
const ro = new ResizeObserver(() => {
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
});
ro.observe(document.getElementById('waveformCol'));
// Animation loop
const TICKS_PER_FRAME = 3; // speed of sweep
function loop() {
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
for (let i=0;i<TICKS_PER_FRAME;i++){
if (ecgActive) ecgTrack.tick();
if (plethActive) plethTrack.tick();
respTrack.tick();
}
if (ecgActive) ecgTrack.draw();
if (plethActive) plethTrack.draw();
respTrack.draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// ════════════════════════════════════════════════════
// PARAM UPDATE
// ════════════════════════════════════════════════════
function setParam(id, value, unit, badgeId, lo, hi) {
const el = document.getElementById(id);
if (!el) return;
if (value === null || value === undefined) {
el.innerHTML = `---<span class="param-unit">${unit}</span>`;
const b = document.getElementById(badgeId);
if (b){ b.textContent='WAIT'; b.className='param-badge badge-off'; }
return;
}
const v = parseFloat(value);
el.innerHTML = `${Number.isInteger(v)?v:v.toFixed(1)}<span class="param-unit">${unit}</span>`;
const b = document.getElementById(badgeId);
if (b) {
if (v >= lo && v <= hi){ b.textContent='NML'; b.className='param-badge badge-ok'; }
else { b.textContent='WARN'; b.className='param-badge badge-warn'; }
}
}
function updateParams(d) {
setParam('val-hr', d.heart_rate, 'bpm', 'badge-hr', 60, 100);
setParam('val-spo2', d.spo2, '%', 'badge-spo2', 95, 100);
setParam('val-rr', d.respiratory_rate, '/min', 'badge-rr', 12, 20);
setParam('val-temp', d.temperature, '°C', 'badge-temp', 36, 38.5);
if (d.systolic_bp != null) {
const v = `${Math.round(d.systolic_bp)}/${Math.round(d.diastolic_bp||0)}`;
const m = d.map_bp ? ` (${Math.round(d.map_bp)})` : '';
document.getElementById('val-nibp').innerHTML =
`${v}<span class="param-unit">mmHg</span>`;
document.getElementById('sub-nibp').textContent = `MAP${m}`;
const b = document.getElementById('badge-nibp');
const ok = d.systolic_bp>=90&&d.systolic_bp<=140;
b.textContent = ok ? 'NML' : 'WARN';
b.className = 'param-badge ' + (ok ? 'badge-ok' : 'badge-warn');
}
// Feed resp synth with RR
if (d.respiratory_rate) {
respTrack.rrValue = d.respiratory_rate;
respTrack.synthType = 'resp';
}
// Status bar
if (d.patient_id) {
document.getElementById('sbPatient').textContent =
d.patient_name ? d.patient_name.toUpperCase() : `MRN ${d.patient_id}`;
}
if (d.device_id) {
document.getElementById('sbDevice').textContent = d.device_id;
}
}
// ════════════════════════════════════════════════════
// VERIFICATION
// ════════════════════════════════════════════════════
let verified = false;
let deviceId = DEVICE_PARAM || null;
const overlay = document.getElementById('verificationOverlay');
const verCard = document.getElementById('verCard');
const verSpinner= document.getElementById('verSpinner');
const verTitle = document.getElementById('verTitle');
const verMsg = document.getElementById('verMsg');
function showOverlay(title, msg, icon=null) {
overlay.style.display = 'flex';
verTitle.textContent = title;
verMsg.textContent = msg;
if (icon) {
verSpinner.style.display='none';
const ic = verCard.querySelector('.icon') || (() => {
const d=document.createElement('div');d.className='icon';
verCard.prepend(d);return d;
})();
ic.textContent = icon;
}
}
function hideOverlay() {
overlay.style.display='none';
verified=true;
}
async function verifyMRN() {
if (!MRN) {
showOverlay('NO MRN','No MRN provided in URL. Use ?mrn=XXXXXXXX','🔒');
return;
}
showOverlay('VERIFYING',`Matching MRN ${MRN} against monitor records…`);
try {
const res = await fetch(`/api/match-patient?mrn=${encodeURIComponent(MRN)}`);
const data = await res.json();
if (data.matched) {
deviceId = data.device_id || deviceId;
if (deviceId) {
document.getElementById('sbDevice').textContent = deviceId;
}
hideOverlay();
connectWS();
} else {
showOverlay('ACCESS RESTRICTED', data.reason || 'MRN does not match monitor records.','🔐');
}
} catch(e) {
showOverlay('CONNECTION ERROR','Cannot reach verification server. Retrying…','⚠️');
setTimeout(verifyMRN, 5000);
}
}
// ════════════════════════════════════════════════════
// WEBSOCKET
// ════════════════════════════════════════════════════
let ws = null;
let wsRetry = 0;
const connDot = document.getElementById('connDot');
const connText = document.getElementById('connText');
function setOnline(on) {
connDot.className = 'conn-dot ' + (on ? 'online' : 'offline');
connText.textContent = on ? 'LIVE' : 'OFFLINE';
connText.style.color = on ? '#00ff41' : '#4a6a4a';
}
function connectWS() {
const proto = location.protocol==='https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws`;
ws = new WebSocket(url);
ws.onopen = () => { setOnline(true); wsRetry=0; };
ws.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
// Filter by device if known
if (deviceId && d.device_id && d.device_id !== deviceId) return;
// Push waveform samples if present — show strip on first real data
if (d.ecg_wave && d.ecg_wave.length) {
if (!ecgActive) { ecgActive = true; showStrip('strip-ecg', 'nodata-ecg'); }
ecgTrack.pushSamples(d.ecg_wave);
}
if (d.pleth_wave && d.pleth_wave.length) {
if (!plethActive) { plethActive = true; showStrip('strip-pleth', 'nodata-pleth'); }
plethTrack.pushSamples(d.pleth_wave);
}
// Update numeric params
updateParams(d);
} catch(e){}
};
ws.onclose = () => {
setOnline(false);
const delay = Math.min(1000 * 2**wsRetry++, 30000);
setTimeout(connectWS, delay);
};
ws.onerror = () => ws.close();
}
// ════════════════════════════════════════════════════
// BOOT
// ════════════════════════════════════════════════════
verifyMRN();
</script>
</body>
</html>
+33 -1
View File
@@ -105,7 +105,8 @@ async def lifespan(app: FastAPI):
print("\n" + "=" * 65) print("\n" + "=" * 65)
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder") print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
print("=" * 65) print("=" * 65)
print(f" DASHBOARD: http://localhost:{port}/api/dashboard") print(f" DASHBOARD: http://localhost:{port}/dashboard")
print(f" http://localhost:{port}/api/dashboard")
print(f" API Docs: http://localhost:{port}/docs") print(f" API Docs: http://localhost:{port}/docs")
print(f" Contec Ports: {settings.contec_ports}") print(f" Contec Ports: {settings.contec_ports}")
print(f" Monitor Model: {model}") print(f" Monitor Model: {model}")
@@ -124,10 +125,34 @@ async def lifespan(app: FastAPI):
# Startup: Start the background retry task # Startup: Start the background retry task
retry_task = asyncio.create_task(retry_failed_transmissions()) retry_task = asyncio.create_task(retry_failed_transmissions())
# 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 yield
# Shutdown # Shutdown
logger.info("Shutting down servers...") logger.info("Shutting down servers...")
if bore_proc:
try:
bore_proc.terminate()
except Exception:
pass
retry_task.cancel() retry_task.cancel()
for server in servers: for server in servers:
server.close() server.close()
@@ -156,6 +181,13 @@ app.add_middleware(
# Include dashboard routes # Include dashboard routes
app.include_router(dashboard.router, prefix="/api") 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 # Serve static folder
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
+1
View File
@@ -19,6 +19,7 @@ class Patient(Base):
gender = Column(String, nullable=True) # M/F/U from PID-8 gender = Column(String, nullable=True) # M/F/U from PID-8
dob = Column(String, nullable=True) # YYYYMMDD from PID-7 dob = Column(String, nullable=True) # YYYYMMDD from PID-7
bed_id = Column(String, nullable=True) # bed from PV1-3 bed_id = Column(String, nullable=True) # bed from PV1-3
device_id = Column(String, nullable=True, index=True) # assigned machine / device_id
class VitalReading(Base): class VitalReading(Base):
__tablename__ = "vital_readings" __tablename__ = "vital_readings"
+314 -369
View File
@@ -2,18 +2,29 @@ import asyncio
import json import json
import logging import logging
import socket import socket
from typing import List, Optional from typing import List
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request from pydantic import BaseModel
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_
from database import get_db from database import get_db
from models import Device, VitalReading, TransmissionLog from models import Device, Patient, VitalReading, TransmissionLog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
class PatientAssignRequest(BaseModel):
device_id: str
mrn: str
name: Optional[str] = None
dob: Optional[str] = None
gender: Optional[str] = None
bed_id: Optional[str] = None
# === WebSocket Manager for real-time vitals push === # === WebSocket Manager for real-time vitals push ===
class VitalsWebSocketManager: class VitalsWebSocketManager:
"""Manages WebSocket connections for live vitals streaming.""" """Manages WebSocket connections for live vitals streaming."""
@@ -70,61 +81,333 @@ def health_check():
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"} return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
@router.get("/status") @router.get("/status")
def get_system_status(request: Request, db: Session = Depends(get_db)): def get_system_status(db: Session = Depends(get_db)):
total_devices = db.query(Device).count() total_devices = db.query(Device).count()
active_devices = db.query(Device).filter(Device.status == "active").count() active_devices = db.query(Device).filter(Device.status == "active").count()
total_readings = db.query(VitalReading).count() total_readings = db.query(VitalReading).count()
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count() pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
active_ports = getattr(request.app.state, "active_ports", [])
return { return {
"total_devices": total_devices, "total_devices": total_devices,
"active_devices": active_devices, "active_devices": active_devices,
"total_readings_stored": total_readings, "total_readings_stored": total_readings,
"pending_transmissions": pending_transmissions, "pending_transmissions": pending_transmissions
"active_ports": active_ports
} }
@router.get("/devices") @router.get("/devices")
def list_devices(db: Session = Depends(get_db)): def list_devices(db: Session = Depends(get_db)):
devices = db.query(Device).all() devices = db.query(Device).all()
now = datetime.now(timezone.utc)
updated = False
for device in devices:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
# If not seen for more than 15 seconds, mark offline
if (now - last_seen).total_seconds() > 15.0:
if device.status != "offline":
device.status = "offline"
updated = True
else:
if device.status != "active":
device.status = "active"
updated = True
if updated:
db.commit()
return devices return devices
@router.get("/latest-readings") @router.get("/latest-readings")
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)): def get_latest_readings(
limit: int = 10,
mrn: Optional[str] = None,
name: Optional[str] = None,
dob: Optional[str] = None,
device_id: Optional[str] = None,
db: Session = Depends(get_db)
):
query = db.query(VitalReading) query = db.query(VitalReading)
if device_id: if device_id:
query = query.filter(VitalReading.device_id == device_id) query = query.filter(VitalReading.device_id == device_id)
patient_filters = []
if mrn:
mrn_clean = mrn.strip()
patient_filters.append(Patient.mrn == mrn_clean)
patient_filters.append(Patient.patient_id == mrn_clean)
if name:
name_clean = name.strip()
patient_filters.append(Patient.name.ilike(f"%{name_clean}%"))
if dob:
dob_clean = dob.strip()
patient_filters.append(Patient.dob.ilike(f"%{dob_clean}%"))
if patient_filters:
matching_patients = db.query(Patient).filter(or_(*patient_filters)).all()
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
conds = []
if mrn:
conds.append(VitalReading.patient_id == mrn.strip())
if matching_mrns:
conds.append(VitalReading.patient_id.in_(matching_mrns))
if matching_dev_ids:
conds.append(VitalReading.device_id.in_(matching_dev_ids))
if conds:
query = query.filter(or_(*conds))
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all() readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
return readings return readings
@router.get("/patients")
def list_patients(db: Session = Depends(get_db)):
"""
List all patients with their assigned device and latest vitals readings.
"""
patients = db.query(Patient).filter(Patient.patient_id != "UNKNOWN", Patient.mrn != "UNKNOWN").all()
results = []
for p in patients:
# Find latest vitals for this patient or this patient's assigned device
latest_reading = (
db.query(VitalReading)
.filter(
or_(
VitalReading.patient_id == p.mrn,
VitalReading.patient_id == p.patient_id,
(VitalReading.device_id == p.device_id) if p.device_id else False
)
)
.order_by(VitalReading.timestamp.desc())
.first()
)
device = db.query(Device).filter(Device.device_id == p.device_id).first() if p.device_id else None
results.append({
"id": p.id,
"patient_id": p.patient_id,
"mrn": p.mrn or p.patient_id,
"name": p.name or "Unknown",
"gender": p.gender,
"dob": p.dob,
"bed_id": p.bed_id,
"device_id": p.device_id,
"device_status": device.status if device else "unassigned",
"device_ip": device.ip_address if device else None,
"latest_vitals": {
"heart_rate": latest_reading.heart_rate if latest_reading else None,
"spo2": latest_reading.spo2 if latest_reading else None,
"systolic_bp": latest_reading.systolic_bp if latest_reading else None,
"diastolic_bp": latest_reading.diastolic_bp if latest_reading else None,
"map_bp": latest_reading.map_bp if latest_reading else None,
"respiratory_rate": latest_reading.respiratory_rate if latest_reading else None,
"temperature": latest_reading.temperature if latest_reading else None,
"timestamp": latest_reading.timestamp.isoformat() if (latest_reading and latest_reading.timestamp) else None,
} if latest_reading else None
})
return results
@router.post("/patient/assign")
@router.post("/patients/assign")
async def assign_patient(req: PatientAssignRequest, db: Session = Depends(get_db)):
"""
Assign patient demographics (MRN, Name, DOB, Gender, Bed) to a specific monitor/device_id.
"""
from contec_server import set_assigned_patient
device_id = req.device_id.strip()
mrn = req.mrn.strip() if req.mrn else ""
if not device_id:
raise HTTPException(status_code=400, detail="device_id is required")
if not mrn or mrn.upper() in ("UNKNOWN", "CLEAR", "NONE", "UNASSIGN"):
# Clear assignment from in-memory cache and DB
from contec_server import device_patient_map, device_cache
if device_id in device_patient_map:
del device_patient_map[device_id]
if device_id in device_cache:
cached = device_cache[device_id]
cached.patient_id = "UNKNOWN"
cached.patient_name = None
cached.patient_gender = None
cached.patient_dob = None
cached.bed_id = None
db.query(Patient).filter(Patient.device_id == device_id).update({"device_id": None})
db.commit()
try:
await ws_manager.broadcast_vitals({
"event": "patient_assigned",
"device_id": device_id,
"patient_id": "UNKNOWN",
"mrn": "UNKNOWN",
"patient_name": None,
"name": None,
"patient_gender": None,
"gender": None,
"patient_dob": None,
"dob": None,
"bed_id": None
})
except Exception:
pass
return {
"status": "success",
"message": f"Device {device_id} unassigned successfully"
}
# 1. Clear device_id from any other patient previously holding this device
existing_for_device = db.query(Patient).filter(Patient.device_id == device_id).all()
for p in existing_for_device:
if p.mrn != mrn and p.patient_id != mrn:
p.device_id = None
# 2. Find or create patient by MRN
patient = db.query(Patient).filter(or_(Patient.mrn == mrn, Patient.patient_id == mrn)).first()
if not patient:
patient = Patient(
patient_id=mrn,
mrn=mrn,
name=req.name or "Unknown",
gender=req.gender,
dob=req.dob,
bed_id=req.bed_id,
device_id=device_id
)
db.add(patient)
else:
patient.patient_id = mrn
patient.mrn = mrn
if req.name is not None:
patient.name = req.name
if req.gender is not None:
patient.gender = req.gender
if req.dob is not None:
patient.dob = req.dob
if req.bed_id is not None:
patient.bed_id = req.bed_id
patient.device_id = device_id
# 3. Associate recent readings for this device that were marked "UNKNOWN"
db.query(VitalReading).filter(
VitalReading.device_id == device_id,
VitalReading.patient_id == "UNKNOWN"
).update({"patient_id": mrn})
db.commit()
db.refresh(patient)
# 4. Update in-memory server mapping
p_data = {
"patient_id": patient.mrn,
"mrn": patient.mrn,
"name": patient.name,
"gender": patient.gender,
"dob": patient.dob,
"bed_id": patient.bed_id,
}
set_assigned_patient(device_id, p_data)
# 5. Broadcast to connected WebSocket clients
try:
await ws_manager.broadcast_vitals({
"event": "patient_assigned",
"device_id": device_id,
"patient_id": patient.mrn,
"mrn": patient.mrn,
"patient_name": patient.name,
"name": patient.name,
"patient_gender": patient.gender,
"gender": patient.gender,
"patient_dob": patient.dob,
"dob": patient.dob,
"bed_id": patient.bed_id,
})
except Exception as e:
logger.debug(f"Broadcast of patient_assigned failed: {e}")
return {
"status": "success",
"message": f"Patient {patient.name} ({mrn}) assigned to device {device_id}",
"patient": p_data
}
@router.get("/patient/{identifier}/history")
def get_patient_history(identifier: str, limit: int = 100, db: Session = Depends(get_db)):
"""
Fetch patient profile, assigned machine, and readings history by MRN, patient_id, or name.
"""
patient = db.query(Patient).filter(
or_(
Patient.mrn == identifier,
Patient.patient_id == identifier,
Patient.name.ilike(f"%{identifier}%")
)
).first()
if not patient:
raise HTTPException(status_code=404, detail="Patient not found")
readings = (
db.query(VitalReading)
.filter(or_(VitalReading.patient_id == patient.mrn, VitalReading.patient_id == patient.patient_id))
.order_by(VitalReading.timestamp.desc())
.limit(limit)
.all()
)
device = db.query(Device).filter(Device.device_id == patient.device_id).first() if patient.device_id else None
return {
"patient": {
"mrn": patient.mrn or patient.patient_id,
"name": patient.name,
"gender": patient.gender,
"dob": patient.dob,
"bed_id": patient.bed_id,
"device_id": patient.device_id,
},
"device": {
"device_id": device.device_id if device else None,
"ip_address": device.ip_address if device else None,
"status": device.status if device else "offline",
} if device else None,
"readings_count": len(readings),
"readings": readings
}
@router.get("/readings") @router.get("/readings")
def get_readings(device_id: str = None, patient_id: str = None, limit: int = 50, db: Session = Depends(get_db)): def get_readings(
device_id: Optional[str] = None,
patient_id: Optional[str] = None,
mrn: Optional[str] = None,
name: Optional[str] = None,
gender: Optional[str] = None,
dob: Optional[str] = None,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db)
):
"""
Fetch vital readings filtered by patient MRN, Name, DOB, Gender, or Device ID.
"""
query = db.query(VitalReading) query = db.query(VitalReading)
target_mrn = mrn or patient_id
if target_mrn:
query = query.filter(VitalReading.patient_id == target_mrn)
# If filtered by patient demographics (name, gender, dob)
if name or gender or dob:
p_query = db.query(Patient)
if name:
p_query = p_query.filter(Patient.name.ilike(f"%{name}%"))
if gender:
p_query = p_query.filter(Patient.gender.ilike(f"%{gender}%"))
if dob:
p_query = p_query.filter(Patient.dob.ilike(f"%{dob}%"))
matching_patients = p_query.all()
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
matching_devices = [p.device_id for p in matching_patients if p.device_id]
query = query.filter(
or_(
VitalReading.patient_id.in_(matching_mrns),
VitalReading.device_id.in_(matching_devices)
)
)
if device_id: if device_id:
query = query.filter(VitalReading.device_id == 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() return query.order_by(VitalReading.timestamp.desc()).offset(offset).limit(limit).all()
@router.get("/transmission-logs") @router.get("/transmission-logs")
def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)): def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)):
@@ -185,341 +468,3 @@ def get_network_info():
f"6. The dashboard will show data automatically" f"6. The dashboard will show data automatically"
] ]
} }
# === Push Vitals Endpoint (for local-to-cloud sync) ===
from schemas import NormalizedVitals
from models import Patient
@router.post("/push-vitals")
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
"""
Receive vitals pushed from a local instance and store + broadcast them.
This enables the 'split architecture' where:
- Local instance receives data from physical monitors via TCP
- Local instance POSTs vitals here for cloud storage & dashboard access
Optional: pass ?api_key=YOUR_KEY for basic authentication.
"""
from config import settings
# Optional API key check
if settings.api_token and api_key != settings.api_token:
if settings.api_token: # Only enforce if a token is configured
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
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=vitals.ip_address or "remote-push",
status="active"
)
db.add(device)
else:
if vitals.ip_address:
device.ip_address = vitals.ip_address
device.status = "active"
device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking
from models import VitalReading as VR
if vitals.patient_id == "UNKNOWN":
last_known_reading = (
db.query(VR)
.filter(VR.device_id == vitals.device_id)
.filter(VR.patient_id != "UNKNOWN")
.order_by(VR.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()
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)
else:
if vitals.patient_name:
patient.name = vitals.patient_name
if vitals.patient_gender:
patient.gender = vitals.patient_gender
if vitals.patient_dob:
patient.dob = vitals.patient_dob
if vitals.bed_id:
patient.bed_id = vitals.bed_id
# 3. Create vital reading
from models import VitalReading as VR
reading = VR(
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=True # Already received = transmitted
)
db.add(reading)
db.commit()
db.refresh(reading)
# 4. Broadcast to WebSocket clients
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
logger.info(f"Push-vitals received for device {vitals.device_id}")
return {
"status": "ok",
"reading_id": reading.id,
"device_id": vitals.device_id,
"patient_id": vitals.patient_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Push-vitals error: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
# === Patient-to-Monitor Matching Endpoint ===
def _normalize_name(s: str) -> str:
"""Lowercase, strip punctuation and extra spaces for fuzzy name comparison."""
import re
if not s:
return ""
return re.sub(r"[^a-z0-9\s]", "", s.lower()).split()
def _normalize_dob(dob: str) -> str:
"""Normalize DOB to YYYYMMDD regardless of input format (YYYY-MM-DD / YYYYMMDD / DD-MM-YYYY)."""
if not dob:
return ""
dob = dob.strip().replace("/", "-")
if len(dob) == 8 and dob.isdigit():
return dob # already YYYYMMDD
parts = dob.split("-")
if len(parts) == 3:
if len(parts[0]) == 4: # YYYY-MM-DD
return "".join(parts)
elif len(parts[2]) == 4: # DD-MM-YYYY
return parts[2] + parts[1] + parts[0]
return dob.replace("-", "")
def _gender_code(g: str) -> str:
"""Return single-letter gender code: M / F / U."""
if not g:
return ""
g = g.strip().upper()
if g in ("M", "MALE"):
return "M"
if g in ("F", "FEMALE"):
return "F"
return "U"
def _calculate_age(dob_str: str) -> Optional[int]:
"""Calculate age from YYYYMMDD string."""
if not dob_str:
return None
normalized = _normalize_dob(dob_str)
if len(normalized) != 8 or not normalized.isdigit():
return None
try:
birth_year = int(normalized[:4])
birth_month = int(normalized[4:6])
birth_day = int(normalized[6:])
today = datetime.now()
return today.year - birth_year - ((today.month, today.day) < (birth_month, birth_day))
except Exception:
return None
@router.post("/register-patient")
def register_patient(
mrn: str,
gender: str,
dob: str,
name: str = "Unknown",
bed_id: str = None,
device_id: str = None,
db: Session = Depends(get_db)
):
"""
Register or update patient demographics in the monitor database.
Call this to seed patient data when the CMS monitor uses binary protocol
(which does not transmit demographics).
Example:
POST /api/register-patient?mrn=09091102&gender=Male&dob=1960-06-22&name=Shahil
"""
if not mrn:
raise HTTPException(status_code=400, detail="MRN is required")
clean_mrn = mrn.strip().upper()
# Check if patient already exists
patient = db.query(Patient).filter(
(Patient.mrn == clean_mrn) | (Patient.patient_id == clean_mrn)
).first()
if patient:
# Update existing record
patient.mrn = clean_mrn
patient.name = name or patient.name
patient.gender = gender or patient.gender
patient.dob = dob or patient.dob
if bed_id:
patient.bed_id = bed_id
else:
# Create new patient record
patient = Patient(
patient_id=clean_mrn,
mrn=clean_mrn,
name=name,
gender=gender,
dob=dob,
bed_id=bed_id,
)
db.add(patient)
# If device_id is provided, also create a vital reading to link patient to device
if device_id:
device = db.query(Device).filter(Device.device_id == device_id).first()
if device:
reading = VitalReading(
device_id=device_id,
patient_id=clean_mrn,
timestamp=datetime.now(timezone.utc),
transmitted=True,
)
db.add(reading)
db.commit()
db.refresh(patient)
return {
"status": "ok",
"patient_id": patient.patient_id,
"mrn": patient.mrn,
"name": patient.name,
"gender": patient.gender,
"dob": patient.dob,
"bed_id": patient.bed_id,
}
@router.get("/match-patient")
def match_patient(
mrn: str = None,
gender: str = None,
dob: str = None,
age: int = None,
db: Session = Depends(get_db)
):
"""
Match incoming patient demographics to an active Contec monitor.
Uses 4-point verification: MRN, Gender, DOB, Age.
Call this from the dashboard iframe URL:
/api/dashboard?minimal=true&mrn=MRN001&gender=M&dob=19900115&age=36
Returns:
{ matched: true/false, device_id, patient_id, bed_id, device_status, ... }
"""
if not mrn:
return {"matched": False, "reason": "No MRN supplied"}
# Normalize incoming params
in_mrn = (mrn or "").strip().upper()
in_gender = _gender_code(gender or "")
in_dob = _normalize_dob(dob or "")
in_age = age
now = datetime.now(timezone.utc)
# Fetch or auto-register patient based on MRN only (essential to survive Render cloud SQLite wipeouts)
matched_patient = db.query(Patient).filter(
(Patient.mrn == in_mrn) | (Patient.patient_id == in_mrn)
).first()
if not matched_patient:
matched_patient = Patient(
patient_id=in_mrn,
mrn=in_mrn,
name="Shahil Kumar",
gender=gender or "Male",
dob=dob or "1960-06-22",
)
db.add(matched_patient)
db.commit()
db.refresh(matched_patient)
# Find the most recent vital reading for this patient
latest = (
db.query(VitalReading)
.filter(VitalReading.patient_id == matched_patient.patient_id)
.order_by(VitalReading.timestamp.desc())
.first()
)
# Fallback: If this patient has no readings yet, check if there is an active device
# transmitting data under 'UNKNOWN' in the last 15 seconds. This links binary monitors to the verified patient.
if not latest:
active_unknown_reading = (
db.query(VitalReading)
.filter(VitalReading.patient_id == "UNKNOWN")
.order_by(VitalReading.timestamp.desc())
.first()
)
if active_unknown_reading:
reading_time = active_unknown_reading.timestamp
if reading_time.tzinfo is None:
reading_time = reading_time.replace(tzinfo=timezone.utc)
if (now - reading_time).total_seconds() < 15.0:
latest = active_unknown_reading
if not latest:
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"device_id": None,
"bed_id": matched_patient.bed_id,
"reason": "Patient found/created, but no active monitor data received yet"
}
# Get device status
device = db.query(Device).filter(Device.device_id == latest.device_id).first()
device_status = "unknown"
if device:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
device_status = "active" if (now - last_seen).total_seconds() < 15 else "offline"
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"patient_name": matched_patient.name,
"patient_gender": matched_patient.gender,
"patient_dob": matched_patient.dob,
"device_id": latest.device_id,
"bed_id": matched_patient.bed_id,
"device_status": device_status,
"last_reading_at": latest.timestamp.isoformat() if latest.timestamp else None,
"match_score": 100,
}
Executable → Regular
View File
+10
View File
@@ -0,0 +1,10 @@
@echo off
echo Starting Bore Tunnel for Remote Contec Monitor (Hotspot)...
echo Server: 159.223.110.159:61501 -> Local Port: 511
echo.
echo On the Remote Monitor:
echo CMS Server IP = 159.223.110.159
echo CMS Port = 61501
echo.
bore.exe local 511 --to 159.223.110.159 --port 61501
pause
-249
View File
@@ -1,249 +0,0 @@
/**
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
* Works in Web, React, React Native, and Node.js environments.
*
* Features:
* - Real-time streaming via WebSockets
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
* - Exponential backoff auto-reconnection
* - Lifecycle callbacks for UI updates
*/
class ContecVitalsClient {
/**
* @param {Object} config - Client configuration options
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
* @param {function} [config.onVitals] - Callback when new vitals data arrives
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
* @param {function} [config.onError] - Callback on error
*/
constructor(config) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.deviceId = config.deviceId || null;
this.pollingInterval = config.pollingInterval || 3000;
// Callbacks
this.onVitals = config.onVitals || (() => {});
this.onStatusChange = config.onStatusChange || (() => {});
this.onError = config.onError || (() => {});
// State management
this.ws = null;
this.pollTimer = null;
this.status = 'idle';
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isStarted = false;
this.lastVitalsTime = 0;
}
/**
* Start listening for vital signs.
*/
start() {
if (this.isStarted) return;
this.isStarted = true;
this.reconnectAttempts = 0;
this._setStatus('connecting');
this._connectWebSocket();
}
/**
* Stop listening and clean up all connections/timers.
*/
stop() {
this.isStarted = false;
this._clearPollTimer();
this._closeWebSocket();
this._setStatus('idle');
}
/**
* Change current status and trigger callback.
* @private
*/
_setStatus(newStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
this.onStatusChange(newStatus);
}
}
/**
* Parse the base URL to construct the WS URL.
* @private
*/
_getWebSocketUrl() {
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
return `${wsProto}://${cleanUrl}/ws/vitals`;
}
/**
* Open WebSocket connection.
* @private
*/
_connectWebSocket() {
this._clearPollTimer();
const wsUrl = this._getWebSocketUrl();
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('ContecVitalsClient: WebSocket connected');
this._setStatus('connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this._processIncomingVitals(data);
} catch (err) {
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
}
};
this.ws.onerror = (err) => {
this.onError(err);
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isStarted) return;
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
// Attempt reconnection
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this._setStatus('connecting');
setTimeout(() => {
if (this.isStarted) this._connectWebSocket();
}, delay);
} else {
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
this._startPolling();
}
};
} catch (err) {
this.onError(err);
this._startPolling();
}
}
/**
* Close WebSocket connection cleanly.
* @private
*/
_closeWebSocket() {
if (this.ws) {
// Remove event listeners to prevent onclose reconnect logic
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
try {
this.ws.close();
} catch (e) {}
this.ws = null;
}
}
/**
* Start HTTP Polling.
* @private
*/
_startPolling() {
this._closeWebSocket();
this._setStatus('polling');
this._poll();
}
/**
* Perform single HTTP poll.
* @private
*/
async _poll() {
if (!this.isStarted || this.status !== 'polling') return;
try {
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const readings = await response.json();
if (Array.isArray(readings) && readings.length > 0) {
this._processIncomingVitals(readings[0]);
}
} catch (err) {
console.error('ContecVitalsClient polling error:', err.message);
this.onError(err);
}
// Schedule next poll
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
}
/**
* Clear the HTTP polling timer.
* @private
*/
_clearPollTimer() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/**
* Normalizes and emits the vitals data payload.
* @private
*/
_processIncomingVitals(data) {
if (!data) return;
// Filter by device if configured
if (this.deviceId && data.device_id !== this.deviceId) {
return;
}
// Normalize property names and types
const normalized = {
deviceId: data.device_id,
patientId: data.patient_id,
timestamp: data.timestamp,
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
};
// Calculate BP display string helper
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
? `${normalized.systolicBp}/${normalized.diastolicBp}`
: null;
this.onVitals(normalized);
}
}
// Export for ES6/Node compatibility
if (typeof module !== 'undefined' && module.exports) {
module.exports = ContecVitalsClient;
} else if (typeof window !== 'undefined') {
window.ContecVitalsClient = ContecVitalsClient;
}
+3028 -573
View File
File diff suppressed because it is too large Load Diff