feat(miniprogram): BLE 设备同步模块 — 扫描+连接+数据上传
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- Task 18: BLE 类型定义(NormalizedReading/DeviceAdapter/BLEDevice)+ BLEManager 连接管理器
- Task 19: XiaomiBandAdapter 心率读取适配器(标准 HRS Service 0x180D)
- Task 20: device-sync API 层 + 设备同步页面 + app.config 路由注册
This commit is contained in:
iven
2026-04-27 07:53:12 +08:00
parent d1ab8074a3
commit 215fb35e0e
9 changed files with 1011 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ export default defineAppConfig({
'pages/doctor/report/index',
'pages/doctor/report/detail/index',
'pages/events/index',
'pages/device-sync/index',
],
tabBar: {
color: '#94A3B8',

View File

@@ -0,0 +1,235 @@
.device-sync-page {
min-height: 100vh;
background: #F1F5F9;
padding-bottom: env(safe-area-inset-bottom);
}
.sync-header {
background: linear-gradient(135deg, #0891B2, #0E7490);
padding: 48px 24px 24px;
color: #fff;
}
.sync-header-title {
font-size: 20px;
font-weight: 600;
}
.sync-section {
padding: 16px;
}
.sync-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
}
.sync-hero-icon {
font-size: 48px;
margin-bottom: 12px;
}
.sync-hero-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
}
.sync-hero-desc {
font-size: 13px;
color: #64748B;
}
.sync-action {
display: flex;
align-items: center;
justify-content: center;
background: #0891B2;
border-radius: 8px;
padding: 12px 24px;
margin: 8px 0;
}
.sync-action--primary {
flex: 1;
background: #0891B2;
}
.sync-action--danger {
flex: 1;
background: #EF4444;
margin-left: 12px;
}
.sync-action-text {
color: #fff;
font-size: 15px;
font-weight: 500;
}
.sync-device-list {
margin-top: 12px;
}
.sync-section-title {
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
display: block;
}
.sync-device-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 8px;
}
.sync-device-info {
display: flex;
flex-direction: column;
}
.sync-device-name {
font-size: 15px;
font-weight: 500;
color: #1E293B;
}
.sync-device-adapter {
font-size: 12px;
color: #94A3B8;
margin-top: 2px;
}
.sync-device-rssi {
font-size: 12px;
color: #64748B;
}
.sync-status-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
}
.sync-status-dot {
width: 8px;
height: 8px;
border-radius: 4px;
margin-right: 8px;
background: #94A3B8;
}
.sync-status-dot--connected {
background: #22C55E;
}
.sync-status-text {
font-size: 14px;
color: #1E293B;
}
.sync-readings-panel {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
}
.sync-reading-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #F1F5F9;
}
.sync-reading-type {
font-size: 13px;
color: #64748B;
}
.sync-reading-value {
font-size: 15px;
font-weight: 600;
color: #0891B2;
}
.sync-readings-count {
display: block;
margin-top: 8px;
font-size: 12px;
color: #94A3B8;
text-align: center;
}
.sync-actions-row {
display: flex;
gap: 8px;
}
.sync-error {
margin: 16px;
padding: 12px 16px;
background: #FEF2F2;
border-radius: 8px;
border: 1px solid #FECACA;
}
.sync-error-text {
font-size: 13px;
color: #DC2626;
}
.sync-loading {
display: flex;
justify-content: center;
padding: 48px 16px;
}
.sync-loading-text {
font-size: 14px;
color: #64748B;
}
.sync-result-card {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 32px 16px;
margin-bottom: 16px;
}
.sync-result-icon {
font-size: 40px;
color: #22C55E;
margin-bottom: 8px;
}
.sync-result-title {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin-bottom: 4px;
}
.sync-result-count {
font-size: 13px;
color: #64748B;
}

View File

