Initial commit
This commit is contained in:
81
components/blecommu.uvue.bak
Normal file
81
components/blecommu.uvue.bak
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<view>
|
||||
<view>
|
||||
<button @tap="loadLogs('')">加载历史数据</button>
|
||||
</view>
|
||||
<view v-if="logs.length === 0">暂无数据</view>
|
||||
<view v-for="item in logs" :key="item.key">
|
||||
<text>{{ item.type }} | {{ item.timestamp }} | {{ item.data }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { sqliteContext } from '@/ak/sqlite.uts'
|
||||
|
||||
type LogItem = {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
data: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "blecommu",
|
||||
data() {
|
||||
return {
|
||||
logs: [] as LogItem[]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadLogs(deviceId: string = '') {
|
||||
this.logs = [];
|
||||
// 查询 ble_data_log
|
||||
sqliteContext.selectSql({
|
||||
sql: `SELECT id, device_id, service_id, char_id, direction, data, timestamp FROM ble_data_log ORDER BY timestamp DESC`,
|
||||
success: (res) => {
|
||||
console.log('查询 ble_data_log 成功', res);
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
for (const row of res.data) {
|
||||
this.logs.push({
|
||||
type: row[4] === 'send' ? '发送' : '接收',
|
||||
timestamp: parseInt(row[6]),
|
||||
data: `service:${row[2]} char:${row[3]} data:${row[5]}`,
|
||||
key: row[4] + '_' + row[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
// 查询 ble_event_log
|
||||
sqliteContext.selectSql({
|
||||
sql: `SELECT id, device_id, event_type, detail, timestamp FROM ble_event_log WHERE device_id = '${deviceId}' ORDER BY timestamp DESC`,
|
||||
success: (res2) => {
|
||||
console.log('查询 ble_event_log 成功', res2);
|
||||
if (Array.isArray(res2.data) && res2.data.length > 0) {
|
||||
for (const row of res2.data) {
|
||||
this.logs.push({
|
||||
type: '事件',
|
||||
timestamp: parseInt(row[4]),
|
||||
data: `type:${row[2]} detail:${row[3]}`,
|
||||
key: 'event_' + row[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
this.logs.sort((a, b) => b.timestamp - a.timestamp);
|
||||
},
|
||||
fail: (err2) => {
|
||||
console.log('查询 ble_event_log 失败', err2);
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('查询 ble_data_log 失败', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
439
components/ringcard.uvue
Normal file
439
components/ringcard.uvue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<view
|
||||
ref="fullscreenCard"
|
||||
class="ring-card"
|
||||
:style="[connected ? connectedstyle : '', isFullscreen ? fullscreenstyle : '']"
|
||||
>
|
||||
<!-- 顶部信息区(可选:全屏时可移到左侧) -->
|
||||
<!-- <view class="top-info"> ... </view> -->
|
||||
|
||||
<view class="main-content">
|
||||
<!-- 左侧信息+操作栏 -->
|
||||
<view class="side-bar">
|
||||
<view class="info">
|
||||
<text class="name">{{ name ?? '未知设备' }}</text>
|
||||
<text class="id" v-if="isFullscreen">({{ deviceId ?? '' }})</text>
|
||||
<text>电量: {{ batteryText }}</text>
|
||||
<text>时间: {{ time ?? '--' }}</text>
|
||||
<text>步数: {{ steps ?? '--' }}</text>
|
||||
<text>软件版本: {{ swVersion ?? '--' }}</text>
|
||||
<text>硬件版本: {{ hwVersion ?? '--' }}</text>
|
||||
<text>血氧: {{ spo2Text }}</text>
|
||||
<text>心率: {{ heartRate != null ? heartRate : '--' }}</text>
|
||||
<text>体温: {{ temperatureText }}</text>
|
||||
</view>
|
||||
<view class="actions-vertical">
|
||||
<button @click="connect" v-if="!connected">连接</button>
|
||||
<button @click="disconnect" v-if="connected">断开</button>
|
||||
<button @click="refreshInfo" v-if="connected">刷新</button>
|
||||
<button @click="measureSpo2WithEvents" v-if="connected && oximetryMeasuring==false">测血氧</button>
|
||||
<button disabled v-if="oximetryMeasuring">测量中...</button>
|
||||
<button @click="gotoHistory">历史数据</button>
|
||||
<button @click="onFullscreen" v-if="!isFullscreen" class="fullscreen-btn">全屏</button>
|
||||
<button @click="onFullscreen" v-if="isFullscreen" class="fullscreen-exit-btn">退出全屏</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 中间ak-charts和AI评论 -->
|
||||
<view class="center-area">
|
||||
<ak-charts :option="ppgChartOption" canvas-id="ppg-canvas"
|
||||
style="width: 100%; height: 40vh; background: #fff; border: 1px solid #eee; margin-top: 10px;" />
|
||||
<view class="ai-comment">
|
||||
<text>AI评论区</text>
|
||||
<!-- 这里可以插入AI分析结果 -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
// Platform-specific entrypoint: import per-platform index to avoid bundler pulling Android-only code into web builds
|
||||
// #ifdef APP-ANDROID
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
|
||||
// #endif
|
||||
// #ifdef WEB
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||||
// #endif
|
||||
import {BleEvent, BleEventPayload} from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||||
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol-handler.uts'
|
||||
import AkCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
|
||||
|
||||
type PpgWaveformItem = {
|
||||
red: number;
|
||||
ir: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
type PpgWaveformPacket= {
|
||||
seq: number;
|
||||
num: number;
|
||||
data: PpgWaveformItem[];
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AkCharts
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
deviceId: String,
|
||||
isFullscreen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
connectedstyle:'border-color: #4caf50;',
|
||||
fullscreenstyle:'padding: 24px;',
|
||||
connected: false,
|
||||
battery: null as number | null,
|
||||
time: null as string | null,
|
||||
steps: null as number | null,
|
||||
swVersion: null as string | null,
|
||||
hwVersion: null as string | null,
|
||||
spo2: null as number | null,
|
||||
heartRate: null as number | null,
|
||||
temperature: null as number | null,
|
||||
oximetryMeasuring: false as boolean,
|
||||
handler: null as ProtocolHandler | null,
|
||||
refreshTimer: null as number | null, // 定时器句柄
|
||||
oximetryTimer: null as number | null,
|
||||
ppgWaveforms: [] as PpgWaveformPacket[],
|
||||
isBigScreen: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 判断是否为大屏
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
this.isBigScreen = res.screenWidth >= 900; // 900px 仅为示例
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
computed: {
|
||||
batteryBarStyle(): any {
|
||||
const battery = this.battery;
|
||||
let width = (battery != null ? battery : 0) + '%';
|
||||
let background = '#ccc';
|
||||
if (battery != null) {
|
||||
if (battery < 20) background = '#f44336';
|
||||
else if (battery < 50) background = '#ff9800';
|
||||
else background = '#4caf50';
|
||||
}
|
||||
return { width, background };
|
||||
},
|
||||
batteryText(): string {
|
||||
const battery = this.battery;
|
||||
return battery != null ? battery.toString() + '%' : '--';
|
||||
},
|
||||
spo2Text(): string {
|
||||
const spo2 = this.spo2;
|
||||
return spo2 != null ? spo2.toString() + '%' : '--';
|
||||
},
|
||||
temperatureText(): string {
|
||||
const temperature = this.temperature;
|
||||
return temperature != null ? temperature.toString() + '℃' : '--';
|
||||
},
|
||||
// ak-charts option
|
||||
ppgChartOption(): any {
|
||||
// 展示最近240个点
|
||||
const redPoints: number[] = [];
|
||||
const irPoints: number[] = [];
|
||||
for (let i = Math.max(0, this.ppgWaveforms.length - 20); i < this.ppgWaveforms.length; i++) {
|
||||
const packet = this.ppgWaveforms[i];
|
||||
for (let j = 0; j < packet.data.length; j++) {
|
||||
redPoints.push(packet.data[j].red);
|
||||
irPoints.push(packet.data[j].ir);
|
||||
}
|
||||
}
|
||||
const labels = redPoints.map((_, idx) => idx.toString());
|
||||
return {
|
||||
type: 'line',
|
||||
data: redPoints,
|
||||
// series: [
|
||||
// { name: '红光', data: redPoints, color: '#4caf50' },
|
||||
// { name: '红外', data: irPoints, color: '#f44336' }
|
||||
// ],
|
||||
labels: labels,
|
||||
smooth: true
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async connect() {
|
||||
try {
|
||||
await bluetoothService.connectDevice(this.deviceId!, 'BLE', { timeout: 30000 });
|
||||
this.connected = true;
|
||||
await this.initHandler();
|
||||
await this.refreshInfo();
|
||||
this.clearRefreshTimer();
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.refreshInfo();
|
||||
}, 300 * 1000);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
},
|
||||
async disconnect() {
|
||||
await bluetoothService.disconnectDevice(this.deviceId!, 'BLE');
|
||||
this.connected = false;
|
||||
this.handler = null;
|
||||
this.clearRefreshTimer();
|
||||
},
|
||||
clearRefreshTimer() {
|
||||
const timer = this.refreshTimer;
|
||||
if (timer != null) {
|
||||
clearInterval(timer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
async initHandler() {
|
||||
const res = await bluetoothService.getAutoBleInterfaces(this.deviceId!);
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(this.deviceId!, res.serviceId, res.writeCharId, res.notifyCharId);
|
||||
await handler.initialize();
|
||||
handler.onPushData((data) => {
|
||||
console.log(data)
|
||||
// ...主动推送处理...
|
||||
|
||||
// 波形响应
|
||||
if (data.length >= 6 && data[2] === 0x32 && data[1] ==0x00) {
|
||||
console.log(data[3])
|
||||
this.parseBloodOxygenWave(data);
|
||||
|
||||
}
|
||||
});
|
||||
this.handler = handler;
|
||||
},
|
||||
async refreshInfo() {
|
||||
if (this.handler == null) await this.initHandler();
|
||||
const handler = this.handler;
|
||||
if (handler != null) {
|
||||
this.battery = await handler.testBatteryLevel();
|
||||
this.swVersion = await handler.testVersionInfo(false);
|
||||
this.hwVersion = await handler.testVersionInfo(true);
|
||||
this.time = await handler.testGetTime();
|
||||
this.steps = await handler.testStepCount();
|
||||
}
|
||||
},
|
||||
async measureBattery() {
|
||||
if (!this.connected) await this.connect();
|
||||
if (this.handler == null) await this.initHandler();
|
||||
const handler = this.handler;
|
||||
if (handler != null) {
|
||||
this.battery = await handler.testBatteryLevel();
|
||||
return this.battery;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async clearSteps() {
|
||||
if (!this.connected) await this.connect();
|
||||
if (this.handler == null) await this.initHandler();
|
||||
const handler = this.handler;
|
||||
if (handler != null && typeof handler.testClearStepCount() === 'function') {
|
||||
await handler.testClearStepCount();
|
||||
this.steps = 0;
|
||||
}
|
||||
},
|
||||
async measureSpo2WithEvents() {
|
||||
if (!this.connected) await this.connect();
|
||||
if (this.handler == null) await this.initHandler();
|
||||
const handler = this.handler;
|
||||
if (handler != null) {
|
||||
this.oximetryMeasuring = true;
|
||||
// 清理上一次的定时器
|
||||
const timer = this.oximetryTimer;
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
this.oximetryTimer = null;
|
||||
}
|
||||
// 设置测量超时时间(如60秒)
|
||||
this.oximetryTimer = setTimeout(() => {
|
||||
this.oximetryMeasuring = false;
|
||||
handler.oximetryWithEvents({ stop: true }, null);
|
||||
uni.showToast({ title: '血氧测量已结束', icon: 'none' });
|
||||
this.oximetryTimer = null;
|
||||
}, 60000); // 60秒
|
||||
|
||||
handler.oximetryWithEvents({}, (data: Uint8Array) => {
|
||||
console.log(data);
|
||||
// 结果响应
|
||||
if (data.length >= 9 && data[2] === 0x32 && data[3] === 0x00) {
|
||||
this.heartRate = data[5];
|
||||
this.spo2 = data[6];
|
||||
let tempRaw = (data[8] << 8) | data[7];
|
||||
if ((tempRaw & 0x8000) > 0) tempRaw = tempRaw - 0x10000;
|
||||
this.temperature = tempRaw / 100;
|
||||
}
|
||||
// 波形响应
|
||||
if (data.length >= 6 && data[2] === 0x32 && data[3] === 0x01) {
|
||||
const frameid = data[1];
|
||||
if (frameid === 0) { // 只处理frameid为0的包
|
||||
const seq = data[4];
|
||||
const num = data[5];
|
||||
const waveArr: PpgWaveformItem[] = [];
|
||||
for (let i = 0; i < num; i++) {
|
||||
const base = 6 + i * 14;
|
||||
if ((base + 13) >= data.length) break;
|
||||
let red = (data[base + 3] << 24) | (data[base + 2] << 16) | (data[base + 1] << 8) | data[base + 0];
|
||||
if ((red & 0x80000000) !== 0) red = red - 0x100000000;
|
||||
let ir = (data[base + 7] << 24) | (data[base + 6] << 16) | (data[base + 5] << 8) | data[base + 4];
|
||||
if ((ir & 0x80000000) !== 0) ir = ir - 0x100000000;
|
||||
let x = (data[base + 9] << 8) | data[base + 8];
|
||||
if ((x & 0x8000) !== 0) x = x - 0x10000;
|
||||
let y = (data[base + 11] << 8) | data[base + 10];
|
||||
if ((y & 0x8000) !== 0) y = y - 0x10000;
|
||||
let z = (data[base + 13] << 8) | data[base + 12];
|
||||
if ((z & 0x8000) !== 0) z = z - 0x10000;
|
||||
waveArr.push({ red, ir, x, y, z });
|
||||
}
|
||||
// 按seq去重拼包
|
||||
const idx = this.ppgWaveforms.findIndex(p => p.seq === seq);
|
||||
if (idx >= 0) {
|
||||
this.ppgWaveforms[idx] = { seq, num, data: waveArr };
|
||||
} else {
|
||||
this.ppgWaveforms.push({ seq, num, data: waveArr });
|
||||
}
|
||||
// ak-charts 自动响应数据变化,无需手动draw
|
||||
}
|
||||
}
|
||||
// 波形结束通知
|
||||
// if (data.length >= 4 && data[2] === 0x32 && data[3] === 0xFF) {
|
||||
// this.oximetryMeasuring = false;
|
||||
// if (this.oximetryTimer != null) {
|
||||
// const timer = this.oximetryTimer as number;
|
||||
// clearTimeout(timer);
|
||||
// this.oximetryTimer = null;
|
||||
// }
|
||||
// }
|
||||
});
|
||||
}
|
||||
},
|
||||
onConnectionStateChanged(state: number) {
|
||||
if (state === 0) {
|
||||
this.connected = false;
|
||||
this.handler = null;
|
||||
this.clearRefreshTimer();
|
||||
// 这里可以加UI变灰等处理
|
||||
} else if (state === 2) {
|
||||
this.connected = true;
|
||||
// 这里可以加UI恢复等处理
|
||||
}
|
||||
},
|
||||
gotoHistory() {
|
||||
uni.navigateTo({
|
||||
url: `/components/blecommu?deviceId=${this.deviceId}`
|
||||
});
|
||||
},
|
||||
onFullscreen() {
|
||||
if (this.isFullscreen) {
|
||||
this.$emit('exit-fullscreen', this.deviceId);
|
||||
} else {
|
||||
this.$emit('request-fullscreen', this.deviceId);
|
||||
}
|
||||
},
|
||||
parseBloodOxygenWave(data: Uint8Array) {
|
||||
const seq = data[4];
|
||||
const num = data[5];
|
||||
const waveArr: PpgWaveformItem[] = [];
|
||||
for (let i = 0; i < num; i++) {
|
||||
const base = 6 + i * 14;
|
||||
if ((base + 13) >= data.length) break;
|
||||
// 红色PPG
|
||||
let red = (data[base + 3] << 24) | (data[base + 2] << 16) | (data[base + 1] << 8) | data[base + 0];
|
||||
if ((red & 0x80000000) !== 0) red = red - 0x100000000;
|
||||
// 红外PPG
|
||||
let ir = (data[base + 7] << 24) | (data[base + 6] << 16) | (data[base + 5] << 8) | data[base + 4];
|
||||
if ((ir & 0x80000000) !== 0) ir = ir - 0x100000000;
|
||||
// X加速度
|
||||
let x = (data[base + 9] << 8) | data[base + 8];
|
||||
if ((x & 0x8000) !== 0) x = x - 0x10000;
|
||||
// Y加速度
|
||||
let y = (data[base + 11] << 8) | data[base + 10];
|
||||
if ((y & 0x8000) !== 0) y = y - 0x10000;
|
||||
// Z加速度
|
||||
let z = (data[base + 13] << 8) | data[base + 12];
|
||||
if ((z & 0x8000) !== 0) z = z - 0x10000;
|
||||
waveArr.push({ red, ir, x, y, z });
|
||||
}
|
||||
console.log(seq, num, data)
|
||||
this.ppgWaveforms.push({ seq, num, data: waveArr });
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.clearRefreshTimer();
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ring-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.side-bar {
|
||||
width: 20%;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
background: #f8f8f8;
|
||||
padding: 24px 12px 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.actions-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.center-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 24px 0 0 0;
|
||||
background-color: #fff;
|
||||
}
|
||||
.ai-comment {
|
||||
margin-top: 24px;
|
||||
width: 90%;
|
||||
min-height: 60px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
.fullscreen-btn, .fullscreen-exit-btn {
|
||||
width: 100px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user