diff --git a/api_client.py b/api_client.py index 7882a8a..12fd250 100644 --- a/api_client.py +++ b/api_client.py @@ -6,6 +6,17 @@ from config import settings 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: """ 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 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) primary_url = settings.target_api_url primary_success = False @@ -23,17 +41,17 @@ async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool: 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 + response = await client.post( + primary_url, + json=payload, + 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}") + 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 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: 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 + response = await client.post( + url_with_key, + json=payload, + 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}") + 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 diff --git a/background_tasks.py b/background_tasks.py index 6130a38..f27b5c4 100644 --- a/background_tasks.py +++ b/background_tasks.py @@ -17,50 +17,64 @@ async def retry_failed_transmissions(): while True: try: + # 1. Fetch untransmitted readings in a quick DB session and close it immediately db = SessionLocal() + readings_to_retry = [] 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 + 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 ) - 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() - + + # 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() + try: + for rid in successful_ids: + db.query(VitalReading).filter(VitalReading.id == rid).update({"transmitted": True}) + db.add(TransmissionLog(reading_id=rid, status="success", response_code=200)) + for rid in failed_ids: + db.add(TransmissionLog(reading_id=rid, status="failed", error_message="Retry failed")) + db.commit() + except Exception as e: + logger.error(f"Failed updating retry transmission status: {e}") + db.rollback() + finally: + db.close() + except Exception as e: logger.error(f"Error in retry background task: {e}") diff --git a/bore.exe b/bore.exe new file mode 100644 index 0000000..4caa559 Binary files /dev/null and b/bore.exe differ diff --git a/contec_parser.py b/contec_parser.py index 95b7ad2..4116255 100644 --- a/contec_parser.py +++ b/contec_parser.py @@ -247,7 +247,7 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[ vitals_dict["spo2"] = float(spo2) 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: vitals_dict["heart_rate"] = float(hr_ecg) 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: """ - 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: - [0-1] Packet length LE u16 = 45 - [2-3] Marker: 01 46 + [0-1] Packet length LE u16 = 45 or 47 + [2-3] Marker: 01 46 or 00 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) + [15-16] NIBP Systolic (LE u16, 32767 / 9999 = no measurement / idle) + [17-18] NIBP Diastolic (LE u16, 32767 / 9999 = no measurement / idle) + [19-20] NIBP MAP (LE u16, 32767 / 9999 = no measurement / idle) """ - if len(data) < 22: + if len(data) < 21: return False 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_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 - 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: + if nibp_sys is not None and nibp_sys not in sentinels and 40 <= nibp_sys <= 260: vitals_dict["systolic_bp"] = float(nibp_sys) - found = True - else: - vitals_dict["systolic_bp"] = None + if "systolic_bp" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("systolic_bp") - 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) - found = True - else: - vitals_dict["diastolic_bp"] = None + if "diastolic_bp" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("diastolic_bp") - 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) - found = True - else: - vitals_dict["map_bp"] = None + if "map_bp" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("map_bp") - return found + return True 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: 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: + def is_real_temp(t): + # 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) + if "temperature" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("temperature") 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) + if "temperature" not in vitals_dict["present_fields"]: + vitals_dict["present_fields"].append("temperature") 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 def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool: - """ - 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 + return _parse_spo2_waveform_packet(data, vitals_dict) def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool: - """ - 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 + return _parse_spo2_waveform_packet(data, vitals_dict) 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 + return _parse_spo2_waveform_packet(data, vitals_dict) 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: 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"]: 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) - 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 + 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.setdefault("present_fields", []).append("heart_rate") - return found + return True 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 + Scan the data stream for Contec CMS7000PLUS / CMS8500 sub-packets and parse them. """ model = settings.device_models.get(client_ip, settings.monitor_model) vitals_dict = { @@ -530,23 +468,20 @@ def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[Normal pkt_data = data[i:i + pkt_len] if pkt_len in (45, 47): - _parse_45_byte_packet(pkt_data, vitals_dict) - found_any = True + if _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 + if _parse_56_byte_packet(pkt_data, vitals_dict): + found_any = True + elif pkt_len in (286, 288, 341): + if _parse_spo2_waveform_packet(pkt_data, vitals_dict): + found_any = True elif pkt_len == 989: - _parse_989_byte_packet(pkt_data, vitals_dict) - found_any = True + 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 i += max(pkt_len, 4) else: diff --git a/contec_server.py b/contec_server.py index 12f18b6..44372c0 100644 --- a/contec_server.py +++ b/contec_server.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from datetime import datetime, timezone from contec_parser import parse_contec_data @@ -34,16 +35,195 @@ def generate_ack(received_msg: str) -> str: msg_control_id = fields[9] now_str = datetime.now().strftime("%Y%m%d%H%M%S") - - ack_msh = f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}|{now_str}||ACK^R01|{msg_control_id}|P|2.3.1\r" + + ack_msh = ( + f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}" + f"|{now_str}||ACK|{now_str}|P|2.3\r" + ) ack_msa = f"MSA|AA|{msg_control_id}\r" - + return ack_msh + ack_msa except Exception as e: logger.debug(f"Could not generate ACK: {e}") return "" +TELEMETRY_WORDS = { + 'sensor', 'off', 'lead', 'leads', 'fall', 'searching', 'pulse', + 'le', 'le1', 'le2', 'le3', 'le4', 'le5', 'alarm', 'probe', + 'cuff', 'error', 'leak', 'motion', 'weak', 'signal', 'learning', 'arr', + 'asystole', 'vfib', 'vtac', 'pvc', 'brady', 'tachy', 'adult', 'pediatric', + 'neonate', 'unknown', 'none', 'clear', 'default', 'demo', 't1', 't2', + 'spo2', 'ecg', 'nibp', 'resp', 'temp', 'hr', 'pr', 'map', 'dia', 'sys' +} + + +def extract_demographics_from_packet(data: bytes, client_ip: str) -> dict: + """ + Extracts real patient demographics from Contec CMS8500 / CMS7000 binary packets. + Only extracts clean, legitimate patient fields — strictly rejects sensor alarms, + waveform noise, and hardware telemetry. + """ + clean = [] + for m in re.finditer(rb'[a-zA-Z0-9_\-\.]{2,}', data): + s = m.group(0).decode('latin1', errors='ignore').strip() + if not s or len(s) < 2: + continue + s_lower = s.lower() + if any(kw in s_lower for kw in TELEMETRY_WORDS): + continue + if len(set(s)) == 1: + continue + if set(s).issubset({'.', '-', '_', '/', '\\'}): + continue + if len(s) > 8 and re.match(r'^[A-Za-z]+$', s): + diffs = [abs(ord(s[i]) - ord(s[i-1])) for i in range(1, len(s))] + if sum(1 for d in diffs if d <= 2) / len(diffs) > 0.6: + continue + if re.match(r'^\.\d+$', s): + continue + clean.append(s) + + if not clean: + return None + + name = None + mrn = None + dob = None + gender = None + + for s in clean: + s_up = s.upper() + s_low = s.lower() + if s_low in TELEMETRY_WORDS: + continue + + if s_up in ('MALE', 'FEMALE') and not gender: + gender = 'Male' if s_up == 'MALE' else 'Female' + continue + elif s_up in ('M', 'F') and not gender: + gender = 'Male' if s_up == 'M' else 'Female' + continue + + # Date of birth: 4-digit birth year between 1920 and 2024, or YYYY-MM-DD + if re.match(r'^(19\d\d|20[01]\d|202[0-4])$', s) and not dob: + dob = s + continue + elif re.match(r'^\d{4}[-/]\d{2}[-/]\d{2}$', s) and not dob: + dob = s + continue + + # Name candidate: strictly at least 3 letters, has a vowel, not telemetry + if re.search(r'^[A-Za-z\s\.\-]{3,30}$', s) and re.search(r'[aeiouyAEIOUY]', s) and not name: + if s_low not in TELEMETRY_WORDS: + name = s + continue + + # MRN candidate: numeric or alphanumeric ID, at least 2 chars, not a year, not telemetry + if not mrn and re.match(r'^[A-Za-z0-9\-]{2,15}$', s) and s_low not in TELEMETRY_WORDS: + if not (s.isdigit() and 1920 <= int(s) <= 2030): + mrn = s + continue + + # Only accept when BOTH real name and real MRN/DOB are present together + if name and (mrn or dob): + return { + "name": name, + "mrn": mrn or name, + "patient_id": mrn or name, + "dob": dob, + "gender": gender, + } + return None + + +async def apply_auto_demographics(client_ip: str, demo: dict): + from config import settings + model = settings.device_models.get(client_ip, settings.monitor_model) + device_id = f"{model}_{client_ip}" + name = demo.get("name") + mrn = demo.get("mrn") + dob = demo.get("dob") + gender = demo.get("gender") + + current = device_patient_map.get(device_id, {}) + if current.get("name") == name and current.get("mrn") == mrn and current.get("dob") == dob and current.get("gender") == gender: + return + + logger.info(f"*** REAL PATIENT DEMOGRAPHICS DETECTED FROM MACHINE ({client_ip}): Name={name}, MRN={mrn}, DOB={dob}, Gender={gender} ***") + + bed_id = "1" if "192.168.100.106" in client_ip else "2" + p_data = { + "patient_id": mrn, + "mrn": mrn, + "name": name, + "gender": gender, + "dob": dob, + "bed_id": bed_id + } + device_patient_map[device_id] = p_data + + if device_id in device_cache: + cached = device_cache[device_id] + cached.patient_id = mrn + cached.patient_name = name + cached.patient_gender = gender + cached.patient_dob = dob + cached.bed_id = bed_id + + db = SessionLocal() + try: + db.query(Patient).filter(Patient.device_id == device_id).update({"device_id": None}) + patient = db.query(Patient).filter(Patient.mrn == mrn).first() + if not patient: + patient = Patient( + patient_id=mrn, + mrn=mrn, + name=name or "Unknown", + gender=gender, + dob=dob, + bed_id=bed_id, + device_id=device_id + ) + db.add(patient) + else: + patient.patient_id = mrn + patient.mrn = mrn + if name: + patient.name = name + if gender: + patient.gender = gender + if dob: + patient.dob = dob + patient.bed_id = bed_id + patient.device_id = device_id + db.commit() + except Exception as e: + logger.error(f"Error persisting auto patient: {e}") + db.rollback() + finally: + db.close() + + try: + from routers.dashboard import get_ws_manager + ws_manager = get_ws_manager() + await ws_manager.broadcast_vitals({ + "event": "patient_assigned", + "device_id": device_id, + "patient_id": mrn, + "mrn": mrn, + "patient_name": name, + "name": name, + "patient_gender": gender, + "gender": gender, + "patient_dob": dob, + "dob": dob, + "bed_id": bed_id + }) + except Exception as e: + logger.debug(f"Failed to broadcast auto patient: {e}") + + async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int): """ TCP server handler for Contec CMS7000PLUS client connections. @@ -52,6 +232,13 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str client_ip, client_port = writer.get_extra_info('peername') logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}") + sock = writer.get_extra_info('socket') + if sock: + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except Exception: + pass + display = get_terminal_display() display.connection_opened(client_ip, port) @@ -64,8 +251,10 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)") break + if client_ip != "127.0.0.1": + logger.info(f"TCP READ from {client_ip}: {len(data)} bytes, hex: {data[:24].hex()}") + buffer += data - logger.debug(f"Received {len(data)} bytes from {client_ip}:{client_port} on port {port} (buf={len(buffer)})") # --------------------------------------------------------------- # MLLP / HL7 text path (typically port 511) @@ -166,6 +355,48 @@ device_cache = {} device_field_timestamps = {} # {device_id: {field_name: datetime}} last_db_write = {} +# In-memory mapping of device_id -> patient demographic dict: +# {"mrn": "...", "name": "...", "dob": "...", "gender": "...", "bed_id": "..."} +device_patient_map = {} + + +def get_assigned_patient(device_id: str): + """Retrieve patient demographics assigned to device_id (cached or from DB).""" + if device_id in device_patient_map: + return device_patient_map[device_id] + + db = SessionLocal() + try: + patient = db.query(Patient).filter(Patient.device_id == device_id).first() + if patient: + p_data = { + "patient_id": patient.mrn or patient.patient_id, + "mrn": patient.mrn or patient.patient_id, + "name": patient.name or "Unknown", + "gender": patient.gender, + "dob": patient.dob, + "bed_id": patient.bed_id, + } + device_patient_map[device_id] = p_data + return p_data + except Exception as e: + logger.debug(f"Error querying patient for device {device_id}: {e}") + finally: + db.close() + return None + + +def set_assigned_patient(device_id: str, patient_data: dict): + """Update in-memory mapping and active device cache for device_id.""" + device_patient_map[device_id] = patient_data + if device_id in device_cache: + cached = device_cache[device_id] + cached.patient_id = patient_data.get("mrn") or patient_data.get("patient_id") or "UNKNOWN" + cached.patient_name = patient_data.get("name") + cached.patient_gender = patient_data.get("gender") + cached.patient_dob = patient_data.get("dob") + cached.bed_id = patient_data.get("bed_id") + async def process_vitals(vitals): """ @@ -177,6 +408,25 @@ async def process_vitals(vitals): if device_id not in device_field_timestamps: device_field_timestamps[device_id] = {} + # Check for assigned patient demographics + assigned = get_assigned_patient(device_id) + if (vitals.patient_id == "UNKNOWN" or not vitals.patient_name or vitals.patient_name == "Unknown") and assigned: + vitals.patient_id = assigned.get("mrn") or assigned.get("patient_id") or "UNKNOWN" + vitals.patient_name = assigned.get("name") + vitals.patient_gender = assigned.get("gender") + vitals.patient_dob = assigned.get("dob") + vitals.bed_id = assigned.get("bed_id") + elif vitals.patient_name and vitals.patient_name != "Unknown": + # Monitor sent fresh demographics via HL7 PID segment! + device_patient_map[device_id] = { + "patient_id": vitals.patient_id, + "mrn": vitals.patient_id, + "name": vitals.patient_name, + "gender": vitals.patient_gender, + "dob": vitals.patient_dob, + "bed_id": vitals.bed_id, + } + # Check if incoming packet has fresh waveforms has_fresh_waveforms = (vitals.ecg_wave is not None) or (vitals.pleth_wave is not None) @@ -205,18 +455,34 @@ async def process_vitals(vitals): # Update cache & record timestamps for field in active_fields: - val = getattr(vitals, field) + val = getattr(vitals, field, None) setattr(cached, field, val) - device_field_timestamps[device_id][field] = now + if val is not None: + device_field_timestamps[device_id][field] = now + else: + device_field_timestamps[device_id].pop(field, None) - # Clean up stale fields (older than 15 seconds) + # Clean up stale fields: + # Live vitals (HR, SpO2, Temp, RR) have a 60-second window because monitor cycles take ~24s + # NIBP (BP, MAP) has a 1-hour window because it is measured intermittently for field in list(device_field_timestamps[device_id].keys()): last_updated = device_field_timestamps[device_id][field] - if (now - last_updated).total_seconds() > 15.0: + timeout = 3600.0 if field in ("systolic_bp", "diastolic_bp", "map_bp") else 60.0 + if (now - last_updated).total_seconds() > timeout: setattr(cached, field, None) device_field_timestamps[device_id].pop(field, None) cached.timestamp = vitals.timestamp + if vitals.patient_id and vitals.patient_id != "UNKNOWN": + cached.patient_id = vitals.patient_id + if vitals.patient_name: + cached.patient_name = vitals.patient_name + if vitals.patient_gender: + cached.patient_gender = vitals.patient_gender + if vitals.patient_dob: + cached.patient_dob = vitals.patient_dob + if vitals.bed_id: + cached.bed_id = vitals.bed_id vitals = cached # 2. Update console display banner @@ -224,7 +490,7 @@ async def process_vitals(vitals): display.update_vitals(vitals) logger.info( - f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id}: " + f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id} ({vitals.patient_name}): " f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, " f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, " f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}" @@ -234,23 +500,40 @@ async def process_vitals(vitals): try: from routers.dashboard import get_ws_manager ws_manager = get_ws_manager() - await ws_manager.broadcast_vitals(vitals.model_dump(mode="json")) + vitals_dict = vitals.model_dump(mode="json") + vitals_dict["mrn"] = vitals.patient_id + vitals_dict["name"] = vitals.patient_name + vitals_dict["gender"] = vitals.patient_gender + vitals_dict["dob"] = vitals.patient_dob + vitals_dict["bed_id"] = vitals.bed_id + await ws_manager.broadcast_vitals(vitals_dict) except Exception as e: logger.debug(f"WebSocket broadcast failed: {e}") - # 4. Throttled Database & REST forwarding (at most once every 5 seconds) - # Note: If we have fresh waveforms, we bypass throttle for REST forwarding to keep cloud graphs real-time + # 4. Asynchronous background persistence and cloud forwarding (completely non-blocking, throttled to 1s) now = datetime.now(timezone.utc) - should_write_db = False - if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0: - should_write_db = True + should_persist = False + if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 1.0: + should_persist = True last_db_write[device_id] = now - if should_write_db: - # Save to SQLite database and REST forward + if should_persist: + asyncio.create_task(_persist_and_forward_bg(vitals, should_persist)) + + # Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets + if device_id in device_cache: + device_cache[device_id].ecg_wave = None + device_cache[device_id].pleth_wave = None + + +async def _persist_and_forward_bg(vitals, should_persist): + """Background worker for DB persistence and external cloud forwarding.""" + if not should_persist: + return + reading_id = None + if should_persist: db = SessionLocal() try: - # 1. Device tracking device = db.query(Device).filter(Device.device_id == vitals.device_id).first() if not device: device = Device( @@ -264,33 +547,36 @@ async def process_vitals(vitals): device.ip_address = vitals.ip_address device.status = "active" device.last_seen = datetime.now(timezone.utc) - - # 2. Patient tracking — store/update demographics from HL7 - if vitals.patient_id == "UNKNOWN": - last_known_reading = ( - db.query(VitalReading) - .filter(VitalReading.device_id == vitals.device_id) - .filter(VitalReading.patient_id != "UNKNOWN") - .order_by(VitalReading.timestamp.desc()) - .first() - ) - if last_known_reading: - vitals.patient_id = last_known_reading.patient_id - patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() + patient = None + if vitals.patient_id and vitals.patient_id != "UNKNOWN": + patient = db.query(Patient).filter( + (Patient.mrn == vitals.patient_id) | (Patient.patient_id == vitals.patient_id) + ).first() + + if not patient and vitals.device_id: + patient = db.query(Patient).filter( + Patient.device_id == vitals.device_id, + Patient.patient_id != "UNKNOWN" + ).first() + if not patient: - patient = Patient( - patient_id=vitals.patient_id, - mrn=vitals.patient_id, - name=vitals.patient_name or "Unknown", - gender=vitals.patient_gender, - dob=vitals.patient_dob, - bed_id=vitals.bed_id, - ) - db.add(patient) + if vitals.patient_id and vitals.patient_id != "UNKNOWN": + patient = Patient( + patient_id=vitals.patient_id, + mrn=vitals.patient_id, + name=vitals.patient_name or "Unknown", + gender=vitals.patient_gender, + dob=vitals.patient_dob, + bed_id=vitals.bed_id, + device_id=vitals.device_id + ) + db.add(patient) else: - # Refresh demographics whenever monitor sends updated PID fields - if vitals.patient_name: + if vitals.patient_id and vitals.patient_id != "UNKNOWN": + patient.patient_id = vitals.patient_id + patient.mrn = vitals.patient_id + if vitals.patient_name and vitals.patient_name != "Unknown": patient.name = vitals.patient_name if vitals.patient_gender: patient.gender = vitals.patient_gender @@ -298,9 +584,9 @@ async def process_vitals(vitals): patient.dob = vitals.patient_dob if vitals.bed_id: patient.bed_id = vitals.bed_id + if vitals.device_id: + patient.device_id = vitals.device_id - - # 3. Create Reading Entry reading = VitalReading( device_id=vitals.device_id, patient_id=vitals.patient_id, @@ -317,40 +603,35 @@ async def process_vitals(vitals): db.add(reading) db.commit() db.refresh(reading) - - # 4. REST forwarding - success = await forward_vitals_to_api(vitals) - - if success: - reading.transmitted = True - log_entry = TransmissionLog( - reading_id=reading.id, - status="success", - response_code=200 - ) - else: - log_entry = TransmissionLog( - reading_id=reading.id, - status="failed", - error_message="Immediate REST forward failed" - ) - - db.add(log_entry) - db.commit() - + reading_id = reading.id except Exception as e: - logger.error(f"Database operation failed: {e}") + logger.error(f"Background database operation failed: {e}") db.rollback() finally: db.close() - elif has_fresh_waveforms: - # Bypass throttle for REST forwarding to keep cloud graphs real-time - try: - await forward_vitals_to_api(vitals) - except Exception as e: - logger.error(f"Real-time REST forwarding failed: {e}") - # Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets - if device_id in device_cache: - device_cache[device_id].ecg_wave = None - device_cache[device_id].pleth_wave = None + # DB connection is now completely closed. Awaiting HTTP requests will never exhaust DB connections! + success = False + if should_persist: + try: + success = await forward_vitals_to_api(vitals) + except Exception as e: + logger.warning(f"Background REST forward failed: {type(e).__name__}: {e}") + + # Record transmission log in a separate, brief connection + if reading_id is not None: + db = SessionLocal() + try: + log_entry = TransmissionLog( + reading_id=reading_id, + status="success" if success else "failed", + response_code=200 if success else 500, + error_message=None if success else "Immediate REST forward failed" + ) + db.add(log_entry) + db.commit() + except Exception as e: + logger.error(f"Failed to record transmission log: {e}") + db.rollback() + finally: + db.close() diff --git a/database.py b/database.py index cbde1b6..770383a 100644 --- a/database.py +++ b/database.py @@ -1,9 +1,12 @@ from sqlalchemy import create_engine +from sqlalchemy.pool import NullPool from sqlalchemy.orm import sessionmaker, declarative_base from config import settings 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) diff --git a/fiveparaminte-main/fiveparaminte-main/.dockerignore b/fiveparaminte-main/fiveparaminte-main/.dockerignore deleted file mode 100644 index a1f2f8a..0000000 --- a/fiveparaminte-main/fiveparaminte-main/.dockerignore +++ /dev/null @@ -1,17 +0,0 @@ -__pycache__/ -*.py[cod] -*.db -*.sqlite3 -*.log -*.bin -.env -venv/ -.venv/ -build/ -dist/ -*.spec -scratch/ -captures/ -.git/ -.idea/ -.vscode/ diff --git a/fiveparaminte-main/fiveparaminte-main/.gitignore b/fiveparaminte-main/fiveparaminte-main/.gitignore deleted file mode 100644 index a9bc693..0000000 --- a/fiveparaminte-main/fiveparaminte-main/.gitignore +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/Dockerfile b/fiveparaminte-main/fiveparaminte-main/Dockerfile deleted file mode 100644 index 1d6b037..0000000 --- a/fiveparaminte-main/fiveparaminte-main/Dockerfile +++ /dev/null @@ -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} diff --git a/fiveparaminte-main/fiveparaminte-main/Procfile b/fiveparaminte-main/fiveparaminte-main/Procfile deleted file mode 100644 index e20a7fe..0000000 --- a/fiveparaminte-main/fiveparaminte-main/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/fiveparaminte-main/fiveparaminte-main/README.md b/fiveparaminte-main/fiveparaminte-main/README.md deleted file mode 100644 index fd73963..0000000 --- a/fiveparaminte-main/fiveparaminte-main/README.md +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/api_client.py b/fiveparaminte-main/fiveparaminte-main/api_client.py deleted file mode 100644 index 7882a8a..0000000 --- a/fiveparaminte-main/fiveparaminte-main/api_client.py +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/background_tasks.py b/fiveparaminte-main/fiveparaminte-main/background_tasks.py deleted file mode 100644 index 6130a38..0000000 --- a/fiveparaminte-main/fiveparaminte-main/background_tasks.py +++ /dev/null @@ -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) diff --git a/fiveparaminte-main/fiveparaminte-main/build.bat b/fiveparaminte-main/fiveparaminte-main/build.bat deleted file mode 100644 index 69cda06..0000000 --- a/fiveparaminte-main/fiveparaminte-main/build.bat +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/build.sh b/fiveparaminte-main/fiveparaminte-main/build.sh deleted file mode 100644 index 646d542..0000000 --- a/fiveparaminte-main/fiveparaminte-main/build.sh +++ /dev/null @@ -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." diff --git a/fiveparaminte-main/fiveparaminte-main/config.json b/fiveparaminte-main/fiveparaminte-main/config.json deleted file mode 100644 index 115508f..0000000 --- a/fiveparaminte-main/fiveparaminte-main/config.json +++ /dev/null @@ -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" -} diff --git a/fiveparaminte-main/fiveparaminte-main/config.py b/fiveparaminte-main/fiveparaminte-main/config.py deleted file mode 100644 index eb095e8..0000000 --- a/fiveparaminte-main/fiveparaminte-main/config.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/fiveparaminte-main/contec_discovery.py b/fiveparaminte-main/fiveparaminte-main/contec_discovery.py deleted file mode 100644 index 8d6b1f6..0000000 --- a/fiveparaminte-main/fiveparaminte-main/contec_discovery.py +++ /dev/null @@ -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.") diff --git a/fiveparaminte-main/fiveparaminte-main/contec_parser.py b/fiveparaminte-main/fiveparaminte-main/contec_parser.py deleted file mode 100644 index 966ebec..0000000 --- a/fiveparaminte-main/fiveparaminte-main/contec_parser.py +++ /dev/null @@ -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(' Optional[float]: - if offset + 4 <= len(buf): - return struct.unpack_from(' 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(' 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 diff --git a/fiveparaminte-main/fiveparaminte-main/contec_server.py b/fiveparaminte-main/fiveparaminte-main/contec_server.py deleted file mode 100644 index 675f4b6..0000000 --- a/fiveparaminte-main/fiveparaminte-main/contec_server.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/fiveparaminte-main/database.py b/fiveparaminte-main/fiveparaminte-main/database.py deleted file mode 100644 index cbde1b6..0000000 --- a/fiveparaminte-main/fiveparaminte-main/database.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/fiveparaminte-main/main.py b/fiveparaminte-main/fiveparaminte-main/main.py deleted file mode 100644 index 617f971..0000000 --- a/fiveparaminte-main/fiveparaminte-main/main.py +++ /dev/null @@ -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) diff --git a/fiveparaminte-main/fiveparaminte-main/models.py b/fiveparaminte-main/fiveparaminte-main/models.py deleted file mode 100644 index f1fa727..0000000 --- a/fiveparaminte-main/fiveparaminte-main/models.py +++ /dev/null @@ -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)) diff --git a/fiveparaminte-main/fiveparaminte-main/render.yaml b/fiveparaminte-main/fiveparaminte-main/render.yaml deleted file mode 100644 index c2862c9..0000000 --- a/fiveparaminte-main/fiveparaminte-main/render.yaml +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/requirements.txt b/fiveparaminte-main/fiveparaminte-main/requirements.txt deleted file mode 100644 index b76f540..0000000 --- a/fiveparaminte-main/fiveparaminte-main/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi -uvicorn -sqlalchemy -pydantic -pydantic-settings -httpx -gunicorn -rich -websockets diff --git a/fiveparaminte-main/fiveparaminte-main/routers/__init__.py b/fiveparaminte-main/fiveparaminte-main/routers/__init__.py deleted file mode 100644 index 5f24585..0000000 --- a/fiveparaminte-main/fiveparaminte-main/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Python package marker diff --git a/fiveparaminte-main/fiveparaminte-main/routers/dashboard.py b/fiveparaminte-main/fiveparaminte-main/routers/dashboard.py deleted file mode 100644 index 01bdf90..0000000 --- a/fiveparaminte-main/fiveparaminte-main/routers/dashboard.py +++ /dev/null @@ -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="