@@ -0,0 +1,216 @@
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import { BLEManager } from '@/services/ble/BLEManager';
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
import { uploadReadings } from '@/services/device-sync';
import { useAuthStore } from '@/stores/auth';
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
import './index.scss';
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
bleManager.registerAdapter(XiaomiBandAdapter);
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
export default function DeviceSync() {
const { currentPatient } = useAuthStore();
const [pageState, setPageState] = useState<PageState>('idle');
const [devices, setDevices] = useState<BLEDevice[]>([]);
const [selectedDevice, setSelectedDevice] = useState<BLEDevice | null>(null);
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
const [syncCount, setSyncCount] = useState(0);
const [errorMsg, setErrorMsg] = useState('');
useDidShow(() => {
bleManager.setOnConnectionChange(() => {});
bleManager.setOnReadings((readings) => {
setLiveReadings((prev) => [...prev, ...readings]);
});
return () => {
bleManager.destroy();
};
});
const handleScan = useCallback(async () => {
setPageState('scanning');
setDevices([]);
setErrorMsg('');
try {
const found = await bleManager.scanDevices();
setDevices(found);
if (found.length === 0) {
setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机');
}
setPageState('idle');
} catch (e: any) {
setErrorMsg(e.message || '扫描失败');
setPageState('error');
}
}, []);
const handleConnect = useCallback(async (device: BLEDevice) => {
setSelectedDevice(device);
setPageState('connecting');
setErrorMsg('');
try {
await bleManager.connect(device);
setPageState('connected');
} catch (e: any) {
setErrorMsg(e.message || '连接失败');
setPageState('error');
}
}, []);
const handleSync = useCallback(async () => {
if (!currentPatient || !selectedDevice) return;
setPageState('syncing');
setErrorMsg('');
try {
const result = await bleManager.syncToServer(async (readings) => {
return uploadReadings(
currentPatient.id,
selectedDevice.deviceId,
selectedDevice.name,
readings,
);
});
if (result.success) {
setSyncCount(result.uploadedCount);
setPageState('done');
} else {
setErrorMsg(result.error || '同步失败');
setPageState('error');
}
} catch (e: any) {
setErrorMsg(e.message || '同步失败');
setPageState('error');
}
}, [currentPatient, selectedDevice]);
const handleDisconnect = useCallback(async () => {
await bleManager.disconnect();
setPageState('idle');
setSelectedDevice(null);
setLiveReadings([]);
setSyncCount(0);
setErrorMsg('');
}, []);
const renderIdle = () => (
<View className="sync-section">
<View className="sync-hero">
<Text className="sync-hero-icon"></Text>
<Text className="sync-hero-title"></Text>
<Text className="sync-hero-desc"></Text>
</View>
<View className="sync-action" onClick={handleScan}>
<Text className="sync-action-text"></Text>
</View>
{devices.length > 0 && (
<View className="sync-device-list">
<Text className="sync-section-title"></Text>
{devices.map((d) => (
<View
key={d.deviceId}
className="sync-device-item"
onClick={() => handleConnect(d)}
>
<View className="sync-device-info">
<Text className="sync-device-name">{d.name}</Text>
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
</View>
<Text className="sync-device-rssi"> {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
</View>
))}
</View>
)}
</View>
);
const renderConnected = () => (
<View className="sync-section">
<View className="sync-status-card">
<Text className="sync-status-dot sync-status-dot--connected" />
<Text className="sync-status-text">: {selectedDevice?.name}</Text>
</View>
{liveReadings.length > 0 && (
<View className="sync-readings-panel">
<Text className="sync-section-title"></Text>
{liveReadings.slice(-5).reverse().map((r, i) => (
<View key={i} className="sync-reading-item">
<Text className="sync-reading-type">
{r.device_type === 'heart_rate' ? '心率' : r.device_type}
</Text>
<Text className="sync-reading-value">
{r.device_type === 'heart_rate'
? `${r.values.heart_rate} bpm`
: JSON.stringify(r.values)}
</Text>
</View>
))}
<Text className="sync-readings-count">
{liveReadings.length}
</Text>
</View>
)}
<View className="sync-actions-row">
<View className="sync-action sync-action--primary" onClick={handleSync}>
<Text className="sync-action-text"></Text>
</View>
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
<Text className="sync-action-text"></Text>
</View>
</View>
</View>
);
const renderDone = () => (
<View className="sync-section">
<View className="sync-result-card">
<Text className="sync-result-icon"></Text>
<Text className="sync-result-title"></Text>
<Text className="sync-result-count"> {syncCount} </Text>
</View>
<View className="sync-action" onClick={handleDisconnect}>
<Text className="sync-action-text"></Text>
</View>
</View>
);
return (
<View className="device-sync-page">
<View className="sync-header">
<Text className="sync-header-title"></Text>
</View>
{errorMsg && (
<View className="sync-error">
<Text className="sync-error-text">{errorMsg}</Text>
</View>
)}
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
<View className="sync-loading">
<Text className="sync-loading-text">
{pageState === 'scanning' && '正在扫描设备...'}
{pageState === 'connecting' && '正在连接设备...'}
{pageState === 'syncing' && '正在上传数据...'}
</Text>
</View>
)}
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
{pageState === 'connected' && renderConnected()}
{pageState === 'done' && renderDone()}
</View>
);
}

View File

