- DataBuffer: 离线持久化缓冲(分桶存储 + 去重 + 容量管理) - GenericBleAdapter: 基于 Bluetooth SIG 标准 Health Profile 的通用适配器 (Heart Rate 0x180D / Health Thermometer 0x1809 / Blood Pressure 0x1810) - DataSyncScheduler: 定时自动同步调度(基于时间间隔判断是否需要同步) - BLEManager: 集成 DataBuffer 替换简单 Storage 缓存 - device-sync 页面: 注册 CustomBandAdapter + 自动同步 + 状态显示 - 新增 vitest 单元测试配置,30 个测试全部通过
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { createGenericBleAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
|
import type { GenericBLEProfile } from '@/services/ble/types';
|
|
|
|
// ---- Heart Rate (0x180D / 0x2A37) ----
|
|
// Flag byte=0x00 (UINT8), HR=75
|
|
function makeHeartRateData(hr: number, isUint16 = false): ArrayBuffer {
|
|
const buf = new ArrayBuffer(isUint16 ? 3 : 2);
|
|
const view = new DataView(buf);
|
|
view.setUint8(0, isUint16 ? 0x01 : 0x00);
|
|
if (isUint16) {
|
|
view.setUint16(1, hr, true);
|
|
} else {
|
|
view.setUint8(1, hr);
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
// ---- Health Thermometer (0x1809 / 0x2A1C) ----
|
|
// IEEE 11073 FLOAT: 32-bit — mantissa (24-bit) + exponent (8-bit)
|
|
function makeTemperatureData(tempCelsius: number): ArrayBuffer {
|
|
const buf = new ArrayBuffer(4);
|
|
const view = new DataView(buf);
|
|
// flags byte: 0x00 = Celsius, no timestamp, no type
|
|
view.setUint8(0, 0x00);
|
|
// 11073 FLOAT: mantissa * 10^exponent
|
|
// For 36.5: mantissa=365, exponent=-1
|
|
const mantissa = Math.round(tempCelsius * 10);
|
|
const exponent = -1;
|
|
view.setInt16(1, mantissa, true);
|
|
view.setInt8(3, exponent);
|
|
return buf;
|
|
}
|
|
|
|
describe('GenericBleAdapter', () => {
|
|
describe('心率解析', () => {
|
|
const adapter = createGenericBleAdapter({
|
|
name: 'Test Wristband',
|
|
supportedModels: ['TestBand'],
|
|
profiles: ['heart_rate'],
|
|
});
|
|
|
|
it('解析 UINT8 心率', () => {
|
|
const data = makeHeartRateData(75);
|
|
const results = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A37-0000-1000-8000-00805f9b34fb',
|
|
data,
|
|
);
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].device_type).toBe('heart_rate');
|
|
expect(results[0].values.heart_rate).toBe(75);
|
|
});
|
|
|
|
it('解析 UINT16 心率', () => {
|
|
const data = makeHeartRateData(200, true);
|
|
const results = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A37-0000-1000-8000-00805f9b34fb',
|
|
data,
|
|
);
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].values.heart_rate).toBe(200);
|
|
});
|
|
|
|
it('忽略非目标 Characteristic', () => {
|
|
const data = makeHeartRateData(75);
|
|
const results = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A38-0000-1000-8000-00805f9b34fb', // Body Sensor Location
|
|
data,
|
|
);
|
|
expect(results.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('体温解析', () => {
|
|
const adapter = createGenericBleAdapter({
|
|
name: 'Test Thermometer',
|
|
supportedModels: ['TestThermo'],
|
|
profiles: ['health_thermometer'],
|
|
});
|
|
|
|
it('解析体温读数', () => {
|
|
const data = makeTemperatureData(36.5);
|
|
const results = adapter.parseNotification(
|
|
'00001809-0000-1000-8000-00805f9b34fb',
|
|
'00002A1C-0000-1000-8000-00805f9b34fb',
|
|
data,
|
|
);
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].device_type).toBe('temperature');
|
|
expect(results[0].values.value).toBeCloseTo(36.5, 0);
|
|
});
|
|
});
|
|
|
|
describe('多 Profile 适配器', () => {
|
|
const adapter = createGenericBleAdapter({
|
|
name: 'Multi-Profile Band',
|
|
supportedModels: ['CustomBand', 'MedicalBand'],
|
|
profiles: ['heart_rate', 'health_thermometer'],
|
|
});
|
|
|
|
it('包含两个 Service UUID', () => {
|
|
expect(adapter.serviceUUIDs.length).toBe(2);
|
|
});
|
|
|
|
it('包含两个 Profile 的 Characteristic', () => {
|
|
expect(adapter.notifyCharacteristics.length).toBe(2);
|
|
});
|
|
|
|
it('supportedModels 配置正确', () => {
|
|
expect(adapter.supportedModels).toEqual(['CustomBand', 'MedicalBand']);
|
|
});
|
|
|
|
it('解析心率 + 体温', () => {
|
|
const hrResults = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A37-0000-1000-8000-00805f9b34fb',
|
|
makeHeartRateData(80),
|
|
);
|
|
expect(hrResults[0].device_type).toBe('heart_rate');
|
|
|
|
const tempResults = adapter.parseNotification(
|
|
'00001809-0000-1000-8000-00805f9b34fb',
|
|
'00002A1C-0000-1000-8000-00805f9b34fb',
|
|
makeTemperatureData(37.2),
|
|
);
|
|
expect(tempResults[0].device_type).toBe('temperature');
|
|
});
|
|
});
|
|
|
|
describe('边界情况', () => {
|
|
const adapter = createGenericBleAdapter({
|
|
name: 'Edge Case Band',
|
|
supportedModels: ['Edge'],
|
|
profiles: ['heart_rate'],
|
|
});
|
|
|
|
it('空数据返回空数组', () => {
|
|
const results = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A37-0000-1000-8000-00805f9b34fb',
|
|
new ArrayBuffer(0),
|
|
);
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
it('心率超范围 (>300) 返回空数组', () => {
|
|
const results = adapter.parseNotification(
|
|
'0000180D-0000-1000-8000-00805f9b34fb',
|
|
'00002A37-0000-1000-8000-00805f9b34fb',
|
|
makeHeartRateData(0),
|
|
);
|
|
expect(results.length).toBe(0);
|
|
});
|
|
});
|
|
});
|