Dashboard not found

static/dashboard.html is missing

", 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)}") diff --git a/fiveparaminte-main/fiveparaminte-main/run.sh b/fiveparaminte-main/fiveparaminte-main/run.sh deleted file mode 100644 index 58e9f3e..0000000 --- a/fiveparaminte-main/fiveparaminte-main/run.sh +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/runtime.txt b/fiveparaminte-main/fiveparaminte-main/runtime.txt deleted file mode 100644 index 44f8fbe..0000000 --- a/fiveparaminte-main/fiveparaminte-main/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.0 diff --git a/fiveparaminte-main/fiveparaminte-main/schemas.py b/fiveparaminte-main/fiveparaminte-main/schemas.py deleted file mode 100644 index 6a8e69e..0000000 --- a/fiveparaminte-main/fiveparaminte-main/schemas.py +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/fiveparaminte-main/setup_service.sh b/fiveparaminte-main/fiveparaminte-main/setup_service.sh deleted file mode 100755 index ea3a2e6..0000000 --- a/fiveparaminte-main/fiveparaminte-main/setup_service.sh +++ /dev/null @@ -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}" < {}); - 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; -} diff --git a/fiveparaminte-main/fiveparaminte-main/static/dashboard.html b/fiveparaminte-main/fiveparaminte-main/static/dashboard.html deleted file mode 100644 index aa380d4..0000000 --- a/fiveparaminte-main/fiveparaminte-main/static/dashboard.html +++ /dev/null @@ -1,1631 +0,0 @@ - - - - - - Patient Vital Signs Monitor — Contec CMS7000PLUS / CMS8500 - - - - - - - -
-
- -
- - - - - -
- -
-
-
-
- - - -
- Heart Rate -
- No Data -
-
-
---
-
-
- -
- -
- - -
-
-
-
- - - - - - -
- SpO₂ -
- No Data -
-
-
---
-
-
- -
- -
- - -
-
-
-
- - - -
- Blood Pressure -
- No Data -
-
-
---
-
-
- -
- -
- - -
-
-
-
- - - - - -
- Respiratory Rate -
- No Data -
-
-
---
-
-
- -
- -
- - -
-
-
-
- - - - -
- Temperature -
- No Data -
-
-
---
-
-
- -
- -
- - -
-
-
-
- - - -
- Mean Arterial Pressure -
- No Data -
-
-
---
-
-
- -
- -
-
- - -
-
- Model: - CMS7000PLUS -
-
-
- Device: - Waiting... -
-
-
- Patient: - Waiting... -
-
-
- Readings: - 0 -
-
-
- Last Update: - Never -
-
-
- API: - Checking... -
-
-
- - - - diff --git a/fiveparaminte-main/fiveparaminte-main/terminal_display.py b/fiveparaminte-main/fiveparaminte-main/terminal_display.py deleted file mode 100644 index fb04af9..0000000 --- a/fiveparaminte-main/fiveparaminte-main/terminal_display.py +++ /dev/null @@ -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 diff --git a/fiveparaminte-main/routers/__init__.py b/fiveparaminte-main/routers/__init__.py deleted file mode 100644 index 5f24585..0000000 --- a/fiveparaminte-main/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Python package marker diff --git a/fiveparaminte-main/routers/dashboard.py b/fiveparaminte-main/routers/dashboard.py deleted file mode 100644 index 6e4acd0..0000000 --- a/fiveparaminte-main/routers/dashboard.py +++ /dev/null @@ -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="