@@ -0,0 +1,308 @@
import Taro from '@tarojs/taro';
import type {
DeviceAdapter,
BLEDevice,
BLEConnection,
BLEConnectionState,
NormalizedReading,
SyncResult,
BLEManagerConfig,
} from './types';
const DEFAULT_CONFIG: BLEManagerConfig = {
scanTimeout: 10000,
maxReadingsPerSync: 500,
retryCount: 3,
};
export class BLEManager {
private adapters: DeviceAdapter[] = [];
private connection: BLEConnection | null = null;
private readings: NormalizedReading[] = [];
private config: BLEManagerConfig;
private scanTimer: ReturnType<typeof setTimeout> | null = null;
private onConnectionChange?: (state: BLEConnectionState) => void;
private onReadings?: (readings: NormalizedReading[]) => void;
constructor(config?: Partial<BLEManagerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/** 注册设备适配器 */
registerAdapter(adapter: DeviceAdapter): void {
this.adapters = [...this.adapters, adapter];
}
/** 设置连接状态回调 */
setOnConnectionChange(cb: (state: BLEConnectionState) => void): void {
this.onConnectionChange = cb;
}
/** 设置读数回调 */
setOnReadings(cb: (readings: NormalizedReading[]) => void): void {
this.onReadings = cb;
}
private updateState(state: BLEConnectionState, error?: string): void {
if (this.connection) {
this.connection = { ...this.connection, state, error };
}
this.onConnectionChange?.(state);
}
/** 匹配设备到适配器 */
private matchAdapter(deviceName: string): DeviceAdapter | undefined {
const lower = deviceName.toLowerCase();
return this.adapters.find((a) =>
a.supportedModels.some((m) => lower.includes(m.toLowerCase())),
);
}
/** 初始化蓝牙适配器 */
async initialize(): Promise<void> {
try {
await Taro.openBluetoothAdapter();
} catch (e: any) {
throw new Error(e.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启');
}
}
/** 扫描 BLE 设备 */
async scanDevices(): Promise<BLEDevice[]> {
await this.initialize();
const discovered = new Map<string, BLEDevice>();
const onFound = (res: any) => {
for (const device of res.devices || []) {
const name = device.name || device.localName || '';
if (!name) continue;
const adapter = this.matchAdapter(name);
if (adapter) {
discovered.set(device.deviceId, {
deviceId: device.deviceId,
name,
RSSI: device.RSSI,
localName: device.localName,
advertisData: device.advertisData,
adapter,
});
}
}
};
Taro.onBluetoothDeviceFound(onFound);
const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs);
await Taro.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined,
});
return new Promise((resolve) => {
this.scanTimer = setTimeout(async () => {
await this.stopScan();
Taro.offBluetoothDeviceFound(onFound);
resolve(Array.from(discovered.values()));
}, this.config.scanTimeout);
});
}
/** 停止扫描 */
async stopScan(): Promise<void> {
if (this.scanTimer) {
clearTimeout(this.scanTimer);
this.scanTimer = null;
}
try {
await Taro.stopBluetoothDevicesDiscovery();
} catch {
// 忽略停止扫描错误
}
}
/** 连接到设备 */
async connect(device: BLEDevice): Promise<void> {
if (!device.adapter) throw new Error('设备无适配器');
this.connection = {
deviceId: device.deviceId,
state: 'connecting',
adapter: device.adapter,
};
this.updateState('connecting');
this.readings = [];
try {
await Taro.createBLEConnection({
deviceId: device.deviceId,
timeout: 10000,
});
// 监听断连
Taro.onBLEConnectionStateChange((res: any) => {
if (res.deviceId === device.deviceId && !res.connected) {
this.updateState('disconnected', '设备断开连接');
this.connection = null;
}
});
// 发现服务
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
const services = servicesRes.services || [];
// 启用通知
for (const { service: svcUUID, characteristic: charUUID } of device.adapter.notifyCharacteristics) {
const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
if (!svc) continue;
await Taro.getBLEDeviceCharacteristics({
deviceId: device.deviceId,
serviceId: svc.uuid,
});
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
state: true,
});
}
// 监听数据通知
Taro.onBLECharacteristicValueChange((res: any) => {
if (res.deviceId !== device.deviceId) return;
const reading = device.adapter!.parseNotification(
res.serviceId,
res.characteristicId,
res.value,
);
if (reading) {
this.readings = [...this.readings, reading];
this.onReadings?.([reading]);
}
});
this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() };
this.updateState('connected');
} catch (e: any) {
this.updateState('error', e.errMsg || e.message || '连接失败');
this.connection = null;
throw new Error(e.errMsg || '蓝牙连接失败');
}
}
/** 手动读取特征值 */
async readCharacteristics(): Promise<NormalizedReading[]> {
if (!this.connection || this.connection.state !== 'connected') {
throw new Error('设备未连接');
}
const { deviceId, adapter } = this.connection;
const results: NormalizedReading[] = [];
const servicesRes = await Taro.getBLEDeviceServices({ deviceId });
const services = servicesRes.services || [];
for (const { service: svcUUID, characteristic: charUUID } of adapter.readCharacteristics) {
const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
if (!svc) continue;
try {
// Taro readBLECharacteristicValue 触发 onBLECharacteristicValueChange 回调
// 读取结果会通过 BLEManager 已注册的 onBLECharacteristicValueChange 监听器返回
await Taro.readBLECharacteristicValue({
deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
});
} catch {
// 某些特征值可能不支持读取
}
}
if (results.length > 0) {
this.readings = [...this.readings, ...results];
this.onReadings?.(results);
}
return results;
}
/** 同步收集的读数到后端 */
async syncToServer(uploadFn: (readings: NormalizedReading[]) => Promise<number>): Promise<SyncResult> {
if (!this.connection) {
return { success: false, readingsCount: 0, uploadedCount: 0, error: '未连接设备' };
}
this.updateState('syncing');
const batch = this.readings.slice(-this.config.maxReadingsPerSync);
if (batch.length === 0) {
this.updateState('connected');
return { success: true, readingsCount: 0, uploadedCount: 0 };
}
let lastError: string | undefined;
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
try {
const uploaded = await uploadFn(batch);
this.readings = this.readings.slice(batch.length);
this.updateState('connected');
return {
success: true,
readingsCount: batch.length,
uploadedCount: uploaded,
};
} catch (e: any) {
lastError = e.message || '上传失败';
}
}
this.updateState('error', lastError);
return { success: false, readingsCount: batch.length, uploadedCount: 0, error: lastError };
}
/** 断开连接 */
async disconnect(): Promise<void> {
if (!this.connection) return;
const { deviceId } = this.connection;
try {
await Taro.closeBLEConnection({ deviceId });
} catch {
// 忽略断连错误
}
this.connection = null;
this.readings = [];
this.updateState('disconnected');
}
/** 关闭蓝牙适配器 */
async destroy(): Promise<void> {
await this.disconnect();
try {
await Taro.closeBluetoothAdapter();
} catch {
// 忽略关闭错误
}
}
/** 获取当前连接信息 */
getConnection(): BLEConnection | null {
return this.connection;
}
/** 获取缓存的读数 */
getCachedReadings(): NormalizedReading[] {
return [...this.readings];
}
/** 清除缓存读数 */
clearReadings(): void {
this.readings = [];
}
}
export default new BLEManager();

