Files
akmon/pages/sense/healthble.uvue
2026-01-20 08:04:15 +08:00

2454 lines
71 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view class="healthble-page" direction="vertical">
<view class="status-banner" @click="toggleDeviceCard">
<view class="status-main">
<l-icon class="status-icon" :name="connectionIcon" :color="connectionIconColor" size="48rpx" />
<view class="status-texts">
<text class="status-title">{{ connectionTitle }}</text>
<text class="status-subtitle">{{ connectionSubtitle }}</text>
<text v-if="connectionState == 'connected' && batteryText != '--'" class="status-battery"
:style="{ color: batteryColor }">🔋 {{ batteryText }}</text>
</view>
<text v-if="connectionState == 'connected'"
class="status-expand-icon">{{ deviceCardExpanded ? '▼' : '▶' }}</text>
</view>
<view class="status-actions">
<button class="status-btn status-btn-first" @click="toggleMonitoring"
:disabled="connectionState == 'connecting'">
{{ monitoringActionLabel }}
</button>
<button class="status-btn status-btn-secondary" @click="refreshHistory" :disabled="isLoadingHistory">
{{ isLoadingHistory ? '刷新中…' : '刷新记录' }}
</button>
</view>
</view>
<view v-if="connectionState != 'connected' || deviceCardExpanded" class="device-card">
<view class="device-header">
<view class="device-summary">
<text class="device-name">{{ deviceName }}</text>
<text class="device-battery" v-if="batteryText != '--'">🔋 {{ batteryText }}</text>
</view>
<button class="device-action" @click="onConnectPressed" :disabled="connectionState == 'connecting'">
{{ connectionButtonLabel }}
</button>
</view>
<view class="device-meta">
<text class="meta-item">信号:{{ signalText }}</text>
<text class="meta-item">最近同步:{{ lastSyncTime }}</text>
</view>
<view class="device-meta">
<text class="meta-item">监测:{{ isMonitoring ? '进行中' : '暂停' }}</text>
<text class="meta-item">模式:{{ connectionState == 'connected' ? '实时' : '演示' }}</text>
</view>
<view class="device-meta">
<text class="meta-item">硬件版本:{{ hardwareVersionText }}</text>
<text class="meta-item">软件版本:{{ softwareVersionText }}</text>
</view>
<view class="device-meta" v-if="firmwareUpdateAvailable">
<text class="meta-item firmware-update">🔄 新固件可用:{{ firmwareUpdateInfo?.version }}</text>
</view>
<view class="device-meta" v-if="firmwareUpdateInProgress">
<text class="meta-item firmware-progress">{{ firmwareUpdateProgress?.message }}
({{ firmwareUpdateProgress?.progress }}%)</text>
</view>
<view class="device-meta">
<text class="meta-item">设备时间:{{ deviceTimeText }}</text>
</view>
<button class="scan-btn" @click="scanForDevices" :disabled="isScanning">
{{ isScanning ? '扫描中…' : '扫描附近设备' }}
</button>
<view v-if="connectionState == 'connected'" class="ping-row">
<button class="ping-btn" @click="sendPingCommand" :disabled="pingInFlight">
{{ pingInFlight ? 'Ping中…' : '发送 Ping' }}
</button>
<text class="ping-status" :class="'ping-' + pingStatusTone">{{ pingStatusMessage }}</text>
</view>
<text v-if="connectionState == 'connected' && pingLastSuccessAt != ''" class="ping-meta">最近成功:{{ pingLastSuccessAt }}</text>
<button v-if="firmwareUpdateAvailable && !firmwareUpdateInProgress" class="firmware-btn primary"
@click="startFirmwareUpdate">
升级固件到 {{ firmwareUpdateInfo?.version }}
</button>
<button v-if="firmwareUpdateInProgress" class="firmware-btn secondary" disabled>
升级中... {{ firmwareUpdateProgress?.progress }}%
</button>
<scroll-view v-if="discoveredDevices.length > 0" class="device-discovery" direction="horizontal">
<view class="discovery-chip" v-for="(device, index) in discoveredDevices" :key="index"
:class="{ active: selectedDeviceId == (device.getString('deviceId') ?? '') }"
@click="selectDiscoveredDevice(device.getString('deviceId') ?? '')">
<text class="chip-name">{{ device.getString('name') ?? '未知设备' }}</text>
<text class="chip-sub">{{ formatRssi(device.getNumber('rssi') ?? 0) }}</text>
<button class="chip-connect" @click.stop="connectFromDiscovery(device)">连接</button>
</view>
</scroll-view>
</view>
<view class="quick-actions">
<view class="action-item" @click="toggleMonitoring">
<l-icon class="action-icon" :name="monitoringIcon" size="42rpx" color="#1f2937" />
<text class="action-label">{{ isMonitoring ? '暂停监测' : '开始监测' }}</text>
</view>
<view class="action-item" @click="toggleSimulation">
<l-icon class="action-icon" :name="simulationIcon" size="42rpx" color="#1f2937" />
<text class="action-label">{{ useSimulation ? '关闭模拟' : '开启模拟' }}</text>
</view>
<view class="action-item" @click="triggerSOS">
<l-icon class="action-icon" :name="sosIcon" size="42rpx" color="#dc2626" />
<text class="action-label">紧急报警</text>
</view>
<view class="action-item" @click="startRecording">
<l-icon class="action-icon" :name="recordingIcon" size="42rpx" color="#1f2937" />
<text class="action-label">录音发送</text>
</view>
<view class="action-item" @click="openDiagnostics">
<l-icon class="action-icon" :name="diagnosticsIcon" size="42rpx" color="#1f2937" />
<text class="action-label">连接诊断</text>
</view>
</view>
<view class="chart-card">
<view class="chart-header">
<view class="chart-title-row">
<text class="chart-title">实时趋势</text>
</view>
<view class="chart-controls">
<view class="time-config">
<text class="config-label">时间格式:</text>
<button class="config-btn" :class="{ active: timeFormat == 'ss' }"
@click="timeFormat = 'ss'">秒</button>
<button class="config-btn config-btn-last" :class="{ active: timeFormat == 'mm:ss' }"
@click="timeFormat = 'mm:ss'">分:秒</button>
</view>
<view class="interval-config">
<text class="config-label">间隔:</text>
<button class="config-btn" :class="{ active: timeInterval == 1 }"
@click="timeInterval = 1">1秒</button>
<button class="config-btn" :class="{ active: timeInterval == 3 }"
@click="timeInterval = 3">3秒</button>
<button class="config-btn config-btn-last" :class="{ active: timeInterval == 5 }"
@click="timeInterval = 5">5秒</button>
</view>
</view>
<view class="chart-tabs">
<button v-for="(type, idx) in chartTypes" :key="idx" class="chart-tab"
:class="{ active: activeMetric == type }" @click="switchMetric(type)">
{{ getMetricLabel(type) }}
</button>
</view>
</view>
<ak-charts class="chart-view" canvas-id="health-chart" :option="chartOption" />
</view>
<view class="metrics-card">
<view class="metric" v-for="(metric, index) in realtimeMetrics" :key="index">
<view class="metric-header">
<text class="metric-icon">{{ metric.icon }}</text>
<text class="metric-label">{{ metric.label }}</text>
</view>
<view class="metric-value-row">
<text class="metric-value">{{ metric.value }}</text>
<text class="metric-unit">{{ metric.unit }}</text>
</view>
<view class="metric-extreme">
<text class="metric-range">最高 {{ metric.high ?? '--' }}</text>
<text class="metric-range">最低 {{ metric.low ?? '--' }}</text>
</view>
</view>
</view>
<view class="history-card">
<view class="history-header">
<text class="history-title">最新记录</text>
<button class="history-more" @click="refreshHistory" :disabled="isLoadingHistory">
{{ isLoadingHistory ? '加载中…' : '刷新' }}
</button>
</view>
<view v-if="historyMeasurements.length > 0" class="history-list">
<view class="history-item" v-for="(item, index) in historyMeasurements" :key="index">
<view class="history-main">
<text class="history-type">{{ getMetricLabel(item.measurement_type ?? '') }}</text>
<text class="history-value">{{ formatHistoryValue(item) }}</text>
</view>
<text class="history-time">{{ formatHistoryTime(item.measured_at ?? '') }}</text>
</view>
</view>
<view v-else class="history-empty">
<text class="empty-text">暂无记录,点击上方按钮刷新。</text>
</view>
</view>
<view v-if="currentAnomaly != ''" class="alert-banner">
<text class="alert-icon">⚠️</text>
<text class="alert-text">{{ currentAnomaly }}</text>
</view>
<view v-if="isRecording" class="recording-panel">
<text class="recording-title">录音中 {{ recordingClock }}</text>
<view class="recording-actions">
<button class="recording-btn secondary" @click="stopRecording(false)">取消</button>
<button class="recording-btn primary" @click="stopRecording(true)">完成</button>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
// @ts-nocheck
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import akCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
import LIcon from '@/uni_modules/lime-icon/components/l-icon/l-icon.uvue'
import { state, loadDevicesWithDefault, setCurrentDevice, bindNewDevice } from '@/utils/store.uts'
import { SenseDataService, type SensorDataParams } from './senseDataService.uts'
import type { DeviceInfo, SensorMeasurement, NotifyLogItem, RealtimeMetric, ChartBuffer, HealthSample } from './types.uts'
import { ProtocolHandler, type PingResult } from '@/uni_modules/ak-sbsrv/utssdk/protocol_handler.uts'
import type { BleDevice, BleEventPayload, BleService, BleCharacteristic, BluetoothService, BleConnectOptionsExt, HealthData } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
import { processHeartRateData, processSpo2Data, processStepsData, createMultiMetricCharts, getLatestValue, hasEnoughDataForChart, type ChartOption } from '@/uts/utils/chartDataUtils.uts'
import { dfuTool, DFUTool, type FirmwareInfo, type DFUProgress } from '@/uts/utils/dfu.uts'
// #ifdef APP-ANDROID
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
// #endif
// #ifndef APP-ANDROID
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
// #endif
import { BLE_PREFIX } from '@/utils/bleConfig.uts'
// In-memory log of incoming notify packets for debugging/export
defineOptions({
components: {
'l-icon': LIcon
}
})
const ICON_FONT_FAMILY = 'keyicon'
const ICON_FONT_SOURCE = 'url("https://at.alicdn.com/t/c/font_4741157_ul7wcp52yys.ttf")'
let iconFontLoaded = false
let iconFontLoading = false
function ensureIconFontLoaded() : void {
if (iconFontLoaded || iconFontLoading) {
return
}
if (typeof uni.loadFontFace != 'function') {
return
}
iconFontLoading = true
try {
uni.loadFontFace({
family: ICON_FONT_FAMILY,
source: ICON_FONT_SOURCE,
success: (res: any) => {
iconFontLoaded = true
},
fail: (err: any) => {
console.warn('[healthble] loadFontFace failed', err)
},
complete: (res: any) => {
iconFontLoading = false
}
})
} catch (err) {
iconFontLoading = false
console.warn('[healthble] loadFontFace threw', err)
}
}
function getBluetoothService() : BluetoothService {
return bluetoothService
}
const selectedDeviceId = ref<string>('')
onLoad((options : OnLoadOptions) => {
ensureIconFontLoaded()
})
// UI event handler functions - must be defined before template usage
function selectDiscoveredDevice(deviceId : string) {
if (deviceId == '') return
selectedDeviceId.value = deviceId
}
const bluetoothSvc = getBluetoothService()
const protocolHandler = new ProtocolHandler(bluetoothSvc)
const REQUIRED_SERVICE_UUIDS = [
'0000180a-0000-1000-8000-00805f9b34fb', // Device Information
'00001805-0000-1000-8000-00805f9b34fb', // Current Time
'0000180f-0000-1000-8000-00805f9b34fb' // Battery Service
]
const connectionState = ref<string>('disconnected')
const isMonitoring = ref<boolean>(false)
const isScanning = ref<boolean>(false)
const isLoadingHistory = ref<boolean>(false)
const batteryLevel = ref<number | null>(null)
const isCharging = ref<boolean>(false)
const deviceCardExpanded = ref<boolean>(false)
const useSimulation = ref<boolean>(false)
const signalRssi = ref<number | null>(null)
const lastSyncTime = ref<string>('--')
const activeMetric = ref<string>('heart_rate')
const currentAnomaly = ref<string>('')
const isRecording = ref<boolean>(false)
const recordingSeconds = ref<number>(0)
const discoveredDevices = ref<Array<UTSJSONObject>>([])
// 可配置的设备名前缀;置空则不进行前缀过滤
const blePrefix = ref<string>(BLE_PREFIX ?? 'CF')
const hardwareVersion = ref<string>('--')
const softwareVersion = ref<string>('--')
const deviceTimeDisplay = ref<string>('--')
const deviceInfoLoading = ref<boolean>(false)
const healthSubscribed = ref<boolean>(false)
const pingInFlight = ref<boolean>(false)
const pingStatusMessage = ref<string>('未测试')
const pingStatusTone = ref<string>('idle')
const pingLastSuccessAt = ref<string>('')
// 固件升级相关状态
const firmwareUpdateAvailable = ref<boolean>(false)
const firmwareUpdateInfo = ref<FirmwareInfo | null>(null)
const firmwareUpdateProgress = ref<DFUProgress | null>(null)
const firmwareUpdateInProgress = ref<boolean>(false)
const monitoringIcon = computed(() : string => isMonitoring.value ? 'pause-circle' : 'play-circle')
const simulationIcon = computed(() : string => useSimulation.value ? 'chart-analytics' : 'chart-line-data')
const sosIcon = 'alarm'
const recordingIcon = 'microphone'
const diagnosticsIcon = 'bug-report'
const baseMetrics : Array<RealtimeMetric> = [
{ type: 'heart_rate', label: '心率', icon: '❤️', value: '--', unit: 'bpm', high: '--', low: '--' },
{ type: 'spo2', label: '血氧', icon: '🩸', value: '--', unit: '%', high: '--', low: '--' },
{ type: 'steps', label: '步数', icon: '👟', value: '--', unit: '步', high: '--', low: '--' },
{ type: 'speed', label: '速度', icon: '🏃', value: '--', unit: 'km/h', high: '--', low: '--' }
]
const metricsSeed = [] as Array<RealtimeMetric>
const notifyLog = ref<NotifyLogItem[]>([])
function getChartColor(type : string) : string {
if (type == 'heart_rate') return '#FF6B6B'
if (type == 'spo2') return '#2196F3'
if (type == 'steps') return '#4CAF50'
if (type == 'speed') return '#FFA726'
return '#5C6BC0'
}
function createChartOption(metricType : string) : UTSJSONObject {
// 使用新的数据处理工具
const healthDataArray = notifyLog.value.map((log : NotifyLogItem) => log.pkt as HealthData).filter((pkt : HealthData | null) => pkt != null) as Array<HealthData>
const interval = 1 // 默认使用1秒间隔
if (healthDataArray.length == 0) {
return new UTSJSONObject()
}
let chartOptionData : ChartOption | null = null
if (metricType === 'heart_rate') {
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
} else if (metricType === 'spo2') {
chartOptionData = processSpo2Data(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
} else if (metricType === 'steps') {
chartOptionData = processStepsData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
} else if (metricType === 'overview') {
// Overview: show single heart-rate series (line with points) sampled at configured interval
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: 1 }) as ChartOption
} else {
// 默认使用心率数据
chartOptionData = processHeartRateData(healthDataArray as Array<HealthData>, { maxPoints: 100, frequency: interval }) as ChartOption
}
// 转换为UTSJSONObject格式
const option = new UTSJSONObject()
if (chartOptionData != null) {
option.set('type', chartOptionData.type)
option.set('data', chartOptionData.data)
const labels = chartOptionData.labels ?? []
option.set('labels', labels as Array<string>)
const color = chartOptionData.color ?? getChartColor(metricType)
option.set('color', color as string)
}
return option
}
const realtimeMetrics = ref<Array<RealtimeMetric>>(metricsSeed)
const chartBuffers = ref<Array<ChartBuffer>>([
{ type: 'heart_rate', data: [], labels: [] },
{ type: 'spo2', data: [], labels: [] },
{ type: 'steps', data: [], labels: [] },
{ type: 'speed', data: [], labels: [] }
])
const chartOption = ref<UTSJSONObject>(createChartOption('heart_rate'))
const historyMeasurements = ref<Array<SensorMeasurement>>([])
const chartTypes = ['overview', 'heart_rate', 'spo2', 'steps', 'speed']
// 时间轴配置
const timeFormat = ref<'ss' | 'mm:ss'>('ss')
const timeInterval = ref<1 | 3 | 5>(1)
// 用于存储间隔缓冲区和时间
const intervalBuffers = new Map<string, Array<number>>()
const lastIntervalTimes = new Map<string, number>()
let monitoringTimer : number | null = null
let recordingTimer : number | null = null
let chartLastUpdateTime = 0 // 图表最后更新时间戳
let heartRateHigh = 0
let heartRateLow = 999
let spo2High = 0
let spo2Low = 100
let speedHigh = 0
const dailyStepTotal = ref<number>(0)
function appendNotifyLog(pkt : HealthData, hex : string | null, b64 : string | null) {
try {
const item : NotifyLogItem = {
ts: Date.now(),
pkt: pkt as HealthData,
hex: hex,
b64: b64
}
notifyLog.value.push(item)
// keep bounded to avoid unbounded memory growth
if (notifyLog.value.length > 5000) {
notifyLog.value.shift()
}
} catch (e) { console.warn('[healthble] appendNotifyLog failed', e) }
}
function switchMetric(type : string) {
activeMetric.value = type
}
function stopRecording(save : boolean) {
if (!isRecording.value) return
isRecording.value = false
if (recordingTimer != null) {
clearInterval(recordingTimer as number)
recordingTimer = null
}
if (save) {
uni.showToast({ title: '录音已保存', icon: 'success' })
} else {
uni.showToast({ title: '录音已取消', icon: 'none' })
}
}
// #ifdef WEB
function _fallbackBlob(dump : string) {
try {
if (typeof document != 'undefined' && typeof window != 'undefined') {
const blob = new Blob([dump], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1500)
return
}
console.log('[healthble] exportNotifyLog fallback output:', dump)
} catch (e) { console.warn('[healthble] exportNotifyLog fallback failed', e) }
}
// #endif
function exportNotifyLog() {
try {
const dump = JSON.stringify(notifyLog.value, null, 2)
// Try uni-app FileSystemManager when available (mini-programs / native runtimes)
try {
if (typeof uni.getFileSystemManager == 'function') {
const fs = uni.getFileSystemManager()
const name = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
let filePath = name
try {
// prefer USER_DATA_PATH when present (mini-programs / app)
if (uni.env != null && uni.env.USER_DATA_PATH != null) filePath = `${uni.env.USER_DATA_PATH}/${name}`
} catch (e) { /* ignore env issues */ }
fs.writeFile({
filePath,
data: dump,
encoding: 'utf8',
success: () => {
console.log('[healthble] exportNotifyLog wrote file to', filePath)
try { uni.showToast({ title: '日志已保存', icon: 'none' }) } catch (e) { }
},
fail: (err) => {
console.warn('[healthble] exportNotifyLog writeFile failed', err)
// fallback to browser download
// #ifdef WEB
_fallbackBlob(dump)
// #endif
}
})
return
}
} catch (e) {
console.warn('[healthble] exportNotifyLog uni write attempt failed', e)
}
// Browser / H5 fallback: trigger a download
// #ifdef WEB
_fallbackBlob(dump)
// #endif
} catch (e) { console.warn('[healthble] exportNotifyLog failed', e) }
}
function clearNotifyLog() {
notifyLog.value = []
}
function parseHealthSampleFromRaw(rawValue : Uint8Array | null, fallbackTs : number) : HealthSample | null {
if (rawValue == null) return null
// 简化处理假设raw数据是简单的二进制格式
// 这里可以根据实际的协议格式进行解析
// 目前返回null因为具体的解析逻辑需要根据设备协议确定
return null
}
function getChartBuffer(metricType : string) : ChartBuffer {
const buffers = chartBuffers.value
for (let i : Int = 0; i < buffers.length; i++) {
const buffer = buffers[i]
if (buffer.type == metricType) return buffer
}
const buffer : ChartBuffer = { type: metricType, data: [], labels: [] }
buffers.push(buffer)
return buffer
}
function formatTimeLabel(date : Date) : string {
if (timeFormat.value == 'ss') {
const seconds = date.getSeconds().toString().padStart(2, '0')
return seconds
} else { // 'mm:ss'
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return `${minutes}:${seconds}`
}
}
function appendChartPoint(type : string, value : number, timestamp : Date) {
try {
// 为每个指标类型维护临时缓冲区来收集当前时间间隔内的数据
const bufferKey = `${type}_intervalBuffer`
const lastIntervalKey = `${type}_lastIntervalTime`
// 确保间隔缓冲区存在
if (!intervalBuffers.has(bufferKey)) {
intervalBuffers.set(bufferKey, [])
}
if (!lastIntervalTimes.has(lastIntervalKey)) {
lastIntervalTimes.set(lastIntervalKey, timestamp.getTime())
}
// 安全读取
const intervalBuffer = intervalBuffers.get(bufferKey) as Array<number>
const lastIntervalTime = lastIntervalTimes.get(lastIntervalKey) as number
const currentTime = timestamp.getTime()
const intervalMs = 1000 // 默认1秒间隔
// 强制值为数字(防止意外类型)
let numericValue = 0
if (value != null) {
numericValue = value as number
}
// 添加当前值到缓冲区
intervalBuffer.push(numericValue)
// 检查是否到达时间间隔结束
if (currentTime - lastIntervalTime >= intervalMs) {
// 找出当前间隔内的最高值(防御性编程:处理非数组或空数组)
let maxValue = numericValue
if (Array.isArray(intervalBuffer) && intervalBuffer.length > 0) {
try {
const values = intervalBuffer.map(v => v as number)
maxValue = Math.max(...values)
} catch (e) {
// 如果 Math.max 仍出错,保守回退为当前值
console.warn('[healthble] Math.max failed on intervalBuffer, fallback to numericValue', e)
maxValue = numericValue
}
}
// 添加最高值到图表
const buffer = getChartBuffer(type)
buffer.data.push(maxValue)
buffer.labels.push(formatTimeLabel(timestamp))
// 使用与createChartOption相同的maxPoints配置
const maxPoints = 100
const scrollThreshold = Math.floor(maxPoints * 0.75) // 75%时开始滚动
// 当达到滚动阈值时,开始移除最旧的数据以实现滚动效果
if (buffer.data.length > scrollThreshold) {
buffer.data.shift()
buffer.labels.shift()
}
// 确保不超过最大点数
if (buffer.data.length > maxPoints) {
buffer.data.shift()
buffer.labels.shift()
}
// 控制图表更新频率每0.75秒最多更新一次
const now = Date.now()
const updateInterval = 750 // 0.75秒
if (activeMetric.value == type && (now - chartLastUpdateTime) >= updateInterval) {
chartOption.value = createChartOption(type)
chartLastUpdateTime = now
}
// 重置缓冲区和时间戳
intervalBuffers.set(bufferKey, [])
lastIntervalTimes.set(lastIntervalKey, currentTime)
}
} catch (err) {
console.error('[healthble] appendChartPoint error', err, { type, value, timestamp })
}
}
function pad2(value : number) : string {
return value.toString().padStart(2, '0')
}
function updateMetricValue(type : string, value : number, high : number | null, low : number | null) {
const metrics = realtimeMetrics.value
for (let i : Int = 0; i < metrics.length; i++) {
const metric = metrics[i]
if (metric.type == type) {
let formatted = ''
if (type == 'speed') {
formatted = value.toFixed(1)
} else if (type == 'spo2') {
formatted = value.toFixed(0)
} else {
formatted = value.toFixed(0)
}
metric.value = formatted
if (high != null) {
metric.high = (type == 'speed' ? high.toFixed(1) : high.toFixed(0))
}
if (low != null) {
metric.low = (type == 'speed' ? low.toFixed(1) : low.toFixed(0))
}
return
}
}
}
function formatFullTime(date : Date) : string {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
const second = date.getSeconds().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
function applySample(sample : HealthSample, fromDevice : boolean) {
if (heartRateHigh < sample.heartRate) heartRateHigh = sample.heartRate
if (heartRateLow > sample.heartRate) heartRateLow = sample.heartRate
if (heartRateLow == 999) heartRateLow = sample.heartRate
if (spo2High < sample.spo2) spo2High = sample.spo2
if (spo2Low > sample.spo2) spo2Low = sample.spo2
if (spo2Low == 100) spo2Low = sample.spo2
if (speedHigh < sample.speed) speedHigh = sample.speed
const stepIncrement = Math.max(0, Math.round(sample.steps))
dailyStepTotal.value = dailyStepTotal.value + stepIncrement
updateMetricValue('heart_rate', sample.heartRate, heartRateHigh, heartRateLow)
updateMetricValue('spo2', sample.spo2, spo2High, spo2Low)
updateMetricValue('speed', sample.speed, speedHigh, 0)
updateMetricValue('steps', dailyStepTotal.value, dailyStepTotal.value, Math.max(0, dailyStepTotal.value - stepIncrement))
appendChartPoint('heart_rate', sample.heartRate, sample.timestamp)
appendChartPoint('spo2', sample.spo2, sample.timestamp)
appendChartPoint('speed', sample.speed, sample.timestamp)
appendChartPoint('steps', dailyStepTotal.value, sample.timestamp)
if (sample.heartRate > 115) {
currentAnomaly.value = '心率偏高,请注意休息或降低运动强度'
} else if (sample.spo2 < 94) {
currentAnomaly.value = '血氧低于安全阈值,建议暂停运动并深呼吸'
} else {
currentAnomaly.value = ''
}
if (fromDevice) {
lastSyncTime.value = formatFullTime(sample.timestamp)
if (signalRssi.value == null) {
signalRssi.value = -55 + Math.floor(Math.random() * 6)
}
}
}
function handleHealthData(packet : HealthData) : void {
try { console.log('[healthble] notify packet', packet) } catch (e) { }
appendNotifyLog(packet, null, null)
const baseTs = packet.timestamp ?? Date.now()
const packetType = packet.type ?? ''
if (packetType == 'heart') {
const heart = packet.heartRate ?? packet.pulse
if (heart != null && heart > 0) {
applySample({ heartRate: heart, spo2: 0, steps: 0, speed: 0, timestamp: new Date(baseTs) }, true)
return
}
} else if (packetType == 'spo2') {
const spo2 = packet.spo2
if (spo2 != null && spo2 > 0) {
applySample({ heartRate: 0, spo2: spo2, steps: 0, speed: 0, timestamp: new Date(baseTs) }, true)
return
}
} else if (packetType == 'steps') {
const steps = packet.steps
if (steps != null && steps >= 0) {
applySample({ heartRate: 0, spo2: 0, steps: steps, speed: 0, timestamp: new Date(baseTs) }, true)
return
}
} else if (packetType == 'raw') {
const sample = parseHealthSampleFromRaw(packet.raw, baseTs)
if (sample != null) {
applySample(sample, true)
return
}
}
const heartFallback = packet.heartRate ?? packet.pulse
const spo2Fallback = packet.spo2
const stepsFallback = packet.steps
if ((heartFallback != null && heartFallback > 0) || (spo2Fallback != null && spo2Fallback > 0) || (stepsFallback != null && stepsFallback >= 0)) {
applySample({
heartRate: heartFallback ?? 0,
spo2: spo2Fallback ?? 0,
steps: stepsFallback ?? 0,
speed: 0,
timestamp: new Date(baseTs)
}, true)
}
}
for (let i : Int = 0; i < baseMetrics.length; i++) {
const item = baseMetrics[i]
const metric : RealtimeMetric = {
type: item.type,
label: item.label,
icon: item.icon,
value: item.value,
unit: item.unit,
high: item.high,
low: item.low
}
metricsSeed.push(metric)
}
const connectedDevice = computed<DeviceInfo | null>(() => state.deviceState.currentDevice)
const deviceName = computed<string>(() => {
const device = connectedDevice.value
if (device != null) {
const name = device.device_name
if (name != null && name != '') return name
}
if (state.deviceState.devices.length > 0) {
const fallback = state.deviceState.devices[0]
const fallbackName = fallback.device_name
if (fallbackName != null && fallbackName != '') return fallbackName
}
return '健康手环'
})
const connectionIcon = computed<string>(() => {
const stateValue = connectionState.value
if (stateValue == 'connected') return 'bluetooth'
if (stateValue == 'connecting') return 'loading'
if (stateValue == 'scanning') return 'search'
return 'close-circle'
})
const connectionIconColor = computed<string>(() => {
const stateValue = connectionState.value
if (stateValue == 'connected') return '#22c55e'
if (stateValue == 'connecting') return '#facc15'
if (stateValue == 'scanning') return '#2563eb'
return '#ef4444'
})
const connectionTitle = computed<string>(() => {
switch (connectionState.value) {
case 'connected':
return deviceName.value
case 'connecting':
return '正在连接设备'
case 'scanning':
return '正在扫描设备'
default:
return '设备未连接'
}
})
const monitoringActionLabel = computed<string>(() => isMonitoring.value ? '暂停监测' : '开始监测')
const batteryText = computed<string>(() => {
if (batteryLevel.value == null) return '--'
const percent = Math.max(0, Math.min(100, Math.round(batteryLevel.value as number)))
return isCharging.value ? `${percent}% (充电中)` : `${percent}%`
})
const batteryColor = computed<string>(() => {
if (batteryLevel.value == null) return '#666'
const percent = Math.max(0, Math.min(100, Math.round(batteryLevel.value as number)))
if (percent < 20) return '#ef4444' // 红色
if (percent < 50) return '#f59e0b' // 橙色
return '#10b981' // 绿色
})
function describeSignal(rssi : number) : string {
if (rssi >= -60) return '强'
if (rssi >= -75) return '中'
return '弱'
}
const signalText = computed<string>(() => {
const rssi = signalRssi.value
if (rssi == null) return '未检测'
const strength = describeSignal(rssi as number)
return `${strength} (${rssi} dBm)`
})
const connectionButtonLabel = computed<string>(() => {
if (connectionState.value == 'connected') return '断开设备'
if (connectionState.value == 'connecting') return '连接中…'
return '连接设备'
})
function formatDuration(seconds : number) : string {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const recordingClock = computed<string>(() => formatDuration(recordingSeconds.value))
const hardwareVersionText = computed<string>(() => hardwareVersion.value != '' ? hardwareVersion.value : '--')
const softwareVersionText = computed<string>(() => softwareVersion.value != '' ? softwareVersion.value : '--')
const deviceTimeText = computed<string>(() => {
if (deviceInfoLoading.value) return '获取中…'
if (deviceTimeDisplay.value == '') return '--'
return deviceTimeDisplay.value
})
function getMetricLabel(type : string) : string {
if (type == 'overview') return '总览'
if (type == 'heart_rate') return '心率'
if (type == 'spo2') return '血氧'
if (type == 'steps') return '步数'
if (type == 'speed') return '速度'
return type
}
function generateSample() : HealthSample {
const now = new Date()
const heart = 72 + Math.sin(now.getTime() / 8000) * 8 + Math.random() * 3
const spo2 = 97 + Math.sin(now.getTime() / 12000) * 1 + Math.random() * 0.8
const steps = Math.random() * 10
const speed = 5.5 + Math.sin(now.getTime() / 9000) * 1.4 + Math.random() * 0.6
return {
heartRate: heart,
spo2: spo2,
steps: steps,
speed: speed,
timestamp: now
}
}
function startSimulation() {
if (monitoringTimer != null) return
monitoringTimer = setInterval(() => {
const sample = generateSample()
applySample(sample, false)
}, 3000) as number
}
function stopSimulation() {
if (monitoringTimer != null) {
clearInterval(monitoringTimer as number)
monitoringTimer = null
}
}
function startMonitoring() {
if (!isMonitoring.value) {
isMonitoring.value = true
}
if (useSimulation.value) {
startSimulation()
applySample(generateSample(), false)
}
}
function stopMonitoring() {
isMonitoring.value = false
stopSimulation()
// Platform-specific handling: use uni file API on non-H5, and Blob download on H5
const dump = JSON.stringify(notifyLog.value, null, 2)
// #ifndef H5
try {
const fs = uni.getFileSystemManager()
const name = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
let filePath = name
try { if (uni.env != null && uni.env.USER_DATA_PATH != null) filePath = `${uni.env.USER_DATA_PATH}/${name}` } catch (e) { }
fs.writeFile({
filePath, data: dump, success: () => {
console.log('[healthble] exportNotifyLog wrote file to', filePath)
try { uni.showToast({ title: '日志已保存', icon: 'none' }) } catch (e) { }
}, fail: (err) => {
console.warn('[healthble] exportNotifyLog writeFile failed', err)
// If write fails, fall through to H5 fallback if compiled for H5, otherwise log
console.log('[healthble] exportNotifyLog fallback to console')
}
})
return
} catch (e) {
console.warn('[healthble] exportNotifyLog write attempt failed', e)
}
// #endif
// #ifdef H5
// Browser / H5: trigger a download via Blob
try {
const blob = new Blob([dump], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `notify-log-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1500)
return
} catch (e) { console.warn('[healthble] exportNotifyLog H5 fallback failed', e); }
// #endif
}
function toggleMonitoring() {
if (isMonitoring.value) {
stopMonitoring()
} else {
startMonitoring()
}
}
function toggleSimulation() {
useSimulation.value = !useSimulation.value
if (useSimulation.value && isMonitoring.value) {
startSimulation()
applySample(generateSample(), false)
} else if (!useSimulation.value) {
stopSimulation()
}
}
function toggleDeviceCard() {
if (connectionState.value == 'connected') {
deviceCardExpanded.value = !deviceCardExpanded.value
}
}
function addDiscoveredDevice(device : BleDevice) {
if (device == null) return
// 若配置了前缀,则只显示以该前缀开头的设备(不区分大小写)
const prefix = (blePrefix.value ?? '').toString().trim()
const devName = (device.name ?? '').toString()
if (devName == '') return
if (prefix != '' && !devName.toUpperCase().startsWith(prefix.toUpperCase())) return
const deviceId = device.deviceId ?? ''
const currentList = discoveredDevices.value
const list = [] as Array<UTSJSONObject>
for (let i : Int = 0; i < currentList.length; i++) {
const existing = currentList[i]
const existingId = existing.getString('deviceId') ?? ''
if (existingId != deviceId) {
list.push(existing)
}
}
const obj = new UTSJSONObject()
obj.set('deviceId', deviceId)
obj.set('name', device.name ?? '未知设备')
obj.set('rssi', device.rssi ?? 0)
list.push(obj)
discoveredDevices.value = list
}
async function simulateDiscovery() {
await new Promise<void>((resolve) => {
setTimeout(() => {
const mock = new UTSJSONObject()
mock.set('deviceId', 'mock-device')
mock.set('name', '演示健康手环')
mock.set('rssi', -58)
discoveredDevices.value = [mock]
resolve()
}, 1200)
})
}
async function scanForDevices() {
if (isScanning.value) return
isScanning.value = true
discoveredDevices.value = []
const previousState = connectionState.value
connectionState.value = 'scanning'
try {
if (bluetoothSvc != null) {
// #ifdef APP-ANDROID
await bluetoothSvc.scanDevices({
optionalServices: REQUIRED_SERVICE_UUIDS,
onDeviceFound: (device : BleDevice) => {
addDiscoveredDevice(device)
}
})
// #endif
// #ifndef APP-ANDROID
await bluetoothSvc.scanDevices({ optionalServices: REQUIRED_SERVICE_UUIDS })
// #endif
} else {
await simulateDiscovery()
}
} catch (e) {
console.log('扫描设备失败', e)
await simulateDiscovery()
} finally {
isScanning.value = false
if (connectionState.value == 'scanning') {
connectionState.value = previousState
}
}
}
function getPreferredDeviceId() : string {
if (selectedDeviceId.value != '') return selectedDeviceId.value
const device = connectedDevice.value
if (device != null) {
return device.device_mac ?? device.id
}
if (state.deviceState.devices.length > 0) {
const first = state.deviceState.devices[0]
return first.device_mac ?? first.id
}
return ''
}
function decodeTimeBuffer(buffer : ArrayBuffer, format : string) : string {
const arr = new Uint8Array(buffer)
if (arr.length == 0) return ''
if (format == 'string') {
try {
const text = new TextDecoder().decode(arr).trim()
if (text != '') return text
} catch (e) {
return ''
}
}
if (arr.length < 7) return ''
const year = arr[0] + (arr[1] << 8)
const month = arr[2]
const day = arr[3]
const hour = arr[4]
const minute = arr[5]
const second = arr[6]
if (year <= 0 || month <= 0 || day <= 0) return ''
return `${year}-${pad2(month)}-${pad2(day)} ${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
}
async function tryReadTimeFromService(deviceId : string, serviceId : string) : Promise<string> {
if (serviceId == '') {
return ''
}
let charsRaw : Array<BleCharacteristic> = []
try {
const raw = await bluetoothSvc.getCharacteristics(deviceId, serviceId)
charsRaw = raw as Array<BleCharacteristic>
} catch (e) {
console.log('获取特征失败', serviceId, e)
return ''
}
if (charsRaw.length == 0) return ''
for (let i : Int = 0; i < charsRaw.length; i++) {
const ch = charsRaw[i] as BleCharacteristic
if (ch == null || ch.uuid == null) continue
const charUuidLower = ('' + ch.uuid).toLowerCase()
let format : string = ''
if (charUuidLower.indexOf('2a2b') != -1 || charUuidLower.indexOf('current') != -1) {
format = 'current'
} else if (charUuidLower.indexOf('2a08') != -1 || charUuidLower.indexOf('date') != -1) {
format = 'datetime'
} else if (charUuidLower.indexOf('2a0f') != -1 || charUuidLower.indexOf('exact') != -1) {
format = 'exact'
} else if (charUuidLower.indexOf('time') != -1) {
format = 'string'
}
if (format == '') continue
try {
const buffer = await bluetoothSvc.readCharacteristic(deviceId, serviceId, ch.uuid)
const parsed = decodeTimeBuffer(buffer, format)
if (parsed != '') return parsed
} catch (readErr) {
console.log('读取时间特征失败', ch.uuid, readErr)
}
}
return ''
}
async function readDeviceCurrentTime(deviceId : string) : Promise<string> {
if (bluetoothSvc == null || deviceId == '') return ''
let servicesRaw : Array<BleService> = []
try {
const raw = await bluetoothSvc.getServices(deviceId)
servicesRaw = raw as Array<BleService>
} catch (e) {
console.log('获取服务列表失败', e)
return ''
}
if (servicesRaw.length == 0) return ''
const primaryCandidates : Array<string> = []
const secondaryCandidates : Array<string> = []
for (let i : Int = 0; i < servicesRaw.length; i++) {
const svc = servicesRaw[i] as BleService
if (svc == null || svc.uuid == null) continue
const uuidLower = ('' + svc.uuid).toLowerCase()
if (uuidLower.indexOf('1805') != -1 || uuidLower.indexOf('time') != -1) {
primaryCandidates.push(svc.uuid)
} else {
secondaryCandidates.push(svc.uuid)
}
}
const orderedServices = primaryCandidates.concat(secondaryCandidates)
for (let i : Int = 0; i < orderedServices.length; i++) {
const serviceId = orderedServices[i]
const value = await tryReadTimeFromService(deviceId, serviceId)
if (value != '') return value
}
return ''
}
function resetDeviceInfo() : void {
hardwareVersion.value = '--'
softwareVersion.value = '--'
deviceTimeDisplay.value = '--'
deviceInfoLoading.value = false
batteryLevel.value = null
isCharging.value = false
pingInFlight.value = false
pingStatusMessage.value = '未测试'
pingStatusTone.value = 'idle'
pingLastSuccessAt.value = ''
}
async function fetchDeviceInfo(deviceId : string) {
if (deviceId == '') return
protocolHandler.setConnectionParameters(deviceId, '', '', '')
try {
await protocolHandler.prepareControlChannel()
} catch (prepErr) {
console.warn('[healthble] prepareControlChannel during fetchDeviceInfo failed', prepErr)
}
deviceInfoLoading.value = true
try {
let swVersion = ''
// normalizeVersion: safely produce a trimmed string from various return types
function normalizeVersion(v : any) : string {
if (v == null) return ''
if (typeof v == 'string') {
const s = v.trim()
if (s == '' || /^\[object\s+.+\]$/.test(s)) return ''
return s
}
// numeric arrays like [65,66,67] or ArrayBuffer views
try {
if (v instanceof Uint8Array) {
const s = new TextDecoder().decode(v).trim()
return s
}
if (v != null && v instanceof ArrayBuffer) {
const s = new TextDecoder().decode(new Uint8Array(v as Array<number>)).trim()
return s
}
if (Array.isArray(v)) {
// numeric byte arrays
if (v.length > 0 && v.every((x : any) => typeof x == 'number')) {
const s = new TextDecoder().decode(new Uint8Array(v as Array<number>)).trim()
return s
}
// array of strings
if (v.length > 0 && v.every((x : any) => typeof x == 'string')) {
return v.join('').trim()
}
// array of objects: try to extract known fields from first element
if (v.length > 0 && typeof v[0] == 'object') {
const first = v[0] as UTSJSONObject
const candidates = ['version', 'sw', 'swVersion', 'software', 'firmware', 'fw']
for (let i = 0; i < candidates.length; i++) {
const key = candidates[i]
const value = first[key] ?? null
if (value != null) return normalizeVersion(value)
}
return ''
}
}
} catch (e) { /* ignore decode errors */ }
// If it's an object, try to extract common fields instead of stringifying
if (typeof v == 'object') {
const candidates = ['version', 'sw', 'swVersion', 'software', 'firmware', 'fw']
for (let i = 0; i < candidates.length; i++) {
const k = candidates[i]
const value = (v as UTSJSONObject).get(k) as string | null
if (value != null) {
return normalizeVersion(value)
}
}
return '' // avoid '[object Object]'
}
try {
const s = ('' + v).trim()
if (s == '' || /^\[object\s+.+\]$/.test(s)) return ''
return s
} catch (e) { return '' }
}
try {
console.log('读取软件版本: 0', swVersion)
swVersion = await protocolHandler.testVersionInfo(false)
console.log('读取软件版本: 1', swVersion)
} catch (swError) {
console.log('读取软件版本失败', swError)
}
// Debug: log raw return value shapes to diagnose 'object,object' UI
try {
console.log('[healthble] raw swVersion:', swVersion)
console.log('[healthble] swVersion type:', typeof swVersion, 'isArray=', Array.isArray(swVersion))
if (swVersion != null && typeof swVersion == 'object' && !Array.isArray(swVersion)) {
// show keys for object
const swObj = swVersion as UTSJSONObject
console.log('[healthble] swVersion:', swObj)
}
} catch (e) { /* ignore logging errors */ }
const swStr = normalizeVersion(swVersion)
if (swStr == '') {
softwareVersion.value = '--'
} else {
softwareVersion.value = swStr
}
let hwVersion = ''
try {
console.log('读取硬件版本: 0', hwVersion)
hwVersion = await protocolHandler.testVersionInfo(true)
console.log('读取硬件版本: 1', hwVersion)
} catch (hwError) {
console.log('读取硬件版本失败', hwError)
}
// Debug: log raw hwVersion shape
try {
console.log('[healthble] raw hwVersion:', hwVersion)
console.log('[healthble] hwVersion type:', typeof hwVersion, 'isArray=', Array.isArray(hwVersion))
if (hwVersion != null && typeof hwVersion == 'object' && !Array.isArray(hwVersion)) {
const hwObj = hwVersion as UTSJSONObject
console.log('[healthble] hwVersion:', hwObj)
}
} catch (e) { }
const hwStr = normalizeVersion(hwVersion)
if (hwStr == '') {
hardwareVersion.value = '--'
} else {
hardwareVersion.value = hwStr
}
// 检查固件升级
if (hwStr != '--' && swStr != '--') {
const hwVersion = hwStr as string
const swVersion = swStr as string
try {
const firmwareInfo = await dfuTool.checkFirmwareUpdate(hwVersion, swVersion)
if (firmwareInfo != null) {
firmwareUpdateAvailable.value = true
firmwareUpdateInfo.value = firmwareInfo
}
} catch (error) {
console.error('检查固件升级失败:', error)
firmwareUpdateAvailable.value = false
firmwareUpdateInfo.value = null
}
}
let deviceTime = ''
try {
deviceTime = await readDeviceCurrentTime(deviceId)
} catch (timeError) {
console.log('读取设备时间失败', timeError)
}
if (deviceTime == '') {
deviceTimeDisplay.value = '--'
} else {
deviceTimeDisplay.value = deviceTime
}
let battery = -1
try {
battery = await protocolHandler.testBatteryLevel()
} catch (batteryError) {
console.log('读取电量失败', batteryError)
}
if (battery >= 0) {
batteryLevel.value = battery
} else {
batteryLevel.value = null
}
} finally {
deviceInfoLoading.value = false
}
}
// 开始固件升级
async function startFirmwareUpdate() {
if (firmwareUpdateInfo.value == null) return
firmwareUpdateInProgress.value = true
const progressObj = new UTSJSONObject()
progressObj.set('stage', 'downloading')
progressObj.set('progress', 0)
progressObj.set('message', '准备升级...')
firmwareUpdateProgress.value = progressObj as DFUProgress
try {
const dfuWithProgress = new DFUTool((progress : DFUProgress) => {
firmwareUpdateProgress.value = progress
console.log(`DFU进度: ${progress.stage} - ${progress.progress}% - ${progress.message}`)
})
const deviceId = getPreferredDeviceId()
if (deviceId == '') {
throw new Error('未找到连接的设备')
}
const success = await dfuWithProgress.performFirmwareUpdate(firmwareUpdateInfo.value, deviceId)
if (success) {
uni.showToast({ title: '固件升级成功', icon: 'success' })
// 重新获取设备信息
await fetchDeviceInfo(deviceId)
} else {
uni.showToast({ title: '固件升级失败', icon: 'error' })
}
} catch (error) {
console.error('固件升级过程出错:', error)
uni.showToast({ title: '升级失败', icon: 'error' })
} finally {
firmwareUpdateInProgress.value = false
firmwareUpdateProgress.value = null
}
}
async function connectSelectedDevice() {
const deviceId = getPreferredDeviceId()
if (deviceId == '') {
uni.showToast({ title: '请先扫描并选择设备', icon: 'none' })
return
}
connectionState.value = 'connecting'
try {
if (bluetoothSvc != null) {
// #ifdef APP-ANDROID
const androidConnectOptions = {} as BleConnectOptionsExt
await bluetoothSvc.connectDevice(deviceId, 'standard', androidConnectOptions)
// #endif
// #ifndef APP-ANDROID
const params = new UTSJSONObject()
params.set('deviceId', deviceId)
params.set('options', null)
await bluetoothSvc.connectDevice(params)
// #endif
}
connectionState.value = 'connected'
lastSyncTime.value = formatFullTime(new Date())
if (signalRssi.value == null) {
signalRssi.value = -55
}
for (let i : Int = 0; i < state.deviceState.devices.length; i++) {
const device = state.deviceState.devices[i]
const candidateId = device.device_mac ?? device.id
if (candidateId == deviceId) {
setCurrentDevice(device)
break
}
}
selectedDeviceId.value = deviceId
// Ensure protocol handler knows which device we're working with before attempting sync
try {
protocolHandler.setConnectionParameters(deviceId, '', '', '')
} catch (e) { console.warn('[healthble] failed to set protocolHandler connection params', e) }
try {
await protocolHandler.prepareControlChannel()
} catch (controlErr) {
console.warn('[healthble] prepareControlChannel failed', controlErr)
}
// Attempt to synchronize device time (CTS / vendor fallback) immediately after connect
try {
// const syncResult = await protocolHandler.synchronizeOnConnect()
// console.log('[healthble] synchronizeOnConnect result:', syncResult)
// if (syncResult == 'timeSynced') {
// try {
// const dt = await readDeviceCurrentTime(deviceId)
// if (dt != '') {
// deviceTimeDisplay.value = dt
// }
// } catch (e) {
// console.log('[healthble] readDeviceCurrentTime after sync failed', e)
// }
// }
} catch (syncErr) {
console.log('[healthble] synchronizeOnConnect error:', syncErr)
}
// Subscribe to health notifications (device will push notify packets)
try {
if (!healthSubscribed.value) {
await protocolHandler.subscribeHealthNotifications((packet : HealthData) => {
try {
handleHealthData(packet)
} catch (notifyError) {
console.warn('[healthble] health notify handler error', notifyError)
}
})
healthSubscribed.value = true
}
} catch (e) { console.warn('[healthble] subscribeHealthNotifications failed', e) }
await fetchDeviceInfo(deviceId)
// 获取设备的服务和特征信息并保存到数据库
try {
const services = await bluetoothSvc.getServices(deviceId)
const deviceInfo = new UTSJSONObject()
deviceInfo.set('services', JSON.stringify(services))
deviceInfo.set('characteristics', new UTSJSONObject())
// 为每个服务获取特征
for (let i = 0; i < services.length; i++) {
const service = services[i] as BleService
if (service != null && service.uuid != null) {
try {
const characteristics = await bluetoothSvc.getCharacteristics(deviceId, service.uuid)
const charsObj = new UTSJSONObject()
charsObj.set(service.uuid ?? '', JSON.stringify(characteristics))
deviceInfo.set('characteristics', charsObj)
} catch (charError) {
console.warn('[healthble] 获取服务特征失败', service.uuid, charError)
}
}
}
// 构造设备数据
const deviceData = new UTSJSONObject()
deviceData.set('device_type', 'bluetooth_health_monitor')
deviceData.set('device_name', deviceName.value)
deviceData.set('device_mac', deviceId)
deviceData.set('extra', JSON.stringify(deviceInfo))
// 保存到数据库
const saveResult = await bindNewDevice(deviceData)
if (saveResult) {
console.log('[healthble] 设备信息已保存到数据库')
} else {
console.warn('[healthble] 保存设备信息到数据库失败')
}
} catch (deviceInfoError) {
console.warn('[healthble] 获取设备服务信息失败', deviceInfoError)
// 不影响连接流程,继续执行
}
uni.showToast({ title: '设备已连接', icon: 'success' })
pingStatusMessage.value = '已连接,可发送 Ping'
pingStatusTone.value = 'idle'
pingLastSuccessAt.value = ''
if (!isMonitoring.value) {
startMonitoring()
}
} catch (e) {
console.log('连接设备失败', e)
connectionState.value = 'disconnected'
resetDeviceInfo()
uni.showToast({ title: '连接失败', icon: 'error' })
}
}
function assignCurrentDevice(deviceId : string) {
for (let i : Int = 0; i < state.deviceState.devices.length; i++) {
const device = state.deviceState.devices[i]
const candidateId = device.device_mac ?? device.id
if (candidateId == deviceId) {
setCurrentDevice(device)
return
}
}
}
async function disconnectDevice() {
const deviceId = getPreferredDeviceId()
if (deviceId == '') {
connectionState.value = 'disconnected'
return
}
try {
if (bluetoothSvc != null) {
// #ifdef APP-ANDROID
await bluetoothSvc.disconnectDevice(deviceId, 'standard')
// #endif
// #ifndef APP-ANDROID
await bluetoothSvc.disconnectDevice(deviceId)
// #endif
}
} catch (e) {
console.log('断开设备失败', e)
}
// Ensure we unsubscribe from health notifications when disconnecting
try {
if (protocolHandler != null) {
await protocolHandler.unsubscribeHealthNotifications()
}
} catch (e) { console.warn('[healthble] unsubscribeHealthNotifications on disconnect failed', e) }
healthSubscribed.value = false
connectionState.value = 'disconnected'
resetDeviceInfo()
uni.showToast({ title: '已断开', icon: 'none' })
}
async function sendPingCommand() {
if (connectionState.value != 'connected') {
uni.showToast({ title: '请先连接设备', icon: 'none' })
return
}
if (pingInFlight.value) return
pingInFlight.value = true
pingStatusTone.value = 'idle'
pingStatusMessage.value = '等待应答…'
try {
const result : PingResult = await protocolHandler.sendPing()
pingStatusTone.value = 'success'
pingStatusMessage.value = `成功 ${result.latencyMs} ms`
pingLastSuccessAt.value = formatFullTime(new Date())
uni.showToast({ title: 'Ping成功', icon: 'success' })
} catch (err : any) {
const message = err != null && typeof err == 'object' && err.message != null ? ('' + err.message) : 'Ping失败'
pingStatusTone.value = 'error'
if (message.indexOf('timeout') != -1) {
pingStatusMessage.value = '超时未响应'
} else {
pingStatusMessage.value = message
}
console.warn('[healthble] sendPingCommand failed', err)
uni.showToast({ title: 'Ping失败', icon: 'none' })
} finally {
pingInFlight.value = false
}
}
function onConnectPressed() {
if (connectionState.value == 'connecting') return
if (connectionState.value == 'connected') {
disconnectDevice()
} else {
connectSelectedDevice()
}
}
function connectFromDiscovery(device : UTSJSONObject) {
const deviceId = device.getString('deviceId') ?? ''
if (deviceId != '') {
selectedDeviceId.value = deviceId
onConnectPressed()
}
}
async function loadHistory() {
isLoadingHistory.value = true
try {
const params : SensorDataParams = { limit: 6 }
const device = connectedDevice.value
if (device != null) {
params.device_id = device.id
}
const result = await SenseDataService.getMeasurements(params)
if (result.error == null && result.data != null) {
const records = result.data as Array<SensorMeasurement>
historyMeasurements.value = records
} else {
historyMeasurements.value = []
}
} catch (e) {
console.log('加载历史数据异常', e)
historyMeasurements.value = []
} finally {
isLoadingHistory.value = false
}
}
async function refreshHistory() {
await loadHistory()
}
function formatHistoryValue(item : SensorMeasurement) : string {
const raw = item.raw_data
if (raw == null) return '--'
const type = item.measurement_type ?? ''
if (type == 'heart_rate') {
const bpm = raw.getNumber('bpm') ?? 0
return `${bpm} bpm`
}
if (type == 'spo2') {
const spo2 = raw.getNumber('spo2') ?? 0
return `${spo2}%`
}
if (type == 'steps') {
const count = raw.getNumber('count') ?? 0
return `${count} 步`
}
if (type == 'temp') {
const temp = raw.getNumber('temp') ?? 0
return `${temp.toFixed(1)}℃`
}
if (type == 'bp') {
const sys = raw.getNumber('systolic') ?? 0
const dia = raw.getNumber('diastolic') ?? 0
return `${sys}/${dia} mmHg`
}
return '--'
}
function formatHistoryTime(timeStr : string) : string {
if (timeStr == '') return '--'
const date = new Date(timeStr)
const day = (date.getMonth() + 1).toString().padStart(2, '0') + '-' + date.getDate().toString().padStart(2, '0')
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
return `${day} ${time}`
}
function startRecording() {
if (isRecording.value) return
isRecording.value = true
recordingSeconds.value = 0
recordingTimer = setInterval(() => {
recordingSeconds.value = recordingSeconds.value + 1
}, 1000) as number
}
function triggerSOS() {
uni.showModal({
title: '确认 SOS',
content: '确认向护理人员发送紧急报警?',
success: (res) => {
if (res.confirm) {
currentAnomaly.value = '已发出SOS等待响应…'
uni.showToast({ title: 'SOS已发送', icon: 'success' })
}
}
})
}
function openDiagnostics() {
uni.showToast({ title: '诊断日志即将集成', icon: 'none' })
}
function handleConnectionEvent(payload : BleEventPayload | null) {
if (payload == null) return
const stateValue = payload.state ?? 0
if (stateValue == 2) {
connectionState.value = 'connected'
if (payload.device != null) {
const deviceInfo = payload.device
const deviceId = deviceInfo?.deviceId ?? ''
if (deviceId != '') {
selectedDeviceId.value = deviceId
fetchDeviceInfo(deviceId)
}
}
if (!isMonitoring.value) startMonitoring()
} else if (stateValue == 0) {
connectionState.value = 'disconnected'
resetDeviceInfo()
}
}
function convertToSample(json : UTSJSONObject) : HealthSample | null {
try {
const heartRate = json.getNumber('heart_rate') ?? json.getNumber('bpm') ?? 0
const spo2 = json.getNumber('spo2') ?? json.getNumber('oxygen') ?? 0
const steps = json.getNumber('steps') ?? json.getNumber('step') ?? 0
const speed = json.getNumber('speed') ?? json.getNumber('pace') ?? 0
const timestamp = json.getNumber('timestamp') ?? Date.now()
return {
heartRate: heartRate,
spo2: spo2,
steps: steps,
speed: speed,
timestamp: new Date(timestamp)
}
} catch (e) {
console.log('转换样本数据失败', e)
return null
}
}
function handleDataEvent(payload : BleEventPayload | null) {
if (payload == null) return
try {
const rawData = payload.data
let jsonStr = ''
if (typeof rawData == 'string') {
jsonStr = rawData as string
} else if (rawData instanceof ArrayBuffer) {
const decoder = new TextDecoder()
jsonStr = decoder.decode(new Uint8Array(rawData as ArrayBuffer))
}
const parsed = JSON.parse(jsonStr) as UTSJSONObject
const sample = convertToSample(parsed)
if (sample != null) {
applySample(sample, true)
}
} catch (e) {
console.log('解析实时蓝牙数据失败', e)
}
}
function registerBleListeners() {
if (bluetoothSvc == null) return
try {
bluetoothSvc.on('deviceFound', (payload : BleEventPayload) => {
try {
if (payload != null && payload.device != null) {
const device = payload.device as BleDevice
addDiscoveredDevice(device)
}
} catch (e) {
console.log('处理deviceFound失败', e)
}
})
} catch (e) {
console.log('注册deviceFound事件失败', e)
}
try {
bluetoothSvc.on('connectionStateChanged', (payload : BleEventPayload) => {
handleConnectionEvent(payload)
})
} catch (e) {
console.log('注册connectionStateChanged失败', e)
}
try {
bluetoothSvc.on('dataReceived', (payload : BleEventPayload) => {
handleDataEvent(payload)
})
} catch (e) {
// 某些平台暂无 dataReceived 事件,忽略
}
}
const connectionSubtitle = computed<string>(() => {
const name = deviceName.value
switch (connectionState.value) {
case 'connected':
let subtitle = '已准备好采集实时数据'
if (useSimulation.value) {
subtitle = '正在使用模拟数据'
}
if (batteryText.value != null && batteryText.value != '--') {
subtitle += ` • ${batteryText.value as string}`
}
return subtitle
case 'connecting':
return `正在连接 ${name}…`
case 'scanning':
return '请保持设备靠近并开启蓝牙'
default:
let defaultSubtitle = '点击扫描按钮搜索附近的健康手环'
if (useSimulation.value) {
defaultSubtitle = '正在使用模拟数据演示'
}
return defaultSubtitle
}
})
function unregisterBleListeners() {
if (bluetoothSvc == null) return
try {
bluetoothSvc.off('deviceFound', null)
} catch (e) { }
try {
bluetoothSvc.off('connectionStateChanged', null)
} catch (e) { }
try {
bluetoothSvc.off('dataReceived', null)
} catch (e) { }
}
function formatRssi(rssi : number) : string {
return `${describeSignal(rssi)} (${rssi} dBm)`
}
onLoad((op : OnLoadOptions) => {
console.log(op)
loadHistory()
})
onMounted(() => {
registerBleListeners()
applySample(generateSample(), false)
})
onUnmounted(() => {
stopSimulation()
if (recordingTimer != null) {
clearInterval(recordingTimer as number)
recordingTimer = null
}
unregisterBleListeners()
})
watch(activeMetric, (metric : string) => {
chartOption.value = createChartOption(metric)
})
watch(connectedDevice, (device : DeviceInfo | null) => {
if (device != null) {
selectedDeviceId.value = device.device_mac ?? device.id
loadHistory()
const candidateId = device.device_mac ?? device.id
if (candidateId != null && candidateId != '' && connectionState.value == 'connected') {
console.log('已连接设备变更,获取设备信息', candidateId)
fetchDeviceInfo(candidateId)
}
}
if (device == null) {
resetDeviceInfo()
}
})
</script>
<style scoped>
.healthble-page {
flex: 1;
background-color: #f5f7fb;
padding: 24rpx 24rpx 120rpx 24rpx;
box-sizing: border-box;
}
.status-banner {
background: linear-gradient(135deg, #3b82f6, #06b6d4);
padding: 32rpx;
border-radius: 24rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 28rpx;
}
.status-main {
display: flex;
flex-direction: row;
align-items: center;
}
.status-icon {
font-size: 48rpx;
margin-right: 20rpx;
}
.status-expand-icon {
font-size: 32rpx;
margin-left: 16rpx;
opacity: 0.7;
}
.status-texts {
display: flex;
flex-direction: column;
flex: 1;
}
.status-title {
font-size: 34rpx;
font-weight: bold;
margin-bottom: 6rpx;
}
.status-subtitle {
font-size: 26rpx;
opacity: 0.85;
}
.status-battery {
font-size: 24rpx;
margin-top: 4rpx;
}
.status-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.status-btn {
padding: 16rpx 28rpx;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 999rpx;
font-size: 26rpx;
margin-left: 16rpx;
}
.status-btn-first {
margin-left: 0;
margin-right: 16rpx;
}
.device-card {
background: #fff;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 12rpx 34rpx rgba(59, 130, 246, 0.08);
margin-bottom: 28rpx;
}
.device-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.device-summary {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.device-name {
font-size: 34rpx;
font-weight: bold;
color: #111827;
margin-right: 20rpx;
}
.device-battery {
font-size: 26rpx;
color: #059669;
}
.device-action {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
padding: 18rpx 36rpx;
border-radius: 16rpx;
font-size: 26rpx;
}
.device-meta {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 8rpx;
}
.meta-item {
margin-right: 24rpx;
margin-bottom: 12rpx;
color: #4b5563;
font-size: 26rpx;
}
.firmware-update {
color: #059669;
font-weight: bold;
}
.firmware-progress {
color: #d97706;
font-weight: bold;
}
.firmware-btn {
padding: 16rpx 24rpx;
border-radius: 16rpx;
font-size: 26rpx;
margin-top: 4rpx;
margin-left: 16rpx;
}
.firmware-btn.primary {
background: #059669;
color: #fff;
}
.firmware-btn.secondary {
background: #d97706;
color: #fff;
}
.scan-btn {
background: #eef2ff;
color: #1d4ed8;
padding: 16rpx 24rpx;
border-radius: 16rpx;
font-size: 26rpx;
margin-top: 4rpx;
}
.ping-row {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16rpx;
}
.ping-btn {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
padding: 16rpx 28rpx;
border-radius: 16rpx;
font-size: 26rpx;
}
.ping-status {
margin-left: 16rpx;
font-size: 24rpx;
color: #4b5563;
}
.ping-status.ping-idle {
color: #4b5563;
}
.ping-status.ping-success {
color: #059669;
}
.ping-status.ping-error {
color: #dc2626;
}
.ping-meta {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #6b7280;
}
.device-discovery {
display: flex;
flex-direction: row;
}
.discovery-chip {
background: #f3f4f6;
padding: 20rpx;
border-radius: 20rpx;
min-width: 220rpx;
display: flex;
flex-direction: column;
margin-right: 16rpx;
margin-bottom: 16rpx;
}
.discovery-chip.active {
background: #e0f2fe;
border: 2rpx solid #38bdf8;
}
.chip-name {
font-size: 28rpx;
font-weight: bold;
color: #1f2937;
margin-bottom: 8rpx;
}
.chip-sub {
font-size: 24rpx;
color: #4b5563;
margin-bottom: 8rpx;
}
.chip-connect {
background: #2563eb;
color: #fff;
padding: 12rpx 20rpx;
border-radius: 12rpx;
font-size: 24rpx;
}
.quick-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 28rpx;
}
.action-item {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 10rpx 20rpx rgba(15, 23, 42, 0.04);
width: 48%;
margin-bottom: 16rpx;
}
.action-icon {
font-size: 42rpx;
margin-bottom: 12rpx;
}
.action-label {
font-size: 24rpx;
color: #1f2937;
}
.chart-card {
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 28rpx;
box-shadow: 0 12rpx 28rpx rgba(59, 130, 246, 0.08);
}
.chart-header {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 20rpx;
}
.chart-title-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 16rpx;
}
.chart-controls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 16rpx;
}
.time-config,
.interval-config {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12rpx;
}
.time-config {
margin-right: 24rpx;
}
.interval-config {
margin-right: 0;
}
.config-label {
font-size: 24rpx;
color: #6b7280;
margin-right: 8rpx;
}
.config-btn {
padding: 6rpx 12rpx;
border-radius: 12rpx;
background: #f3f4f6;
font-size: 22rpx;
color: #374151;
border: 1rpx solid #d1d5db;
margin-right: 8rpx;
}
.config-btn-last {
margin-right: 0;
}
.config-btn.active {
background: #dbeafe;
color: #1d4ed8;
border-color: #3b82f6;
}
.chart-tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.chart-tab {
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: #f3f4f6;
font-size: 24rpx;
color: #374151;
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.chart-tab.active {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
}
.chart-view {
width: 100%;
height: 500rpx;
}
.metrics-card {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 28rpx;
}
.metric {
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
width: 48%;
margin-bottom: 20rpx;
}
.metric-header {
display: flex;
flex-direction: row;
align-items: center;
}
.metric-icon {
font-size: 34rpx;
margin-right: 12rpx;
}
.metric-label {
font-size: 28rpx;
font-weight: bold;
color: #1f2937;
}
.metric-value-row {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.metric-value {
font-size: 44rpx;
font-weight: 700;
color: #111827;
margin-right: 12rpx;
}
.metric-unit {
font-size: 24rpx;
color: #6b7280;
}
.metric-extreme {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.metric-range {
color: #6b7280;
font-size: 24rpx;
}
.history-card {
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
margin-bottom: 28rpx;
}
.history-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.history-title {
font-size: 30rpx;
font-weight: bold;
color: #1f2937;
}
.history-more {
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: #f3f4f6;
font-size: 24rpx;
color: #2563eb;
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background: #f9fafb;
padding: 18rpx 24rpx;
border-radius: 18rpx;
margin-bottom: 16rpx;
}
.history-main {
display: flex;
flex-direction: column;
}
.history-type {
font-size: 26rpx;
font-weight: bold;
color: #1f2937;
margin-bottom: 8rpx;
}
.history-value {
font-size: 24rpx;
color: #4b5563;
}
.history-time {
font-size: 24rpx;
color: #6b7280;
}
.history-empty {
padding: 32rpx;
display: flex;
justify-content: center;
align-items: center;
}
.empty-text {
color: #6b7280;
font-size: 24rpx;
text-align: center;
}
.alert-banner {
background: #fef3c7;
color: #b45309;
padding: 20rpx 24rpx;
border-radius: 18rpx;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 28rpx;
}
.alert-icon {
font-size: 34rpx;
margin-right: 12rpx;
}
.alert-text {
font-size: 26rpx;
}
.recording-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #111827;
padding: 28rpx;
display: flex;
flex-direction: column;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
color: #fff;
}
.recording-title {
font-size: 28rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.recording-actions {
display: flex;
flex-direction: row;
}
.recording-btn {
flex: 1;
padding: 18rpx;
border-radius: 16rpx;
font-size: 26rpx;
margin-right: 16rpx;
}
.recording-btn.primary {
background: #ef4444;
color: #fff;
}
.recording-btn.secondary {
background: rgba(255, 255, 255, 0.1);
color: #f9fafb;
margin-right: 0;
}
@media screen and (min-width: 900px) {
.action-item {
width: 23%;
}
.metric {
width: 48%;
}
}
@media screen and (max-width: 600px) {
.action-item {
width: 100%;
}
.metric {
width: 100%;
}
}
</style>