Dashboard not found

static/dashboard.html is missing

", 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, - } - diff --git a/fiveparaminte-main/scratch/analysis_findings.py b/fiveparaminte-main/scratch/analysis_findings.py deleted file mode 100644 index 5d57805..0000000 --- a/fiveparaminte-main/scratch/analysis_findings.py +++ /dev/null @@ -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") diff --git a/fiveparaminte-main/scratch/analyze_bp.py b/fiveparaminte-main/scratch/analyze_bp.py deleted file mode 100644 index 946c21a..0000000 --- a/fiveparaminte-main/scratch/analyze_bp.py +++ /dev/null @@ -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") diff --git a/fiveparaminte-main/scratch/analyze_logs.py b/fiveparaminte-main/scratch/analyze_logs.py deleted file mode 100644 index 11570ee..0000000 --- a/fiveparaminte-main/scratch/analyze_logs.py +++ /dev/null @@ -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('= 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 diff --git a/fiveparaminte-main/scratch/check_active_connections.py b/fiveparaminte-main/scratch/check_active_connections.py deleted file mode 100644 index f02ddd0..0000000 --- a/fiveparaminte-main/scratch/check_active_connections.py +++ /dev/null @@ -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.") diff --git a/fiveparaminte-main/scratch/check_db_devices.py b/fiveparaminte-main/scratch/check_db_devices.py deleted file mode 100644 index 3fde8b7..0000000 --- a/fiveparaminte-main/scratch/check_db_devices.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/scratch/dissect_45.py b/fiveparaminte-main/scratch/dissect_45.py deleted file mode 100644 index d40c8aa..0000000 --- a/fiveparaminte-main/scratch/dissect_45.py +++ /dev/null @@ -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(' 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)) diff --git a/fiveparaminte-main/scratch/dissect_56.py b/fiveparaminte-main/scratch/dissect_56.py deleted file mode 100644 index f58ef0a..0000000 --- a/fiveparaminte-main/scratch/dissect_56.py +++ /dev/null @@ -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(' 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}") diff --git a/fiveparaminte-main/scratch/dissect_packets.py b/fiveparaminte-main/scratch/dissect_packets.py deleted file mode 100644 index fd75bf1..0000000 --- a/fiveparaminte-main/scratch/dissect_packets.py +++ /dev/null @@ -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(' float: - if offset + 4 > len(data): - return None - return struct.unpack_from(' 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)) diff --git a/fiveparaminte-main/scratch/dump_packets.py b/fiveparaminte-main/scratch/dump_packets.py deleted file mode 100644 index 0264870..0000000 --- a/fiveparaminte-main/scratch/dump_packets.py +++ /dev/null @@ -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(' 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 diff --git a/fiveparaminte-main/scratch/find_pr_offset.py b/fiveparaminte-main/scratch/find_pr_offset.py deleted file mode 100644 index 829efca..0000000 --- a/fiveparaminte-main/scratch/find_pr_offset.py +++ /dev/null @@ -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(' 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(' 5: - vals = [m[1] for m in matches] - print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}") diff --git a/fiveparaminte-main/scratch/inspect_raw_tail.py b/fiveparaminte-main/scratch/inspect_raw_tail.py deleted file mode 100644 index c26065b..0000000 --- a/fiveparaminte-main/scratch/inspect_raw_tail.py +++ /dev/null @@ -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}") diff --git a/fiveparaminte-main/scratch/mock_monitor.py b/fiveparaminte-main/scratch/mock_monitor.py deleted file mode 100644 index 50966ef..0000000 --- a/fiveparaminte-main/scratch/mock_monitor.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/scratch/mock_multi_monitors.py b/fiveparaminte-main/scratch/mock_multi_monitors.py deleted file mode 100644 index f0e2b45..0000000 --- a/fiveparaminte-main/scratch/mock_multi_monitors.py +++ /dev/null @@ -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() diff --git a/fiveparaminte-main/scratch/raw_contec_stream.bin b/fiveparaminte-main/scratch/raw_contec_stream.bin deleted file mode 100644 index f50eb02..0000000 Binary files a/fiveparaminte-main/scratch/raw_contec_stream.bin and /dev/null differ diff --git a/fiveparaminte-main/scratch/reconstruct_packets.py b/fiveparaminte-main/scratch/reconstruct_packets.py deleted file mode 100644 index fb1b478..0000000 --- a/fiveparaminte-main/scratch/reconstruct_packets.py +++ /dev/null @@ -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(' 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="") diff --git a/fiveparaminte-main/scratch/search_vitals.py b/fiveparaminte-main/scratch/search_vitals.py deleted file mode 100644 index 51913e7..0000000 --- a/fiveparaminte-main/scratch/search_vitals.py +++ /dev/null @@ -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(' 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(' u16 LE = {val}") diff --git a/fiveparaminte-main/scratch/search_vitals_989.py b/fiveparaminte-main/scratch/search_vitals_989.py deleted file mode 100644 index a0571d5..0000000 --- a/fiveparaminte-main/scratch/search_vitals_989.py +++ /dev/null @@ -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}") diff --git a/fiveparaminte-main/scratch/search_vitals_range.py b/fiveparaminte-main/scratch/search_vitals_range.py deleted file mode 100644 index 77b7aa5..0000000 --- a/fiveparaminte-main/scratch/search_vitals_range.py +++ /dev/null @@ -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(' Cache: {current}") - last_printed = current diff --git a/fiveparaminte-main/scratch/test_parser.py b/fiveparaminte-main/scratch/test_parser.py deleted file mode 100644 index 60225bc..0000000 --- a/fiveparaminte-main/scratch/test_parser.py +++ /dev/null @@ -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(' len(data): - return None - return struct.unpack_from(' 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 diff --git a/fiveparaminte-main/scratch/test_updated_parser.py b/fiveparaminte-main/scratch/test_updated_parser.py deleted file mode 100644 index 189584a..0000000 --- a/fiveparaminte-main/scratch/test_updated_parser.py +++ /dev/null @@ -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.") diff --git a/fiveparaminte-main/scratch/udp_heartbeat.py b/fiveparaminte-main/scratch/udp_heartbeat.py deleted file mode 100644 index 09bc49e..0000000 --- a/fiveparaminte-main/scratch/udp_heartbeat.py +++ /dev/null @@ -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.") diff --git a/fiveparaminte-main/scratch/udp_sniff_now.py b/fiveparaminte-main/scratch/udp_sniff_now.py deleted file mode 100644 index d267235..0000000 --- a/fiveparaminte-main/scratch/udp_sniff_now.py +++ /dev/null @@ -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") diff --git a/fiveparaminte-main/static/contec-vitals-sdk.js b/fiveparaminte-main/static/contec-vitals-sdk.js deleted file mode 100644 index 8bb37f5..0000000 --- a/fiveparaminte-main/static/contec-vitals-sdk.js +++ /dev/null @@ -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; -} diff --git a/fiveparaminte-main/static/dashboard.html b/fiveparaminte-main/static/dashboard.html deleted file mode 100644 index ff64601..0000000 --- a/fiveparaminte-main/static/dashboard.html +++ /dev/null @@ -1,665 +0,0 @@ - - - - - -Contec Vital Signs Monitor - - - - - - - -
-
-
-

