From 4239dfa35a82f7714289bd108bb7f6f15410a08d Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 15 Mar 2026 16:56:11 -0400 Subject: [PATCH] feat: RuView Live unified dashboard + improved examples README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruview_live.py: single-file dashboard that auto-detects CSI and mmWave sensors, displays fused vitals (HR, BR, BP, stress/HRV), environment (light, RSSI, RF fingerprint), presence, and events. Tested live: CSI 1000 frames/60s (17 Hz), light trending 7.4→6.0 lux, RSSI -57 to -72 dBm. Handles graceful degradation when sensors are unavailable. README: updated with unified dashboard as primary entry point, hardware table with capabilities, expanded quick start. Co-Authored-By: claude-flow --- examples/README.md | 50 ++++-- examples/ruview_live.py | 386 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 examples/ruview_live.py diff --git a/examples/README.md b/examples/README.md index 3fa166de..b10c7554 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,34 +2,54 @@ Real-time sensing applications built on the RuView platform. +## Unified Dashboard (start here) + +```bash +pip install pyserial numpy +python examples/ruview_live.py --csi COM7 --mmwave COM4 +``` + +The live dashboard auto-detects available sensors and displays fused vitals, environment data, and events in real-time. Works with any combination of sensors. + +## Individual Examples + | Example | Sensors | What It Does | |---------|---------|-------------| -| [Medical: Blood Pressure](medical/) | mmWave (COM4) | Contactless BP estimation from HRV | -| [Sleep: Apnea Screener](sleep/) | mmWave (COM4) | Detects breathing cessation events, computes AHI | -| [Stress: HRV Monitor](stress/) | mmWave (COM4) | Real-time stress level from heart rate variability | -| [Environment: Room Monitor](environment/) | CSI (COM7) + mmWave (COM4) | Occupancy, light, RF fingerprint, activity events | +| [**ruview_live.py**](ruview_live.py) | CSI + mmWave + Light | Unified dashboard: HR, BR, BP, stress, presence, light, RSSI | +| [Medical: Blood Pressure](medical/) | mmWave | Contactless BP estimation from HRV | +| [Sleep: Apnea Screener](sleep/) | mmWave | Detects breathing cessation events, computes AHI | +| [Stress: HRV Monitor](stress/) | mmWave | Real-time stress level from heart rate variability | +| [Environment: Room Monitor](environment/) | CSI + mmWave | Occupancy, light, RF fingerprint, activity events | -## Hardware Required +## Hardware -| Port | Device | Cost | -|------|--------|------| -| COM7 | ESP32-S3 (WiFi CSI) | ~$9 | -| COM4 | ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave) | ~$15 | +| Port | Device | Cost | What It Provides | +|------|--------|------|-----------------| +| COM7 | ESP32-S3 (WiFi CSI) | ~$9 | Presence, motion, breathing, heart rate (through walls) | +| COM4 | ESP32-C6 + Seeed MR60BHA2 | ~$15 | Precise HR/BR, presence, distance, ambient light | + +Either sensor works alone. Both together enable fusion (mmWave 80% + CSI 20%). ## Quick Start ```bash pip install pyserial numpy -# Blood pressure +# Unified dashboard (recommended) +python examples/ruview_live.py --csi COM7 --mmwave COM4 + +# Blood pressure estimation python examples/medical/bp_estimator.py --port COM4 -# Sleep apnea screening -python examples/sleep/apnea_screener.py --port COM4 --duration 3600 +# Sleep apnea screening (run overnight) +python examples/sleep/apnea_screener.py --port COM4 --duration 28800 -# Stress monitoring -python examples/stress/hrv_stress_monitor.py --port COM4 +# Stress monitoring (workday session) +python examples/stress/hrv_stress_monitor.py --port COM4 --duration 3600 -# Full room monitor (both sensors) +# Room environment monitor python examples/environment/room_monitor.py --csi-port COM7 --mmwave-port COM4 + +# CSI only (no mmWave) +python examples/ruview_live.py --csi COM7 --mmwave none ``` diff --git a/examples/ruview_live.py b/examples/ruview_live.py new file mode 100644 index 00000000..fc72e7e9 --- /dev/null +++ b/examples/ruview_live.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +RuView Live — Unified Real-Time Ambient Intelligence Dashboard + +Combines all available RuView sensors into a single live display: + - ESP32-S3 WiFi CSI (serial or UDP): presence, motion, breathing, heart rate + - MR60BHA2 mmWave (serial): precise HR, BR, presence, distance, light + - Derived: blood pressure, stress (HRV), sleep state, activity + +Automatically detects which sensors are available and adapts. + +Usage: + python examples/ruview_live.py + python examples/ruview_live.py --csi COM7 --mmwave COM4 + python examples/ruview_live.py --csi COM7 # CSI only + python examples/ruview_live.py --mmwave COM4 # mmWave only +""" + +import argparse +import collections +import math +import re +import serial +import sys +import threading +import time + +# ---- Regex patterns ---- +RE_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# mmWave (ESPHome) +RE_MW_HR = re.compile(r"'Real-time heart rate'.*?(\d+\.?\d*)\s*bpm", re.I) +RE_MW_BR = re.compile(r"'Real-time respiratory rate'.*?(\d+\.?\d*)", re.I) +RE_MW_PRES = re.compile(r"'Person Information'.*?state\s+(ON|OFF)", re.I) +RE_MW_DIST = re.compile(r"'Distance to detection object'.*?(\d+\.?\d*)\s*cm", re.I) +RE_MW_LUX = re.compile(r"illuminance=(\d+\.?\d*)", re.I) +RE_MW_TARGETS = re.compile(r"'Target Number'.*?(\d+\.?\d*)", re.I) +# CSI (edge_proc) +RE_CSI_VITALS = re.compile(r"Vitals:.*?br=(\d+\.?\d*).*?hr=(\d+\.?\d*).*?motion=(\d+\.?\d*).*?pres=(\w+)", re.I) +RE_CSI_CB = re.compile(r"CSI cb #(\d+).*?rssi=(-?\d+)") +RE_CSI_CALIB = re.compile(r"Adaptive calibration.*?threshold=(\d+\.?\d*)") +RE_CSI_FALL = re.compile(r"Fall detected.*?accel=(\d+\.?\d*)") + + +class SensorHub: + """Aggregates data from all sensors with thread-safe access.""" + + def __init__(self): + self.lock = threading.Lock() + # mmWave + self.mw_hr = 0.0 + self.mw_br = 0.0 + self.mw_presence = False + self.mw_distance = 0.0 + self.mw_lux = 0.0 + self.mw_targets = 0 + self.mw_frames = 0 + self.mw_connected = False + # CSI + self.csi_hr = 0.0 + self.csi_br = 0.0 + self.csi_motion = 0.0 + self.csi_presence = False + self.csi_rssi = 0 + self.csi_frames = 0 + self.csi_calibrated = False + self.csi_calib_thresh = 0.0 + self.csi_fall = False + self.csi_connected = False + # Derived + self.hr_history = collections.deque(maxlen=120) + self.events = collections.deque(maxlen=50) + + def update_mw(self, **kw): + with self.lock: + for k, v in kw.items(): + setattr(self, f"mw_{k}", v) + self.mw_connected = True + + def update_csi(self, **kw): + with self.lock: + for k, v in kw.items(): + setattr(self, f"csi_{k}", v) + self.csi_connected = True + + def add_hr(self, hr): + if 30 < hr < 200: + self.hr_history.append(hr) + + def add_event(self, msg): + self.events.append((time.time(), msg)) + + def snapshot(self): + with self.lock: + return {k: getattr(self, k) for k in vars(self) if not k.startswith("_") and k != "lock"} + + +def compute_derived(hub_snap): + """Compute fused vitals + derived metrics.""" + d = {} + + # Fused HR: prefer mmWave, fallback CSI + mw_hr = hub_snap["mw_hr"] + csi_hr = hub_snap["csi_hr"] + if mw_hr > 0 and csi_hr > 0: + d["hr"] = mw_hr * 0.8 + csi_hr * 0.2 + d["hr_src"] = "Fused" + elif mw_hr > 0: + d["hr"] = mw_hr + d["hr_src"] = "mmWave" + elif csi_hr > 0: + d["hr"] = csi_hr + d["hr_src"] = "CSI" + else: + d["hr"] = 0 + d["hr_src"] = "—" + + # Fused BR + mw_br = hub_snap["mw_br"] + csi_br = hub_snap["csi_br"] + if mw_br > 0 and csi_br > 0: + d["br"] = mw_br * 0.8 + csi_br * 0.2 + elif mw_br > 0: + d["br"] = mw_br + elif csi_br > 0: + d["br"] = csi_br + else: + d["br"] = 0 + + # Fused presence (OR) + d["presence"] = hub_snap["mw_presence"] or hub_snap["csi_presence"] + + # HRV from HR history + hrs = list(hub_snap["hr_history"]) + if len(hrs) >= 5: + rr = [60000.0 / h for h in hrs if h > 0] + rr_mean = sum(rr) / len(rr) + d["sdnn"] = math.sqrt(sum((x - rr_mean) ** 2 for x in rr) / len(rr)) + diffs = [(rr[i + 1] - rr[i]) ** 2 for i in range(len(rr) - 1)] + d["rmssd"] = math.sqrt(sum(diffs) / len(diffs)) if diffs else 0 + else: + d["sdnn"] = 0 + d["rmssd"] = 0 + + # Blood pressure estimate + if d["hr"] > 0 and d["sdnn"] > 0: + delta = d["hr"] - 72 + d["sbp"] = round(max(80, min(200, 120 + 0.5 * delta - 0.8 * (d["sdnn"] - 50) / 50))) + d["dbp"] = round(max(50, min(130, 80 + 0.3 * delta - 0.5 * (d["sdnn"] - 50) / 50))) + else: + d["sbp"] = 0 + d["dbp"] = 0 + + # Stress level + if d["sdnn"] > 0: + if d["sdnn"] < 30: + d["stress"] = "HIGH" + elif d["sdnn"] < 50: + d["stress"] = "Moderate" + elif d["sdnn"] < 80: + d["stress"] = "Mild" + elif d["sdnn"] < 100: + d["stress"] = "Relaxed" + else: + d["stress"] = "Calm" + else: + d["stress"] = "—" + + # Light + d["lux"] = hub_snap["mw_lux"] + if d["lux"] < 1: + d["light"] = "Dark" + elif d["lux"] < 10: + d["light"] = "Dim" + elif d["lux"] < 50: + d["light"] = "Low" + elif d["lux"] < 200: + d["light"] = "Normal" + else: + d["light"] = "Bright" + + return d + + +def reader_mmwave(port, baud, hub, stop): + try: + ser = serial.Serial(port, baud, timeout=1) + hub.add_event(f"mmWave connected on {port}") + except Exception as e: + hub.add_event(f"mmWave FAILED: {e}") + return + + prev_pres = None + while not stop.is_set(): + try: + line = ser.readline().decode("utf-8", errors="replace") + except Exception: + continue + clean = RE_ANSI.sub("", line) + + m = RE_MW_HR.search(clean) + if m: + hr = float(m.group(1)) + hub.update_mw(hr=hr, frames=hub.mw_frames + 1) + hub.add_hr(hr) + + m = RE_MW_BR.search(clean) + if m: + hub.update_mw(br=float(m.group(1))) + + m = RE_MW_PRES.search(clean) + if m: + pres = m.group(1) == "ON" + if prev_pres is not None and pres != prev_pres: + hub.add_event(f"mmWave: person {'arrived' if pres else 'left'}") + prev_pres = pres + hub.update_mw(presence=pres) + + m = RE_MW_DIST.search(clean) + if m: + hub.update_mw(distance=float(m.group(1))) + + m = RE_MW_LUX.search(clean) + if m: + hub.update_mw(lux=float(m.group(1))) + + m = RE_MW_TARGETS.search(clean) + if m: + hub.update_mw(targets=int(float(m.group(1)))) + + ser.close() + + +def reader_csi(port, baud, hub, stop): + try: + ser = serial.Serial(port, baud, timeout=1) + hub.add_event(f"CSI connected on {port}") + except Exception as e: + hub.add_event(f"CSI FAILED: {e}") + return + + while not stop.is_set(): + try: + line = ser.readline().decode("utf-8", errors="replace") + except Exception: + continue + + m = RE_CSI_VITALS.search(line) + if m: + hub.update_csi( + br=float(m.group(1)), + hr=float(m.group(2)), + motion=float(m.group(3)), + presence=(m.group(4).upper() == "YES"), + ) + hub.add_hr(float(m.group(2))) + + m = RE_CSI_CB.search(line) + if m: + hub.update_csi(frames=int(m.group(1)), rssi=int(m.group(2))) + + m = RE_CSI_CALIB.search(line) + if m: + hub.update_csi(calibrated=True, calib_thresh=float(m.group(1))) + hub.add_event(f"CSI calibrated (threshold={m.group(1)})") + + m = RE_CSI_FALL.search(line) + if m: + hub.update_csi(fall=True) + hub.add_event(f"FALL DETECTED (accel={m.group(1)})") + + ser.close() + + +def display(hub, duration, interval=3): + start = time.time() + last = 0 + + # Header + print() + print("=" * 78) + print(" RuView Live — Ambient Intelligence Dashboard") + print("=" * 78) + print() + cols = f"{'Time':>5} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} {'SDNN':>5} " \ + f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} {'CSI#':>5} {'MW#':>4}" + print(cols) + print("-" * 78) + + while time.time() - start < duration: + time.sleep(0.5) + elapsed = int(time.time() - start) + if elapsed <= last or elapsed % interval != 0: + continue + last = elapsed + + snap = hub.snapshot() + d = compute_derived(snap) + + # Format + hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else " —" + br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else " —" + bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— " + pres_s = "YES" if d["presence"] else " no" + dist_s = f"{snap['mw_distance']:>4.0f}cm" if snap["mw_distance"] > 0 else " — " + lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else " — " + rssi_s = f"{snap['csi_rssi']:>5}" if snap["csi_rssi"] != 0 else " — " + + print(f"{elapsed:>4}s {hr_s} {br_s} {bp_s} {d['stress']:>8} {d['sdnn']:>5.0f} " + f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} {snap['csi_frames']:>5} {snap['mw_frames']:>4}") + + # Print recent events + for ts, msg in snap["events"]: + age = elapsed - int(ts - (time.time() - elapsed)) + if 0 <= age < interval + 1: + print(f" >> {msg}") + + # Summary + snap = hub.snapshot() + d = compute_derived(snap) + print() + print("=" * 78) + print(" SESSION SUMMARY") + print("=" * 78) + sensors = [] + if snap["csi_connected"]: + sensors.append(f"CSI ({snap['csi_frames']} frames)") + if snap["mw_connected"]: + sensors.append(f"mmWave ({snap['mw_frames']} readings)") + print(f" Sensors: {', '.join(sensors) if sensors else 'None detected'}") + print(f" Duration: {duration}s") + if d["hr"] > 0: + print(f" Heart Rate: {d['hr']:.0f} bpm ({d['hr_src']})") + if d["br"] > 0: + print(f" Breathing: {d['br']:.0f}/min") + if d["sbp"] > 0: + print(f" BP Estimate: {d['sbp']}/{d['dbp']} mmHg") + if d["sdnn"] > 0: + print(f" HRV (SDNN): {d['sdnn']:.0f} ms — {d['stress']}") + if d["lux"] > 0: + print(f" Light: {d['lux']:.1f} lux ({d['light']})") + if snap["csi_rssi"] != 0: + print(f" WiFi RSSI: {snap['csi_rssi']} dBm") + events = list(snap["events"]) + if events: + print(f" Events ({len(events)}):") + for ts, msg in events[-10:]: + print(f" {msg}") + print() + + +def main(): + parser = argparse.ArgumentParser(description="RuView Live Dashboard") + parser.add_argument("--csi", default="COM7", help="CSI serial port (or 'none')") + parser.add_argument("--mmwave", default="COM4", help="mmWave serial port (or 'none')") + parser.add_argument("--duration", type=int, default=120) + parser.add_argument("--interval", type=int, default=3, help="Display update interval (seconds)") + args = parser.parse_args() + + hub = SensorHub() + stop = threading.Event() + threads = [] + + if args.mmwave.lower() != "none": + t = threading.Thread(target=reader_mmwave, args=(args.mmwave, 115200, hub, stop), daemon=True) + t.start() + threads.append(t) + + if args.csi.lower() != "none": + t = threading.Thread(target=reader_csi, args=(args.csi, 115200, hub, stop), daemon=True) + t.start() + threads.append(t) + + time.sleep(2) # Let sensors connect + + try: + display(hub, args.duration, args.interval) + except KeyboardInterrupt: + print("\nStopping...") + + stop.set() + for t in threads: + t.join(timeout=2) + + +if __name__ == "__main__": + main()