refactor: implement packet parsing heuristics and introduce device-level state caching to throttle database writes and vitals updates
This commit is contained in:
@@ -3,7 +3,10 @@
|
|||||||
"hl7_port": 6060,
|
"hl7_port": 6060,
|
||||||
"contec_ports": [511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 8001, 8002, 8300, 9000, 9100, 9200, 10008, 12345],
|
"contec_ports": [511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 8001, 8002, 8300, 9000, 9100, 9200, 10008, 12345],
|
||||||
"monitor_ip": "",
|
"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",
|
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3",
|
||||||
"api_token": "",
|
"api_token": "",
|
||||||
"retry_interval_seconds": 60,
|
"retry_interval_seconds": 60,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
|
|||||||
contec_ports: list = [511, 515, 516, 517, 518, 519, 520]
|
contec_ports: list = [511, 515, 516, 517, 518, 519, 520]
|
||||||
monitor_ip: str = ""
|
monitor_ip: str = ""
|
||||||
monitor_model: str = "CMS7000PLUS"
|
monitor_model: str = "CMS7000PLUS"
|
||||||
|
device_models: dict = {}
|
||||||
target_api_url: str = "https://api.example.com/vitals"
|
target_api_url: str = "https://api.example.com/vitals"
|
||||||
api_token: str = ""
|
api_token: str = ""
|
||||||
retry_interval_seconds: int = 60
|
retry_interval_seconds: int = 60
|
||||||
@@ -18,14 +19,27 @@ class Settings(BaseSettings):
|
|||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
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):
|
if os.path.exists(config_file):
|
||||||
with open(config_file, "r") as f:
|
with open(config_file, "r") as f:
|
||||||
try:
|
try:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return Settings(**data)
|
for k, v in data.items():
|
||||||
|
setattr(settings_obj, k, v)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
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()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Optional, List
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
from schemas import NormalizedVitals
|
from schemas import NormalizedVitals
|
||||||
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ def safe_float(value: str) -> Optional[float]:
|
|||||||
|
|
||||||
|
|
||||||
def is_valid_u16(val: Optional[int]) -> bool:
|
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:
|
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
|
return None
|
||||||
|
|
||||||
# Parse MSH segment
|
# Parse MSH segment
|
||||||
|
model = settings.device_models.get(client_ip, settings.monitor_model)
|
||||||
msh_fields = segments[0].split("|")
|
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"
|
patient_id = "UNKNOWN"
|
||||||
vitals_dict = {
|
vitals_dict = {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"timestamp": datetime.now(timezone.utc),
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
"ip_address": client_ip,
|
||||||
}
|
}
|
||||||
|
|
||||||
found_any = False
|
found_any = False
|
||||||
@@ -145,7 +148,6 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
|||||||
|
|
||||||
elif seg_name == "OBX":
|
elif seg_name == "OBX":
|
||||||
if len(fields) > 5:
|
if len(fields) > 5:
|
||||||
obs_id = fields[3].split("^")[0].strip().upper()
|
|
||||||
obs_val = fields[5].strip()
|
obs_val = fields[5].strip()
|
||||||
|
|
||||||
if not obs_val or obs_val == "---":
|
if not obs_val or obs_val == "---":
|
||||||
@@ -155,11 +157,19 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
|||||||
if val_float is None:
|
if val_float is None:
|
||||||
continue
|
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 param_key, mapped_field in CONTEC_PARAM_MAP.items():
|
||||||
if obs_id == param_key or param_key in obs_id:
|
for part in obs_parts:
|
||||||
vitals_dict[mapped_field] = val_float
|
if param_key == part or param_key in part:
|
||||||
found_any = True
|
vitals_dict[mapped_field] = val_float
|
||||||
logger.info(f"Parsed Contec HL7 Vital: {mapped_field} = {val_float} (from {obs_id})")
|
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
|
break
|
||||||
|
|
||||||
return NormalizedVitals(**vitals_dict) if found_any else None
|
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 +10 : uint16 LE → NIBP Systolic (9999 = invalid)
|
||||||
Offset +12 : uint16 LE → NIBP Diastolic (9999 = invalid)
|
Offset +12 : uint16 LE → NIBP Diastolic (9999 = invalid)
|
||||||
"""
|
"""
|
||||||
|
model = settings.device_models.get(client_ip, settings.monitor_model)
|
||||||
vitals_dict = {
|
vitals_dict = {
|
||||||
"device_id": f"CMS7000PLUS_{client_ip}",
|
"device_id": f"{model}_{client_ip}",
|
||||||
"patient_id": "UNKNOWN",
|
"patient_id": "UNKNOWN",
|
||||||
"timestamp": datetime.now(timezone.utc),
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
"ip_address": client_ip,
|
||||||
}
|
}
|
||||||
found = False
|
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
|
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.
|
Parse 45-byte packet (Subtype 22) — contains NIBP values and alarm limits.
|
||||||
Returns True if any valid vitals found.
|
|
||||||
|
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
|
found = False
|
||||||
|
|
||||||
nibp_sys = read_f32_le(data, base_offset)
|
# Mark NIBP fields as present so cache can clear them if 9999
|
||||||
nibp_dia = read_f32_le(data, base_offset + 4)
|
for f in ["systolic_bp", "diastolic_bp", "map_bp"]:
|
||||||
nibp_map = read_f32_le(data, base_offset + 8)
|
if f not in vitals_dict["present_fields"]:
|
||||||
temp1 = read_f32_le(data, base_offset + 12)
|
vitals_dict["present_fields"].append(f)
|
||||||
temp2 = read_f32_le(data, base_offset + 16)
|
|
||||||
|
|
||||||
if nibp_sys is not None and is_valid_f32(nibp_sys) and 50.0 < nibp_sys < 300.0:
|
if nibp_sys is not None and nibp_sys != 9999 and 40 < nibp_sys < 250:
|
||||||
vitals_dict["systolic_bp"] = round(nibp_sys, 1)
|
vitals_dict["systolic_bp"] = float(nibp_sys)
|
||||||
found = True
|
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:
|
if nibp_dia is not None and nibp_dia != 9999 and 20 < nibp_dia < 200:
|
||||||
vitals_dict["diastolic_bp"] = round(nibp_dia, 1)
|
vitals_dict["diastolic_bp"] = float(nibp_dia)
|
||||||
found = True
|
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:
|
if nibp_map is not None and nibp_map != 9999 and 20 < nibp_map < 250:
|
||||||
vitals_dict["map_bp"] = round(nibp_map, 1)
|
vitals_dict["map_bp"] = float(nibp_map)
|
||||||
found = True
|
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)
|
vitals_dict["temperature"] = round(temp1, 1)
|
||||||
found = True
|
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)
|
vitals_dict["temperature"] = round(temp2, 1)
|
||||||
found = True
|
found = True
|
||||||
|
|
||||||
return found
|
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]:
|
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:
|
Packet types (verified by raw hex analysis):
|
||||||
[len_lo] [len_hi] [04] [46] [sub_type] [00] [sub_len_lo] [00] ...payload...
|
45 bytes (Subtype 22): NIBP Sys/Dia/MAP (u16 LE starting at offset 15)
|
||||||
|
56 bytes (Subtype 23): Float32 Temperature (offset 8/12)
|
||||||
OR the data is a concatenation of multiple sub-packets separated by
|
286 bytes (Subtype 21): SpO2% at offset 264
|
||||||
[xx][xx][04][47] (end marker?)
|
341 bytes (Subtype 21): SpO2% at offset 264
|
||||||
|
989 bytes (Subtype 20): ECG Heart Rate at offset 904
|
||||||
The key is to find [04][46] marker bytes and parse the packet.
|
|
||||||
"""
|
"""
|
||||||
|
model = settings.device_models.get(client_ip, settings.monitor_model)
|
||||||
vitals_dict = {
|
vitals_dict = {
|
||||||
"device_id": f"CMS7000PLUS_{client_ip}",
|
"device_id": f"{model}_{client_ip}",
|
||||||
"patient_id": "UNKNOWN",
|
"patient_id": "UNKNOWN",
|
||||||
"timestamp": datetime.now(timezone.utc),
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
"ip_address": client_ip,
|
||||||
}
|
}
|
||||||
found_any = False
|
found_any = False
|
||||||
|
|
||||||
# Scan through the buffer looking for [04 46] packet markers
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(data) - 8:
|
while i < len(data) - 8:
|
||||||
# Look for the [04][46] marker which is the packet type indicator
|
if (data[i + 2] == 0x04 or data[i + 2] == 0x01 or data[i + 2] == 0x00) and data[i + 3] == 0x46:
|
||||||
if data[i + 2] == 0x04 and data[i + 3] == 0x46:
|
pkt_len = struct.unpack_from('<H', data, i)[0]
|
||||||
pkt_len = struct.unpack_from('<H', data, i)[0] # little-endian length
|
|
||||||
|
|
||||||
# Packet type B: small packets (< 600 bytes) with vitals summary
|
if 30 < pkt_len < 1050 and i + pkt_len <= len(data):
|
||||||
# These have float32 NIBP values at offset +8 relative to packet start
|
pkt_data = data[i:i + pkt_len]
|
||||||
if 30 < pkt_len < 600 and i + 8 <= len(data):
|
|
||||||
# Read float32 vitals at offsets 8, 12, 16, 20, 24 from packet start
|
|
||||||
_parse_float_vitals(data, i + 8, vitals_dict)
|
|
||||||
|
|
||||||
# Find the vitals summary block in this packet
|
if pkt_len in (45, 47):
|
||||||
# It appears near the tail of the resp waveform data
|
_parse_45_byte_packet(pkt_data, vitals_dict)
|
||||||
# The pattern we look for: SpO2 byte (50-100), followed by various u16 values
|
found_any = True
|
||||||
# In packet B, the summary is 75 bytes before the end
|
elif pkt_len == 56:
|
||||||
if pkt_len > 50:
|
_parse_56_byte_packet(pkt_data, vitals_dict)
|
||||||
# The summary block is consistently found around offset 322 in 397-byte packets
|
found_any = True
|
||||||
# Relative to packet start: around pkt_len - 75
|
elif pkt_len == 286:
|
||||||
summary_offset = i + max(4, pkt_len - 80)
|
_parse_286_byte_packet(pkt_data, vitals_dict)
|
||||||
if summary_offset + 10 < len(data):
|
found_any = True
|
||||||
# Scan in the last ~100 bytes of the packet for SpO2 pattern
|
elif pkt_len == 288:
|
||||||
scan_end = min(i + pkt_len, len(data) - 4)
|
_parse_288_byte_packet(pkt_data, vitals_dict)
|
||||||
scan_start = max(i + 4, scan_end - 100)
|
found_any = True
|
||||||
for j in range(scan_start, scan_end - 4, 2):
|
elif pkt_len == 341:
|
||||||
spo2 = read_u16_le(data, j)
|
_parse_341_byte_packet(pkt_data, vitals_dict)
|
||||||
hr = read_u16_le(data, j + 2)
|
found_any = True
|
||||||
pr = read_u16_le(data, j + 4)
|
elif pkt_len == 989:
|
||||||
|
_parse_989_byte_packet(pkt_data, vitals_dict)
|
||||||
|
found_any = True
|
||||||
|
|
||||||
# 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)
|
i += max(pkt_len, 4)
|
||||||
else:
|
else:
|
||||||
i += 1
|
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)
|
vitals = parse_contec_hl7_text(text, client_ip)
|
||||||
if vitals:
|
if vitals:
|
||||||
return vitals
|
return vitals
|
||||||
except Exception:
|
else:
|
||||||
pass
|
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)
|
# Attempt 2: Binary Parsing (Contec proprietary protocol)
|
||||||
vitals = parse_contec_binary_packet(raw_data, client_ip)
|
vitals = parse_contec_binary_packet(raw_data, client_ip)
|
||||||
|
|||||||
@@ -65,12 +65,15 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
break
|
break
|
||||||
|
|
||||||
buffer += data
|
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)})")
|
logger.debug(f"Received {len(data)} bytes from {client_ip}:{client_port} on port {port} (buf={len(buffer)})")
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# MLLP / HL7 text path (typically port 511)
|
# MLLP / HL7 text path (typically port 511)
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
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:
|
while True:
|
||||||
start_idx = buffer.find(VT)
|
start_idx = buffer.find(VT)
|
||||||
end_idx = buffer.find(FS_CR)
|
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]
|
b2 = buffer[consumed + 2]
|
||||||
b3 = buffer[consumed + 3]
|
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_len = b0 | (b1 << 8)
|
||||||
pkt_end = consumed + pkt_len
|
pkt_end = consumed + pkt_len
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
|
|
||||||
consumed = pkt_end
|
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
|
# End-of-frame marker — skip 4 bytes
|
||||||
consumed += 4
|
consumed += 4
|
||||||
|
|
||||||
@@ -161,13 +164,63 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
pass
|
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):
|
async def process_vitals(vitals):
|
||||||
"""
|
"""
|
||||||
Log, database, WebSocket broadcast, and REST forward parsed 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)
|
display.update_vitals(vitals)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -177,7 +230,7 @@ async def process_vitals(vitals):
|
|||||||
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
|
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Broadcast to live WebSockets dashboard
|
# 3. Broadcast to live WebSockets dashboard in real-time
|
||||||
try:
|
try:
|
||||||
from routers.dashboard import get_ws_manager
|
from routers.dashboard import get_ws_manager
|
||||||
ws_manager = get_ws_manager()
|
ws_manager = get_ws_manager()
|
||||||
@@ -185,63 +238,78 @@ async def process_vitals(vitals):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
logger.debug(f"WebSocket broadcast failed: {e}")
|
||||||
|
|
||||||
# Save to SQLite database
|
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
|
||||||
db = SessionLocal()
|
now = datetime.now(timezone.utc)
|
||||||
try:
|
should_write_db = False
|
||||||
# 1. Device tracking
|
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0:
|
||||||
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
should_write_db = True
|
||||||
if not device:
|
last_db_write[device_id] = now
|
||||||
device = Device(device_id=vitals.device_id, ip_address="unknown")
|
|
||||||
db.add(device)
|
|
||||||
else:
|
|
||||||
device.last_seen = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# 2. Patient tracking
|
if should_write_db:
|
||||||
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
# Save to SQLite database and REST forward
|
||||||
if not patient:
|
db = SessionLocal()
|
||||||
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
try:
|
||||||
db.add(patient)
|
# 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)
|
||||||
|
|
||||||
# 3. Create Reading Entry
|
# 2. Patient tracking
|
||||||
reading = VitalReading(
|
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
||||||
device_id=vitals.device_id,
|
if not patient:
|
||||||
patient_id=vitals.patient_id,
|
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
||||||
timestamp=vitals.timestamp,
|
db.add(patient)
|
||||||
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
|
# 3. Create Reading Entry
|
||||||
success = await forward_vitals_to_api(vitals)
|
reading = VitalReading(
|
||||||
|
device_id=vitals.device_id,
|
||||||
if success:
|
patient_id=vitals.patient_id,
|
||||||
reading.transmitted = True
|
timestamp=vitals.timestamp,
|
||||||
log_entry = TransmissionLog(
|
heart_rate=vitals.heart_rate,
|
||||||
reading_id=reading.id,
|
spo2=vitals.spo2,
|
||||||
status="success",
|
systolic_bp=vitals.systolic_bp,
|
||||||
response_code=200
|
diastolic_bp=vitals.diastolic_bp,
|
||||||
)
|
map_bp=vitals.map_bp,
|
||||||
else:
|
respiratory_rate=vitals.respiratory_rate,
|
||||||
log_entry = TransmissionLog(
|
temperature=vitals.temperature,
|
||||||
reading_id=reading.id,
|
transmitted=False
|
||||||
status="failed",
|
|
||||||
error_message="Immediate REST forward failed"
|
|
||||||
)
|
)
|
||||||
|
db.add(reading)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(reading)
|
||||||
|
|
||||||
db.add(log_entry)
|
# 4. REST forwarding
|
||||||
db.commit()
|
success = await forward_vitals_to_api(vitals)
|
||||||
|
|
||||||
except Exception as e:
|
if success:
|
||||||
logger.error(f"Database operation failed: {e}")
|
reading.transmitted = True
|
||||||
db.rollback()
|
log_entry = TransmissionLog(
|
||||||
finally:
|
reading_id=reading.id,
|
||||||
db.close()
|
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()
|
||||||
|
|||||||
@@ -91,18 +91,22 @@ async def lifespan(app: FastAPI):
|
|||||||
for s in servers:
|
for s in servers:
|
||||||
for sock in s.sockets:
|
for sock in s.sockets:
|
||||||
active_ports.append(sock.getsockname()[1])
|
active_ports.append(sock.getsockname()[1])
|
||||||
|
app.state.active_ports = active_ports
|
||||||
logger.info(f" Active listener ports: {active_ports}")
|
logger.info(f" Active listener ports: {active_ports}")
|
||||||
else:
|
else:
|
||||||
|
app.state.active_ports = []
|
||||||
logger.error("No servers could be started! Check port availability and permissions.")
|
logger.error("No servers could be started! Check port availability and permissions.")
|
||||||
|
|
||||||
# === Print startup banner ===
|
# === Print startup banner ===
|
||||||
|
import os
|
||||||
|
port = int(os.environ.get("PORT", 8000))
|
||||||
local_ips = _get_local_ips()
|
local_ips = _get_local_ips()
|
||||||
model = settings.monitor_model
|
model = settings.monitor_model
|
||||||
print("\n" + "=" * 65)
|
print("\n" + "=" * 65)
|
||||||
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
|
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
|
||||||
print("=" * 65)
|
print("=" * 65)
|
||||||
print(f" DASHBOARD: http://localhost:8000/api/dashboard")
|
print(f" DASHBOARD: http://localhost:{port}/api/dashboard")
|
||||||
print(f" API Docs: http://localhost:8000/docs")
|
print(f" API Docs: http://localhost:{port}/docs")
|
||||||
print(f" Contec Ports: {settings.contec_ports}")
|
print(f" Contec Ports: {settings.contec_ports}")
|
||||||
print(f" Monitor Model: {model}")
|
print(f" Monitor Model: {model}")
|
||||||
print(f" API Target: {settings.target_api_url}")
|
print(f" API Target: {settings.target_api_url}")
|
||||||
@@ -114,7 +118,7 @@ async def lifespan(app: FastAPI):
|
|||||||
print("=" * 65)
|
print("=" * 65)
|
||||||
print(f" Waiting for {model} connections...")
|
print(f" Waiting for {model} connections...")
|
||||||
print(f" On your monitor: System Setup > Network > CMS Settings")
|
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")
|
print("=" * 65 + "\n")
|
||||||
|
|
||||||
# Startup: Start the background retry task
|
# Startup: Start the background retry task
|
||||||
@@ -165,5 +169,7 @@ if __name__ == "__main__":
|
|||||||
logger.info("Initializing Application...")
|
logger.info("Initializing Application...")
|
||||||
|
|
||||||
# Run the FastAPI server.
|
# Run the FastAPI server.
|
||||||
# Notice we run on port 8000 for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060)
|
# Notice we run on port 8000 (or PORT env var) for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060)
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
import os
|
||||||
|
port = int(os.environ.get("PORT", 8000))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ httpx
|
|||||||
pyinstaller
|
pyinstaller
|
||||||
rich
|
rich
|
||||||
pyserial
|
pyserial
|
||||||
|
websockets
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import List
|
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 fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -69,27 +70,50 @@ def health_check():
|
|||||||
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def get_system_status(db: Session = Depends(get_db)):
|
def get_system_status(request: Request, db: Session = Depends(get_db)):
|
||||||
total_devices = db.query(Device).count()
|
total_devices = db.query(Device).count()
|
||||||
active_devices = db.query(Device).filter(Device.status == "active").count()
|
active_devices = db.query(Device).filter(Device.status == "active").count()
|
||||||
total_readings = db.query(VitalReading).count()
|
total_readings = db.query(VitalReading).count()
|
||||||
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
|
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
|
||||||
|
active_ports = getattr(request.app.state, "active_ports", [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_devices": total_devices,
|
"total_devices": total_devices,
|
||||||
"active_devices": active_devices,
|
"active_devices": active_devices,
|
||||||
"total_readings_stored": total_readings,
|
"total_readings_stored": total_readings,
|
||||||
"pending_transmissions": pending_transmissions
|
"pending_transmissions": pending_transmissions,
|
||||||
|
"active_ports": active_ports
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/devices")
|
@router.get("/devices")
|
||||||
def list_devices(db: Session = Depends(get_db)):
|
def list_devices(db: Session = Depends(get_db)):
|
||||||
devices = db.query(Device).all()
|
devices = db.query(Device).all()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
updated = False
|
||||||
|
for device in devices:
|
||||||
|
last_seen = device.last_seen
|
||||||
|
if last_seen.tzinfo is None:
|
||||||
|
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# If not seen for more than 15 seconds, mark offline
|
||||||
|
if (now - last_seen).total_seconds() > 15.0:
|
||||||
|
if device.status != "offline":
|
||||||
|
device.status = "offline"
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
if device.status != "active":
|
||||||
|
device.status = "active"
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
db.commit()
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
@router.get("/latest-readings")
|
@router.get("/latest-readings")
|
||||||
def get_latest_readings(limit: int = 10, db: Session = Depends(get_db)):
|
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
|
||||||
readings = db.query(VitalReading).order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
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
|
return readings
|
||||||
|
|
||||||
@router.get("/readings")
|
@router.get("/readings")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class NormalizedVitals(BaseModel):
|
|||||||
device_id: str
|
device_id: str
|
||||||
patient_id: str
|
patient_id: str
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
ip_address: Optional[str] = None
|
||||||
heart_rate: Optional[float] = None
|
heart_rate: Optional[float] = None
|
||||||
spo2: Optional[float] = None
|
spo2: Optional[float] = None
|
||||||
systolic_bp: Optional[float] = None
|
systolic_bp: Optional[float] = None
|
||||||
@@ -13,6 +14,7 @@ class NormalizedVitals(BaseModel):
|
|||||||
map_bp: Optional[float] = None
|
map_bp: Optional[float] = None
|
||||||
respiratory_rate: Optional[float] = None
|
respiratory_rate: Optional[float] = None
|
||||||
temperature: Optional[float] = None
|
temperature: Optional[float] = None
|
||||||
|
present_fields: Optional[list] = None
|
||||||
|
|
||||||
class DeviceStatus(BaseModel):
|
class DeviceStatus(BaseModel):
|
||||||
device_id: str
|
device_id: str
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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('<H', data, offset)[0]
|
||||||
|
|
||||||
|
# Read the log file
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find all occurrences of Hex head logging
|
||||||
|
hex_heads = re.findall(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", content)
|
||||||
|
warnings = re.findall(r"unrecognized. Len: (\d+), Hex head: ([0-9a-fA-F]+)", content)
|
||||||
|
|
||||||
|
print(f"Found {len(hex_heads)} hex heads from server logging")
|
||||||
|
print(f"Found {len(warnings)} unrecognized packet hex heads")
|
||||||
|
|
||||||
|
# Also find all data packets printed or parsed in logs
|
||||||
|
# Let's inspect some of the unrecognized packets of size 56 and 45
|
||||||
|
print("\nSample of Len 56 packets:")
|
||||||
|
count = 0
|
||||||
|
for len_str, hex_str in warnings:
|
||||||
|
if len_str == "56":
|
||||||
|
print(f" {hex_str}")
|
||||||
|
count += 1
|
||||||
|
if count >= 5:
|
||||||
|
break
|
||||||
|
|
||||||
|
print("\nSample of Len 45 packets:")
|
||||||
|
count = 0
|
||||||
|
for len_str, hex_str in warnings:
|
||||||
|
if len_str == "45":
|
||||||
|
print(f" {hex_str}")
|
||||||
|
count += 1
|
||||||
|
if count >= 5:
|
||||||
|
break
|
||||||
@@ -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()
|
||||||
@@ -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('<H', data, offset)[0]
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
# Extract 45-byte packets (subtype 22)
|
||||||
|
packets = []
|
||||||
|
consumed = 0
|
||||||
|
while consumed < len(stream) - 4:
|
||||||
|
b0 = stream[consumed]
|
||||||
|
b1 = stream[consumed + 1]
|
||||||
|
b2 = stream[consumed + 2]
|
||||||
|
b3 = stream[consumed + 3]
|
||||||
|
|
||||||
|
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
||||||
|
pkt_len = b0 | (b1 << 8)
|
||||||
|
if pkt_len == 45 and consumed + pkt_len <= len(stream):
|
||||||
|
packets.append(stream[consumed:consumed + pkt_len])
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
if pkt_len < 5 or pkt_len > 8192:
|
||||||
|
consumed += 1
|
||||||
|
else:
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
consumed += 1
|
||||||
|
|
||||||
|
print(f"Found {len(packets)} 45-byte packets:")
|
||||||
|
seen_timestamps = set()
|
||||||
|
for p in packets:
|
||||||
|
# Decode timestamp
|
||||||
|
year = read_u16_le(p, 8)
|
||||||
|
month = p[10]
|
||||||
|
day = p[11]
|
||||||
|
hour = p[12]
|
||||||
|
minute = p[13]
|
||||||
|
second = p[14]
|
||||||
|
ts_str = f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
||||||
|
|
||||||
|
if ts_str in seen_timestamps:
|
||||||
|
continue
|
||||||
|
seen_timestamps.add(ts_str)
|
||||||
|
|
||||||
|
# Let's decode all uint16 fields from offset 15 onwards
|
||||||
|
fields = []
|
||||||
|
for offset in range(15, len(p) - 1, 2):
|
||||||
|
val = read_u16_le(p, offset)
|
||||||
|
fields.append(f"off{offset}:{val}")
|
||||||
|
|
||||||
|
print(f"Timestamp: {ts_str} | Payload: {p[15:].hex()}")
|
||||||
|
print(" Decoded uint16s: " + ", ".join(fields))
|
||||||
@@ -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('<f', data, offset)[0]
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
# Extract 56-byte packets (subtype 23)
|
||||||
|
packets = []
|
||||||
|
consumed = 0
|
||||||
|
while consumed < len(stream) - 4:
|
||||||
|
b0 = stream[consumed]
|
||||||
|
b1 = stream[consumed + 1]
|
||||||
|
b2 = stream[consumed + 2]
|
||||||
|
b3 = stream[consumed + 3]
|
||||||
|
|
||||||
|
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
||||||
|
pkt_len = b0 | (b1 << 8)
|
||||||
|
if pkt_len == 56 and consumed + pkt_len <= len(stream):
|
||||||
|
packets.append(stream[consumed:consumed + pkt_len])
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
if pkt_len < 5 or pkt_len > 8192:
|
||||||
|
consumed += 1
|
||||||
|
else:
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
consumed += 1
|
||||||
|
|
||||||
|
print(f"Found {len(packets)} 56-byte packets:")
|
||||||
|
# Print the float32 values of the first packet at every possible offset
|
||||||
|
if packets:
|
||||||
|
p = packets[0]
|
||||||
|
print(f"Sample 56-byte packet: {p.hex()}")
|
||||||
|
for offset in range(8, len(p) - 3, 4):
|
||||||
|
val = read_f32_le(p, offset)
|
||||||
|
print(f" Offset {offset:02d}: {val}")
|
||||||
@@ -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('<H', data, offset)[0]
|
||||||
|
|
||||||
|
def read_f32_le(data: bytes, offset: int) -> float:
|
||||||
|
if offset + 4 > len(data):
|
||||||
|
return None
|
||||||
|
return struct.unpack_from('<f', data, offset)[0]
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
print(f"Total stream size: {len(stream)} bytes")
|
||||||
|
|
||||||
|
# Extract packets
|
||||||
|
packets = []
|
||||||
|
consumed = 0
|
||||||
|
while consumed < len(stream) - 4:
|
||||||
|
b0 = stream[consumed]
|
||||||
|
b1 = stream[consumed + 1]
|
||||||
|
b2 = stream[consumed + 2]
|
||||||
|
b3 = stream[consumed + 3]
|
||||||
|
|
||||||
|
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
||||||
|
pkt_len = b0 | (b1 << 8)
|
||||||
|
if pkt_len < 5 or pkt_len > 8192:
|
||||||
|
consumed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if consumed + pkt_len <= len(stream):
|
||||||
|
packets.append(stream[consumed:consumed + pkt_len])
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
consumed += 1
|
||||||
|
|
||||||
|
print(f"Successfully extracted {len(packets)} packets")
|
||||||
|
|
||||||
|
# Analyze packet types and sizes
|
||||||
|
by_size = {}
|
||||||
|
for p in packets:
|
||||||
|
l = len(p)
|
||||||
|
subtype = p[6] if l > 6 else -1
|
||||||
|
key = (l, subtype)
|
||||||
|
if key not in by_size:
|
||||||
|
by_size[key] = []
|
||||||
|
by_size[key].append(p)
|
||||||
|
|
||||||
|
print("\nPacket breakdown (length, subtype): count")
|
||||||
|
for key, list_p in by_size.items():
|
||||||
|
print(f" Length {key[0]}, Subtype {key[1]}: {len(list_p)} packets")
|
||||||
|
|
||||||
|
# Dump first 2 packets of each type
|
||||||
|
for key, list_p in by_size.items():
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Sample packets for Length {key[0]}, Subtype {key[1]}:")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
for idx, p in enumerate(list_p[:2]):
|
||||||
|
print(f"Packet #{idx+1} hex:")
|
||||||
|
print(" " + p.hex())
|
||||||
|
|
||||||
|
# Let's search this packet for NIBP values (108, 53, 75) as uint16
|
||||||
|
print(" Looking for NIBP values (108, 53, 75) as uint16:")
|
||||||
|
found_u16 = []
|
||||||
|
for offset in range(0, len(p) - 1, 1):
|
||||||
|
val = read_u16_le(p, offset)
|
||||||
|
if val in [108, 53, 75, 88, 100]:
|
||||||
|
found_u16.append(f"offset {offset}: {val}")
|
||||||
|
if found_u16:
|
||||||
|
print(" uint16 matches: " + ", ".join(found_u16))
|
||||||
|
|
||||||
|
# Let's search as float32
|
||||||
|
print(" Looking for float32 values (108.0, 53.0, 75.0, 88.0, 100.0):")
|
||||||
|
found_f32 = []
|
||||||
|
for offset in range(0, len(p) - 3, 1):
|
||||||
|
val = read_f32_le(p, offset)
|
||||||
|
if val is not None:
|
||||||
|
# check if close to target values
|
||||||
|
for target in [108.0, 53.0, 75.0, 88.0, 100.0]:
|
||||||
|
if abs(val - target) < 0.1:
|
||||||
|
found_f32.append(f"offset {offset}: {val:.1f}")
|
||||||
|
if found_f32:
|
||||||
|
print(" float32 matches: " + ", ".join(found_f32))
|
||||||
@@ -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('<H', stream, i)[0]
|
||||||
|
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
||||||
|
packets.append((pkt_len, stream[i:i + pkt_len]))
|
||||||
|
i += max(pkt_len, 4)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Search for byte value 88 (0x58) in 286-byte packets
|
||||||
|
print("=== Searching ALL offsets in 286-byte packets for u16=88 ===")
|
||||||
|
for idx, (l, pkt) in enumerate(packets):
|
||||||
|
if l == 286:
|
||||||
|
for off in range(4, l-1, 2):
|
||||||
|
v = struct.unpack_from('<H', pkt, off)[0]
|
||||||
|
if v == 88:
|
||||||
|
print(f" Packet {idx}, offset {off}: u16={v}")
|
||||||
|
# Also search single bytes
|
||||||
|
for off in range(4, l):
|
||||||
|
if pkt[off] == 88:
|
||||||
|
print(f" Packet {idx}, byte[{off}] = 88 (0x58)")
|
||||||
|
break # Just check first one
|
||||||
|
|
||||||
|
# Search 989-byte packets
|
||||||
|
print("\n=== Searching 989-byte packets for byte=88 in offsets 0-30 and 950-989 ===")
|
||||||
|
for idx, (l, pkt) in enumerate(packets):
|
||||||
|
if l == 989:
|
||||||
|
for off in range(0, 30):
|
||||||
|
if pkt[off] == 88:
|
||||||
|
print(f" Packet {idx}, byte[{off}] = 88")
|
||||||
|
for off in range(950, l):
|
||||||
|
if pkt[off] == 88:
|
||||||
|
print(f" Packet {idx}, byte[{off}] = 88")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Search 45-byte packets
|
||||||
|
print("\n=== Searching 45-byte packets for byte=88 ===")
|
||||||
|
for idx, (l, pkt) in enumerate(packets):
|
||||||
|
if l == 45:
|
||||||
|
for off in range(8, l):
|
||||||
|
if pkt[off] == 88:
|
||||||
|
print(f" Packet {idx}, byte[{off}] = 88")
|
||||||
|
break
|
||||||
|
|
||||||
|
# The ECG HR may also be encoded differently. Let's check what's at byte
|
||||||
|
# positions right before the alarm limits in the 45-byte packet
|
||||||
|
print("\n=== 45-byte packet: All non-zero bytes with their positions ===")
|
||||||
|
for idx, (l, pkt) in enumerate(packets):
|
||||||
|
if l == 45:
|
||||||
|
for off in range(8, l):
|
||||||
|
if pkt[off] != 0:
|
||||||
|
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check the 45-byte packet at index 147 specifically (when NIBP was 110/67/82)
|
||||||
|
print("\n=== 45-byte packet at the time of NIBP 110/67/82 ===")
|
||||||
|
count = 0
|
||||||
|
for idx, (l, pkt) in enumerate(packets):
|
||||||
|
if l == 45:
|
||||||
|
count += 1
|
||||||
|
if count > 36: # Skip to around packet index 147
|
||||||
|
for off in range(8, l):
|
||||||
|
if pkt[off] != 0:
|
||||||
|
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
|
||||||
|
break
|
||||||
@@ -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('<H', pkt, off)[0]
|
||||||
|
if 75 <= val <= 95:
|
||||||
|
found_offsets_288.setdefault(off, []).append((idx, val))
|
||||||
|
|
||||||
|
for off, matches in found_offsets_288.items():
|
||||||
|
if len(matches) > 5:
|
||||||
|
# print first few matches and total count
|
||||||
|
vals = [m[1] for m in matches]
|
||||||
|
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
|
||||||
|
|
||||||
|
# Search all offsets in 989-byte packets for any u16 LE value between 70 and 95
|
||||||
|
print("\nScanning 989-byte packets for u16 LE values in range [75, 95] across all packets:")
|
||||||
|
found_offsets_989 = {}
|
||||||
|
for idx, pkt in enumerate(packets_989):
|
||||||
|
# ECG packets are mostly waveform data in the first 900 bytes, so let's check from 900 to 988
|
||||||
|
for off in range(900, len(pkt) - 1, 2):
|
||||||
|
val = struct.unpack_from('<H', pkt, off)[0]
|
||||||
|
if 75 <= val <= 95:
|
||||||
|
found_offsets_989.setdefault(off, []).append((idx, val))
|
||||||
|
|
||||||
|
for off, matches in found_offsets_989.items():
|
||||||
|
if len(matches) > 5:
|
||||||
|
vals = [m[1] for m in matches]
|
||||||
|
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Binary file not shown.
@@ -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('<H', data, offset)[0]
|
||||||
|
|
||||||
|
# Let's read the log line by line and reconstruct packets
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# We want to find raw reads and buffer state
|
||||||
|
# The server logs:
|
||||||
|
# "Received {len} bytes from ... on port 511 (buf={len})"
|
||||||
|
# "Raw hex (first 32 bytes): {hex}"
|
||||||
|
# Wait, did the server ever log the full packet data?
|
||||||
|
# Let's look at the log lines.
|
||||||
|
print("Searching for raw hex data logs...")
|
||||||
|
hex_patterns = []
|
||||||
|
for line in lines[:5000]:
|
||||||
|
m = re.search(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", line)
|
||||||
|
if m:
|
||||||
|
hex_patterns.append(m.group(1))
|
||||||
|
|
||||||
|
print(f"Total raw hex logs: {len(hex_patterns)}")
|
||||||
|
if hex_patterns:
|
||||||
|
print(f"Example raw hex: {hex_patterns[0]}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
import struct
|
||||||
|
|
||||||
|
def search_scaled_values(p: bytes, val_sys: int, val_dia: int, val_map: int):
|
||||||
|
# We will search for scaled values: val, val * 10, val * 100
|
||||||
|
targets = [
|
||||||
|
(val_sys, "Sys"), (val_sys * 10, "Sys*10"), (val_sys * 100, "Sys*100"),
|
||||||
|
(val_dia, "Dia"), (val_dia * 10, "Dia*10"), (val_dia * 100, "Dia*100"),
|
||||||
|
(val_map, "Map"), (val_map * 10, "Map*10"), (val_map * 100, "Map*100")
|
||||||
|
]
|
||||||
|
|
||||||
|
for offset in range(len(p) - 1):
|
||||||
|
v = struct.unpack_from('<H', p, offset)[0]
|
||||||
|
for target_val, name in targets:
|
||||||
|
if v == target_val:
|
||||||
|
print(f" Found {name} ({target_val}) at offset {offset}")
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
consumed = 0
|
||||||
|
packets = []
|
||||||
|
while consumed < len(stream) - 4:
|
||||||
|
b0, b1, b2, b3 = stream[consumed:consumed+4]
|
||||||
|
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
||||||
|
pkt_len = b0 | (b1 << 8)
|
||||||
|
packets.append(stream[consumed:consumed+pkt_len])
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
consumed += 1
|
||||||
|
|
||||||
|
print(f"Scanning {len(packets)} packets for scaled BP (108, 53, 75)...")
|
||||||
|
for idx, p in enumerate(packets):
|
||||||
|
l = len(p)
|
||||||
|
subtype = p[6] if l > 6 else -1
|
||||||
|
|
||||||
|
# We want to print only if something is found
|
||||||
|
# Redirect output internally
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
new_stdout = io.StringIO()
|
||||||
|
sys.stdout = new_stdout
|
||||||
|
|
||||||
|
search_scaled_values(p, 108, 53, 75)
|
||||||
|
|
||||||
|
output = new_stdout.getvalue()
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
|
||||||
|
if output.strip():
|
||||||
|
print(f"Packet #{idx} (Len={l}, Subtype={subtype}):")
|
||||||
|
print(output, end="")
|
||||||
@@ -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('<H', p, offset)[0]
|
||||||
|
if v == val:
|
||||||
|
print(f" {label} found as uint16 LE at offset {offset}")
|
||||||
|
|
||||||
|
# Search as float32 LE
|
||||||
|
for offset in range(len(p) - 3):
|
||||||
|
v = struct.unpack_from('<f', p, offset)[0]
|
||||||
|
if abs(v - float(val)) < 0.01:
|
||||||
|
print(f" {label} found as float32 LE at offset {offset}")
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
# Extract packets
|
||||||
|
packets = []
|
||||||
|
consumed = 0
|
||||||
|
while consumed < len(stream) - 4:
|
||||||
|
b0 = stream[consumed]
|
||||||
|
b1 = stream[consumed + 1]
|
||||||
|
b2 = stream[consumed + 2]
|
||||||
|
b3 = stream[consumed + 3]
|
||||||
|
|
||||||
|
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
||||||
|
pkt_len = b0 | (b1 << 8)
|
||||||
|
if pkt_len < 5 or pkt_len > 8192:
|
||||||
|
consumed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if consumed + pkt_len <= len(stream):
|
||||||
|
packets.append(stream[consumed:consumed + pkt_len])
|
||||||
|
consumed += pkt_len
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
consumed += 1
|
||||||
|
|
||||||
|
print(f"Loaded {len(packets)} packets. Scanning for HR=88, Systolic=108, MAP=75...")
|
||||||
|
|
||||||
|
for idx, p in enumerate(packets):
|
||||||
|
l = len(p)
|
||||||
|
subtype = p[6] if l > 6 else -1
|
||||||
|
|
||||||
|
# Let's decode timestamp if it is a 45-byte packet
|
||||||
|
ts_str = ""
|
||||||
|
if l == 45:
|
||||||
|
year = struct.unpack_from('<H', p, 8)[0]
|
||||||
|
month, day, hour, minute, second = p[10], p[11], p[12], p[13], p[14]
|
||||||
|
ts_str = f" @ {year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
||||||
|
|
||||||
|
print(f"\nPacket #{idx} (Len={l}, Subtype={subtype}){ts_str}:")
|
||||||
|
search_value_in_packet(p, 88, "HR (88)")
|
||||||
|
search_value_in_packet(p, 108, "Systolic (108)")
|
||||||
|
search_value_in_packet(p, 75, "MAP (75)")
|
||||||
@@ -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 == 288 and i + pkt_len <= len(stream):
|
||||||
|
packets.append(stream[i:i + pkt_len])
|
||||||
|
i += max(pkt_len, 4)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if packets:
|
||||||
|
pkt = packets[-1]
|
||||||
|
print("Last 40 bytes of 288-byte packet:")
|
||||||
|
for off in range(248, 288, 2):
|
||||||
|
val = pkt[off] | (pkt[off+1] << 8)
|
||||||
|
print(f" Offset {off}: hex={pkt[off]:02x} {pkt[off+1]:02x} -> u16 LE = {val}")
|
||||||
@@ -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}")
|
||||||
@@ -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('<H', pkt_288, off)[0]
|
||||||
|
if 75 <= val <= 95:
|
||||||
|
print(f" Offset {off}: u16 LE = {val}")
|
||||||
|
|
||||||
|
if pkt_989:
|
||||||
|
print("\n=== Scanning 989-byte packet (tail, offsets 900-988) for values 75-90 ===")
|
||||||
|
for off in range(900, len(pkt_989) - 1, 2):
|
||||||
|
val = struct.unpack_from('<H', pkt_989, off)[0]
|
||||||
|
if 75 <= val <= 95:
|
||||||
|
print(f" Offset {off}: u16 LE = {val}")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import sys
|
||||||
|
import struct
|
||||||
|
sys.path.insert(0, "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main")
|
||||||
|
|
||||||
|
from contec_parser import parse_contec_binary_packet
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
pkts = []
|
||||||
|
while i < len(stream) - 8:
|
||||||
|
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
|
||||||
|
pkt_len = struct.unpack_from('<H', stream, i)[0]
|
||||||
|
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
||||||
|
pkts.append(stream[i:i+pkt_len])
|
||||||
|
i += max(pkt_len, 4)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
print(f"Loaded {len(pkts)} packets.")
|
||||||
|
|
||||||
|
vitals_cache = {
|
||||||
|
"heart_rate": None,
|
||||||
|
"spo2": None,
|
||||||
|
"temperature": None,
|
||||||
|
"systolic_bp": None,
|
||||||
|
"diastolic_bp": None,
|
||||||
|
"map_bp": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
last_printed = {}
|
||||||
|
|
||||||
|
for idx, pkt in enumerate(pkts):
|
||||||
|
vitals = parse_contec_binary_packet(pkt, "192.168.100.120")
|
||||||
|
if vitals:
|
||||||
|
# Merge like contec_server.py
|
||||||
|
changed = False
|
||||||
|
for field in vitals.present_fields or []:
|
||||||
|
val = getattr(vitals, field)
|
||||||
|
if vitals_cache[field] != val:
|
||||||
|
vitals_cache[field] = val
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
current = {k: v for k, v in vitals_cache.items()}
|
||||||
|
if current != last_printed:
|
||||||
|
print(f"Pkt #{idx} (len={len(pkt)}) -> Cache: {current}")
|
||||||
|
last_printed = current
|
||||||
@@ -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('<H', data, offset)[0]
|
||||||
|
|
||||||
|
def read_f32_le(data, offset):
|
||||||
|
if offset + 4 > len(data):
|
||||||
|
return None
|
||||||
|
return struct.unpack_from('<f', data, offset)[0]
|
||||||
|
|
||||||
|
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
||||||
|
stream = f.read()
|
||||||
|
|
||||||
|
# Parse packets
|
||||||
|
i = 0
|
||||||
|
pkts = []
|
||||||
|
while i < len(stream) - 8:
|
||||||
|
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
|
||||||
|
pkt_len = struct.unpack_from('<H', stream, i)[0]
|
||||||
|
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
||||||
|
pkts.append((pkt_len, stream[i:i + pkt_len]))
|
||||||
|
i += max(pkt_len, 4)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
vitals_cache = {
|
||||||
|
"heart_rate": None,
|
||||||
|
"spo2": None,
|
||||||
|
"temperature": None,
|
||||||
|
"systolic_bp": None,
|
||||||
|
"diastolic_bp": None,
|
||||||
|
"map_bp": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
last_printed = {}
|
||||||
|
|
||||||
|
for idx, (pkt_len, pkt) in enumerate(pkts):
|
||||||
|
changed = False
|
||||||
|
if pkt_len == 989:
|
||||||
|
hr = read_u16_le(pkt, 904)
|
||||||
|
# 65535 or 9999 means disconnected
|
||||||
|
if hr is not None and hr != 65535 and hr != 9999 and 20 <= hr <= 300:
|
||||||
|
if vitals_cache["heart_rate"] != float(hr):
|
||||||
|
vitals_cache["heart_rate"] = float(hr)
|
||||||
|
changed = True
|
||||||
|
elif hr == 65535 or hr == 9999:
|
||||||
|
if vitals_cache["heart_rate"] is not None:
|
||||||
|
vitals_cache["heart_rate"] = None
|
||||||
|
changed = True
|
||||||
|
elif pkt_len == 286:
|
||||||
|
spo2 = read_u16_le(pkt, 264)
|
||||||
|
if spo2 is not None and spo2 != 65535 and spo2 != 255 and 50 <= spo2 <= 100:
|
||||||
|
if vitals_cache["spo2"] != float(spo2):
|
||||||
|
vitals_cache["spo2"] = float(spo2)
|
||||||
|
changed = True
|
||||||
|
elif spo2 == 65535 or spo2 == 255:
|
||||||
|
if vitals_cache["spo2"] is not None:
|
||||||
|
vitals_cache["spo2"] = None
|
||||||
|
changed = True
|
||||||
|
elif pkt_len == 56:
|
||||||
|
temp = read_f32_le(pkt, 8)
|
||||||
|
if temp is not None and abs(temp - 9999.0) > 1.0 and 30.0 < temp < 45.0:
|
||||||
|
if vitals_cache["temperature"] != round(temp, 1):
|
||||||
|
vitals_cache["temperature"] = round(temp, 1)
|
||||||
|
changed = True
|
||||||
|
elif temp is not None and abs(temp - 9999.0) <= 1.0:
|
||||||
|
if vitals_cache["temperature"] is not None:
|
||||||
|
vitals_cache["temperature"] = None
|
||||||
|
changed = True
|
||||||
|
elif pkt_len == 45:
|
||||||
|
sys_bp = read_u16_le(pkt, 15)
|
||||||
|
dia_bp = read_u16_le(pkt, 17)
|
||||||
|
map_bp = read_u16_le(pkt, 19)
|
||||||
|
|
||||||
|
# 9999 means no NIBP measurement active
|
||||||
|
if sys_bp is not None and sys_bp != 9999 and sys_bp > 0:
|
||||||
|
vitals_cache["systolic_bp"] = float(sys_bp)
|
||||||
|
changed = True
|
||||||
|
elif sys_bp == 9999 or sys_bp == 0:
|
||||||
|
if vitals_cache["systolic_bp"] is not None:
|
||||||
|
vitals_cache["systolic_bp"] = None
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if dia_bp is not None and dia_bp != 9999 and dia_bp > 0:
|
||||||
|
vitals_cache["diastolic_bp"] = float(dia_bp)
|
||||||
|
changed = True
|
||||||
|
elif dia_bp == 9999 or dia_bp == 0:
|
||||||
|
if vitals_cache["diastolic_bp"] is not None:
|
||||||
|
vitals_cache["diastolic_bp"] = None
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if map_bp is not None and map_bp != 9999 and map_bp > 0:
|
||||||
|
vitals_cache["map_bp"] = float(map_bp)
|
||||||
|
changed = True
|
||||||
|
elif map_bp == 9999 or map_bp == 0:
|
||||||
|
if vitals_cache["map_bp"] is not None:
|
||||||
|
vitals_cache["map_bp"] = None
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
current_vitals = {k: v for k, v in vitals_cache.items()}
|
||||||
|
if current_vitals != last_printed:
|
||||||
|
print(f"Pkt #{idx} (len={pkt_len}) -> Vitals: {current_vitals}")
|
||||||
|
last_printed = current_vitals
|
||||||
@@ -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.")
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Patient Vital Signs Monitor — Contec CMS7000PLUS</title>
|
<title>Patient Vital Signs Monitor — Contec CMS7000PLUS / CMS8500</title>
|
||||||
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS7000PLUS patient monitor">
|
<meta name="description" content="Real-time patient vital signs monitoring dashboard for Contec CMS7000PLUS / CMS8500 patient monitors">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -224,6 +224,37 @@
|
|||||||
color: var(--text-secondary);
|
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 Panel === */
|
||||||
.setup-guide {
|
.setup-guide {
|
||||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06));
|
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08), rgba(168, 85, 247, 0.06));
|
||||||
@@ -699,10 +730,16 @@
|
|||||||
<div class="header-icon">🏥</div>
|
<div class="header-icon">🏥</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="header-title">Patient Vital Signs Monitor</div>
|
<div class="header-title">Patient Vital Signs Monitor</div>
|
||||||
<div class="header-subtitle">Contec CMS7000PLUS — Real-time Monitoring</div>
|
<div class="header-subtitle">Contec Monitor — Real-time Monitoring</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<div class="device-selector-container">
|
||||||
|
<label for="deviceSelector" style="font-size: 13px; color: var(--text-secondary); font-weight: 500;">Select Monitor:</label>
|
||||||
|
<select id="deviceSelector" class="device-select-dropdown" onchange="selectDevice(this.value)">
|
||||||
|
<option value="">Waiting for devices...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="audio-toggle" id="audioToggle" onclick="toggleAudioAlert()" title="Toggle audio alerts for critical vitals">
|
<div class="audio-toggle" id="audioToggle" onclick="toggleAudioAlert()" title="Toggle audio alerts for critical vitals">
|
||||||
<span id="audioIcon">🔇</span>
|
<span id="audioIcon">🔇</span>
|
||||||
<span id="audioText">Alerts Off</span>
|
<span id="audioText">Alerts Off</span>
|
||||||
@@ -720,7 +757,7 @@
|
|||||||
<div class="setup-guide-header" onclick="toggleSetupGuide()">
|
<div class="setup-guide-header" onclick="toggleSetupGuide()">
|
||||||
<div class="setup-guide-title">
|
<div class="setup-guide-title">
|
||||||
<span>📡</span>
|
<span>📡</span>
|
||||||
<span>CMS7000PLUS Setup Guide — Configure Your Monitor</span>
|
<span>Contec CMS7000PLUS / CMS8500 Setup Guide — Configure Your Monitor</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-guide-toggle" id="setupToggleText">▾ Collapse</div>
|
<div class="setup-guide-toggle" id="setupToggleText">▾ Collapse</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,43 +765,43 @@
|
|||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">1</div>
|
<div class="step-number">1</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Connect via Ethernet</h4>
|
<h4>Connect via Wi-Fi or Ethernet</h4>
|
||||||
<p>Connect your CMS7000PLUS to this PC using an Ethernet cable (direct or through a network switch).</p>
|
<p><strong>Wi-Fi:</strong> Connect the monitor (e.g. CMS8500) and your PC to the same Wi-Fi network. <br><strong>Ethernet:</strong> Connect using an Ethernet cable (direct or switch).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">2</div>
|
<div class="step-number">2</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Set PC IP Address</h4>
|
<h4>Get PC IP Address</h4>
|
||||||
<p>Set your PC's Ethernet adapter to a static IP, e.g. <code>192.168.1.50</code>, subnet <code>255.255.255.0</code>.</p>
|
<p>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.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">3</div>
|
<div class="step-number">3</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Configure Monitor CMS Settings</h4>
|
<h4>Configure Monitor CMS Settings</h4>
|
||||||
<p>On your CMS7000PLUS: <code>System Setup → Network → CMS Settings</code>. Set the Server IP to your PC's IP.</p>
|
<p>On your monitor: Go to <code>System Setup → Network → CMS Settings</code>. Set <strong>Server IP</strong> (or CMS IP) to your PC's IP address.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">4</div>
|
<div class="step-number">4</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Set Server Port</h4>
|
<h4>Set Server Port</h4>
|
||||||
<p>Set Server Port to <code>511</code> (default). If running without admin, use a port >1024 like <code>6060</code>.</p>
|
<p>Set Server Port on your monitor to one of the listening ports: <code id="activePortsList">Detecting...</code> (usually <code>511</code> or <code>518</code>).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">5</div>
|
<div class="step-number">5</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Enable CMS Connection</h4>
|
<h4>Enable CMS Connection</h4>
|
||||||
<p>Enable the CMS/Central Monitor connection on your CMS7000PLUS. Set the sending interval (e.g., 5 seconds).</p>
|
<p>Enable the CMS/Central Monitor connection in the monitor settings. Set the sending interval (e.g. 5 seconds) to start transmitting.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-step">
|
<div class="setup-step">
|
||||||
<div class="step-number">6</div>
|
<div class="step-number">6</div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h4>Data Will Appear Automatically</h4>
|
<h4>Data Will Appear Automatically</h4>
|
||||||
<p>Once connected, vital signs will appear on this dashboard in real-time via WebSocket or polling.</p>
|
<p>Once connected, the monitor will show up in the "Select Monitor" list, and its vitals will display in real-time.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-ip-display">
|
<div class="setup-ip-display">
|
||||||
@@ -970,15 +1007,22 @@
|
|||||||
let setupGuideCollapsed = false;
|
let setupGuideCollapsed = false;
|
||||||
let hasReceivedData = false;
|
let hasReceivedData = false;
|
||||||
|
|
||||||
|
let activeDevices = [];
|
||||||
|
let selectedDeviceId = localStorage.getItem('selectedDeviceId') || '';
|
||||||
|
|
||||||
// === Initialize ===
|
// === Initialize ===
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initWaveforms();
|
initWaveforms();
|
||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
tryWebSocket();
|
fetchDevices().then(() => {
|
||||||
startPolling();
|
tryWebSocket();
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
animateWaveforms();
|
animateWaveforms();
|
||||||
fetchNetworkInfo();
|
fetchNetworkInfo();
|
||||||
|
// Periodically refresh the list of active devices
|
||||||
|
setInterval(fetchDevices, 4000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Fetch Network Info ===
|
// === 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 ===
|
// === Setup Guide Toggle ===
|
||||||
function toggleSetupGuide() {
|
function toggleSetupGuide() {
|
||||||
const body = document.getElementById('setupGuideBody');
|
const body = document.getElementById('setupGuideBody');
|
||||||
@@ -1086,7 +1222,15 @@
|
|||||||
};
|
};
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
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 = () => {
|
ws.onclose = () => {
|
||||||
useWebSocket = false;
|
useWebSocket = false;
|
||||||
@@ -1109,7 +1253,10 @@
|
|||||||
|
|
||||||
async function fetchLatestReadings() {
|
async function fetchLatestReadings() {
|
||||||
try {
|
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');
|
if (!response.ok) throw new Error('API error');
|
||||||
|
|
||||||
const readings = await response.json();
|
const readings = await response.json();
|
||||||
@@ -1138,6 +1285,12 @@
|
|||||||
status.pending_transmissions > 0
|
status.pending_transmissions > 0
|
||||||
? `${status.pending_transmissions} pending`
|
? `${status.pending_transmissions} pending`
|
||||||
: 'All sent ✓';
|
: '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) {
|
} catch (e) {
|
||||||
if (!useWebSocket) {
|
if (!useWebSocket) {
|
||||||
|
|||||||
Reference in New Issue
Block a user