- schemas.py: Add ecg_wave and pleth_wave List[int] fields to NormalizedVitals - contec_parser.py: Extract raw 8-bit waveform samples from binary packets - 286/288-byte (Subtype 21): bytes[8..263] = Pleth waveform (256 samples) - 989-byte (Subtype 20): bytes[8..903] = ECG waveform, downsampled 4x -> 224 pts - dashboard.html: Full clinical monitor redesign - Scrolling sweep waveform engine (ECG green, Pleth cyan, Resp amber) - Real samples fed from WebSocket ecg_wave / pleth_wave arrays - Synthetic fallbacks when no live data - Right panel: HR, SpO2, RR, NIBP, Temp with range badges - MRN-only verification overlay - Minimal-mode support for iframe embedding
599 lines
21 KiB
HTML
599 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Contec Vital Signs Monitor</title>
|
|
<meta name="description" content="Real-time patient vital signs monitoring — Contec CMS8500">
|
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<style>
|
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}
|
|
|
|
:root{
|
|
--ecg-color:#00ff41;
|
|
--pleth-color:#00e5ff;
|
|
--resp-color:#ffb300;
|
|
--hr-color:#00ff41;
|
|
--spo2-color:#00e5ff;
|
|
--rr-color:#ffb300;
|
|
--nibp-color:#ff6b6b;
|
|
--temp-color:#ff9500;
|
|
--bg:#0a0e0a;
|
|
--panel:#111611;
|
|
--border:#1a2e1a;
|
|
--text:#e0ffe0;
|
|
--dim:#4a6a4a;
|
|
}
|
|
|
|
body{
|
|
font-family:'Share Tech Mono',monospace;
|
|
background:var(--bg);
|
|
color:var(--text);
|
|
height:100vh;
|
|
overflow:hidden;
|
|
display:flex;
|
|
flex-direction:column;
|
|
}
|
|
|
|
/* ── Verification Overlay ── */
|
|
#verificationOverlay{
|
|
position:fixed;inset:0;z-index:1000;
|
|
background:rgba(0,0,0,0.92);
|
|
display:flex;align-items:center;justify-content:center;
|
|
}
|
|
.ver-card{
|
|
background:#0d1a0d;border:1px solid #1a3a1a;
|
|
border-radius:12px;padding:40px;text-align:center;max-width:420px;
|
|
}
|
|
.ver-card .icon{font-size:56px;margin-bottom:16px;}
|
|
.ver-card h2{font-family:'Orbitron',sans-serif;font-size:18px;color:#00ff41;margin-bottom:10px;}
|
|
.ver-card p{color:#4a6a4a;font-size:13px;line-height:1.6;}
|
|
.spinner{
|
|
width:48px;height:48px;border:3px solid #1a3a1a;
|
|
border-top-color:#00ff41;border-radius:50%;
|
|
margin:0 auto 16px;animation:spin 1s linear infinite;
|
|
}
|
|
@keyframes spin{to{transform:rotate(360deg);}}
|
|
|
|
/* ── Top Status Bar ── */
|
|
#statusBar{
|
|
background:#050a05;
|
|
border-bottom:1px solid var(--border);
|
|
display:flex;align-items:center;justify-content:space-between;
|
|
padding:4px 12px;height:32px;flex-shrink:0;
|
|
}
|
|
.sb-left{display:flex;align-items:center;gap:16px;}
|
|
.sb-patient{font-family:'Orbitron',sans-serif;font-size:11px;color:#00ff41;letter-spacing:1px;}
|
|
.sb-device{font-size:11px;color:var(--dim);}
|
|
.sb-right{display:flex;align-items:center;gap:16px;}
|
|
.conn-dot{
|
|
width:8px;height:8px;border-radius:50%;background:#333;
|
|
display:inline-block;margin-right:4px;
|
|
}
|
|
.conn-dot.online{background:#00ff41;box-shadow:0 0 6px #00ff41;}
|
|
.conn-dot.offline{background:#ff4444;box-shadow:0 0 6px #ff4444;}
|
|
#clockDisplay{font-size:11px;color:#00ff41;font-family:'Orbitron',sans-serif;}
|
|
|
|
/* ── Main Layout: waveforms left, params right ── */
|
|
#monitorBody{
|
|
flex:1;display:flex;overflow:hidden;
|
|
}
|
|
|
|
/* ── Waveform Column ── */
|
|
#waveformCol{
|
|
flex:1;display:flex;flex-direction:column;
|
|
border-right:1px solid var(--border);overflow:hidden;
|
|
}
|
|
|
|
.waveform-strip{
|
|
flex:1;position:relative;
|
|
border-bottom:1px solid var(--border);
|
|
overflow:hidden;
|
|
}
|
|
.waveform-strip:last-child{border-bottom:none;}
|
|
|
|
.strip-label{
|
|
position:absolute;top:6px;left:10px;z-index:2;
|
|
font-family:'Orbitron',sans-serif;font-size:10px;
|
|
letter-spacing:2px;font-weight:700;
|
|
}
|
|
.strip-label.ecg{color:var(--ecg-color);}
|
|
.strip-label.pleth{color:var(--pleth-color);}
|
|
.strip-label.resp{color:var(--resp-color);}
|
|
|
|
.strip-canvas{
|
|
position:absolute;inset:0;width:100%;height:100%;
|
|
}
|
|
|
|
/* ── Parameter Column ── */
|
|
#paramCol{
|
|
width:220px;flex-shrink:0;
|
|
display:flex;flex-direction:column;
|
|
background:var(--panel);
|
|
}
|
|
|
|
.param-box{
|
|
flex:1;border-bottom:1px solid var(--border);
|
|
display:flex;flex-direction:column;
|
|
justify-content:center;padding:8px 14px;
|
|
position:relative;cursor:default;
|
|
transition:background 0.2s;
|
|
}
|
|
.param-box:last-child{border-bottom:none;}
|
|
.param-box:hover{background:#151f15;}
|
|
|
|
.param-label{
|
|
font-family:'Orbitron',sans-serif;
|
|
font-size:9px;letter-spacing:2px;font-weight:700;
|
|
margin-bottom:2px;
|
|
}
|
|
.param-value{
|
|
font-family:'Orbitron',sans-serif;
|
|
font-size:36px;font-weight:900;line-height:1;
|
|
}
|
|
.param-unit{font-size:11px;font-weight:400;margin-left:4px;opacity:0.7;}
|
|
.param-sub{font-size:11px;color:var(--dim);margin-top:2px;}
|
|
.param-badge{
|
|
position:absolute;top:8px;right:10px;
|
|
font-size:9px;padding:2px 6px;border-radius:3px;
|
|
border:1px solid;letter-spacing:1px;
|
|
}
|
|
.badge-ok{color:#00ff41;border-color:#00ff41;background:rgba(0,255,65,0.08);}
|
|
.badge-warn{color:#ffb300;border-color:#ffb300;background:rgba(255,179,0,0.08);}
|
|
.badge-off{color:#333;border-color:#333;background:transparent;}
|
|
|
|
.hr-box .param-label{color:var(--hr-color);}
|
|
.hr-box .param-value{color:var(--hr-color);text-shadow:0 0 20px rgba(0,255,65,0.5);}
|
|
.spo2-box .param-label{color:var(--spo2-color);}
|
|
.spo2-box .param-value{color:var(--spo2-color);text-shadow:0 0 20px rgba(0,229,255,0.5);}
|
|
.rr-box .param-label{color:var(--rr-color);}
|
|
.rr-box .param-value{color:var(--rr-color);text-shadow:0 0 20px rgba(255,179,0,0.4);}
|
|
.nibp-box .param-label{color:var(--nibp-color);}
|
|
.nibp-box .param-value{color:var(--nibp-color);font-size:26px;}
|
|
.temp-box .param-label{color:var(--temp-color);}
|
|
.temp-box .param-value{color:var(--temp-color);font-size:30px;}
|
|
|
|
/* ── Scrolling waveform sweep line ── */
|
|
.sweep-cursor{
|
|
position:absolute;top:0;bottom:0;width:2px;
|
|
background:rgba(0,0,0,0.7);pointer-events:none;z-index:3;
|
|
}
|
|
|
|
/* ── Minimal mode ── */
|
|
body.minimal-mode #statusBar{display:none;}
|
|
body.minimal-mode #monitorBody{height:100vh;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Verification Overlay -->
|
|
<div id="verificationOverlay">
|
|
<div class="ver-card" id="verCard">
|
|
<div class="spinner" id="verSpinner"></div>
|
|
<h2 id="verTitle">CONNECTING</h2>
|
|
<p id="verMsg">Verifying patient identity against monitor records…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Bar -->
|
|
<div id="statusBar">
|
|
<div class="sb-left">
|
|
<span class="sb-patient" id="sbPatient">— PATIENT —</span>
|
|
<span class="sb-device" id="sbDevice">No device</span>
|
|
</div>
|
|
<div class="sb-right">
|
|
<span><span class="conn-dot offline" id="connDot"></span><span id="connText" style="font-size:11px;color:#4a6a4a;">OFFLINE</span></span>
|
|
<span id="clockDisplay">--:--:--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monitor Body -->
|
|
<div id="monitorBody">
|
|
|
|
<!-- Waveform strips -->
|
|
<div id="waveformCol">
|
|
<div class="waveform-strip" id="strip-ecg">
|
|
<div class="strip-label ecg">I ECG</div>
|
|
<canvas class="strip-canvas" id="canvas-ecg"></canvas>
|
|
</div>
|
|
<div class="waveform-strip" id="strip-pleth">
|
|
<div class="strip-label pleth">PLETH</div>
|
|
<canvas class="strip-canvas" id="canvas-pleth"></canvas>
|
|
</div>
|
|
<div class="waveform-strip" id="strip-resp">
|
|
<div class="strip-label resp">RESP</div>
|
|
<canvas class="strip-canvas" id="canvas-resp"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Parameter panel -->
|
|
<div id="paramCol">
|
|
|
|
<div class="param-box hr-box">
|
|
<div class="param-label">HEART RATE</div>
|
|
<div class="param-value" id="val-hr">---<span class="param-unit">bpm</span></div>
|
|
<div class="param-sub">ECG Lead I</div>
|
|
<div class="param-badge badge-off" id="badge-hr">WAIT</div>
|
|
</div>
|
|
|
|
<div class="param-box spo2-box">
|
|
<div class="param-label">SpO₂</div>
|
|
<div class="param-value" id="val-spo2">---<span class="param-unit">%</span></div>
|
|
<div class="param-sub" id="sub-spo2">Pleth waveform</div>
|
|
<div class="param-badge badge-off" id="badge-spo2">WAIT</div>
|
|
</div>
|
|
|
|
<div class="param-box rr-box">
|
|
<div class="param-label">RESP RATE</div>
|
|
<div class="param-value" id="val-rr">---<span class="param-unit">/min</span></div>
|
|
<div class="param-sub">Thoracic impedance</div>
|
|
<div class="param-badge badge-off" id="badge-rr">WAIT</div>
|
|
</div>
|
|
|
|
<div class="param-box nibp-box">
|
|
<div class="param-label">NIBP</div>
|
|
<div class="param-value" id="val-nibp">---<span class="param-unit">mmHg</span></div>
|
|
<div class="param-sub" id="sub-nibp">Sys / Dia (MAP)</div>
|
|
<div class="param-badge badge-off" id="badge-nibp">WAIT</div>
|
|
</div>
|
|
|
|
<div class="param-box temp-box">
|
|
<div class="param-label">TEMP</div>
|
|
<div class="param-value" id="val-temp">---<span class="param-unit">°C</span></div>
|
|
<div class="param-sub">Body temperature</div>
|
|
<div class="param-badge badge-off" id="badge-temp">WAIT</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ════════════════════════════════════════════════════
|
|
// CONFIG
|
|
// ════════════════════════════════════════════════════
|
|
const params = new URLSearchParams(location.search);
|
|
const MRN = params.get('mrn') || '';
|
|
const isMinimal = params.get('minimal') === 'true' || params.get('view') === 'minimal';
|
|
const DEVICE_PARAM = params.get('device') || '';
|
|
|
|
if (isMinimal) document.body.classList.add('minimal-mode');
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// CLOCK
|
|
// ════════════════════════════════════════════════════
|
|
function updateClock(){
|
|
const n = new Date();
|
|
document.getElementById('clockDisplay').textContent =
|
|
n.toLocaleTimeString('en-GB',{hour12:false});
|
|
}
|
|
setInterval(updateClock, 1000);
|
|
updateClock();
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// CANVAS WAVEFORM ENGINE
|
|
// Continuous right-to-left scrolling sweep
|
|
// ════════════════════════════════════════════════════
|
|
class WaveformTrack {
|
|
constructor(canvasId, color, baseline=0.5, amplitude=0.38) {
|
|
this.canvas = document.getElementById(canvasId);
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.color = color;
|
|
this.baseline = baseline; // 0..1 from top
|
|
this.amplitude = amplitude; // fraction of height
|
|
this.samples = []; // raw 0-255 bytes from monitor
|
|
this.drawBuf = []; // processed display values (0..1 Y position)
|
|
this.MAX_DRAW = 600; // max points in draw buffer
|
|
this.sweepPos = 0; // current write head X pixel
|
|
this.lastWidth = 0;
|
|
this.lastHeight= 0;
|
|
this.animating = false;
|
|
this.synthPhase = 0;
|
|
this.synthType = 'flat';
|
|
this.rrValue = null;
|
|
}
|
|
|
|
resize() {
|
|
const w = this.canvas.offsetWidth;
|
|
const h = this.canvas.offsetHeight;
|
|
if (w !== this.lastWidth || h !== this.lastHeight) {
|
|
this.canvas.width = w;
|
|
this.canvas.height = h;
|
|
this.lastWidth = w; this.lastHeight = h;
|
|
this.drawBuf = new Array(w).fill(null);
|
|
this.sweepPos = 0;
|
|
}
|
|
}
|
|
|
|
// Push raw byte samples (0-255) from monitor packet
|
|
pushSamples(arr) {
|
|
if (!arr || arr.length === 0) return;
|
|
// normalise 0-255 → 0..1 Y position (inverted: 0=top)
|
|
for (const s of arr) {
|
|
const norm = (255 - s) / 255; // flip: high byte = up
|
|
const y = this.baseline + (norm - 0.5) * this.amplitude * 2;
|
|
this.samples.push(Math.max(0.02, Math.min(0.98, y)));
|
|
}
|
|
}
|
|
|
|
// Drain one sample into draw buffer at sweepPos
|
|
tick() {
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
if (!w || !h) return;
|
|
|
|
// blank 6px ahead of sweep cursor
|
|
const eraseW = 8;
|
|
for (let e = 0; e < eraseW; e++) {
|
|
const ex = (this.sweepPos + e) % w;
|
|
if (this.drawBuf[ex] !== undefined) this.drawBuf[ex] = null;
|
|
}
|
|
|
|
let yNorm;
|
|
if (this.samples.length > 0) {
|
|
yNorm = this.samples.shift();
|
|
} else {
|
|
// synthetic fallback
|
|
yNorm = this._synth();
|
|
}
|
|
this.drawBuf[this.sweepPos] = yNorm;
|
|
this.sweepPos = (this.sweepPos + 1) % w;
|
|
}
|
|
|
|
_synth() {
|
|
this.synthPhase += 0.05;
|
|
const t = this.synthPhase;
|
|
if (this.synthType === 'ecg') {
|
|
const p = (t % (Math.PI * 2)) / (Math.PI * 2);
|
|
if (p > 0.35 && p < 0.38) return this.baseline - 0.32;
|
|
if (p > 0.38 && p < 0.42) return this.baseline + 0.12;
|
|
return this.baseline + Math.sin(t * 0.3) * 0.02;
|
|
} else if (this.synthType === 'pleth') {
|
|
return this.baseline + Math.sin(t * 0.8) * 0.28;
|
|
} else if (this.synthType === 'resp') {
|
|
const rr = this.rrValue || 15;
|
|
const speed = rr / 60 * 0.025;
|
|
return this.baseline + Math.sin(this.synthPhase * speed * 20) * 0.22;
|
|
}
|
|
return this.baseline; // flat
|
|
}
|
|
|
|
draw() {
|
|
const ctx = this.ctx;
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
if (!w || !h) return;
|
|
|
|
// Clear
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Grid lines (subtle)
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x < w; x += 50) {
|
|
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
|
|
}
|
|
for (let y = 0; y < h; y += h/4) {
|
|
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
|
}
|
|
|
|
// Waveform
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = this.color;
|
|
ctx.lineWidth = 1.8;
|
|
ctx.shadowColor = this.color;
|
|
ctx.shadowBlur = 6;
|
|
|
|
let started = false;
|
|
for (let x = 0; x < w; x++) {
|
|
const idx = (this.sweepPos + x) % w;
|
|
const yNorm = this.drawBuf[idx];
|
|
if (yNorm === null || yNorm === undefined) {
|
|
started = false; continue;
|
|
}
|
|
const px = x;
|
|
const py = yNorm * h;
|
|
if (!started) { ctx.moveTo(px, py); started = true; }
|
|
else { ctx.lineTo(px, py); }
|
|
}
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Sweep cursor (black erase bar)
|
|
ctx.fillStyle = 'rgba(10,14,10,0.85)';
|
|
ctx.fillRect(((this.sweepPos + w - 4) % w), 0, 6, h);
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// TRACKS
|
|
// ════════════════════════════════════════════════════
|
|
const ecgTrack = new WaveformTrack('canvas-ecg', '#00ff41', 0.5, 0.42);
|
|
const plethTrack = new WaveformTrack('canvas-pleth','#00e5ff', 0.5, 0.38);
|
|
const respTrack = new WaveformTrack('canvas-resp', '#ffb300', 0.5, 0.30);
|
|
|
|
ecgTrack.synthType = 'ecg';
|
|
plethTrack.synthType = 'pleth';
|
|
respTrack.synthType = 'resp';
|
|
|
|
// Resize observer
|
|
const ro = new ResizeObserver(() => {
|
|
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
|
|
});
|
|
ro.observe(document.getElementById('waveformCol'));
|
|
|
|
// Animation loop
|
|
const TICKS_PER_FRAME = 3; // speed of sweep
|
|
function loop() {
|
|
ecgTrack.resize(); plethTrack.resize(); respTrack.resize();
|
|
for (let i=0;i<TICKS_PER_FRAME;i++){
|
|
ecgTrack.tick(); plethTrack.tick(); respTrack.tick();
|
|
}
|
|
ecgTrack.draw(); plethTrack.draw(); respTrack.draw();
|
|
requestAnimationFrame(loop);
|
|
}
|
|
requestAnimationFrame(loop);
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// PARAM UPDATE
|
|
// ════════════════════════════════════════════════════
|
|
function setParam(id, value, unit, badgeId, lo, hi) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
if (value === null || value === undefined) {
|
|
el.innerHTML = `---<span class="param-unit">${unit}</span>`;
|
|
const b = document.getElementById(badgeId);
|
|
if (b){ b.textContent='WAIT'; b.className='param-badge badge-off'; }
|
|
return;
|
|
}
|
|
const v = parseFloat(value);
|
|
el.innerHTML = `${Number.isInteger(v)?v:v.toFixed(1)}<span class="param-unit">${unit}</span>`;
|
|
const b = document.getElementById(badgeId);
|
|
if (b) {
|
|
if (v >= lo && v <= hi){ b.textContent='NML'; b.className='param-badge badge-ok'; }
|
|
else { b.textContent='WARN'; b.className='param-badge badge-warn'; }
|
|
}
|
|
}
|
|
|
|
function updateParams(d) {
|
|
setParam('val-hr', d.heart_rate, 'bpm', 'badge-hr', 60, 100);
|
|
setParam('val-spo2', d.spo2, '%', 'badge-spo2', 95, 100);
|
|
setParam('val-rr', d.respiratory_rate, '/min', 'badge-rr', 12, 20);
|
|
setParam('val-temp', d.temperature, '°C', 'badge-temp', 36, 38.5);
|
|
|
|
if (d.systolic_bp != null) {
|
|
const v = `${Math.round(d.systolic_bp)}/${Math.round(d.diastolic_bp||0)}`;
|
|
const m = d.map_bp ? ` (${Math.round(d.map_bp)})` : '';
|
|
document.getElementById('val-nibp').innerHTML =
|
|
`${v}<span class="param-unit">mmHg</span>`;
|
|
document.getElementById('sub-nibp').textContent = `MAP${m}`;
|
|
const b = document.getElementById('badge-nibp');
|
|
const ok = d.systolic_bp>=90&&d.systolic_bp<=140;
|
|
b.textContent = ok ? 'NML' : 'WARN';
|
|
b.className = 'param-badge ' + (ok ? 'badge-ok' : 'badge-warn');
|
|
}
|
|
|
|
// Feed resp synth with RR
|
|
if (d.respiratory_rate) {
|
|
respTrack.rrValue = d.respiratory_rate;
|
|
respTrack.synthType = 'resp';
|
|
}
|
|
|
|
// Status bar
|
|
if (d.patient_id) {
|
|
document.getElementById('sbPatient').textContent =
|
|
d.patient_name ? d.patient_name.toUpperCase() : `MRN ${d.patient_id}`;
|
|
}
|
|
if (d.device_id) {
|
|
document.getElementById('sbDevice').textContent = d.device_id;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// VERIFICATION
|
|
// ════════════════════════════════════════════════════
|
|
let verified = false;
|
|
let deviceId = DEVICE_PARAM || null;
|
|
const overlay = document.getElementById('verificationOverlay');
|
|
const verCard = document.getElementById('verCard');
|
|
const verSpinner= document.getElementById('verSpinner');
|
|
const verTitle = document.getElementById('verTitle');
|
|
const verMsg = document.getElementById('verMsg');
|
|
|
|
function showOverlay(title, msg, icon=null) {
|
|
overlay.style.display = 'flex';
|
|
verTitle.textContent = title;
|
|
verMsg.textContent = msg;
|
|
if (icon) {
|
|
verSpinner.style.display='none';
|
|
const ic = verCard.querySelector('.icon') || (() => {
|
|
const d=document.createElement('div');d.className='icon';
|
|
verCard.prepend(d);return d;
|
|
})();
|
|
ic.textContent = icon;
|
|
}
|
|
}
|
|
|
|
function hideOverlay() {
|
|
overlay.style.display='none';
|
|
verified=true;
|
|
}
|
|
|
|
async function verifyMRN() {
|
|
if (!MRN) {
|
|
showOverlay('NO MRN','No MRN provided in URL. Use ?mrn=XXXXXXXX','🔒');
|
|
return;
|
|
}
|
|
showOverlay('VERIFYING',`Matching MRN ${MRN} against monitor records…`);
|
|
try {
|
|
const res = await fetch(`/api/match-patient?mrn=${encodeURIComponent(MRN)}`);
|
|
const data = await res.json();
|
|
if (data.matched) {
|
|
deviceId = data.device_id || deviceId;
|
|
if (deviceId) {
|
|
document.getElementById('sbDevice').textContent = deviceId;
|
|
}
|
|
hideOverlay();
|
|
connectWS();
|
|
} else {
|
|
showOverlay('ACCESS RESTRICTED', data.reason || 'MRN does not match monitor records.','🔐');
|
|
}
|
|
} catch(e) {
|
|
showOverlay('CONNECTION ERROR','Cannot reach verification server. Retrying…','⚠️');
|
|
setTimeout(verifyMRN, 5000);
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// WEBSOCKET
|
|
// ════════════════════════════════════════════════════
|
|
let ws = null;
|
|
let wsRetry = 0;
|
|
const connDot = document.getElementById('connDot');
|
|
const connText = document.getElementById('connText');
|
|
|
|
function setOnline(on) {
|
|
connDot.className = 'conn-dot ' + (on ? 'online' : 'offline');
|
|
connText.textContent = on ? 'LIVE' : 'OFFLINE';
|
|
connText.style.color = on ? '#00ff41' : '#4a6a4a';
|
|
}
|
|
|
|
function connectWS() {
|
|
const proto = location.protocol==='https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${location.host}/ws`;
|
|
ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => { setOnline(true); wsRetry=0; };
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const d = JSON.parse(ev.data);
|
|
|
|
// Filter by device if known
|
|
if (deviceId && d.device_id && d.device_id !== deviceId) return;
|
|
|
|
// Push waveform samples if present
|
|
if (d.ecg_wave && d.ecg_wave.length) ecgTrack.pushSamples(d.ecg_wave);
|
|
if (d.pleth_wave && d.pleth_wave.length) plethTrack.pushSamples(d.pleth_wave);
|
|
|
|
// Update numeric params
|
|
updateParams(d);
|
|
} catch(e){}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setOnline(false);
|
|
const delay = Math.min(1000 * 2**wsRetry++, 30000);
|
|
setTimeout(connectWS, delay);
|
|
};
|
|
|
|
ws.onerror = () => ws.close();
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════
|
|
// BOOT
|
|
// ════════════════════════════════════════════════════
|
|
verifyMRN();
|
|
</script>
|
|
</body>
|
|
</html>
|