CONNECTING

-

Verifying patient identity against monitor records…

-
-
- - -
-
- — PATIENT — - No device -
-
- OFFLINE - --:--:-- -
-
- - -
- - -
- - -
-
RESP
- -
-
- - -
- -
-
HEART RATE
-
---bpm
-
ECG Lead I
-
WAIT
-
- -
-
SpO₂
-
---%
-
Pleth waveform
-
WAIT
-
- -
-
RESP RATE
-
---/min
-
Thoracic impedance
-
WAIT
-
- -
-
NIBP
-
---mmHg
-
Sys / Dia (MAP)
-
WAIT
-
- -
-
TEMP
-
---°C
-
Body temperature
-
WAIT
-
- -
-
- - - - diff --git a/main.py b/main.py index 617f971..8683ee7 100644 --- a/main.py +++ b/main.py @@ -105,7 +105,8 @@ async def lifespan(app: FastAPI): print("\n" + "=" * 65) print(f" Contec {model} Patient Monitor - Vital Signs Forwarder") 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" Contec Ports: {settings.contec_ports}") print(f" Monitor Model: {model}") @@ -124,10 +125,34 @@ async def lifespan(app: FastAPI): # Startup: Start the background retry task 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 # Shutdown logger.info("Shutting down servers...") + if bore_proc: + try: + bore_proc.terminate() + except Exception: + pass retry_task.cancel() for server in servers: server.close() @@ -156,6 +181,13 @@ app.add_middleware( # Include dashboard routes app.include_router(dashboard.router, prefix="/api") +# Also serve dashboard at root and /dashboard directly for convenience +from fastapi.responses import HTMLResponse +@app.get("/", response_class=HTMLResponse) +@app.get("/dashboard", response_class=HTMLResponse) +def serve_dashboard_root(): + return dashboard.serve_dashboard() + # Serve static folder from fastapi.staticfiles import StaticFiles app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/models.py b/models.py index 9194976..a4d02e2 100644 --- a/models.py +++ b/models.py @@ -19,6 +19,7 @@ class Patient(Base): gender = Column(String, nullable=True) # M/F/U from PID-8 dob = Column(String, nullable=True) # YYYYMMDD from PID-7 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): __tablename__ = "vital_readings" diff --git a/routers/dashboard.py b/routers/dashboard.py index 6e4acd0..5908179 100644 --- a/routers/dashboard.py +++ b/routers/dashboard.py @@ -2,18 +2,29 @@ import asyncio import json import logging import socket -from typing import List, Optional -from datetime import datetime, timezone +from typing import List -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 sqlalchemy.orm import Session +from sqlalchemy import or_ from database import get_db -from models import Device, VitalReading, TransmissionLog +from models import Device, Patient, VitalReading, TransmissionLog logger = logging.getLogger(__name__) 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 === class VitalsWebSocketManager: """Manages WebSocket connections for live vitals streaming.""" @@ -70,61 +81,333 @@ 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)): +def get_system_status(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 + "pending_transmissions": pending_transmissions } @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)): +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) if 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() 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") -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) + + 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: 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") 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" ] } - - -# === 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, - } - diff --git a/setup_service.sh b/setup_service.sh old mode 100755 new mode 100644 diff --git a/start_tunnel.bat b/start_tunnel.bat new file mode 100644 index 0000000..f3eff08 --- /dev/null +++ b/start_tunnel.bat @@ -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 diff --git a/static/contec-vitals-sdk.js b/static/contec-vitals-sdk.js deleted file mode 100644 index 8bb37f5..0000000 --- a/static/contec-vitals-sdk.js +++ /dev/null @@ -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; -} diff --git a/static/dashboard.html b/static/dashboard.html index ff64601..18a6f21 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -1,665 +1,3120 @@ - - -Contec Vital Signs Monitor - - - + /* === Header === */ + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 28px; + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 16px; + margin-bottom: 24px; + } + + .header-left { + display: flex; + align-items: center; + gap: 16px; + } + + .header-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: linear-gradient(135deg, var(--spo2-color), var(--hr-color)); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + box-shadow: 0 4px 16px rgba(6, 182, 212, 0.3); + } + + .header-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.5px; + } + + .header-subtitle { + font-size: 13px; + color: var(--text-secondary); + margin-top: 2px; + } + + .header-right { + display: flex; + align-items: center; + gap: 16px; + } + + /* Audio Alert Toggle */ + .audio-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + background: rgba(100, 116, 139, 0.1); + border: 1px solid rgba(100, 116, 139, 0.2); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.3s; + user-select: none; + } + + .audio-toggle:hover { + background: rgba(100, 116, 139, 0.2); + } + + .audio-toggle.active { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + color: var(--status-critical); + } + + .connection-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); + color: var(--status-ok); + transition: all 0.3s; + } + + .connection-badge.offline { + background: rgba(100, 116, 139, 0.1); + border-color: rgba(100, 116, 139, 0.2); + color: var(--status-offline); + } + + .connection-badge.warning { + background: rgba(234, 179, 8, 0.1); + border-color: rgba(234, 179, 8, 0.2); + color: var(--status-warn); + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--status-ok); + animation: statusPulse 2s ease-in-out infinite; + } + + .connection-badge.offline .status-dot { + background: var(--status-offline); + animation: none; + } + + @keyframes statusPulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); } + 50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } + } + + .time-display { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: var(--text-secondary); + } + + /* === Setup Guide Panel === */ + .setup-guide { + background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06)); + backdrop-filter: blur(20px); + border: 1px solid rgba(6, 182, 212, 0.15); + border-radius: 16px; + margin-bottom: 24px; + overflow: hidden; + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + } + + .setup-guide.hidden { + max-height: 0; + margin-bottom: 0; + border-color: transparent; + opacity: 0; + } + + .setup-guide-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + cursor: pointer; + user-select: none; + } + + .setup-guide-header:hover { + background: rgba(255, 255, 255, 0.02); + } + + .setup-guide-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 15px; + font-weight: 600; + color: var(--spo2-color); + } + + .setup-guide-toggle { + font-size: 12px; + color: var(--text-dim); + padding: 4px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + transition: all 0.2s; + } + + .setup-guide-body { + padding: 0 24px 20px 24px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + } + + .setup-step { + display: flex; + gap: 14px; + align-items: flex-start; + } + + .step-number { + width: 32px; + height: 32px; + border-radius: 10px; + background: rgba(6, 182, 212, 0.15); + color: var(--spo2-color); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + flex-shrink: 0; + } + + .step-content h4 { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + } + + .step-content p { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + .step-content code { + background: rgba(255, 255, 255, 0.08); + padding: 2px 6px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--spo2-color); + } + + .setup-ip-display { + grid-column: 1 / -1; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + } + + .setup-ip-label { + font-size: 13px; + color: var(--text-secondary); + } + + .setup-ip-value { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + font-weight: 700; + color: var(--spo2-color); + text-shadow: 0 0 20px var(--spo2-glow); + } + + /* === Vitals Grid === */ + .vitals-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: auto auto; + gap: 20px; + flex: 1; + } + + .vital-card { + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 28px; + position: relative; + overflow: hidden; + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + display: flex; + flex-direction: column; + } + + .vital-card:hover { + background: var(--bg-card-hover); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); + } + + .vital-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + border-radius: 20px 20px 0 0; + } + + .vital-card.hr::before { background: linear-gradient(90deg, var(--hr-color), transparent); } + .vital-card.spo2::before { background: linear-gradient(90deg, var(--spo2-color), transparent); } + .vital-card.bp::before { background: linear-gradient(90deg, var(--bp-color), transparent); } + .vital-card.rr::before { background: linear-gradient(90deg, var(--rr-color), transparent); } + .vital-card.temp::before { background: linear-gradient(90deg, var(--temp-color), transparent); } + .vital-card.map-card::before { background: linear-gradient(90deg, var(--map-color), transparent); } + + /* Glow effect behind cards */ + .vital-card::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 200px; + height: 200px; + border-radius: 50%; + transform: translate(-50%, -50%); + opacity: 0; + transition: opacity 0.6s; + pointer-events: none; + z-index: 0; + } + + .vital-card.hr::after { background: radial-gradient(circle, var(--hr-glow), transparent 70%); } + .vital-card.spo2::after { background: radial-gradient(circle, var(--spo2-glow), transparent 70%); } + .vital-card.bp::after { background: radial-gradient(circle, var(--bp-glow), transparent 70%); } + .vital-card.rr::after { background: radial-gradient(circle, var(--rr-glow), transparent 70%); } + .vital-card.temp::after { background: radial-gradient(circle, var(--temp-glow), transparent 70%); } + .vital-card.map-card::after { background: radial-gradient(circle, var(--map-glow), transparent 70%); } + + .vital-card.has-value::after { + opacity: 0.15; + } + + .vital-card.has-value:hover::after { + opacity: 0.3; + } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + position: relative; + z-index: 1; + } + + .card-label { + display: flex; + align-items: center; + gap: 10px; + } + + .card-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + + .vital-card.hr .card-icon { background: rgba(239, 68, 68, 0.15); } + .vital-card.spo2 .card-icon { background: rgba(6, 182, 212, 0.15); } + .vital-card.bp .card-icon { background: rgba(168, 85, 247, 0.15); } + .vital-card.rr .card-icon { background: rgba(34, 197, 94, 0.15); } + .vital-card.temp .card-icon { background: rgba(249, 115, 22, 0.15); } + .vital-card.map-card .card-icon { background: rgba(236, 72, 153, 0.15); } + + .card-name { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); + } + + .vital-card.hr .card-name { color: var(--hr-color); } + .vital-card.spo2 .card-name { color: var(--spo2-color); } + .vital-card.bp .card-name { color: var(--bp-color); } + .vital-card.rr .card-name { color: var(--rr-color); } + .vital-card.temp .card-name { color: var(--temp-color); } + .vital-card.map-card .card-name { color: var(--map-color); } + + .status-badge { + padding: 4px 10px; + border-radius: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .status-badge.normal { + background: rgba(34, 197, 94, 0.15); + color: var(--status-ok); + } + + .status-badge.warning { + background: rgba(234, 179, 8, 0.15); + color: var(--status-warn); + animation: warningFlash 2s ease-in-out infinite; + } + + .status-badge.critical { + background: rgba(239, 68, 68, 0.15); + color: var(--status-critical); + animation: criticalFlash 1s ease-in-out infinite; + } + + .status-badge.offline { + background: rgba(100, 116, 139, 0.1); + color: var(--status-offline); + } + + @keyframes warningFlash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + @keyframes criticalFlash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + /* === Vital Value Display === */ + .vital-value-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + min-height: 100px; + } + + .vital-value { + font-family: 'JetBrains Mono', monospace; + font-size: 64px; + font-weight: 700; + line-height: 1; + letter-spacing: -2px; + transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); + } + + .vital-card.hr .vital-value { color: var(--hr-color); text-shadow: 0 0 30px var(--hr-glow); } + .vital-card.spo2 .vital-value { color: var(--spo2-color); text-shadow: 0 0 30px var(--spo2-glow); } + .vital-card.bp .vital-value { color: var(--bp-color); text-shadow: 0 0 30px var(--bp-glow); } + .vital-card.rr .vital-value { color: var(--rr-color); text-shadow: 0 0 30px var(--rr-glow); } + .vital-card.temp .vital-value { color: var(--temp-color); text-shadow: 0 0 30px var(--temp-glow); } + .vital-card.map-card .vital-value { color: var(--map-color); text-shadow: 0 0 30px var(--map-glow); } + + .vital-value.no-data { + font-size: 48px; + color: var(--text-dim) !important; + text-shadow: none !important; + } + + .vital-value .unit { + font-size: 20px; + font-weight: 400; + letter-spacing: 0; + margin-left: 4px; + opacity: 0.6; + } + + .vital-value .slash { + font-size: 48px; + opacity: 0.4; + margin: 0 2px; + } + + .vital-value-update { + animation: valueFlash 0.6s ease-out; + } + + @keyframes valueFlash { + 0% { transform: scale(1.08); filter: brightness(1.4); } + 100% { transform: scale(1); filter: brightness(1); } + } + + /* === Waveform Canvas === */ + .waveform-container { + height: 50px; + margin-top: 12px; + position: relative; + z-index: 1; + overflow: hidden; + border-radius: 8px; + } + + .waveform-container canvas { + width: 100%; + height: 100%; + display: block; + } + + /* === Normal Range Footer === */ + .card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + position: relative; + z-index: 1; + } + + .normal-range { + font-size: 12px; + color: var(--text-dim); + } + + .last-update { + font-size: 11px; + color: var(--text-dim); + font-family: 'JetBrains Mono', monospace; + } + + /* === Info Bar === */ + .info-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 14px; + margin-top: 20px; + flex-wrap: wrap; + gap: 12px; + } + + .info-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + } + + .info-item strong { + color: var(--text-primary); + font-weight: 600; + } + + .info-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); + } + + .model-badge { + padding: 4px 12px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.15), rgba(168, 85, 247, 0.15)); + color: var(--spo2-color); + border: 1px solid rgba(6, 182, 212, 0.2); + } + + /* === Responsive === */ + @media (max-width: 1024px) { + .vitals-grid { + grid-template-columns: repeat(2, 1fr); + } + .setup-guide-body { + grid-template-columns: 1fr; + } + } + + @media (max-width: 640px) { + .app-container { padding: 12px; } + .vitals-grid { + grid-template-columns: 1fr; + } + .header { + flex-direction: column; + gap: 12px; + text-align: center; + } + .header-right { + flex-wrap: wrap; + justify-content: center; + } + .vital-value { font-size: 48px; } + .setup-guide-body { + grid-template-columns: 1fr; + } + } + + /* === Device Navigation & Multi-Bed CSS === */ + .device-nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 12px 18px; + margin-bottom: 20px; + gap: 12px; + flex-wrap: wrap; + } + + .device-tabs-wrap { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .device-nav-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + } + + .device-tabs { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .device-tab-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; + } + + .device-tab-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); + } + + .device-tab-btn.active { + background: linear-gradient(135deg, rgba(6, 182, 212, 0.25), rgba(168, 85, 247, 0.25)); + border-color: var(--spo2-color); + color: #fff; + box-shadow: 0 0 14px rgba(6, 182, 212, 0.3); + } + + .device-tab-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--status-ok); + box-shadow: 0 0 6px var(--status-ok); + } + + .view-mode-toggles { + display: flex; + background: rgba(0, 0, 0, 0.35); + padding: 4px; + border-radius: 8px; + border: 1px solid var(--border-color); + gap: 4px; + } + + .view-mode-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .view-mode-btn.active { + background: rgba(255, 255, 255, 0.15); + color: #fff; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + } + + /* === Multi-Bed Central Grid === */ + .multi-bed-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + margin-bottom: 24px; + } + + .bed-card { + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 20px; + transition: all 0.25s ease; + cursor: pointer; + position: relative; + overflow: hidden; + } + + .bed-card:hover { + border-color: rgba(6, 182, 212, 0.5); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + transform: translateY(-2px); + } + + .bed-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 12px; + } + + .bed-card-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + } + + .bed-card-sub { + font-size: 11px; + color: var(--text-dim); + font-family: 'JetBrains Mono', monospace; + margin-top: 2px; + } + + .bed-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 12px; + } + + .bed-metric { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 10px 8px; + text-align: center; + } + + .bed-metric-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; + } + + .bed-metric-val { + font-size: 22px; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + } + + .bed-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--text-dim); + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + } + + /* === Patient Demographics & Filter Toolbar === */ + .patient-filter-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 12px 18px; + margin-bottom: 16px; + flex-wrap: wrap; + } + + .search-box-wrap { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 280px; + } + + .search-input { + width: 100%; + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 9px 14px; + font-size: 13px; + color: var(--text-primary); + font-family: inherit; + outline: none; + transition: all 0.2s; + } + + .search-input:focus { + border-color: var(--spo2-color); + box-shadow: 0 0 10px rgba(6, 182, 212, 0.25); + } + + .filter-select { + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 9px 12px; + font-size: 13px; + color: var(--text-primary); + outline: none; + cursor: pointer; + } + + .filter-actions { + display: flex; + align-items: center; + gap: 10px; + } + + .btn-primary { + background: linear-gradient(135deg, #06b6d4, #3b82f6); + color: #fff; + border: none; + padding: 9px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + } + + .btn-primary:hover { + filter: brightness(1.15); + box-shadow: 0 4px 14px rgba(6, 182, 212, 0.4); + } + + .btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 9px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + } + + .btn-secondary:hover { + background: rgba(255, 255, 255, 0.15); + } + + /* Patient Demographics Badges */ + .patient-mrn-badge { + background: rgba(6, 182, 212, 0.15); + color: var(--spo2-color); + border: 1px solid rgba(6, 182, 212, 0.3); + border-radius: 6px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + display: inline-flex; + align-items: center; + gap: 4px; + } + + .patient-gender-badge { + background: rgba(168, 85, 247, 0.15); + color: #c084fc; + border: 1px solid rgba(168, 85, 247, 0.3); + border-radius: 6px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + } + + .patient-dob-badge { + background: rgba(249, 115, 22, 0.15); + color: #fb923c; + border: 1px solid rgba(249, 115, 22, 0.3); + border-radius: 6px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + } + + .patient-name-display { + font-size: 17px; + font-weight: 700; + color: #fff; + letter-spacing: -0.3px; + } + + .card-actions-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + gap: 8px; + } + + .card-action-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + padding: 5px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 5px; + } + + .card-action-btn:hover { + background: rgba(6, 182, 212, 0.15); + color: var(--spo2-color); + border-color: rgba(6, 182, 212, 0.3); + } + + /* Modal Overlay & Dialog */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.2s ease-out; + } + + .modal-overlay.active { + display: flex; + } + + .modal-dialog { + background: #111827; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + width: 100%; + max-width: 580px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: 90vh; + } + + .modal-dialog.large { + max-width: 900px; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + border-bottom: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.02); + } + + .modal-title { + font-size: 17px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + } + + .modal-close-btn { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 22px; + cursor: pointer; + padding: 4px; + line-height: 1; + transition: color 0.2s; + } + + .modal-close-btn:hover { + color: #fff; + } + + .modal-body { + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + } + + .modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 16px 24px; + border-top: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.02); + gap: 10px; + } + + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + + .form-group { + display: flex; + flex-direction: column; + gap: 6px; + } + + .form-label { + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .form-input, .form-select { + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 10px 12px; + color: var(--text-primary); + font-size: 14px; + font-family: inherit; + outline: none; + transition: all 0.2s; + } + + .form-input:focus, .form-select:focus { + border-color: var(--spo2-color); + box-shadow: 0 0 10px rgba(6, 182, 212, 0.25); + } + + /* History Table */ + .history-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + .history-table th { + text-align: left; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .history-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + } + + .history-table tr:hover td { + background: rgba(255, 255, 255, 0.03); + } + + /* Single view patient info card */ + .patient-banner { + background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(168, 85, 247, 0.08)); + border: 1px solid rgba(6, 182, 212, 0.2); + border-radius: 14px; + padding: 16px 20px; + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 14px; + } + + .patient-banner-left { + display: flex; + align-items: center; + gap: 14px; + } + + .patient-banner-avatar { + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(6, 182, 212, 0.2); + color: var(--spo2-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + } + + .patient-banner-details { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 4px; + } + + /* === Scrollbar === */ + ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } + +
+
- -
-
-
-