View File

@@ -0,0 +1,83 @@
import type { DeviceAdapter, NormalizedReading } from '../types';
/**
* 小米手环 BLE 适配器
*
* 支持 Mi Band 7/8 等型号,使用标准 Heart Rate Service 读取心率数据。
* 未来可扩展步数0xFEE1、睡眠等 Mi Band 专有 Service。
*/
// 标准 BLE Heart Rate Service
const HRS_SERVICE = '0000180D-0000-1000-8000-00805F9B34FB';
const HRM_CHARACTERISTIC = '00002A37-0000-1000-8000-00805F9B34FB';
// 小米 Mi Band 专有 Service步数/活动量)
// const MI_BAND_SERVICE = '0000FEE1-0000-1000-8000-8000-00805F9B34FB';
/** 解析心率测量值Heart Rate Measurement 格式) */
function parseHeartRate(data: ArrayBuffer): number | null {
const view = new DataView(data);
if (view.byteLength < 2) return null;
const flags = view.getUint8(0);
// Bit 0: Heart Rate Format — 0 = UINT8, 1 = UINT16
const isUINT16 = (flags & 0x01) !== 0;
if (isUINT16) {
if (view.byteLength < 3) return null;
return view.getUint16(1, true); // little-endian
}
return view.getUint8(1);
}
export const XiaomiBandAdapter: DeviceAdapter = {
name: 'Xiaomi Band',
supportedModels: [
'Mi Band',
'Mi Smart Band',
'Xiaomi Band',
'Xiaomi Smart Band',
'MI BAND',
'MiBand',
],
serviceUUIDs: [HRS_SERVICE],
notifyCharacteristics: [
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
],
readCharacteristics: [
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
],
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading | null {
if (charUUID.toUpperCase().includes('2A37')) {
const hr = parseHeartRate(data);
if (hr !== null && hr > 0 && hr < 300) {
return {
device_type: 'heart_rate',
values: { heart_rate: hr },
measured_at: new Date().toISOString(),
};
}
}
return null;
},
parseReadResponse(
_serviceUUID: string,
_charUUID: string,
_data: ArrayBuffer,
): NormalizedReading | null {
// 读取模式暂不支持,使用通知模式获取数据
return null;
},
};
export default XiaomiBandAdapter;

