diff --git a/fiveparaminte-main/config.json b/fiveparaminte-main/config.json index 13d28c4..115508f 100644 --- a/fiveparaminte-main/config.json +++ b/fiveparaminte-main/config.json @@ -3,7 +3,10 @@ "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": "CMS7000PLUS", + "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, diff --git a/fiveparaminte-main/config.py b/fiveparaminte-main/config.py index 3b9c60a..eb095e8 100644 --- a/fiveparaminte-main/config.py +++ b/fiveparaminte-main/config.py @@ -8,6 +8,7 @@ class Settings(BaseSettings): 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 @@ -18,14 +19,27 @@ class Settings(BaseSettings): env_file = ".env" def get_settings() -> Settings: - config_file = "config.json" + 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) - return Settings(**data) + for k, v in data.items(): + setattr(settings_obj, k, v) except json.JSONDecodeError: pass - return Settings() + + 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/contec_parser.py b/fiveparaminte-main/contec_parser.py index 106e5c1..e301f81 100644 --- a/fiveparaminte-main/contec_parser.py +++ b/fiveparaminte-main/contec_parser.py @@ -4,6 +4,7 @@ from typing import Optional, List import struct from schemas import NormalizedVitals +from config import settings logger = logging.getLogger(__name__) @@ -79,7 +80,7 @@ def safe_float(value: str) -> Optional[float]: def is_valid_u16(val: Optional[int]) -> bool: - return val is not None and val != INVALID_SENTINEL_U16 + return val is not None and val != INVALID_SENTINEL_U16 and val != 255 and val != 65535 def is_valid_f32(val: Optional[float]) -> bool: @@ -118,14 +119,16 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV 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"CMS7000_{client_ip}" + 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 @@ -145,7 +148,6 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV elif seg_name == "OBX": if len(fields) > 5: - obs_id = fields[3].split("^")[0].strip().upper() obs_val = fields[5].strip() if not obs_val or obs_val == "---": @@ -154,12 +156,20 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV 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(): - if obs_id == param_key or param_key in obs_id: - vitals_dict[mapped_field] = val_float - found_any = True - logger.info(f"Parsed Contec HL7 Vital: {mapped_field} = {val_float} (from {obs_id})") + 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 @@ -192,10 +202,12 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[ 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"CMS7000PLUS_{client_ip}", + "device_id": f"{model}_{client_ip}", "patient_id": "UNKNOWN", "timestamp": datetime.now(timezone.utc), + "ip_address": client_ip, } found = False @@ -219,113 +231,271 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[ return NormalizedVitals(**vitals_dict) if found else None -def _parse_float_vitals(data: bytes, base_offset: int, vitals_dict: dict) -> bool: +def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool: """ - Parse float32 vitals (NIBP, Temperature) from packet starting at base_offset. - Returns True if any valid vitals found. + 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 - nibp_sys = read_f32_le(data, base_offset) - nibp_dia = read_f32_le(data, base_offset + 4) - nibp_map = read_f32_le(data, base_offset + 8) - temp1 = read_f32_le(data, base_offset + 12) - temp2 = read_f32_le(data, base_offset + 16) + # 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 is_valid_f32(nibp_sys) and 50.0 < nibp_sys < 300.0: - vitals_dict["systolic_bp"] = round(nibp_sys, 1) + 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 is_valid_f32(nibp_dia) and 20.0 < nibp_dia < 200.0: - vitals_dict["diastolic_bp"] = round(nibp_dia, 1) + 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 is_valid_f32(nibp_map) and 20.0 < nibp_map < 250.0: - vitals_dict["map_bp"] = round(nibp_map, 1) + 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 - if temp1 is not None and is_valid_f32(temp1) and 30.0 < temp1 < 45.0: + 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 30.0 < temp2 < 45.0: + 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: + [266-267] PR (LE u16) — pulse rate + [268-269] SpO2% (LE u16) — live oxygen saturation percentage + """ + 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 268: SpO2 percentage + spo2_val = read_u16_le(data, 268) + 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. + Scan the data stream for Contec CMS7000PLUS sub-packets and parse them + deterministically based on packet length. - Contec packets follow this framing pattern: - [len_lo] [len_hi] [04] [46] [sub_type] [00] [sub_len_lo] [00] ...payload... - - OR the data is a concatenation of multiple sub-packets separated by - [xx][xx][04][47] (end marker?) - - The key is to find [04][46] marker bytes and parse the packet. + 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"CMS7000PLUS_{client_ip}", + "device_id": f"{model}_{client_ip}", "patient_id": "UNKNOWN", "timestamp": datetime.now(timezone.utc), + "ip_address": client_ip, } found_any = False - # Scan through the buffer looking for [04 46] packet markers i = 0 while i < len(data) - 8: - # Look for the [04][46] marker which is the packet type indicator - if data[i + 2] == 0x04 and data[i + 3] == 0x46: - pkt_len = struct.unpack_from(' 50: - # The summary block is consistently found around offset 322 in 397-byte packets - # Relative to packet start: around pkt_len - 75 - summary_offset = i + max(4, pkt_len - 80) - if summary_offset + 10 < len(data): - # Scan in the last ~100 bytes of the packet for SpO2 pattern - scan_end = min(i + pkt_len, len(data) - 4) - scan_start = max(i + 4, scan_end - 100) - for j in range(scan_start, scan_end - 4, 2): - spo2 = read_u16_le(data, j) - hr = read_u16_le(data, j + 2) - pr = read_u16_le(data, j + 4) - - # Check if this looks like [SpO2][HR/sentinel][PR] - spo2_ok = spo2 is not None and 50 <= spo2 <= 100 - # HR can be 9999 (invalid/ECG disconnected) or valid 20-300 - hr_or_sentinel = hr is not None and (hr == INVALID_SENTINEL_U16 or 20 <= hr <= 300) - pr_ok = pr is not None and (pr == INVALID_SENTINEL_U16 or 20 <= pr <= 300) - - if spo2_ok and hr_or_sentinel and pr_ok: - vitals_dict["spo2"] = float(spo2) - found_any = True - - # Use ECG HR if valid, else use SpO2 PR - if hr is not None and hr != INVALID_SENTINEL_U16 and 20 <= hr <= 300: - vitals_dict["heart_rate"] = float(hr) - elif pr is not None and pr != INVALID_SENTINEL_U16 and 20 <= pr <= 300: - vitals_dict["heart_rate"] = float(pr) - - logger.info( - f"Parsed vitals block at offset {j}: " - f"SpO2={spo2}, HR={hr}, PR={pr}" - ) - break - - # Move past this packet i += max(pkt_len, 4) else: i += 1 @@ -365,8 +535,10 @@ def parse_contec_data(raw_data: bytes, client_ip: str) -> Optional[NormalizedVit vitals = parse_contec_hl7_text(text, client_ip) if vitals: return vitals - except Exception: - pass + 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) diff --git a/fiveparaminte-main/contec_server.py b/fiveparaminte-main/contec_server.py index 7412f48..389aa9a 100644 --- a/fiveparaminte-main/contec_server.py +++ b/fiveparaminte-main/contec_server.py @@ -65,12 +65,15 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str break buffer += data + # Temporary debug capture of raw binary stream + with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "ab") as f: + f.write(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: + 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) @@ -102,7 +105,7 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str b2 = buffer[consumed + 2] b3 = buffer[consumed + 3] - if b2 == 0x04 and b3 == 0x46: + if (b2 == 0x04 or b2 == 0x01 or b2 == 0x00) and b3 == 0x46: pkt_len = b0 | (b1 << 8) pkt_end = consumed + pkt_len @@ -129,7 +132,7 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str consumed = pkt_end - elif b2 == 0x04 and b3 == 0x47: + elif (b2 == 0x04 or b2 == 0x01) and b3 == 0x47: # End-of-frame marker — skip 4 bytes consumed += 4 @@ -161,13 +164,63 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str 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. """ - display = get_terminal_display() + device_id = vitals.device_id + now = datetime.now(timezone.utc) - # Update console display banner + 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( @@ -177,7 +230,7 @@ async def process_vitals(vitals): f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}" ) - # Broadcast to live WebSockets dashboard + # 3. Broadcast to live WebSockets dashboard in real-time try: from routers.dashboard import get_ws_manager ws_manager = get_ws_manager() @@ -185,63 +238,78 @@ async def process_vitals(vitals): except Exception as e: logger.debug(f"WebSocket broadcast failed: {e}") - # Save to SQLite database - db = SessionLocal() - try: - # 1. Device tracking - device = db.query(Device).filter(Device.device_id == vitals.device_id).first() - if not device: - device = Device(device_id=vitals.device_id, ip_address="unknown") - db.add(device) - else: - device.last_seen = datetime.now(timezone.utc) - - # 2. Patient tracking - patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() - if not patient: - patient = Patient(patient_id=vitals.patient_id, name="Unknown") - db.add(patient) - - # 3. Create Reading Entry - reading = VitalReading( - device_id=vitals.device_id, - patient_id=vitals.patient_id, - timestamp=vitals.timestamp, - heart_rate=vitals.heart_rate, - spo2=vitals.spo2, - systolic_bp=vitals.systolic_bp, - diastolic_bp=vitals.diastolic_bp, - map_bp=vitals.map_bp, - respiratory_rate=vitals.respiratory_rate, - temperature=vitals.temperature, - transmitted=False - ) - db.add(reading) - db.commit() - db.refresh(reading) - - # 4. REST forwarding - success = await forward_vitals_to_api(vitals) - - if success: - reading.transmitted = True - log_entry = TransmissionLog( - reading_id=reading.id, - status="success", - response_code=200 + # 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 ) - 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() + 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/main.py b/fiveparaminte-main/main.py index ce54c2f..1bf42ef 100644 --- a/fiveparaminte-main/main.py +++ b/fiveparaminte-main/main.py @@ -91,18 +91,22 @@ async def lifespan(app: FastAPI): 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:8000/api/dashboard") - print(f" API Docs: http://localhost:8000/docs") + 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}") @@ -114,7 +118,7 @@ async def lifespan(app: FastAPI): 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 = 511") + 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 @@ -165,5 +169,7 @@ if __name__ == "__main__": logger.info("Initializing Application...") # Run the FastAPI server. - # Notice we run on port 8000 for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060) - uvicorn.run(app, host="0.0.0.0", port=8000) + # 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/requirements.txt b/fiveparaminte-main/requirements.txt index 58c658e..637ff8a 100644 --- a/fiveparaminte-main/requirements.txt +++ b/fiveparaminte-main/requirements.txt @@ -7,3 +7,4 @@ httpx pyinstaller rich pyserial +websockets diff --git a/fiveparaminte-main/routers/dashboard.py b/fiveparaminte-main/routers/dashboard.py index bd68c70..8be7957 100644 --- a/fiveparaminte-main/routers/dashboard.py +++ b/fiveparaminte-main/routers/dashboard.py @@ -3,8 +3,9 @@ import json import logging import socket from typing import List +from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from database import get_db @@ -69,27 +70,50 @@ def health_check(): return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"} @router.get("/status") -def get_system_status(db: Session = Depends(get_db)): +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 + "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(limit: int = 10, db: Session = Depends(get_db)): - readings = db.query(VitalReading).order_by(VitalReading.timestamp.desc()).limit(limit).all() +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") diff --git a/fiveparaminte-main/schemas.py b/fiveparaminte-main/schemas.py index ea135ad..6a8e69e 100644 --- a/fiveparaminte-main/schemas.py +++ b/fiveparaminte-main/schemas.py @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,7 @@ class NormalizedVitals(BaseModel): 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 diff --git a/fiveparaminte-main/scratch/analysis_findings.py b/fiveparaminte-main/scratch/analysis_findings.py new file mode 100644 index 0000000..5d57805 --- /dev/null +++ b/fiveparaminte-main/scratch/analysis_findings.py @@ -0,0 +1,117 @@ +# 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 new file mode 100644 index 0000000..946c21a --- /dev/null +++ b/fiveparaminte-main/scratch/analyze_bp.py @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..11570ee --- /dev/null +++ b/fiveparaminte-main/scratch/analyze_logs.py @@ -0,0 +1,39 @@ +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_db_devices.py b/fiveparaminte-main/scratch/check_db_devices.py new file mode 100644 index 0000000..3fde8b7 --- /dev/null +++ b/fiveparaminte-main/scratch/check_db_devices.py @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..d40c8aa --- /dev/null +++ b/fiveparaminte-main/scratch/dissect_45.py @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..f58ef0a --- /dev/null +++ b/fiveparaminte-main/scratch/dissect_56.py @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..fd75bf1 --- /dev/null +++ b/fiveparaminte-main/scratch/dissect_packets.py @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..0264870 --- /dev/null +++ b/fiveparaminte-main/scratch/dump_packets.py @@ -0,0 +1,78 @@ +""" +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 new file mode 100644 index 0000000..829efca --- /dev/null +++ b/fiveparaminte-main/scratch/find_pr_offset.py @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..c26065b --- /dev/null +++ b/fiveparaminte-main/scratch/inspect_raw_tail.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..50966ef --- /dev/null +++ b/fiveparaminte-main/scratch/mock_monitor.py @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..f0e2b45 --- /dev/null +++ b/fiveparaminte-main/scratch/mock_multi_monitors.py @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..f50eb02 Binary files /dev/null and b/fiveparaminte-main/scratch/raw_contec_stream.bin differ diff --git a/fiveparaminte-main/scratch/reconstruct_packets.py b/fiveparaminte-main/scratch/reconstruct_packets.py new file mode 100644 index 0000000..fb1b478 --- /dev/null +++ b/fiveparaminte-main/scratch/reconstruct_packets.py @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..51913e7 --- /dev/null +++ b/fiveparaminte-main/scratch/search_vitals.py @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..a0571d5 --- /dev/null +++ b/fiveparaminte-main/scratch/search_vitals_989.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..77b7aa5 --- /dev/null +++ b/fiveparaminte-main/scratch/search_vitals_range.py @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..60225bc --- /dev/null +++ b/fiveparaminte-main/scratch/test_parser.py @@ -0,0 +1,114 @@ +""" +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 new file mode 100644 index 0000000..189584a --- /dev/null +++ b/fiveparaminte-main/scratch/test_updated_parser.py @@ -0,0 +1,50 @@ +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/static/dashboard.html b/fiveparaminte-main/static/dashboard.html index 54755bd..758a73c 100644 --- a/fiveparaminte-main/static/dashboard.html +++ b/fiveparaminte-main/static/dashboard.html @@ -3,8 +3,8 @@ - Patient Vital Signs Monitor — Contec CMS7000PLUS - + Patient Vital Signs Monitor — Contec CMS7000PLUS / CMS8500 + @@ -224,6 +224,37 @@ color: var(--text-secondary); } + /* === Device Selector Styles === */ + .device-selector-container { + display: flex; + align-items: center; + gap: 8px; + margin-right: 8px; + } + + .device-select-dropdown { + background: rgba(17, 24, 39, 0.8); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + outline: none; + cursor: pointer; + transition: all 0.3s; + } + + .device-select-dropdown:hover { + border-color: var(--spo2-color); + background: rgba(25, 34, 56, 0.9); + } + + .device-select-dropdown option { + background-color: var(--bg-secondary); + color: var(--text-primary); + } + /* === Setup Guide Panel === */ .setup-guide { background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06)); @@ -699,10 +730,16 @@
🏥
Patient Vital Signs Monitor
-
Contec CMS7000PLUS — Real-time Monitoring
+
Contec Monitor — Real-time Monitoring
+
+ + +
🔇 Alerts Off @@ -720,7 +757,7 @@
📡 - CMS7000PLUS Setup Guide — Configure Your Monitor + Contec CMS7000PLUS / CMS8500 Setup Guide — Configure Your Monitor
▾ Collapse
@@ -728,43 +765,43 @@
1
-