CONNECTING

-

Verifying patient identity against monitor records…

-
-
+
+ + - -
-
- — PATIENT — - No device -
-
- OFFLINE - --:--:-- -
-
+ +
+
+
+ 📡 + CMS7000PLUS Setup Guide — Configure Your Monitor +
+
▾ Collapse
+
+
+
+
1
+
+

Connect via Ethernet

+

Connect your CMS7000PLUS to this PC using an Ethernet cable (direct or through a network switch).

+
+
+
+
2
+
+

Set PC IP Address

+

Set your PC's Ethernet adapter to a static IP, e.g. 192.168.1.50, subnet 255.255.255.0.

+
+
+
+
3
+
+

Configure Monitor CMS Settings

+

On your CMS7000PLUS: System Setup → Network → CMS Settings. Set the Server IP to your PC's IP.

+
+
+
+
4
+
+

Set Server Port

+

Set Server Port to 511 (default). If running without admin, use a port >1024 like 6060.

+
+
+
+
5
+
+

Enable CMS Connection

+

Enable the CMS/Central Monitor connection on your CMS7000PLUS. Set the sending interval (e.g., 5 seconds).

+
+
+
+
6
+
+

Data Will Appear Automatically

+

Once connected, vital signs will appear on this dashboard in real-time via WebSocket or polling.

