feat: Add HR data visualization and CSV conversion scripts

This commit is contained in:
2025-06-10 17:09:14 +08:00
parent 884a575d7d
commit 1d1170f19c
10 changed files with 1323 additions and 38 deletions

110
main.py
View File

@ -27,20 +27,29 @@ from pydantic import BaseModel, computed_field
from datetime import datetime, timedelta
import awkward as ak
from awkward import Array as AwkwardArray, Record as AwkwardRecord
from app.model import AlgoReport, HrPacket, hr_confidence_to_num
from app.model import AlgoReport
from app.utils import Instant
from collections import deque
from dataclasses import dataclass
from aiomqtt import Client as MqttClient, Message as MqttMessage
SELECT_BAND_ID = 17
MQTT_BROKER: Final[str] = "192.168.2.189"
MQTT_BROKER_PORT: Final[int] = 1883
MAX_LENGTH = 600
TOPIC: Final[str] = "GwData"
NDArray = np.ndarray
T = TypeVar("T")
class AppHistory(TypedDict):
timescape: deque[datetime]
hr_data: deque[float]
hr_conf: deque[float] # in %
accel_x_data: deque[int]
accel_y_data: deque[int]
accel_z_data: deque[int]
pd_data: deque[int]
# https://handmadesoftware.medium.com/streamlit-asyncio-and-mongodb-f85f77aea825
@ -66,6 +75,62 @@ def unwrap(value: Optional[T]) -> T:
return value
# /**
# * @brief Structure of the Heart Rate Measurement characteristic
# *
# * @see https://www.bluetooth.com/specifications/gss/
# * @see section 3.116 Heart Rate Measurement of the document: GATT Specification Supplement.
# */
# struct ble_hr_measurement_flag_t {
# // LSB first
# /*
# * 0: uint8_t
# * 1: uint16_t
# */
# bool heart_rate_value_format : 1;
# bool sensor_contact_detected : 1;
# bool sensor_contact_supported : 1;
# bool energy_expended_present : 1;
# bool rr_interval_present : 1;
# uint8_t reserved : 3;
# };
def parse_ble_hr_measurement(data: bytes) -> Optional[int]:
"""
Parse BLE Heart Rate Measurement characteristic data according to Bluetooth specification.
Args:
data: Raw bytes from the heart rate measurement characteristic
Returns:
Heart rate value in BPM, or None if parsing fails
"""
if len(data) < 2:
return None
# First byte contains flags
flags = data[0]
# Bit 0: Heart Rate Value Format (0 = uint8, 1 = uint16)
heart_rate_value_format = (flags & 0x01) != 0
try:
if heart_rate_value_format:
# 16-bit heart rate value (little endian)
if len(data) < 3:
return None
hr_value = int.from_bytes(data[1:3], byteorder="little")
else:
# 8-bit heart rate value
hr_value = data[1]
return hr_value
except (IndexError, ValueError):
return None
@st.cache_resource
def resource(params: Any = None):
set_ev = anyio.Event()
@ -87,7 +152,7 @@ def resource(params: Any = None):
tr = Thread(target=anyio.run, args=(poll_task,))
tr.start()
while not set_ev.is_set():
sleep(0.01)
sleep(0.001)
logger.info("Poll task initialized")
state: AppState = {
"worker_thread": tr,
@ -97,10 +162,6 @@ def resource(params: Any = None):
"timescape": deque(maxlen=MAX_LENGTH),
"hr_data": deque(maxlen=MAX_LENGTH),
"hr_conf": deque(maxlen=MAX_LENGTH),
"accel_x_data": deque(maxlen=MAX_LENGTH),
"accel_y_data": deque(maxlen=MAX_LENGTH),
"accel_z_data": deque(maxlen=MAX_LENGTH),
"pd_data": deque(maxlen=MAX_LENGTH),
},
"refresh_inst": Instant(),
}
@ -124,9 +185,6 @@ def main():
history["timescape"].clear()
history["hr_data"].clear()
history["hr_conf"].clear()
history["accel_x_data"].clear()
history["accel_y_data"].clear()
history["accel_z_data"].clear()
# https://docs.streamlit.io/develop/api-reference/layout
st.title("MAX-BAND Visualizer")
@ -153,16 +211,16 @@ def main():
message = state["message_queue"].receive_nowait()
except anyio.WouldBlock:
continue
try:
packet = HrPacket.unmarshal(message)
except ValueError as e:
logger.error(f"bad packet: {e}")
hr_value = parse_ble_hr_measurement(message)
if hr_value is None:
logger.error("Failed to parse heart rate measurement data")
continue
with placeholder.container():
history["hr_data"].append(packet.hr)
history["hr_conf"].append(hr_confidence_to_num(packet.status.hr_confidence))
history["pd_data"].extend(packet.raw_data)
history["hr_data"].append(float(hr_value))
history["hr_conf"].append(
100.0
) # Default confidence since we're not parsing it
fig_hr, fig_pd = st.tabs(["Heart Rate", "PD"])
with fig_hr:
@ -182,18 +240,6 @@ def main():
]
)
)
with fig_pd:
st.plotly_chart(
go.Figure(
data=[
go.Scatter(
y=list(history["pd_data"]),
mode="lines",
name="PD",
)
]
)
)
if __name__ == "__main__":