refactor: implement packet parsing heuristics and introduce device-level state caching to throttle database writes and vitals updates

This commit is contained in:
2026-07-11 14:24:14 +05:30
parent a4972b3251
commit 84b8acc904
32 changed files with 3499 additions and 180 deletions
+4 -1
View File
@@ -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,
+17 -3
View File
@@ -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()
+257 -85
View File
@@ -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 == "---":
@@ -154,12 +156,20 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
val_float = safe_float(obs_val) val_float = safe_float(obs_val)
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 if pkt_len in (45, 47):
_parse_float_vitals(data, i + 8, vitals_dict) _parse_45_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 56:
_parse_56_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 286:
_parse_286_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 288:
_parse_288_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 341:
_parse_341_byte_packet(pkt_data, vitals_dict)
found_any = True
elif pkt_len == 989:
_parse_989_byte_packet(pkt_data, vitals_dict)
found_any = True
# Find the vitals summary block in this packet
# It appears near the tail of the resp waveform data
# The pattern we look for: SpO2 byte (50-100), followed by various u16 values
# In packet B, the summary is 75 bytes before the end
if pkt_len > 50:
# The summary block is consistently found around offset 322 in 397-byte packets
# Relative to packet start: around pkt_len - 75
summary_offset = i + max(4, pkt_len - 80)
if summary_offset + 10 < len(data):
# Scan in the last ~100 bytes of the packet for SpO2 pattern
scan_end = min(i + pkt_len, len(data) - 4)
scan_start = max(i + 4, scan_end - 100)
for j in range(scan_start, scan_end - 4, 2):
spo2 = read_u16_le(data, j)
hr = read_u16_le(data, j + 2)
pr = read_u16_le(data, j + 4)
# Check if this looks like [SpO2][HR/sentinel][PR]
spo2_ok = spo2 is not None and 50 <= spo2 <= 100
# HR can be 9999 (invalid/ECG disconnected) or valid 20-300
hr_or_sentinel = hr is not None and (hr == INVALID_SENTINEL_U16 or 20 <= hr <= 300)
pr_ok = pr is not None and (pr == INVALID_SENTINEL_U16 or 20 <= pr <= 300)
if spo2_ok and hr_or_sentinel and pr_ok:
vitals_dict["spo2"] = float(spo2)
found_any = True
# Use ECG HR if valid, else use SpO2 PR
if hr is not None and hr != INVALID_SENTINEL_U16 and 20 <= hr <= 300:
vitals_dict["heart_rate"] = float(hr)
elif pr is not None and pr != INVALID_SENTINEL_U16 and 20 <= pr <= 300:
vitals_dict["heart_rate"] = float(pr)
logger.info(
f"Parsed vitals block at offset {j}: "
f"SpO2={spo2}, HR={hr}, PR={pr}"
)
break
# Move past this packet
i += max(pkt_len, 4) 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)
+133 -65
View File
@@ -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) if should_write_db:
else: # Save to SQLite database and REST forward
device.last_seen = datetime.now(timezone.utc) db = SessionLocal()
try:
# 2. Patient tracking # 1. Device tracking
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
if not patient: if not device:
patient = Patient(patient_id=vitals.patient_id, name="Unknown") device = Device(
db.add(patient) device_id=vitals.device_id,
ip_address=vitals.ip_address or "unknown",
# 3. Create Reading Entry status="active"
reading = VitalReading( )
device_id=vitals.device_id, db.add(device)
patient_id=vitals.patient_id, else:
timestamp=vitals.timestamp, if vitals.ip_address:
heart_rate=vitals.heart_rate, device.ip_address = vitals.ip_address
spo2=vitals.spo2, device.status = "active"
systolic_bp=vitals.systolic_bp, device.last_seen = datetime.now(timezone.utc)
diastolic_bp=vitals.diastolic_bp,
map_bp=vitals.map_bp, # 2. Patient tracking
respiratory_rate=vitals.respiratory_rate, patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
temperature=vitals.temperature, if not patient:
transmitted=False patient = Patient(patient_id=vitals.patient_id, name="Unknown")
) db.add(patient)
db.add(reading)
db.commit() # 3. Create Reading Entry
db.refresh(reading) reading = VitalReading(
device_id=vitals.device_id,
# 4. REST forwarding patient_id=vitals.patient_id,
success = await forward_vitals_to_api(vitals) timestamp=vitals.timestamp,
heart_rate=vitals.heart_rate,
if success: spo2=vitals.spo2,
reading.transmitted = True systolic_bp=vitals.systolic_bp,
log_entry = TransmissionLog( diastolic_bp=vitals.diastolic_bp,
reading_id=reading.id, map_bp=vitals.map_bp,
status="success", respiratory_rate=vitals.respiratory_rate,
response_code=200 temperature=vitals.temperature,
transmitted=False
) )
else: db.add(reading)
log_entry = TransmissionLog( db.commit()
reading_id=reading.id, db.refresh(reading)
status="failed",
error_message="Immediate REST forward failed" # 4. REST forwarding
) success = await forward_vitals_to_api(vitals)
db.add(log_entry) if success:
db.commit() reading.transmitted = True
log_entry = TransmissionLog(
except Exception as e: reading_id=reading.id,
logger.error(f"Database operation failed: {e}") status="success",
db.rollback() response_code=200
finally: )
db.close() 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()
+11 -5
View File
@@ -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)
+1
View File
@@ -7,3 +7,4 @@ httpx
pyinstaller pyinstaller
rich rich
pyserial pyserial
websockets
+29 -5
View File
@@ -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")
+2
View File
@@ -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")
+11
View File
@@ -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()
+56
View File
@@ -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))
+40
View File
@@ -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
+114
View File
@@ -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.")
+169 -16
View File
@@ -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 &gt;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) {