commit 721c787e5ff1e9ebe32ec9cc465d1d2332cfcd43 Author: Abhishek Kumar Date: Fri Jun 19 08:43:45 2026 +0530 Initial biometric data receiver implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a94c61 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Pilot Bio-Data — Pulse Oximeter Logger + +Receives real-time **heart rate (BPM)** and **blood-oxygen (SpO₂)** readings +from the Controlytics AI pulse oximeter over a wired Ethernet connection and +logs them to a timestamped CSV file. + +--- + +## Hardware + +| Item | Detail | +|---|---| +| Device | Controlytics AI Pulse Oximeter v1.0.0 | +| Interface | Ethernet (UDP) | +| Device Ethernet IP | `192.168.1.2` | +| Device WiFi AP | `PULSE_OXI_WIFI` / password `controlytics` | +| Device config page | `http://192.168.4.1` (Admin / Admin) | + +--- + +## Network Setup + +The device sends UDP packets to a configured destination address. +The one-time setup below is already done; repeat only if hardware changes. + +### Computer (receiver) + +Assign a static IP to the Ethernet interface connected to the device: + +```bash +sudo ip addr add 192.168.1.100/24 dev +``` + +On this machine the interface is `enp1s0`: + +```bash +sudo ip addr add 192.168.1.100/24 dev enp1s0 +``` + +### Device configuration + +Connect to `PULSE_OXI_WIFI`, open `http://192.168.4.1`, log in, and set: + +| Field | Value | +|---|---| +| IP | `192.168.1.2` | +| Gateway | `192.168.1.1` | +| Subnet | `255.255.255.0` | +| Destination address | `192.168.1.100` | +| Destination port | `5005` | +| Send interval (ms) | `500` | + +Submit and restart the device (Log Out button). + +--- + +## Requirements + +Python 3.10 or later. No third-party packages — only the standard library is used. + +```bash +python3 --version # 3.10+ +``` + +--- + +## Running + +```bash +python3 receive.py +``` + +Place the pilot's index finger on the sensor with light, steady pressure. +Hold still for a few seconds for the reading to stabilise. + +### Example output + +``` +Listening on UDP 0.0.0.0:5005 | Logging to readings.csv +TIME BPM SpO2 % STATUS +---------------------------------------------------- +11:34:00.464 92 97 OK +11:34:00.965 92 97 OK +11:34:01.466 74 97 OK +11:34:22.034 87 98 OK +11:35:14.207 --- --- No finger +``` + +Press `Ctrl+C` to stop. + +--- + +## Output — readings.csv + +Every packet is appended to `readings.csv` in the same directory. +Rows where no finger is detected have empty `bpm` and `spo2` fields. + +| Column | Description | +|---|---| +| `timestamp` | ISO-8601 local timestamp | +| `bpm` | Heart rate in beats per minute (empty if no finger) | +| `spo2` | Blood-oxygen saturation % (empty if no finger) | +| `raw` | Raw JSON packet from device | + +### Example rows + +``` +timestamp,bpm,spo2,raw +2026-06-12T11:34:00.464000,92,97,"{""bpm"":92,""spo2"":97}" +2026-06-12T11:34:01.466000,74,97,"{""bpm"":74,""spo2"":97}" +2026-06-12T11:35:14.207000,,,"{""bpm"":-1,""spo2"":-1}" +``` + +--- + +## Packet Format + +The device sends a UDP JSON payload every 500 ms: + +```json +{"bpm": 92, "spo2": 97} +``` + +Values of `-1` indicate no finger is on the sensor. +The parser also accepts `key=value` and bare CSV formats for forward compatibility. + +--- + +## Project Structure + +``` +pilot_bio_data/ +├── receive.py # UDP listener and CSV logger +├── readings.csv # Auto-generated log (appended on each run) +└── README.md +``` + +--- + +*Axial Aero — Internal tooling* diff --git a/readings.csv b/readings.csv new file mode 100644 index 0000000..1c0c511 --- /dev/null +++ b/readings.csv @@ -0,0 +1,155 @@ +2026-06-12T11:33:32.827783,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:33.329104,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:33.830768,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:34.331759,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:34.833746,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:35.334779,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:35.836760,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:36.338792,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:36.839758,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:37.341826,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:37.842746,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:38.344744,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:38.845791,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:39.347801,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:39.848753,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:40.350772,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:40.851753,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:41.354134,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:41.854786,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:42.356788,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:42.857741,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:43.359757,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:43.860750,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:44.362749,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:44.864750,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:45.365806,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:45.867796,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:46.368828,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:46.870841,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:49.428895,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:49.929775,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:50.431867,-1,-1,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:33:50.932922,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:51.434831,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:51.935795,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:52.437837,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:52.939862,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:53.441175,-1,89,"{""bpm"":-1,""spo2"":89}" +2026-06-12T11:33:53.942848,-1,90,"{""bpm"":-1,""spo2"":90}" +2026-06-12T11:33:54.444145,-1,90,"{""bpm"":-1,""spo2"":90}" +2026-06-12T11:33:54.945841,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:55.447881,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:55.948881,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:56.451052,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:56.952797,-1,90,"{""bpm"":-1,""spo2"":90}" +2026-06-12T11:33:57.453943,-1,90,"{""bpm"":-1,""spo2"":90}" +2026-06-12T11:33:57.955804,-1,90,"{""bpm"":-1,""spo2"":90}" +2026-06-12T11:33:58.456900,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:58.958901,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:59.461022,-1,96,"{""bpm"":-1,""spo2"":96}" +2026-06-12T11:33:59.961826,92,97,"{""bpm"":92,""spo2"":97}" +2026-06-12T11:34:00.464106,92,97,"{""bpm"":92,""spo2"":97}" +2026-06-12T11:34:00.965835,92,97,"{""bpm"":92,""spo2"":97}" +2026-06-12T11:34:01.466897,74,97,"{""bpm"":74,""spo2"":97}" +2026-06-12T11:34:01.968838,77,97,"{""bpm"":77,""spo2"":97}" +2026-06-12T11:34:02.470858,77,97,"{""bpm"":77,""spo2"":97}" +2026-06-12T11:34:02.971864,81,92,"{""bpm"":81,""spo2"":92}" +2026-06-12T11:34:03.473928,77,87,"{""bpm"":77,""spo2"":87}" +2026-06-12T11:34:03.974932,77,86,"{""bpm"":77,""spo2"":86}" +2026-06-12T11:34:04.476900,77,86,"{""bpm"":77,""spo2"":86}" +2026-06-12T11:34:04.978820,81,86,"{""bpm"":81,""spo2"":86}" +2026-06-12T11:34:05.479860,81,86,"{""bpm"":81,""spo2"":86}" +2026-06-12T11:34:05.981890,81,92,"{""bpm"":81,""spo2"":92}" +2026-06-12T11:34:06.483873,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:06.984850,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:07.487236,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:07.988840,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:08.489924,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:08.991922,85,95,"{""bpm"":85,""spo2"":95}" +2026-06-12T11:34:09.493953,81,94,"{""bpm"":81,""spo2"":94}" +2026-06-12T11:34:09.994843,81,94,"{""bpm"":81,""spo2"":94}" +2026-06-12T11:34:10.496933,81,94,"{""bpm"":81,""spo2"":94}" +2026-06-12T11:34:10.997865,81,94,"{""bpm"":81,""spo2"":94}" +2026-06-12T11:34:11.499929,81,93,"{""bpm"":81,""spo2"":93}" +2026-06-12T11:34:12.002030,81,93,"{""bpm"":81,""spo2"":93}" +2026-06-12T11:34:12.502893,81,88,"{""bpm"":81,""spo2"":88}" +2026-06-12T11:34:13.004880,81,93,"{""bpm"":81,""spo2"":93}" +2026-06-12T11:34:13.506926,92,94,"{""bpm"":92,""spo2"":94}" +2026-06-12T11:34:14.007895,92,94,"{""bpm"":92,""spo2"":94}" +2026-06-12T11:34:14.510099,92,94,"{""bpm"":92,""spo2"":94}" +2026-06-12T11:34:15.011892,92,94,"{""bpm"":92,""spo2"":94}" +2026-06-12T11:34:15.513911,92,95,"{""bpm"":92,""spo2"":95}" +2026-06-12T11:34:16.014909,87,96,"{""bpm"":87,""spo2"":96}" +2026-06-12T11:34:16.516899,87,96,"{""bpm"":87,""spo2"":96}" +2026-06-12T11:34:17.018859,87,96,"{""bpm"":87,""spo2"":96}" +2026-06-12T11:34:17.519930,92,96,"{""bpm"":92,""spo2"":96}" +2026-06-12T11:34:18.021888,92,96,"{""bpm"":92,""spo2"":96}" +2026-06-12T11:34:18.523985,92,96,"{""bpm"":92,""spo2"":96}" +2026-06-12T11:34:19.024943,92,96,"{""bpm"":92,""spo2"":96}" +2026-06-12T11:34:19.527042,92,96,"{""bpm"":92,""spo2"":96}" +2026-06-12T11:34:20.028912,89,96,"{""bpm"":89,""spo2"":96}" +2026-06-12T11:34:20.529898,89,96,"{""bpm"":89,""spo2"":96}" +2026-06-12T11:34:21.031946,89,96,"{""bpm"":89,""spo2"":96}" +2026-06-12T11:34:21.533984,89,96,"{""bpm"":89,""spo2"":96}" +2026-06-12T11:34:22.034882,87,98,"{""bpm"":87,""spo2"":98}" +2026-06-12T11:34:22.536947,87,98,"{""bpm"":87,""spo2"":98}" +2026-06-12T11:34:23.038877,85,98,"{""bpm"":85,""spo2"":98}" +2026-06-12T11:34:23.539937,89,98,"{""bpm"":89,""spo2"":98}" +2026-06-12T11:34:24.041915,89,98,"{""bpm"":89,""spo2"":98}" +2026-06-12T11:34:24.544003,89,96,"{""bpm"":89,""spo2"":96}" +2026-06-12T11:34:25.044909,85,96,"{""bpm"":85,""spo2"":96}" +2026-06-12T11:34:25.547076,96,93,"{""bpm"":96,""spo2"":93}" +2026-06-12T11:34:26.048937,96,92,"{""bpm"":96,""spo2"":92}" +2026-06-12T11:34:26.549927,96,92,"{""bpm"":96,""spo2"":92}" +2026-06-12T11:34:27.051903,96,92,"{""bpm"":96,""spo2"":92}" +2026-06-12T11:34:27.554174,94,90,"{""bpm"":94,""spo2"":90}" +2026-06-12T11:34:28.055238,94,90,"{""bpm"":94,""spo2"":90}" +2026-06-12T11:34:28.556968,94,92,"{""bpm"":94,""spo2"":92}" +2026-06-12T11:34:29.058950,87,92,"{""bpm"":87,""spo2"":92}" +2026-06-12T11:34:29.559892,94,92,"{""bpm"":94,""spo2"":92}" +2026-06-12T11:34:30.061926,85,94,"{""bpm"":85,""spo2"":94}" +2026-06-12T11:34:30.563924,85,96,"{""bpm"":85,""spo2"":96}" +2026-06-12T11:34:31.065938,79,96,"{""bpm"":79,""spo2"":96}" +2026-06-12T11:34:31.566943,85,96,"{""bpm"":85,""spo2"":96}" +2026-06-12T11:34:32.068920,99,96,"{""bpm"":99,""spo2"":96}" +2026-06-12T11:35:14.207713,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:14.709107,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:15.211099,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:15.712087,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:16.214076,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:16.715095,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:17.217370,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:17.718063,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:18.220188,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:18.722138,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:19.223071,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:19.725082,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:20.227142,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:20.728106,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:21.230101,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:21.731071,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:22.233061,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:22.735075,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:23.236111,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:23.738588,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:24.239134,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:24.741129,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:25.242099,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:25.744096,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:26.246144,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:26.747135,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:27.249471,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:27.750167,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:28.252514,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:28.754229,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:29.255536,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:29.757130,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:30.258461,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:30.760323,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:31.262249,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:31.763243,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:32.265482,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:32.766157,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:33.268510,,,"{""bpm"":-1,""spo2"":-1}" +2026-06-12T11:35:33.769203,,,"{""bpm"":-1,""spo2"":-1}" diff --git a/receive.py b/receive.py new file mode 100644 index 0000000..ab35298 --- /dev/null +++ b/receive.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Pulse oximeter UDP listener. +Receives heart-rate (BPM) and SpO2 readings from the device and prints them. +Run: python3 receive.py +""" + +import socket +import datetime +import json +import csv +import sys +import os + +HOST = "0.0.0.0" +PORT = 5005 + +def parse_payload(raw: bytes) -> dict | None: + """Try JSON first, then common key=value or CSV formats.""" + text = raw.decode(errors="replace").strip() + if not text: + return None + + # JSON + if text.startswith("{"): + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # key=value pairs (e.g. BPM=72,SPO2=98) + if "=" in text: + try: + return dict(part.split("=", 1) for part in text.replace(";", ",").split(",") if "=" in part) + except Exception: + pass + + # Bare CSV: treat first field as BPM, second as SpO2 + parts = text.split(",") + if len(parts) >= 2 and all(p.strip().lstrip("-").isdigit() for p in parts[:2]): + return {"BPM": parts[0].strip(), "SpO2": parts[1].strip()} + + # Unknown — return raw + return {"raw": text} + + +def main(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((HOST, PORT)) + + log_path = os.path.join(os.path.dirname(__file__), "readings.csv") + write_header = not os.path.exists(log_path) + log_file = open(log_path, "a", newline="") + writer = csv.writer(log_file) + if write_header: + writer.writerow(["timestamp", "bpm", "spo2", "raw"]) + + print(f"Listening on UDP {HOST}:{PORT} | Logging to {log_path}") + print(f"{'TIME':12} {'BPM':>6} {'SpO2 %':>7} STATUS") + print("-" * 52) + + try: + while True: + data, addr = sock.recvfrom(4096) + ts = datetime.datetime.now() + parsed = parse_payload(data) + + def _get(d, *keys): + for k in keys: + if k in d: + return d[k] + return None + + bpm_raw = _get(parsed, "bpm", "BPM", "heartrate") + spo2_raw = _get(parsed, "spo2", "SPO2", "SpO2", "oxygen") + raw = parsed.get("raw", "") + + bpm_val = int(bpm_raw) if bpm_raw is not None else None + spo2_val = int(spo2_raw) if spo2_raw is not None else None + + no_finger = (bpm_val is not None and bpm_val < 0) or \ + (spo2_val is not None and spo2_val < 0) + + bpm_str = "---" if (bpm_val is None or bpm_val < 0) else str(bpm_val) + spo2_str = "---" if (spo2_val is None or spo2_val < 0) else str(spo2_val) + status = "No finger" if no_finger else (f"({raw})" if raw else "OK") + + print(f"{ts.strftime('%H:%M:%S.%f')[:12]} {bpm_str:>6} {spo2_str:>7} {status}") + sys.stdout.flush() + + # Log only rows where valid readings exist + log_bpm = bpm_val if (bpm_val is not None and bpm_val >= 0) else "" + log_spo2 = spo2_val if (spo2_val is not None and spo2_val >= 0) else "" + writer.writerow([ts.isoformat(), log_bpm, log_spo2, data.decode(errors="replace").strip()]) + log_file.flush() + + except KeyboardInterrupt: + print("\nStopped.") + finally: + sock.close() + log_file.close() + + +if __name__ == "__main__": + main()