Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,482 @@
/**
* 健康数据图表处理工具
* 提供将原始健康数据转换为ak-charts显示格式的功能
*/
import type { HealthData } from '../../uni_modules/ak-sbsrv/utssdk/interface.uts'
import type { ChartOption, ChartType } from '../../uni_modules/ak-charts/interface.uts'
type MultiMetricBucket = {
heart?: number[]
spo2?: number[]
speed?: number[]
steps?: number[]
}
// Re-export ChartOption for use in other modules
export type { ChartOption, ChartType }
/**
* 心率数据处理配置
*/
export type HeartRateChartConfig = {
maxPoints?: number; // 最大显示点数默认50
frequency?: number; // 数据频率控制每N个点取一个默认1全部
color?: string; // 图表颜色,默认'#FF6384'
}
/**
* 血氧数据处理配置
*/
export type Spo2ChartConfig = {
maxPoints?: number; // 最大显示点数默认50
frequency?: number; // 数据频率控制默认1
color?: string; // 图表颜色,默认'#36A2EB'
}
/**
* 步数数据处理配置
*/
export type StepsChartConfig = {
maxPoints?: number; // 最大显示点数默认50
frequency?: number; // 数据频率控制默认1
color?: string; // 图表颜色,默认'#FFCE56'
}
/**
* 处理心率数据为图表格式
* @param healthDataArray 健康数据数组
* @param config 配置选项
* @returns ChartOption对象
*/
export function processHeartRateData(
healthDataArray: HealthData[],
config: HeartRateChartConfig = {}
): ChartOption {
const {
maxPoints = 100, // 增加到100个点
frequency = 1,
color = '#FF6384'
} = config
// 兼容性如果传入frequency作为时间间隔1或5秒则使用时间分桶
// frequency字段 legacy 用法仍然支持按索引采样
const freq = frequency
const useBucket = freq >= 1 && freq <= 10
if (!useBucket) {
// legacy 行为:按索引采样
const heartRateData = healthDataArray
.filter(data => data.type === 'heart' && data.heartRate != null)
.filter((_, index) => index % frequency === 0)
.slice(-maxPoints)
const data = heartRateData.map(item => item.heartRate!)
const labels = heartRateData.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
return { type: 'line' as ChartType, data, labels, color }
}
// 使用时间分桶默认1s或5s: 将心率按时间区间取最大值
const intervalSeconds = Math.max(1, Math.floor(freq))
const intervalMs = intervalSeconds * 1000
const buckets = new Map<number, number[]>()
for (let i = 0; i < healthDataArray.length; i++) {
const d = healthDataArray[i]
if (d.type !== 'heart' || d.heartRate == null) continue
const timestamp = d.timestamp
const ts = (timestamp == null) ? Date.now() : (typeof timestamp === 'number' ? timestamp : new Date(timestamp as string).getTime())
const key = Math.floor(ts / intervalMs) * intervalMs
if (!buckets.has(key)) buckets.set(key, [])
const heartRateValue = d.heartRate != null ? Number.parseFloat(d.heartRate.toString()) : 0
buckets.get(key)!.push(heartRateValue)
}
// 按时间排序并只取最新maxPoints
const keys: number[] = []
buckets.forEach((_, key) => { keys.push(key) })
keys.sort((a, b) => a - b)
const slicedKeys = keys.slice(-maxPoints)
const data: number[] = []
const labels: string[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const arr = (buckets.get(key) != null) ? buckets.get(key)! : []
let maxValue = NaN
for (let j = 0; j < arr.length; j++) {
const num = arr[j] as number
if (isNaN(num)) continue
if (isNaN(maxValue) || num > maxValue) {
maxValue = num
}
}
data.push(maxValue)
const labelDate = new Date(key)
const ss = labelDate.getSeconds().toString().padStart(2, '0')
const mm = labelDate.getMinutes().toString().padStart(2, '0')
labels.push(`${mm}:${ss}`)
}
return { type: 'line' as ChartType, data, labels, color }
}
/**
* 处理血氧数据为图表格式
* @param healthDataArray 健康数据数组
* @param config 配置选项
* @returns ChartOption对象
*/
export function processSpo2Data(
healthDataArray: HealthData[],
config: Spo2ChartConfig = {}
): ChartOption {
const {
maxPoints = 100, // 增加到100个点
frequency = 1,
color = '#36A2EB'
} = config
// 使用时间分桶如果frequency为秒级1或5取区间内最大值或最后值
const freq = frequency
const useBucket = freq >= 1 && freq <= 10
if (!useBucket) {
const spo2Data = healthDataArray
.filter(data => data.type === 'spo2' && data.spo2 != null)
.filter((_, index) => index % frequency === 0)
.slice(-maxPoints)
const data = spo2Data.map(item => item.spo2!)
const labels = spo2Data.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
return { type: 'line' as ChartType, data, labels, color }
}
const intervalSeconds = Math.max(1, Math.floor(freq))
const intervalMs = intervalSeconds * 1000
const buckets = new Map<number, number[]>()
for (let i = 0; i < healthDataArray.length; i++) {
const d = healthDataArray[i]
if (d.type !== 'spo2' || d.spo2 == null) continue
const timestamp = d.timestamp
const ts = (timestamp == null) ? Date.now() : (typeof timestamp === 'number' ? timestamp : new Date(timestamp as string).getTime())
const key = Math.floor(ts / intervalMs) * intervalMs
if (!buckets.has(key)) buckets.set(key, [])
const spo2Value = d.spo2 != null ? Number.parseFloat(d.spo2.toString()) : 0
buckets.get(key)!.push(spo2Value)
}
const keys: number[] = []
buckets.forEach((_, key) => { keys.push(key) })
keys.sort((a, b) => a - b)
const slicedKeys = keys.slice(-maxPoints)
const data: number[] = []
const labels: string[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue != null) ? bucketValue : []
let maxValue = NaN
for (let j = 0; j < arr.length; j++) {
const num = arr[j] as number
if (isNaN(num)) continue
if (isNaN(maxValue) || num > maxValue) {
maxValue = num
}
}
data.push(maxValue)
const labelDate = new Date(key)
const ss = labelDate.getSeconds().toString().padStart(2, '0')
const mm = labelDate.getMinutes().toString().padStart(2, '0')
labels.push(`${mm}:${ss}`)
}
return { type: 'line' as ChartType, data, labels, color }
}
/**
* 处理步数数据为图表格式
* @param healthDataArray 健康数据数组
* @param config 配置选项
* @returns ChartOption对象
*/
export function processStepsData(
healthDataArray: HealthData[],
config: StepsChartConfig = {}
): ChartOption {
const {
maxPoints = 100, // 增加到100个点
frequency = 1,
color = '#FFCE56'
} = config
// 步数通常以累计值或增量形式出现,按时间区间取最后一个值(或累加)
const freq = frequency
const useBucket = freq >= 1 && freq <= 10
if (!useBucket) {
const stepsData = healthDataArray
.filter(data => data.type === 'steps' && data.steps != null)
.filter((_, index) => index % frequency === 0)
.slice(-maxPoints)
const data = stepsData.map(item => item.steps!)
const labels = stepsData.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
return { type: 'bar' as ChartType, data, labels, color }
}
const intervalSeconds = Math.max(1, Math.floor(freq))
const intervalMs = intervalSeconds * 1000
const buckets = new Map<number, number[]>()
for (let i = 0; i < healthDataArray.length; i++) {
const d = healthDataArray[i]
if (d.type !== 'steps' || d.steps == null) continue
const ts = (d.timestamp == null) ? Date.now() : (typeof d.timestamp === 'number' ? d.timestamp : new Date(d.timestamp as string).getTime())
const key = Math.floor(ts / intervalMs) * intervalMs
if (!buckets.has(key)) buckets.set(key, [])
const stepsValue = d.steps != null ? Number.parseFloat(d.steps.toString()) : 0
buckets.get(key)!.push(stepsValue)
}
const keys: number[] = []
buckets.forEach((_, key) => { keys.push(key) })
keys.sort((a, b) => a - b)
const slicedKeys = keys.slice(-maxPoints)
const data: number[] = []
const labels: string[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue != null) ? bucketValue : []
let lastValue = NaN
if (arr.length > 0) {
const candidate = Number.parseFloat(arr[arr.length - 1].toString())
lastValue = isNaN(candidate) ? NaN : candidate
}
data.push(lastValue)
const labelDate = new Date(key)
const ss = labelDate.getSeconds().toString().padStart(2, '0')
const mm = labelDate.getMinutes().toString().padStart(2, '0')
labels.push(`${mm}:${ss}`)
}
return { type: 'bar' as ChartType, data, labels, color }
}
/**
* 创建对齐的多指标图表数据(心率、血氧、速度、步数)
* 返回一个数组,包含每个指标对应的 ChartOption且 labels 保持一致
*/
export function processMultiMetricCharts(
healthDataArray: HealthData[],
intervalSeconds: number = 1,
maxPoints: number = 100,
metrics: ('heart' | 'spo2' | 'steps' | 'speed')[] = ['heart', 'spo2', 'steps']
): ChartOption[] {
const intervalMs = Math.max(1, intervalSeconds) * 1000
const buckets = new Map<number, MultiMetricBucket>()
for (let i = 0; i < healthDataArray.length; i++) {
const d = healthDataArray[i]
const ts = (d.timestamp == null) ? Date.now() : (typeof d.timestamp === 'number' ? d.timestamp : new Date(d.timestamp).getTime())
const key = Math.floor(ts / intervalMs) * intervalMs
if (!buckets.has(key)) buckets.set(key, {})
const entry = buckets.get(key)!
if (d.type === 'heart' && d.heartRate != null) {
if (!entry.heart) entry.heart = []
const heartRateValue = d.heartRate != null ? Number.parseFloat(d.heartRate.toString()) : 0
entry.heart.push(heartRateValue)
}
if (d.type === 'spo2' && d.spo2 != null) {
if (!entry.spo2) entry.spo2 = []
const spo2Value = d.spo2 != null ? Number.parseFloat(d.spo2.toString()) : 0
entry.spo2.push(spo2Value)
}
if ((d.type === 'speed' || d.type === 'heart') && (d as any).speed != null) {
if (!entry.speed) entry.speed = []
const speedValue = (d as any).speed != null ? Number.parseFloat((d as any).speed.toString()) : 0
entry.speed.push(speedValue)
}
if (d.type === 'steps' && d.steps != null) {
if (!entry.steps) entry.steps = []
const stepsValue = d.steps != null ? Number.parseFloat(d.steps.toString()) : 0
entry.steps.push(stepsValue)
}
}
const keys: number[] = []
buckets.forEach((_, key) => { keys.push(key) })
keys.sort((a, b) => a - b)
const slicedKeys = keys.slice(-maxPoints)
const labels: string[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const labelDate = new Date(key)
const ss = labelDate.getSeconds().toString().padStart(2, '0')
const mm = labelDate.getMinutes().toString().padStart(2, '0')
labels.push(`${mm}:${ss}`)
}
const results: ChartOption[] = []
if (metrics.includes('heart')) {
const data: number[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue?.heart != null) ? bucketValue.heart : []
let maxValue = NaN
for (let j = 0; j < arr.length; j++) {
const num = Number.parseFloat(arr[j].toString())
if (isNaN(num)) continue
if (isNaN(maxValue) || num > maxValue) {
maxValue = num
}
}
data.push(maxValue)
}
results.push({ type: 'line' as ChartType, data, labels, color: '#FF6B6B' })
}
if (metrics.includes('spo2')) {
const data: number[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue?.spo2 != null) ? bucketValue.spo2 : []
let maxValue = NaN
for (let j = 0; j < arr.length; j++) {
const num = Number(arr[j])
if (isNaN(num)) continue
if (isNaN(maxValue) || num > maxValue) {
maxValue = num
}
}
data.push(maxValue)
}
results.push({ type: 'line' as ChartType, data, labels, color: '#2196F3' })
}
if (metrics.includes('speed')) {
const data: number[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue?.speed != null) ? bucketValue.speed : []
let maxValue = NaN
for (let j = 0; j < arr.length; j++) {
const num = Number(arr[j])
if (isNaN(num)) continue
if (isNaN(maxValue) || num > maxValue) {
maxValue = num
}
}
data.push(maxValue)
}
results.push({ type: 'line' as ChartType, data, labels, color: '#FFA726' })
}
if (metrics.includes('steps')) {
const data: number[] = []
for (let i = 0; i < slicedKeys.length; i++) {
const key = slicedKeys[i]
const bucketValue = buckets.get(key)
const arr = (bucketValue?.steps != null) ? bucketValue.steps : []
let lastValue = NaN
if (arr.length > 0) {
const candidate = Number(arr[arr.length - 1])
lastValue = isNaN(candidate) ? NaN : candidate
}
data.push(lastValue)
}
results.push({ type: 'bar' as ChartType, data, labels, color: '#4CAF50' })
}
return results
}
/**
* 创建多指标组合图表数据
* @param healthDataArray 健康数据数组
* @param metrics 要包含的指标数组
* @param maxPoints 最大显示点数
* @returns ChartOption对象数组每个指标一个图表
*/
export function createMultiMetricCharts(
healthDataArray: HealthData[],
metrics: ('heart' | 'spo2' | 'steps')[] = ['heart', 'spo2', 'steps'],
maxPoints: number = 100
): ChartOption[] {
const charts: ChartOption[] = []
if (metrics.includes('heart')) {
charts.push(processHeartRateData(healthDataArray, { maxPoints }))
}
if (metrics.includes('spo2')) {
charts.push(processSpo2Data(healthDataArray, { maxPoints }))
}
if (metrics.includes('steps')) {
charts.push(processStepsData(healthDataArray, { maxPoints }))
}
return charts
}
/**
* 获取数据的最新值
* @param healthDataArray 健康数据数组
* @param dataType 数据类型
* @returns 最新值或null
*/
export function getLatestValue(
healthDataArray: HealthData[],
dataType: 'heart' | 'spo2' | 'steps'
): number | null {
const filteredData = healthDataArray
.filter(data => data.type === dataType)
.filter(data => {
switch (dataType) {
case 'heart': return data.heartRate != null
case 'spo2': return data.spo2 != null
case 'steps': return data.steps != null
default: return false
}
})
if (filteredData.length === 0) return null
const latest = filteredData[filteredData.length - 1]
switch (dataType) {
case 'heart': return latest.heartRate!
case 'spo2': return latest.spo2!
case 'steps': return latest.steps!
default: return null
}
}
/**
* 检查是否有足够的数据用于图表显示
* @param healthDataArray 健康数据数组
* @param dataType 数据类型
* @param minPoints 最少点数默认3
* @returns 是否有足够数据
*/
export function hasEnoughDataForChart(
healthDataArray: HealthData[],
dataType: 'heart' | 'spo2' | 'steps',
minPoints: number = 3
): boolean {
const filteredData = healthDataArray.filter(data => data.type === dataType)
return filteredData.length >= minPoints
}
/**
* 测试数据处理功能(用于开发调试)
* @returns 测试结果
*/
export function testChartDataProcessing(): string {
// 创建测试数据
const testData: HealthData[] = [
{ type: 'heart', heartRate: 72, timestamp: Date.now() },
{ type: 'spo2', spo2: 98, timestamp: Date.now() },
{ type: 'steps', steps: 150, timestamp: Date.now() },
{ type: 'heart', heartRate: 75, timestamp: Date.now() },
{ type: 'spo2', spo2: 97, timestamp: Date.now() },
{ type: 'steps', steps: 200, timestamp: Date.now() }
]
// 测试心率数据处理
const heartChart = processHeartRateData(testData, { maxPoints: 100 })
const spo2Chart = processSpo2Data(testData, { maxPoints: 100 })
const stepsChart = processStepsData(testData, { maxPoints: 100 })
return `测试完成 - 心率: ${heartChart.data.length}点, 血氧: ${spo2Chart.data.length}点, 步数: ${stepsChart.data.length}点`
}