Connect via Ethernet

-

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

+

Connect via Wi-Fi or Ethernet

+

Wi-Fi: Connect the monitor (e.g. CMS8500) and your PC to the same Wi-Fi network.
Ethernet: Connect using an Ethernet cable (direct or 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.

+

Get PC IP Address

+

Find your PC's IP address on the network (shown in the box below). If using Wi-Fi, use your PC's Wi-Fi IP address.

3

Configure Monitor CMS Settings

-

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

+

On your monitor: Go to System Setup → Network → CMS Settings. Set Server IP (or CMS IP) to your PC's IP address.

4

Set Server Port

-

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

+

Set Server Port on your monitor to one of the listening ports: Detecting... (usually 511 or 518).

5

Enable CMS Connection

-

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

+

Enable the CMS/Central Monitor connection in the monitor settings. Set the sending interval (e.g. 5 seconds) to start transmitting.

6

Data Will Appear Automatically

-

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

+

Once connected, the monitor will show up in the "Select Monitor" list, and its vitals will display in real-time.

@@ -970,15 +1007,22 @@ let setupGuideCollapsed = false; let hasReceivedData = false; + let activeDevices = []; + let selectedDeviceId = localStorage.getItem('selectedDeviceId') || ''; + // === Initialize === document.addEventListener('DOMContentLoaded', () => { initWaveforms(); updateClock(); setInterval(updateClock, 1000); - tryWebSocket(); - startPolling(); + fetchDevices().then(() => { + tryWebSocket(); + startPolling(); + }); animateWaveforms(); fetchNetworkInfo(); + // Periodically refresh the list of active devices + setInterval(fetchDevices, 4000); }); // === Fetch Network Info === @@ -1005,6 +1049,98 @@ } } + // === Fetch Devices List === + async function fetchDevices() { + try { + const response = await fetch(`${API_BASE}/devices`); + if (response.ok) { + const devices = await response.json(); + activeDevices = devices; + updateDeviceSelector(); + } + } catch (e) { + console.error("Error fetching devices:", e); + } + } + + // === Update Device Dropdown Selector === + function updateDeviceSelector() { + const selector = document.getElementById('deviceSelector'); + const previousSelection = selector.value || selectedDeviceId; + + selector.innerHTML = ''; + + if (activeDevices.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No monitors connected'; + selector.appendChild(opt); + return; + } + + // Sort: active devices first, then offline + const sortedDevices = [...activeDevices].sort((a, b) => { + if (a.status === 'active' && b.status !== 'active') return -1; + if (a.status !== 'active' && b.status === 'active') return 1; + return new Date(b.last_seen) - new Date(a.last_seen); + }); + + sortedDevices.forEach(d => { + const opt = document.createElement('option'); + opt.value = d.device_id; + + const statusSymbol = d.status === 'active' ? '🟢' : '⚫'; + const ipStr = d.ip_address && d.ip_address !== 'unknown' ? ` (${d.ip_address})` : ''; + opt.textContent = `${statusSymbol} ${d.device_id}${ipStr}`; + + selector.appendChild(opt); + }); + + if (previousSelection && sortedDevices.some(d => d.device_id === previousSelection)) { + selector.value = previousSelection; + selectedDeviceId = previousSelection; + } else { + const firstActive = sortedDevices.find(d => d.status === 'active'); + if (firstActive) { + selector.value = firstActive.device_id; + selectedDeviceId = firstActive.device_id; + } else if (sortedDevices.length > 0) { + selector.value = sortedDevices[0].device_id; + selectedDeviceId = sortedDevices[0].device_id; + } + } + + localStorage.setItem('selectedDeviceId', selectedDeviceId); + } + + // === Change Selected Device === + function selectDevice(deviceId) { + selectedDeviceId = deviceId; + localStorage.setItem('selectedDeviceId', deviceId); + clearVitalsDisplay(); + fetchLatestReadings(); + } + + // === Clear Vitals from Screen === + function clearVitalsDisplay() { + ['hr', 'spo2', 'bp', 'rr', 'temp', 'map'].forEach(key => { + const valueEl = document.getElementById(`value-${key}`); + const statusEl = document.getElementById(`status-${key}`); + const cardEl = document.getElementById(`card-${key}`); + + valueEl.innerHTML = '---'; + valueEl.className = 'vital-value no-data'; + statusEl.textContent = 'No Data'; + statusEl.className = 'status-badge offline'; + cardEl.classList.remove('has-value'); + + waveformData[key] = []; + }); + document.getElementById('deviceId').textContent = 'Waiting...'; + document.getElementById('patientId').textContent = 'Waiting...'; + lastReadingId = null; + } + // === Setup Guide Toggle === function toggleSetupGuide() { const body = document.getElementById('setupGuideBody'); @@ -1086,7 +1222,15 @@ }; ws.onmessage = (event) => { const data = JSON.parse(event.data); - updateVitals(data); + // Filter messages by selected device or auto-select if nothing selected yet + if (!selectedDeviceId && data.device_id) { + selectDevice(data.device_id); + } + if (data.device_id === selectedDeviceId) { + updateVitals(data); + } + // Refresh device list status + fetchDevices(); }; ws.onclose = () => { useWebSocket = false; @@ -1109,7 +1253,10 @@ async function fetchLatestReadings() { try { - const response = await fetch(`${API_BASE}/latest-readings?limit=1`); + const url = selectedDeviceId + ? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1` + : `${API_BASE}/latest-readings?limit=1`; + const response = await fetch(url); if (!response.ok) throw new Error('API error'); const readings = await response.json(); @@ -1138,6 +1285,12 @@ status.pending_transmissions > 0 ? `${status.pending_transmissions} pending` : 'All sent ✓'; + + // Update active ports list in guide + const portsEl = document.getElementById('activePortsList'); + if (portsEl && status.active_ports && status.active_ports.length > 0) { + portsEl.textContent = status.active_ports.join(', '); + } } } catch (e) { if (!useWebSocket) {