View File

@@ -0,0 +1 @@
export { XiaomiBandAdapter } from './XiaomiBandAdapter';

View File

@@ -0,0 +1,11 @@
export { BLEManager } from './BLEManager';
export type {
DeviceType,
NormalizedReading,
DeviceAdapter,
BLEDevice,
BLEConnection,
BLEConnectionState,
SyncResult,
BLEManagerConfig,
} from './types';

View File

@@ -0,0 +1,89 @@
/** BLE 模块类型定义 */
/** 设备数据类型(与后端 device_readings.device_type 枚举对齐) */
export type DeviceType =
| 'heart_rate'
| 'blood_oxygen'
| 'steps'
| 'sleep'
| 'temperature'
| 'stress';
/** 标准化的设备读数 */
export interface NormalizedReading {
device_type: DeviceType;
values: Record<string, number>;
measured_at: string;
}
/** BLE 设备适配器接口 — 不同品牌设备实现此接口 */
export interface DeviceAdapter {
/** 适配器名称 */
readonly name: string;
/** 支持的设备型号关键字(用于匹配广播名称) */
readonly supportedModels: string[];
/** 需要订阅的 Service UUID 列表 */
readonly serviceUUIDs: string[];
/** 需要监听的 Characteristic用于通知模式 */
readonly notifyCharacteristics: { service: string; characteristic: string }[];
/** 需要主动读取的 Characteristic */
readonly readCharacteristics: { service: string; characteristic: string }[];
/** 解析 BLE 通知数据为标准读数 */
parseNotification(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading | null;
/** 解析 BLE 读取数据为标准读数 */
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading | null;
}
/** 扫描发现的 BLE 设备 */
export interface BLEDevice {
deviceId: string;
name: string;
RSSI: number;
localName?: string;
advertisData?: ArrayBuffer;
adapter?: DeviceAdapter;
}
/** BLE 连接状态 */
export type BLEConnectionState = 'disconnected' | 'connecting' | 'connected' | 'syncing' | 'error';
/** BLE 连接信息 */
export interface BLEConnection {
deviceId: string;
state: BLEConnectionState;
adapter: DeviceAdapter;
connectedAt?: number;
error?: string;
}
/** 同步操作结果 */
export interface SyncResult {
success: boolean;
readingsCount: number;
uploadedCount: number;
error?: string;
}
/** BLEManager 配置 */
export interface BLEManagerConfig {
/** 扫描超时(毫秒) */
scanTimeout: number;
/** 单次同步最大读数数量 */
maxReadingsPerSync: number;
/** 同步失败重试次数 */
retryCount: number;
}

View File

@@ -0,0 +1,67 @@
import { api } from './request';
import type { NormalizedReading } from './ble/types';
interface BatchReadingRequest {
device_id: string;
device_model?: string;
readings: {
device_type: string;
values: Record<string, number>;
measured_at: string;
}[];
}
interface BatchResult {
accepted: number;
duplicates: number;
earliest: string | null;
latest: string | null;
}
/** 将标准化读数转换为后端批量请求格式并上传 */
export async function uploadReadings(
patientId: string,
deviceId: string,
deviceModel: string | undefined,
readings: NormalizedReading[],
): Promise<number> {
if (readings.length === 0) return 0;
const body: BatchReadingRequest = {
device_id: deviceId,
device_model: deviceModel,
readings: readings.map((r) => ({
device_type: r.device_type,
values: r.values,
measured_at: r.measured_at,
})),
};
const result = await api.post<BatchResult>(
`/health/patients/${patientId}/device-readings/batch`,
body,
);
return result.accepted;
}
/** 查询设备原始数据 */
export async function queryDeviceReadings(
patientId: string,
params?: { device_type?: string; hours?: number },
) {
return api.get<{ data: unknown[]; total: number }>(
`/health/patients/${patientId}/device-readings`,
params,
);
}
/** 查询小时级降采样数据 */
export async function queryHourlyReadings(
patientId: string,
params: { device_type: string; days?: number },
) {
return api.get<{ data: unknown[]; total: number }>(
`/health/patients/${patientId}/device-readings/hourly`,
params,
);
}