261
uts/utils/dfu.uts Normal file
View File

@@ -0,0 +1,261 @@
// DFU (Device Firmware Update) 工具类
// 用于处理设备固件升级过程
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
export type FirmwareInfo {
version: string
url: string
size: number
checksum: string
description?: string
}
//https://ak3.oulog.com/storage/v1/object/public/akmon/OmFw2510221553.zip
export type FirmwareConfig {
default_value: string
default_key?: string
}
export type DFUProgress {
stage: 'downloading' | 'verifying' | 'uploading' | 'upgrading' | 'completed'
progress: number // 0-100
message: string
}
export class DFUTool {
private progressCallback?: (progress: DFUProgress) => void
constructor(progressCallback?: (progress: DFUProgress) => void) {
this.progressCallback = progressCallback
}
/**
* 检查是否有可用的固件升级
* @param currentHardwareVersion 当前硬件版本
* @param currentSoftwareVersion 当前软件版本
* @returns 固件信息或null
*/
async checkFirmwareUpdate(currentHardwareVersion: string, currentSoftwareVersion: string): Promise<FirmwareInfo | null> {
try {
// 从Supabase查询最新固件版本
const result = await supa
.from('ak_global_config')
.select('default_value,default_key', {})
.eq('config_key', 'cfwatch_hardware_version')
.single()
.executeAs<FirmwareConfig>()
console.log(result.data)
const data = result.data as FirmwareConfig
if (result.error !=null ) {
console.log('查询固件版本失败:', result.error)
return null
}
const firmwareConfig = data
// 比较版本号,检查是否需要升级
if (this.compareVersions(currentSoftwareVersion, firmwareConfig.default_value) < 0) {
return {
version: firmwareConfig.default_value,
url: firmwareConfig.default_key,
size: 0,
checksum: '',
description: ''
}
}
return null
} catch (error) {
console.error('检查固件升级失败:', error)
return null
}
}
/**
* 执行固件升级
* @param firmwareInfo 固件信息
* @param deviceId 设备ID
* @returns 升级结果
*/
async performFirmwareUpdate(firmwareInfo: FirmwareInfo, deviceId: string): Promise<boolean> {
try {
this.updateProgress('downloading', 0, '开始下载固件...')
// 下载固件文件
const firmwareData = await this.downloadFirmware(firmwareInfo.url)
this.updateProgress('verifying', 50, '验证固件完整性...')
// 验证文件完整性
if (!this.verifyFirmware(firmwareData, firmwareInfo.checksum)) {
throw new Error('固件校验失败')
}
this.updateProgress('uploading', 70, '上传固件到设备...')
// 执行DFU升级
const success = await this.executeDFU(firmwareData, deviceId)
if (success) {
this.updateProgress('completed', 100, '固件升级完成')
return true
} else {
throw new Error('DFU升级失败')
}
} catch (error) {
console.error('固件升级失败:', error)
this.updateProgress('completed', 0, `升级失败: ${error}`)
return false
}
}
/**
* 下载固件文件
*/
private async downloadFirmware(url: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
uni.downloadFile({
url: url,
success: (res) => {
if (res.statusCode === 200) {
// 读取下载的文件
uni.getFileSystemManager().readFile({
filePath: res.tempFilePath,
success: (readRes) => {
resolve(readRes.data as ArrayBuffer)
},
fail: reject
})
} else {
reject(new Error(`下载失败: ${res.statusCode}`))
}
},
fail: reject
})
})
}
/**
* 验证固件完整性
*/
private verifyFirmware(data: ArrayBuffer, expectedChecksum: string): boolean {
// 简单的校验和验证(可以根据需要实现更复杂的校验)
const hash = this.calculateSimpleHash(data)
return hash === expectedChecksum
}
/**
* 执行DFU升级
*/
private async executeDFU(firmwareData: ArrayBuffer, deviceId: string): Promise<boolean> {
// 这里实现具体的DFU协议
// 需要根据设备的DFU协议来实现
// 这是一个简化的实现,实际需要根据具体设备协议调整
try {
// 1. 进入DFU模式
await this.enterDFUMode(deviceId)
// 2. 发送固件数据包
await this.sendFirmwarePackets(firmwareData, deviceId)
// 3. 验证升级结果
await this.verifyUpdate(deviceId)
return true
} catch (error) {
console.error('DFU执行失败:', error)
return false
}
}
/**
* 进入DFU模式
*/
private async enterDFUMode(deviceId: string): Promise<void> {
// 实现进入DFU模式的逻辑
// 通常需要发送特定的命令到设备
}
/**
* 发送固件数据包
*/
private async sendFirmwarePackets(firmwareData: ArrayBuffer, deviceId: string): Promise<void> {
// 实现分包发送固件的逻辑
const packetSize = 20 // 根据设备协议调整
const totalPackets = Math.ceil(firmwareData.byteLength / packetSize)
for (let i = 0; i < totalPackets; i++) {
const start = i * packetSize
const end = Math.min(start + packetSize, firmwareData.byteLength)
const packet = firmwareData.slice(start, end)
// 发送数据包
await this.sendPacket(packet, deviceId)
// 更新进度
const progress = Math.round((i + 1) / totalPackets * 100)
this.updateProgress('uploading', 70 + (progress * 0.2), `上传进度: ${i + 1}/${totalPackets}`)
}
}
/**
* 发送单个数据包
*/
private async sendPacket(packet: ArrayBuffer, deviceId: string): Promise<void> {
// 实现BLE发送数据包的逻辑
}
/**
* 验证升级结果
*/
private async verifyUpdate(deviceId: string): Promise<void> {
// 实现验证升级结果的逻辑
}
/**
* 计算简单校验和
*/
private calculateSimpleHash(data: ArrayBuffer): string {
const view = new Uint8Array(data)
let hash = 0
for (let i = 0; i < view.length; i++) {
hash = ((hash << 5) - hash + view[i]) & 0xffffffff
}
return hash.toString(16)
}
/**
* 比较版本号
*/
private compareVersions(version1: string, version2: string): number {
const v1Parts = version1.split('.').map((item) => parseInt(item))
const v2Parts = version2.split('.').map((item) => parseInt(item))
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1 = v1Parts[i] != null ? v1Parts[i] : 0
const v2 = v2Parts[i] != null ? v2Parts[i] : 0
if (v1 > v2) return 1
if (v1 < v2) return -1
}
return 0
}
/**
* 更新进度
*/
private updateProgress(stage: 'downloading' | 'verifying' | 'uploading' | 'upgrading' | 'completed', progress: number, message: string): void {
if (this.progressCallback != null) {
this.progressCallback({
stage,
progress: Math.min(100, Math.max(0, progress)),
message
})
}
}
}
// 导出单例实例
export const dfuTool = new DFUTool(null)