feat: Add HR data visualization and CSV conversion scripts
This commit is contained in:
110
main.py
110
main.py
@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user