+
+
+
+
+
🖥️ Your PC's Detected IP Address (set this on the monitor)
+
+
Detecting...
+
+
+
- -
+ +
+
+ 🔍 + +
+
+ + + +
+
- -
- - -
-
RESP
- -
-
+ +
+
+ 🖥️ Monitors / Beds: +
+ +
+
+
+ + +
+
- -
+ +
+
+
⏳
+
Waiting for patient monitors to connect...
+
+ Local Wi-Fi monitors connect to 192.168.100.112:511 | Remote hotspot monitors connect to 159.223.110.159:61501 +
+
+
-
-
HEART RATE
-
---bpm
-
ECG Lead I
-
WAIT
+ + + + +
+
+ Model: + CMS7000PLUS +
+
+
+ Device: + Waiting... +
+
+
+ Patient: + Waiting... +
+
+
+ Readings: + 0 +
+
+
+ Last Update: + Never +
+
+
+ API: + Checking... +
+
-
-
SpO₂
-
---%
-
Pleth waveform
-
WAIT
+ + -
-
RESP RATE
-
---/min
-
Thoracic impedance
-
WAIT
+ + -
-
NIBP
-
---mmHg
-
Sys / Dia (MAP)
-
WAIT
-
+ + const searchInput = document.getElementById('patientSearchInput'); + const query = searchInput ? searchInput.value.trim().toLowerCase() : ''; + const genderSelect = document.getElementById('genderFilterSelect'); + const genderFilter = genderSelect ? genderSelect.value : ''; + + if (devIds.length === 0) { + const msg = hasTargetFilter + ? `Waiting for Contec Monitor data for: ${getTargetPatientBadge()}` + : `Waiting for patient monitors to connect...`; + const sub = hasTargetFilter + ? `Showing only monitors matching target demographics. Ensure monitor is connected.` + : `Local Wi-Fi monitors connect to 192.168.100.112:511 | Remote hotspot monitors connect to 159.223.110.159:61501`; + multiGrid.innerHTML = ` +
+
⏳
+
${msg}
+
${sub}
+
+ `; + return; + } + + // Filter devices by demographics query & gender + const filteredIds = devIds.filter(id => { + const dev = connectedDevices[id]; + if (hasTargetFilter && !matchesTargetPatient(dev, id)) return false; + + const pName = (dev.patient_name || dev.name || '').toLowerCase(); + const pMrn = (dev.patient_id || dev.mrn || '').toLowerCase(); + const pDob = (dev.patient_dob || dev.dob || '').toLowerCase(); + const pGender = (dev.patient_gender || dev.gender || '').toLowerCase(); + const pBed = (dev.bed_id || dev.bedLabel || '').toLowerCase(); + const devIdStr = id.toLowerCase(); + + if (genderFilter) { + if (!pGender.includes(genderFilter.toLowerCase())) return false; + } + + if (query) { + const match = pName.includes(query) || pMrn.includes(query) || + pDob.includes(query) || pGender.includes(query) || + pBed.includes(query) || devIdStr.includes(query); + if (!match) return false; + } + return true; + }); + + if (filteredIds.length === 0) { + const msg = hasTargetFilter + ? `No monitors match Patient: ${getTargetPatientBadge()}` + : `No monitors match your search criteria`; + const sub = hasTargetFilter + ? `Filtered for: ${getTargetPatientBadge()}. Waiting for transmission.` + : `Filter: "${query || genderFilter}" — Try clearing the search bar or changing filters.`; + multiGrid.innerHTML = ` +
+
🔍
+
${msg}
+
${sub}
+ ${!hasTargetFilter ? ` +
+ +
` : ''} +
+ `; + return; + } + + let html = ''; + filteredIds.forEach(id => { + const dev = connectedDevices[id]; + const label = dev.bedLabel || dev.bed_id || getBedLabel(id, dev.ip_address); + const hasAssigned = dev.patient_name && dev.patient_name !== 'Unknown' && dev.patient_name !== 'UNKNOWN' && dev.patient_name !== 'Unassigned Patient'; + const pName = hasAssigned ? dev.patient_name : label; + const pMrn = (dev.patient_id && dev.patient_id !== 'UNKNOWN' && dev.patient_id !== 'UNASSIGNED') ? dev.patient_id : null; + const pGender = (dev.patient_gender && dev.patient_gender !== 'Unknown') ? dev.patient_gender : null; + const pDob = (dev.patient_dob && dev.patient_dob !== 'Unknown') ? dev.patient_dob : null; + + // A device is "live" only if it has received a live WebSocket packet within 8 seconds + // Devices loaded only from DB (never seen via WebSocket) are shown as DISCONNECTED + const isLive = dev._liveData === true && dev.lastSeen && (Date.now() - dev.lastSeen.getTime()) < 8000; + // Has active vitals (probe connected and sending data) + const hasVitals = isLive && (dev.heart_rate != null || dev.spo2 != null || dev.temperature != null); + const hr = hasVitals && dev.heart_rate != null ? Math.round(dev.heart_rate) : '---'; + const spo2 = hasVitals && dev.spo2 != null ? Math.round(dev.spo2) : '---'; + const sys = hasVitals && dev.systolic_bp != null ? Math.round(dev.systolic_bp) : '--'; + const dia = hasVitals && dev.diastolic_bp != null ? Math.round(dev.diastolic_bp) : '--'; + const bp = (sys !== '--' || dia !== '--') ? `${sys}/${dia}` : '---'; + const temp = hasVitals && dev.temperature != null ? dev.temperature.toFixed(1) + '°C' : '---'; + const rr = hasVitals && dev.respiratory_rate != null ? Math.round(dev.respiratory_rate) : '---'; + const timeStr = dev.lastSeen ? dev.lastSeen.toLocaleTimeString('en-US', { hour12: false }) : '--'; + + const genderIcon = pGender ? (pGender.toLowerCase().startsWith('m') ? '♂ Male' : pGender.toLowerCase().startsWith('f') ? '♀ Female' : pGender) : ''; + + // Badge: LIVE (data flowing) | CONNECTED (monitor on, probe off) | DISCONNECTED + const statusBadge = isLive + ? (hasVitals + ? `Live ↗` + : `Connected`) + : `Disconnected`; + + html += ` +
+
+
+
${pName}
+
+ ${pMrn ? `MRN: ${pMrn}` : `No MRN assigned`} + ${pGender ? `${genderIcon}` : ''} + ${pDob ? `🎂 ${pDob}` : ''} +
+
+ 🏥 ${label} • ${id} +
+
+ ${statusBadge} +
+
+
+
❤️ HR
+
${hr}
+
+
+
🫁 SpO₂
+
${spo2}${spo2 !== '---' ? '%' : ''}
+
+
+
🩺 NIBP
+
${bp}
+
+
+
🌡️ Temp
+
${temp}
+
+
+
🫁 RR
+
${rr}
+
+
+
🩸 MAP
+
${dev.map_bp ? Math.round(dev.map_bp) : '---'}
+
+
+ +
+ `; + }); + + multiGrid.innerHTML = html; + } + + // === Update Vitals Display === + function updateVitals(data) { + if (!data) return; + + // Handle special event broadcasts like patient assignment + if (data.event === 'patient_assigned' && data.device_id) { + const devId = data.device_id; + connectedDevices[devId] = { + ...(connectedDevices[devId] || {}), + device_id: devId, + patient_id: data.mrn || data.patient_id, + mrn: data.mrn || data.patient_id, + patient_name: data.patient_name || data.name, + name: data.patient_name || data.name, + patient_gender: data.patient_gender || data.gender, + gender: data.patient_gender || data.gender, + patient_dob: data.patient_dob || data.dob, + dob: data.patient_dob || data.dob, + bed_id: data.bed_id, + bedLabel: data.bed_id || connectedDevices[devId]?.bedLabel + }; + updateDeviceTabs(); + if (hasTargetFilter) { + if (matchesTargetPatient(connectedDevices[devId], devId)) { + activeDeviceId = devId; + renderSingleDevice(connectedDevices[devId]); + } + } else { + if (viewMode === 'grid') renderMultiBedGrid(); + else if (viewMode === 'single' && activeDeviceId === devId) { + renderSingleDevice(connectedDevices[devId]); + } + } + return; + } + + if (!data.device_id) return; + + // Hide setup guide once data starts flowing + if (!hasReceivedData) { + hasReceivedData = true; + const guide = document.getElementById('setupGuide'); + if (guide) guide.classList.add('hidden'); + } + + const devId = data.device_id; + const existing = connectedDevices[devId] || {}; + + if (!existing._vitalTimestamps) existing._vitalTimestamps = {}; + const retainVital = (field, maxAgeMs) => { + const newVal = data[field]; + if (newVal !== null && newVal !== undefined) { + existing._vitalTimestamps[field] = Date.now(); + return newVal; + } + const lastTime = existing._vitalTimestamps[field]; + if (existing[field] !== null && existing[field] !== undefined && lastTime && (Date.now() - lastTime < maxAgeMs)) { + return existing[field]; + } + return null; + }; + + connectedDevices[devId] = { + ...existing, + ...data, + heart_rate: retainVital('heart_rate', 60000), + spo2: retainVital('spo2', 60000), + temperature: retainVital('temperature', 60000), + respiratory_rate: retainVital('respiratory_rate', 60000), + systolic_bp: retainVital('systolic_bp', 3600000), + diastolic_bp: retainVital('diastolic_bp', 3600000), + map_bp: retainVital('map_bp', 3600000), + patient_id: (data.mrn && data.mrn !== 'UNKNOWN') ? data.mrn : (existing.patient_id || 'UNKNOWN'), + mrn: (data.mrn && data.mrn !== 'UNKNOWN') ? data.mrn : (existing.mrn || 'UNKNOWN'), + patient_name: (data.patient_name && data.patient_name !== 'Unknown') ? data.patient_name : (existing.patient_name || null), + patient_gender: data.patient_gender || existing.patient_gender || null, + patient_dob: data.patient_dob || existing.patient_dob || null, + bed_id: data.bed_id || existing.bed_id || null, + lastSeen: new Date(), + bedLabel: data.bed_id || existing.bed_id || existing.bedLabel || getBedLabel(devId, data.ip_address), + _fromDb: false, // confirmed live via WebSocket + _liveData: true // this device is actively transmitting + }; + + const isTargetMatch = !hasTargetFilter || matchesTargetPatient(connectedDevices[devId], devId); + + if (isTargetMatch) { + if (!activeDeviceId || hasTargetFilter) { + activeDeviceId = devId; + } + } + + updateDeviceTabs(); + + if (isTargetMatch && hasTargetFilter) { + // Target patient matched — force single view and render immediately + // (don't wait for fetchInitialDevicesAndPatients async race) + if (viewMode !== 'single') setViewMode('single'); + renderSingleDevice(connectedDevices[devId]); + } else if (viewMode === 'grid') { + renderMultiBedGrid(); + } else if (viewMode === 'single') { + if (isTargetMatch && activeDeviceId === devId) { + renderSingleDevice(connectedDevices[devId]); + } else if (hasTargetFilter && !activeDeviceId) { + renderSingleDevice(null); + } + } + } + + function renderSingleDevice(data) { + const waitingState = document.getElementById('singleTargetWaitingState'); + const patientBanner = document.getElementById('singlePatientBanner'); + const vitalsGrid = document.getElementById('singleVitalsGrid'); + + if (hasTargetFilter) { + const isMatch = data && matchesTargetPatient(data, data.device_id); + if (!isMatch) { + if (waitingState) { + waitingState.style.display = 'flex'; + const badge = document.getElementById('targetWaitingMrnBadge'); + if (badge) badge.innerHTML = `Target Patient: ${getTargetPatientBadge()}`; + } + if (patientBanner) patientBanner.style.display = 'none'; + if (vitalsGrid) vitalsGrid.style.display = 'none'; + return; + } + } + + if (waitingState) waitingState.style.display = 'none'; + if (patientBanner) patientBanner.style.display = 'flex'; + if (vitalsGrid) vitalsGrid.style.display = 'grid'; + + if (!data) return; + // 1. Update Patient Banner + const hasAssigned = data.patient_name && data.patient_name !== 'Unknown' && data.patient_name !== 'UNKNOWN' && data.patient_name !== 'Unassigned Patient'; + const label = data.bedLabel || data.bed_id || getBedLabel(data.device_id, data.ip_address); + const pName = hasAssigned ? data.patient_name : `${label} (No Patient Assigned)`; + const pMrn = (data.patient_id && data.patient_id !== 'UNKNOWN' && data.patient_id !== 'UNASSIGNED') ? data.patient_id : '---'; + const pGender = (data.patient_gender && data.patient_gender !== 'Unknown') ? data.patient_gender : '---'; + const pDob = (data.patient_dob && data.patient_dob !== 'Unknown') ? data.patient_dob : '---'; + const pBed = data.bed_id || label; + + const bannerEl = document.getElementById('singlePatientBanner'); + if (bannerEl) { + document.getElementById('bannerPatientName').textContent = pName; + document.getElementById('bannerMrn').textContent = `MRN: ${pMrn}`; + document.getElementById('bannerGender').textContent = `Gender: ${pGender}`; + document.getElementById('bannerDob').textContent = `DOB: ${pDob}`; + document.getElementById('bannerBed').textContent = `Location: ${pBed}`; + } + + // 2. Update Vital Cards + updateVitalCard('hr', data.heart_rate, 'heart_rate', v => `${Math.round(v)}bpm`); + updateVitalCard('spo2', data.spo2, 'spo2', v => `${Math.round(v)}%`); + + // Blood pressure - combined display + if (data.systolic_bp !== null && data.systolic_bp !== undefined) { + const sys = Math.round(data.systolic_bp); + const dia = data.diastolic_bp !== null ? Math.round(data.diastolic_bp) : '--'; + updateVitalCard('bp', data.systolic_bp, 'systolic_bp', + () => `${sys}/${dia}mmHg`); + } else { + updateVitalCard('bp', null, 'systolic_bp'); + } + + updateVitalCard('rr', data.respiratory_rate, 'respiratory_rate', v => `${Math.round(v)}/min`); + updateVitalCard('temp', data.temperature, 'temperature', v => `${v.toFixed(1)}°C`); + updateVitalCard('map', data.map_bp, 'map_bp', v => `${Math.round(v)}mmHg`); + + // Update info bar + if (data.device_id) document.getElementById('deviceId').textContent = `${label} (${data.device_id})`; + document.getElementById('patientId').textContent = hasAssigned ? `${pName} (${pMrn})` : 'Not Assigned'; + + const now = new Date(); + document.getElementById('lastUpdateTime').textContent = + now.toLocaleTimeString('en-US', { hour12: false }); + + // Push waveform data + pushWaveformPoint('hr', data.heart_rate); + pushWaveformPoint('spo2', data.spo2); + pushWaveformPoint('bp', data.systolic_bp); + pushWaveformPoint('rr', data.respiratory_rate); + pushWaveformPoint('temp', data.temperature); + pushWaveformPoint('map', data.map_bp); + } + + // === Modal 1: Assign / Edit Patient Logic === + async function openAssignPatientModal(deviceId) { + const modal = document.getElementById('modalAssignPatient'); + const select = document.getElementById('assignDeviceSelect'); + select.innerHTML = ''; + + // Fetch live devices from API or connected state + let devIds = Object.keys(connectedDevices); + try { + const resp = await fetch(`${API_BASE}/devices`); + if (resp.ok) { + const dbDevs = await resp.json(); + dbDevs.forEach(d => { + if (!devIds.includes(d.device_id)) devIds.push(d.device_id); + }); + } + } catch (e) {} + + devIds.forEach(id => { + const dev = connectedDevices[id] || {}; + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = `${dev.bedLabel || id} — ${id}`; + select.appendChild(opt); + }); + + const targetId = deviceId || (devIds.length > 0 ? devIds[0] : select.value); + if (targetId) select.value = targetId; + + // Pre-fill existing demographics if known + const existing = connectedDevices[targetId]; + if (existing && existing.patient_name && existing.patient_name !== 'Unknown') { + document.getElementById('assignMrn').value = (existing.patient_id && existing.patient_id !== 'UNKNOWN') ? existing.patient_id : ''; + document.getElementById('assignName').value = existing.patient_name || ''; + document.getElementById('assignDob').value = existing.patient_dob || ''; + document.getElementById('assignGender').value = existing.patient_gender || 'Male'; + document.getElementById('assignBed').value = existing.bed_id || existing.bedLabel || ''; + } else { + document.getElementById('assignMrn').value = ''; + document.getElementById('assignName').value = ''; + document.getElementById('assignDob').value = ''; + document.getElementById('assignGender').value = 'Male'; + document.getElementById('assignBed').value = ''; + } + + modal.classList.add('active'); + } + + function closeAssignPatientModal() { + document.getElementById('modalAssignPatient').classList.remove('active'); + } + + async function submitAssignPatient(event) { + event.preventDefault(); + const btn = document.getElementById('btnSavePatient'); + btn.disabled = true; + btn.innerHTML = '⏳ Saving...'; + + const device_id = document.getElementById('assignDeviceSelect').value; + const mrn = document.getElementById('assignMrn').value.trim(); + const name = document.getElementById('assignName').value.trim(); + const dob = document.getElementById('assignDob').value; + const gender = document.getElementById('assignGender').value; + const bed_id = document.getElementById('assignBed').value.trim(); + + try { + const response = await fetch(`${API_BASE}/patient/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_id, mrn, name, dob, gender, bed_id }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Assignment failed'); + } + + // Update local device state immediately + connectedDevices[device_id] = { + ...(connectedDevices[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, + bedLabel: bed_id || connectedDevices[device_id]?.bedLabel + }; + + closeAssignPatientModal(); + updateDeviceTabs(); + if (viewMode === 'grid') renderMultiBedGrid(); + else if (viewMode === 'single' && activeDeviceId === device_id) { + renderSingleDevice(connectedDevices[device_id]); + } + } catch (err) { + alert(`Could not save patient: ${err.message}`); + } finally { + btn.disabled = false; + btn.innerHTML = '💾 Save & Link to Machine'; + } + } + + // === Modal 2: Fetch Patient Data & History Logic === + let currentFetchedReadings = []; + let currentFetchedPatient = null; + + function openPatientHistoryModal(identifier) { + const modal = document.getElementById('modalPatientHistory'); + const queryInput = document.getElementById('historySearchQuery'); + modal.classList.add('active'); + + if (identifier && identifier !== 'UNKNOWN') { + queryInput.value = identifier; + executePatientHistorySearch(); + } else { + queryInput.value = ''; + document.getElementById('historyPatientProfile').style.display = 'none'; + document.getElementById('historyTableBody').innerHTML = ` + + + Enter an MRN, Name, or DOB above and click "Search Patient" to fetch all recorded vitals. + + + `; + } + } + + function closePatientHistoryModal() { + document.getElementById('modalPatientHistory').classList.remove('active'); + } + + async function executePatientHistorySearch() { + const query = document.getElementById('historySearchQuery').value.trim(); + if (!query) return; + + const tbody = document.getElementById('historyTableBody'); + tbody.innerHTML = `⏳ Searching patient database...`; + + try { + let patientInfo = null; + let readings = []; + + const histResp = await fetch(`${API_BASE}/patient/${encodeURIComponent(query)}/history?limit=100`); + if (histResp.ok) { + const data = await histResp.json(); + patientInfo = data.patient; + readings = data.readings || []; + } else { + const readResp = await fetch(`${API_BASE}/readings?mrn=${encodeURIComponent(query)}&name=${encodeURIComponent(query)}&limit=100`); + if (readResp.ok) { + readings = await readResp.json(); + } + } + + currentFetchedReadings = readings; + currentFetchedPatient = patientInfo || { mrn: query, name: query }; + + const profileBanner = document.getElementById('historyPatientProfile'); + if (patientInfo) { + profileBanner.style.display = 'flex'; + document.getElementById('historyName').textContent = patientInfo.name || 'Unknown'; + document.getElementById('historyMrn').textContent = `MRN: ${patientInfo.mrn || '---'}`; + document.getElementById('historyGender').textContent = `Gender: ${patientInfo.gender || '---'}`; + document.getElementById('historyDob').textContent = `DOB: ${patientInfo.dob || '---'}`; + document.getElementById('historyBed').textContent = `Bed: ${patientInfo.bed_id || '---'}`; + document.getElementById('historyDevice').textContent = `Machine: ${patientInfo.device_id || '---'}`; + } else { + profileBanner.style.display = 'none'; + } + + if (readings.length === 0) { + tbody.innerHTML = ` + + + No vitals records found for "${query}". + + + `; + return; + } + + let rowsHtml = ''; + readings.forEach(r => { + const timeStr = r.timestamp ? new Date(r.timestamp).toLocaleString() : '--'; + const hr = r.heart_rate !== null ? Math.round(r.heart_rate) : '--'; + const spo2 = r.spo2 !== null ? `${Math.round(r.spo2)}%` : '--'; + const sys = r.systolic_bp !== null ? Math.round(r.systolic_bp) : '--'; + const dia = r.diastolic_bp !== null ? Math.round(r.diastolic_bp) : '--'; + const bp = (sys !== '--' || dia !== '--') ? `${sys}/${dia}` : '--'; + const rr = r.respiratory_rate !== null ? Math.round(r.respiratory_rate) : '--'; + const temp = r.temperature !== null ? `${r.temperature.toFixed(1)}°C` : '--'; + const map = r.map_bp !== null ? Math.round(r.map_bp) : '--'; + + rowsHtml += ` + + ${timeStr} + ${hr} + ${spo2} + ${bp} + ${rr} + ${temp} + ${map} + + `; + }); + tbody.innerHTML = rowsHtml; + } catch (err) { + tbody.innerHTML = ` + + + Error searching: ${err.message} + + + `; + } + } + + function exportHistoryToCsv() { + if (!currentFetchedReadings || currentFetchedReadings.length === 0) { + alert('No vitals data available to export.'); + return; + } + + const p = currentFetchedPatient || {}; + let csv = `Patient Vital Signs History\n`; + csv += `MRN,${p.mrn || ''}\n`; + csv += `Name,${p.name || ''}\n`; + csv += `DOB,${p.dob || ''}\n`; + csv += `Gender,${p.gender || ''}\n`; + csv += `Bed,${p.bed_id || ''}\n\n`; + csv += `Timestamp,Heart Rate (bpm),SpO2 (%),Systolic BP (mmHg),Diastolic BP (mmHg),RR (/min),Temp (C),MAP\n`; + + currentFetchedReadings.forEach(r => { + const t = r.timestamp || ''; + csv += `"${t}",${r.heart_rate || ''},${r.spo2 || ''},${r.systolic_bp || ''},${r.diastolic_bp || ''},${r.respiratory_rate || ''},${r.temperature || ''},${r.map_bp || ''}\n`; + }); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `vitals_${p.mrn || 'patient'}_${new Date().toISOString().slice(0,10)}.csv`; + link.click(); + } + + function updateVitalCard(cardKey, value, rangeKey, formatter) { + const valueEl = document.getElementById(`value-${cardKey}`); + const statusEl = document.getElementById(`status-${cardKey}`); + const updateEl = document.getElementById(`update-${cardKey}`); + const cardEl = document.getElementById(`card-${cardKey}`); + + if (value === null || value === undefined) { + valueEl.innerHTML = '---'; + valueEl.className = 'vital-value no-data'; + statusEl.textContent = 'No Data'; + statusEl.className = 'status-badge offline'; + cardEl.classList.remove('has-value'); + return; + } + + // Format value + const formatted = formatter ? formatter(value) : Math.round(value); + valueEl.innerHTML = formatted; + valueEl.className = 'vital-value vital-value-update'; + cardEl.classList.add('has-value'); + + // Remove animation class after it plays + setTimeout(() => valueEl.classList.remove('vital-value-update'), 600); + + // Calculate status + const ranges = VITAL_RANGES[rangeKey]; + let isCritical = false; + if (ranges) { + if (value <= ranges.critLow || value >= ranges.critHigh) { + statusEl.textContent = '🚨 Critical'; + statusEl.className = 'status-badge critical'; + isCritical = true; + } else if (value < ranges.low || value > ranges.high) { + statusEl.textContent = '⚠ Warning'; + statusEl.className = 'status-badge warning'; + } else { + statusEl.textContent = '✓ Normal'; + statusEl.className = 'status-badge normal'; + } + } + + // Play alert for critical values + if (isCritical) { + playAlertBeep(); + } + + // Update timestamp + const now = new Date(); + updateEl.textContent = now.toLocaleTimeString('en-US', { hour12: false }); + } + + // === Connection Status === + function updateConnectionStatus(status, text) { + const badge = document.getElementById('connectionBadge'); + const textEl = document.getElementById('connectionText'); + textEl.textContent = text; + + badge.className = 'connection-badge'; + if (status === 'offline') badge.classList.add('offline'); + else if (status === 'waiting') badge.classList.add('warning'); + // 'connected' uses default (green) + } + + // === Waveform Drawing === + function initWaveforms() { + ['hr', 'spo2', 'bp', 'rr', 'temp', 'map'].forEach(key => { + const canvas = document.getElementById(`waveform-${key}`); + if (canvas) { + const ctx = canvas.getContext('2d'); + // Set canvas resolution + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width * 2; + canvas.height = rect.height * 2; + ctx.scale(2, 2); + waveformCanvases[key] = { canvas, ctx, width: rect.width, height: rect.height }; + waveformData[key] = []; + } + }); + } + + function pushWaveformPoint(key, value) { + if (!waveformData[key]) waveformData[key] = []; + waveformData[key].push(value !== null && value !== undefined ? value : null); + if (waveformData[key].length > 100) waveformData[key].shift(); + } + + const WAVEFORM_COLORS = { + hr: '#ef4444', spo2: '#06b6d4', bp: '#a855f7', + rr: '#22c55e', temp: '#f97316', map: '#ec4899' + }; + + function drawWaveform(key) { + const wf = waveformCanvases[key]; + if (!wf) return; + + const { ctx, width, height } = wf; + const data = waveformData[key] || []; + const color = WAVEFORM_COLORS[key]; + + ctx.clearRect(0, 0, width, height); + + if (data.length < 2) { + // Draw flat line + ctx.beginPath(); + ctx.strokeStyle = `${color}33`; + ctx.lineWidth = 1; + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + return; + } + + // Filter valid values for min/max + const validValues = data.filter(v => v !== null); + if (validValues.length === 0) return; + + const min = Math.min(...validValues) * 0.9; + const max = Math.max(...validValues) * 1.1; + const range = max - min || 1; + + // Draw gradient fill + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, `${color}20`); + gradient.addColorStop(1, `${color}00`); + + ctx.beginPath(); + ctx.moveTo(0, height); + + let firstPoint = true; + const step = width / (data.length - 1); + + data.forEach((v, i) => { + if (v === null) return; + const x = i * step; + const y = height - ((v - min) / range) * (height * 0.8) - height * 0.1; + if (firstPoint) { + ctx.moveTo(x, height); + ctx.lineTo(x, y); + firstPoint = false; + } else { + ctx.lineTo(x, y); + } + }); + + // Close fill path + const lastValidIdx = data.length - 1 - [...data].reverse().findIndex(v => v !== null); + ctx.lineTo(lastValidIdx * step, height); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // Draw line + ctx.beginPath(); + firstPoint = true; + data.forEach((v, i) => { + if (v === null) return; + const x = i * step; + const y = height - ((v - min) / range) * (height * 0.8) - height * 0.1; + if (firstPoint) { + ctx.moveTo(x, y); + firstPoint = false; + } else { + ctx.lineTo(x, y); + } + }); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Glow effect + ctx.strokeStyle = `${color}40`; + ctx.lineWidth = 6; + ctx.stroke(); + + // Draw latest point dot + if (validValues.length > 0) { + const lastVal = validValues[validValues.length - 1]; + const lastIdx = data.lastIndexOf(lastVal); + const x = lastIdx * step; + const y = height - ((lastVal - min) / range) * (height * 0.8) - height * 0.1; + + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.beginPath(); + ctx.arc(x, y, 8, 0, Math.PI * 2); + ctx.fillStyle = `${color}30`; + ctx.fill(); + } + } + + let waveformPhase = 0; + + function generateIdleWaveform(key, type) { + const data = waveformData[key] || []; + if (data.some(v => v !== null)) return; // Only show idle for no-data + + // Generate synthetic waveform for visual appeal + const points = 100; + waveformData[key] = []; + for (let i = 0; i < points; i++) { + const t = (i / points) * Math.PI * 4 + waveformPhase; + let val; + if (type === 'ecg') { + // ECG-like pattern + const phase = (t % (Math.PI * 2)) / (Math.PI * 2); + if (phase > 0.35 && phase < 0.40) val = 80 + 40 * Math.sin((phase - 0.35) * Math.PI / 0.05); + else if (phase > 0.40 && phase < 0.45) val = 80 - 15 * Math.sin((phase - 0.40) * Math.PI / 0.05); + else val = 72 + Math.sin(t * 0.5) * 2; + } else if (type === 'sine') { + val = 50 + Math.sin(t) * 20; + } else { + val = 50 + Math.sin(t * 0.8) * 15 + Math.sin(t * 2.1) * 5; + } + waveformData[key].push(val); + } + } + + function animateWaveforms() { + waveformPhase += 0.05; + + // Generate idle waveforms for cards with no data + generateIdleWaveform('hr', 'ecg'); + generateIdleWaveform('spo2', 'sine'); + generateIdleWaveform('bp', 'sine'); + generateIdleWaveform('rr', 'sine'); + generateIdleWaveform('temp', 'sine'); + generateIdleWaveform('map', 'sine'); + + Object.keys(waveformCanvases).forEach(drawWaveform); + requestAnimationFrame(animateWaveforms); + } + + // === Handle window resize === + window.addEventListener('resize', () => { + initWaveforms(); + }); +