Compare commits
51
Commits
0ecace84d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d3ba3eed | ||
|
|
a7999add61 | ||
|
|
68edb0b224 | ||
|
|
ecd0fe0ca6 | ||
|
|
ccab4992af | ||
|
|
cc45a2f270 | ||
|
|
ce16210525 | ||
|
|
1da4595666 | ||
|
|
ace6ad1dbd | ||
|
|
f57e8b9916 | ||
|
|
26819ac893 | ||
|
|
a8075da9ee | ||
|
|
1ebbd0e68a | ||
|
|
b35d196ba6 | ||
|
|
2342346d39 | ||
|
|
37445d2ae9 | ||
|
|
68bda2519f | ||
|
|
1b1b382f3e | ||
|
|
a0ab3c073e | ||
|
|
60da868a3a | ||
|
|
5773656b01 | ||
|
|
4dc1b8e720 | ||
|
|
48a9288e93 | ||
|
|
9d7c370836 | ||
|
|
2011544f53 | ||
|
|
4a0aae6003 | ||
|
|
dd7bf27fc0 | ||
|
|
e417ada576 | ||
|
|
fbb9febda8 | ||
|
|
14f3d45e53 | ||
|
|
78500c487a | ||
|
|
61f977e718 | ||
|
|
f262155049 | ||
|
|
0f360cf144 | ||
|
|
8e3be794ce | ||
|
|
fb291062de | ||
|
|
10884623d0 | ||
|
|
801def2dc4 | ||
|
|
d7a8e1910f | ||
|
|
e21ed72634 | ||
|
|
554771abfe | ||
|
|
ae5a928671 | ||
|
|
43c18954b8 | ||
|
|
8ebd4dc766 | ||
|
|
6f8cc31a02 | ||
|
|
f6c3d2b8cf | ||
|
|
35f90d2b49 | ||
|
|
f519527d8f | ||
|
|
073deb2814 | ||
|
|
941de4d038 | ||
|
|
732ebbd1b2 |
@@ -1,6 +1,7 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
+104
-41
@@ -1,63 +1,126 @@
|
|||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from schemas import NormalizedVitals
|
from schemas import NormalizedVitals
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
def get_http_client() -> httpx.AsyncClient:
|
||||||
|
global _http_client
|
||||||
|
if _http_client is None or _http_client.is_closed:
|
||||||
|
_http_client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=10.0),
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=20, max_connections=50)
|
||||||
|
)
|
||||||
|
return _http_client
|
||||||
|
|
||||||
|
def get_cloud_base_url() -> str:
|
||||||
|
url = getattr(settings, "cloud_api_url", None) or os.getenv("CLOUD_API_URL") or "https://services-api.a2zhealth.in/v1/contec-monitor"
|
||||||
|
return url.rstrip("/")
|
||||||
|
|
||||||
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
|
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempts to POST the normalized vitals to the configured target API
|
Attempts to POST the normalized vitals to the configured target API
|
||||||
and to the Render cloud database.
|
and to the Cloud server concurrently.
|
||||||
Returns True if at least one forward succeeds.
|
Returns True if at least one forward succeeds.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
# 1. Forward to primary target API (e.g. Injomo Dev Workflow)
|
# Strip heavy wave sample arrays before REST transmission (cloud APIs only ingest vital signs)
|
||||||
primary_url = settings.target_api_url
|
payload = vitals.model_dump(mode="json")
|
||||||
primary_success = False
|
payload.pop("ecg_wave", None)
|
||||||
if primary_url:
|
payload.pop("pleth_wave", None)
|
||||||
|
|
||||||
|
if not payload.get("patient_id"):
|
||||||
|
payload["patient_id"] = "UNKNOWN"
|
||||||
|
|
||||||
|
client = get_http_client()
|
||||||
|
|
||||||
|
async def _post_target(url: str, label: str) -> bool:
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
curr_port = os.getenv("PORT", "8000")
|
||||||
|
if f":{curr_port}" in url and ("localhost" in url or "127.0.0.1" in url):
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
primary_headers = headers.copy()
|
req_headers = headers.copy()
|
||||||
if settings.api_token:
|
if settings.api_token:
|
||||||
primary_headers["Authorization"] = f"Bearer {settings.api_token}"
|
req_headers["Authorization"] = f"Bearer {settings.api_token}"
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
url_to_call = url
|
||||||
response = await client.post(
|
if settings.api_token and "?" not in url and "/api/push-vitals" in url:
|
||||||
primary_url,
|
url_to_call = f"{url}?api_key={settings.api_token}"
|
||||||
json=vitals.model_dump(mode="json"),
|
response = await client.post(url_to_call, json=payload, headers=req_headers)
|
||||||
headers=primary_headers
|
response.raise_for_status()
|
||||||
)
|
logger.info(f"Successfully forwarded vitals to {label} ({url}) for device {vitals.device_id}")
|
||||||
response.raise_for_status()
|
return True
|
||||||
logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}")
|
|
||||||
primary_success = True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error forwarding to primary API: {e}")
|
err_msg = str(e).strip() or "timeout / connection reset"
|
||||||
|
logger.warning(f"Error forwarding to {label} ({url}): {type(e).__name__} ({err_msg})")
|
||||||
|
return False
|
||||||
|
|
||||||
# 2. Forward to Render Cloud Server
|
targets = []
|
||||||
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
|
if settings.target_api_url:
|
||||||
render_success = False
|
targets.append((settings.target_api_url, "Primary Target API"))
|
||||||
|
|
||||||
# Detect if we are already running on Render to prevent an infinite loop
|
cloud_base = get_cloud_base_url()
|
||||||
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
|
is_on_cloud = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
|
||||||
|
if not (is_on_cloud and "onrender.com" in cloud_base):
|
||||||
if not is_on_render:
|
cloud_push_url = f"{cloud_base}/api/push-vitals" if not cloud_base.endswith("/api/push-vitals") else cloud_base
|
||||||
|
if cloud_push_url not in [t[0] for t in targets]:
|
||||||
|
targets.append((cloud_push_url, "Cloud Server"))
|
||||||
|
|
||||||
|
for extra_url in getattr(settings, "forward_urls", []):
|
||||||
|
if extra_url and extra_url not in [t[0] for t in targets]:
|
||||||
|
targets.append((extra_url, "Configured Forward URL"))
|
||||||
|
|
||||||
|
tasks = [_post_target(url, lbl) for url, lbl in targets]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
return any(r is True for r in results if not isinstance(r, Exception))
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_patient_assignment_to_cloud(patient_data: dict, device_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Synchronizes patient demographics assignment to Cloud and LAN servers.
|
||||||
|
Ensures local and remote dashboards always show the exact same patient.
|
||||||
|
"""
|
||||||
|
client = get_http_client()
|
||||||
|
payload = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"mrn": patient_data.get("mrn") or patient_data.get("patient_id") or "",
|
||||||
|
"name": patient_data.get("name") or patient_data.get("patient_name") or "Unknown",
|
||||||
|
"dob": patient_data.get("dob") or patient_data.get("patient_dob"),
|
||||||
|
"gender": patient_data.get("gender") or patient_data.get("patient_gender"),
|
||||||
|
"bed_id": patient_data.get("bed_id"),
|
||||||
|
"patient_type": patient_data.get("patient_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
cloud_base = get_cloud_base_url()
|
||||||
|
targets.append(f"{cloud_base}/api/patient/assign")
|
||||||
|
|
||||||
|
for extra_url in getattr(settings, "forward_urls", []):
|
||||||
|
if "/api/push-vitals" in extra_url:
|
||||||
|
assign_url = extra_url.replace("/api/push-vitals", "/api/patient/assign")
|
||||||
|
if assign_url not in targets:
|
||||||
|
targets.append(assign_url)
|
||||||
|
|
||||||
|
async def _sync_one(url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
url_with_key = render_url
|
url_with_key = url
|
||||||
if settings.api_token:
|
if settings.api_token:
|
||||||
url_with_key = f"{render_url}?api_key={settings.api_token}"
|
url_with_key = f"{url}?api_key={settings.api_token}"
|
||||||
|
resp = await client.post(url_with_key, json=payload, headers={"Content-Type": "application/json"})
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
resp.raise_for_status()
|
||||||
response = await client.post(
|
logger.info(f"Successfully synced patient {payload['mrn']} to {url} for {device_id}")
|
||||||
url_with_key,
|
return True
|
||||||
json=vitals.model_dump(mode="json"),
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}")
|
|
||||||
render_success = True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error forwarding to Render Cloud: {e}")
|
logger.warning(f"Could not sync patient assignment to {url}: {type(e).__name__} ({e})")
|
||||||
|
return False
|
||||||
return primary_success or render_success
|
|
||||||
|
results = await asyncio.gather(*[_sync_one(u) for u in targets], return_exceptions=True)
|
||||||
|
return any(r is True for r in results if not isinstance(r, Exception))
|
||||||
|
|||||||
+141
-39
@@ -17,51 +17,153 @@ async def retry_failed_transmissions():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# 1. Fetch untransmitted readings in a quick DB session and close it immediately
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
readings_to_retry = []
|
||||||
try:
|
try:
|
||||||
# Get readings that haven't been transmitted
|
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).order_by(VitalReading.id.desc()).limit(20).all()
|
||||||
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).limit(50).all()
|
for r in failed_readings:
|
||||||
|
readings_to_retry.append({
|
||||||
for reading in failed_readings:
|
"id": r.id,
|
||||||
vitals = NormalizedVitals(
|
"vitals": NormalizedVitals(
|
||||||
device_id=reading.device_id,
|
device_id=r.device_id,
|
||||||
patient_id=reading.patient_id,
|
patient_id=r.patient_id,
|
||||||
timestamp=reading.timestamp,
|
timestamp=r.timestamp,
|
||||||
heart_rate=reading.heart_rate,
|
heart_rate=r.heart_rate,
|
||||||
spo2=reading.spo2,
|
spo2=r.spo2,
|
||||||
systolic_bp=reading.systolic_bp,
|
systolic_bp=r.systolic_bp,
|
||||||
diastolic_bp=reading.diastolic_bp,
|
diastolic_bp=r.diastolic_bp,
|
||||||
map_bp=reading.map_bp,
|
map_bp=r.map_bp,
|
||||||
respiratory_rate=reading.respiratory_rate,
|
respiratory_rate=r.respiratory_rate,
|
||||||
temperature=reading.temperature
|
temperature=r.temperature
|
||||||
)
|
|
||||||
|
|
||||||
success = await forward_vitals_to_api(vitals)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
reading.transmitted = True
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="success",
|
|
||||||
response_code=200
|
|
||||||
)
|
)
|
||||||
db.add(log_entry)
|
})
|
||||||
logger.info(f"Successfully re-transmitted reading {reading.id}")
|
|
||||||
else:
|
|
||||||
# Log the failure but don't mark as transmitted
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="failed",
|
|
||||||
error_message="Retry failed"
|
|
||||||
)
|
|
||||||
db.add(log_entry)
|
|
||||||
|
|
||||||
if failed_readings:
|
|
||||||
db.commit()
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# 2. Forward outside of any DB transaction
|
||||||
|
successful_ids = []
|
||||||
|
failed_ids = []
|
||||||
|
consecutive_failures = 0
|
||||||
|
|
||||||
|
for item in readings_to_retry:
|
||||||
|
success = await forward_vitals_to_api(item["vitals"])
|
||||||
|
if success:
|
||||||
|
successful_ids.append(item["id"])
|
||||||
|
consecutive_failures = 0
|
||||||
|
else:
|
||||||
|
failed_ids.append(item["id"])
|
||||||
|
consecutive_failures += 1
|
||||||
|
# If external API is unresponsive, stop this batch immediately so we don't spam
|
||||||
|
if consecutive_failures >= 3:
|
||||||
|
logger.warning("External API appears unreachable during retry; pausing retry queue.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. Update status in a quick DB session
|
||||||
|
if successful_ids or failed_ids:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for rid in successful_ids:
|
||||||
|
db.query(VitalReading).filter(VitalReading.id == rid).update({"transmitted": True})
|
||||||
|
db.add(TransmissionLog(reading_id=rid, status="success", response_code=200))
|
||||||
|
for rid in failed_ids:
|
||||||
|
db.add(TransmissionLog(reading_id=rid, status="failed", error_message="Retry failed"))
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed updating retry transmission status: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in retry background task: {e}")
|
logger.error(f"Error in retry background task: {e}")
|
||||||
|
|
||||||
await asyncio.sleep(settings.retry_interval_seconds)
|
await asyncio.sleep(settings.retry_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_cloud_state():
|
||||||
|
"""
|
||||||
|
Periodically syncs all assigned patients and their latest non-null vitals to Render Cloud.
|
||||||
|
Ensures that whenever Render restarts or deploys with an empty DB, the cloud dashboard
|
||||||
|
is immediately populated with the same data as localhost.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
if os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", ""):
|
||||||
|
return # Do not run sync loop if we are running inside Render
|
||||||
|
|
||||||
|
from models import Patient
|
||||||
|
from api_client import sync_patient_assignment_to_cloud, forward_vitals_to_api
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
logger.info("Starting background task for periodic cloud state sync to Render...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
patients = db.query(Patient).filter(
|
||||||
|
Patient.patient_id != "UNKNOWN",
|
||||||
|
Patient.mrn != "UNKNOWN"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for p in patients:
|
||||||
|
if not p.device_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1. Sync patient demographics assignment to Render
|
||||||
|
p_data = {
|
||||||
|
"mrn": p.mrn or p.patient_id,
|
||||||
|
"name": p.name,
|
||||||
|
"gender": p.gender,
|
||||||
|
"dob": p.dob,
|
||||||
|
"bed_id": p.bed_id,
|
||||||
|
"patient_type": getattr(p, "patient_type", None)
|
||||||
|
}
|
||||||
|
await sync_patient_assignment_to_cloud(p_data, p.device_id)
|
||||||
|
|
||||||
|
# 2. Forward latest non-null vitals to Render (Option B)
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
patient_filters = or_(
|
||||||
|
VitalReading.patient_id == p.mrn,
|
||||||
|
VitalReading.patient_id == p.patient_id,
|
||||||
|
VitalReading.device_id == p.device_id
|
||||||
|
)
|
||||||
|
hr_r = db.query(VitalReading.heart_rate).filter(patient_filters, VitalReading.heart_rate != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
spo2_r = db.query(VitalReading.spo2).filter(patient_filters, VitalReading.spo2 != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
bp_r = db.query(VitalReading.systolic_bp, VitalReading.diastolic_bp, VitalReading.map_bp).filter(patient_filters, VitalReading.systolic_bp != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
temp_r = db.query(VitalReading.temperature).filter(patient_filters, VitalReading.temperature != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
temp2_r = db.query(VitalReading.temperature2).filter(patient_filters, VitalReading.temperature2 != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
temp_diff_r = db.query(VitalReading.temperature_diff).filter(patient_filters, VitalReading.temperature_diff != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
rr_r = db.query(VitalReading.respiratory_rate).filter(patient_filters, VitalReading.respiratory_rate != None).order_by(VitalReading.timestamp.desc()).first()
|
||||||
|
|
||||||
|
if hr_r or spo2_r or temp_r or bp_r:
|
||||||
|
merged = NormalizedVitals(
|
||||||
|
device_id=p.device_id,
|
||||||
|
patient_id=p.mrn or p.patient_id,
|
||||||
|
patient_name=p.name,
|
||||||
|
patient_gender=p.gender,
|
||||||
|
patient_dob=p.dob,
|
||||||
|
bed_id=p.bed_id,
|
||||||
|
patient_type=getattr(p, "patient_type", None),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
heart_rate=hr_r[0] if hr_r else None,
|
||||||
|
spo2=spo2_r[0] if spo2_r else None,
|
||||||
|
systolic_bp=bp_r[0] if bp_r else None,
|
||||||
|
diastolic_bp=bp_r[1] if bp_r else None,
|
||||||
|
map_bp=bp_r[2] if bp_r else None,
|
||||||
|
temperature=temp_r[0] if temp_r else None,
|
||||||
|
temperature2=temp2_r[0] if temp2_r else None,
|
||||||
|
temperature_diff=temp_diff_r[0] if temp_diff_r else None,
|
||||||
|
respiratory_rate=rr_r[0] if rr_r else None
|
||||||
|
)
|
||||||
|
await forward_vitals_to_api(merged)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in sync_cloud_state background task: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -1,13 +1,18 @@
|
|||||||
{
|
{
|
||||||
"hl7_host": "0.0.0.0",
|
"hl7_host": "0.0.0.0",
|
||||||
"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, 61501],
|
||||||
"monitor_ip": "",
|
"monitor_ip": "",
|
||||||
"monitor_model": "CMS8500",
|
"monitor_model": "CMS8500",
|
||||||
"device_models": {
|
"device_models": {
|
||||||
"192.168.100.120": "CMS7000PLUS"
|
"192.168.100.120": "CMS7000PLUS"
|
||||||
},
|
},
|
||||||
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3",
|
"target_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
|
||||||
|
"cloud_api_url": "https://services-api.a2zhealth.in/v1/contec-monitor",
|
||||||
|
"forward_urls": [
|
||||||
|
"http://192.168.100.104:10000/api/push-vitals",
|
||||||
|
"https://services-api.a2zhealth.in/v1/contec-monitor"
|
||||||
|
],
|
||||||
"api_token": "",
|
"api_token": "",
|
||||||
"retry_interval_seconds": 60,
|
"retry_interval_seconds": 60,
|
||||||
"log_file": "hl7_forwarder.log",
|
"log_file": "hl7_forwarder.log",
|
||||||
|
|||||||
@@ -5,17 +5,23 @@ from pydantic_settings import BaseSettings
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
hl7_host: str = "0.0.0.0"
|
hl7_host: str = "0.0.0.0"
|
||||||
hl7_port: int = 6060
|
hl7_port: int = 6060
|
||||||
contec_ports: list = [511, 515, 516, 517, 518, 519, 520]
|
contec_ports: list = [511, 515, 516, 517, 518, 519, 520, 61501]
|
||||||
monitor_ip: str = ""
|
monitor_ip: str = ""
|
||||||
monitor_model: str = "CMS7000PLUS"
|
monitor_model: str = "CMS7000PLUS"
|
||||||
device_models: dict = {}
|
device_models: dict = {}
|
||||||
target_api_url: str = "https://api.example.com/vitals"
|
target_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
|
||||||
|
cloud_api_url: str = "https://services-api.a2zhealth.in/v1/contec-monitor"
|
||||||
|
forward_urls: list = [
|
||||||
|
"http://192.168.100.104:10000/api/push-vitals",
|
||||||
|
"https://services-api.a2zhealth.in/v1/contec-monitor"
|
||||||
|
]
|
||||||
api_token: str = ""
|
api_token: str = ""
|
||||||
retry_interval_seconds: int = 60
|
retry_interval_seconds: int = 60
|
||||||
log_file: str = "hl7_forwarder.log"
|
log_file: str = "hl7_forwarder.log"
|
||||||
database_url: str = "sqlite:///./hl7_forwarder.db"
|
database_url: str = "sqlite:///./hl7_forwarder.db"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
+151
-174
@@ -58,13 +58,14 @@ CONTEC_PARAM_MAP = {
|
|||||||
# Temperature
|
# Temperature
|
||||||
"TEMP": "temperature",
|
"TEMP": "temperature",
|
||||||
"TEMP1": "temperature",
|
"TEMP1": "temperature",
|
||||||
"TEMP2": "temperature",
|
"TEMP2": "temperature2",
|
||||||
"T1": "temperature",
|
"T1": "temperature",
|
||||||
"T2": "temperature",
|
"T2": "temperature2",
|
||||||
"BODY_TEMP": "temperature",
|
"BODY_TEMP": "temperature",
|
||||||
"TEMP-1": "temperature",
|
"TEMP-1": "temperature",
|
||||||
"TEMP-2": "temperature",
|
"TEMP-2": "temperature2",
|
||||||
"BODYTEMP": "temperature",
|
"BODYTEMP": "temperature",
|
||||||
|
"TD": "temperature_diff",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sentinel value used by Contec to mark an invalid/disconnected parameter
|
# Sentinel value used by Contec to mark an invalid/disconnected parameter
|
||||||
@@ -133,7 +134,7 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
|||||||
|
|
||||||
found_any = False
|
found_any = False
|
||||||
|
|
||||||
# Parse PID and OBX segments
|
# Parse PID, PV1 and OBX segments
|
||||||
for seg in segments[1:]:
|
for seg in segments[1:]:
|
||||||
fields = seg.split("|")
|
fields = seg.split("|")
|
||||||
if not fields:
|
if not fields:
|
||||||
@@ -142,10 +143,45 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
|||||||
seg_name = fields[0]
|
seg_name = fields[0]
|
||||||
|
|
||||||
if seg_name == "PID":
|
if seg_name == "PID":
|
||||||
|
# PID-3: MRN / Patient ID
|
||||||
if len(fields) > 3 and fields[3]:
|
if len(fields) > 3 and fields[3]:
|
||||||
patient_id = fields[3].split("^")[0]
|
patient_id = fields[3].split("^")[0].strip()
|
||||||
vitals_dict["patient_id"] = patient_id
|
vitals_dict["patient_id"] = patient_id
|
||||||
|
|
||||||
|
# PID-5: Patient Name (format: FamilyName^GivenName^...)
|
||||||
|
if len(fields) > 5 and fields[5]:
|
||||||
|
name_parts = fields[5].split("^")
|
||||||
|
family = name_parts[0].strip() if len(name_parts) > 0 else ""
|
||||||
|
given = name_parts[1].strip() if len(name_parts) > 1 else ""
|
||||||
|
full_name = f"{given} {family}".strip() or family or given
|
||||||
|
if full_name:
|
||||||
|
vitals_dict["patient_name"] = full_name
|
||||||
|
|
||||||
|
# PID-7: Date of Birth (YYYYMMDD or YYYY-MM-DD)
|
||||||
|
if len(fields) > 7 and fields[7]:
|
||||||
|
vitals_dict["patient_dob"] = fields[7].strip()
|
||||||
|
|
||||||
|
# PID-8: Administrative Sex (M / F / U / O)
|
||||||
|
if len(fields) > 8 and fields[8]:
|
||||||
|
vitals_dict["patient_gender"] = fields[8].strip().upper()
|
||||||
|
|
||||||
|
# PID-35: Patient Category (ADULT / PEDIATRIC / NEONATAL)
|
||||||
|
if len(fields) > 35 and fields[35]:
|
||||||
|
vitals_dict["patient_type"] = fields[35].strip()
|
||||||
|
|
||||||
|
elif seg_name == "PV1":
|
||||||
|
# PV1-3: Assigned Patient Location (Room^Bed^...)
|
||||||
|
if len(fields) > 3 and fields[3]:
|
||||||
|
location_parts = fields[3].split("^")
|
||||||
|
# Use second component (Bed) if present, else first (Room)
|
||||||
|
bed = location_parts[1].strip() if len(location_parts) > 1 else location_parts[0].strip()
|
||||||
|
if bed:
|
||||||
|
vitals_dict["bed_id"] = bed
|
||||||
|
|
||||||
|
# PV1-18: Patient Type
|
||||||
|
if len(fields) > 18 and fields[18]:
|
||||||
|
vitals_dict["patient_type"] = fields[18].strip()
|
||||||
|
|
||||||
elif seg_name == "OBX":
|
elif seg_name == "OBX":
|
||||||
if len(fields) > 5:
|
if len(fields) > 5:
|
||||||
obs_val = fields[5].strip()
|
obs_val = fields[5].strip()
|
||||||
@@ -220,7 +256,7 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[
|
|||||||
vitals_dict["spo2"] = float(spo2)
|
vitals_dict["spo2"] = float(spo2)
|
||||||
found = True
|
found = True
|
||||||
|
|
||||||
# Heart rate: prefer ECG HR; fall back to SpO2-derived PR if ECG invalid
|
# Heart rate: ECG HR at offset+2 is primary; SpO2 PR at offset+4 is fallback.
|
||||||
if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300:
|
if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300:
|
||||||
vitals_dict["heart_rate"] = float(hr_ecg)
|
vitals_dict["heart_rate"] = float(hr_ecg)
|
||||||
found = True
|
found = True
|
||||||
@@ -233,59 +269,52 @@ def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[
|
|||||||
|
|
||||||
def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Parse 45-byte packet (Subtype 22) — contains NIBP values and alarm limits.
|
Parse 45/47-byte packet (Subtype 22) — contains NIBP values and alarm limits.
|
||||||
|
|
||||||
Verified layout:
|
Verified layout:
|
||||||
[0-1] Packet length LE u16 = 45
|
[0-1] Packet length LE u16 = 45 or 47
|
||||||
[2-3] Marker: 01 46
|
[2-3] Marker: 01 46 or 00 46
|
||||||
[4-7] Sub-header (00 00 16 00)
|
[4-7] Sub-header (00 00 16 00)
|
||||||
[8-9] Year LE u16
|
[8-9] Year LE u16
|
||||||
[10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds
|
[10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds
|
||||||
[15-16] NIBP Systolic (LE u16, 9999 = no measurement)
|
[15-16] NIBP Systolic (LE u16, 32767 / 9999 = no measurement / idle)
|
||||||
[17-18] NIBP Diastolic (LE u16, 9999 = no measurement)
|
[17-18] NIBP Diastolic (LE u16, 32767 / 9999 = no measurement / idle)
|
||||||
[19-20] NIBP MAP (LE u16, 9999 = no measurement)
|
[19-20] NIBP MAP (LE u16, 32767 / 9999 = no measurement / idle)
|
||||||
"""
|
"""
|
||||||
if len(data) < 22:
|
if len(data) < 21:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
if "present_fields" not in vitals_dict:
|
||||||
vitals_dict["present_fields"] = []
|
vitals_dict["present_fields"] = []
|
||||||
|
|
||||||
|
logger.debug("DEBUG_NIBP_PKT len=%d nibp_hex=%s", len(data), data[8:24].hex())
|
||||||
nibp_sys = read_u16_le(data, 15)
|
nibp_sys = read_u16_le(data, 15)
|
||||||
nibp_dia = read_u16_le(data, 17)
|
nibp_dia = read_u16_le(data, 17)
|
||||||
nibp_map = read_u16_le(data, 19)
|
nibp_map = read_u16_le(data, 19)
|
||||||
|
|
||||||
found = False
|
sentinels = (9999, 32767, 65535, 0)
|
||||||
|
|
||||||
# Mark NIBP fields as present so cache can clear them if 9999
|
if nibp_sys is not None and nibp_sys not in sentinels and 40 <= nibp_sys <= 260:
|
||||||
for f in ["systolic_bp", "diastolic_bp", "map_bp"]:
|
|
||||||
if f not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append(f)
|
|
||||||
|
|
||||||
if nibp_sys is not None and nibp_sys != 9999 and 40 < nibp_sys < 250:
|
|
||||||
vitals_dict["systolic_bp"] = float(nibp_sys)
|
vitals_dict["systolic_bp"] = float(nibp_sys)
|
||||||
found = True
|
if "systolic_bp" not in vitals_dict["present_fields"]:
|
||||||
else:
|
vitals_dict["present_fields"].append("systolic_bp")
|
||||||
vitals_dict["systolic_bp"] = None
|
|
||||||
|
|
||||||
if nibp_dia is not None and nibp_dia != 9999 and 20 < nibp_dia < 200:
|
if nibp_dia is not None and nibp_dia not in sentinels and 20 <= nibp_dia <= 200:
|
||||||
vitals_dict["diastolic_bp"] = float(nibp_dia)
|
vitals_dict["diastolic_bp"] = float(nibp_dia)
|
||||||
found = True
|
if "diastolic_bp" not in vitals_dict["present_fields"]:
|
||||||
else:
|
vitals_dict["present_fields"].append("diastolic_bp")
|
||||||
vitals_dict["diastolic_bp"] = None
|
|
||||||
|
|
||||||
if nibp_map is not None and nibp_map != 9999 and 20 < nibp_map < 250:
|
if nibp_map is not None and nibp_map not in sentinels and 20 <= nibp_map <= 250:
|
||||||
vitals_dict["map_bp"] = float(nibp_map)
|
vitals_dict["map_bp"] = float(nibp_map)
|
||||||
found = True
|
if "map_bp" not in vitals_dict["present_fields"]:
|
||||||
else:
|
vitals_dict["present_fields"].append("map_bp")
|
||||||
vitals_dict["map_bp"] = None
|
|
||||||
|
|
||||||
return found
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Parse 56-byte packet (Subtype 23) — contains float32 Temperature values.
|
Parse 56-byte packet (Subtype 23) — contains float32 Temperature values for T1 and T2.
|
||||||
|
|
||||||
Verified layout:
|
Verified layout:
|
||||||
[8-11] Temperature 1 (float32)
|
[8-11] Temperature 1 (float32)
|
||||||
@@ -298,134 +327,91 @@ def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
if "present_fields" not in vitals_dict:
|
||||||
vitals_dict["present_fields"] = []
|
vitals_dict["present_fields"] = []
|
||||||
if "temperature" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("temperature")
|
|
||||||
vitals_dict.setdefault("temperature", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
temp1 = read_f32_le(data, 8)
|
temp1 = read_f32_le(data, 8)
|
||||||
temp2 = read_f32_le(data, 12)
|
temp2 = read_f32_le(data, 12)
|
||||||
|
|
||||||
# 9999.0 is sentinel
|
def is_real_temp(t):
|
||||||
if temp1 is not None and is_valid_f32(temp1) and 10.0 < temp1 < 50.0:
|
# Disconnected sentinel is 9999.0 or 3276.7 (0x7FFF)
|
||||||
vitals_dict["temperature"] = round(temp1, 1)
|
return t is not None and is_valid_f32(t) and abs(t - 3276.7) > 1.0 and 20.0 < t < 45.0
|
||||||
found = True
|
|
||||||
elif temp2 is not None and is_valid_f32(temp2) and 10.0 < temp2 < 50.0:
|
|
||||||
vitals_dict["temperature"] = round(temp2, 1)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
t1_val = round(temp1, 1) if is_real_temp(temp1) else None
|
||||||
|
t2_val = round(temp2, 1) if is_real_temp(temp2) else None
|
||||||
|
|
||||||
|
vitals_dict["temperature"] = t1_val
|
||||||
|
vitals_dict["temperature2"] = t2_val
|
||||||
|
if t1_val is not None and t2_val is not None:
|
||||||
|
vitals_dict["temperature_diff"] = round(abs(t1_val - t2_val), 1)
|
||||||
|
else:
|
||||||
|
vitals_dict["temperature_diff"] = None
|
||||||
|
|
||||||
|
if "temperature" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("temperature")
|
||||||
|
if "temperature2" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("temperature2")
|
||||||
|
if "temperature_diff" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("temperature_diff")
|
||||||
|
|
||||||
|
return (t1_val is not None or t2_val is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_spo2_waveform_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Parse SpO2 & Pleth waveform packet (Subtype 21 for CMS7000PLUS / CMS8500).
|
||||||
|
SpO2% is located at offset 264. Disconnected sentinel is 65535, 32767, or 255.
|
||||||
|
Offsets 270+ contain alarm limits (100, 90) and must NOT be parsed as vitals.
|
||||||
|
"""
|
||||||
|
if len(data) < 266:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "present_fields" not in vitals_dict:
|
||||||
|
vitals_dict["present_fields"] = []
|
||||||
|
|
||||||
|
# Extract Pleth waveform samples (bytes 8..263)
|
||||||
|
vitals_dict["pleth_wave"] = list(data[8:min(264, len(data))])
|
||||||
|
if "pleth_wave" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("pleth_wave")
|
||||||
|
|
||||||
|
# Check SpO2 at standard offset 264
|
||||||
|
spo2_val = read_u16_le(data, 264)
|
||||||
|
|
||||||
|
if spo2_val is not None and 50 <= spo2_val <= 100:
|
||||||
|
vitals_dict["spo2"] = float(spo2_val)
|
||||||
|
if "spo2" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("spo2")
|
||||||
|
|
||||||
|
# Pulse Rate / ECG HR is at offset 266 in the footer
|
||||||
|
ecg_hr = read_u16_le(data, 266)
|
||||||
|
if ecg_hr is not None and ecg_hr not in (9999, 65535, 32767, 0) and 20 <= ecg_hr <= 300:
|
||||||
|
vitals_dict["heart_rate"] = float(ecg_hr)
|
||||||
|
if "heart_rate" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("heart_rate")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SpO2 pkt: SpO2[264]={spo2_val} ECG_HR[266]={ecg_hr} alarm_hi[268]={read_u16_le(data,268)} alarm_lo[270]={read_u16_le(data,270) if len(data)>=272 else 'N/A'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
return _parse_spo2_waveform_packet(data, vitals_dict)
|
||||||
Parse 286-byte waveform packet (Subtype 21).
|
|
||||||
|
|
||||||
Verified layout 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:
|
def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
return _parse_spo2_waveform_packet(data, vitals_dict)
|
||||||
Parse 288-byte waveform packet (Subtype 21 for CMS8500).
|
|
||||||
|
|
||||||
Layout:
|
|
||||||
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
|
|
||||||
[266-267] PR (LE u16) — pulse rate
|
|
||||||
"""
|
|
||||||
if len(data) < 270:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "spo2" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("spo2")
|
|
||||||
vitals_dict.setdefault("spo2", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
# Offset 264: SpO2 percentage
|
|
||||||
spo2_val = read_u16_le(data, 264)
|
|
||||||
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
# Offset 266: PR from SpO2 PR
|
|
||||||
hr_val = read_u16_le(data, 266)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
return _parse_spo2_waveform_packet(data, vitals_dict)
|
||||||
Parse 341-byte waveform packet. Same tail layout as 286-byte.
|
|
||||||
"""
|
|
||||||
if len(data) < 270:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "spo2" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("spo2")
|
|
||||||
vitals_dict.setdefault("spo2", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
spo2_val = read_u16_le(data, 264)
|
|
||||||
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
hr_val = read_u16_le(data, 266)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Parse 989-byte waveform packet (Subtype 20).
|
Parse 989-byte waveform packet (Subtype 20 / 14h).
|
||||||
|
|
||||||
Verified layout:
|
Verified layout:
|
||||||
|
[8..903] ECG waveform samples (8-bit unsigned, 896 samples @ ~250Hz)
|
||||||
[904-905] ECG Heart Rate (LE u16)
|
[904-905] ECG Heart Rate (LE u16)
|
||||||
65535 or 9999 = invalid/disconnected
|
65535 or 9999 = invalid/disconnected
|
||||||
"""
|
"""
|
||||||
@@ -434,31 +420,27 @@ def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
if "present_fields" not in vitals_dict:
|
||||||
vitals_dict["present_fields"] = []
|
vitals_dict["present_fields"] = []
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
vitals_dict.setdefault("heart_rate", None)
|
|
||||||
|
|
||||||
found = False
|
# Extract ECG waveform samples (bytes 8..903), downsample to ~224 points for smooth real-time rendering
|
||||||
|
raw_ecg = list(data[8:904])
|
||||||
|
vitals_dict["ecg_wave"] = raw_ecg[::4]
|
||||||
|
if "ecg_wave" not in vitals_dict["present_fields"]:
|
||||||
|
vitals_dict["present_fields"].append("ecg_wave")
|
||||||
|
|
||||||
|
logger.debug("DEBUG_ECG_PKT len=%d tail_hex=%s", len(data), data[890:989].hex())
|
||||||
hr_val = read_u16_le(data, 904)
|
hr_val = read_u16_le(data, 904)
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
if hr_val is not None and hr_val not in (65535, 32767, 9999, 0) and 20 <= hr_val <= 300:
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
if vitals_dict.get("heart_rate") is None:
|
||||||
found = True
|
vitals_dict["heart_rate"] = float(hr_val)
|
||||||
|
if "heart_rate" not in vitals_dict.setdefault("present_fields", []):
|
||||||
|
vitals_dict["present_fields"].append("heart_rate")
|
||||||
|
|
||||||
return found
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
|
def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
|
||||||
"""
|
"""
|
||||||
Scan the data stream for Contec CMS7000PLUS sub-packets and parse them
|
Scan the data stream for Contec CMS7000PLUS / CMS8500 sub-packets and parse them.
|
||||||
deterministically based on packet length.
|
|
||||||
|
|
||||||
Packet types (verified by raw hex analysis):
|
|
||||||
45 bytes (Subtype 22): NIBP Sys/Dia/MAP (u16 LE starting at offset 15)
|
|
||||||
56 bytes (Subtype 23): Float32 Temperature (offset 8/12)
|
|
||||||
286 bytes (Subtype 21): SpO2% at offset 264
|
|
||||||
341 bytes (Subtype 21): SpO2% at offset 264
|
|
||||||
989 bytes (Subtype 20): ECG Heart Rate at offset 904
|
|
||||||
"""
|
"""
|
||||||
model = settings.device_models.get(client_ip, settings.monitor_model)
|
model = settings.device_models.get(client_ip, settings.monitor_model)
|
||||||
vitals_dict = {
|
vitals_dict = {
|
||||||
@@ -478,23 +460,18 @@ def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[Normal
|
|||||||
pkt_data = data[i:i + pkt_len]
|
pkt_data = data[i:i + pkt_len]
|
||||||
|
|
||||||
if pkt_len in (45, 47):
|
if pkt_len in (45, 47):
|
||||||
_parse_45_byte_packet(pkt_data, vitals_dict)
|
if _parse_45_byte_packet(pkt_data, vitals_dict):
|
||||||
found_any = True
|
found_any = True
|
||||||
elif pkt_len == 56:
|
elif pkt_len == 56:
|
||||||
_parse_56_byte_packet(pkt_data, vitals_dict)
|
if _parse_56_byte_packet(pkt_data, vitals_dict):
|
||||||
found_any = True
|
found_any = True
|
||||||
elif pkt_len == 286:
|
elif pkt_len in (286, 288, 341):
|
||||||
_parse_286_byte_packet(pkt_data, vitals_dict)
|
if _parse_spo2_waveform_packet(pkt_data, vitals_dict):
|
||||||
found_any = True
|
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:
|
elif pkt_len == 989:
|
||||||
_parse_989_byte_packet(pkt_data, vitals_dict)
|
if _parse_989_byte_packet(pkt_data, vitals_dict):
|
||||||
found_any = True
|
found_any = True
|
||||||
|
# Handled specifically by known packet sizes above
|
||||||
|
|
||||||
i += max(pkt_len, 4)
|
i += max(pkt_len, 4)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+463
-49
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from contec_parser import parse_contec_data
|
from contec_parser import parse_contec_data
|
||||||
@@ -34,16 +35,255 @@ def generate_ack(received_msg: str) -> str:
|
|||||||
msg_control_id = fields[9]
|
msg_control_id = fields[9]
|
||||||
|
|
||||||
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
ack_msh = f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}|{now_str}||ACK^R01|{msg_control_id}|P|2.3.1\r"
|
ack_msh = (
|
||||||
|
f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}"
|
||||||
|
f"|{now_str}||ACK|{now_str}|P|2.3\r"
|
||||||
|
)
|
||||||
ack_msa = f"MSA|AA|{msg_control_id}\r"
|
ack_msa = f"MSA|AA|{msg_control_id}\r"
|
||||||
|
|
||||||
return ack_msh + ack_msa
|
return ack_msh + ack_msa
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not generate ACK: {e}")
|
logger.debug(f"Could not generate ACK: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
TELEMETRY_WORDS = {
|
||||||
|
'sensor', 'off', 'lead', 'leads', 'fall', 'searching', 'pulse',
|
||||||
|
'le', 'le1', 'le2', 'le3', 'le4', 'le5', 'alarm', 'probe',
|
||||||
|
'cuff', 'error', 'leak', 'motion', 'weak', 'signal', 'learning', 'arr',
|
||||||
|
'asystole', 'vfib', 'vtac', 'pvc', 'brady', 'tachy', 'adult', 'pediatric',
|
||||||
|
'neonate', 'unknown', 'none', 'clear', 'default', 'demo', 't1', 't2',
|
||||||
|
'spo2', 'ecg', 'nibp', 'resp', 'temp', 'hr', 'pr', 'map', 'dia', 'sys'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_telemetry_word(s_lower: str) -> bool:
|
||||||
|
"""Check if string matches hardware telemetry or alarm words."""
|
||||||
|
if s_lower in TELEMETRY_WORDS:
|
||||||
|
return True
|
||||||
|
tokens = set(re.findall(r'[a-zA-Z0-9]+', s_lower))
|
||||||
|
alarm_tokens = {
|
||||||
|
'sensor', 'alarm', 'lead', 'leads', 'searching', 'pulse', 'cuff', 'probe',
|
||||||
|
'off', 'fall', 'leak', 'motion', 'weak', 'arr', 'asystole', 'vfib', 'vtac',
|
||||||
|
'pvc', 'brady', 'tachy', 'error', 'ecg', 'spo2', 'nibp', 'resp', 'temp',
|
||||||
|
'mv', 'bpm', 'mmhg'
|
||||||
|
}
|
||||||
|
if any(tok in alarm_tokens for tok in tokens):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_demographics_from_packet(data: bytes, client_ip: str) -> dict:
|
||||||
|
"""
|
||||||
|
Extracts real patient demographics from Contec packets.
|
||||||
|
Only extracts clean, legitimate patient fields — strictly rejects sensor alarms,
|
||||||
|
waveform noise, and hardware telemetry.
|
||||||
|
"""
|
||||||
|
patient_type = None
|
||||||
|
data_upper = data.upper()
|
||||||
|
if b'PEDIATRIC' in data_upper or b'PED' in data_upper:
|
||||||
|
patient_type = 'Pediatric'
|
||||||
|
elif b'NEONATE' in data_upper or b'NEONATAL' in data_upper or b'NEO' in data_upper:
|
||||||
|
patient_type = 'Neonatal'
|
||||||
|
elif b'ADULT' in data_upper or b'ADU' in data_upper:
|
||||||
|
patient_type = 'Adult'
|
||||||
|
|
||||||
|
# Strictly reject binary waveform/measurement packets from generating false patient records.
|
||||||
|
# Patient MRN/Name extraction requires an authentic HL7 message with PID or MSH.
|
||||||
|
if b'PID|' not in data and b'MSH|' not in data:
|
||||||
|
if patient_type:
|
||||||
|
return {"patient_type": patient_type}
|
||||||
|
return None
|
||||||
|
|
||||||
|
clean = []
|
||||||
|
# Match strings of printable alphanumeric and reasonable characters, allowing single spaces
|
||||||
|
for m in re.finditer(rb'[a-zA-Z0-9_\-\.]{2,}(?:\s+[a-zA-Z0-9_\-\.]{2,})*', data):
|
||||||
|
s = m.group(0).decode('latin1', errors='ignore').strip()
|
||||||
|
if not s or len(s) < 2:
|
||||||
|
continue
|
||||||
|
s_lower = s.lower()
|
||||||
|
if is_telemetry_word(s_lower):
|
||||||
|
continue
|
||||||
|
if len(set(s)) == 1:
|
||||||
|
continue
|
||||||
|
if set(s).issubset({'.', '-', '_', '/', '\\'}):
|
||||||
|
continue
|
||||||
|
if len(s) > 6 and re.match(r'^[A-Za-z0-9]+$', s):
|
||||||
|
diffs = [abs(ord(s[i]) - ord(s[i-1])) for i in range(1, len(s))]
|
||||||
|
if sum(1 for d in diffs if d <= 2) / len(diffs) > 0.6:
|
||||||
|
continue
|
||||||
|
if re.match(r'^\.\d+$', s):
|
||||||
|
continue
|
||||||
|
clean.append(s)
|
||||||
|
|
||||||
|
if not clean and not patient_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = None
|
||||||
|
mrn = None
|
||||||
|
dob = None
|
||||||
|
gender = None
|
||||||
|
|
||||||
|
for s in clean:
|
||||||
|
s_up = s.upper()
|
||||||
|
s_low = s.lower()
|
||||||
|
|
||||||
|
# Check gender first
|
||||||
|
if s_up in ('MALE', 'FEMALE') and not gender:
|
||||||
|
gender = 'Male' if s_up == 'MALE' else 'Female'
|
||||||
|
continue
|
||||||
|
elif s_up in ('M', 'F') and not gender:
|
||||||
|
gender = 'Male' if s_up == 'M' else 'Female'
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_telemetry_word(s_low):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Date of birth: 4-digit birth year between 1920 and 2026, or YYYY-MM-DD
|
||||||
|
if re.match(r'^(19\d\d|20[01]\d|202[0-6])$', s) and not dob:
|
||||||
|
dob = s
|
||||||
|
continue
|
||||||
|
elif re.match(r'^\d{4}[-/]\d{2}[-/]\d{2}$', s) and not dob:
|
||||||
|
dob = s
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Name candidate: contains letters and a vowel, length between 3 and 40
|
||||||
|
if re.search(r'^[A-Za-z\s\.\-]{3,40}$', s) and re.search(r'[aeiouyAEIOUY]', s) and not name:
|
||||||
|
if not is_telemetry_word(s_low):
|
||||||
|
name = s
|
||||||
|
continue
|
||||||
|
|
||||||
|
# MRN candidate: numeric or alphanumeric ID, 2..15 chars, not a birth year
|
||||||
|
if not mrn and re.match(r'^[A-Za-z0-9\-]{2,15}$', s) and not is_telemetry_word(s_low):
|
||||||
|
if not (s.isdigit() and 1920 <= int(s) <= 2030):
|
||||||
|
if s.isdigit() and len(s) > 10:
|
||||||
|
continue
|
||||||
|
mrn = s
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Accept if valid MRN or Name was detected
|
||||||
|
if mrn or name:
|
||||||
|
clean_mrn = mrn or name
|
||||||
|
clean_name = name or f"Patient {clean_mrn}"
|
||||||
|
return {
|
||||||
|
"name": clean_name,
|
||||||
|
"mrn": clean_mrn,
|
||||||
|
"patient_id": clean_mrn,
|
||||||
|
"dob": dob,
|
||||||
|
"gender": gender,
|
||||||
|
"patient_type": patient_type,
|
||||||
|
}
|
||||||
|
elif patient_type:
|
||||||
|
return {
|
||||||
|
"patient_type": patient_type,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_auto_demographics(client_ip: str, demo: dict):
|
||||||
|
from config import settings
|
||||||
|
from api_client import sync_patient_assignment_to_cloud
|
||||||
|
model = settings.device_models.get(client_ip, settings.monitor_model)
|
||||||
|
device_id = f"{model}_{client_ip}"
|
||||||
|
name = demo.get("name")
|
||||||
|
mrn = demo.get("mrn")
|
||||||
|
dob = demo.get("dob")
|
||||||
|
gender = demo.get("gender")
|
||||||
|
patient_type = demo.get("patient_type")
|
||||||
|
|
||||||
|
current = device_patient_map.get(device_id, {})
|
||||||
|
# If only patient_type updated
|
||||||
|
if not mrn and not name:
|
||||||
|
if patient_type and current.get("patient_type") != patient_type:
|
||||||
|
current["patient_type"] = patient_type
|
||||||
|
device_patient_map[device_id] = current
|
||||||
|
return
|
||||||
|
|
||||||
|
if (current.get("name") == name and current.get("mrn") == mrn and
|
||||||
|
current.get("dob") == dob and current.get("gender") == gender and
|
||||||
|
current.get("patient_type") == patient_type):
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"*** REAL PATIENT DEMOGRAPHICS DETECTED FROM MACHINE ({client_ip}): Name={name}, MRN={mrn}, DOB={dob}, Gender={gender}, Type={patient_type} ***")
|
||||||
|
|
||||||
|
bed_id = "1" if "192.168.100.106" in client_ip else "2"
|
||||||
|
p_data = {
|
||||||
|
"patient_id": mrn,
|
||||||
|
"mrn": mrn,
|
||||||
|
"name": name,
|
||||||
|
"gender": gender,
|
||||||
|
"dob": dob,
|
||||||
|
"bed_id": bed_id,
|
||||||
|
"patient_type": patient_type or current.get("patient_type"),
|
||||||
|
}
|
||||||
|
device_patient_map[device_id] = p_data
|
||||||
|
|
||||||
|
if device_id in device_cache:
|
||||||
|
cached = device_cache[device_id]
|
||||||
|
cached.patient_id = mrn
|
||||||
|
cached.patient_name = name
|
||||||
|
cached.patient_gender = gender
|
||||||
|
cached.patient_dob = dob
|
||||||
|
cached.bed_id = bed_id
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(Patient).filter(Patient.device_id == device_id).update({"device_id": None})
|
||||||
|
patient = db.query(Patient).filter(Patient.mrn == mrn).first()
|
||||||
|
if not patient:
|
||||||
|
patient = Patient(
|
||||||
|
patient_id=mrn,
|
||||||
|
mrn=mrn,
|
||||||
|
name=name or "Unknown",
|
||||||
|
gender=gender,
|
||||||
|
dob=dob,
|
||||||
|
bed_id=bed_id,
|
||||||
|
device_id=device_id
|
||||||
|
)
|
||||||
|
db.add(patient)
|
||||||
|
else:
|
||||||
|
patient.patient_id = mrn
|
||||||
|
patient.mrn = mrn
|
||||||
|
if name:
|
||||||
|
patient.name = name
|
||||||
|
if gender:
|
||||||
|
patient.gender = gender
|
||||||
|
if dob:
|
||||||
|
patient.dob = dob
|
||||||
|
patient.bed_id = bed_id
|
||||||
|
patient.device_id = device_id
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error persisting auto patient: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from routers.dashboard import get_ws_manager
|
||||||
|
ws_manager = get_ws_manager()
|
||||||
|
await ws_manager.broadcast_vitals({
|
||||||
|
"event": "patient_assigned",
|
||||||
|
"device_id": device_id,
|
||||||
|
"patient_id": mrn,
|
||||||
|
"mrn": mrn,
|
||||||
|
"patient_name": name,
|
||||||
|
"name": name,
|
||||||
|
"patient_gender": gender,
|
||||||
|
"gender": gender,
|
||||||
|
"patient_dob": dob,
|
||||||
|
"dob": dob,
|
||||||
|
"bed_id": bed_id,
|
||||||
|
"patient_type": p_data.get("patient_type"),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to broadcast auto patient: {e}")
|
||||||
|
|
||||||
|
# Forward to Render Cloud Server so remote is kept in sync
|
||||||
|
asyncio.create_task(sync_patient_assignment_to_cloud(p_data, device_id))
|
||||||
|
|
||||||
|
|
||||||
async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
|
async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
|
||||||
"""
|
"""
|
||||||
TCP server handler for Contec CMS7000PLUS client connections.
|
TCP server handler for Contec CMS7000PLUS client connections.
|
||||||
@@ -52,6 +292,13 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
client_ip, client_port = writer.get_extra_info('peername')
|
client_ip, client_port = writer.get_extra_info('peername')
|
||||||
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
|
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
|
||||||
|
|
||||||
|
sock = writer.get_extra_info('socket')
|
||||||
|
if sock:
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
display = get_terminal_display()
|
display = get_terminal_display()
|
||||||
display.connection_opened(client_ip, port)
|
display.connection_opened(client_ip, port)
|
||||||
|
|
||||||
@@ -64,8 +311,15 @@ async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
|
logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Dynamically extract and apply patient demographics sent from the machine
|
||||||
|
try:
|
||||||
|
demo = extract_demographics_from_packet(data, client_ip)
|
||||||
|
if demo:
|
||||||
|
await apply_auto_demographics(client_ip, demo)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.debug(f"Demographics extraction error: {ex}")
|
||||||
|
|
||||||
buffer += data
|
buffer += data
|
||||||
logger.debug(f"Received {len(data)} bytes from {client_ip}:{client_port} on port {port} (buf={len(buffer)})")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# MLLP / HL7 text path (typically port 511)
|
# MLLP / HL7 text path (typically port 511)
|
||||||
@@ -166,6 +420,50 @@ device_cache = {}
|
|||||||
device_field_timestamps = {} # {device_id: {field_name: datetime}}
|
device_field_timestamps = {} # {device_id: {field_name: datetime}}
|
||||||
last_db_write = {}
|
last_db_write = {}
|
||||||
|
|
||||||
|
# In-memory mapping of device_id -> patient demographic dict:
|
||||||
|
# {"mrn": "...", "name": "...", "dob": "...", "gender": "...", "bed_id": "..."}
|
||||||
|
device_patient_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_assigned_patient(device_id: str):
|
||||||
|
"""Retrieve patient demographics assigned to device_id (cached or from DB)."""
|
||||||
|
if device_id in device_patient_map:
|
||||||
|
return device_patient_map[device_id]
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
patient = db.query(Patient).filter(Patient.device_id == device_id).first()
|
||||||
|
if patient:
|
||||||
|
p_data = {
|
||||||
|
"patient_id": patient.mrn or patient.patient_id,
|
||||||
|
"mrn": patient.mrn or patient.patient_id,
|
||||||
|
"name": patient.name or "Unknown",
|
||||||
|
"gender": patient.gender,
|
||||||
|
"dob": patient.dob,
|
||||||
|
"bed_id": patient.bed_id,
|
||||||
|
"patient_type": getattr(patient, "patient_type", None),
|
||||||
|
}
|
||||||
|
device_patient_map[device_id] = p_data
|
||||||
|
return p_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying patient for device {device_id}: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_assigned_patient(device_id: str, patient_data: dict):
|
||||||
|
"""Update in-memory mapping and active device cache for device_id."""
|
||||||
|
device_patient_map[device_id] = patient_data
|
||||||
|
if device_id in device_cache:
|
||||||
|
cached = device_cache[device_id]
|
||||||
|
cached.patient_id = patient_data.get("mrn") or patient_data.get("patient_id") or "UNKNOWN"
|
||||||
|
cached.patient_name = patient_data.get("name")
|
||||||
|
cached.patient_gender = patient_data.get("gender")
|
||||||
|
cached.patient_dob = patient_data.get("dob")
|
||||||
|
cached.bed_id = patient_data.get("bed_id")
|
||||||
|
cached.patient_type = patient_data.get("patient_type")
|
||||||
|
|
||||||
|
|
||||||
async def process_vitals(vitals):
|
async def process_vitals(vitals):
|
||||||
"""
|
"""
|
||||||
@@ -177,6 +475,30 @@ async def process_vitals(vitals):
|
|||||||
if device_id not in device_field_timestamps:
|
if device_id not in device_field_timestamps:
|
||||||
device_field_timestamps[device_id] = {}
|
device_field_timestamps[device_id] = {}
|
||||||
|
|
||||||
|
# Check for assigned patient demographics
|
||||||
|
assigned = get_assigned_patient(device_id)
|
||||||
|
if (vitals.patient_id == "UNKNOWN" or not vitals.patient_name or vitals.patient_name == "Unknown") and assigned:
|
||||||
|
vitals.patient_id = assigned.get("mrn") or assigned.get("patient_id") or "UNKNOWN"
|
||||||
|
vitals.patient_name = assigned.get("name")
|
||||||
|
vitals.patient_gender = assigned.get("gender")
|
||||||
|
vitals.patient_dob = assigned.get("dob")
|
||||||
|
vitals.bed_id = assigned.get("bed_id")
|
||||||
|
vitals.patient_type = assigned.get("patient_type")
|
||||||
|
elif vitals.patient_name and vitals.patient_name != "Unknown":
|
||||||
|
# Monitor sent fresh demographics via HL7 PID segment!
|
||||||
|
device_patient_map[device_id] = {
|
||||||
|
"patient_id": vitals.patient_id,
|
||||||
|
"mrn": vitals.patient_id,
|
||||||
|
"name": vitals.patient_name,
|
||||||
|
"gender": vitals.patient_gender,
|
||||||
|
"dob": vitals.patient_dob,
|
||||||
|
"bed_id": vitals.bed_id,
|
||||||
|
"patient_type": getattr(vitals, "patient_type", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if incoming packet has fresh waveforms
|
||||||
|
has_fresh_waveforms = (vitals.ecg_wave is not None) or (vitals.pleth_wave is not None)
|
||||||
|
|
||||||
# 1. Merge new vitals into cached vitals to prevent fragmented entries
|
# 1. Merge new vitals into cached vitals to prevent fragmented entries
|
||||||
if device_id not in device_cache:
|
if device_id not in device_cache:
|
||||||
device_cache[device_id] = vitals
|
device_cache[device_id] = vitals
|
||||||
@@ -200,20 +522,37 @@ async def process_vitals(vitals):
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
active_fields.append(field)
|
active_fields.append(field)
|
||||||
|
|
||||||
# Update cache & record timestamps
|
# Update cache & record timestamps for present values
|
||||||
for field in active_fields:
|
for field in active_fields:
|
||||||
val = getattr(vitals, field)
|
val = getattr(vitals, field, None)
|
||||||
setattr(cached, field, val)
|
if val is not None:
|
||||||
device_field_timestamps[device_id][field] = now
|
setattr(cached, field, val)
|
||||||
|
device_field_timestamps[device_id][field] = now
|
||||||
|
else:
|
||||||
|
setattr(cached, field, None)
|
||||||
|
device_field_timestamps[device_id].pop(field, None)
|
||||||
|
|
||||||
# Clean up stale fields (older than 15 seconds)
|
# Clean up stale fields:
|
||||||
|
# Live vitals (HR, SpO2, Temp, RR) have a 15-second window
|
||||||
|
# NIBP (BP, MAP) has a 15-minute window because it is measured intermittently
|
||||||
for field in list(device_field_timestamps[device_id].keys()):
|
for field in list(device_field_timestamps[device_id].keys()):
|
||||||
last_updated = device_field_timestamps[device_id][field]
|
last_updated = device_field_timestamps[device_id][field]
|
||||||
if (now - last_updated).total_seconds() > 15.0:
|
timeout = 900.0 if field in ("systolic_bp", "diastolic_bp", "map_bp") else 15.0
|
||||||
|
if (now - last_updated).total_seconds() > timeout:
|
||||||
setattr(cached, field, None)
|
setattr(cached, field, None)
|
||||||
device_field_timestamps[device_id].pop(field, None)
|
device_field_timestamps[device_id].pop(field, None)
|
||||||
|
|
||||||
cached.timestamp = vitals.timestamp
|
cached.timestamp = vitals.timestamp
|
||||||
|
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
|
||||||
|
cached.patient_id = vitals.patient_id
|
||||||
|
if vitals.patient_name:
|
||||||
|
cached.patient_name = vitals.patient_name
|
||||||
|
if vitals.patient_gender:
|
||||||
|
cached.patient_gender = vitals.patient_gender
|
||||||
|
if vitals.patient_dob:
|
||||||
|
cached.patient_dob = vitals.patient_dob
|
||||||
|
if vitals.bed_id:
|
||||||
|
cached.bed_id = vitals.bed_id
|
||||||
vitals = cached
|
vitals = cached
|
||||||
|
|
||||||
# 2. Update console display banner
|
# 2. Update console display banner
|
||||||
@@ -221,7 +560,7 @@ async def process_vitals(vitals):
|
|||||||
display.update_vitals(vitals)
|
display.update_vitals(vitals)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id}: "
|
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id} ({vitals.patient_name}): "
|
||||||
f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
|
f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
|
||||||
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
|
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
|
||||||
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
|
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
|
||||||
@@ -231,22 +570,41 @@ async def process_vitals(vitals):
|
|||||||
try:
|
try:
|
||||||
from routers.dashboard import get_ws_manager
|
from routers.dashboard import get_ws_manager
|
||||||
ws_manager = get_ws_manager()
|
ws_manager = get_ws_manager()
|
||||||
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
|
vitals_dict = vitals.model_dump(mode="json")
|
||||||
|
vitals_dict["mrn"] = vitals.patient_id
|
||||||
|
vitals_dict["name"] = vitals.patient_name
|
||||||
|
vitals_dict["gender"] = vitals.patient_gender
|
||||||
|
vitals_dict["dob"] = vitals.patient_dob
|
||||||
|
vitals_dict["bed_id"] = vitals.bed_id
|
||||||
|
vitals_dict["patient_type"] = getattr(vitals, "patient_type", None)
|
||||||
|
await ws_manager.broadcast_vitals(vitals_dict)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
logger.debug(f"WebSocket broadcast failed: {e}")
|
||||||
|
|
||||||
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
|
# 4. Asynchronous background persistence and cloud forwarding (completely non-blocking, throttled to 1s)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
should_write_db = False
|
should_persist = False
|
||||||
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0:
|
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 1.0:
|
||||||
should_write_db = True
|
should_persist = True
|
||||||
last_db_write[device_id] = now
|
last_db_write[device_id] = now
|
||||||
|
|
||||||
if should_write_db:
|
if should_persist:
|
||||||
# Save to SQLite database and REST forward
|
asyncio.create_task(_persist_and_forward_bg(vitals, should_persist))
|
||||||
|
|
||||||
|
# Clear waveforms from cache to prevent repetition in subsequent non-waveform sub-packets
|
||||||
|
if device_id in device_cache:
|
||||||
|
device_cache[device_id].ecg_wave = None
|
||||||
|
device_cache[device_id].pleth_wave = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_and_forward_bg(vitals, should_persist):
|
||||||
|
"""Background worker for DB persistence and external cloud forwarding."""
|
||||||
|
if not should_persist:
|
||||||
|
return
|
||||||
|
reading_id = None
|
||||||
|
if should_persist:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# 1. Device tracking
|
|
||||||
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
device = Device(
|
device = Device(
|
||||||
@@ -260,17 +618,65 @@ async def process_vitals(vitals):
|
|||||||
device.ip_address = vitals.ip_address
|
device.ip_address = vitals.ip_address
|
||||||
device.status = "active"
|
device.status = "active"
|
||||||
device.last_seen = datetime.now(timezone.utc)
|
device.last_seen = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 2. Patient tracking
|
patient = None
|
||||||
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
|
||||||
|
patient = db.query(Patient).filter(
|
||||||
|
(Patient.mrn == vitals.patient_id) | (Patient.patient_id == vitals.patient_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not patient and vitals.device_id:
|
||||||
|
patient = db.query(Patient).filter(
|
||||||
|
Patient.device_id == vitals.device_id,
|
||||||
|
Patient.patient_id != "UNKNOWN"
|
||||||
|
).first()
|
||||||
|
|
||||||
if not patient:
|
if not patient:
|
||||||
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
|
||||||
db.add(patient)
|
patient = Patient(
|
||||||
|
patient_id=vitals.patient_id,
|
||||||
# 3. Create Reading Entry
|
mrn=vitals.patient_id,
|
||||||
|
name=vitals.patient_name or "Unknown",
|
||||||
|
gender=vitals.patient_gender,
|
||||||
|
dob=vitals.patient_dob,
|
||||||
|
bed_id=vitals.bed_id,
|
||||||
|
patient_type=getattr(vitals, "patient_type", None),
|
||||||
|
device_id=vitals.device_id
|
||||||
|
)
|
||||||
|
db.add(patient)
|
||||||
|
else:
|
||||||
|
if vitals.patient_id and vitals.patient_id != "UNKNOWN":
|
||||||
|
patient.patient_id = vitals.patient_id
|
||||||
|
patient.mrn = vitals.patient_id
|
||||||
|
if vitals.patient_name and vitals.patient_name != "Unknown":
|
||||||
|
patient.name = vitals.patient_name
|
||||||
|
if vitals.patient_gender:
|
||||||
|
patient.gender = vitals.patient_gender
|
||||||
|
if vitals.patient_dob:
|
||||||
|
patient.dob = vitals.patient_dob
|
||||||
|
if vitals.bed_id:
|
||||||
|
patient.bed_id = vitals.bed_id
|
||||||
|
if getattr(vitals, "patient_type", None):
|
||||||
|
patient.patient_type = vitals.patient_type
|
||||||
|
if vitals.device_id:
|
||||||
|
patient.device_id = vitals.device_id
|
||||||
|
if patient:
|
||||||
|
if not vitals.patient_id or vitals.patient_id == "UNKNOWN":
|
||||||
|
vitals.patient_id = patient.mrn or patient.patient_id
|
||||||
|
if not vitals.patient_name or vitals.patient_name == "Unknown":
|
||||||
|
vitals.patient_name = patient.name
|
||||||
|
if not vitals.patient_gender and patient.gender:
|
||||||
|
vitals.patient_gender = patient.gender
|
||||||
|
if not vitals.patient_dob and patient.dob:
|
||||||
|
vitals.patient_dob = patient.dob
|
||||||
|
if not vitals.bed_id and patient.bed_id:
|
||||||
|
vitals.bed_id = patient.bed_id
|
||||||
|
if not getattr(vitals, "patient_type", None) and getattr(patient, "patient_type", None):
|
||||||
|
vitals.patient_type = patient.patient_type
|
||||||
|
|
||||||
reading = VitalReading(
|
reading = VitalReading(
|
||||||
device_id=vitals.device_id,
|
device_id=vitals.device_id,
|
||||||
patient_id=vitals.patient_id,
|
patient_id=vitals.patient_id or (patient.mrn if patient else "UNKNOWN"),
|
||||||
timestamp=vitals.timestamp,
|
timestamp=vitals.timestamp,
|
||||||
heart_rate=vitals.heart_rate,
|
heart_rate=vitals.heart_rate,
|
||||||
spo2=vitals.spo2,
|
spo2=vitals.spo2,
|
||||||
@@ -279,34 +685,42 @@ async def process_vitals(vitals):
|
|||||||
map_bp=vitals.map_bp,
|
map_bp=vitals.map_bp,
|
||||||
respiratory_rate=vitals.respiratory_rate,
|
respiratory_rate=vitals.respiratory_rate,
|
||||||
temperature=vitals.temperature,
|
temperature=vitals.temperature,
|
||||||
|
temperature2=vitals.temperature2,
|
||||||
|
temperature_diff=vitals.temperature_diff,
|
||||||
transmitted=False
|
transmitted=False
|
||||||
)
|
)
|
||||||
db.add(reading)
|
db.add(reading)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(reading)
|
db.refresh(reading)
|
||||||
|
reading_id = reading.id
|
||||||
# 4. REST forwarding
|
|
||||||
success = await forward_vitals_to_api(vitals)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
reading.transmitted = True
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="success",
|
|
||||||
response_code=200
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="failed",
|
|
||||||
error_message="Immediate REST forward failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(log_entry)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database operation failed: {e}")
|
logger.error(f"Background database operation failed: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# DB connection is now completely closed. Awaiting HTTP requests will never exhaust DB connections!
|
||||||
|
success = False
|
||||||
|
if should_persist:
|
||||||
|
try:
|
||||||
|
success = await forward_vitals_to_api(vitals)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Background REST forward failed: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# Record transmission log in a separate, brief connection
|
||||||
|
if reading_id is not None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
log_entry = TransmissionLog(
|
||||||
|
reading_id=reading_id,
|
||||||
|
status="success" if success else "failed",
|
||||||
|
response_code=200 if success else 500,
|
||||||
|
error_message=None if success else "Immediate REST forward failed"
|
||||||
|
)
|
||||||
|
db.add(log_entry)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to record transmission log: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url, connect_args={"check_same_thread": False}
|
settings.database_url,
|
||||||
|
connect_args=connect_args,
|
||||||
|
poolclass=NullPool
|
||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.db
|
|
||||||
*.sqlite3
|
|
||||||
*.log
|
|
||||||
*.bin
|
|
||||||
.env
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
*.spec
|
|
||||||
scratch/
|
|
||||||
captures/
|
|
||||||
.git/
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Databases
|
|
||||||
*.db
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Scratch / debug
|
|
||||||
scratch/
|
|
||||||
captures/
|
|
||||||
*.bin
|
|
||||||
|
|
||||||
# IDE / OS
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Render sets PORT env var (default 10000)
|
|
||||||
EXPOSE 10000
|
|
||||||
|
|
||||||
CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-10000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
web: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Mindray uMEC 100 HL7 Forwarder
|
|
||||||
|
|
||||||
This application listens for HL7 messages from Mindray uMEC 100 patient monitors over TCP/IP, parses the vital signs, stores them in a local SQLite database, and immediately forwards them to a configured REST API endpoint. It includes a FastAPI-based dashboard to view status and readings.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
- Python 3.12 (if running from source)
|
|
||||||
- Dependencies in `requirements.txt`
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
The easiest way to start the application is using the provided `run.sh` script. This script will automatically set up the Python virtual environment, install dependencies, and start the application.
|
|
||||||
|
|
||||||
1. Install Python 3.12.
|
|
||||||
2. Edit `config.json` with your settings (target API URL, API token, etc.).
|
|
||||||
3. Run the following command in your terminal:
|
|
||||||
```bash
|
|
||||||
./run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration (`config.json`)
|
|
||||||
- `hl7_host`: The IP address to listen on for HL7 connections (default: `0.0.0.0` for all interfaces).
|
|
||||||
- `hl7_port`: The TCP port to listen on (default: `6060`).
|
|
||||||
- `target_api_url`: The REST API URL to POST normalized vitals to.
|
|
||||||
- `api_token`: Bearer token for authenticating with the target API.
|
|
||||||
- `retry_interval_seconds`: How often to retry forwarding failed transmissions.
|
|
||||||
- `log_file`: Name of the log file.
|
|
||||||
- `database_url`: SQLite database URI.
|
|
||||||
|
|
||||||
## Building for Linux
|
|
||||||
To package the application as a standalone executable for Linux:
|
|
||||||
1. Ensure you have installed the requirements (`pip install -r requirements.txt`).
|
|
||||||
2. Run `./build.sh` from your terminal.
|
|
||||||
3. The standalone executable `MindrayHL7Forwarder` will be generated in the `dist/` directory.
|
|
||||||
|
|
||||||
### Running the Linux Executable
|
|
||||||
Simply copy the `MindrayHL7Forwarder` binary and `config.json` to the target Linux machine and run `./MindrayHL7Forwarder`. It does not require Python to be installed. The Dashboard API will be available at `http://localhost:8000/docs`.
|
|
||||||
|
|
||||||
## Dashboard APIs
|
|
||||||
When the application is running, it starts a web server on port `8000`. You can access the following endpoints:
|
|
||||||
- `GET /api/health`
|
|
||||||
- `GET /api/status`
|
|
||||||
- `GET /api/devices`
|
|
||||||
- `GET /api/latest-readings`
|
|
||||||
- `GET /api/transmission-logs`
|
|
||||||
# ContectFiveParaMonitor
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import httpx
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from schemas import NormalizedVitals
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
async def forward_vitals_to_api(vitals: NormalizedVitals) -> bool:
|
|
||||||
"""
|
|
||||||
Attempts to POST the normalized vitals to the configured target API
|
|
||||||
and to the Render cloud database.
|
|
||||||
Returns True if at least one forward succeeds.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
# 1. Forward to primary target API (e.g. Injomo Dev Workflow)
|
|
||||||
primary_url = settings.target_api_url
|
|
||||||
primary_success = False
|
|
||||||
if primary_url:
|
|
||||||
try:
|
|
||||||
primary_headers = headers.copy()
|
|
||||||
if settings.api_token:
|
|
||||||
primary_headers["Authorization"] = f"Bearer {settings.api_token}"
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
primary_url,
|
|
||||||
json=vitals.model_dump(mode="json"),
|
|
||||||
headers=primary_headers
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
logger.info(f"Successfully forwarded vitals to primary API for device {vitals.device_id}")
|
|
||||||
primary_success = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error forwarding to primary API: {e}")
|
|
||||||
|
|
||||||
# 2. Forward to Render Cloud Server
|
|
||||||
render_url = "https://contectfiveparamonitor.onrender.com/api/push-vitals"
|
|
||||||
render_success = False
|
|
||||||
|
|
||||||
# Detect if we are already running on Render to prevent an infinite loop
|
|
||||||
is_on_render = os.getenv("RENDER") is not None or os.getenv("PORT") == "10000" or "render.local" in os.getenv("HOSTNAME", "")
|
|
||||||
|
|
||||||
if not is_on_render:
|
|
||||||
try:
|
|
||||||
url_with_key = render_url
|
|
||||||
if settings.api_token:
|
|
||||||
url_with_key = f"{render_url}?api_key={settings.api_token}"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
url_with_key,
|
|
||||||
json=vitals.model_dump(mode="json"),
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
logger.info(f"Successfully forwarded vitals to Render Cloud for device {vitals.device_id}")
|
|
||||||
render_success = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error forwarding to Render Cloud: {e}")
|
|
||||||
|
|
||||||
return primary_success or render_success
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from database import SessionLocal
|
|
||||||
from models import VitalReading, TransmissionLog
|
|
||||||
from schemas import NormalizedVitals
|
|
||||||
from api_client import forward_vitals_to_api
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
async def retry_failed_transmissions():
|
|
||||||
"""
|
|
||||||
Background task that periodically checks for untransmitted vital readings
|
|
||||||
and attempts to forward them to the API.
|
|
||||||
"""
|
|
||||||
logger.info("Starting background task for retrying failed transmissions...")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Get readings that haven't been transmitted
|
|
||||||
failed_readings = db.query(VitalReading).filter(VitalReading.transmitted == False).limit(50).all()
|
|
||||||
|
|
||||||
for reading in failed_readings:
|
|
||||||
vitals = NormalizedVitals(
|
|
||||||
device_id=reading.device_id,
|
|
||||||
patient_id=reading.patient_id,
|
|
||||||
timestamp=reading.timestamp,
|
|
||||||
heart_rate=reading.heart_rate,
|
|
||||||
spo2=reading.spo2,
|
|
||||||
systolic_bp=reading.systolic_bp,
|
|
||||||
diastolic_bp=reading.diastolic_bp,
|
|
||||||
map_bp=reading.map_bp,
|
|
||||||
respiratory_rate=reading.respiratory_rate,
|
|
||||||
temperature=reading.temperature
|
|
||||||
)
|
|
||||||
|
|
||||||
success = await forward_vitals_to_api(vitals)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
reading.transmitted = True
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="success",
|
|
||||||
response_code=200
|
|
||||||
)
|
|
||||||
db.add(log_entry)
|
|
||||||
logger.info(f"Successfully re-transmitted reading {reading.id}")
|
|
||||||
else:
|
|
||||||
# Log the failure but don't mark as transmitted
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="failed",
|
|
||||||
error_message="Retry failed"
|
|
||||||
)
|
|
||||||
db.add(log_entry)
|
|
||||||
|
|
||||||
if failed_readings:
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in retry background task: {e}")
|
|
||||||
|
|
||||||
await asyncio.sleep(settings.retry_interval_seconds)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo Building Mindray HL7 Forwarder Executable...
|
|
||||||
|
|
||||||
REM Clean previous builds
|
|
||||||
rmdir /s /q build
|
|
||||||
rmdir /s /q dist
|
|
||||||
|
|
||||||
REM Run PyInstaller with necessary hidden imports for FastAPI, Uvicorn, and SQLAlchemy
|
|
||||||
pyinstaller -F --clean ^
|
|
||||||
--name "MindrayHL7Forwarder" ^
|
|
||||||
--hidden-import uvicorn.logging ^
|
|
||||||
--hidden-import uvicorn.loops ^
|
|
||||||
--hidden-import uvicorn.loops.auto ^
|
|
||||||
--hidden-import uvicorn.protocols ^
|
|
||||||
--hidden-import uvicorn.protocols.http ^
|
|
||||||
--hidden-import uvicorn.protocols.http.auto ^
|
|
||||||
--hidden-import uvicorn.protocols.websockets ^
|
|
||||||
--hidden-import uvicorn.protocols.websockets.auto ^
|
|
||||||
--hidden-import uvicorn.lifespan ^
|
|
||||||
--hidden-import uvicorn.lifespan.on ^
|
|
||||||
--hidden-import uvicorn.lifespan.off ^
|
|
||||||
--hidden-import fastapi ^
|
|
||||||
--hidden-import pydantic ^
|
|
||||||
--hidden-import sqlalchemy ^
|
|
||||||
--hidden-import hl7 ^
|
|
||||||
main.py
|
|
||||||
|
|
||||||
echo Build complete! Executable is located in the dist/ folder.
|
|
||||||
pause
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
echo "Building Mindray HL7 Forwarder Executable for Linux..."
|
|
||||||
|
|
||||||
# Clean previous builds
|
|
||||||
rm -rf build
|
|
||||||
rm -rf dist
|
|
||||||
|
|
||||||
# Run PyInstaller with necessary hidden imports for FastAPI, Uvicorn, and SQLAlchemy
|
|
||||||
pyinstaller -F --clean \
|
|
||||||
--name "MindrayHL7Forwarder" \
|
|
||||||
--hidden-import uvicorn.logging \
|
|
||||||
--hidden-import uvicorn.loops \
|
|
||||||
--hidden-import uvicorn.loops.auto \
|
|
||||||
--hidden-import uvicorn.protocols \
|
|
||||||
--hidden-import uvicorn.protocols.http \
|
|
||||||
--hidden-import uvicorn.protocols.http.auto \
|
|
||||||
--hidden-import uvicorn.protocols.websockets \
|
|
||||||
--hidden-import uvicorn.protocols.websockets.auto \
|
|
||||||
--hidden-import uvicorn.lifespan \
|
|
||||||
--hidden-import uvicorn.lifespan.on \
|
|
||||||
--hidden-import uvicorn.lifespan.off \
|
|
||||||
--hidden-import fastapi \
|
|
||||||
--hidden-import pydantic \
|
|
||||||
--hidden-import sqlalchemy \
|
|
||||||
--hidden-import hl7 \
|
|
||||||
main.py
|
|
||||||
|
|
||||||
echo "Build complete! Executable is located in the dist/ folder."
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"hl7_host": "0.0.0.0",
|
|
||||||
"hl7_port": 6060,
|
|
||||||
"contec_ports": [511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 8001, 8002, 8300, 9000, 9100, 9200, 10008, 12345],
|
|
||||||
"monitor_ip": "",
|
|
||||||
"monitor_model": "CMS8500",
|
|
||||||
"device_models": {
|
|
||||||
"192.168.100.120": "CMS7000PLUS"
|
|
||||||
},
|
|
||||||
"target_api_url": "https://innov-dev.beta.injomo.com/workflow.trigger/69b2712a7866bf86ca0060c3",
|
|
||||||
"api_token": "",
|
|
||||||
"retry_interval_seconds": 60,
|
|
||||||
"log_file": "hl7_forwarder.log",
|
|
||||||
"database_url": "sqlite:///./hl7_forwarder.db"
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
hl7_host: str = "0.0.0.0"
|
|
||||||
hl7_port: int = 6060
|
|
||||||
contec_ports: list = [511, 515, 516, 517, 518, 519, 520]
|
|
||||||
monitor_ip: str = ""
|
|
||||||
monitor_model: str = "CMS7000PLUS"
|
|
||||||
device_models: dict = {}
|
|
||||||
target_api_url: str = "https://api.example.com/vitals"
|
|
||||||
api_token: str = ""
|
|
||||||
retry_interval_seconds: int = 60
|
|
||||||
log_file: str = "hl7_forwarder.log"
|
|
||||||
database_url: str = "sqlite:///./hl7_forwarder.db"
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
config_file = os.path.join(base_dir, "config.json")
|
|
||||||
|
|
||||||
settings_obj = Settings()
|
|
||||||
|
|
||||||
if os.path.exists(config_file):
|
|
||||||
with open(config_file, "r") as f:
|
|
||||||
try:
|
|
||||||
data = json.load(f)
|
|
||||||
for k, v in data.items():
|
|
||||||
setattr(settings_obj, k, v)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if settings_obj.database_url.startswith("sqlite:///./"):
|
|
||||||
db_name = settings_obj.database_url.split("sqlite:///./")[1]
|
|
||||||
settings_obj.database_url = f"sqlite:///{os.path.join(base_dir, db_name)}"
|
|
||||||
|
|
||||||
if not os.path.isabs(settings_obj.log_file):
|
|
||||||
settings_obj.log_file = os.path.join(base_dir, settings_obj.log_file)
|
|
||||||
|
|
||||||
return settings_obj
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"""
|
|
||||||
Contec CMS7000PLUS / CMS9200PLUS Discovery Tool
|
|
||||||
==================================
|
|
||||||
This script listens on multiple TCP ports simultaneously to discover
|
|
||||||
what data the CMS7000PLUS / CMS9200PLUS patient monitor sends over the network.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python contec_discovery.py
|
|
||||||
|
|
||||||
It will listen on ports: 511, 515, 516, 517, 518, 519, 520, 6060
|
|
||||||
and display all received data in hex + ASCII format for protocol analysis.
|
|
||||||
|
|
||||||
IMPORTANT: Run as Administrator (required for port 511 on Windows)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
LISTEN_HOST = "0.0.0.0"
|
|
||||||
DISCOVERY_PORTS = [511, 515, 516, 517, 518, 519, 520, 6060]
|
|
||||||
LOG_DIR = "captures"
|
|
||||||
|
|
||||||
# --- Setup Logging ---
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
|
||||||
handlers=[logging.StreamHandler(sys.stdout)]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger("contec_discovery")
|
|
||||||
|
|
||||||
|
|
||||||
def hex_dump(data: bytes, width: int = 16) -> str:
|
|
||||||
"""Format bytes as a hex dump with ASCII representation."""
|
|
||||||
lines = []
|
|
||||||
for i in range(0, len(data), width):
|
|
||||||
chunk = data[i:i + width]
|
|
||||||
hex_part = ' '.join(f'{b:02x}' for b in chunk)
|
|
||||||
ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
|
|
||||||
lines.append(f" {i:04x} {hex_part:<{width * 3}} |{ascii_part}|")
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def try_decode_hl7(data: bytes) -> str | None:
|
|
||||||
"""Attempt to decode the data as an HL7 message."""
|
|
||||||
try:
|
|
||||||
# Strip MLLP framing if present
|
|
||||||
cleaned = data
|
|
||||||
if cleaned.startswith(b'\x0b'):
|
|
||||||
cleaned = cleaned[1:]
|
|
||||||
if b'\x1c\x0d' in cleaned:
|
|
||||||
cleaned = cleaned[:cleaned.find(b'\x1c\x0d')]
|
|
||||||
|
|
||||||
text = cleaned.decode('utf-8', errors='ignore').strip()
|
|
||||||
if 'MSH|' in text or 'MSH' in text[:20]:
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_discovery_client(
|
|
||||||
reader: asyncio.StreamReader,
|
|
||||||
writer: asyncio.StreamWriter,
|
|
||||||
port: int,
|
|
||||||
capture_file
|
|
||||||
):
|
|
||||||
"""Handle a single incoming connection and dump all data."""
|
|
||||||
client_ip, client_port = writer.get_extra_info('peername')
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print(f" NEW CONNECTION on port {port}")
|
|
||||||
print(f" From: {client_ip}:{client_port}")
|
|
||||||
print(f" Time: {timestamp}")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
capture_file.write(f"\n{'='*80}\n")
|
|
||||||
capture_file.write(f"Connection on port {port} from {client_ip}:{client_port} at {timestamp}\n")
|
|
||||||
capture_file.write(f"{'='*80}\n")
|
|
||||||
capture_file.flush()
|
|
||||||
|
|
||||||
packet_count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await asyncio.wait_for(reader.read(8192), timeout=30.0)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
packet_count += 1
|
|
||||||
recv_time = datetime.now().strftime("%H:%M:%S.%f")
|
|
||||||
|
|
||||||
print(f"\n--- Packet #{packet_count} on port {port} [{recv_time}] ({len(data)} bytes) ---")
|
|
||||||
print(hex_dump(data))
|
|
||||||
|
|
||||||
# Try to decode as HL7
|
|
||||||
hl7_text = try_decode_hl7(data)
|
|
||||||
if hl7_text:
|
|
||||||
print(f"\n >>> HL7 MESSAGE DETECTED <<<")
|
|
||||||
segments = hl7_text.replace('\n', '\r').split('\r')
|
|
||||||
for seg in segments:
|
|
||||||
seg = seg.strip()
|
|
||||||
if seg:
|
|
||||||
print(f" {seg}")
|
|
||||||
|
|
||||||
# Extract vital signs from OBX segments
|
|
||||||
if seg.startswith("OBX|"):
|
|
||||||
fields = seg.split('|')
|
|
||||||
if len(fields) > 5:
|
|
||||||
obs_id = fields[3] if len(fields) > 3 else "?"
|
|
||||||
obs_value = fields[5] if len(fields) > 5 else "?"
|
|
||||||
obs_unit = fields[6] if len(fields) > 6 else ""
|
|
||||||
print(f" -> Vital: {obs_id} = {obs_value} {obs_unit}")
|
|
||||||
|
|
||||||
# Also try plain text decode
|
|
||||||
try:
|
|
||||||
text = data.decode('utf-8', errors='ignore')
|
|
||||||
printable_ratio = sum(1 for c in text if c.isprintable() or c in '\r\n\t') / max(len(text), 1)
|
|
||||||
if printable_ratio > 0.7 and not hl7_text:
|
|
||||||
print(f"\n [Text representation]:")
|
|
||||||
for line in text.split('\r'):
|
|
||||||
line = line.strip()
|
|
||||||
if line:
|
|
||||||
print(f" {line}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Write to capture file
|
|
||||||
capture_file.write(f"\nPacket #{packet_count} [{recv_time}] ({len(data)} bytes)\n")
|
|
||||||
capture_file.write(hex_dump(data) + "\n")
|
|
||||||
if hl7_text:
|
|
||||||
capture_file.write(f"HL7: {hl7_text}\n")
|
|
||||||
capture_file.write(f"Raw bytes: {data.hex()}\n")
|
|
||||||
capture_file.flush()
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
print(f" [Timeout - no data for 30s on port {port}]")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except ConnectionResetError:
|
|
||||||
print(f" [Connection reset by {client_ip} on port {port}]")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [Error on port {port}: {e}]")
|
|
||||||
finally:
|
|
||||||
print(f"\n Connection closed on port {port} from {client_ip} ({packet_count} packets received)")
|
|
||||||
writer.close()
|
|
||||||
try:
|
|
||||||
await writer.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def start_port_listener(port: int, capture_file):
|
|
||||||
"""Start a TCP server on a specific port."""
|
|
||||||
async def client_handler(reader, writer):
|
|
||||||
await handle_discovery_client(reader, writer, port, capture_file)
|
|
||||||
|
|
||||||
try:
|
|
||||||
server = await asyncio.start_server(client_handler, LISTEN_HOST, port)
|
|
||||||
logger.info(f" ✓ Listening on port {port}")
|
|
||||||
return server
|
|
||||||
except PermissionError:
|
|
||||||
logger.error(f" ✗ Port {port} - Permission denied (try running as Administrator)")
|
|
||||||
return None
|
|
||||||
except OSError as e:
|
|
||||||
if "already in use" in str(e).lower() or e.errno == 10048:
|
|
||||||
logger.error(f" ✗ Port {port} - Already in use by another application")
|
|
||||||
else:
|
|
||||||
logger.error(f" ✗ Port {port} - {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
print(r"""
|
|
||||||
╔══════════════════════════════════════════════════════════════╗
|
|
||||||
║ Contec CMS7000PLUS / CMS9200PLUS Discovery Tool ║
|
|
||||||
║ ║
|
|
||||||
║ This tool listens for incoming data from your patient ║
|
|
||||||
║ monitor to discover its communication protocol. ║
|
|
||||||
║ ║
|
|
||||||
║ SETUP REQUIRED: ║
|
|
||||||
║ 1. Connect monitor to your PC via Ethernet ║
|
|
||||||
║ 2. Set your PC's Ethernet IP to: 192.168.1.50 ║
|
|
||||||
║ (or whatever subnet your monitor uses) ║
|
|
||||||
║ 3. On the monitor, set CMS/Server IP to YOUR PC's IP ║
|
|
||||||
║ 4. Run this script as Administrator ║
|
|
||||||
║ ║
|
|
||||||
║ Press Ctrl+C to stop ║
|
|
||||||
╚══════════════════════════════════════════════════════════════╝
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create captures directory
|
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
|
||||||
capture_filename = os.path.join(
|
|
||||||
LOG_DIR,
|
|
||||||
f"capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
|
||||||
)
|
|
||||||
capture_file = open(capture_filename, 'w', encoding='utf-8')
|
|
||||||
logger.info(f"Capture log: {capture_filename}")
|
|
||||||
|
|
||||||
# Show current network config hint
|
|
||||||
print("\nStarting listeners on all discovery ports...")
|
|
||||||
print("(If port 511 fails, run PowerShell as Administrator)\n")
|
|
||||||
|
|
||||||
servers = []
|
|
||||||
for port in DISCOVERY_PORTS:
|
|
||||||
server = await start_port_listener(port, capture_file)
|
|
||||||
if server:
|
|
||||||
servers.append(server)
|
|
||||||
|
|
||||||
if not servers:
|
|
||||||
logger.error("No ports could be opened! Check permissions and existing services.")
|
|
||||||
capture_file.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
active_ports = [s.sockets[0].getsockname()[1] for s in servers if s.sockets]
|
|
||||||
print(f"\n{'─' * 60}")
|
|
||||||
print(f" Active on ports: {active_ports}")
|
|
||||||
print(f" Waiting for data from CMS7000PLUS / CMS9200PLUS...")
|
|
||||||
print(f" Make sure the monitor's CMS/Server IP points to this PC")
|
|
||||||
print(f"{'─' * 60}\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Keep running until Ctrl+C
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
print("\nShutting down...")
|
|
||||||
for server in servers:
|
|
||||||
server.close()
|
|
||||||
for server in servers:
|
|
||||||
await server.wait_closed()
|
|
||||||
capture_file.close()
|
|
||||||
print(f"Capture saved to: {capture_filename}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
asyncio.run(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDiscovery stopped by user.")
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional, List
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from schemas import NormalizedVitals
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Contec CMS7000PLUS / CMS8000 / CMS9200PLUS OBX identifiers mapping to schema fields
|
|
||||||
CONTEC_PARAM_MAP = {
|
|
||||||
# Heart Rate / Pulse Rate
|
|
||||||
"HR": "heart_rate",
|
|
||||||
"HEART_RATE": "heart_rate",
|
|
||||||
"ECG_HR": "heart_rate",
|
|
||||||
"PR": "heart_rate",
|
|
||||||
"PULSE_RATE": "heart_rate",
|
|
||||||
"SPO2_PR": "heart_rate",
|
|
||||||
"ECGHR": "heart_rate",
|
|
||||||
"ECG-HR": "heart_rate",
|
|
||||||
"PULSERATE": "heart_rate",
|
|
||||||
|
|
||||||
# SpO2
|
|
||||||
"SPO2": "spo2",
|
|
||||||
"SAO2": "spo2",
|
|
||||||
"SPO2_PCT": "spo2",
|
|
||||||
"OXYGEN_SAT": "spo2",
|
|
||||||
"SPO2_VAL": "spo2",
|
|
||||||
"O2SAT": "spo2",
|
|
||||||
|
|
||||||
# Blood Pressure (NIBP)
|
|
||||||
"NIBP_SYS": "systolic_bp",
|
|
||||||
"NIBP_DIA": "diastolic_bp",
|
|
||||||
"NIBP_MEAN": "map_bp",
|
|
||||||
"NIBP_MAP": "map_bp",
|
|
||||||
"SYS": "systolic_bp",
|
|
||||||
"DIA": "diastolic_bp",
|
|
||||||
"MAP": "map_bp",
|
|
||||||
"NBP_S": "systolic_bp",
|
|
||||||
"NBP_D": "diastolic_bp",
|
|
||||||
"NBP_M": "map_bp",
|
|
||||||
"NIBP-S": "systolic_bp",
|
|
||||||
"NIBP-D": "diastolic_bp",
|
|
||||||
"NIBP-M": "map_bp",
|
|
||||||
"BP_SYS": "systolic_bp",
|
|
||||||
"BP_DIA": "diastolic_bp",
|
|
||||||
"BP_MEAN": "map_bp",
|
|
||||||
|
|
||||||
# Respiratory Rate
|
|
||||||
"RESP": "respiratory_rate",
|
|
||||||
"RESP_RATE": "respiratory_rate",
|
|
||||||
"RR": "respiratory_rate",
|
|
||||||
"BR": "respiratory_rate",
|
|
||||||
"RESPRATE": "respiratory_rate",
|
|
||||||
"RESP-RATE": "respiratory_rate",
|
|
||||||
|
|
||||||
# Temperature
|
|
||||||
"TEMP": "temperature",
|
|
||||||
"TEMP1": "temperature",
|
|
||||||
"TEMP2": "temperature",
|
|
||||||
"T1": "temperature",
|
|
||||||
"T2": "temperature",
|
|
||||||
"BODY_TEMP": "temperature",
|
|
||||||
"TEMP-1": "temperature",
|
|
||||||
"TEMP-2": "temperature",
|
|
||||||
"BODYTEMP": "temperature",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sentinel value used by Contec to mark an invalid/disconnected parameter
|
|
||||||
INVALID_SENTINEL_U16 = 9999 # 0x270F
|
|
||||||
INVALID_SENTINEL_F32 = 9999.0
|
|
||||||
|
|
||||||
|
|
||||||
def safe_float(value: str) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_u16(val: Optional[int]) -> bool:
|
|
||||||
return val is not None and val != INVALID_SENTINEL_U16 and val != 255 and val != 65535
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_f32(val: Optional[float]) -> bool:
|
|
||||||
return val is not None and abs(val - INVALID_SENTINEL_F32) > 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def read_u16_le(buf: bytes, offset: int) -> Optional[int]:
|
|
||||||
if offset + 2 <= len(buf):
|
|
||||||
return struct.unpack_from('<H', buf, offset)[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def read_f32_le(buf: bytes, offset: int) -> Optional[float]:
|
|
||||||
if offset + 4 <= len(buf):
|
|
||||||
return struct.unpack_from('<f', buf, offset)[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedVitals]:
|
|
||||||
"""
|
|
||||||
Lightweight, native parser for Contec HL7 messages.
|
|
||||||
Splits segments and fields manually, avoiding third-party library dependencies.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Strip MLLP framing if present (VT=0x0b, FS=0x1c, CR=0x0d)
|
|
||||||
clean_text = raw_text.strip()
|
|
||||||
if clean_text.startswith("\x0b"):
|
|
||||||
clean_text = clean_text[1:]
|
|
||||||
if "\x1c\x0d" in clean_text:
|
|
||||||
clean_text = clean_text.split("\x1c\x0d")[0]
|
|
||||||
|
|
||||||
# Split into segments by CR or LF
|
|
||||||
segments = [seg.strip() for seg in clean_text.replace("\n", "\r").split("\r") if seg.strip()]
|
|
||||||
|
|
||||||
if not segments or not segments[0].startswith("MSH"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Parse MSH segment
|
|
||||||
model = settings.device_models.get(client_ip, settings.monitor_model)
|
|
||||||
msh_fields = segments[0].split("|")
|
|
||||||
device_id = msh_fields[2] if len(msh_fields) > 2 and msh_fields[2] else f"{model}_{client_ip}"
|
|
||||||
|
|
||||||
patient_id = "UNKNOWN"
|
|
||||||
vitals_dict = {
|
|
||||||
"device_id": device_id,
|
|
||||||
"patient_id": patient_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc),
|
|
||||||
"ip_address": client_ip,
|
|
||||||
}
|
|
||||||
|
|
||||||
found_any = False
|
|
||||||
|
|
||||||
# Parse PID and OBX segments
|
|
||||||
for seg in segments[1:]:
|
|
||||||
fields = seg.split("|")
|
|
||||||
if not fields:
|
|
||||||
continue
|
|
||||||
|
|
||||||
seg_name = fields[0]
|
|
||||||
|
|
||||||
if seg_name == "PID":
|
|
||||||
if len(fields) > 3 and fields[3]:
|
|
||||||
patient_id = fields[3].split("^")[0]
|
|
||||||
vitals_dict["patient_id"] = patient_id
|
|
||||||
|
|
||||||
elif seg_name == "OBX":
|
|
||||||
if len(fields) > 5:
|
|
||||||
obs_val = fields[5].strip()
|
|
||||||
|
|
||||||
if not obs_val or obs_val == "---":
|
|
||||||
continue
|
|
||||||
|
|
||||||
val_float = safe_float(obs_val)
|
|
||||||
if val_float is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Split OBX-3 (Observation Identifier) by '^' and check all parts for parameter matches
|
|
||||||
obs_parts = [p.strip().upper() for p in fields[3].split("^") if p.strip()]
|
|
||||||
|
|
||||||
matched = False
|
|
||||||
for param_key, mapped_field in CONTEC_PARAM_MAP.items():
|
|
||||||
for part in obs_parts:
|
|
||||||
if param_key == part or param_key in part:
|
|
||||||
vitals_dict[mapped_field] = val_float
|
|
||||||
found_any = True
|
|
||||||
logger.info(f"Parsed Contec HL7 Vital: {mapped_field} = {val_float} (from OBX-3: {fields[3]})")
|
|
||||||
matched = True
|
|
||||||
break
|
|
||||||
if matched:
|
|
||||||
break
|
|
||||||
|
|
||||||
return NormalizedVitals(**vitals_dict) if found_any else None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error parsing Contec HL7 text: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_vitals_block(data: bytes, offset: int, client_ip: str) -> Optional[NormalizedVitals]:
|
|
||||||
"""
|
|
||||||
Extract vitals from a Contec vitals block starting at the given offset.
|
|
||||||
|
|
||||||
Known Contec CMS7000PLUS binary vitals block layout (confirmed from packet captures):
|
|
||||||
|
|
||||||
PACKET TYPE B (header bytes [xx][00][04][46] or [xx][yy][04][46]):
|
|
||||||
-----------------------------------------------------------------------
|
|
||||||
Bytes 8-11 : float32 LE → NIBP Systolic (9999.0 = invalid)
|
|
||||||
Bytes 12-15 : float32 LE → NIBP Diastolic (9999.0 = invalid)
|
|
||||||
Bytes 16-19 : float32 LE → NIBP MAP (9999.0 = invalid)
|
|
||||||
Bytes 20-23 : float32 LE → Temperature 1 (°C, 9999.0 = invalid)
|
|
||||||
Bytes 24-27 : float32 LE → Temperature 2 (°C, 9999.0 = invalid)
|
|
||||||
|
|
||||||
Vitals summary block (at data[offset] position, found by scanning for SpO2+HR pattern):
|
|
||||||
Offset +0 : uint16 LE → SpO2 (%) (9999 = invalid)
|
|
||||||
Offset +2 : uint16 LE → Heart Rate (bpm)(9999 = invalid, ECG not connected)
|
|
||||||
Offset +4 : uint16 LE → Pulse Rate (bpm)(from SpO2 probe, also 9999 if invalid)
|
|
||||||
Offset +6 : uint16 LE → (SpO2 alarm/threshold)
|
|
||||||
Offset +8 : uint16 LE → (mode flags)
|
|
||||||
Offset +10 : uint16 LE → NIBP Systolic (9999 = invalid)
|
|
||||||
Offset +12 : uint16 LE → NIBP Diastolic (9999 = invalid)
|
|
||||||
"""
|
|
||||||
model = settings.device_models.get(client_ip, settings.monitor_model)
|
|
||||||
vitals_dict = {
|
|
||||||
"device_id": f"{model}_{client_ip}",
|
|
||||||
"patient_id": "UNKNOWN",
|
|
||||||
"timestamp": datetime.now(timezone.utc),
|
|
||||||
"ip_address": client_ip,
|
|
||||||
}
|
|
||||||
found = False
|
|
||||||
|
|
||||||
spo2 = read_u16_le(data, offset)
|
|
||||||
hr_ecg = read_u16_le(data, offset + 2)
|
|
||||||
hr_spo2 = read_u16_le(data, offset + 4)
|
|
||||||
|
|
||||||
# SpO2 value: valid range 50–100
|
|
||||||
if spo2 is not None and 50 <= spo2 <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
# Heart rate: prefer ECG HR; fall back to SpO2-derived PR if ECG invalid
|
|
||||||
if is_valid_u16(hr_ecg) and 20 <= hr_ecg <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_ecg)
|
|
||||||
found = True
|
|
||||||
elif is_valid_u16(hr_spo2) and 20 <= hr_spo2 <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_spo2)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return NormalizedVitals(**vitals_dict) if found else None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_45_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 45-byte packet (Subtype 22) — contains NIBP values and alarm limits.
|
|
||||||
|
|
||||||
Verified layout:
|
|
||||||
[0-1] Packet length LE u16 = 45
|
|
||||||
[2-3] Marker: 01 46
|
|
||||||
[4-7] Sub-header (00 00 16 00)
|
|
||||||
[8-9] Year LE u16
|
|
||||||
[10] Month, [11] Day, [12] Hour, [13] Minute, [14] Seconds
|
|
||||||
[15-16] NIBP Systolic (LE u16, 9999 = no measurement)
|
|
||||||
[17-18] NIBP Diastolic (LE u16, 9999 = no measurement)
|
|
||||||
[19-20] NIBP MAP (LE u16, 9999 = no measurement)
|
|
||||||
"""
|
|
||||||
if len(data) < 22:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
|
|
||||||
nibp_sys = read_u16_le(data, 15)
|
|
||||||
nibp_dia = read_u16_le(data, 17)
|
|
||||||
nibp_map = read_u16_le(data, 19)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
# Mark NIBP fields as present so cache can clear them if 9999
|
|
||||||
for f in ["systolic_bp", "diastolic_bp", "map_bp"]:
|
|
||||||
if f not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append(f)
|
|
||||||
|
|
||||||
if nibp_sys is not None and nibp_sys != 9999 and 40 < nibp_sys < 250:
|
|
||||||
vitals_dict["systolic_bp"] = float(nibp_sys)
|
|
||||||
found = True
|
|
||||||
else:
|
|
||||||
vitals_dict["systolic_bp"] = None
|
|
||||||
|
|
||||||
if nibp_dia is not None and nibp_dia != 9999 and 20 < nibp_dia < 200:
|
|
||||||
vitals_dict["diastolic_bp"] = float(nibp_dia)
|
|
||||||
found = True
|
|
||||||
else:
|
|
||||||
vitals_dict["diastolic_bp"] = None
|
|
||||||
|
|
||||||
if nibp_map is not None and nibp_map != 9999 and 20 < nibp_map < 250:
|
|
||||||
vitals_dict["map_bp"] = float(nibp_map)
|
|
||||||
found = True
|
|
||||||
else:
|
|
||||||
vitals_dict["map_bp"] = None
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_56_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 56-byte packet (Subtype 23) — contains float32 Temperature values.
|
|
||||||
|
|
||||||
Verified layout:
|
|
||||||
[8-11] Temperature 1 (float32)
|
|
||||||
[12-15] Temperature 2 (float32)
|
|
||||||
[20-23] Temp 1 Alarm High Limit (float32)
|
|
||||||
[24-27] Temp 1 Alarm Low Limit (float32)
|
|
||||||
"""
|
|
||||||
if len(data) < 16:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "temperature" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("temperature")
|
|
||||||
vitals_dict.setdefault("temperature", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
temp1 = read_f32_le(data, 8)
|
|
||||||
temp2 = read_f32_le(data, 12)
|
|
||||||
|
|
||||||
# 9999.0 is sentinel
|
|
||||||
if temp1 is not None and is_valid_f32(temp1) and 10.0 < temp1 < 50.0:
|
|
||||||
vitals_dict["temperature"] = round(temp1, 1)
|
|
||||||
found = True
|
|
||||||
elif temp2 is not None and is_valid_f32(temp2) and 10.0 < temp2 < 50.0:
|
|
||||||
vitals_dict["temperature"] = round(temp2, 1)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_286_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 286-byte waveform packet (Subtype 21).
|
|
||||||
|
|
||||||
Verified layout at tail:
|
|
||||||
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
|
|
||||||
255 / 65535 = sensor disconnected
|
|
||||||
[266-267] ECG HR via SpO2 PR (LE u16) — 9999 / 65535 = not available
|
|
||||||
"""
|
|
||||||
if len(data) < 270:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "spo2" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("spo2")
|
|
||||||
vitals_dict.setdefault("spo2", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
# Offset 264: SpO2 percentage
|
|
||||||
spo2_val = read_u16_le(data, 264)
|
|
||||||
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
# Offset 266: HR from SpO2 PR (only add to present fields if valid to prevent overriding ECG HR)
|
|
||||||
hr_val = read_u16_le(data, 266)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_288_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 288-byte waveform packet (Subtype 21 for CMS8500).
|
|
||||||
|
|
||||||
Layout:
|
|
||||||
[264-265] SpO2% (LE u16) — live oxygen saturation percentage
|
|
||||||
[266-267] PR (LE u16) — pulse rate
|
|
||||||
"""
|
|
||||||
if len(data) < 270:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "spo2" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("spo2")
|
|
||||||
vitals_dict.setdefault("spo2", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
# Offset 264: SpO2 percentage
|
|
||||||
spo2_val = read_u16_le(data, 264)
|
|
||||||
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
# Offset 266: PR from SpO2 PR
|
|
||||||
hr_val = read_u16_le(data, 266)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_341_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 341-byte waveform packet. Same tail layout as 286-byte.
|
|
||||||
"""
|
|
||||||
if len(data) < 270:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "spo2" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("spo2")
|
|
||||||
vitals_dict.setdefault("spo2", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
spo2_val = read_u16_le(data, 264)
|
|
||||||
if spo2_val is not None and spo2_val != 65535 and spo2_val != 255 and 50 <= spo2_val <= 100:
|
|
||||||
vitals_dict["spo2"] = float(spo2_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
hr_val = read_u16_le(data, 266)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_989_byte_packet(data: bytes, vitals_dict: dict) -> bool:
|
|
||||||
"""
|
|
||||||
Parse 989-byte waveform packet (Subtype 20).
|
|
||||||
|
|
||||||
Verified layout:
|
|
||||||
[904-905] ECG Heart Rate (LE u16)
|
|
||||||
65535 or 9999 = invalid/disconnected
|
|
||||||
"""
|
|
||||||
if len(data) < 906:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if "present_fields" not in vitals_dict:
|
|
||||||
vitals_dict["present_fields"] = []
|
|
||||||
if "heart_rate" not in vitals_dict["present_fields"]:
|
|
||||||
vitals_dict["present_fields"].append("heart_rate")
|
|
||||||
vitals_dict.setdefault("heart_rate", None)
|
|
||||||
|
|
||||||
found = False
|
|
||||||
|
|
||||||
hr_val = read_u16_le(data, 904)
|
|
||||||
if hr_val is not None and hr_val != 65535 and hr_val != 9999 and 20 <= hr_val <= 300:
|
|
||||||
vitals_dict["heart_rate"] = float(hr_val)
|
|
||||||
found = True
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _find_vitals_block_in_stream(data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
|
|
||||||
"""
|
|
||||||
Scan the data stream for Contec CMS7000PLUS sub-packets and parse them
|
|
||||||
deterministically based on packet length.
|
|
||||||
|
|
||||||
Packet types (verified by raw hex analysis):
|
|
||||||
45 bytes (Subtype 22): NIBP Sys/Dia/MAP (u16 LE starting at offset 15)
|
|
||||||
56 bytes (Subtype 23): Float32 Temperature (offset 8/12)
|
|
||||||
286 bytes (Subtype 21): SpO2% at offset 264
|
|
||||||
341 bytes (Subtype 21): SpO2% at offset 264
|
|
||||||
989 bytes (Subtype 20): ECG Heart Rate at offset 904
|
|
||||||
"""
|
|
||||||
model = settings.device_models.get(client_ip, settings.monitor_model)
|
|
||||||
vitals_dict = {
|
|
||||||
"device_id": f"{model}_{client_ip}",
|
|
||||||
"patient_id": "UNKNOWN",
|
|
||||||
"timestamp": datetime.now(timezone.utc),
|
|
||||||
"ip_address": client_ip,
|
|
||||||
}
|
|
||||||
found_any = False
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
while i < len(data) - 8:
|
|
||||||
if (data[i + 2] == 0x04 or data[i + 2] == 0x01 or data[i + 2] == 0x00) and data[i + 3] == 0x46:
|
|
||||||
pkt_len = struct.unpack_from('<H', data, i)[0]
|
|
||||||
|
|
||||||
if 30 < pkt_len < 1050 and i + pkt_len <= len(data):
|
|
||||||
pkt_data = data[i:i + pkt_len]
|
|
||||||
|
|
||||||
if pkt_len in (45, 47):
|
|
||||||
_parse_45_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
elif pkt_len == 56:
|
|
||||||
_parse_56_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
elif pkt_len == 286:
|
|
||||||
_parse_286_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
elif pkt_len == 288:
|
|
||||||
_parse_288_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
elif pkt_len == 341:
|
|
||||||
_parse_341_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
elif pkt_len == 989:
|
|
||||||
_parse_989_byte_packet(pkt_data, vitals_dict)
|
|
||||||
found_any = True
|
|
||||||
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return NormalizedVitals(**vitals_dict) if found_any else None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_contec_binary_packet(data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
|
|
||||||
"""
|
|
||||||
Decodes the Contec CMS7000PLUS proprietary binary protocol.
|
|
||||||
|
|
||||||
The monitor sends two alternating TCP packets:
|
|
||||||
- Packet A (991 bytes): ECG waveform + SpO2 waveform + vitals footer
|
|
||||||
- Packet B (397 bytes): Resp waveform + vitals summary + param block
|
|
||||||
|
|
||||||
Both have the structure: [len_lo][len_hi][04][46][subtype][00][sublen][00]...[payload]...
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return _find_vitals_block_in_stream(data, client_ip)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error parsing Contec binary packet: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_contec_data(raw_data: bytes, client_ip: str) -> Optional[NormalizedVitals]:
|
|
||||||
"""
|
|
||||||
Unified entry point for parsing data from a Contec CMS7000PLUS monitor.
|
|
||||||
Attempts HL7 text parsing first, falling back to proprietary binary parsing.
|
|
||||||
"""
|
|
||||||
if not raw_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Attempt 1: HL7 Text Decoding
|
|
||||||
try:
|
|
||||||
text = raw_data.decode("utf-8", errors="ignore").strip()
|
|
||||||
if "MSH" in text or "\x0bMSH" in text:
|
|
||||||
vitals = parse_contec_hl7_text(text, client_ip)
|
|
||||||
if vitals:
|
|
||||||
return vitals
|
|
||||||
else:
|
|
||||||
logger.warning(f"HL7 message detected but failed to parse. Content: {repr(text)}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed decoding/parsing HL7: {e}")
|
|
||||||
|
|
||||||
# Attempt 2: Binary Parsing (Contec proprietary protocol)
|
|
||||||
vitals = parse_contec_binary_packet(raw_data, client_ip)
|
|
||||||
if vitals:
|
|
||||||
return vitals
|
|
||||||
|
|
||||||
# Logging fallback
|
|
||||||
logger.warning(
|
|
||||||
f"Data packet from {client_ip} unrecognized. "
|
|
||||||
f"Len: {len(raw_data)}, Hex head: {raw_data[:32].hex()}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from contec_parser import parse_contec_data
|
|
||||||
from database import SessionLocal
|
|
||||||
from models import Device, Patient, VitalReading, TransmissionLog
|
|
||||||
from api_client import forward_vitals_to_api
|
|
||||||
from terminal_display import get_terminal_display
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Constants for MLLP framing (used on port 511 for Contec HL7 text)
|
|
||||||
VT = b'\x0b'
|
|
||||||
FS_CR = b'\x1c\x0d'
|
|
||||||
|
|
||||||
|
|
||||||
def generate_ack(received_msg: str) -> str:
|
|
||||||
"""Generates a simple HL7 ACK message to satisfy the monitor's TCP client."""
|
|
||||||
try:
|
|
||||||
segments = received_msg.split('\r')
|
|
||||||
msh = segments[0] if segments else ""
|
|
||||||
if not msh.startswith("MSH|"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
fields = msh.split('|')
|
|
||||||
if len(fields) < 10:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
sending_app = fields[2]
|
|
||||||
sending_fac = fields[3]
|
|
||||||
rec_app = fields[4]
|
|
||||||
rec_fac = fields[5]
|
|
||||||
msg_control_id = fields[9]
|
|
||||||
|
|
||||||
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
||||||
|
|
||||||
ack_msh = f"MSH|^~\\&|{rec_app}|{rec_fac}|{sending_app}|{sending_fac}|{now_str}||ACK^R01|{msg_control_id}|P|2.3.1\r"
|
|
||||||
ack_msa = f"MSA|AA|{msg_control_id}\r"
|
|
||||||
|
|
||||||
return ack_msh + ack_msa
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not generate ACK: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_contec_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int):
|
|
||||||
"""
|
|
||||||
TCP server handler for Contec CMS7000PLUS client connections.
|
|
||||||
Directs incoming traffic to the unified parse_contec_data handler.
|
|
||||||
"""
|
|
||||||
client_ip, client_port = writer.get_extra_info('peername')
|
|
||||||
logger.info(f"Accepted Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
|
|
||||||
|
|
||||||
display = get_terminal_display()
|
|
||||||
display.connection_opened(client_ip, port)
|
|
||||||
|
|
||||||
buffer = b""
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await reader.read(8192)
|
|
||||||
if not data:
|
|
||||||
logger.info(f"Contec client {client_ip}:{client_port} on port {port} sent EOF (0 bytes)")
|
|
||||||
break
|
|
||||||
|
|
||||||
buffer += data
|
|
||||||
logger.debug(f"Received {len(data)} bytes from {client_ip}:{client_port} on port {port} (buf={len(buffer)})")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# MLLP / HL7 text path (typically port 511)
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
if VT in buffer and FS_CR in buffer and b"MSH|" in buffer:
|
|
||||||
while True:
|
|
||||||
start_idx = buffer.find(VT)
|
|
||||||
end_idx = buffer.find(FS_CR)
|
|
||||||
if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
|
|
||||||
raw_hl7 = buffer[start_idx + 1:end_idx].decode('utf-8', errors='ignore')
|
|
||||||
buffer = buffer[end_idx + 2:]
|
|
||||||
logger.info(f"Contec MLLP message detected on port {port}")
|
|
||||||
vitals = parse_contec_data(raw_hl7.encode('utf-8'), client_ip)
|
|
||||||
if vitals:
|
|
||||||
await process_vitals(vitals)
|
|
||||||
ack_msg = generate_ack(raw_hl7)
|
|
||||||
if ack_msg:
|
|
||||||
writer.write(VT + ack_msg.encode('utf-8') + FS_CR)
|
|
||||||
await writer.drain()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
continue # Go back to reading more data
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Binary Contec proprietary protocol (port 514)
|
|
||||||
# Packets are framed as: [len_lo][len_hi][04][46] ... payload ...
|
|
||||||
# The monitor sends each packet as a complete TCP write,
|
|
||||||
# but we buffer anyway in case of TCP segmentation.
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
consumed = 0
|
|
||||||
while consumed < len(buffer) - 4:
|
|
||||||
b0 = buffer[consumed]
|
|
||||||
b1 = buffer[consumed + 1]
|
|
||||||
b2 = buffer[consumed + 2]
|
|
||||||
b3 = buffer[consumed + 3]
|
|
||||||
|
|
||||||
if (b2 == 0x04 or b2 == 0x01 or b2 == 0x00) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
pkt_end = consumed + pkt_len
|
|
||||||
|
|
||||||
if pkt_len < 5 or pkt_len > 8192:
|
|
||||||
# Bad length — skip one byte and keep scanning
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if pkt_end > len(buffer):
|
|
||||||
# Don't have the full packet yet — wait for more data
|
|
||||||
break
|
|
||||||
|
|
||||||
# Parse the complete packet
|
|
||||||
pkt = buffer[consumed:pkt_end]
|
|
||||||
vitals = parse_contec_data(pkt, client_ip)
|
|
||||||
if vitals:
|
|
||||||
logger.info(
|
|
||||||
f"[PORT {port}] Vitals from {client_ip}: "
|
|
||||||
f"SpO2={vitals.spo2}%, HR={vitals.heart_rate}bpm "
|
|
||||||
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp} "
|
|
||||||
f"Temp={vitals.temperature}C"
|
|
||||||
)
|
|
||||||
await process_vitals(vitals)
|
|
||||||
|
|
||||||
consumed = pkt_end
|
|
||||||
|
|
||||||
elif (b2 == 0x04 or b2 == 0x01) and b3 == 0x47:
|
|
||||||
# End-of-frame marker — skip 4 bytes
|
|
||||||
consumed += 4
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Not a recognised frame start — advance one byte
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
# Discard consumed bytes from front of buffer
|
|
||||||
buffer = buffer[consumed:]
|
|
||||||
|
|
||||||
# Safety valve
|
|
||||||
if len(buffer) > 32768:
|
|
||||||
logger.warning(f"Buffer too large ({len(buffer)} bytes) on port {port}, resetting")
|
|
||||||
buffer = b""
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.info(f"Contec CMS7000PLUS connection reset from {client_ip} on port {port}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in Contec client handler (port {port}): {e}")
|
|
||||||
finally:
|
|
||||||
logger.info(f"Closing Contec CMS7000PLUS connection from {client_ip}:{client_port} on port {port}")
|
|
||||||
display.connection_closed(client_ip, port)
|
|
||||||
writer.close()
|
|
||||||
try:
|
|
||||||
await writer.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Cache to hold merged vitals per device to prevent fragmentation
|
|
||||||
device_cache = {}
|
|
||||||
device_field_timestamps = {} # {device_id: {field_name: datetime}}
|
|
||||||
last_db_write = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def process_vitals(vitals):
|
|
||||||
"""
|
|
||||||
Log, database, WebSocket broadcast, and REST forward parsed vitals.
|
|
||||||
"""
|
|
||||||
device_id = vitals.device_id
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if device_id not in device_field_timestamps:
|
|
||||||
device_field_timestamps[device_id] = {}
|
|
||||||
|
|
||||||
# 1. Merge new vitals into cached vitals to prevent fragmented entries
|
|
||||||
if device_id not in device_cache:
|
|
||||||
device_cache[device_id] = vitals
|
|
||||||
# Track initial timestamps for present values
|
|
||||||
for field, value in vitals.model_dump().items():
|
|
||||||
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
|
|
||||||
continue
|
|
||||||
if value is not None:
|
|
||||||
device_field_timestamps[device_id][field] = now
|
|
||||||
else:
|
|
||||||
cached = device_cache[device_id]
|
|
||||||
|
|
||||||
# Determine active fields in incoming packet
|
|
||||||
active_fields = []
|
|
||||||
if vitals.present_fields is not None:
|
|
||||||
active_fields = vitals.present_fields
|
|
||||||
else:
|
|
||||||
for field, value in vitals.model_dump().items():
|
|
||||||
if field == "present_fields" or field in ["device_id", "patient_id", "timestamp"]:
|
|
||||||
continue
|
|
||||||
if value is not None:
|
|
||||||
active_fields.append(field)
|
|
||||||
|
|
||||||
# Update cache & record timestamps
|
|
||||||
for field in active_fields:
|
|
||||||
val = getattr(vitals, field)
|
|
||||||
setattr(cached, field, val)
|
|
||||||
device_field_timestamps[device_id][field] = now
|
|
||||||
|
|
||||||
# Clean up stale fields (older than 15 seconds)
|
|
||||||
for field in list(device_field_timestamps[device_id].keys()):
|
|
||||||
last_updated = device_field_timestamps[device_id][field]
|
|
||||||
if (now - last_updated).total_seconds() > 15.0:
|
|
||||||
setattr(cached, field, None)
|
|
||||||
device_field_timestamps[device_id].pop(field, None)
|
|
||||||
|
|
||||||
cached.timestamp = vitals.timestamp
|
|
||||||
vitals = cached
|
|
||||||
|
|
||||||
# 2. Update console display banner
|
|
||||||
display = get_terminal_display()
|
|
||||||
display.update_vitals(vitals)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Vitals for Device {vitals.device_id}, Patient {vitals.patient_id}: "
|
|
||||||
f"HR={vitals.heart_rate}, SpO2={vitals.spo2}, "
|
|
||||||
f"BP={vitals.systolic_bp}/{vitals.diastolic_bp}, "
|
|
||||||
f"RR={vitals.respiratory_rate}, Temp={vitals.temperature}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Broadcast to live WebSockets dashboard in real-time
|
|
||||||
try:
|
|
||||||
from routers.dashboard import get_ws_manager
|
|
||||||
ws_manager = get_ws_manager()
|
|
||||||
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
|
||||||
|
|
||||||
# 4. Throttled Database & REST forwarding (at most once every 5 seconds)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
should_write_db = False
|
|
||||||
if device_id not in last_db_write or (now - last_db_write[device_id]).total_seconds() >= 5.0:
|
|
||||||
should_write_db = True
|
|
||||||
last_db_write[device_id] = now
|
|
||||||
|
|
||||||
if should_write_db:
|
|
||||||
# Save to SQLite database and REST forward
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# 1. Device tracking
|
|
||||||
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
|
||||||
if not device:
|
|
||||||
device = Device(
|
|
||||||
device_id=vitals.device_id,
|
|
||||||
ip_address=vitals.ip_address or "unknown",
|
|
||||||
status="active"
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
else:
|
|
||||||
if vitals.ip_address:
|
|
||||||
device.ip_address = vitals.ip_address
|
|
||||||
device.status = "active"
|
|
||||||
device.last_seen = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# 2. Patient tracking
|
|
||||||
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
|
||||||
if not patient:
|
|
||||||
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
|
||||||
db.add(patient)
|
|
||||||
|
|
||||||
# 3. Create Reading Entry
|
|
||||||
reading = VitalReading(
|
|
||||||
device_id=vitals.device_id,
|
|
||||||
patient_id=vitals.patient_id,
|
|
||||||
timestamp=vitals.timestamp,
|
|
||||||
heart_rate=vitals.heart_rate,
|
|
||||||
spo2=vitals.spo2,
|
|
||||||
systolic_bp=vitals.systolic_bp,
|
|
||||||
diastolic_bp=vitals.diastolic_bp,
|
|
||||||
map_bp=vitals.map_bp,
|
|
||||||
respiratory_rate=vitals.respiratory_rate,
|
|
||||||
temperature=vitals.temperature,
|
|
||||||
transmitted=False
|
|
||||||
)
|
|
||||||
db.add(reading)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(reading)
|
|
||||||
|
|
||||||
# 4. REST forwarding
|
|
||||||
success = await forward_vitals_to_api(vitals)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
reading.transmitted = True
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="success",
|
|
||||||
response_code=200
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log_entry = TransmissionLog(
|
|
||||||
reading_id=reading.id,
|
|
||||||
status="failed",
|
|
||||||
error_message="Immediate REST forward failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(log_entry)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Database operation failed: {e}")
|
|
||||||
db.rollback()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
settings.database_url, connect_args={"check_same_thread": False}
|
|
||||||
)
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import multiprocessing
|
|
||||||
import socket
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from database import engine, Base
|
|
||||||
from contec_server import handle_contec_client
|
|
||||||
from background_tasks import retry_failed_transmissions
|
|
||||||
from routers import dashboard
|
|
||||||
from routers.dashboard import get_ws_manager
|
|
||||||
|
|
||||||
# Setup Logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(settings.log_file),
|
|
||||||
logging.StreamHandler()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Create database tables if they don't exist
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_contec_handler(port: int):
|
|
||||||
"""Create a connection handler closure for a specific Contec port."""
|
|
||||||
async def handler(reader, writer):
|
|
||||||
await handle_contec_client(reader, writer, port)
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
def _get_local_ips():
|
|
||||||
"""Get all local IP addresses for this machine."""
|
|
||||||
ips = []
|
|
||||||
try:
|
|
||||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
||||||
ip = info[4][0]
|
|
||||||
if ip not in ips and not ip.startswith('127.'):
|
|
||||||
ips.append(ip)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Also try connecting to an external address to find the primary IP
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
primary_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
if primary_ip not in ips:
|
|
||||||
ips.insert(0, primary_ip)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ips if ips else ['Could not detect — check ipconfig']
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
servers = []
|
|
||||||
|
|
||||||
# === Start Contec CMS7000PLUS TCP Servers ===
|
|
||||||
logger.info("Starting Contec CMS7000PLUS listeners...")
|
|
||||||
for port in settings.contec_ports:
|
|
||||||
try:
|
|
||||||
contec_server = await asyncio.start_server(
|
|
||||||
_make_contec_handler(port),
|
|
||||||
settings.hl7_host,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
servers.append(contec_server)
|
|
||||||
logger.info(f" [OK] Contec listener on port {port}")
|
|
||||||
except PermissionError:
|
|
||||||
logger.warning(
|
|
||||||
f" [FAIL] Port {port} - Permission denied. "
|
|
||||||
f"Run as Administrator for ports below 1024."
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
if "already in use" in str(e).lower() or getattr(e, 'errno', 0) == 10048:
|
|
||||||
logger.warning(f" [FAIL] Port {port} - Already in use")
|
|
||||||
else:
|
|
||||||
logger.warning(f" [FAIL] Port {port} - {e}")
|
|
||||||
|
|
||||||
if servers:
|
|
||||||
active_ports = []
|
|
||||||
for s in servers:
|
|
||||||
for sock in s.sockets:
|
|
||||||
active_ports.append(sock.getsockname()[1])
|
|
||||||
app.state.active_ports = active_ports
|
|
||||||
logger.info(f" Active listener ports: {active_ports}")
|
|
||||||
else:
|
|
||||||
app.state.active_ports = []
|
|
||||||
logger.error("No servers could be started! Check port availability and permissions.")
|
|
||||||
|
|
||||||
# === Print startup banner ===
|
|
||||||
import os
|
|
||||||
port = int(os.environ.get("PORT", 8000))
|
|
||||||
local_ips = _get_local_ips()
|
|
||||||
model = settings.monitor_model
|
|
||||||
print("\n" + "=" * 65)
|
|
||||||
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
|
|
||||||
print("=" * 65)
|
|
||||||
print(f" DASHBOARD: http://localhost:{port}/api/dashboard")
|
|
||||||
print(f" API Docs: http://localhost:{port}/docs")
|
|
||||||
print(f" Contec Ports: {settings.contec_ports}")
|
|
||||||
print(f" Monitor Model: {model}")
|
|
||||||
print(f" API Target: {settings.target_api_url}")
|
|
||||||
print(f" Database: {settings.database_url}")
|
|
||||||
print(f" ---------------------------------------------------------")
|
|
||||||
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
|
|
||||||
for ip in local_ips:
|
|
||||||
print(f" -> {ip}")
|
|
||||||
print("=" * 65)
|
|
||||||
print(f" Waiting for {model} connections...")
|
|
||||||
print(f" On your monitor: System Setup > Network > CMS Settings")
|
|
||||||
print(f" Set Server IP = one of the IPs above, Port = {settings.contec_ports[0] if settings.contec_ports else 511}")
|
|
||||||
print("=" * 65 + "\n")
|
|
||||||
|
|
||||||
# Startup: Start the background retry task
|
|
||||||
retry_task = asyncio.create_task(retry_failed_transmissions())
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
# Shutdown
|
|
||||||
logger.info("Shutting down servers...")
|
|
||||||
retry_task.cancel()
|
|
||||||
for server in servers:
|
|
||||||
server.close()
|
|
||||||
for server in servers:
|
|
||||||
await server.wait_closed()
|
|
||||||
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
|
||||||
app = FastAPI(
|
|
||||||
title="Contec CMS7000PLUS Vital Signs Forwarder",
|
|
||||||
description="Service to receive vital signs from Contec CMS7000PLUS patient monitors and forward them to a REST API",
|
|
||||||
version="2.0.0",
|
|
||||||
lifespan=lifespan
|
|
||||||
)
|
|
||||||
|
|
||||||
# Enable CORS for cross-origin app integrations (e.g. Electron apps, local dev servers)
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include dashboard routes
|
|
||||||
app.include_router(dashboard.router, prefix="/api")
|
|
||||||
|
|
||||||
# Serve static folder
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
# === WebSocket endpoint for live vitals ===
|
|
||||||
@app.websocket("/ws/vitals")
|
|
||||||
async def websocket_vitals(websocket: WebSocket):
|
|
||||||
manager = get_ws_manager()
|
|
||||||
await manager.connect(websocket)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# Keep connection alive, listen for any client messages
|
|
||||||
await websocket.receive_text()
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
manager.disconnect(websocket)
|
|
||||||
except Exception:
|
|
||||||
manager.disconnect(websocket)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Required for Windows when building with PyInstaller using multiprocessing (uvicorn workers)
|
|
||||||
multiprocessing.freeze_support()
|
|
||||||
|
|
||||||
logger.info("Initializing Application...")
|
|
||||||
|
|
||||||
# Run the FastAPI server.
|
|
||||||
# Notice we run on port 8000 (or PORT env var) for the REST Dashboard, while HL7 is on settings.hl7_port (e.g. 6060)
|
|
||||||
import os
|
|
||||||
port = int(os.environ.get("PORT", 8000))
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
class Device(Base):
|
|
||||||
__tablename__ = "devices"
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
device_id = Column(String, unique=True, index=True)
|
|
||||||
ip_address = Column(String)
|
|
||||||
status = Column(String, default="active")
|
|
||||||
last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
class Patient(Base):
|
|
||||||
__tablename__ = "patients"
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
patient_id = Column(String, unique=True, index=True)
|
|
||||||
name = Column(String)
|
|
||||||
|
|
||||||
class VitalReading(Base):
|
|
||||||
__tablename__ = "vital_readings"
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
device_id = Column(String, index=True)
|
|
||||||
patient_id = Column(String, index=True)
|
|
||||||
timestamp = Column(DateTime, index=True)
|
|
||||||
heart_rate = Column(Float, nullable=True)
|
|
||||||
spo2 = Column(Float, nullable=True)
|
|
||||||
systolic_bp = Column(Float, nullable=True)
|
|
||||||
diastolic_bp = Column(Float, nullable=True)
|
|
||||||
map_bp = Column(Float, nullable=True)
|
|
||||||
respiratory_rate = Column(Float, nullable=True)
|
|
||||||
temperature = Column(Float, nullable=True)
|
|
||||||
transmitted = Column(Boolean, default=False, index=True)
|
|
||||||
|
|
||||||
class TransmissionLog(Base):
|
|
||||||
__tablename__ = "transmission_logs"
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
reading_id = Column(Integer, index=True)
|
|
||||||
status = Column(String)
|
|
||||||
response_code = Column(Integer, nullable=True)
|
|
||||||
error_message = Column(String, nullable=True)
|
|
||||||
timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
services:
|
|
||||||
- type: web
|
|
||||||
name: contec-monitor-api
|
|
||||||
runtime: python
|
|
||||||
buildCommand: pip install -r requirements.txt
|
|
||||||
startCommand: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
|
|
||||||
envVars:
|
|
||||||
- key: PORT
|
|
||||||
value: 10000
|
|
||||||
- key: PYTHON_VERSION
|
|
||||||
value: 3.12.0
|
|
||||||
- key: TARGET_API_URL
|
|
||||||
sync: false
|
|
||||||
- key: API_TOKEN
|
|
||||||
sync: false
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
fastapi
|
|
||||||
uvicorn
|
|
||||||
sqlalchemy
|
|
||||||
pydantic
|
|
||||||
pydantic-settings
|
|
||||||
httpx
|
|
||||||
gunicorn
|
|
||||||
rich
|
|
||||||
websockets
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Python package marker
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from typing import List
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from database import get_db
|
|
||||||
from models import Device, VitalReading, TransmissionLog
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# === WebSocket Manager for real-time vitals push ===
|
|
||||||
class VitalsWebSocketManager:
|
|
||||||
"""Manages WebSocket connections for live vitals streaming."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.active_connections: List[WebSocket] = []
|
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket):
|
|
||||||
await websocket.accept()
|
|
||||||
self.active_connections.append(websocket)
|
|
||||||
logger.info(f"WebSocket client connected ({len(self.active_connections)} active)")
|
|
||||||
|
|
||||||
def disconnect(self, websocket: WebSocket):
|
|
||||||
if websocket in self.active_connections:
|
|
||||||
self.active_connections.remove(websocket)
|
|
||||||
logger.info(f"WebSocket client disconnected ({len(self.active_connections)} active)")
|
|
||||||
|
|
||||||
async def broadcast_vitals(self, vitals_data: dict):
|
|
||||||
"""Send vitals to all connected WebSocket clients."""
|
|
||||||
dead = []
|
|
||||||
for connection in self.active_connections:
|
|
||||||
try:
|
|
||||||
await connection.send_json(vitals_data)
|
|
||||||
except Exception:
|
|
||||||
dead.append(connection)
|
|
||||||
for d in dead:
|
|
||||||
self.disconnect(d)
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton
|
|
||||||
ws_manager = VitalsWebSocketManager()
|
|
||||||
|
|
||||||
|
|
||||||
def get_ws_manager() -> VitalsWebSocketManager:
|
|
||||||
return ws_manager
|
|
||||||
|
|
||||||
|
|
||||||
# === Dashboard HTML Route ===
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
|
||||||
def serve_dashboard():
|
|
||||||
"""Serve the vitals monitoring dashboard."""
|
|
||||||
import os
|
|
||||||
dashboard_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "dashboard.html")
|
|
||||||
try:
|
|
||||||
with open(dashboard_path, "r", encoding="utf-8") as f:
|
|
||||||
return HTMLResponse(content=f.read())
|
|
||||||
except FileNotFoundError:
|
|
||||||
return HTMLResponse(content="<h1>Dashboard not found</h1><p>static/dashboard.html is missing</p>", status_code=404)
|
|
||||||
|
|
||||||
|
|
||||||
# === API Endpoints ===
|
|
||||||
@router.get("/health")
|
|
||||||
def health_check():
|
|
||||||
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
def get_system_status(request: Request, db: Session = Depends(get_db)):
|
|
||||||
total_devices = db.query(Device).count()
|
|
||||||
active_devices = db.query(Device).filter(Device.status == "active").count()
|
|
||||||
total_readings = db.query(VitalReading).count()
|
|
||||||
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
|
|
||||||
active_ports = getattr(request.app.state, "active_ports", [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_devices": total_devices,
|
|
||||||
"active_devices": active_devices,
|
|
||||||
"total_readings_stored": total_readings,
|
|
||||||
"pending_transmissions": pending_transmissions,
|
|
||||||
"active_ports": active_ports
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/devices")
|
|
||||||
def list_devices(db: Session = Depends(get_db)):
|
|
||||||
devices = db.query(Device).all()
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
updated = False
|
|
||||||
for device in devices:
|
|
||||||
last_seen = device.last_seen
|
|
||||||
if last_seen.tzinfo is None:
|
|
||||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
# If not seen for more than 15 seconds, mark offline
|
|
||||||
if (now - last_seen).total_seconds() > 15.0:
|
|
||||||
if device.status != "offline":
|
|
||||||
device.status = "offline"
|
|
||||||
updated = True
|
|
||||||
else:
|
|
||||||
if device.status != "active":
|
|
||||||
device.status = "active"
|
|
||||||
updated = True
|
|
||||||
if updated:
|
|
||||||
db.commit()
|
|
||||||
return devices
|
|
||||||
|
|
||||||
@router.get("/latest-readings")
|
|
||||||
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
|
|
||||||
query = db.query(VitalReading)
|
|
||||||
if device_id:
|
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
|
||||||
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
|
||||||
return readings
|
|
||||||
|
|
||||||
@router.get("/readings")
|
|
||||||
def get_readings(device_id: str = None, patient_id: str = None, limit: int = 50, db: Session = Depends(get_db)):
|
|
||||||
query = db.query(VitalReading)
|
|
||||||
if device_id:
|
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
|
||||||
if patient_id:
|
|
||||||
query = query.filter(VitalReading.patient_id == patient_id)
|
|
||||||
|
|
||||||
return query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
|
||||||
|
|
||||||
@router.get("/transmission-logs")
|
|
||||||
def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)):
|
|
||||||
logs = db.query(TransmissionLog).order_by(TransmissionLog.timestamp.desc()).limit(limit).all()
|
|
||||||
return logs
|
|
||||||
|
|
||||||
@router.post("/test-api")
|
|
||||||
def test_target_api_connection():
|
|
||||||
"""
|
|
||||||
Endpoint to trigger a test POST to the target API.
|
|
||||||
"""
|
|
||||||
from config import settings
|
|
||||||
return {
|
|
||||||
"message": "Test triggered",
|
|
||||||
"target_url": settings.target_api_url
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/network-info")
|
|
||||||
def get_network_info():
|
|
||||||
"""
|
|
||||||
Returns this PC's local IP addresses.
|
|
||||||
Useful for configuring the CMS7000PLUS monitor's CMS Server IP.
|
|
||||||
"""
|
|
||||||
from config import settings
|
|
||||||
ips = []
|
|
||||||
try:
|
|
||||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
||||||
ip = info[4][0]
|
|
||||||
if ip not in ips and not ip.startswith('127.'):
|
|
||||||
ips.append(ip)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Find primary IP
|
|
||||||
primary_ip = None
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
primary_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
if primary_ip not in ips:
|
|
||||||
ips.insert(0, primary_ip)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hostname": socket.gethostname(),
|
|
||||||
"ip_addresses": ips,
|
|
||||||
"primary_ip": primary_ip or (ips[0] if ips else None),
|
|
||||||
"monitor_model": settings.monitor_model,
|
|
||||||
"contec_ports": settings.contec_ports,
|
|
||||||
"instructions": [
|
|
||||||
f"1. Connect your {settings.monitor_model} to this PC via Ethernet",
|
|
||||||
f"2. On the monitor: System Setup → Network → CMS Settings",
|
|
||||||
f"3. Set Server IP to: {primary_ip or 'your PC IP'}",
|
|
||||||
f"4. Set Server Port to: {settings.contec_ports[0] if settings.contec_ports else 511}",
|
|
||||||
f"5. Enable the CMS connection",
|
|
||||||
f"6. The dashboard will show data automatically"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# === Push Vitals Endpoint (for local-to-cloud sync) ===
|
|
||||||
from schemas import NormalizedVitals
|
|
||||||
from models import Patient
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/push-vitals")
|
|
||||||
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Receive vitals pushed from a local instance and store + broadcast them.
|
|
||||||
|
|
||||||
This enables the 'split architecture' where:
|
|
||||||
- Local instance receives data from physical monitors via TCP
|
|
||||||
- Local instance POSTs vitals here for cloud storage & dashboard access
|
|
||||||
|
|
||||||
Optional: pass ?api_key=YOUR_KEY for basic authentication.
|
|
||||||
"""
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
# Optional API key check
|
|
||||||
if settings.api_token and api_key != settings.api_token:
|
|
||||||
if settings.api_token: # Only enforce if a token is configured
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. Device tracking
|
|
||||||
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
|
||||||
if not device:
|
|
||||||
device = Device(
|
|
||||||
device_id=vitals.device_id,
|
|
||||||
ip_address=vitals.ip_address or "remote-push",
|
|
||||||
status="active"
|
|
||||||
)
|
|
||||||
db.add(device)
|
|
||||||
else:
|
|
||||||
if vitals.ip_address:
|
|
||||||
device.ip_address = vitals.ip_address
|
|
||||||
device.status = "active"
|
|
||||||
device.last_seen = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# 2. Patient tracking
|
|
||||||
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
|
||||||
if not patient:
|
|
||||||
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
|
||||||
db.add(patient)
|
|
||||||
|
|
||||||
# 3. Create vital reading
|
|
||||||
from models import VitalReading as VR
|
|
||||||
reading = VR(
|
|
||||||
device_id=vitals.device_id,
|
|
||||||
patient_id=vitals.patient_id,
|
|
||||||
timestamp=vitals.timestamp,
|
|
||||||
heart_rate=vitals.heart_rate,
|
|
||||||
spo2=vitals.spo2,
|
|
||||||
systolic_bp=vitals.systolic_bp,
|
|
||||||
diastolic_bp=vitals.diastolic_bp,
|
|
||||||
map_bp=vitals.map_bp,
|
|
||||||
respiratory_rate=vitals.respiratory_rate,
|
|
||||||
temperature=vitals.temperature,
|
|
||||||
transmitted=True # Already received = transmitted
|
|
||||||
)
|
|
||||||
db.add(reading)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(reading)
|
|
||||||
|
|
||||||
# 4. Broadcast to WebSocket clients
|
|
||||||
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
|
|
||||||
|
|
||||||
logger.info(f"Push-vitals received for device {vitals.device_id}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"reading_id": reading.id,
|
|
||||||
"device_id": vitals.device_id,
|
|
||||||
"patient_id": vitals.patient_id
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Push-vitals error: {e}")
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== Mindray HL7 Forwarder ==="
|
|
||||||
|
|
||||||
# 1. Create a virtual environment if it doesn't exist
|
|
||||||
if [ ! -d "venv" ]; then
|
|
||||||
echo "Creating Python virtual environment..."
|
|
||||||
python3 -m venv venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Activate the virtual environment
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# 3. Install dependencies quietly
|
|
||||||
echo "Checking and installing dependencies..."
|
|
||||||
pip install -r requirements.txt -q
|
|
||||||
|
|
||||||
# 4. Start the server
|
|
||||||
echo "Starting the application..."
|
|
||||||
python main.py
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python-3.12.0
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class NormalizedVitals(BaseModel):
|
|
||||||
device_id: str
|
|
||||||
patient_id: str
|
|
||||||
timestamp: datetime
|
|
||||||
ip_address: Optional[str] = None
|
|
||||||
heart_rate: Optional[float] = None
|
|
||||||
spo2: Optional[float] = None
|
|
||||||
systolic_bp: Optional[float] = None
|
|
||||||
diastolic_bp: Optional[float] = None
|
|
||||||
map_bp: Optional[float] = None
|
|
||||||
respiratory_rate: Optional[float] = None
|
|
||||||
temperature: Optional[float] = None
|
|
||||||
present_fields: Optional[list] = None
|
|
||||||
|
|
||||||
class DeviceStatus(BaseModel):
|
|
||||||
device_id: str
|
|
||||||
ip_address: str
|
|
||||||
status: str
|
|
||||||
last_seen: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class TransmissionLogResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
reading_id: int
|
|
||||||
status: str
|
|
||||||
response_code: Optional[int]
|
|
||||||
error_message: Optional[str]
|
|
||||||
timestamp: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Findings summary and analysis artifact
|
|
||||||
|
|
||||||
"""
|
|
||||||
=== COMPLETE PACKET STRUCTURE DECODED ===
|
|
||||||
|
|
||||||
From monitor image: ECG=88, SpO2=98, NIBP=110/67 (MAP=82), TEMP=disconnected
|
|
||||||
|
|
||||||
--- 45-BYTE PACKET (Subtype 22) = REAL-TIME VITALS + NIBP ---
|
|
||||||
The 45-byte packet uses BIG-ENDIAN u16 format!
|
|
||||||
Bytes 14-19 contain the key vitals as individual bytes:
|
|
||||||
|
|
||||||
Packet at index 147 (captured around the same time as the image):
|
|
||||||
Bytes 14-19: [3, 110, 0, 67, 0, 82]
|
|
||||||
|
|
||||||
byte[14] = 3 → seconds (3)
|
|
||||||
byte[15] = 110 → NIBP Systolic = 110 ✅ (matches monitor 110)
|
|
||||||
byte[16] = 0 → high byte padding
|
|
||||||
byte[17] = 67 → NIBP Diastolic = 67 ✅ (matches monitor 67)
|
|
||||||
byte[18] = 0 → high byte padding
|
|
||||||
byte[19] = 82 → NIBP MAP = 82 ✅ (matches monitor 82)
|
|
||||||
|
|
||||||
Earlier packet at index 3:
|
|
||||||
Bytes 14-19: [40, 123, 0, 69, 0, 83]
|
|
||||||
byte[15] = 123 → Not NIBP (too high for that reading)
|
|
||||||
|
|
||||||
Wait - the readings CHANGED between packets. Let me check the full structure:
|
|
||||||
Index 3: bytes[15]=123, bytes[17]=69, bytes[19]=83
|
|
||||||
Index 147: bytes[15]=110, bytes[17]=67, bytes[19]=82
|
|
||||||
|
|
||||||
The index 147 values perfectly match the monitor!
|
|
||||||
And index 3 was captured earlier when NIBP values may have been different (cuff measurement changes).
|
|
||||||
|
|
||||||
Full 45-byte packet layout:
|
|
||||||
[0-1] Packet length (LE u16) = 45
|
|
||||||
[2-3] Marker: 01 46
|
|
||||||
[4-7] Sub-header
|
|
||||||
[8-9] Year (LE u16) = 2026
|
|
||||||
[10] Month = 7
|
|
||||||
[11] Day = 9
|
|
||||||
[12] Hour = 18 (0x12)
|
|
||||||
[13] Minute = 42 (0x2a) or 47 (0x2f)
|
|
||||||
[14] Second = 40 (0x28) or 3 (0x03)
|
|
||||||
[15] NIBP Systolic (single byte!)
|
|
||||||
[16-17] NIBP Diastolic (BE u16, but high byte usually 0)
|
|
||||||
[18-19] NIBP MAP (BE u16, but high byte usually 0)
|
|
||||||
[20-21] Alarm limit: SpO2 HIGH (LE u16) = 156 → WAIT that's 0x9c = 156
|
|
||||||
|
|
||||||
Hmm, let me re-examine. Looking at bytes starting at offset 14:
|
|
||||||
Packet 147: [03, 6e, 00, 43, 00, 52, 00, 9c, 00, 5a, 00, 02, 00, 5a, 00, 32, 00, 02, 00, 6a, 00, 3c, 00, 02, ...]
|
|
||||||
|
|
||||||
0x6e=110, 0x43=67, 0x52=82 → These are NIBP values!
|
|
||||||
|
|
||||||
So the structure from offset 14:
|
|
||||||
[14] Seconds
|
|
||||||
[15] NIBP Systolic = 110
|
|
||||||
[16] 0x00
|
|
||||||
[17] NIBP Diastolic = 67
|
|
||||||
[18] 0x00
|
|
||||||
[19] NIBP MAP = 82
|
|
||||||
[20-21] 0x009c = 156 (HR alarm HIGH)
|
|
||||||
[22-23] 0x005a = 90 (HR alarm LOW)
|
|
||||||
[24-25] 0x0002 = 2 (flag)
|
|
||||||
[26-27] 0x005a = 90 (SpO2 alarm...)
|
|
||||||
[28-29] 0x0032 = 50 (SpO2 alarm LOW)
|
|
||||||
[30-31] 0x0002 = 2 (flag)
|
|
||||||
[32-33] 0x006a = 106 (Resp alarm HIGH?)
|
|
||||||
[34-35] 0x003c = 60 (Resp alarm LOW?)
|
|
||||||
[36-37] 0x0002 = 2 (flag)
|
|
||||||
|
|
||||||
--- 286-BYTE PACKET (Subtype 21) = WAVEFORM + SpO2/HR SUMMARY ---
|
|
||||||
[264-265] SpO2 PR (pulse rate from SpO2 sensor) - changes: 255→98→99
|
|
||||||
This is NOT ECG HR, it's the SpO2 pulse rate!
|
|
||||||
[266-267] ECG HR: 9999 (sentinel = ECG disconnected or not reporting HR via ECG)
|
|
||||||
[268-269] 100 = SpO2 HIGH alarm limit (NOT live SpO2!)
|
|
||||||
[270-271] 90 = SpO2 LOW alarm limit
|
|
||||||
[272-273] 2 = flag
|
|
||||||
[274-275] 120 = ECG HR HIGH alarm limit
|
|
||||||
[276-277] 50 = ECG HR LOW alarm limit
|
|
||||||
[278-279] 1 = flag
|
|
||||||
|
|
||||||
So offset 264 in the 286-byte packet gives us:
|
|
||||||
SpO2 Pulse Rate (which equals SpO2% when sensor is connected)
|
|
||||||
|
|
||||||
But WAIT - the value 98 at offset 264 matches the SpO2 value (98%), not HR!
|
|
||||||
And ECG HR (88) is NOT in this packet at all.
|
|
||||||
|
|
||||||
Let me check: at index 74, offset 264 = 98, which matches SpO2 = 98%.
|
|
||||||
At index 186, offset 264 = 99, but the SpO2 was still 98 on monitor...
|
|
||||||
|
|
||||||
Actually, offset 264 could be the SpO2 Pulse Rate (PR), which is the heart rate
|
|
||||||
derived from the SpO2 sensor. When ECG is also connected, the ECG HR is shown
|
|
||||||
separately. But the PR from SpO2 sensor = 98 is close to the SpO2% = 98.
|
|
||||||
|
|
||||||
Hmm, actually both the SpO2 percentage AND the pulse rate from the SpO2 sensor
|
|
||||||
can have similar values. We need to figure out which is which.
|
|
||||||
|
|
||||||
--- 56-BYTE PACKET (Subtype 23) = FLOAT32 NIBP/TEMP ---
|
|
||||||
[8-11] f32=9999.0 → NIBP Systolic (sentinel = no measurement)
|
|
||||||
[12-15] f32=9999.0 → NIBP Diastolic (sentinel)
|
|
||||||
[16-19] f32=9999.0 → NIBP MAP (sentinel)
|
|
||||||
[20-23] f32=39.0 → TEMP alarm HIGH limit (NOT actual temp!)
|
|
||||||
[24-27] f32=36.0 → TEMP alarm LOW limit (NOT actual temp!)
|
|
||||||
|
|
||||||
--- 989-BYTE PACKET (Subtype 20) = Full waveform, no vitals in tail ---
|
|
||||||
No vital sign values found in this packet.
|
|
||||||
|
|
||||||
=== CONCLUSIONS ===
|
|
||||||
1. The 286-byte packet at offset 264 contains SpO2 PR (pulse rate), NOT ECG HR
|
|
||||||
2. ECG HR (88 bpm) is NOT transmitted in any of these packets!
|
|
||||||
The ECG HR is only available on the monitor's own display.
|
|
||||||
3. NIBP values (110/67/82) are in the 45-byte packet at bytes 15/17/19
|
|
||||||
4. Temperature 39.0 in the 56-byte packet is the ALARM HIGH limit, not actual temp
|
|
||||||
5. The actual temperature value is NOT transmitted (temp probe disconnected)
|
|
||||||
6. SpO2 percentage is NOT directly transmitted - only the SpO2 PR is at offset 264
|
|
||||||
(but these are often the same value when the sensor is working)
|
|
||||||
"""
|
|
||||||
print("Analysis complete - see code comments for findings")
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
warnings = re.findall(r"unrecognized. Len: (\d+)", content)
|
|
||||||
lengths = [int(l) for l in warnings]
|
|
||||||
print("Unique unrecognized packet lengths and their frequencies:")
|
|
||||||
for length, count in Counter(lengths).most_common():
|
|
||||||
print(f" Length {length}: {count} times")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import re
|
|
||||||
import struct
|
|
||||||
|
|
||||||
# Helper to read uint16 from bytes
|
|
||||||
def read_u16_le(data: bytes, offset: int) -> int:
|
|
||||||
if offset + 2 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<H', data, offset)[0]
|
|
||||||
|
|
||||||
# Read the log file
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Find all occurrences of Hex head logging
|
|
||||||
hex_heads = re.findall(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", content)
|
|
||||||
warnings = re.findall(r"unrecognized. Len: (\d+), Hex head: ([0-9a-fA-F]+)", content)
|
|
||||||
|
|
||||||
print(f"Found {len(hex_heads)} hex heads from server logging")
|
|
||||||
print(f"Found {len(warnings)} unrecognized packet hex heads")
|
|
||||||
|
|
||||||
# Also find all data packets printed or parsed in logs
|
|
||||||
# Let's inspect some of the unrecognized packets of size 56 and 45
|
|
||||||
print("\nSample of Len 56 packets:")
|
|
||||||
count = 0
|
|
||||||
for len_str, hex_str in warnings:
|
|
||||||
if len_str == "56":
|
|
||||||
print(f" {hex_str}")
|
|
||||||
count += 1
|
|
||||||
if count >= 5:
|
|
||||||
break
|
|
||||||
|
|
||||||
print("\nSample of Len 45 packets:")
|
|
||||||
count = 0
|
|
||||||
for len_str, hex_str in warnings:
|
|
||||||
if len_str == "45":
|
|
||||||
print(f" {hex_str}")
|
|
||||||
count += 1
|
|
||||||
if count >= 5:
|
|
||||||
break
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import psutil
|
|
||||||
import time
|
|
||||||
|
|
||||||
print("Monitoring TCP connections for traffic from 202.114.4.114 for 15 seconds...")
|
|
||||||
monitor_ip = "202.114.4.114"
|
|
||||||
start = time.time()
|
|
||||||
found_any = False
|
|
||||||
|
|
||||||
while time.time() - start < 15:
|
|
||||||
connections = psutil.net_connections(kind='tcp')
|
|
||||||
for conn in connections:
|
|
||||||
# Check if remote address matches the monitor IP
|
|
||||||
if conn.raddr and conn.raddr.ip == monitor_ip:
|
|
||||||
print(f"Connection detected: Local={conn.laddr.ip}:{conn.laddr.port}, Remote={conn.raddr.ip}:{conn.raddr.port}, Status={conn.status}")
|
|
||||||
found_any = True
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if not found_any:
|
|
||||||
print("No TCP connections from 202.114.4.114 were detected during the 15-second window.")
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
|
|
||||||
def main():
|
|
||||||
conn = sqlite3.connect("hl7_forwarder.db")
|
|
||||||
cursor = conn.cursor()
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT id, device_id, ip_address, status, last_seen FROM devices")
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
print("=== Registered Devices ===")
|
|
||||||
for row in rows:
|
|
||||||
print(f"ID: {row[0]} | Device ID: {row[1]} | IP: {row[2]} | Status: {row[3]} | Last Seen: {row[4]}")
|
|
||||||
except Exception as e:
|
|
||||||
print("Error:", e)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
def read_u16_le(data: bytes, offset: int) -> int:
|
|
||||||
if offset + 2 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<H', data, offset)[0]
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
# Extract 45-byte packets (subtype 22)
|
|
||||||
packets = []
|
|
||||||
consumed = 0
|
|
||||||
while consumed < len(stream) - 4:
|
|
||||||
b0 = stream[consumed]
|
|
||||||
b1 = stream[consumed + 1]
|
|
||||||
b2 = stream[consumed + 2]
|
|
||||||
b3 = stream[consumed + 3]
|
|
||||||
|
|
||||||
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 45 and consumed + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[consumed:consumed + pkt_len])
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
if pkt_len < 5 or pkt_len > 8192:
|
|
||||||
consumed += 1
|
|
||||||
else:
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
print(f"Found {len(packets)} 45-byte packets:")
|
|
||||||
seen_timestamps = set()
|
|
||||||
for p in packets:
|
|
||||||
# Decode timestamp
|
|
||||||
year = read_u16_le(p, 8)
|
|
||||||
month = p[10]
|
|
||||||
day = p[11]
|
|
||||||
hour = p[12]
|
|
||||||
minute = p[13]
|
|
||||||
second = p[14]
|
|
||||||
ts_str = f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
|
||||||
|
|
||||||
if ts_str in seen_timestamps:
|
|
||||||
continue
|
|
||||||
seen_timestamps.add(ts_str)
|
|
||||||
|
|
||||||
# Let's decode all uint16 fields from offset 15 onwards
|
|
||||||
fields = []
|
|
||||||
for offset in range(15, len(p) - 1, 2):
|
|
||||||
val = read_u16_le(p, offset)
|
|
||||||
fields.append(f"off{offset}:{val}")
|
|
||||||
|
|
||||||
print(f"Timestamp: {ts_str} | Payload: {p[15:].hex()}")
|
|
||||||
print(" Decoded uint16s: " + ", ".join(fields))
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
def read_f32_le(data: bytes, offset: int) -> float:
|
|
||||||
if offset + 4 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<f', data, offset)[0]
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
# Extract 56-byte packets (subtype 23)
|
|
||||||
packets = []
|
|
||||||
consumed = 0
|
|
||||||
while consumed < len(stream) - 4:
|
|
||||||
b0 = stream[consumed]
|
|
||||||
b1 = stream[consumed + 1]
|
|
||||||
b2 = stream[consumed + 2]
|
|
||||||
b3 = stream[consumed + 3]
|
|
||||||
|
|
||||||
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 56 and consumed + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[consumed:consumed + pkt_len])
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
if pkt_len < 5 or pkt_len > 8192:
|
|
||||||
consumed += 1
|
|
||||||
else:
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
print(f"Found {len(packets)} 56-byte packets:")
|
|
||||||
# Print the float32 values of the first packet at every possible offset
|
|
||||||
if packets:
|
|
||||||
p = packets[0]
|
|
||||||
print(f"Sample 56-byte packet: {p.hex()}")
|
|
||||||
for offset in range(8, len(p) - 3, 4):
|
|
||||||
val = read_f32_le(p, offset)
|
|
||||||
print(f" Offset {offset:02d}: {val}")
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
def read_u16_le(data: bytes, offset: int) -> int:
|
|
||||||
if offset + 2 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<H', data, offset)[0]
|
|
||||||
|
|
||||||
def read_f32_le(data: bytes, offset: int) -> float:
|
|
||||||
if offset + 4 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<f', data, offset)[0]
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
print(f"Total stream size: {len(stream)} bytes")
|
|
||||||
|
|
||||||
# Extract packets
|
|
||||||
packets = []
|
|
||||||
consumed = 0
|
|
||||||
while consumed < len(stream) - 4:
|
|
||||||
b0 = stream[consumed]
|
|
||||||
b1 = stream[consumed + 1]
|
|
||||||
b2 = stream[consumed + 2]
|
|
||||||
b3 = stream[consumed + 3]
|
|
||||||
|
|
||||||
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len < 5 or pkt_len > 8192:
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if consumed + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[consumed:consumed + pkt_len])
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
print(f"Successfully extracted {len(packets)} packets")
|
|
||||||
|
|
||||||
# Analyze packet types and sizes
|
|
||||||
by_size = {}
|
|
||||||
for p in packets:
|
|
||||||
l = len(p)
|
|
||||||
subtype = p[6] if l > 6 else -1
|
|
||||||
key = (l, subtype)
|
|
||||||
if key not in by_size:
|
|
||||||
by_size[key] = []
|
|
||||||
by_size[key].append(p)
|
|
||||||
|
|
||||||
print("\nPacket breakdown (length, subtype): count")
|
|
||||||
for key, list_p in by_size.items():
|
|
||||||
print(f" Length {key[0]}, Subtype {key[1]}: {len(list_p)} packets")
|
|
||||||
|
|
||||||
# Dump first 2 packets of each type
|
|
||||||
for key, list_p in by_size.items():
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Sample packets for Length {key[0]}, Subtype {key[1]}:")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
for idx, p in enumerate(list_p[:2]):
|
|
||||||
print(f"Packet #{idx+1} hex:")
|
|
||||||
print(" " + p.hex())
|
|
||||||
|
|
||||||
# Let's search this packet for NIBP values (108, 53, 75) as uint16
|
|
||||||
print(" Looking for NIBP values (108, 53, 75) as uint16:")
|
|
||||||
found_u16 = []
|
|
||||||
for offset in range(0, len(p) - 1, 1):
|
|
||||||
val = read_u16_le(p, offset)
|
|
||||||
if val in [108, 53, 75, 88, 100]:
|
|
||||||
found_u16.append(f"offset {offset}: {val}")
|
|
||||||
if found_u16:
|
|
||||||
print(" uint16 matches: " + ", ".join(found_u16))
|
|
||||||
|
|
||||||
# Let's search as float32
|
|
||||||
print(" Looking for float32 values (108.0, 53.0, 75.0, 88.0, 100.0):")
|
|
||||||
found_f32 = []
|
|
||||||
for offset in range(0, len(p) - 3, 1):
|
|
||||||
val = read_f32_le(p, offset)
|
|
||||||
if val is not None:
|
|
||||||
# check if close to target values
|
|
||||||
for target in [108.0, 53.0, 75.0, 88.0, 100.0]:
|
|
||||||
if abs(val - target) < 0.1:
|
|
||||||
found_f32.append(f"offset {offset}: {val:.1f}")
|
|
||||||
if found_f32:
|
|
||||||
print(" float32 matches: " + ", ".join(found_f32))
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""
|
|
||||||
Search the entire 286-byte and 989-byte packets for the ECG HR value (88).
|
|
||||||
The ECG HR must be somewhere in the packet data.
|
|
||||||
"""
|
|
||||||
import struct
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
# Find all 286-byte packets with SpO2=98 at offset 264
|
|
||||||
# (indicating a time when the monitor was showing ECG=88, SpO2=98)
|
|
||||||
packets = []
|
|
||||||
i = 0
|
|
||||||
while i < len(stream) - 8:
|
|
||||||
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
|
|
||||||
pkt_len = struct.unpack_from('<H', stream, i)[0]
|
|
||||||
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
|
||||||
packets.append((pkt_len, stream[i:i + pkt_len]))
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Search for byte value 88 (0x58) in 286-byte packets
|
|
||||||
print("=== Searching ALL offsets in 286-byte packets for u16=88 ===")
|
|
||||||
for idx, (l, pkt) in enumerate(packets):
|
|
||||||
if l == 286:
|
|
||||||
for off in range(4, l-1, 2):
|
|
||||||
v = struct.unpack_from('<H', pkt, off)[0]
|
|
||||||
if v == 88:
|
|
||||||
print(f" Packet {idx}, offset {off}: u16={v}")
|
|
||||||
# Also search single bytes
|
|
||||||
for off in range(4, l):
|
|
||||||
if pkt[off] == 88:
|
|
||||||
print(f" Packet {idx}, byte[{off}] = 88 (0x58)")
|
|
||||||
break # Just check first one
|
|
||||||
|
|
||||||
# Search 989-byte packets
|
|
||||||
print("\n=== Searching 989-byte packets for byte=88 in offsets 0-30 and 950-989 ===")
|
|
||||||
for idx, (l, pkt) in enumerate(packets):
|
|
||||||
if l == 989:
|
|
||||||
for off in range(0, 30):
|
|
||||||
if pkt[off] == 88:
|
|
||||||
print(f" Packet {idx}, byte[{off}] = 88")
|
|
||||||
for off in range(950, l):
|
|
||||||
if pkt[off] == 88:
|
|
||||||
print(f" Packet {idx}, byte[{off}] = 88")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Search 45-byte packets
|
|
||||||
print("\n=== Searching 45-byte packets for byte=88 ===")
|
|
||||||
for idx, (l, pkt) in enumerate(packets):
|
|
||||||
if l == 45:
|
|
||||||
for off in range(8, l):
|
|
||||||
if pkt[off] == 88:
|
|
||||||
print(f" Packet {idx}, byte[{off}] = 88")
|
|
||||||
break
|
|
||||||
|
|
||||||
# The ECG HR may also be encoded differently. Let's check what's at byte
|
|
||||||
# positions right before the alarm limits in the 45-byte packet
|
|
||||||
print("\n=== 45-byte packet: All non-zero bytes with their positions ===")
|
|
||||||
for idx, (l, pkt) in enumerate(packets):
|
|
||||||
if l == 45:
|
|
||||||
for off in range(8, l):
|
|
||||||
if pkt[off] != 0:
|
|
||||||
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check the 45-byte packet at index 147 specifically (when NIBP was 110/67/82)
|
|
||||||
print("\n=== 45-byte packet at the time of NIBP 110/67/82 ===")
|
|
||||||
count = 0
|
|
||||||
for idx, (l, pkt) in enumerate(packets):
|
|
||||||
if l == 45:
|
|
||||||
count += 1
|
|
||||||
if count > 36: # Skip to around packet index 147
|
|
||||||
for off in range(8, l):
|
|
||||||
if pkt[off] != 0:
|
|
||||||
print(f" byte[{off}] = {pkt[off]} (0x{pkt[off]:02x})")
|
|
||||||
break
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
import sys
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
packets_288 = []
|
|
||||||
packets_989 = []
|
|
||||||
i = 0
|
|
||||||
while i < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 288 and i + pkt_len <= len(stream):
|
|
||||||
packets_288.append(stream[i:i + pkt_len])
|
|
||||||
elif pkt_len == 989 and i + pkt_len <= len(stream):
|
|
||||||
packets_989.append(stream[i:i + pkt_len])
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
print(f"Loaded {len(packets_288)} 288-byte packets and {len(packets_989)} 989-byte packets.")
|
|
||||||
|
|
||||||
# Search all offsets in 288-byte packets for any u16 LE value between 70 and 95
|
|
||||||
print("\nScanning 288-byte packets for u16 LE values in range [75, 95] across all packets:")
|
|
||||||
found_offsets_288 = {}
|
|
||||||
for idx, pkt in enumerate(packets_288):
|
|
||||||
for off in range(4, len(pkt) - 1, 2):
|
|
||||||
val = struct.unpack_from('<H', pkt, off)[0]
|
|
||||||
if 75 <= val <= 95:
|
|
||||||
found_offsets_288.setdefault(off, []).append((idx, val))
|
|
||||||
|
|
||||||
for off, matches in found_offsets_288.items():
|
|
||||||
if len(matches) > 5:
|
|
||||||
# print first few matches and total count
|
|
||||||
vals = [m[1] for m in matches]
|
|
||||||
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
|
|
||||||
|
|
||||||
# Search all offsets in 989-byte packets for any u16 LE value between 70 and 95
|
|
||||||
print("\nScanning 989-byte packets for u16 LE values in range [75, 95] across all packets:")
|
|
||||||
found_offsets_989 = {}
|
|
||||||
for idx, pkt in enumerate(packets_989):
|
|
||||||
# ECG packets are mostly waveform data in the first 900 bytes, so let's check from 900 to 988
|
|
||||||
for off in range(900, len(pkt) - 1, 2):
|
|
||||||
val = struct.unpack_from('<H', pkt, off)[0]
|
|
||||||
if 75 <= val <= 95:
|
|
||||||
found_offsets_989.setdefault(off, []).append((idx, val))
|
|
||||||
|
|
||||||
for off, matches in found_offsets_989.items():
|
|
||||||
if len(matches) > 5:
|
|
||||||
vals = [m[1] for m in matches]
|
|
||||||
print(f" Offset {off}: found {len(matches)} times. Values: {set(vals)}")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
f.seek(0, 2)
|
|
||||||
size = f.tell()
|
|
||||||
# Read the last 2000 bytes
|
|
||||||
f.seek(max(0, size - 2000))
|
|
||||||
data = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading file: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Total file size: {size} bytes")
|
|
||||||
print(f"Read last {len(data)} bytes of stream.")
|
|
||||||
|
|
||||||
# Let's search for potential packet headers in the last 2000 bytes.
|
|
||||||
# Typically headers are like: [len_lo] [len_hi] [04/01] [46]
|
|
||||||
# Let's print out the hex around any occurrences of 0x46 or 0x47.
|
|
||||||
print("\nScanning for potential 0x46 or 0x47 headers...")
|
|
||||||
for i in range(len(data) - 4):
|
|
||||||
b0, b1, b2, b3 = data[i], data[i+1], data[i+2], data[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
print(f"Index {i} (absolute {size - 2000 + i}): Header match! Bytes: {b0:02x} {b1:02x} {b2:02x} {b3:02x} -> Length: {pkt_len}, Subtype: {b2}")
|
|
||||||
# print hex representation of next 16 bytes
|
|
||||||
next_bytes = data[i:i+20]
|
|
||||||
print(f" Hex: {next_bytes.hex()}")
|
|
||||||
elif b3 == 0x47:
|
|
||||||
print(f"Index {i} (absolute {size - 2000 + i}): End-marker? Bytes: {b0:02x} {b1:02x} {b2:02x} {b3:02x}")
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import socket
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
|
||||||
PORT = 12345
|
|
||||||
|
|
||||||
VT = b'\x0b'
|
|
||||||
FS_CR = b'\x1c\x0d'
|
|
||||||
|
|
||||||
def generate_hl7_message(patient_id="PAT-12345", device_id="MOCK-CMS7000"):
|
|
||||||
# Generate realistic vital signs with small random variations
|
|
||||||
hr = random.randint(70, 95)
|
|
||||||
spo2 = random.randint(96, 99)
|
|
||||||
sys = random.randint(115, 125)
|
|
||||||
dia = random.randint(75, 83)
|
|
||||||
map_bp = int(dia + (sys - dia) / 3)
|
|
||||||
resp = random.randint(14, 18)
|
|
||||||
temp = round(random.uniform(36.5, 37.1), 1)
|
|
||||||
|
|
||||||
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
||||||
|
|
||||||
# Construct HL7 message segments
|
|
||||||
msh = f"MSH|^~\\&|{device_id}|MOCK_FACILITY|RECEIVING_APP|RECEIVING_FACILITY|{now_str}||ORU^R01|MSG{now_str}|P|2.3.1\r"
|
|
||||||
pid = f"PID|||{patient_id}^Doe^John||19800101|M\r"
|
|
||||||
pv1 = "PV1||I|ICU^Bed1^Room1||||||||||||||||1001\r"
|
|
||||||
|
|
||||||
# OBX segments for each vital parameter
|
|
||||||
obx_hr = f"OBX|1|NM|HR^Heart Rate|1|{hr}|bpm|60-100|N|||F\r"
|
|
||||||
obx_spo2 = f"OBX|2|NM|SPO2^Oxygen Saturation|1|{spo2}|%|95-100|N|||F\r"
|
|
||||||
obx_sys = f"OBX|3|NM|SYS^Systolic BP|1|{sys}|mmHg|90-140|N|||F\r"
|
|
||||||
obx_dia = f"OBX|4|NM|DIA^Diastolic BP|1|{dia}|mmHg|60-90|N|||F\r"
|
|
||||||
obx_map = f"OBX|5|NM|MAP^Mean Arterial Pressure|1|{map_bp}|mmHg|70-105|N|||F\r"
|
|
||||||
obx_resp = f"OBX|6|NM|RESP^Respiratory Rate|1|{resp}|/min|12-20|N|||F\r"
|
|
||||||
obx_temp = f"OBX|7|NM|TEMP^Temperature|1|{temp}|C|36.1-37.2|N|||F"
|
|
||||||
|
|
||||||
hl7_msg = msh + pid + pv1 + obx_hr + obx_spo2 + obx_sys + obx_dia + obx_map + obx_resp + obx_temp
|
|
||||||
return hl7_msg
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print(f"Starting mock patient monitor simulator...")
|
|
||||||
print(f"Connecting to Contec receiver at {HOST}:{PORT}...")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.connect((HOST, PORT))
|
|
||||||
print(f"Connected to Contec server at {HOST}:{PORT}")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
hl7_msg = generate_hl7_message()
|
|
||||||
# Frame message with MLLP wrappers (VT ... FS_CR)
|
|
||||||
framed_msg = VT + hl7_msg.encode('utf-8') + FS_CR
|
|
||||||
|
|
||||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Sending mock HL7 vitals:")
|
|
||||||
# Print individual observations for clear console trace
|
|
||||||
for line in hl7_msg.split('\r'):
|
|
||||||
if line.startswith("OBX"):
|
|
||||||
print(f" {line}")
|
|
||||||
|
|
||||||
s.sendall(framed_msg)
|
|
||||||
|
|
||||||
# Check for ACK response
|
|
||||||
try:
|
|
||||||
s.settimeout(1.0)
|
|
||||||
response = s.recv(4096)
|
|
||||||
if response:
|
|
||||||
print(" [ACK Received]")
|
|
||||||
except socket.timeout:
|
|
||||||
pass
|
|
||||||
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
except (ConnectionRefusedError, ConnectionResetError) as e:
|
|
||||||
print(f"Connection error: {e}. Retrying in 3 seconds...")
|
|
||||||
time.sleep(3)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Unexpected error: {e}. Retrying in 3 seconds...")
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import socket
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
from datetime import datetime
|
|
||||||
import threading
|
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
|
||||||
PORT = 12345
|
|
||||||
|
|
||||||
VT = b'\x0b'
|
|
||||||
FS_CR = b'\x1c\x0d'
|
|
||||||
|
|
||||||
def generate_hl7_message(patient_id, device_id):
|
|
||||||
hr = random.randint(70, 95)
|
|
||||||
spo2 = random.randint(96, 99)
|
|
||||||
sys = random.randint(115, 125)
|
|
||||||
dia = random.randint(75, 83)
|
|
||||||
map_bp = int(dia + (sys - dia) / 3)
|
|
||||||
resp = random.randint(14, 18)
|
|
||||||
temp = round(random.uniform(36.5, 37.1), 1)
|
|
||||||
|
|
||||||
now_str = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
||||||
|
|
||||||
msh = f"MSH|^~\\&|{device_id}|MOCK_FACILITY|RECEIVING_APP|RECEIVING_FACILITY|{now_str}||ORU^R01|MSG{now_str}|P|2.3.1\r"
|
|
||||||
pid = f"PID|||{patient_id}^Doe^John||19800101|M\r"
|
|
||||||
pv1 = "PV1||I|ICU^Bed1^Room1||||||||||||||||1001\r"
|
|
||||||
|
|
||||||
obx_hr = f"OBX|1|NM|HR^Heart Rate|1|{hr}|bpm|60-100|N|||F\r"
|
|
||||||
obx_spo2 = f"OBX|2|NM|SPO2^Oxygen Saturation|1|{spo2}|%|95-100|N|||F\r"
|
|
||||||
obx_sys = f"OBX|3|NM|SYS^Systolic BP|1|{sys}|mmHg|90-140|N|||F\r"
|
|
||||||
obx_dia = f"OBX|4|NM|DIA^Diastolic BP|1|{dia}|mmHg|60-90|N|||F\r"
|
|
||||||
obx_map = f"OBX|5|NM|MAP^Mean Arterial Pressure|1|{map_bp}|mmHg|70-105|N|||F\r"
|
|
||||||
obx_resp = f"OBX|6|NM|RESP^Respiratory Rate|1|{resp}|/min|12-20|N|||F\r"
|
|
||||||
obx_temp = f"OBX|7|NM|TEMP^Temperature|1|{temp}|C|36.1-37.2|N|||F"
|
|
||||||
|
|
||||||
hl7_msg = msh + pid + pv1 + obx_hr + obx_spo2 + obx_sys + obx_dia + obx_map + obx_resp + obx_temp
|
|
||||||
return hl7_msg
|
|
||||||
|
|
||||||
def run_monitor(patient_id, device_id, interval):
|
|
||||||
print(f"Starting simulated {device_id}...")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.connect((HOST, PORT))
|
|
||||||
print(f"[{device_id}] Connected to server")
|
|
||||||
while True:
|
|
||||||
hl7_msg = generate_hl7_message(patient_id, device_id)
|
|
||||||
framed_msg = VT + hl7_msg.encode('utf-8') + FS_CR
|
|
||||||
s.sendall(framed_msg)
|
|
||||||
try:
|
|
||||||
s.settimeout(1.0)
|
|
||||||
response = s.recv(4096)
|
|
||||||
except socket.timeout:
|
|
||||||
pass
|
|
||||||
time.sleep(interval)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{device_id}] Error: {e}. Reconnecting in 3 seconds...")
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
t1 = threading.Thread(target=run_monitor, args=("PAT-1001", "CMS7000PLUS_127.0.0.1", 3), daemon=True)
|
|
||||||
t2 = threading.Thread(target=run_monitor, args=("PAT-8500", "CMS8500_127.0.0.1", 4), daemon=True)
|
|
||||||
t1.start()
|
|
||||||
t2.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("Stopping simulators.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Binary file not shown.
@@ -1,28 +0,0 @@
|
|||||||
import re
|
|
||||||
import struct
|
|
||||||
|
|
||||||
def read_u16_le(data: bytes, offset: int) -> int:
|
|
||||||
if offset + 2 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<H', data, offset)[0]
|
|
||||||
|
|
||||||
# Let's read the log line by line and reconstruct packets
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/hl7_forwarder.log", "r") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
# We want to find raw reads and buffer state
|
|
||||||
# The server logs:
|
|
||||||
# "Received {len} bytes from ... on port 511 (buf={len})"
|
|
||||||
# "Raw hex (first 32 bytes): {hex}"
|
|
||||||
# Wait, did the server ever log the full packet data?
|
|
||||||
# Let's look at the log lines.
|
|
||||||
print("Searching for raw hex data logs...")
|
|
||||||
hex_patterns = []
|
|
||||||
for line in lines[:5000]:
|
|
||||||
m = re.search(r"Raw hex \(first 32 bytes\): ([0-9a-fA-F]+)", line)
|
|
||||||
if m:
|
|
||||||
hex_patterns.append(m.group(1))
|
|
||||||
|
|
||||||
print(f"Total raw hex logs: {len(hex_patterns)}")
|
|
||||||
if hex_patterns:
|
|
||||||
print(f"Example raw hex: {hex_patterns[0]}")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import socket
|
|
||||||
import sys
|
|
||||||
|
|
||||||
TARGET_IP = "202.114.4.114"
|
|
||||||
COMMON_PORTS = [
|
|
||||||
511, 515, 516, 517, 518, 519, 520, 1000, 2000, 3000, 4000, 5000,
|
|
||||||
6060, 7000, 8000, 8001, 8002, 8080, 8300, 9000, 9100, 9200, 9500, 10008
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"Scanning target monitor {TARGET_IP} for open TCP ports...")
|
|
||||||
|
|
||||||
open_ports = []
|
|
||||||
|
|
||||||
for port in COMMON_PORTS:
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(0.5)
|
|
||||||
result = s.connect_ex((TARGET_IP, port))
|
|
||||||
if result == 0:
|
|
||||||
print(f" [OPEN] Port {port} is open!")
|
|
||||||
open_ports.append(port)
|
|
||||||
s.close()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Also do a quick scan of port range 500-600 (very common for Contec)
|
|
||||||
print("Scanning port range 500-600...")
|
|
||||||
for port in range(500, 601):
|
|
||||||
if port in COMMON_PORTS:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(0.1)
|
|
||||||
result = s.connect_ex((TARGET_IP, port))
|
|
||||||
if result == 0:
|
|
||||||
print(f" [OPEN] Port {port} is open!")
|
|
||||||
open_ports.append(port)
|
|
||||||
s.close()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print("\nScan complete.")
|
|
||||||
if open_ports:
|
|
||||||
print(f"Found open TCP ports on monitor: {open_ports}")
|
|
||||||
else:
|
|
||||||
print("No open TCP ports found on the monitor. It is not acting as a TCP server on these ports.")
|
|
||||||
|
|
||||||
with open("C:\\Users\\Dell\\.gemini\\antigravity-ide\\brain\\17f86d9d-29a1-4f54-b185-045db1a407b4\\scratch\\monitor_scan_results.txt", "w") as f:
|
|
||||||
f.write(f"Monitor scan results for {TARGET_IP}:\n")
|
|
||||||
f.write(f"Open ports: {open_ports}\n")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
def search_scaled_values(p: bytes, val_sys: int, val_dia: int, val_map: int):
|
|
||||||
# We will search for scaled values: val, val * 10, val * 100
|
|
||||||
targets = [
|
|
||||||
(val_sys, "Sys"), (val_sys * 10, "Sys*10"), (val_sys * 100, "Sys*100"),
|
|
||||||
(val_dia, "Dia"), (val_dia * 10, "Dia*10"), (val_dia * 100, "Dia*100"),
|
|
||||||
(val_map, "Map"), (val_map * 10, "Map*10"), (val_map * 100, "Map*100")
|
|
||||||
]
|
|
||||||
|
|
||||||
for offset in range(len(p) - 1):
|
|
||||||
v = struct.unpack_from('<H', p, offset)[0]
|
|
||||||
for target_val, name in targets:
|
|
||||||
if v == target_val:
|
|
||||||
print(f" Found {name} ({target_val}) at offset {offset}")
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
consumed = 0
|
|
||||||
packets = []
|
|
||||||
while consumed < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[consumed:consumed+4]
|
|
||||||
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
packets.append(stream[consumed:consumed+pkt_len])
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
print(f"Scanning {len(packets)} packets for scaled BP (108, 53, 75)...")
|
|
||||||
for idx, p in enumerate(packets):
|
|
||||||
l = len(p)
|
|
||||||
subtype = p[6] if l > 6 else -1
|
|
||||||
|
|
||||||
# We want to print only if something is found
|
|
||||||
# Redirect output internally
|
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
old_stdout = sys.stdout
|
|
||||||
new_stdout = io.StringIO()
|
|
||||||
sys.stdout = new_stdout
|
|
||||||
|
|
||||||
search_scaled_values(p, 108, 53, 75)
|
|
||||||
|
|
||||||
output = new_stdout.getvalue()
|
|
||||||
sys.stdout = old_stdout
|
|
||||||
|
|
||||||
if output.strip():
|
|
||||||
print(f"Packet #{idx} (Len={l}, Subtype={subtype}):")
|
|
||||||
print(output, end="")
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
def search_value_in_packet(p: bytes, val: int, label: str):
|
|
||||||
# Search as uint8
|
|
||||||
for offset in range(len(p)):
|
|
||||||
if p[offset] == val:
|
|
||||||
print(f" {label} found as uint8 at offset {offset}")
|
|
||||||
|
|
||||||
# Search as uint16 LE
|
|
||||||
for offset in range(len(p) - 1):
|
|
||||||
v = struct.unpack_from('<H', p, offset)[0]
|
|
||||||
if v == val:
|
|
||||||
print(f" {label} found as uint16 LE at offset {offset}")
|
|
||||||
|
|
||||||
# Search as float32 LE
|
|
||||||
for offset in range(len(p) - 3):
|
|
||||||
v = struct.unpack_from('<f', p, offset)[0]
|
|
||||||
if abs(v - float(val)) < 0.01:
|
|
||||||
print(f" {label} found as float32 LE at offset {offset}")
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
# Extract packets
|
|
||||||
packets = []
|
|
||||||
consumed = 0
|
|
||||||
while consumed < len(stream) - 4:
|
|
||||||
b0 = stream[consumed]
|
|
||||||
b1 = stream[consumed + 1]
|
|
||||||
b2 = stream[consumed + 2]
|
|
||||||
b3 = stream[consumed + 3]
|
|
||||||
|
|
||||||
if (b2 == 0x04 or b2 == 0x01) and b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len < 5 or pkt_len > 8192:
|
|
||||||
consumed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if consumed + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[consumed:consumed + pkt_len])
|
|
||||||
consumed += pkt_len
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
print(f"Loaded {len(packets)} packets. Scanning for HR=88, Systolic=108, MAP=75...")
|
|
||||||
|
|
||||||
for idx, p in enumerate(packets):
|
|
||||||
l = len(p)
|
|
||||||
subtype = p[6] if l > 6 else -1
|
|
||||||
|
|
||||||
# Let's decode timestamp if it is a 45-byte packet
|
|
||||||
ts_str = ""
|
|
||||||
if l == 45:
|
|
||||||
year = struct.unpack_from('<H', p, 8)[0]
|
|
||||||
month, day, hour, minute, second = p[10], p[11], p[12], p[13], p[14]
|
|
||||||
ts_str = f" @ {year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
|
||||||
|
|
||||||
print(f"\nPacket #{idx} (Len={l}, Subtype={subtype}){ts_str}:")
|
|
||||||
search_value_in_packet(p, 88, "HR (88)")
|
|
||||||
search_value_in_packet(p, 108, "Systolic (108)")
|
|
||||||
search_value_in_packet(p, 75, "MAP (75)")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
packets = []
|
|
||||||
i = 0
|
|
||||||
while i < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 288 and i + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[i:i + pkt_len])
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if packets:
|
|
||||||
pkt = packets[-1]
|
|
||||||
print("Last 40 bytes of 288-byte packet:")
|
|
||||||
for off in range(248, 288, 2):
|
|
||||||
val = pkt[off] | (pkt[off+1] << 8)
|
|
||||||
print(f" Offset {off}: hex={pkt[off]:02x} {pkt[off+1]:02x} -> u16 LE = {val}")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
packets = []
|
|
||||||
i = 0
|
|
||||||
while i < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 989 and i + pkt_len <= len(stream):
|
|
||||||
packets.append(stream[i:i + pkt_len])
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if packets:
|
|
||||||
pkt = packets[-1]
|
|
||||||
print("Last 100 bytes of 989-byte packet:")
|
|
||||||
for off in range(889, 989, 2):
|
|
||||||
val = pkt[off] | (pkt[off+1] << 8)
|
|
||||||
print(f" Offset {off}: hex={pkt[off]:02x} {pkt[off+1]:02x} -> u16 LE = {val}")
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import struct
|
|
||||||
|
|
||||||
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
import sys
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Find latest 288-byte and 989-byte packets
|
|
||||||
pkt_288 = None
|
|
||||||
pkt_989 = None
|
|
||||||
i = 0
|
|
||||||
while i < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if pkt_len == 288 and i + pkt_len <= len(stream):
|
|
||||||
pkt_288 = stream[i:i + pkt_len]
|
|
||||||
elif pkt_len == 989 and i + pkt_len <= len(stream):
|
|
||||||
pkt_989 = stream[i:i + pkt_len]
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if pkt_288:
|
|
||||||
print("=== Scanning 288-byte packet (tail, offsets 250-287) for values 75-90 ===")
|
|
||||||
for off in range(250, len(pkt_288) - 1, 2):
|
|
||||||
val = struct.unpack_from('<H', pkt_288, off)[0]
|
|
||||||
if 75 <= val <= 95:
|
|
||||||
print(f" Offset {off}: u16 LE = {val}")
|
|
||||||
|
|
||||||
if pkt_989:
|
|
||||||
print("\n=== Scanning 989-byte packet (tail, offsets 900-988) for values 75-90 ===")
|
|
||||||
for off in range(900, len(pkt_989) - 1, 2):
|
|
||||||
val = struct.unpack_from('<H', pkt_989, off)[0]
|
|
||||||
if 75 <= val <= 95:
|
|
||||||
print(f" Offset {off}: u16 LE = {val}")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import sys
|
|
||||||
import struct
|
|
||||||
sys.path.insert(0, "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main")
|
|
||||||
|
|
||||||
from contec_parser import parse_contec_binary_packet
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
pkts = []
|
|
||||||
while i < len(stream) - 8:
|
|
||||||
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
|
|
||||||
pkt_len = struct.unpack_from('<H', stream, i)[0]
|
|
||||||
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
|
||||||
pkts.append(stream[i:i+pkt_len])
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
print(f"Loaded {len(pkts)} packets.")
|
|
||||||
|
|
||||||
vitals_cache = {
|
|
||||||
"heart_rate": None,
|
|
||||||
"spo2": None,
|
|
||||||
"temperature": None,
|
|
||||||
"systolic_bp": None,
|
|
||||||
"diastolic_bp": None,
|
|
||||||
"map_bp": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
last_printed = {}
|
|
||||||
|
|
||||||
for idx, pkt in enumerate(pkts):
|
|
||||||
vitals = parse_contec_binary_packet(pkt, "192.168.100.120")
|
|
||||||
if vitals:
|
|
||||||
# Merge like contec_server.py
|
|
||||||
changed = False
|
|
||||||
for field in vitals.present_fields or []:
|
|
||||||
val = getattr(vitals, field)
|
|
||||||
if vitals_cache[field] != val:
|
|
||||||
vitals_cache[field] = val
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
current = {k: v for k, v in vitals_cache.items()}
|
|
||||||
if current != last_printed:
|
|
||||||
print(f"Pkt #{idx} (len={len(pkt)}) -> Cache: {current}")
|
|
||||||
last_printed = current
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""
|
|
||||||
Verify the new parser offsets on the raw captured stream.
|
|
||||||
New mappings:
|
|
||||||
- HR: 989-byte packet, offset 904 (u16 LE)
|
|
||||||
- SpO2: 286-byte packet, offset 264 (u16 LE)
|
|
||||||
- Temperature: 56-byte packet, offset 8 (float32)
|
|
||||||
- NIBP: 45-byte packet, offset 15, 17, 19 (u16 LE)
|
|
||||||
"""
|
|
||||||
import struct
|
|
||||||
|
|
||||||
def read_u16_le(data, offset):
|
|
||||||
if offset + 2 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<H', data, offset)[0]
|
|
||||||
|
|
||||||
def read_f32_le(data, offset):
|
|
||||||
if offset + 4 > len(data):
|
|
||||||
return None
|
|
||||||
return struct.unpack_from('<f', data, offset)[0]
|
|
||||||
|
|
||||||
with open("/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin", "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
|
|
||||||
# Parse packets
|
|
||||||
i = 0
|
|
||||||
pkts = []
|
|
||||||
while i < len(stream) - 8:
|
|
||||||
if (stream[i + 2] == 0x04 or stream[i + 2] == 0x01) and stream[i + 3] == 0x46:
|
|
||||||
pkt_len = struct.unpack_from('<H', stream, i)[0]
|
|
||||||
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
|
||||||
pkts.append((pkt_len, stream[i:i + pkt_len]))
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
vitals_cache = {
|
|
||||||
"heart_rate": None,
|
|
||||||
"spo2": None,
|
|
||||||
"temperature": None,
|
|
||||||
"systolic_bp": None,
|
|
||||||
"diastolic_bp": None,
|
|
||||||
"map_bp": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
last_printed = {}
|
|
||||||
|
|
||||||
for idx, (pkt_len, pkt) in enumerate(pkts):
|
|
||||||
changed = False
|
|
||||||
if pkt_len == 989:
|
|
||||||
hr = read_u16_le(pkt, 904)
|
|
||||||
# 65535 or 9999 means disconnected
|
|
||||||
if hr is not None and hr != 65535 and hr != 9999 and 20 <= hr <= 300:
|
|
||||||
if vitals_cache["heart_rate"] != float(hr):
|
|
||||||
vitals_cache["heart_rate"] = float(hr)
|
|
||||||
changed = True
|
|
||||||
elif hr == 65535 or hr == 9999:
|
|
||||||
if vitals_cache["heart_rate"] is not None:
|
|
||||||
vitals_cache["heart_rate"] = None
|
|
||||||
changed = True
|
|
||||||
elif pkt_len == 286:
|
|
||||||
spo2 = read_u16_le(pkt, 264)
|
|
||||||
if spo2 is not None and spo2 != 65535 and spo2 != 255 and 50 <= spo2 <= 100:
|
|
||||||
if vitals_cache["spo2"] != float(spo2):
|
|
||||||
vitals_cache["spo2"] = float(spo2)
|
|
||||||
changed = True
|
|
||||||
elif spo2 == 65535 or spo2 == 255:
|
|
||||||
if vitals_cache["spo2"] is not None:
|
|
||||||
vitals_cache["spo2"] = None
|
|
||||||
changed = True
|
|
||||||
elif pkt_len == 56:
|
|
||||||
temp = read_f32_le(pkt, 8)
|
|
||||||
if temp is not None and abs(temp - 9999.0) > 1.0 and 30.0 < temp < 45.0:
|
|
||||||
if vitals_cache["temperature"] != round(temp, 1):
|
|
||||||
vitals_cache["temperature"] = round(temp, 1)
|
|
||||||
changed = True
|
|
||||||
elif temp is not None and abs(temp - 9999.0) <= 1.0:
|
|
||||||
if vitals_cache["temperature"] is not None:
|
|
||||||
vitals_cache["temperature"] = None
|
|
||||||
changed = True
|
|
||||||
elif pkt_len == 45:
|
|
||||||
sys_bp = read_u16_le(pkt, 15)
|
|
||||||
dia_bp = read_u16_le(pkt, 17)
|
|
||||||
map_bp = read_u16_le(pkt, 19)
|
|
||||||
|
|
||||||
# 9999 means no NIBP measurement active
|
|
||||||
if sys_bp is not None and sys_bp != 9999 and sys_bp > 0:
|
|
||||||
vitals_cache["systolic_bp"] = float(sys_bp)
|
|
||||||
changed = True
|
|
||||||
elif sys_bp == 9999 or sys_bp == 0:
|
|
||||||
if vitals_cache["systolic_bp"] is not None:
|
|
||||||
vitals_cache["systolic_bp"] = None
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if dia_bp is not None and dia_bp != 9999 and dia_bp > 0:
|
|
||||||
vitals_cache["diastolic_bp"] = float(dia_bp)
|
|
||||||
changed = True
|
|
||||||
elif dia_bp == 9999 or dia_bp == 0:
|
|
||||||
if vitals_cache["diastolic_bp"] is not None:
|
|
||||||
vitals_cache["diastolic_bp"] = None
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if map_bp is not None and map_bp != 9999 and map_bp > 0:
|
|
||||||
vitals_cache["map_bp"] = float(map_bp)
|
|
||||||
changed = True
|
|
||||||
elif map_bp == 9999 or map_bp == 0:
|
|
||||||
if vitals_cache["map_bp"] is not None:
|
|
||||||
vitals_cache["map_bp"] = None
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
current_vitals = {k: v for k, v in vitals_cache.items()}
|
|
||||||
if current_vitals != last_printed:
|
|
||||||
print(f"Pkt #{idx} (len={pkt_len}) -> Vitals: {current_vitals}")
|
|
||||||
last_printed = current_vitals
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add parent directory to path
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from contec_parser import parse_contec_binary_packet
|
|
||||||
|
|
||||||
# Read captured stream
|
|
||||||
filepath = "/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main/scratch/raw_contec_stream.bin"
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
stream = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Loaded raw stream of {len(stream)} bytes")
|
|
||||||
|
|
||||||
# We will scan the stream and feed packets into the parser
|
|
||||||
# Since we know the packets start with 0x46 at index + 3, let's scan.
|
|
||||||
i = 0
|
|
||||||
packet_count = 0
|
|
||||||
parsed_count = 0
|
|
||||||
last_vitals = None
|
|
||||||
|
|
||||||
while i < len(stream) - 4:
|
|
||||||
b0, b1, b2, b3 = stream[i], stream[i+1], stream[i+2], stream[i+3]
|
|
||||||
if b3 == 0x46:
|
|
||||||
pkt_len = b0 | (b1 << 8)
|
|
||||||
if 30 < pkt_len < 1050 and i + pkt_len <= len(stream):
|
|
||||||
pkt_data = stream[i:i + pkt_len]
|
|
||||||
packet_count += 1
|
|
||||||
|
|
||||||
# Feed packet to parser
|
|
||||||
vitals = parse_contec_binary_packet(pkt_data, "192.168.100.139")
|
|
||||||
if vitals:
|
|
||||||
parsed_count += 1
|
|
||||||
# Check if fields changed
|
|
||||||
v_dict = {k: v for k, v in vitals.model_dump().items() if v is not None and k not in ['device_id', 'patient_id', 'timestamp', 'ip_address', 'present_fields']}
|
|
||||||
if v_dict and v_dict != last_vitals:
|
|
||||||
print(f"Index {i} (len={pkt_len}) -> Vitals: {v_dict}")
|
|
||||||
last_vitals = v_dict
|
|
||||||
i += max(pkt_len, 4)
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
print(f"\nDone. Scanned {packet_count} packets, successfully parsed {parsed_count} packets.")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
TARGET_SUBNETS = ["202.114.4.255", "255.255.255.255"]
|
|
||||||
PORTS = [511, 1000, 2000, 5000, 8000, 8300, 9000, 10008, 12345]
|
|
||||||
|
|
||||||
# Common Contec discovery payloads
|
|
||||||
PAYLOADS = [
|
|
||||||
b"CMS",
|
|
||||||
b"CMS_SEARCH",
|
|
||||||
b"CMS_REGISTER",
|
|
||||||
b"CMS_HEARTBEAT",
|
|
||||||
b"\x55\xaa\x01\x00\x01", # sync header + command
|
|
||||||
b"\xaa\x55\x01\x00\x01",
|
|
||||||
b"\x00\x00\x00\x00",
|
|
||||||
b"\x01"
|
|
||||||
]
|
|
||||||
|
|
||||||
print("Sending UDP discovery heartbeats to trigger the monitor...")
|
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
||||||
|
|
||||||
for subnet in TARGET_SUBNETS:
|
|
||||||
for port in PORTS:
|
|
||||||
for payload in PAYLOADS:
|
|
||||||
try:
|
|
||||||
# Send broadcast packet
|
|
||||||
s.sendto(payload, (subnet, port))
|
|
||||||
print(f"Sent {len(payload)} bytes to {subnet}:{port}")
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
s.close()
|
|
||||||
print("Discovery heartbeats sent successfully.")
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
PORT_LIST = [511, 515, 516, 517, 518, 519, 520, 1000, 2000, 5000, 6060, 8000, 8300, 9000, 9200, 10008, 12345]
|
|
||||||
TIMEOUT = 30.0 # seconds
|
|
||||||
|
|
||||||
print("Starting background UDP sniffer on ports:", PORT_LIST)
|
|
||||||
print("Listening for 30 seconds...")
|
|
||||||
|
|
||||||
sockets = []
|
|
||||||
for port in PORT_LIST:
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
s.bind(("0.0.0.0", port))
|
|
||||||
s.setblocking(False)
|
|
||||||
sockets.append((s, port))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Could not bind to UDP port {port}: {e}")
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
captured = []
|
|
||||||
|
|
||||||
while time.time() - start_time < TIMEOUT:
|
|
||||||
for s, port in sockets:
|
|
||||||
try:
|
|
||||||
data, addr = s.recvfrom(4096)
|
|
||||||
# Log from any sender that is not local loopback
|
|
||||||
if addr[0] != "127.0.0.1":
|
|
||||||
event = f"[UDP Port {port}] Received {len(data)} bytes from {addr[0]}:{addr[1]} - Hex: {data.hex()}"
|
|
||||||
print(event)
|
|
||||||
captured.append(event)
|
|
||||||
except BlockingIOError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
for s, port in sockets:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
print("Finished UDP sniff.")
|
|
||||||
with open("C:\\Users\\Dell\\.gemini\\antigravity-ide\\brain\\17f86d9d-29a1-4f54-b185-045db1a407b4\\scratch\\udp_sniff_now_results.txt", "w") as f:
|
|
||||||
f.write("UDP Sniffing results:\n")
|
|
||||||
if not captured:
|
|
||||||
f.write("No UDP packets captured.\n")
|
|
||||||
for event in captured:
|
|
||||||
f.write(event + "\n")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Exit on error
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SERVICE_NAME="contec-forwarder"
|
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
|
||||||
WORKING_DIR="/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main"
|
|
||||||
PYTHON_EXEC="${WORKING_DIR}/venv/bin/python"
|
|
||||||
SCRIPT_PATH="${WORKING_DIR}/main.py"
|
|
||||||
|
|
||||||
echo "=== Creating systemd service file ==="
|
|
||||||
sudo bash -c "cat > ${SERVICE_FILE}" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Contec Patient Monitor Vital Signs Forwarder
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=${WORKING_DIR}
|
|
||||||
Environment=PORT=8006
|
|
||||||
ExecStart=${PYTHON_EXEC} ${SCRIPT_PATH}
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "=== Reloading systemd daemon ==="
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
echo "=== Enabling ${SERVICE_NAME} to start on boot ==="
|
|
||||||
sudo systemctl enable ${SERVICE_NAME}
|
|
||||||
|
|
||||||
echo "=== Starting ${SERVICE_NAME} service ==="
|
|
||||||
sudo systemctl restart ${SERVICE_NAME}
|
|
||||||
|
|
||||||
echo "=== Service Status ==="
|
|
||||||
sudo systemctl status ${SERVICE_NAME} --no-pager
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================================="
|
|
||||||
echo "SUCCESS: Contec Forwarder is now running in the background!"
|
|
||||||
echo "To check live service logs, run:"
|
|
||||||
echo " sudo journalctl -u contec-forwarder -f"
|
|
||||||
echo "=========================================================="
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
|
|
||||||
* Works in Web, React, React Native, and Node.js environments.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Real-time streaming via WebSockets
|
|
||||||
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
|
|
||||||
* - Exponential backoff auto-reconnection
|
|
||||||
* - Lifecycle callbacks for UI updates
|
|
||||||
*/
|
|
||||||
class ContecVitalsClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} config - Client configuration options
|
|
||||||
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
|
|
||||||
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
|
|
||||||
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
|
|
||||||
* @param {function} [config.onVitals] - Callback when new vitals data arrives
|
|
||||||
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
|
|
||||||
* @param {function} [config.onError] - Callback on error
|
|
||||||
*/
|
|
||||||
constructor(config) {
|
|
||||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
||||||
this.deviceId = config.deviceId || null;
|
|
||||||
this.pollingInterval = config.pollingInterval || 3000;
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
this.onVitals = config.onVitals || (() => {});
|
|
||||||
this.onStatusChange = config.onStatusChange || (() => {});
|
|
||||||
this.onError = config.onError || (() => {});
|
|
||||||
|
|
||||||
// State management
|
|
||||||
this.ws = null;
|
|
||||||
this.pollTimer = null;
|
|
||||||
this.status = 'idle';
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.maxReconnectAttempts = 5;
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
this.isStarted = false;
|
|
||||||
this.lastVitalsTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start listening for vital signs.
|
|
||||||
*/
|
|
||||||
start() {
|
|
||||||
if (this.isStarted) return;
|
|
||||||
this.isStarted = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this._setStatus('connecting');
|
|
||||||
this._connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop listening and clean up all connections/timers.
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
this.isStarted = false;
|
|
||||||
this._clearPollTimer();
|
|
||||||
this._closeWebSocket();
|
|
||||||
this._setStatus('idle');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change current status and trigger callback.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_setStatus(newStatus) {
|
|
||||||
if (this.status !== newStatus) {
|
|
||||||
this.status = newStatus;
|
|
||||||
this.onStatusChange(newStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the base URL to construct the WS URL.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_getWebSocketUrl() {
|
|
||||||
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
||||||
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
|
|
||||||
return `${wsProto}://${cleanUrl}/ws/vitals`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_connectWebSocket() {
|
|
||||||
this._clearPollTimer();
|
|
||||||
const wsUrl = this._getWebSocketUrl();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
console.log('ContecVitalsClient: WebSocket connected');
|
|
||||||
this._setStatus('connected');
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
this._processIncomingVitals(data);
|
|
||||||
} catch (err) {
|
|
||||||
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
this.onError(err);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
this.ws = null;
|
|
||||||
if (!this.isStarted) return;
|
|
||||||
|
|
||||||
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
|
|
||||||
|
|
||||||
// Attempt reconnection
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
||||||
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
|
||||||
this._setStatus('connecting');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.isStarted) this._connectWebSocket();
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
|
|
||||||
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
|
|
||||||
this._startPolling();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
this.onError(err);
|
|
||||||
this._startPolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket connection cleanly.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_closeWebSocket() {
|
|
||||||
if (this.ws) {
|
|
||||||
// Remove event listeners to prevent onclose reconnect logic
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.onclose = null;
|
|
||||||
try {
|
|
||||||
this.ws.close();
|
|
||||||
} catch (e) {}
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start HTTP Polling.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_startPolling() {
|
|
||||||
this._closeWebSocket();
|
|
||||||
this._setStatus('polling');
|
|
||||||
this._poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform single HTTP poll.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async _poll() {
|
|
||||||
if (!this.isStarted || this.status !== 'polling') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readings = await response.json();
|
|
||||||
if (Array.isArray(readings) && readings.length > 0) {
|
|
||||||
this._processIncomingVitals(readings[0]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('ContecVitalsClient polling error:', err.message);
|
|
||||||
this.onError(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule next poll
|
|
||||||
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the HTTP polling timer.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_clearPollTimer() {
|
|
||||||
if (this.pollTimer) {
|
|
||||||
clearTimeout(this.pollTimer);
|
|
||||||
this.pollTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes and emits the vitals data payload.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_processIncomingVitals(data) {
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
// Filter by device if configured
|
|
||||||
if (this.deviceId && data.device_id !== this.deviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize property names and types
|
|
||||||
const normalized = {
|
|
||||||
deviceId: data.device_id,
|
|
||||||
patientId: data.patient_id,
|
|
||||||
timestamp: data.timestamp,
|
|
||||||
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
|
|
||||||
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
|
|
||||||
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
|
|
||||||
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
|
|
||||||
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
|
|
||||||
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
|
|
||||||
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate BP display string helper
|
|
||||||
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
|
|
||||||
? `${normalized.systolicBp}/${normalized.diastolicBp}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this.onVitals(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for ES6/Node compatibility
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = ContecVitalsClient;
|
|
||||||
} else if (typeof window !== 'undefined') {
|
|
||||||
window.ContecVitalsClient = ContecVitalsClient;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
|||||||
"""
|
|
||||||
Terminal Display for Live Vital Signs
|
|
||||||
======================================
|
|
||||||
Provides a rich, color-coded terminal dashboard showing real-time
|
|
||||||
vital signs received from patient monitors.
|
|
||||||
|
|
||||||
Works with both Mindray uMEC 100 and Contec CMS9200PLUS monitors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
|
||||||
from schemas import NormalizedVitals
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Try to use 'rich' for beautiful output, fall back to basic print
|
|
||||||
try:
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.text import Text
|
|
||||||
from rich.layout import Layout
|
|
||||||
from rich.live import Live
|
|
||||||
from rich import box
|
|
||||||
HAS_RICH = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_RICH = False
|
|
||||||
logger.info("'rich' library not installed. Using basic terminal output.")
|
|
||||||
|
|
||||||
|
|
||||||
# === Normal Range Definitions ===
|
|
||||||
VITAL_RANGES = {
|
|
||||||
"heart_rate": {"low": 60, "high": 100, "critical_low": 40, "critical_high": 150, "unit": "bpm", "label": "Heart Rate"},
|
|
||||||
"spo2": {"low": 95, "high": 100, "critical_low": 90, "critical_high": 101, "unit": "%", "label": "SpO2"},
|
|
||||||
"systolic_bp": {"low": 90, "high": 140, "critical_low": 70, "critical_high": 180, "unit": "mmHg", "label": "BP Systolic"},
|
|
||||||
"diastolic_bp": {"low": 60, "high": 90, "critical_low": 40, "critical_high": 120, "unit": "mmHg", "label": "BP Diastolic"},
|
|
||||||
"map_bp": {"low": 70, "high": 105, "critical_low": 60, "critical_high": 130, "unit": "mmHg", "label": "MAP"},
|
|
||||||
"respiratory_rate": {"low": 12, "high": 20, "critical_low": 8, "critical_high": 30, "unit": "/min", "label": "Resp Rate"},
|
|
||||||
"temperature": {"low": 36.1,"high": 37.2,"critical_low": 35.0,"critical_high": 39.0,"unit": "C", "label": "Temperature"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_vital_color(param_name: str, value: Optional[float]) -> str:
|
|
||||||
"""Return a color name based on whether the value is normal, warning, or critical."""
|
|
||||||
if value is None:
|
|
||||||
return "dim"
|
|
||||||
|
|
||||||
ranges = VITAL_RANGES.get(param_name)
|
|
||||||
if not ranges:
|
|
||||||
return "white"
|
|
||||||
|
|
||||||
if value <= ranges["critical_low"] or value >= ranges["critical_high"]:
|
|
||||||
return "bold red"
|
|
||||||
elif value < ranges["low"] or value > ranges["high"]:
|
|
||||||
return "yellow"
|
|
||||||
else:
|
|
||||||
return "bold green"
|
|
||||||
|
|
||||||
|
|
||||||
def format_vital_value(param_name: str, value: Optional[float]) -> str:
|
|
||||||
"""Format a vital sign value with its unit."""
|
|
||||||
if value is None:
|
|
||||||
return "---"
|
|
||||||
|
|
||||||
ranges = VITAL_RANGES.get(param_name, {})
|
|
||||||
unit = ranges.get("unit", "")
|
|
||||||
|
|
||||||
# Format based on typical precision
|
|
||||||
if param_name == "temperature":
|
|
||||||
return f"{value:.1f} {unit}"
|
|
||||||
elif param_name == "spo2":
|
|
||||||
return f"{value:.0f} {unit}"
|
|
||||||
else:
|
|
||||||
return f"{value:.0f} {unit}"
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
|
||||||
"""Manages the live terminal display of vital signs."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._latest_vitals: dict[str, NormalizedVitals] = {} # keyed by device_id
|
|
||||||
self._last_update: Optional[datetime] = None
|
|
||||||
self._connection_count: int = 0
|
|
||||||
self._message_count: int = 0
|
|
||||||
|
|
||||||
if HAS_RICH:
|
|
||||||
self.console = Console()
|
|
||||||
else:
|
|
||||||
self.console = None
|
|
||||||
|
|
||||||
def update_vitals(self, vitals: NormalizedVitals):
|
|
||||||
"""Update the display with new vital signs data."""
|
|
||||||
self._latest_vitals[vitals.device_id] = vitals
|
|
||||||
self._last_update = datetime.now(timezone.utc)
|
|
||||||
self._message_count += 1
|
|
||||||
self._print_vitals(vitals)
|
|
||||||
|
|
||||||
def connection_opened(self, client_ip: str, port: int):
|
|
||||||
"""Track a new connection."""
|
|
||||||
self._connection_count += 1
|
|
||||||
if HAS_RICH:
|
|
||||||
self.console.print(
|
|
||||||
f" [bold cyan]Connected:[/] {client_ip} on port {port} "
|
|
||||||
f"(total connections: {self._connection_count})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f" Connected: {client_ip} on port {port}")
|
|
||||||
|
|
||||||
def connection_closed(self, client_ip: str, port: int):
|
|
||||||
"""Track a closed connection."""
|
|
||||||
if HAS_RICH:
|
|
||||||
self.console.print(f" [dim]Disconnected:[/dim] {client_ip} on port {port}")
|
|
||||||
else:
|
|
||||||
print(f" Disconnected: {client_ip} on port {port}")
|
|
||||||
|
|
||||||
def _print_vitals(self, vitals: NormalizedVitals):
|
|
||||||
"""Print vital signs to terminal."""
|
|
||||||
if HAS_RICH:
|
|
||||||
self._print_vitals_rich(vitals)
|
|
||||||
else:
|
|
||||||
self._print_vitals_basic(vitals)
|
|
||||||
|
|
||||||
def _print_vitals_rich(self, vitals: NormalizedVitals):
|
|
||||||
"""Print vital signs using rich library for beautiful output."""
|
|
||||||
table = Table(
|
|
||||||
title=f"Live Vitals -- Device: {vitals.device_id} | Patient: {vitals.patient_id}",
|
|
||||||
box=box.HEAVY_EDGE,
|
|
||||||
show_header=True,
|
|
||||||
header_style="bold white on dark_blue",
|
|
||||||
title_style="bold white",
|
|
||||||
border_style="blue",
|
|
||||||
padding=(0, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
table.add_column("Parameter", style="bold white", width=16)
|
|
||||||
table.add_column("Value", justify="right", width=14)
|
|
||||||
table.add_column("Status", justify="center", width=10)
|
|
||||||
table.add_column("Normal Range", justify="center", style="dim", width=14)
|
|
||||||
|
|
||||||
vital_params = [
|
|
||||||
("heart_rate", vitals.heart_rate),
|
|
||||||
("spo2", vitals.spo2),
|
|
||||||
("systolic_bp", vitals.systolic_bp),
|
|
||||||
("diastolic_bp", vitals.diastolic_bp),
|
|
||||||
("map_bp", vitals.map_bp),
|
|
||||||
("respiratory_rate", vitals.respiratory_rate),
|
|
||||||
("temperature", vitals.temperature),
|
|
||||||
]
|
|
||||||
|
|
||||||
for param_name, value in vital_params:
|
|
||||||
ranges = VITAL_RANGES.get(param_name, {})
|
|
||||||
color = get_vital_color(param_name, value)
|
|
||||||
formatted = format_vital_value(param_name, value)
|
|
||||||
label = ranges.get("label", param_name)
|
|
||||||
|
|
||||||
# Status indicator
|
|
||||||
if value is None:
|
|
||||||
status = "[dim]-[/dim]"
|
|
||||||
elif color == "bold green":
|
|
||||||
status = "[green]Normal[/green]"
|
|
||||||
elif color == "yellow":
|
|
||||||
status = "[yellow]Warning[/yellow]"
|
|
||||||
elif color == "bold red":
|
|
||||||
status = "[red]ALERT[/red]"
|
|
||||||
else:
|
|
||||||
status = "[dim]-[/dim]"
|
|
||||||
|
|
||||||
# Normal range
|
|
||||||
low = ranges.get("low", "?")
|
|
||||||
high = ranges.get("high", "?")
|
|
||||||
unit = ranges.get("unit", "")
|
|
||||||
range_str = f"{low}-{high} {unit}"
|
|
||||||
|
|
||||||
table.add_row(
|
|
||||||
label,
|
|
||||||
f"[{color}]{formatted}[/{color}]",
|
|
||||||
status,
|
|
||||||
range_str,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.console.print()
|
|
||||||
self.console.print(table)
|
|
||||||
|
|
||||||
# Timestamp footer
|
|
||||||
time_str = vitals.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") if vitals.timestamp else "N/A"
|
|
||||||
self.console.print(
|
|
||||||
f" [dim]Last update: {time_str} | "
|
|
||||||
f"Messages received: {self._message_count} | "
|
|
||||||
f"Active connections: {self._connection_count}[/dim]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _print_vitals_basic(self, vitals: NormalizedVitals):
|
|
||||||
"""Fallback plain-text vital signs display."""
|
|
||||||
print("\n" + "=" * 65)
|
|
||||||
print(f" LIVE VITALS — Device: {vitals.device_id} | Patient: {vitals.patient_id}")
|
|
||||||
print("=" * 65)
|
|
||||||
|
|
||||||
vital_params = [
|
|
||||||
("Heart Rate", vitals.heart_rate, "bpm"),
|
|
||||||
("SpO2", vitals.spo2, "%"),
|
|
||||||
("BP Systolic", vitals.systolic_bp, "mmHg"),
|
|
||||||
("BP Diastolic",vitals.diastolic_bp, "mmHg"),
|
|
||||||
("MAP", vitals.map_bp, "mmHg"),
|
|
||||||
("Resp Rate", vitals.respiratory_rate, "/min"),
|
|
||||||
("Temperature", vitals.temperature, "C"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for label, value, unit in vital_params:
|
|
||||||
if value is not None:
|
|
||||||
if "temp" in label.lower():
|
|
||||||
val_str = f"{value:.1f} {unit}"
|
|
||||||
else:
|
|
||||||
val_str = f"{value:.0f} {unit}"
|
|
||||||
|
|
||||||
# Simple text status
|
|
||||||
ranges = VITAL_RANGES.get(label.lower().replace(" ", "_"), {})
|
|
||||||
if ranges:
|
|
||||||
if value < ranges.get("critical_low", 0) or value > ranges.get("critical_high", 999):
|
|
||||||
status = " *** CRITICAL ***"
|
|
||||||
elif value < ranges.get("low", 0) or value > ranges.get("high", 999):
|
|
||||||
status = " ! WARNING"
|
|
||||||
else:
|
|
||||||
status = " OK"
|
|
||||||
else:
|
|
||||||
status = ""
|
|
||||||
|
|
||||||
print(f" {label:<16} {val_str:>12}{status}")
|
|
||||||
else:
|
|
||||||
print(f" {label:<16} {'---':>12}")
|
|
||||||
|
|
||||||
time_str = vitals.timestamp.strftime("%H:%M:%S") if vitals.timestamp else "N/A"
|
|
||||||
print(f"\n Time: {time_str} | Messages: {self._message_count}")
|
|
||||||
print("-" * 65)
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton
|
|
||||||
_display: Optional[TerminalDisplay] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_terminal_display() -> TerminalDisplay:
|
|
||||||
"""Get or create the global terminal display instance."""
|
|
||||||
global _display
|
|
||||||
if _display is None:
|
|
||||||
_display = TerminalDisplay()
|
|
||||||
return _display
|
|
||||||
@@ -11,7 +11,7 @@ import uvicorn
|
|||||||
from config import settings
|
from config import settings
|
||||||
from database import engine, Base
|
from database import engine, Base
|
||||||
from contec_server import handle_contec_client
|
from contec_server import handle_contec_client
|
||||||
from background_tasks import retry_failed_transmissions
|
from background_tasks import retry_failed_transmissions, sync_cloud_state
|
||||||
from routers import dashboard
|
from routers import dashboard
|
||||||
from routers.dashboard import get_ws_manager
|
from routers.dashboard import get_ws_manager
|
||||||
|
|
||||||
@@ -62,6 +62,22 @@ def _get_local_ips():
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
# Protect against Windows IOCP Proactor crashes (WinError 64 / 121 / 10054 on accept)
|
||||||
|
import sys
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
def silence_windows_iocp_errors(loop, context):
|
||||||
|
msg = str(context.get("message", ""))
|
||||||
|
exc = context.get("exception")
|
||||||
|
winerr = getattr(exc, "winerror", None)
|
||||||
|
if winerr in (64, 121, 10054) or "Accept failed on a socket" in msg or "The specified network name is no longer available" in msg:
|
||||||
|
return
|
||||||
|
loop.default_exception_handler(context)
|
||||||
|
loop.set_exception_handler(silence_windows_iocp_errors)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
servers = []
|
servers = []
|
||||||
|
|
||||||
# === Start Contec CMS7000PLUS TCP Servers ===
|
# === Start Contec CMS7000PLUS TCP Servers ===
|
||||||
@@ -105,11 +121,13 @@ async def lifespan(app: FastAPI):
|
|||||||
print("\n" + "=" * 65)
|
print("\n" + "=" * 65)
|
||||||
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
|
print(f" Contec {model} Patient Monitor - Vital Signs Forwarder")
|
||||||
print("=" * 65)
|
print("=" * 65)
|
||||||
print(f" DASHBOARD: http://localhost:{port}/api/dashboard")
|
print(f" DASHBOARD: http://localhost:{port}/dashboard")
|
||||||
|
print(f" http://localhost:{port}/api/dashboard")
|
||||||
print(f" API Docs: http://localhost:{port}/docs")
|
print(f" API Docs: http://localhost:{port}/docs")
|
||||||
print(f" Contec Ports: {settings.contec_ports}")
|
print(f" Contec Ports: {settings.contec_ports}")
|
||||||
print(f" Monitor Model: {model}")
|
print(f" Monitor Model: {model}")
|
||||||
print(f" API Target: {settings.target_api_url}")
|
print(f" API Target: {settings.target_api_url}")
|
||||||
|
print(f" Cloud Sync: {getattr(settings, 'cloud_api_url', settings.target_api_url)}")
|
||||||
print(f" Database: {settings.database_url}")
|
print(f" Database: {settings.database_url}")
|
||||||
print(f" ---------------------------------------------------------")
|
print(f" ---------------------------------------------------------")
|
||||||
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
|
print(f" YOUR PC's IP ADDRESSES (configure on your monitor):")
|
||||||
@@ -121,14 +139,40 @@ async def lifespan(app: FastAPI):
|
|||||||
print(f" Set Server IP = one of the IPs above, Port = {settings.contec_ports[0] if settings.contec_ports else 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 background retry and cloud state sync tasks
|
||||||
retry_task = asyncio.create_task(retry_failed_transmissions())
|
retry_task = asyncio.create_task(retry_failed_transmissions())
|
||||||
|
cloud_sync_task = asyncio.create_task(sync_cloud_state())
|
||||||
|
|
||||||
|
# Startup: Start bore tunnel for remote monitors if available and not already running
|
||||||
|
bore_proc = None
|
||||||
|
bore_exe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bore.exe")
|
||||||
|
if os.path.exists(bore_exe):
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
check_out = subprocess.check_output('tasklist /FI "IMAGENAME eq bore.exe"', shell=True).decode()
|
||||||
|
if "bore.exe" not in check_out:
|
||||||
|
bore_proc = subprocess.Popen(
|
||||||
|
[bore_exe, "local", "511", "--to", "159.223.110.159", "--port", "61501"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
logger.info(" [OK] Bore tunnel active for remote hotspot monitors (159.223.110.159:61501)")
|
||||||
|
else:
|
||||||
|
logger.info(" [INFO] Bore tunnel is already running")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not check/start bore tunnel: {e}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down servers...")
|
logger.info("Shutting down servers...")
|
||||||
|
if bore_proc:
|
||||||
|
try:
|
||||||
|
bore_proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
retry_task.cancel()
|
retry_task.cancel()
|
||||||
|
cloud_sync_task.cancel()
|
||||||
for server in servers:
|
for server in servers:
|
||||||
server.close()
|
server.close()
|
||||||
for server in servers:
|
for server in servers:
|
||||||
@@ -153,16 +197,42 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include dashboard routes
|
# Include dashboard routes under multiple prefixes so reverse proxies (Nginx)
|
||||||
|
# work whether they strip /v1/contec-monitor or forward it as-is
|
||||||
app.include_router(dashboard.router, prefix="/api")
|
app.include_router(dashboard.router, prefix="/api")
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(dashboard.router, prefix="/v1/contec-monitor/api")
|
||||||
|
app.include_router(dashboard.router, prefix="/v1/contec-monitor")
|
||||||
|
|
||||||
|
# Also serve dashboard directly for convenience across various path styles
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
@app.get("/v1/contec-monitor", response_class=HTMLResponse)
|
||||||
|
@app.get("/v1/contec-monitor/dashboard", response_class=HTMLResponse)
|
||||||
|
@app.get("/v1/contec-monitor/api/dashboard", response_class=HTMLResponse)
|
||||||
|
def serve_dashboard_root():
|
||||||
|
return dashboard.serve_dashboard()
|
||||||
|
|
||||||
# Serve static folder
|
# Serve static folder
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
app.mount("/v1/contec-monitor/static", StaticFiles(directory="static"), name="static_subpath")
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
@app.get("/v1/contec-monitor/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon():
|
||||||
|
return Response(status_code=204, media_type="image/x-icon")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# === WebSocket endpoint for live vitals ===
|
# === WebSocket endpoint for live vitals ===
|
||||||
@app.websocket("/ws/vitals")
|
@app.websocket("/ws/vitals")
|
||||||
|
@app.websocket("/api/ws/vitals")
|
||||||
|
@app.websocket("/v1/contec-monitor/ws/vitals")
|
||||||
|
@app.websocket("/v1/contec-monitor/api/ws/vitals")
|
||||||
async def websocket_vitals(websocket: WebSocket):
|
async def websocket_vitals(websocket: WebSocket):
|
||||||
manager = get_ws_manager()
|
manager = get_ws_manager()
|
||||||
await manager.connect(websocket)
|
await manager.connect(websocket)
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ class Device(Base):
|
|||||||
|
|
||||||
class Patient(Base):
|
class Patient(Base):
|
||||||
__tablename__ = "patients"
|
__tablename__ = "patients"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
patient_id = Column(String, unique=True, index=True)
|
patient_id = Column(String, unique=True, index=True) # MRN from monitor PID-3
|
||||||
name = Column(String)
|
mrn = Column(String, index=True, nullable=True) # explicit MRN (same as patient_id)
|
||||||
|
name = Column(String, nullable=True) # full name from PID-5
|
||||||
|
gender = Column(String, nullable=True) # M/F/U from PID-8
|
||||||
|
dob = Column(String, nullable=True) # YYYYMMDD from PID-7
|
||||||
|
bed_id = Column(String, nullable=True) # bed from PV1-3
|
||||||
|
patient_type = Column(String, nullable=True) # Adult / Pediatric / Neonatal
|
||||||
|
device_id = Column(String, nullable=True, index=True) # assigned machine / device_id
|
||||||
|
|
||||||
class VitalReading(Base):
|
class VitalReading(Base):
|
||||||
__tablename__ = "vital_readings"
|
__tablename__ = "vital_readings"
|
||||||
@@ -29,6 +35,8 @@ class VitalReading(Base):
|
|||||||
map_bp = Column(Float, nullable=True)
|
map_bp = Column(Float, nullable=True)
|
||||||
respiratory_rate = Column(Float, nullable=True)
|
respiratory_rate = Column(Float, nullable=True)
|
||||||
temperature = Column(Float, nullable=True)
|
temperature = Column(Float, nullable=True)
|
||||||
|
temperature2 = Column(Float, nullable=True)
|
||||||
|
temperature_diff = Column(Float, nullable=True)
|
||||||
transmitted = Column(Boolean, default=False, index=True)
|
transmitted = Column(Boolean, default=False, index=True)
|
||||||
|
|
||||||
class TransmissionLog(Base):
|
class TransmissionLog(Base):
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
buildCommand: pip install -r requirements.txt
|
buildCommand: pip install -r requirements.txt
|
||||||
startCommand: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
|
startCommand: python3 -m uvicorn main:app --host 0.0.0.0 --port $PORT
|
||||||
envVars:
|
envVars:
|
||||||
|
- key: PYTHONPATH
|
||||||
|
value: /opt/render/project/src
|
||||||
- key: PORT
|
- key: PORT
|
||||||
value: 10000
|
value: 10000
|
||||||
- key: PYTHON_VERSION
|
- key: PYTHON_VERSION
|
||||||
|
|||||||
+494
-67
@@ -3,17 +3,30 @@ 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, Request
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Device, VitalReading, TransmissionLog
|
from models import Device, Patient, VitalReading, TransmissionLog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
class PatientAssignRequest(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
mrn: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
dob: Optional[str] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
bed_id: Optional[str] = None
|
||||||
|
patient_type: Optional[str] = None
|
||||||
|
|
||||||
# === WebSocket Manager for real-time vitals push ===
|
# === WebSocket Manager for real-time vitals push ===
|
||||||
class VitalsWebSocketManager:
|
class VitalsWebSocketManager:
|
||||||
"""Manages WebSocket connections for live vitals streaming."""
|
"""Manages WebSocket connections for live vitals streaming."""
|
||||||
@@ -59,7 +72,14 @@ def serve_dashboard():
|
|||||||
dashboard_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "dashboard.html")
|
dashboard_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "dashboard.html")
|
||||||
try:
|
try:
|
||||||
with open(dashboard_path, "r", encoding="utf-8") as f:
|
with open(dashboard_path, "r", encoding="utf-8") as f:
|
||||||
return HTMLResponse(content=f.read())
|
return HTMLResponse(
|
||||||
|
content=f.read(),
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0"
|
||||||
|
}
|
||||||
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return HTMLResponse(content="<h1>Dashboard not found</h1><p>static/dashboard.html is missing</p>", status_code=404)
|
return HTMLResponse(content="<h1>Dashboard not found</h1><p>static/dashboard.html is missing</p>", status_code=404)
|
||||||
|
|
||||||
@@ -70,61 +90,437 @@ def health_check():
|
|||||||
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
return {"status": "ok", "service": "Patient Monitor Vital Signs Forwarder"}
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def get_system_status(request: Request, db: Session = Depends(get_db)):
|
def get_system_status(db: Session = Depends(get_db)):
|
||||||
total_devices = db.query(Device).count()
|
total_devices = db.query(Device).count()
|
||||||
active_devices = db.query(Device).filter(Device.status == "active").count()
|
active_devices = db.query(Device).filter(Device.status == "active").count()
|
||||||
total_readings = db.query(VitalReading).count()
|
total_readings = db.query(VitalReading).count()
|
||||||
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
|
pending_transmissions = db.query(VitalReading).filter(VitalReading.transmitted == False).count()
|
||||||
active_ports = getattr(request.app.state, "active_ports", [])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_devices": total_devices,
|
"total_devices": total_devices,
|
||||||
"active_devices": active_devices,
|
"active_devices": active_devices,
|
||||||
"total_readings_stored": total_readings,
|
"total_readings_stored": total_readings,
|
||||||
"pending_transmissions": pending_transmissions,
|
"pending_transmissions": pending_transmissions
|
||||||
"active_ports": active_ports
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/devices")
|
@router.get("/devices")
|
||||||
def list_devices(db: Session = Depends(get_db)):
|
def list_devices(db: Session = Depends(get_db)):
|
||||||
devices = db.query(Device).all()
|
devices = db.query(Device).all()
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
updated = False
|
|
||||||
for device in devices:
|
|
||||||
last_seen = device.last_seen
|
|
||||||
if last_seen.tzinfo is None:
|
|
||||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
# If not seen for more than 15 seconds, mark offline
|
|
||||||
if (now - last_seen).total_seconds() > 15.0:
|
|
||||||
if device.status != "offline":
|
|
||||||
device.status = "offline"
|
|
||||||
updated = True
|
|
||||||
else:
|
|
||||||
if device.status != "active":
|
|
||||||
device.status = "active"
|
|
||||||
updated = True
|
|
||||||
if updated:
|
|
||||||
db.commit()
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
@router.get("/latest-readings")
|
@router.get("/latest-readings")
|
||||||
def get_latest_readings(device_id: str = None, limit: int = 10, db: Session = Depends(get_db)):
|
def get_latest_readings(
|
||||||
|
limit: int = 10,
|
||||||
|
mrn: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
dob: Optional[str] = None,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
query = db.query(VitalReading)
|
query = db.query(VitalReading)
|
||||||
if device_id:
|
if device_id:
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
query = query.filter(VitalReading.device_id == device_id)
|
||||||
|
|
||||||
|
patient_criteria = []
|
||||||
|
if mrn:
|
||||||
|
mrn_clean = mrn.strip()
|
||||||
|
patient_criteria.append(or_(Patient.mrn == mrn_clean, Patient.patient_id == mrn_clean))
|
||||||
|
if name:
|
||||||
|
name_clean = name.strip()
|
||||||
|
patient_criteria.append(Patient.name.ilike(f"%{name_clean}%"))
|
||||||
|
if dob:
|
||||||
|
dob_clean = dob.strip().replace("-", "").replace("/", "")
|
||||||
|
patient_criteria.append(or_(
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob_clean}%"),
|
||||||
|
Patient.dob.ilike(f"%{dob.strip()[:4]}%") if len(dob.strip()) >= 4 else False
|
||||||
|
))
|
||||||
|
|
||||||
|
if patient_criteria:
|
||||||
|
from sqlalchemy import and_
|
||||||
|
matching_patients = db.query(Patient).filter(and_(*patient_criteria)).all()
|
||||||
|
if not matching_patients:
|
||||||
|
return []
|
||||||
|
matching_dev_ids = [p.device_id for p in matching_patients if p.device_id]
|
||||||
|
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
||||||
|
|
||||||
|
conds = []
|
||||||
|
if matching_mrns:
|
||||||
|
conds.append(VitalReading.patient_id.in_(matching_mrns))
|
||||||
|
if matching_dev_ids:
|
||||||
|
conds.append(VitalReading.device_id.in_(matching_dev_ids))
|
||||||
|
|
||||||
|
if conds:
|
||||||
|
query = query.filter(or_(*conds))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
readings = query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
||||||
return readings
|
return readings
|
||||||
|
|
||||||
|
@router.get("/patients")
|
||||||
|
def list_patients(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
List all patients with their assigned device and latest vitals readings.
|
||||||
|
Strictly enforce live data timeouts:
|
||||||
|
- Continuous parameters (HR, SpO2, RR, Temp): max 25 seconds age.
|
||||||
|
- Intermittent parameters (NIBP): max 15 minutes age.
|
||||||
|
- Never backfill from stale historical readings.
|
||||||
|
"""
|
||||||
|
patients = db.query(Patient).filter(Patient.patient_id != "UNKNOWN", Patient.mrn != "UNKNOWN").all()
|
||||||
|
results = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
live_cutoff = now - timedelta(seconds=25)
|
||||||
|
nibp_cutoff = now - timedelta(minutes=15)
|
||||||
|
|
||||||
|
for p in patients:
|
||||||
|
merged_vitals = None
|
||||||
|
|
||||||
|
# Primary source: in-memory live cache if running locally with active machine stream
|
||||||
|
try:
|
||||||
|
from contec_server import device_cache, device_field_timestamps
|
||||||
|
if p.device_id and p.device_id in device_cache:
|
||||||
|
cached = device_cache[p.device_id]
|
||||||
|
field_ts = device_field_timestamps.get(p.device_id, {})
|
||||||
|
cached_time = cached.timestamp
|
||||||
|
if cached_time:
|
||||||
|
if cached_time.tzinfo is None:
|
||||||
|
cached_time = cached_time.replace(tzinfo=timezone.utc)
|
||||||
|
if (now - cached_time).total_seconds() <= 25:
|
||||||
|
merged_vitals = {
|
||||||
|
"heart_rate": cached.heart_rate if (field_ts.get("heart_rate") and (now - field_ts["heart_rate"]).total_seconds() <= 25) else None,
|
||||||
|
"spo2": cached.spo2 if (field_ts.get("spo2") and (now - field_ts["spo2"]).total_seconds() <= 25) else None,
|
||||||
|
"respiratory_rate": cached.respiratory_rate if (field_ts.get("respiratory_rate") and (now - field_ts["respiratory_rate"]).total_seconds() <= 25) else None,
|
||||||
|
"temperature": cached.temperature if (field_ts.get("temperature") and (now - field_ts["temperature"]).total_seconds() <= 25) else None,
|
||||||
|
"temperature2": getattr(cached, 'temperature2', None) if (field_ts.get("temperature2") and (now - field_ts["temperature2"]).total_seconds() <= 25) else None,
|
||||||
|
"temperature_diff": getattr(cached, 'temperature_diff', None) if (field_ts.get("temperature_diff") and (now - field_ts["temperature_diff"]).total_seconds() <= 25) else None,
|
||||||
|
"systolic_bp": cached.systolic_bp if (field_ts.get("systolic_bp") and (now - field_ts["systolic_bp"]).total_seconds() <= 900) else None,
|
||||||
|
"diastolic_bp": cached.diastolic_bp if (field_ts.get("diastolic_bp") and (now - field_ts["diastolic_bp"]).total_seconds() <= 900) else None,
|
||||||
|
"map_bp": cached.map_bp if (field_ts.get("map_bp") and (now - field_ts["map_bp"]).total_seconds() <= 900) else None,
|
||||||
|
"timestamp": cached_time.isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not read from device_cache: {e}")
|
||||||
|
|
||||||
|
# Fallback or cloud/Render mode: query DB
|
||||||
|
if merged_vitals is None:
|
||||||
|
patient_filters = or_(
|
||||||
|
VitalReading.patient_id == p.mrn,
|
||||||
|
VitalReading.patient_id == p.patient_id,
|
||||||
|
(VitalReading.device_id == p.device_id) if p.device_id else False
|
||||||
|
)
|
||||||
|
# 1. Try to find readings within the live cutoff window (last 25 seconds)
|
||||||
|
recent_readings = (
|
||||||
|
db.query(VitalReading)
|
||||||
|
.filter(patient_filters, VitalReading.timestamp >= live_cutoff)
|
||||||
|
.order_by(VitalReading.timestamp.desc())
|
||||||
|
.limit(20)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 2. Option B: If no live reading in last 25s, retain latest recorded readings from DB
|
||||||
|
if not recent_readings:
|
||||||
|
recent_readings = (
|
||||||
|
db.query(VitalReading)
|
||||||
|
.filter(patient_filters)
|
||||||
|
.order_by(VitalReading.timestamp.desc())
|
||||||
|
.limit(20)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if recent_readings:
|
||||||
|
ts_iso = recent_readings[0].timestamp.isoformat() if recent_readings[0].timestamp else None
|
||||||
|
if ts_iso and not ts_iso.endswith('Z') and '+' not in ts_iso:
|
||||||
|
ts_iso += 'Z'
|
||||||
|
merged_vitals = {
|
||||||
|
"heart_rate": None,
|
||||||
|
"spo2": None,
|
||||||
|
"systolic_bp": None,
|
||||||
|
"diastolic_bp": None,
|
||||||
|
"map_bp": None,
|
||||||
|
"respiratory_rate": None,
|
||||||
|
"temperature": None,
|
||||||
|
"temperature2": None,
|
||||||
|
"temperature_diff": None,
|
||||||
|
"timestamp": ts_iso,
|
||||||
|
}
|
||||||
|
for r in reversed(recent_readings):
|
||||||
|
if r.heart_rate is not None: merged_vitals["heart_rate"] = r.heart_rate
|
||||||
|
if r.spo2 is not None: merged_vitals["spo2"] = r.spo2
|
||||||
|
if r.respiratory_rate is not None: merged_vitals["respiratory_rate"] = r.respiratory_rate
|
||||||
|
if r.temperature is not None: merged_vitals["temperature"] = r.temperature
|
||||||
|
if getattr(r, 'temperature2', None) is not None: merged_vitals["temperature2"] = r.temperature2
|
||||||
|
if getattr(r, 'temperature_diff', None) is not None: merged_vitals["temperature_diff"] = r.temperature_diff
|
||||||
|
if r.systolic_bp is not None: merged_vitals["systolic_bp"] = r.systolic_bp
|
||||||
|
if r.diastolic_bp is not None: merged_vitals["diastolic_bp"] = r.diastolic_bp
|
||||||
|
if r.map_bp is not None: merged_vitals["map_bp"] = r.map_bp
|
||||||
|
|
||||||
|
# Check NIBP separately if not already populated
|
||||||
|
if not merged_vitals or merged_vitals.get("systolic_bp") is None:
|
||||||
|
r_bp = (
|
||||||
|
db.query(VitalReading.systolic_bp, VitalReading.diastolic_bp, VitalReading.map_bp, VitalReading.timestamp)
|
||||||
|
.filter(patient_filters, VitalReading.systolic_bp != None)
|
||||||
|
.order_by(VitalReading.timestamp.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if r_bp:
|
||||||
|
if merged_vitals is None:
|
||||||
|
ts_bp = r_bp[3].isoformat() if r_bp[3] else None
|
||||||
|
if ts_bp and not ts_bp.endswith('Z') and '+' not in ts_bp:
|
||||||
|
ts_bp += 'Z'
|
||||||
|
merged_vitals = {
|
||||||
|
"heart_rate": None, "spo2": None, "respiratory_rate": None,
|
||||||
|
"temperature": None, "temperature2": None, "temperature_diff": None,
|
||||||
|
"timestamp": ts_bp
|
||||||
|
}
|
||||||
|
merged_vitals["systolic_bp"] = r_bp[0]
|
||||||
|
merged_vitals["diastolic_bp"] = r_bp[1]
|
||||||
|
merged_vitals["map_bp"] = r_bp[2]
|
||||||
|
|
||||||
|
|
||||||
|
device = db.query(Device).filter(Device.device_id == p.device_id).first() if p.device_id else None
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": p.id,
|
||||||
|
"patient_id": p.patient_id,
|
||||||
|
"mrn": p.mrn or p.patient_id,
|
||||||
|
"name": p.name or "Unknown",
|
||||||
|
"gender": p.gender,
|
||||||
|
"dob": p.dob,
|
||||||
|
"bed_id": p.bed_id,
|
||||||
|
"device_id": p.device_id,
|
||||||
|
"device_status": device.status if device else "unassigned",
|
||||||
|
"device_ip": device.ip_address if device else None,
|
||||||
|
"latest_vitals": merged_vitals
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/patient/assign")
|
||||||
|
@router.post("/patients/assign")
|
||||||
|
async def assign_patient(req: PatientAssignRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Assign patient demographics (MRN, Name, DOB, Gender, Bed) to a specific monitor/device_id.
|
||||||
|
"""
|
||||||
|
from contec_server import set_assigned_patient
|
||||||
|
|
||||||
|
device_id = req.device_id.strip()
|
||||||
|
mrn = req.mrn.strip() if req.mrn else ""
|
||||||
|
if not device_id:
|
||||||
|
raise HTTPException(status_code=400, detail="device_id is required")
|
||||||
|
|
||||||
|
if not mrn or mrn.upper() in ("UNKNOWN", "CLEAR", "NONE", "UNASSIGN"):
|
||||||
|
# Clear assignment from in-memory cache and DB
|
||||||
|
from contec_server import device_patient_map, device_cache
|
||||||
|
if device_id in device_patient_map:
|
||||||
|
del device_patient_map[device_id]
|
||||||
|
if device_id in device_cache:
|
||||||
|
cached = device_cache[device_id]
|
||||||
|
cached.patient_id = "UNKNOWN"
|
||||||
|
cached.patient_name = None
|
||||||
|
cached.patient_gender = None
|
||||||
|
cached.patient_dob = None
|
||||||
|
cached.bed_id = None
|
||||||
|
db.query(Patient).filter(Patient.device_id == device_id).update({"device_id": None})
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast_vitals({
|
||||||
|
"event": "patient_assigned",
|
||||||
|
"device_id": device_id,
|
||||||
|
"patient_id": "UNKNOWN",
|
||||||
|
"mrn": "UNKNOWN",
|
||||||
|
"patient_name": None,
|
||||||
|
"name": None,
|
||||||
|
"patient_gender": None,
|
||||||
|
"gender": None,
|
||||||
|
"patient_dob": None,
|
||||||
|
"dob": None,
|
||||||
|
"bed_id": None
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from api_client import sync_patient_assignment_to_cloud
|
||||||
|
asyncio.create_task(sync_patient_assignment_to_cloud({"mrn": "UNKNOWN", "name": "Unassigned Patient"}, device_id))
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Device {device_id} unassigned successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Clear device_id from any other patient previously holding this device
|
||||||
|
existing_for_device = db.query(Patient).filter(Patient.device_id == device_id).all()
|
||||||
|
for p in existing_for_device:
|
||||||
|
if p.mrn != mrn and p.patient_id != mrn:
|
||||||
|
p.device_id = None
|
||||||
|
|
||||||
|
# 2. Find or create patient by MRN
|
||||||
|
patient = db.query(Patient).filter(or_(Patient.mrn == mrn, Patient.patient_id == mrn)).first()
|
||||||
|
if not patient:
|
||||||
|
patient = Patient(
|
||||||
|
patient_id=mrn,
|
||||||
|
mrn=mrn,
|
||||||
|
name=req.name or "Unknown",
|
||||||
|
gender=req.gender,
|
||||||
|
dob=req.dob,
|
||||||
|
bed_id=req.bed_id,
|
||||||
|
patient_type=req.patient_type,
|
||||||
|
device_id=device_id
|
||||||
|
)
|
||||||
|
db.add(patient)
|
||||||
|
else:
|
||||||
|
patient.patient_id = mrn
|
||||||
|
patient.mrn = mrn
|
||||||
|
if req.name is not None:
|
||||||
|
patient.name = req.name
|
||||||
|
if req.gender is not None:
|
||||||
|
patient.gender = req.gender
|
||||||
|
if req.dob is not None:
|
||||||
|
patient.dob = req.dob
|
||||||
|
if req.bed_id is not None:
|
||||||
|
patient.bed_id = req.bed_id
|
||||||
|
if req.patient_type is not None:
|
||||||
|
patient.patient_type = req.patient_type
|
||||||
|
patient.device_id = device_id
|
||||||
|
|
||||||
|
# 3. Associate recent readings for this device that were marked "UNKNOWN"
|
||||||
|
db.query(VitalReading).filter(
|
||||||
|
VitalReading.device_id == device_id,
|
||||||
|
VitalReading.patient_id == "UNKNOWN"
|
||||||
|
).update({"patient_id": mrn})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(patient)
|
||||||
|
|
||||||
|
# 4. Update in-memory server mapping
|
||||||
|
p_data = {
|
||||||
|
"patient_id": patient.mrn,
|
||||||
|
"mrn": patient.mrn,
|
||||||
|
"name": patient.name,
|
||||||
|
"gender": patient.gender,
|
||||||
|
"dob": patient.dob,
|
||||||
|
"bed_id": patient.bed_id,
|
||||||
|
"patient_type": patient.patient_type,
|
||||||
|
}
|
||||||
|
set_assigned_patient(device_id, p_data)
|
||||||
|
|
||||||
|
# 5. Broadcast to connected WebSocket clients
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast_vitals({
|
||||||
|
"event": "patient_assigned",
|
||||||
|
"device_id": device_id,
|
||||||
|
"patient_id": patient.mrn,
|
||||||
|
"mrn": patient.mrn,
|
||||||
|
"patient_name": patient.name,
|
||||||
|
"name": patient.name,
|
||||||
|
"patient_gender": patient.gender,
|
||||||
|
"gender": patient.gender,
|
||||||
|
"patient_dob": patient.dob,
|
||||||
|
"dob": patient.dob,
|
||||||
|
"bed_id": patient.bed_id,
|
||||||
|
"patient_type": patient.patient_type,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Broadcast of patient_assigned failed: {e}")
|
||||||
|
|
||||||
|
from api_client import sync_patient_assignment_to_cloud
|
||||||
|
asyncio.create_task(sync_patient_assignment_to_cloud(p_data, device_id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Patient {patient.name} ({mrn}) assigned to device {device_id}",
|
||||||
|
"patient": p_data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/patient/{identifier}/history")
|
||||||
|
def get_patient_history(identifier: str, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Fetch patient profile, assigned machine, and readings history by MRN, patient_id, or name.
|
||||||
|
"""
|
||||||
|
patient = db.query(Patient).filter(
|
||||||
|
or_(
|
||||||
|
Patient.mrn == identifier,
|
||||||
|
Patient.patient_id == identifier,
|
||||||
|
Patient.name.ilike(f"%{identifier}%")
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not patient:
|
||||||
|
raise HTTPException(status_code=404, detail="Patient not found")
|
||||||
|
|
||||||
|
readings = (
|
||||||
|
db.query(VitalReading)
|
||||||
|
.filter(or_(VitalReading.patient_id == patient.mrn, VitalReading.patient_id == patient.patient_id))
|
||||||
|
.order_by(VitalReading.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
device = db.query(Device).filter(Device.device_id == patient.device_id).first() if patient.device_id else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"patient": {
|
||||||
|
"mrn": patient.mrn or patient.patient_id,
|
||||||
|
"name": patient.name,
|
||||||
|
"gender": patient.gender,
|
||||||
|
"dob": patient.dob,
|
||||||
|
"bed_id": patient.bed_id,
|
||||||
|
"device_id": patient.device_id,
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"device_id": device.device_id if device else None,
|
||||||
|
"ip_address": device.ip_address if device else None,
|
||||||
|
"status": device.status if device else "offline",
|
||||||
|
} if device else None,
|
||||||
|
"readings_count": len(readings),
|
||||||
|
"readings": readings
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/readings")
|
@router.get("/readings")
|
||||||
def get_readings(device_id: str = None, patient_id: str = None, limit: int = 50, db: Session = Depends(get_db)):
|
def get_readings(
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
patient_id: Optional[str] = None,
|
||||||
|
mrn: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
gender: Optional[str] = None,
|
||||||
|
dob: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetch vital readings filtered by patient MRN, Name, DOB, Gender, or Device ID.
|
||||||
|
"""
|
||||||
query = db.query(VitalReading)
|
query = db.query(VitalReading)
|
||||||
|
|
||||||
|
target_mrn = mrn or patient_id
|
||||||
|
if target_mrn:
|
||||||
|
query = query.filter(VitalReading.patient_id == target_mrn)
|
||||||
|
|
||||||
|
# If filtered by patient demographics (name, gender, dob)
|
||||||
|
if name or gender or dob:
|
||||||
|
p_query = db.query(Patient)
|
||||||
|
if name:
|
||||||
|
p_query = p_query.filter(Patient.name.ilike(f"%{name}%"))
|
||||||
|
if gender:
|
||||||
|
p_query = p_query.filter(Patient.gender.ilike(f"%{gender}%"))
|
||||||
|
if dob:
|
||||||
|
p_query = p_query.filter(Patient.dob.ilike(f"%{dob}%"))
|
||||||
|
|
||||||
|
matching_patients = p_query.all()
|
||||||
|
matching_mrns = [p.mrn for p in matching_patients if p.mrn] + [p.patient_id for p in matching_patients if p.patient_id]
|
||||||
|
matching_devices = [p.device_id for p in matching_patients if p.device_id]
|
||||||
|
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
VitalReading.patient_id.in_(matching_mrns),
|
||||||
|
VitalReading.device_id.in_(matching_devices)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if device_id:
|
if device_id:
|
||||||
query = query.filter(VitalReading.device_id == device_id)
|
query = query.filter(VitalReading.device_id == device_id)
|
||||||
if patient_id:
|
|
||||||
query = query.filter(VitalReading.patient_id == patient_id)
|
|
||||||
|
|
||||||
return query.order_by(VitalReading.timestamp.desc()).limit(limit).all()
|
return query.order_by(VitalReading.timestamp.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
@router.get("/transmission-logs")
|
@router.get("/transmission-logs")
|
||||||
def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)):
|
def get_transmission_logs(limit: int = 50, db: Session = Depends(get_db)):
|
||||||
@@ -162,6 +558,7 @@ def get_network_info():
|
|||||||
primary_ip = None
|
primary_ip = None
|
||||||
try:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(0.5)
|
||||||
s.connect(('8.8.8.8', 80))
|
s.connect(('8.8.8.8', 80))
|
||||||
primary_ip = s.getsockname()[0]
|
primary_ip = s.getsockname()[0]
|
||||||
s.close()
|
s.close()
|
||||||
@@ -189,55 +586,73 @@ def get_network_info():
|
|||||||
|
|
||||||
# === Push Vitals Endpoint (for local-to-cloud sync) ===
|
# === Push Vitals Endpoint (for local-to-cloud sync) ===
|
||||||
from schemas import NormalizedVitals
|
from schemas import NormalizedVitals
|
||||||
from models import Patient
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
@router.post("/push-vitals")
|
@router.post("/push-vitals")
|
||||||
async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session = Depends(get_db)):
|
async def push_vitals(vitals: NormalizedVitals, api_key: Optional[str] = None, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Receive vitals pushed from a local instance and store + broadcast them.
|
Receive vitals pushed from a local instance and store + broadcast them to connected WebSocket clients.
|
||||||
|
|
||||||
This enables the 'split architecture' where:
|
|
||||||
- Local instance receives data from physical monitors via TCP
|
|
||||||
- Local instance POSTs vitals here for cloud storage & dashboard access
|
|
||||||
|
|
||||||
Optional: pass ?api_key=YOUR_KEY for basic authentication.
|
|
||||||
"""
|
"""
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
# Optional API key check
|
|
||||||
if settings.api_token and api_key != settings.api_token:
|
if settings.api_token and api_key != settings.api_token:
|
||||||
if settings.api_token: # Only enforce if a token is configured
|
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing api_key")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 1. Device tracking
|
# 1. Device tracking
|
||||||
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
device = db.query(Device).filter(Device.device_id == vitals.device_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
device = Device(
|
device = Device(
|
||||||
device_id=vitals.device_id,
|
device_id=vitals.device_id,
|
||||||
ip_address=vitals.ip_address or "remote-push",
|
ip_address=vitals.ip_address or "remote-push",
|
||||||
status="active"
|
status="active",
|
||||||
|
last_seen=now
|
||||||
)
|
)
|
||||||
db.add(device)
|
db.add(device)
|
||||||
else:
|
else:
|
||||||
if vitals.ip_address:
|
if vitals.ip_address:
|
||||||
device.ip_address = vitals.ip_address
|
device.ip_address = vitals.ip_address
|
||||||
device.status = "active"
|
device.status = "active"
|
||||||
device.last_seen = datetime.now(timezone.utc)
|
device.last_seen = now
|
||||||
|
|
||||||
# 2. Patient tracking
|
# 2. Patient tracking
|
||||||
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
|
mrn = vitals.patient_id
|
||||||
if not patient:
|
patient = None
|
||||||
patient = Patient(patient_id=vitals.patient_id, name="Unknown")
|
if mrn and mrn != "UNKNOWN":
|
||||||
db.add(patient)
|
patient = db.query(Patient).filter(or_(Patient.mrn == mrn, Patient.patient_id == mrn)).first()
|
||||||
|
if not patient:
|
||||||
# 3. Create vital reading
|
patient = Patient(
|
||||||
from models import VitalReading as VR
|
patient_id=mrn,
|
||||||
reading = VR(
|
mrn=mrn,
|
||||||
|
name=vitals.patient_name or "Unknown",
|
||||||
|
gender=vitals.patient_gender,
|
||||||
|
dob=vitals.patient_dob,
|
||||||
|
bed_id=vitals.bed_id,
|
||||||
|
device_id=vitals.device_id
|
||||||
|
)
|
||||||
|
db.add(patient)
|
||||||
|
else:
|
||||||
|
if vitals.patient_name and vitals.patient_name != "Unknown":
|
||||||
|
patient.name = vitals.patient_name
|
||||||
|
if vitals.patient_gender:
|
||||||
|
patient.gender = vitals.patient_gender
|
||||||
|
if vitals.patient_dob:
|
||||||
|
patient.dob = vitals.patient_dob
|
||||||
|
if vitals.bed_id:
|
||||||
|
patient.bed_id = vitals.bed_id
|
||||||
|
patient.device_id = vitals.device_id
|
||||||
|
elif vitals.device_id:
|
||||||
|
# Check if there's already a patient assigned to this device
|
||||||
|
patient = db.query(Patient).filter(Patient.device_id == vitals.device_id, Patient.patient_id != "UNKNOWN").first()
|
||||||
|
if patient:
|
||||||
|
mrn = patient.mrn or patient.patient_id
|
||||||
|
|
||||||
|
# 3. Create VitalReading
|
||||||
|
reading = VitalReading(
|
||||||
device_id=vitals.device_id,
|
device_id=vitals.device_id,
|
||||||
patient_id=vitals.patient_id,
|
patient_id=mrn or "UNKNOWN",
|
||||||
timestamp=vitals.timestamp,
|
timestamp=vitals.timestamp or now,
|
||||||
heart_rate=vitals.heart_rate,
|
heart_rate=vitals.heart_rate,
|
||||||
spo2=vitals.spo2,
|
spo2=vitals.spo2,
|
||||||
systolic_bp=vitals.systolic_bp,
|
systolic_bp=vitals.systolic_bp,
|
||||||
@@ -245,27 +660,39 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session
|
|||||||
map_bp=vitals.map_bp,
|
map_bp=vitals.map_bp,
|
||||||
respiratory_rate=vitals.respiratory_rate,
|
respiratory_rate=vitals.respiratory_rate,
|
||||||
temperature=vitals.temperature,
|
temperature=vitals.temperature,
|
||||||
transmitted=True # Already received = transmitted
|
temperature2=getattr(vitals, 'temperature2', None),
|
||||||
|
temperature_diff=getattr(vitals, 'temperature_diff', None),
|
||||||
|
transmitted=True
|
||||||
)
|
)
|
||||||
db.add(reading)
|
db.add(reading)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(reading)
|
db.refresh(reading)
|
||||||
|
|
||||||
# 4. Broadcast to WebSocket clients
|
# 4. Broadcast to WebSocket clients
|
||||||
await ws_manager.broadcast_vitals(vitals.model_dump(mode="json"))
|
vitals_dict = vitals.model_dump(mode="json")
|
||||||
|
vitals_dict["mrn"] = mrn or (patient.mrn if patient else None)
|
||||||
logger.info(f"Push-vitals received for device {vitals.device_id}")
|
vitals_dict["patient_id"] = vitals_dict["mrn"] or vitals.patient_id
|
||||||
|
vitals_dict["name"] = vitals.patient_name or (patient.name if patient else None)
|
||||||
|
vitals_dict["patient_name"] = vitals_dict["name"]
|
||||||
|
vitals_dict["gender"] = vitals.patient_gender or (patient.gender if patient else None)
|
||||||
|
vitals_dict["patient_gender"] = vitals_dict["gender"]
|
||||||
|
vitals_dict["dob"] = vitals.patient_dob or (patient.dob if patient else None)
|
||||||
|
vitals_dict["patient_dob"] = vitals_dict["dob"]
|
||||||
|
vitals_dict["bed_id"] = vitals.bed_id or (patient.bed_id if patient else None)
|
||||||
|
vitals_dict["patient_type"] = getattr(vitals, "patient_type", None)
|
||||||
|
|
||||||
|
await ws_manager.broadcast_vitals(vitals_dict)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"reading_id": reading.id,
|
"reading_id": reading.id,
|
||||||
"device_id": vitals.device_id,
|
"device_id": vitals.device_id,
|
||||||
"patient_id": vitals.patient_id
|
"patient_id": vitals_dict["patient_id"]
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Push-vitals error: {e}")
|
logger.error(f"Push-vitals error: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
class NormalizedVitals(BaseModel):
|
class NormalizedVitals(BaseModel):
|
||||||
@@ -14,7 +14,18 @@ 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
|
||||||
|
temperature2: Optional[float] = None
|
||||||
|
temperature_diff: Optional[float] = None
|
||||||
present_fields: Optional[list] = None
|
present_fields: Optional[list] = None
|
||||||
|
# Raw waveform sample arrays (8-bit unsigned, 0-255)
|
||||||
|
ecg_wave: Optional[List[int]] = None # ECG waveform samples from 989-byte packet
|
||||||
|
pleth_wave: Optional[List[int]] = None # Pleth/SpO2 waveform samples from 286/288-byte packet
|
||||||
|
# Patient demographics parsed from HL7 PID/PV1 segments
|
||||||
|
patient_name: Optional[str] = None # PID-5: "Given Family"
|
||||||
|
patient_gender: Optional[str] = None # PID-8: M / F / U
|
||||||
|
patient_dob: Optional[str] = None # PID-7: YYYYMMDD
|
||||||
|
bed_id: Optional[str] = None # PV1-3: bed / room ID
|
||||||
|
patient_type: Optional[str] = None # Patient category: Adult / Pediatric / Neonatal
|
||||||
|
|
||||||
class DeviceStatus(BaseModel):
|
class DeviceStatus(BaseModel):
|
||||||
device_id: str
|
device_id: str
|
||||||
|
|||||||
Executable → Regular
+38
-5
@@ -5,11 +5,42 @@ set -e
|
|||||||
|
|
||||||
SERVICE_NAME="contec-forwarder"
|
SERVICE_NAME="contec-forwarder"
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
WORKING_DIR="/home/prathiyuman/Prathiyuman/ContecMonitor/fiveparaminte-main"
|
|
||||||
|
# Auto-detect script directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
if [ -f "${SCRIPT_DIR}/main.py" ]; then
|
||||||
|
WORKING_DIR="${SCRIPT_DIR}"
|
||||||
|
else
|
||||||
|
WORKING_DIR="${PWD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Detected Working Directory: ${WORKING_DIR} ==="
|
||||||
|
|
||||||
|
# Ensure virtual environment exists
|
||||||
|
if [ ! -f "${WORKING_DIR}/venv/bin/python" ]; then
|
||||||
|
echo "=== Virtual environment not found. Creating venv... ==="
|
||||||
|
python3 -m venv "${WORKING_DIR}/venv" || sudo apt-get update && sudo apt-get install -y python3-venv python3-pip && python3 -m venv "${WORKING_DIR}/venv"
|
||||||
|
fi
|
||||||
|
|
||||||
PYTHON_EXEC="${WORKING_DIR}/venv/bin/python"
|
PYTHON_EXEC="${WORKING_DIR}/venv/bin/python"
|
||||||
SCRIPT_PATH="${WORKING_DIR}/main.py"
|
SCRIPT_PATH="${WORKING_DIR}/main.py"
|
||||||
|
|
||||||
echo "=== Creating systemd service file ==="
|
# Ensure dependencies are installed
|
||||||
|
if [ -f "${WORKING_DIR}/requirements.txt" ]; then
|
||||||
|
echo "=== Installing/verifying dependencies ==="
|
||||||
|
"${WORKING_DIR}/venv/bin/pip" install --upgrade pip
|
||||||
|
"${WORKING_DIR}/venv/bin/pip" install -r "${WORKING_DIR}/requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Open firewall ports for Contec monitor (Port 511, 61501) and Web API (Port 8006)
|
||||||
|
if command -v ufw >/dev/null 2>&1; then
|
||||||
|
echo "=== Configuring firewall (UFW) for Port 511, 61501, and 8006 ==="
|
||||||
|
sudo ufw allow 511/tcp comment "Contec Patient Monitor CMS TCP 511" || true
|
||||||
|
sudo ufw allow 61501/tcp comment "Contec Patient Monitor CMS TCP 61501" || true
|
||||||
|
sudo ufw allow 8006/tcp comment "Contec Forwarder HTTP API" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Creating systemd service file: ${SERVICE_FILE} ==="
|
||||||
sudo bash -c "cat > ${SERVICE_FILE}" <<EOF
|
sudo bash -c "cat > ${SERVICE_FILE}" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Contec Patient Monitor Vital Signs Forwarder
|
Description=Contec Patient Monitor Vital Signs Forwarder
|
||||||
@@ -20,6 +51,7 @@ Type=simple
|
|||||||
User=root
|
User=root
|
||||||
WorkingDirectory=${WORKING_DIR}
|
WorkingDirectory=${WORKING_DIR}
|
||||||
Environment=PORT=8006
|
Environment=PORT=8006
|
||||||
|
Environment=PYTHONPATH=${WORKING_DIR}
|
||||||
ExecStart=${PYTHON_EXEC} ${SCRIPT_PATH}
|
ExecStart=${PYTHON_EXEC} ${SCRIPT_PATH}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -36,7 +68,7 @@ sudo systemctl daemon-reload
|
|||||||
echo "=== Enabling ${SERVICE_NAME} to start on boot ==="
|
echo "=== Enabling ${SERVICE_NAME} to start on boot ==="
|
||||||
sudo systemctl enable ${SERVICE_NAME}
|
sudo systemctl enable ${SERVICE_NAME}
|
||||||
|
|
||||||
echo "=== Starting ${SERVICE_NAME} service ==="
|
echo "=== Restarting ${SERVICE_NAME} service ==="
|
||||||
sudo systemctl restart ${SERVICE_NAME}
|
sudo systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
echo "=== Service Status ==="
|
echo "=== Service Status ==="
|
||||||
@@ -45,6 +77,7 @@ sudo systemctl status ${SERVICE_NAME} --no-pager
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=========================================================="
|
echo "=========================================================="
|
||||||
echo "SUCCESS: Contec Forwarder is now running in the background!"
|
echo "SUCCESS: Contec Forwarder is now running in the background!"
|
||||||
echo "To check live service logs, run:"
|
echo "Port 511 is open and listening for the Contec physical monitor."
|
||||||
echo " sudo journalctl -u contec-forwarder -f"
|
echo "To check live logs, run:"
|
||||||
|
echo " sudo journalctl -u ${SERVICE_NAME} -f"
|
||||||
echo "=========================================================="
|
echo "=========================================================="
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starting Bore Tunnel for Remote Contec Monitor (Hotspot)...
|
||||||
|
echo Server: 159.223.110.159:61501 -> Local Port: 511
|
||||||
|
echo.
|
||||||
|
echo On the Remote Monitor:
|
||||||
|
echo CMS Server IP = 159.223.110.159
|
||||||
|
echo CMS Port = 61501
|
||||||
|
echo.
|
||||||
|
bore.exe local 511 --to 159.223.110.159 --port 61501
|
||||||
|
pause
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* ContecVitalsClient - A reusable JavaScript client for the Contec CMS7000PLUS Patient Monitor.
|
|
||||||
* Works in Web, React, React Native, and Node.js environments.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Real-time streaming via WebSockets
|
|
||||||
* - Automatic HTTP polling fallback if WebSockets are blocked or disconnect
|
|
||||||
* - Exponential backoff auto-reconnection
|
|
||||||
* - Lifecycle callbacks for UI updates
|
|
||||||
*/
|
|
||||||
class ContecVitalsClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} config - Client configuration options
|
|
||||||
* @param {string} config.baseUrl - Server URL (e.g. 'https://contectfiveparamonitor.onrender.com')
|
|
||||||
* @param {string} [config.deviceId] - Optional device filter (e.g. 'CMS7000PLUS_192.168.100.120')
|
|
||||||
* @param {number} [config.pollingInterval=3000] - Polling interval in ms if falling back to HTTP
|
|
||||||
* @param {function} [config.onVitals] - Callback when new vitals data arrives
|
|
||||||
* @param {function} [config.onStatusChange] - Callback when connection status changes ('idle', 'connecting', 'connected', 'polling', 'disconnected')
|
|
||||||
* @param {function} [config.onError] - Callback on error
|
|
||||||
*/
|
|
||||||
constructor(config) {
|
|
||||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
||||||
this.deviceId = config.deviceId || null;
|
|
||||||
this.pollingInterval = config.pollingInterval || 3000;
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
this.onVitals = config.onVitals || (() => {});
|
|
||||||
this.onStatusChange = config.onStatusChange || (() => {});
|
|
||||||
this.onError = config.onError || (() => {});
|
|
||||||
|
|
||||||
// State management
|
|
||||||
this.ws = null;
|
|
||||||
this.pollTimer = null;
|
|
||||||
this.status = 'idle';
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.maxReconnectAttempts = 5;
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
this.isStarted = false;
|
|
||||||
this.lastVitalsTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start listening for vital signs.
|
|
||||||
*/
|
|
||||||
start() {
|
|
||||||
if (this.isStarted) return;
|
|
||||||
this.isStarted = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this._setStatus('connecting');
|
|
||||||
this._connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop listening and clean up all connections/timers.
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
this.isStarted = false;
|
|
||||||
this._clearPollTimer();
|
|
||||||
this._closeWebSocket();
|
|
||||||
this._setStatus('idle');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change current status and trigger callback.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_setStatus(newStatus) {
|
|
||||||
if (this.status !== newStatus) {
|
|
||||||
this.status = newStatus;
|
|
||||||
this.onStatusChange(newStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the base URL to construct the WS URL.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_getWebSocketUrl() {
|
|
||||||
const wsProto = this.baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
||||||
const cleanUrl = this.baseUrl.replace(/^(https?:\/\/)/, '');
|
|
||||||
return `${wsProto}://${cleanUrl}/ws/vitals`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_connectWebSocket() {
|
|
||||||
this._clearPollTimer();
|
|
||||||
const wsUrl = this._getWebSocketUrl();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
console.log('ContecVitalsClient: WebSocket connected');
|
|
||||||
this._setStatus('connected');
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
this._processIncomingVitals(data);
|
|
||||||
} catch (err) {
|
|
||||||
this.onError(new Error('Failed to parse incoming WS message: ' + err.message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (err) => {
|
|
||||||
this.onError(err);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
this.ws = null;
|
|
||||||
if (!this.isStarted) return;
|
|
||||||
|
|
||||||
console.log(`ContecVitalsClient: WebSocket closed (code: ${event.code})`);
|
|
||||||
|
|
||||||
// Attempt reconnection
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
||||||
console.log(`ContecVitalsClient: Reconnecting in ${delay}ms (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
|
||||||
this._setStatus('connecting');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.isStarted) this._connectWebSocket();
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
// Exceeded Max WebSocket retries -> Fallback to HTTP Polling
|
|
||||||
console.log('ContecVitalsClient: WebSocket connection limit exceeded. Falling back to HTTP polling.');
|
|
||||||
this._startPolling();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
this.onError(err);
|
|
||||||
this._startPolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket connection cleanly.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_closeWebSocket() {
|
|
||||||
if (this.ws) {
|
|
||||||
// Remove event listeners to prevent onclose reconnect logic
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.onclose = null;
|
|
||||||
try {
|
|
||||||
this.ws.close();
|
|
||||||
} catch (e) {}
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start HTTP Polling.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_startPolling() {
|
|
||||||
this._closeWebSocket();
|
|
||||||
this._setStatus('polling');
|
|
||||||
this._poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform single HTTP poll.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async _poll() {
|
|
||||||
if (!this.isStarted || this.status !== 'polling') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `${this.baseUrl}/api/latest-readings?limit=1` + (this.deviceId ? `&device_id=${encodeURIComponent(this.deviceId)}` : '');
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readings = await response.json();
|
|
||||||
if (Array.isArray(readings) && readings.length > 0) {
|
|
||||||
this._processIncomingVitals(readings[0]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('ContecVitalsClient polling error:', err.message);
|
|
||||||
this.onError(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule next poll
|
|
||||||
this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the HTTP polling timer.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_clearPollTimer() {
|
|
||||||
if (this.pollTimer) {
|
|
||||||
clearTimeout(this.pollTimer);
|
|
||||||
this.pollTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes and emits the vitals data payload.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_processIncomingVitals(data) {
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
// Filter by device if configured
|
|
||||||
if (this.deviceId && data.device_id !== this.deviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize property names and types
|
|
||||||
const normalized = {
|
|
||||||
deviceId: data.device_id,
|
|
||||||
patientId: data.patient_id,
|
|
||||||
timestamp: data.timestamp,
|
|
||||||
heartRate: typeof data.heart_rate === 'number' ? Math.round(data.heart_rate) : null,
|
|
||||||
spo2: typeof data.spo2 === 'number' ? Math.round(data.spo2) : null,
|
|
||||||
temperature: typeof data.temperature === 'number' ? parseFloat(data.temperature.toFixed(1)) : null,
|
|
||||||
systolicBp: typeof data.systolic_bp === 'number' ? Math.round(data.systolic_bp) : null,
|
|
||||||
diastolicBp: typeof data.diastolic_bp === 'number' ? Math.round(data.diastolic_bp) : null,
|
|
||||||
mapBp: typeof data.map_bp === 'number' ? Math.round(data.map_bp) : null,
|
|
||||||
respiratoryRate: typeof data.respiratory_rate === 'number' ? Math.round(data.respiratory_rate) : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate BP display string helper
|
|
||||||
normalized.bloodPressureDisplay = (normalized.systolicBp && normalized.diastolicBp)
|
|
||||||
? `${normalized.systolicBp}/${normalized.diastolicBp}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this.onVitals(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for ES6/Node compatibility
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = ContecVitalsClient;
|
|
||||||
} else if (typeof window !== 'undefined') {
|
|
||||||
window.ContecVitalsClient = ContecVitalsClient;
|
|
||||||
}
|
|
||||||
+3692
-685
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user