Real-time vital-signs streaming platform - edge sensor to any browser, anywhere.
VitalsLink is an end-to-end telemetry platform for streaming patient vital signs from an edge sensor to clinicians in real time. A DS18B20 thermometer wired to a Raspberry Pi samples once per second over 1-Wire; the Pi POSTs each reading to a Flask ingest server on the same LAN; the server pushes the reading over WebSocket to every connected dashboard — locally and, via Cloudflare Tunnel, to any clinician on the public internet.
I built it as a prototype of a production architecture rather than a one-off script. The pieces are deliberately the same shapes a real medical-IoT stack uses: edge acquisition with CRC-validated sensor reads, an HTTP/JSON ingest boundary, a fan-out broker for live clients, a bounded server-side history buffer so late-joining clients aren't staring at an empty chart, and a clinical decision layer that classifies each reading against axillary reference ranges.
The dashboard renders a single live reading, a session min/max/mean strip, a 2-minute live trace with fever and febrile thresholds overlaid, and a status banner that flips from Normal to Low-grade fever to Febrile (or Hypothermia) as values cross the bands. It's mobile-responsive, supports °C/°F switching, and survives WebSocket-hostile networks via Socket.IO's HTTP-polling fallback.
What it is not yet: HIPAA-deployable. The Roadmap section is explicit about what would need to change before that's true — auth, TLS, persistence, audit.
- 1 Hz axillary thermometry from a DS18B20 over 1-Wire, with CRC retry on bad reads (
pi_publisher.py:60-85). - HTTP/JSON ingest with strict payload validation (
server.py:60-78). - Real-time WebSocket fan-out to N concurrent browsers via Flask-SocketIO (
server.py:76). - 5-minute server-side ring buffer plus
GET /api/historybackfill so new clients see context immediately (server.py:34-35,server.py:81-84,templates/dashboard.html:768-774). - Clinical classification at the UI — Hypothermia / Normal / Low-grade fever / Febrile (
templates/dashboard.html:604-609). - Mobile-responsive dashboard with °C/°F toggle, session min/max/mean, and a live chart with fever and febrile threshold overlays.
- Automatic reconnect and HTTP long-poll fallback when WebSockets are blocked (Socket.IO default).
- Public exposure via Cloudflare Tunnel — no signup, no port-forwarding, WebSocket-clean.
flowchart LR
subgraph EDGE["Edge · Raspberry Pi"]
S["DS18B20<br/>1-Wire sensor"]
P["pi_publisher.py<br/>1 Hz poller"]
S -->|w1_slave file| P
end
subgraph LAN["Hospital LAN"]
F["server.py<br/>Flask ingest"]
H[("Ring buffer<br/>300 readings")]
B["Socket.IO<br/>broadcaster"]
F --> H
F --> B
end
subgraph CLIENTS["Clients"]
D1["Local browser<br/>dashboard.html"]
D2["Remote browser<br/>via tunnel"]
end
P -->|"HTTP POST /api/temperature<br/>JSON, every 1 s"| F
B -->|"WebSocket<br/>temperature_update"| D1
B -->|"WebSocket<br/>temperature_update"| D2
H -->|"GET /api/history<br/>on connect"| D1
H -->|"GET /api/history<br/>on connect"| D2
sequenceDiagram
autonumber
participant Sensor as DS18B20
participant Pi as pi_publisher.py
participant Server as server.py
participant Buf as Ring buffer
participant SIO as Socket.IO
participant UI as dashboard.html
loop every 1 s
Pi->>Sensor: read /sys/.../w1_slave
Sensor-->>Pi: lines (CRC byte + t=...)
alt CRC = YES
Pi->>Server: POST /api/temperature {temperature_c, ts, patient_id}
Server->>Buf: append reading
Server->>SIO: emit "temperature_update"
SIO-->>UI: payload (°C, °F, time_str, patient_id)
UI->>UI: classify · update reading, chart, stats
Server-->>Pi: 200 OK
else CRC fails
Pi->>Pi: retry up to 5x, 200 ms apart
end
end
flowchart TB
subgraph WARD["Hospital LAN (private)"]
PI["Raspberry Pi<br/>+ DS18B20"]
SRV["Laptop<br/>Flask + Socket.IO<br/>:5000"]
LOCAL[Bedside browser]
PI -->|LAN, HTTP| SRV
SRV -->|WS| LOCAL
end
subgraph EDGE_NET["Cloudflare edge"]
TUN["cloudflared<br/>tunnel endpoint"]
end
subgraph REMOTE["Remote clinicians"]
R1[Laptop · home network]
R2[Phone · cellular]
end
SRV -.->|"outbound tunnel<br/>no inbound port"| TUN
TUN -->|HTTPS + WSS| R1
TUN -->|HTTPS + WSS| R2
classDef trust fill:#0d1117,stroke:#34d399,color:#e6edf3;
classDef untrust fill:#0d1117,stroke:#f87171,color:#e6edf3;
class WARD trust
class REMOTE untrust
The dashed tunnel link marks the trust boundary: inside the ward is trusted LAN, outside the tunnel is the public internet.
| Layer | Technology |
|---|---|
| Edge | Raspberry Pi · DS18B20 · 1-Wire (w1-gpio, w1-therm) · Python requests |
| Transport | HTTP/JSON ingest · WebSocket (Socket.IO) fan-out |
| Server | Python 3.9+ · Flask 3 · Flask-SocketIO 5 · simple-websocket |
| Client | Vanilla JS · Chart.js 4 · Socket.IO client 4 |
| Public exposure | Cloudflare Tunnel (primary) · ngrok (alternative) |
| Path | Runs on | Purpose |
|---|---|---|
pi_publisher.py |
Raspberry Pi | Reads DS18B20 every second, POSTs JSON to the server. |
server.py |
Laptop / host | Flask + Socket.IO. Ingests readings, fans out to every connected browser, serves history backfill. |
templates/dashboard.html |
Served by server.py |
The clinician dashboard — live reading, status, chart, stats. |
requirements.txt |
Server host | Python dependencies. |
LICENSE |
— | Apache 2.0. |
Ingest endpoint. Called by the edge publisher.
Request
{
"temperature_c": 36.78,
"timestamp": 1747823412.123,
"patient_id": "PT-0427",
"sensor_id": "DS18B20-01"
}Only temperature_c is required. timestamp defaults to server-time; patient_id and sensor_id default to "PT-0427" and "DS18B20".
Responses
200 OK—{"status": "ok"}400 Bad Request—{"error": "missing temperature_c"}
Returns up to the last 300 readings (≈ 5 minutes at 1 Hz). New dashboard clients call this once on load to backfill the chart.
Returns the most recent reading.
Serves the dashboard (templates/dashboard.html).
Emitted to every connected client on each ingested reading. Payload shape comes from to_dashboard_payload() (server.py:39-49):
{
"temperature_c": 36.78,
"temperature_f": 98.20,
"timestamp": 1747823412.123,
"time_str": "14:30:12",
"patient_id": "PT-0427",
"sensor_id": "DS18B20-01"
}The UI classifies each reading via classify() in templates/dashboard.html:604-609:
| Band | Range | UI state |
|---|---|---|
| Hypothermia | < 35.0 °C |
Critical (red, flashing) |
| Normal | 35.0 – 37.5 °C |
OK (green) |
| Low-grade fever | 37.5 – 38.3 °C |
Warning (amber) |
| Febrile | > 38.3 °C |
Critical (red, flashing) |
These thresholds live in the client today; see Roadmap for why they should move server-side.
Wire the DS18B20 with a 4.7 kΩ pull-up resistor between DATA and 3.3 V:
| DS18B20 pin | Pi pin (BCM) | Pi physical pin |
|---|---|---|
| VDD (red) | 3.3 V | pin 1 |
| DATA (yellow) | GPIO 4 | pin 7 |
| GND (black) | GND | pin 6 or 9 |
| (pull-up) | 4.7 kΩ between DATA and 3.3 V |
Enable 1-Wire (one-time):
sudo raspi-config
# Interface Options -> 1-Wire -> Enable -> rebootOr add dtoverlay=w1-gpio to /boot/config.txt and reboot.
Verify the sensor:
ls /sys/bus/w1/devices/
# expect a folder named like 28-3c01a8164d2cInstall the Python dependency and copy the publisher:
pip3 install requests
scp pi_publisher.py pi@<RASPBERRY_PI_LAN_IP>:~Configure — set LAPTOP_IP in pi_publisher.py:25, or pass it on the command line:
python3 pi_publisher.py 192.168.1.42python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python server.pyExpected output:
Dashboard: http://localhost:5000/
Pi posts to: http://<your-laptop-LAN-IP>:5000/api/temperature
Open the firewall on port 5000 for inbound traffic from the LAN:
- macOS: System Settings → Network → Firewall → allow
python. - Windows: when prompted by Defender, allow on Private networks.
- Linux (ufw):
sudo ufw allow 5000/tcp.
# macOS: brew install cloudflared
# Windows: https://github.com/cloudflare/cloudflared/releases
# Linux: https://pkg.cloudflare.com/
cloudflared tunnel --url http://localhost:5000Prints a https://<random-words>.trycloudflare.com URL. Handles WebSockets cleanly out of the box.
ngrok config add-authtoken <YOUR_TOKEN>
ngrok http 5000| Knob | Location | Default | Purpose |
|---|---|---|---|
LAPTOP_IP |
pi_publisher.py:25 |
192.168.1.179 |
Server LAN address |
LAPTOP_PORT |
pi_publisher.py:26 |
5000 |
Server port |
PATIENT_ID |
pi_publisher.py:27 |
PT-0427 |
Identifier tag on each reading |
POLL_INTERVAL_SEC |
pi_publisher.py:28 |
1.0 |
Sampling cadence |
HISTORY_MAX |
server.py:34 |
300 |
Ring-buffer size (≈ 5 min at 1 Hz) |
No DS18B20 detected at /sys/bus/w1/devices/28-*
1-Wire isn't enabled, or wiring is off. Re-check the pull-up resistor.
network error: Connection refused
Server isn't running, wrong LAPTOP_IP, or firewall is blocking. Test the network path from the Pi:
curl -X POST http://<LAPTOP_IP>:5000/api/temperature \
-H 'Content-Type: application/json' \
-d '{"temperature_c": 36.5}'If curl fails, the network path is wrong — not the publisher script.
Dashboard reads "Disconnected" even though the server is up
The browser couldn't open a WebSocket. Always open the dashboard via http://localhost:5000/, not file://.
Tunnel works but the chart doesn't update for remote viewers Some restrictive corporate networks block WebSockets. Cloudflare Tunnel handles WS well; locked-down clients fall back to HTTP long-polling, so the page still updates — just with higher latency.
Readings look noisy / jumpy
DS18B20 has a ±0.5 °C accuracy spec. Average 3 reads in read_temp_c() (pi_publisher.py:60-85) or apply a moving average server-side before broadcasting.
What would need to change before this could carry real patient data:
- TLS end-to-end — mTLS between edge and ingest; HTTPS for clients (the tunnel covers the public hop, but the LAN hop is still plaintext).
- Authentication on
/api/temperature— signed device identity with rotating tokens. Today the endpoint is open. - Persistent store — Postgres or TimescaleDB instead of an in-memory
deque. Restarting the server today loses all history. - Append-only audit log — every reading, every classification, every alert, with operator attribution.
- Multi-patient, multi-sensor — the current schema implicitly assumes one patient per server instance.
- Server-side clinical thresholds — move
classify()off the client so decisions can be versioned, logged, and audited. - Anomaly detection — sepsis early-warning scoring on the live stream; fever-pattern classification windowed over hours.
- Hardened WSGI deployment — swap
threadingasync-mode +allow_unsafe_werkzeug=Trueforeventlet/geventbehind nginx or Caddy.
- 1-Wire is forgiving in the happy path and unforgiving in the unhappy one. The DS18B20's first line is a CRC byte that has to read
YESbefore the reading is trustworthy. Skipping the retry loop inread_temp_c()(pi_publisher.py:60-85) means occasionally publishing garbage values — which a clinical dashboard will dutifully classify as "Febrile" and alarm on. - I chose Socket.IO over raw WebSockets specifically because of the fallback. The moment the dashboard leaves the LAN it crosses networks I don't control, and many corporate firewalls don't speak WebSockets. Socket.IO's HTTP long-poll fallback means the page still updates, just slower — versus a bare WS dying silently.
- HTTP/JSON for ingest is "boring" and that's the point. Every Pi already has a stable HTTP client, the server handler is twelve lines, and I can debug the whole transport with
curl. The minute I reached for MQTT or gRPC I'd have lost an afternoon of yak-shaving for no benefit at this scale. - A small UX detail mattered more than I expected. Without the
GET /api/historybackfill, a late-joining clinician sees a blank chart and has to wait a full minute for the trace to redraw itself. Five lines ofdequeplus a one-shot fetch fixed that completely. - Threading async-mode was the right tradeoff for a prototype, the wrong one for production.
socketio.run(..., allow_unsafe_werkzeug=True)exists for a reason — it loudly tells me this is not the path to deploy. - Clinical thresholds belong on the server, even when it's faster to ship them on the client. I shipped them client-side because the UI needed them anyway; the moment this becomes a real product, the same
classify()function has to live somewhere that can log every classification, version every threshold change, and survive a browser refresh. - Cloudflare Tunnel was a genuine surprise. No signup, no inbound ports, WebSocket-clean. For a project this size it removed an entire class of "now configure your router" instructions from the deployment story.
- The gap between "this works" and "this is HIPAA-deployable" is mostly auth, TLS, persistence, and auditability. That's a four-item list and it's the same four items for every medical-IoT prototype I'll build after this one.
Apache 2.0 — see LICENSE.