Initial commit

This commit is contained in:
2026-03-16 10:37:46 +08:00
commit c052a67816
508 changed files with 22987 additions and 0 deletions

11
.hbuilderx/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "local",
"packageName" : "airunner.mzhfkj.com",
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

0
.tmp Normal file
View File

0
.tmpdir/placeholder Normal file
View File

1
.todo Normal file
View File

@@ -0,0 +1 @@
temp

44
AndroidManifest.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="io.dcloud.hellouniappx">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<!-- 读取日历权限用于演示权限申请监听APIuni.createRequestPermissionListener -->
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<!-- 录音权限用于演示获取录音权限APIuni.getAppAuthorizeSetting 返回的microphoneAuthorized属性值 -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- 声明设备特性 -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<!-- Android8以上支持圆形应用图标icon_round -->
<application
android:icon="@drawable/icon"
>
<!-- 注册 url schemes -->
<activity android:name="io.dcloud.uniapp.UniLaunchProxyActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="hellouniappx"></data> <!-- 注册 hellouniappx:// 链接打开应用 -->
<data android:scheme="uniappxhello"></data> <!-- 注册 uniappxhello:// 链接打开应用 -->
</intent-filter>
<data
android:mimeType="application/zip"
android:scheme="content" />
</activity>
</application>
</manifest>

53
App.uvue Normal file
View File

@@ -0,0 +1,53 @@
<script lang="uts">
// #ifdef APP
// import { initTables } from './ak/sqlite.uts'
// #endif
let firstBackTime = 0
export default {
onLaunch: function () {
console.log('App Launch')
// initTables();
},
onShow: function () {
console.log('App Show')
},
onHide: function () {
console.log('App Hide')
},
// #ifdef APP-ANDROID
onLastPageBackPress: function () {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
},
// #endif
onExit: function () {
console.log('App Exit')
},
}
</script>
<style>
/*每个页面公共css */
.uni-row {
flex-direction: row;
}
.uni-column {
flex-direction: column;
}
</style>

366
ak/PermissionManager.uts Normal file
View File

@@ -0,0 +1,366 @@
/**
* PermissionManager.uts
*
* Utility class for managing Android permissions throughout the app
* Handles requesting permissions, checking status, and directing users to settings
*/
/**
* Common permission types that can be requested
*/
export enum PermissionType {
BLUETOOTH = 'bluetooth',
LOCATION = 'location',
STORAGE = 'storage',
CAMERA = 'camera',
MICROPHONE = 'microphone',
NOTIFICATIONS = 'notifications',
CALENDAR = 'calendar',
CONTACTS = 'contacts',
SENSORS = 'sensors'
}
/**
* Result of a permission request
*/
type PermissionResult = {
granted: boolean;
grantedPermissions: string[];
deniedPermissions: string[];
}
/**
* Manages permission requests and checks throughout the app
*/
export class PermissionManager {
/**
* Maps permission types to the actual Android permission strings
*/
private static getPermissionsForType(type: PermissionType): string[] {
switch (type) {
case PermissionType.BLUETOOTH:
return [
'android.permission.BLUETOOTH_SCAN',
'android.permission.BLUETOOTH_CONNECT',
'android.permission.BLUETOOTH_ADVERTISE'
];
case PermissionType.LOCATION:
return [
'android.permission.ACCESS_FINE_LOCATION',
'android.permission.ACCESS_COARSE_LOCATION'
];
case PermissionType.STORAGE:
return [
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.WRITE_EXTERNAL_STORAGE'
];
case PermissionType.CAMERA:
return ['android.permission.CAMERA'];
case PermissionType.MICROPHONE:
return ['android.permission.RECORD_AUDIO'];
case PermissionType.NOTIFICATIONS:
return ['android.permission.POST_NOTIFICATIONS'];
case PermissionType.CALENDAR:
return [
'android.permission.READ_CALENDAR',
'android.permission.WRITE_CALENDAR'
];
case PermissionType.CONTACTS:
return [
'android.permission.READ_CONTACTS',
'android.permission.WRITE_CONTACTS'
];
case PermissionType.SENSORS:
return ['android.permission.BODY_SENSORS'];
default:
return [];
}
}
/**
* Get appropriate display name for a permission type
*/
private static getPermissionDisplayName(type: PermissionType): string {
switch (type) {
case PermissionType.BLUETOOTH:
return '蓝牙';
case PermissionType.LOCATION:
return '位置';
case PermissionType.STORAGE:
return '存储';
case PermissionType.CAMERA:
return '相机';
case PermissionType.MICROPHONE:
return '麦克风';
case PermissionType.NOTIFICATIONS:
return '通知';
case PermissionType.CALENDAR:
return '日历';
case PermissionType.CONTACTS:
return '联系人';
case PermissionType.SENSORS:
return '身体传感器';
default:
return '未知权限';
}
}
/**
* Check if a permission is granted
* @param type The permission type to check
* @returns True if the permission is granted, false otherwise
*/
static isPermissionGranted(type: PermissionType): boolean {
// #ifdef APP-ANDROID
try {
const permissions = this.getPermissionsForType(type);
const activity = UTSAndroid.getUniActivity();
if (activity == null || permissions.length === 0) {
return false;
}
// Check each permission in the group
for (const permission of permissions) {
if (!UTSAndroid.checkSystemPermissionGranted(activity, [permission])) {
return false;
}
}
return true;
} catch (e) {
console.error(`Error checking ${type} permission:`, e);
return false;
}
// #endif
// #ifndef APP-ANDROID
return true; // Non-Android platforms don't need explicit permission checks
// #endif
}
/**
* Request a permission from the user
* @param type The permission type to request
* @param callback Function to call with the result of the permission request
* @param showRationale Whether to show a rationale dialog if permission was previously denied
*/
static requestPermission(
type: PermissionType,
callback: (result: PermissionResult) => void,
showRationale: boolean = true
): void {
// #ifdef APP-ANDROID
try {
const permissions = this.getPermissionsForType(type);
const activity = UTSAndroid.getUniActivity();
if (activity == null || permissions.length === 0) {
callback({
granted: false,
grantedPermissions: [],
deniedPermissions: permissions
});
return;
}
// Check if already granted
let allGranted = true;
for (const permission of permissions) {
if (!UTSAndroid.checkSystemPermissionGranted(activity, [permission])) {
allGranted = false;
break;
}
}
if (allGranted) {
callback({
granted: true,
grantedPermissions: permissions,
deniedPermissions: []
});
return;
}
// Request the permissions
UTSAndroid.requestSystemPermission(
activity,
permissions,
(granted: boolean, grantedPermissions: string[]) => {
if (granted) {
callback({
granted: true,
grantedPermissions: grantedPermissions,
deniedPermissions: []
});
} else if (showRationale) {
// Show rationale dialog
this.showPermissionRationale(type, callback);
} else {
// Just report the denial
callback({
granted: false,
grantedPermissions: grantedPermissions,
deniedPermissions: this.getDeniedPermissions(permissions, grantedPermissions)
});
}
},
(denied: boolean, deniedPermissions: string[]) => {
callback({
granted: false,
grantedPermissions: this.getGrantedPermissions(permissions, deniedPermissions),
deniedPermissions: deniedPermissions
});
}
);
} catch (e) {
console.error(`Error requesting ${type} permission:`, e);
callback({
granted: false,
grantedPermissions: [],
deniedPermissions: this.getPermissionsForType(type)
});
}
// #endif
// #ifndef APP-ANDROID
// Non-Android platforms don't need explicit permissions
callback({
granted: true,
grantedPermissions: this.getPermissionsForType(type),
deniedPermissions: []
});
// #endif
}
/**
* Show a rationale dialog explaining why the permission is needed
*/
private static showPermissionRationale(
type: PermissionType,
callback: (result: PermissionResult) => void
): void {
const permissionName = this.getPermissionDisplayName(type);
uni.showModal({
title: '权限申请',
content: `需要${permissionName}权限才能使用相关功能`,
confirmText: '去设置',
cancelText: '取消',
success: (result) => {
if (result.confirm) {
this.openAppSettings();
callback({
granted: false,
grantedPermissions: [],
deniedPermissions: this.getPermissionsForType(type)
});
} else {
callback({
granted: false,
grantedPermissions: [],
deniedPermissions: this.getPermissionsForType(type)
});
}
}
});
}
/**
* Open the app settings page
*/
static openAppSettings(): void {
// #ifdef APP-ANDROID
try {
const context = UTSAndroid.getAppContext();
if (context != null) {
const intent = new android.content.Intent();
intent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS");
const uri = android.net.Uri.fromParts("package", context.getPackageName(), null);
intent.setData(uri);
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
} catch (e) {
console.error('Failed to open app settings', e);
uni.showToast({
title: '请手动前往系统设置修改应用权限',
icon: 'none',
duration: 3000
});
}
// #endif
// #ifndef APP-ANDROID
uni.showToast({
title: '请在系统设置中管理应用权限',
icon: 'none',
duration: 2000
});
// #endif
}
/**
* Helper to get the list of granted permissions
*/
private static getGrantedPermissions(allPermissions: string[], deniedPermissions: string[]): string[] {
return allPermissions.filter(p => !deniedPermissions.includes(p));
}
/**
* Helper to get the list of denied permissions
*/
private static getDeniedPermissions(allPermissions: string[], grantedPermissions: string[]): string[] {
return allPermissions.filter(p => !grantedPermissions.includes(p));
}
/**
* Request multiple permission types at once
* @param types Array of permission types to request
* @param callback Function to call when all permissions have been processed
*/
static requestMultiplePermissions(
types: PermissionType[],
callback: (results: Map<PermissionType, PermissionResult>) => void
): void {
if (types.length === 0) {
callback(new Map());
return;
}
const results = new Map<PermissionType, PermissionResult>();
let remaining = types.length;
for (const type of types) {
this.requestPermission(
type,
(result) => {
results.set(type, result);
remaining--;
if (remaining === 0) {
callback(results);
}
},
true
);
}
}
/**
* Convenience method to request Bluetooth permissions
* @param callback Function to call after the permission request
*/
static requestBluetoothPermissions(callback: (granted: boolean) => void): void {
this.requestPermission(PermissionType.BLUETOOTH, (result) => {
// For Bluetooth, we also need location permissions on Android
if (result.granted) {
this.requestPermission(PermissionType.LOCATION, (locationResult) => {
callback(locationResult.granted);
});
} else {
callback(false);
}
});
}
}

81
ak/sqlite.uts Normal file
View File

@@ -0,0 +1,81 @@
import { createSQLiteContext } from '../uni_modules/ak-sqlite/utssdk/app-android/index.uts'
import { createSQLiteContextOptions } from '../uni_modules/ak-sqlite/utssdk/interface.uts'
//创建查询的上下文
export const sqliteContext = createSQLiteContext({
name: 'ble.db',
} as createSQLiteContextOptions);
// 初始化所有表
export function initTables() {
const sql = `
CREATE TABLE IF NOT EXISTS ble_event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT,
event_type TEXT,
detail TEXT,
timestamp INTEGER
);
CREATE TABLE IF NOT EXISTS ble_data_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT,
service_id TEXT,
char_id TEXT,
direction TEXT,
data TEXT,
timestamp INTEGER
);
`;
console.log(sql); // 打印建表语句,便于调试
sqliteContext.executeSql({
sql: sql,
success: function(res) { console.log('SQLite表已创建', res); },
fail: function(err) { console.error('SQLite表创建失败', err); }
});
}
// 测试插入一条 ble_data_log 日志
export function testInsertDataLog() {
sqliteContext.executeSql({
sql: `
INSERT INTO ble_data_log (device_id, service_id, char_id, direction, data, timestamp)
VALUES ('test_device', 'test_service', 'test_char', 'send', 'test_data', ${Date.now()})
`,
success: (res) => { console.log('插入 ble_data_log 成功', res); },
fail: (err) => { console.error('插入 ble_data_log 失败', err); }
});
}
// 测试查询所有 ble_data_log 日志
export function testQueryDataLog() {
sqliteContext.selectSql({
sql: `SELECT * FROM ble_data_log ORDER BY timestamp DESC`,
success: (res) => { console.log('查询 ble_data_log 成功', res); },
fail: (err) => { console.error('查询 ble_data_log 失败', err); }
});
}
// 测试更新 ble_data_log将 data 字段改为 'updated_data',只更新最新一条)
export function testUpdateDataLog() {
sqliteContext.executeSql({
sql: `
UPDATE ble_data_log
SET data = 'updated_data'
WHERE id = (SELECT id FROM ble_data_log ORDER BY timestamp DESC LIMIT 1)
`,
success: (res) => { console.log('更新 ble_data_log 成功', res); },
fail: (err) => { console.error('更新 ble_data_log 失败', err); }
});
}
// 测试删除 ble_data_log删除最新一条
export function testDeleteDataLog() {
sqliteContext.executeSql({
sql: `
DELETE FROM ble_data_log
WHERE id = (SELECT id FROM ble_data_log ORDER BY timestamp DESC LIMIT 1)
`,
success: (res) => { console.log('删除 ble_data_log 成功', res); },
fail: (err) => { console.error('删除 ble_data_log 失败', err); }
});
}

View 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
View 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>

73
ed.md Normal file
View File

@@ -0,0 +1,73 @@
直接帮我修改一个多个蓝牙设备的连接管理插件,支持以下功能:
1. 扫描设备
2. 连接设备
3. 断开连接
4. 发送数据
5. 接收数据
6. 处理连接状态变化
7. 处理数据接收
8. 处理数据发送
9. 处理错误
10. 处理设备连接状态变化
11. 处理数据发送状态变化
12. 处理数据接收状态变化
这个连接服务需要对接多个蓝牙设备支持多种蓝牙协议如SLEBLE、BR/EDR等并且需要支持多种数据格式如JSON、XML等
这个服务在Uni_modules中按插件方式实现需要支持多种平台如Android、iOS、H5等。可以先只实现h5平台的功能后续再实现其他平台的功能。H5平台需要使用web bluetooth API实现其他平台可以使用原生蓝牙API实现。
这个插件命名ak-sbsrv.使用最严格的 UTS/uni-app-x规范进行开发。参考https://doc.dcloud.net.cn/uni-app-x/uts/
上层操控使用interface.uts的插件规范来处理https://doc.dcloud.net.cn/uni-app-x/plugin/uts-plugin.html。使用强类型的ts进行开发使用uni-app-x的ts规范进行开发https://doc.dcloud.net.cn/uni-app-x/uts/。特别需要注意的是uni-app-x的ts规范和typescript的ts规范有很大的区别特别是在类型定义方面。需要使用uni-app-x的ts规范进行开发。
uni-app-x插件的区分平台是在 /utssdk/下。
h5为 /utssdk/web下。
android为 /utssdk/app-android下。
ios为 /utssdk/app-ios下。
尽量少用interface,用type 来代替
从ak-sbsrv的android的代码看我需要达到的几个目的都没有达到
1.希望通过interface.uts管理整个蓝牙服务.
2.这个蓝牙服务可以管理多个蓝牙连接,维持状态。
3.通过回调函数来获取蓝牙状态及蓝牙的notification
4.通过不同的protocol-handler来连接不同协议的设备。
bluetoothService增加一个自动连接的函数根据deviceid 从connect开始就自动连接连接成功后获取service,然后从data-processor中获取私有service id模板然后执行获取charater, 最后注册可写协议char和notification.后续的业务就围绕可写char和notification展开
在血氧检测中,**红色PPG、红外PPG、XYZ加速度**各自有重要的生理和信号学意义:
---
### 1. 红色PPGRed PPG与红外PPGIR PPG
- **PPG光电容积描记**信号是通过光照射皮肤,检测血液流动引起的光吸收变化,反映脉搏波动。
- **红色PPG**一般波长660nm和**红外PPG**一般波长940nm分别对血红蛋白和氧合血红蛋白有不同吸收特性。
- **血氧饱和度SpO₂**的计算,正是基于红光和红外光的吸收比值。
- 红光/红外光的比值变化 → 反映血液中氧合/还原血红蛋白比例 → 计算出血氧。
- **异常波形**(如幅度异常、形态畸变、无脉搏波)可能提示:
- 血流灌注差(如休克、低温、外周循环障碍)
- 心律失常(如心跳不齐、心搏骤停)
- 佩戴不良或运动伪影
---
### 2. XYZ加速度
- **XYZ加速度**反映手指/手腕/设备的三维运动状态。
- 主要作用:
- **运动伪影识别**运动时PPG信号易受干扰通过加速度信号可辅助算法识别并滤除运动伪影提高血氧和心率测量准确性。
- **佩戴状态判断**:静止时加速度变化小,剧烈运动或脱落时加速度变化大。
- **辅助健康分析**:可用于分析活动量、睡眠、跌倒等。
---
### 3. 能否发现身体状况?
- **血氧异常**持续低血氧如SpO₂<90%)提示呼吸系统、心血管系统疾病风险,如慢阻肺、睡眠呼吸暂停、心衰等。
- **脉搏波异常**PPG波形异常可提示心律失常、外周循环障碍等。
- **运动/静止状态**加速度结合PPG可区分真实生理异常和运动伪影辅助医生或AI判断异常原因。
- **AI分析**结合大量PPG和加速度数据AI可进一步识别心律失常、血管硬化、微循环障碍等风险。
---
**总结:**
- 红色PPG和红外PPG用于计算血氧和心率是血氧仪的核心信号。
- XYZ加速度用于识别运动伪影、判断佩戴状态、辅助健康分析。
- 这些指标结合,可更准确地反映身体状况,发现异常信号时建议进一步医学检查。

20
index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>

9
main.uts Normal file
View File

@@ -0,0 +1,9 @@
import App from './App.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}

71
manifest.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "akbleserver",
"appid": "__UNI__95B2570",
"description": "",
"versionName": "1.0.1",
"versionCode": "101",
"uni-app-x": {},
/* */
"quickapp": {},
/* */
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"app": {
"distribute": {
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png"
}
}
}
},
"app-android": {
"distribute": {
"modules": {
"FileSystem": {}
},
"icons": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"splashScreens": {
"default": {}
}
}
},
"app-ios": {
"distribute": {
"modules": {},
"icons": {
"appstore": "unpackage/res/icons/1024x1024.png"
},
"splashScreens": {}
}
}
}

36
pages.json Normal file
View File

@@ -0,0 +1,36 @@
{
"pages": [
// {
// "path": "pages/alldevices",
// "style": {
// "navigationBarTitleText": "全体都有"
// }
// }
// ,
// {
// "path": "components/blecommu",
// "style": {
// "navigationBarTitleText": "蓝牙通信"
// }
// },
{
"path": "pages/akbletest",
"style": {
"navigationBarTitleText": "akble"
}
}
// {
// "path": "pages/control",
// "style": {
// "navigationBarTitleText": "uni-app x"
// }
// }
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "体测训练",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}

728
pages/akbletest.uvue Normal file
View File

@@ -0,0 +1,728 @@
<template>
<scroll-view direction="vertical" class="container">
<view class="section">
<button @click="scanDevices" :disabled="scanning">{{ scanning ? '正在扫描...' : '扫描设备' }}</button>
<!-- <view style="display:flex; flex-direction:row; margin-left:12px; align-items:center">
<text style="margin-right:8px">预设:</text>
<radio-group :modelValue="presetSelected" @change="onPresetChange">
<view v-for="(opt, index) in presetOptions" :key="index"
style="margin-right:8px; display:flex; align-items:center">
<radio :value="opt['value'] as string" />
<text style="margin-left:4px">{{ opt['label'] as string }}</text>
</view>
</radio-group>
</view> -->
<input v-model="optionalServicesInput" placeholder="可选服务 UUID, 逗号分隔" style="margin-left:12px; width: 40%" />
<button @click="autoConnect" :disabled="connecting || devices.length == 0"
style="margin-left:12px;">{{ connecting ? '正在自动连接...' : '自动连接' }}</button>
<!-- Debug: show devices count and raw devices for troubleshooting -->
<view>
<text>设备计数: {{ devices.length }}</text>
<text style="font-size:12px; color:gray">{{ _fmt(devices) }}</text>
</view>
<view v-if="devices.length">
<text>已发现设备:</text>
<view v-for="item in devices" :key="item.deviceId" class="device-item">
<text>{{ item.name!='' ? item.name : '未知设备' }} ({{ item.deviceId }})</text>
<button @click="connect(item.deviceId)">连接</button>
<button v-if="connectedIds.includes(item.deviceId)" @click="disconnect(item.deviceId)"
:disabled="disconnecting">断开</button>
<button v-if="connectedIds.includes(item.deviceId)"
@click="showServices(item.deviceId)">查看服务</button>
<button v-if="connectedIds.includes(item.deviceId)"
@click="autoDiscoverInterfaces(item.deviceId)">自动发现接口</button>
<button v-if="connectedIds.includes(item.deviceId)"
@click="getDeviceInfo(item.deviceId)">设备信息</button>
<!-- DFU 按钮,仅在 APP-ANDROID 可见 -->
<!-- #ifdef APP-ANDROID -->
<button v-if="connectedIds.includes(item.deviceId)" @click="startDfuFlow(item.deviceId)">DFU
升级</button>
<button v-if="connectedIds.includes(item.deviceId)"
@click="startDfuFlow(item.deviceId, '/static/OmFw2509140009.zip')">使用内置固件 DFU</button>
<!-- #endif -->
</view>
</view>
</view>
<view class="section">
<text>日志:</text>
<scroll-view direction="vertical" style="height:240px;">
<text v-for="(log, idx) in logs" :key="idx" style="font-size:12px;">{{ log }}</text>
</scroll-view>
</view>
<view v-if="showingServicesFor">
<view class="section">
<text>设备 {{ showingServicesFor }} 的服务:</text>
<view v-if="services.length">
<view v-for="srv in services" :key="srv.uuid" class="service-item">
<text>{{ srv.uuid }}</text>
<button @click="showCharacteristics(showingServicesFor, srv.uuid)">查看特征</button>
</view>
</view>
<view v-else><text>无服务</text></view>
<button @click="closeServices">关闭</button>
</view>
</view>
<view v-if="showingCharacteristicsFor">
<view class="section">
<text>服务 的特征:</text>
<view v-if="characteristics.length">
<view v-for="char in characteristics" :key="char.uuid" class="char-item">
<text>{{ char.uuid }} [{{ charProps(char) }}]</text>
<view style="display:flex; flex-direction:row; margin-top:6px">
<button v-if="char.properties?.read"
@click="readCharacteristic(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">读取</button>
<button v-if="char.properties?.write"
@click="writeCharacteristic(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">写入(测试)</button>
<button v-if="char.properties?.notify"
@click="toggleNotify(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">{{ isNotifying(char.uuid) ? '取消订阅' : '订阅' }}</button>
</view>
</view>
</view>
<view v-else><text>无特征</text></view>
<button @click="closeCharacteristics">关闭</button>
</view>
</view>
</scroll-view>
</template>
<script lang="uts">
import { BluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
// Platform-specific entrypoint: import the platform index per build target to avoid bundler including Android-only code in 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 type { BleDevice, BleService, BleCharacteristic } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol_handler.uts'
// #ifdef APP-ANDROID
import { dfuManager } from '@/uni_modules/ak-sbsrv/utssdk/app-android/dfu_manager.uts'
// #endif
import { PermissionManager } from '@/ak/PermissionManager.uts'
type ShowingCharacteristicsFor = {
deviceId : string,
serviceId : string
}
export default {
data() {
return {
scanning: false,
connecting: false,
disconnecting: false,
devices: [] as BleDevice[],
connectedIds: [] as string[],
logs: [] as string[],
showingServicesFor: '',
services: [] as BleService[],
showingCharacteristicsFor: { deviceId: '', serviceId: '' } as ShowingCharacteristicsFor,
characteristics: [] as BleCharacteristic[],
// 新增协议相关参数
protocolDeviceId: '',
protocolServiceId: '',
protocolWriteCharId: '',
protocolNotifyCharId: '',
// protocol handler instances/cache
protocolHandlerMap: new Map<string, ProtocolHandler>(),
protocolHandler: null as ProtocolHandler | null,
// optional services input (comma-separated UUIDs)
optionalServicesInput: '',
// presets for common BLE services (label -> UUID). 'custom' allows free-form input.
presetOptions: [
{ label: '无', value: '' },
{ label: 'Battery Service (180F)', value: '0000180f-0000-1000-8000-00805f9b34fb' },
{ label: 'Device Information (180A)', value: '0000180a-0000-1000-8000-00805f9b34fb' },
{ label: 'Generic Attribute (1801)', value: '00001801-0000-1000-8000-00805f9b34fb' },
{ label: 'Nordic DFU', value: '00001530-1212-efde-1523-785feabcd123' },
{ label: 'Nordic UART (NUS)', value: '6e400001-b5a3-f393-e0a9-e50e24dcca9e' },
{ label: '自定义', value: 'custom' }
],
presetSelected: '',
// map of characteristicId -> boolean (is currently subscribed)
notifyingMap: new Map<string, boolean>(),
}
},
mounted() {
PermissionManager.requestBluetoothPermissions((granted : boolean) => {
if (!granted) {
uni.showToast({ title: '请授权蓝牙和定位权限', icon: 'none' });
}
});
this.log('页面 mounted: 初始化事件监听和蓝牙权限请求完成')
// deviceFound - only accept devices whose name starts with 'CF' or 'BCL'
bluetoothService.on('deviceFound', (payload) => {
try {
// this.log('[event] deviceFound -> ' + this._fmt(payload))
// console.log('[event] deviceFound -> ' + this._fmt(payload))
// payload can be UTSJSONObject-like or plain object. Normalize.
let rawDevice = payload?.device;
if (rawDevice == null) {
this.log('[event] deviceFound - payload.device is null, ignoring');
return;
}
// extract name
let name : string | null = rawDevice.name;
if (name == null) {
this.log('[event] deviceFound - 无名称,忽略: ' + this._fmt(rawDevice as any))
return;
}
const n = name as string;
if (!(n.startsWith('CF') || n.startsWith('BCL'))) {
this.log('[event] deviceFound - 名称不匹配前缀,忽略: ' + n)
return;
}
const exists = this.devices.some(d => d != null && d.name == n);
if (!exists) {
// rawDevice is non-null here per earlier guard
this.devices.push(rawDevice as BleDevice);
const deviceIdStr = (rawDevice.deviceId != null) ? rawDevice.deviceId : '';
this.log('发现设备: ' + n + ' (' + deviceIdStr + ')');
} else {
const deviceIdStr = (rawDevice.deviceId != null) ? rawDevice.deviceId : '';
this.log('发现重复设备: ' + n + ' (' + deviceIdStr + ')')
}
} catch (err) {
this.log('[error] deviceFound handler error: ' + getErrorMessage(err))
console.log(err)
}
})
// scanFinished
bluetoothService.on('scanFinished', (payload) => {
try {
this.scanning = false
this.log('[event] scanFinished -> ' + this._fmt(payload))
} catch (err) {
this.log('[error] scanFinished handler error: ' + getErrorMessage(err))
}
})
// connectionStateChanged
bluetoothService.on('connectionStateChanged', (payload) => {
try {
this.log('[event] connectionStateChanged -> ' + this._fmt(payload))
if (payload != null) {
const device = payload.device
const state = payload.state
this.log(`设备 ${device?.deviceId} 连接状态变为: ${state}`)
// maintain connectedIds
if (state == 2) {
if (device != null && device.deviceId != null && !this.connectedIds.includes(device.deviceId)) {
this.connectedIds.push(device.deviceId)
this.log(`已记录已连接设备: ${device.deviceId}`)
}
} else if (state == 0) {
if (device != null && device.deviceId != null) {
this.connectedIds = this.connectedIds.filter(id => id !== device.deviceId)
this.log(`已移除已断开设备: ${device.deviceId}`)
}
}
}
} catch (err) {
this.log('[error] connectionStateChanged handler error: ' + getErrorMessage(err))
}
})
},
methods: {
async startDfuFlow(deviceId : string, staticFilePath : string = '') {
if (staticFilePath != null && staticFilePath !== '') {
this.log('DFU 开始: 使用内置固件文件 ' + staticFilePath)
} else {
this.log('DFU 开始: 请选择固件文件')
}
try {
let chosenPath : string | null = null
let fileName : string | null = null
if (staticFilePath != null && staticFilePath !== '') {
// Use the app's bundled static file path
chosenPath = staticFilePath.replace(/^\/+/, '')
const tmpName = staticFilePath.split(/[\/]/).pop()
fileName = (tmpName != null && tmpName !== '') ? tmpName : staticFilePath
} else {
const res = await new Promise<any>((resolve, reject) => {
uni.chooseFile({ count: 1, success: (r) => resolve(r), fail: (e) => reject(e) })
})
console.log(res)
// Generator-friendly: avoid property iteration or bracket indexing.
// Serialize and regex-match common file fields (path/uri/tempFilePath/name).
try {
const s = (() => { try { return JSON.stringify(res); } catch (e) { return ''; } })()
const m = s.match(/"(?:path|uri|tempFilePath|temp_file_path|tempFilePath|name)"\s*:\s*"([^"]+)"/i)
if (m != null && m.length >= 2) {
const capturedCandidate : string | null = (m[1] != null ? m[1] : null)
const captured : string = capturedCandidate != null ? capturedCandidate : ''
if (captured !== '') {
chosenPath = captured
const toTest : string = captured
if (!(/^[a-zA-Z]:\\|^\\\//.test(toTest) || /:\/\//.test(toTest))) {
const m2 = s.match(/"(?:path|uri|tempFilePath|temp_file_path|tempFilePath)"\s*:\s*"([^"]+)"/i)
if (m2 != null && m2.length >= 2 && m2[1] != null) {
const pathCandidate : string = m2[1] != null ? ('' + m2[1]) : ''
if (pathCandidate !== '') chosenPath = pathCandidate
}
}
}
}
const nameMatch = s.match(/"name"\s*:\s*"([^"]+)"/i)
if (nameMatch != null && nameMatch.length >= 2 && nameMatch[1] != null) {
const nm : string = nameMatch[1] != null ? ('' + nameMatch[1]) : ''
if (nm !== '') fileName = nm
}
} catch (err) { /* ignore */ }
}
if (chosenPath == null || chosenPath == '') {
this.log('未选择文件')
return
}
// filePath is non-null and non-empty here
const fpStr : string = chosenPath as string
const lastSeg = fpStr.split(/[\/]/).pop();
const displayName = (fileName != null && fileName !== '') ? fileName : (lastSeg != null && lastSeg !== '' ? lastSeg : fpStr)
this.log('已选文件: ' + displayName + ' 路径: ' + fpStr)
const bytes = await this._readFileAsUint8Array(fpStr)
this.log('固件读取完成, 大小: ' + bytes.length)
try {
await dfuManager.startDfu(deviceId, bytes, {
useNordic: false,
onProgress: (p : number) => this.log('DFU 进度: ' + p + '%'),
onLog: (s : string) => this.log('DFU: ' + s),
controlTimeout: 30000
})
this.log('DFU 完成')
} catch (e) {
this.log('DFU 失败: ' + getErrorMessage(e))
}
} catch (e) {
console.log('选择或读取固件失败: ' + e)
}
},
_readFileAsUint8Array(path : string) : Promise<Uint8Array> {
return new Promise((resolve, reject) => {
try {
console.log('should readfile')
const fsm = uni.getFileSystemManager()
console.log(fsm)
// Read file as ArrayBuffer directly to avoid base64 encoding issues
fsm.readFile({
filePath: path, success: (res) => {
try {
const data = res.data as ArrayBuffer
const arr = new Uint8Array(data)
resolve(arr)
} catch (e) { reject(e) }
}, fail: (err) => { reject(err) }
})
} catch (e) { reject(e) }
})
},
log(msg : string) {
const ts = new Date().toISOString();
this.logs.unshift(`[${ts}] ${msg}`)
if (this.logs.length > 100) this.logs.length = 100
},
_fmt(obj : any) : string {
try {
if (obj == null) return 'null'
if (typeof obj == 'string') return obj
return JSON.stringify(obj)
} catch (e) {
return '' + obj
}
},
onPresetChange(e : any) {
try {
// Some platforms emit { detail: { value: 'x' } }, others emit { value: 'x' } or just 'x'.
// Serialize and regex-extract to avoid direct property access that the UTS->Kotlin generator may emit incorrectly.
const s = (() => { try { return JSON.stringify(e); } catch (err) { return ''; } })()
let val : string = this.presetSelected
// try detail.value first
const m = s.match(/"detail"\s*:\s*\{[^}]*"value"\s*:\s*"([^\"]+)"/i)
if (m != null && m.length >= 2 && m[1] != null) {
val = '' + m[1]
} else {
const m2 = s.match(/"value"\s*:\s*"([^\"]+)"/i)
if (m2 != null && m2.length >= 2 && m2[1] != null) {
val = '' + m2[1]
}
}
this.presetSelected = val
if (val == 'custom' || val == '') {
this.log('已选择预设: ' + (val == 'custom' ? '自定义' : '无'))
return;
}
this.optionalServicesInput = val;
this.log('已选择预设服务 UUID: ' + val)
} catch (err) {
this.log('[error] onPresetChange: ' + getErrorMessage(err))
}
},
scanDevices() {
try {
this.scanning = true
this.devices = []
// prepare optional services: prefer free-form input, otherwise use selected preset (unless preset is 'custom' or empty)
let raw = (this.optionalServicesInput != null ? this.optionalServicesInput : '').trim();
if (raw.length == 0 && this.presetSelected != null && this.presetSelected !== '' && this.presetSelected !== 'custom') {
raw = this.presetSelected;
}
// normalize helper: expand 16-bit UUIDs like '180F' to full 128-bit UUIDs
const normalize = (s : string) => {
if (s == null || s.length == 0) return '';
const u = s.toLowerCase().replace(/^0x/, '').trim();
const hex = u.replace(/[^0-9a-f]/g, '');
if (/^[0-9a-f]{4}$/.test(hex)) return `0000${hex}-0000-1000-8000-00805f9b34fb`;
return s;
};
const optionalServices = raw.length > 0 ? raw.split(',').map(s => normalize(s.trim())).filter(s => s.length > 0) : []
this.log('开始扫描... optionalServices=' + JSON.stringify(optionalServices))
bluetoothService.scanDevices({ "protocols": ['BLE'], "optionalServices": optionalServices })
.then(() => {
this.log('scanDevices resolved')
})
.catch((e) => {
this.log('[error] scanDevices failed: ' + getErrorMessage(e))
this.scanning = false
})
} catch (err) {
this.log('[error] scanDevices thrown: ' + getErrorMessage(err))
this.scanning = false
}
},
connect(deviceId : string) {
this.connecting = true
this.log(`connect start -> ${deviceId}`)
try {
bluetoothService.connectDevice(deviceId, 'BLE', { timeout: 10000 }).then(() => {
if (!this.connectedIds.includes(deviceId)) this.connectedIds.push(deviceId)
this.log('连接成功: ' + deviceId)
}).catch((e) => {
this.log('连接失败: ' + getErrorMessage(e!));
}).finally(() => {
this.connecting = false
this.log(`connect finished -> ${deviceId}`)
})
} catch (err) {
this.log('[error] connect thrown: ' + getErrorMessage(err))
this.connecting = false
}
},
disconnect(deviceId : string) {
if (!this.connectedIds.includes(deviceId)) return
this.disconnecting = true
this.log(`disconnect start -> ${deviceId}`)
bluetoothService.disconnectDevice(deviceId, 'BLE').then(() => {
this.log('已断开: ' + deviceId)
this.connectedIds = this.connectedIds.filter(id => id !== deviceId)
// 清理协议处理器缓存
this.protocolHandlerMap.delete(deviceId)
}).catch((e) => {
this.log('断开失败: ' + getErrorMessage(e!));
}).finally(() => {
this.disconnecting = false
this.log(`disconnect finished -> ${deviceId}`)
})
},
showServices(deviceId : string) {
this.showingServicesFor = deviceId
this.services = []
this.log(`showServices start -> ${deviceId}`)
bluetoothService.getServices(deviceId).then((list) => {
this.log('showServices result -> ' + this._fmt(list))
this.services = list as BleService[]
this.log('服务数: ' + (list != null ? list.length : 0) + ' [' + deviceId + ']');
}).catch((e) => {
this.log('获取服务失败: ' + getErrorMessage(e!));
}).finally(() => {
this.log(`showServices finished -> ${deviceId}`)
});
},
closeServices() {
this.showingServicesFor = ''
this.services = []
},
showCharacteristics(deviceId : string, serviceId : string) {
this.showingCharacteristicsFor = { deviceId, serviceId }
this.characteristics = []
bluetoothService.getCharacteristics(deviceId, serviceId).then((list) => {
this.characteristics = list as BleCharacteristic[]
console.log('特征数: ' + (list != null ? list.length : 0) + ' [' + deviceId + ']');
// 自动查找可用的写入和通知特征
const writeChar = this.characteristics.find(c => c.properties.write)
const notifyChar = this.characteristics.find(c => c.properties.notify)
if (writeChar != null && notifyChar != null) {
this.protocolDeviceId = deviceId
this.protocolServiceId = serviceId
this.protocolWriteCharId = writeChar.uuid
this.protocolNotifyCharId = notifyChar.uuid
let abs = bluetoothService as BluetoothService
this.protocolHandler = new ProtocolHandler(abs)
let handler = this.protocolHandler
handler?.setConnectionParameters(deviceId, serviceId, writeChar.uuid, notifyChar.uuid)
handler?.initialize()?.then(() => {
console.log("协议处理器已初始化,可进行协议测试")
})?.catch(e => {
console.log("协议处理器初始化失败: " + getErrorMessage(e!))
})
}
}).catch((e) => {
console.log('获取特征失败: ' + getErrorMessage(e!));
});
// tracking notifying state
// this.$set(this, 'notifyingMap', this.notifyingMap || {});
},
closeCharacteristics() {
this.showingCharacteristicsFor = { deviceId: '', serviceId: '' }
this.characteristics = []
},
charProps(char : BleCharacteristic) : string {
const p = char.properties
const parts = [] as string[]
if (p.read) parts.push('R')
if (p.write) parts.push('W')
if (p.notify) parts.push('N')
if (p.indicate) parts.push('I')
return parts.join('/')
// return [p.read ? 'R' : '', p.write ? 'W' : '', p.notify ? 'N' : '', p.indicate ? 'I' : ''].filter(Boolean).join('/')
},
isNotifying(uuid : string) {
return this.notifyingMap.has(uuid) && this.notifyingMap.get(uuid) == true
},
async readCharacteristic(deviceId : string, serviceId : string, charId : string) {
try {
this.log(`readCharacteristic ${charId} ...`)
const buf = await bluetoothService.readCharacteristic(deviceId, serviceId, charId)
let text = ''
try { text = new TextDecoder().decode(new Uint8Array(buf)) } catch (e) { text = '' }
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(' ')
console.log(`读取 ${charId}: text='${text}' hex='${hex}'`)
this.log(`读取 ${charId}: text='${text}' hex='${hex}'`)
} catch (e) {
this.log('读取特征失败: ' + getErrorMessage(e))
}
},
async writeCharacteristic(deviceId : string, serviceId : string, charId : string) {
try {
const payload = new Uint8Array([0x01])
const ok = await bluetoothService.writeCharacteristic(deviceId, serviceId, charId, payload, null)
if (ok) this.log(`写入 ${charId} 成功`);
else this.log(`写入 ${charId} 失败`);
} catch (e) {
this.log('写入特征失败: ' + getErrorMessage(e))
}
},
async toggleNotify(deviceId : string, serviceId : string, charId : string) {
try {
const map = this.notifyingMap
const cur = map.get(charId) == true
if (cur) {
// unsubscribe
await bluetoothService.unsubscribeCharacteristic(deviceId, serviceId, charId)
map.set(charId, false)
this.log(`取消订阅 ${charId}`)
} else {
// subscribe with callback
await bluetoothService.subscribeCharacteristic(deviceId, serviceId, charId, (payload : any) => {
let data : ArrayBuffer | null = null
try {
if (payload instanceof ArrayBuffer) {
data = payload
} else if (payload != null && typeof payload == 'string') {
// some runtimes deliver base64 strings
try {
const s = atob(payload)
const tmp = new Uint8Array(s.length)
for (let i = 0; i < s.length; i++) {
const ch = s.charCodeAt(i)
tmp[i] = (ch == null) ? 0 : (ch & 0xff)
}
data = tmp.buffer
} catch (e) { data = null }
} else if (payload != null && (payload as UTSJSONObject).get('data') instanceof ArrayBuffer) {
data = (payload as UTSJSONObject).get('data') as ArrayBuffer
}
const arr = data != null ? new Uint8Array(data) : new Uint8Array([])
const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(' ')
this.log(`notify ${charId}: ${hex}`)
} catch (e) { this.log('notify callback error: ' + getErrorMessage(e)) }
})
map.set(charId, true)
this.log(`订阅 ${charId}`)
}
} catch (e) {
this.log('订阅/取消订阅失败: ' + getErrorMessage(e))
}
},
autoConnect() {
if (this.connecting) return;
this.connecting = true;
const toConnect = this.devices.filter(d => !this.connectedIds.includes(d.deviceId));
if (toConnect.length == 0) {
this.log('没有可自动连接的设备');
this.connecting = false;
return;
}
let successCount = 0;
let failCount = 0;
let finished = 0;
toConnect.forEach(device => {
bluetoothService.connectDevice(device.deviceId, 'BLE', { timeout: 10000 }).then(() => {
if (!this.connectedIds.includes(device.deviceId)) this.connectedIds.push(device.deviceId);
this.log('自动连接成功: ' + device.deviceId);
successCount++;
// this.getOrInitProtocolHandler(device.deviceId);
}).catch((e) => {
this.log('自动连接失败: ' + device.deviceId + ' ' + getErrorMessage(e!));
failCount++;
}).finally(() => {
finished++;
if (finished == toConnect.length) {
this.connecting = false;
this.log(`自动连接完成,成功${successCount},失败${failCount}`);
}
});
});
},
autoDiscoverInterfaces(deviceId : string) {
this.log('自动发现接口中...')
bluetoothService.getAutoBleInterfaces(deviceId)
.then((res) => {
console.log(res)
this.log('自动发现接口成功: ' + JSON.stringify(res))
})
.catch((e) => {
console.log(e)
this.log('自动发现接口失败: ' + getErrorMessage(e!))
})
},
// 新增:测试电量功能
async getOrInitProtocolHandler(deviceId : string) : Promise<ProtocolHandler> {
let handler = this.protocolHandlerMap.get(deviceId);
if (handler == null) {
// 自动发现接口
const res = await bluetoothService.getAutoBleInterfaces(deviceId);
handler = new ProtocolHandler(bluetoothService as BluetoothService);
handler.setConnectionParameters(deviceId, res.serviceId, res.writeCharId, res.notifyCharId);
await handler.initialize();
this.protocolHandlerMap.set(deviceId, handler);
this.log(`协议处理器已初始化: ${deviceId}`);
}
return handler!;
},
async getDeviceInfo(deviceId : string) {
this.log('获取设备信息中...');
try {
// First try protocol handler (if device exposes custom protocol)
try {
const handler = await this.getOrInitProtocolHandler(deviceId);
// 获取电量
const battery = await handler.testBatteryLevel();
this.log('协议: 电量: ' + battery);
// 获取软件/硬件版本
const swVersion = await handler.testVersionInfo(false);
this.log('协议: 软件版本: ' + swVersion);
const hwVersion = await handler.testVersionInfo(true);
this.log('协议: 硬件版本: ' + hwVersion);
} catch (protoErr) {
this.log('协议处理器不可用或初始化失败,继续使用通用 GATT 查询: ' + ((protoErr != null && protoErr instanceof Error) ? protoErr.message : this._fmt(protoErr)));
}
// Additionally, attempt to read standard services: Generic Access (0x1800), Generic Attribute (0x1801), Battery (0x180F)
const stdServices = ['1800', '1801', '180f'].map(s => {
const hex = s.toLowerCase().replace(/^0x/, '');
return /^[0-9a-f]{4}$/.test(hex) ? `0000${hex}-0000-1000-8000-00805f9b34fb` : s;
});
// fetch services once to avoid repeated GATT server queries
const services = await bluetoothService.getServices(deviceId);
for (const svc of stdServices) {
try {
this.log('读取服务: ' + svc);
// find matching service
const found = services.find((x : any) => {
const uuid = (x as UTSJSONObject).get('uuid')
return uuid != null && uuid.toString().toLowerCase() == svc.toLowerCase()
});
if (found == null) {
this.log('未发现服务 ' + svc + '(需重新扫描并包含 optionalServices');
continue;
}
const chars = await bluetoothService.getCharacteristics(deviceId, found?.uuid as string);
console.log(`服务 ${svc} 包含 ${chars.length} 个特征`, chars);
for (const c of chars) {
try {
if (c.properties?.read == true) {
const buf = await bluetoothService.readCharacteristic(deviceId, found?.uuid as string, c.uuid);
// try to decode as utf8 then hex
let text = '';
try { text = new TextDecoder().decode(new Uint8Array(buf)); } catch (e) { text = ''; }
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log(`特征 ${c.uuid} 读取: text='${text}' hex='${hex}'`);
} else {
console.log(`特征 ${c.uuid} 不可读`);
}
} catch (e) {
console.log(`读取特征 ${c.uuid} 失败: ${getErrorMessage(e)}`);
}
}
} catch (e) {
console.log('查询服务 ' + svc + ' 失败: ' + getErrorMessage(e));
}
}
} catch (e) {
console.log('获取设备信息失败: ' + getErrorMessage(e));
}
}
}
}
function getErrorMessage(e : Error | string | null) : string {
if (e == null) return '';
if (typeof e == 'string') return e;
try {
return JSON.stringify(e);
} catch (err) {
return '' + e;
}
}
</script>
<style scoped>
.container {
padding: 16px;
flex: 1;
}
.section {
margin-bottom: 18px;
}
.device-item {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.service-item,
.char-item {
margin: 6px 0;
}
button {
margin-left: 8px;
}
</style>

218
pages/alldevices.uvue Normal file
View File

@@ -0,0 +1,218 @@
<template>
<scroll-view
direction="vertical"
class="manager-container"
style="height: 100vh; flex: 1; min-height: 0;"
>
<view class="toolbar">
<button @click="scanDevices" :disabled="scanning">{{ scanning ? '正在扫描...' : '扫描设备' }}</button>
<button @click="measureAllBatteries">全部测电量</button>
</view>
<view class="card-list">
<RingCard
v-for="(dev, idx) in devices"
:key="dev.deviceId"
:name="dev.name"
:deviceId="dev.deviceId"
:isFullscreen="fullscreenDeviceId === dev.deviceId"
:id="'ring_' + idx"
@request-fullscreen="handleRequestFullscreen"
@exit-fullscreen="handleExitFullscreen"
/>
</view>
<view class="log-section">
<text>日志:</text>
<scroll-view scroll-y style="height:120px;">
<text v-for="(log, idx) in logs" :key="idx" style="font-size:12px;">{{ log }}</text>
</scroll-view>
</view>
</scroll-view>
</template>
<script lang="uts">
import RingCard from '@/components/ringcard.uvue'
// #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 { PermissionManager } from '@/ak/PermissionManager.uts'
import type { BleDevice } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
//import {sqliteContext} from '@/ak/sqlite.uts'
export default {
components: { RingCard },
data() {
return {
scanning: false,
devices: [] as BleDevice[],
logs: [] as string[],
fullscreenDeviceId: '', // 当前全屏的设备id
fullscreenElement: null as UniElement | null,
isFullscreen: false,
orientation: "landscape",
navigationUI:"hide",
}
},
mounted() {
PermissionManager.requestBluetoothPermissions(() => {});
bluetoothService.on('deviceFound', (payload) => {
try {
console.log('ak deviceFound')
const device = payload?.device;
if (device == null || device.deviceId == null) return;
const name = (device.name != null) ? device.name : '';
if (name.indexOf('CF') !== 0) return;
if (this.devices.find(d => d.deviceId === device.deviceId) == null) {
this.devices.push(device);
this.log('发现设备: ' + (name !== '' ? name : '未知设备') + ' (' + device.deviceId + ')');
}
} catch (err) {
console.error('deviceFound handler error', err);
}
});
bluetoothService.on('scanFinished', () => {
this.scanning = false;
this.log('扫描完成');
});
bluetoothService.on('connectionStateChanged', (payload) => {
console.log('[AKBLE][LOG] 页面收到 connectionStateChanged', payload)
const { device, state } = payload;
this.log(`设备 ${device?.deviceId} 连接状态变为: ${state}`);
// 通知对应的 RingCard 组件
if (device?.deviceId != null && device.deviceId !== '') {
const idx = this.devices.findIndex(d => d.deviceId === device!.deviceId);
if (idx >= 0) {
const refName = 'ring_' + idx;
const ringCards = this.$refs[refName] as ComponentPublicInstance[] | ComponentPublicInstance;
const arr = Array.isArray(ringCards) ? ringCards : [ringCards];
if (arr.length > 0) {
const ringCard = arr[0];
ringCard.$callMethod('onConnectionStateChanged', state);
}
}
// sqliteContext.executeSql({
// sql: `
// INSERT INTO ble_event_log (device_id, event_type, timestamp)
// VALUES ('${device.deviceId}', '${state}', ${Date.now()})
// `,
// success: (res) => {
// console.log('保存连接日志成功', res);
// },
// fail: (err) => {
// console.error('保存连接日志失败', err);
// }
// });
}
});
},
methods: {
log(msg: string) {
this.logs.unshift(`[${new Date().toTimeString().slice(0,8)}] ${msg}`);
if (this.logs.length > 100) this.logs.length = 100;
},
getCurrentPage() : UniPage {
const pages = getCurrentPages()
return pages[pages.length - 1]
},
scanDevices() {
this.scanning = true;
this.devices = [];
bluetoothService.scanDevices({ protocols: ['BLE'] });
this.log('开始扫描...');
},
async measureAllBatteries() {
for (let i = 0; i < this.devices.length; i++) {
const refName = 'ring_' + i;
const ringCards = this.$refs[refName] as ComponentPublicInstance[] | ComponentPublicInstance;
const arr = Array.isArray(ringCards) ? ringCards : [ringCards];
if (arr.length > 0) {
const ringCard = arr[0];
try {
const battery = await ringCard.$callMethod('measureBattery');
this.log(`设备 ${this.devices[i].deviceId} 电量: ${battery}`);
} catch (err) {
this.log('测量电量失败: ' + (err && (err as any).message ? (err as any).message : String(err)));
}
}
}
},
onMeasure(deviceId: string) {
// 记录日志或其它操作
},
async handleRequestFullscreen(deviceId: string) {
this.fullscreenDeviceId = deviceId;
// 让对应的卡片进入全屏
const idx = this.devices.findIndex(d => d.deviceId === deviceId);
if (idx >= 0) {
const refName = 'ring_' + idx;
// const ringCards = this.$refs[refName] as UTSArray<ComponentPublicInstance>;
// if (ringCards.length > 0) {
// // @ts-ignore
// ringCards[0].$ref.fullscreenCard?.requestFullscreen?.({
// navigationUI: "hide",
// orientation: "auto"
// });
// }
console.log(refName)
this.fullscreenElement = uni.getElementById(refName) as UniElement;
this.fullscreenElement?.requestFullscreen({
navigationUI: this.navigationUI,
orientation: this.orientation,
success: () => {
this.fullscreenDeviceId = deviceId;
console.log( "全屏")
},
fail: (err) => {
console.log('fail', err)
},
complete: () => {
console.log('complete')
}
})
}
},
async handleExitFullscreen(deviceId: string) {
this.fullscreenDeviceId = '';
// 退出全屏
const page = this.getCurrentPage();
page.exitFullscreen(
{
success: () => {
console.log( "退出全屏")
},
fail: (err) => {
console.log('fail', err)
},
complete: () => {
console.log('complete')
}
});
}
}
}
</script>
<style scoped>
.manager-container {
padding: 16px;
box-sizing: border-box;
height: 100vh;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.toolbar { margin-bottom: 18px; }
.card-list {
flex-wrap: wrap;
flex-direction: row;
align-items: flex-start;
}
.log-section { margin-top: 18px; }
</style>

963
pages/control.uvue Normal file
View File

@@ -0,0 +1,963 @@
<template>
<view class="container">
<button @click="scanDevices" :disabled="scanning">扫描蓝牙设备</button>
<view v-if="scanning">正在扫描...</view>
<view class="section-title">设备列表</view>
<view v-if="devices.length">
<view v-for="item in devices" :key="item.deviceId" class="device-item">
<view class="device-info">
<text class="device-name">{{ item.name || '未知设备' }}</text>
<text class="device-id">({{ item.deviceId }})</text>
<text :class="['status', item.connected ? 'connected' : '']">{{ item.connected ? '已连接' : '未连接' }}</text>
</view>
<view class="device-actions">
<button v-if="!item.connected" @click="connectDevice(item.deviceId)" class="btn-connect">连接</button>
<button v-else @click="disconnectDevice(item.deviceId)" class="btn-disconnect">断开</button>
</view>
</view>
</view>
<view v-else class="empty-list">暂无设备,请点击扫描</view>
<view class="section-title">已连接设备({{ connectedDevices.length }})</view>
<view v-if="connectedDevices.length">
<view v-for="item in connectedDevices" :key="item.deviceId" class="device-item connected-item">
<view class="device-info">
<text class="device-name">{{ item.name || '未知设备' }}</text>
<text class="device-id">({{ item.deviceId }})</text>
<!-- 显示电池状态 -->
<view v-if="batteryStatus[item.deviceId]" class="battery-status">
<view class="battery-container">
<view class="battery-level"
:style="{width: batteryStatus[item.deviceId].level >= 0 ? batteryStatus[item.deviceId].level + '%' : '0%'}">
</view>
<text v-if="batteryStatus[item.deviceId].isCharging" class="charging-icon">⚡</text>
</view>
<text class="battery-text">
{{ batteryStatus[item.deviceId].level >= 0 ? batteryStatus[item.deviceId].level + '%' : '未知' }}
{{ batteryStatus[item.deviceId].isCharging ? ' (充电中)' : '' }}
<span v-if="batteryStatus[item.deviceId].chargingStatus"> | 充电状态: {{ batteryStatus[item.deviceId].chargingStatus }}</span>
</text>
</view>
</view>
<view class="device-actions">
<button @click="disconnectDevice(item.deviceId)" class="btn-disconnect">断开</button>
<button @click="manualDiscoverServices(item.deviceId)" class="btn-discover">发现服务</button>
<button @click="checkBatteryStatus(item.deviceId)"
class="btn-battery"
:disabled="checkingBatteryStatus[item.deviceId]">
{{ checkingBatteryStatus[item.deviceId] ? '检查中...' : '检查电量' }}
</button>
<button @click="checkChargingStatus(item.deviceId)" class="btn-battery">测试充电状态</button>
<button @click="checkStepCount(item.deviceId)" class="btn-battery">检查步数</button>
<button @click="checkHwVersion(item.deviceId)" class="btn-battery">查询硬件版本</button>
<button @click="checkSwVersion(item.deviceId)" class="btn-battery">查询软件版本</button>
<button @click="getDeviceTime(item.deviceId)" class="btn-battery">获取设备时间</button>
<button @click="setDeviceTime(item.deviceId)" class="btn-battery">设置设备时间</button>
<button @click="checkHeartRate(item.deviceId)" class="btn-battery">测试心率</button>
<button @click="checkOximetry(item.deviceId)" class="btn-battery">测试血氧</button>
<button @click="checkOximetryWithEvents(item.deviceId)" class="btn-battery">测试血氧(事件流)</button>
</view>
<view v-if="stepCount[item.deviceId] !== undefined" style="margin-top:4px;color:#2196F3;display:flex;align-items:center;gap:8px;">
步数: {{ stepCount[item.deviceId] }}
<button @click="clearStepCount(item.deviceId)" class="btn-battery">步数清零</button>
</view>
<view v-if="deviceTime[item.deviceId]" style="margin-top:2px;color:#009688;">
设备时间: {{ deviceTime[item.deviceId] }}
</view>
<view v-if="hwVersion[item.deviceId]" style="margin-top:2px;color:#795548;">硬件版本: {{ hwVersion[item.deviceId] }}</view>
<view v-if="swVersion[item.deviceId]" style="margin-top:2px;color:#607D8B;">软件版本: {{ swVersion[item.deviceId] }}</view>
</view>
</view>
<view v-else class="empty-list">暂无已连接设备</view>
<!-- 服务和特征值管理 -->
<view v-if="selectedDevice" class="device-detail">
<view class="section-title">设备详情: {{ selectedDevice.name }}</view>
<view class="services-container">
<view v-for="service in services" :key="service.uuid"
:class="['service-item', service.uuid.startsWith('bae') ? 'bae-service' : '']">
<view class="service-header" @click="toggleService(service.uuid)">
<text class="service-uuid">{{ service.uuid }}</text>
<text class="service-tag" v-if="service.uuid.startsWith('bae')">BAE服务</text>
<text class="expand-icon">{{ expandedServices.includes(service.uuid) ? '▼' : '▶' }}</text>
</view>
<view v-if="expandedServices.includes(service.uuid)" class="characteristics-container">
<view v-if="characteristics[service.uuid] && characteristics[service.uuid].length" class="characteristics-list">
<view v-for="char in characteristics[service.uuid]" :key="char.uuid" class="characteristic-item">
<view class="char-header">
<text class="char-uuid">{{ char.uuid }}</text>
<view class="char-properties">
<text v-if="char.properties.read" class="prop-tag read">R</text>
<text v-if="char.properties.write" class="prop-tag write">W</text>
<text v-if="char.properties.notify" class="prop-tag notify">N</text>
<text v-if="char.properties.indicate" class="prop-tag indicate">I</text>
</view>
</view>
<!-- 可写特征值显示输入框 -->
<view v-if="char.properties.write" class="char-write">
<input
type="text"
v-model="writeValues[char.uuid]"
placeholder="输入要写入的数据"
class="write-input"
/>
<button
@click="writeCharacteristic(selectedDevice.deviceId, service.uuid, char.uuid, writeValues[char.uuid])"
class="btn-write"
>
写入
</button>
</view>
<!-- 可订阅特征值显示订阅按钮和数据 -->
<view v-if="char.properties.notify || char.properties.indicate" class="char-notify">
<button
v-if="!subscribedCharacteristics[char.uuid]"
@click="subscribeCharacteristic(selectedDevice.deviceId, service.uuid, char.uuid)"
class="btn-subscribe"
>
订阅
</button>
<button
v-else
class="btn-subscribed"
disabled
>
已订阅
</button>
<view v-if="notifyData[char.uuid]" class="notify-data">
<text class="data-label">收到数据:</text>
<text class="data-value">{{ notifyData[char.uuid] }}</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-list">正在加载特征值...</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
import { BleDevice, BleService, BleCharacteristic, BatteryStatus } from '@/uni_modules/ak-sbsrv/interface.uts'
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol-handler.uts'
// 类型声明集中管理
interface ControlData {
devices: BleDevice[];
connectedDevices: BleDevice[];
scanning: boolean;
selectedDevice: BleDevice | null;
services: BleService[];
characteristics: Record<string, BleCharacteristic[]>;
expandedServices: string[];
writeValues: Record<string, string>;
subscribedCharacteristics: Record<string, boolean>;
notifyData: Record<string, string>;
batteryStatus: Record<string, BatteryStatus>;
checkingBatteryStatus: Record<string, boolean>;
stepCount: Record<string, number>;
hwVersion: Record<string, string>;
swVersion: Record<string, string>;
deviceTime: Record<string, string>; // 新增:存储设备时间
}
export default {
data(): ControlData {
return {
devices: [],
connectedDevices: [],
scanning: false,
selectedDevice: null,
services: [],
characteristics: {},
expandedServices: [],
writeValues: {},
subscribedCharacteristics: {},
notifyData: {},
batteryStatus: {},
checkingBatteryStatus: {}
,
stepCount: {},
hwVersion: {},
swVersion: {},
deviceTime: {} // 新增
}
},
onLoad() {
this.refreshConnectedDevices();
if (typeof bluetoothService.onConnectionStateChange === 'function') {
bluetoothService.onConnectionStateChange((deviceId, state) => {
this.refreshConnectedDevices();
if (typeof this.$forceUpdate === 'function') {
this.$forceUpdate();
}
});
}
},
methods: {
// 统一错误提示
showError(msg: string, e?: any) {
uni.showToast({ title: msg + (e?.message ? ': ' + e.message : ''), icon: 'none' });
if (e) console.error(msg, e);
},
// 刷新已连接设备
refreshConnectedDevices() {
this.connectedDevices = bluetoothService.getConnectedDevices();
},
// 扫描设备
async scanDevices() {
this.scanning = true;
try {
const result = await bluetoothService!.scanDevices();
console.log(result)
if (result!=null && result.length > 0) {
const device = result[0];
console.log(device)
const existingDeviceIndex = this.devices.findIndex(d => d.deviceId === device.deviceId);
if (existingDeviceIndex >= 0) {
this.devices[existingDeviceIndex] = device;
} else {
this.devices.push(device);
}
}
this.refreshConnectedDevices();
} catch (e: any) {
this.showError('扫描失败请确保浏览器支持Web蓝牙API', e);
} finally {
this.scanning = false;
}
},
// 连接设备
async connectDevice(deviceId: string) {
try {
await bluetoothService.connectDevice(deviceId);
this.updateDeviceConnection(deviceId, true);
this.refreshConnectedDevices();
const device = this.devices.find(d => d.deviceId === deviceId);
if (device) this.selectDevice(device);
uni.showToast({ title: '连接成功' });
} catch (e) {
this.showError('连接设备失败', e);
}
},
// 断开设备连接
async disconnectDevice(deviceId: string) {
try {
await bluetoothService.disconnectDevice(deviceId);
this.updateDeviceConnection(deviceId, false);
if (this.selectedDevice && this.selectedDevice.deviceId === deviceId) {
this.selectedDevice = null;
this.services = [];
this.characteristics = {};
this.expandedServices = [];
}
this.refreshConnectedDevices();
uni.showToast({ title: '已断开连接' });
} catch (e) {
this.showError('断开连接失败', e);
}
},
// 更新设备连接状态
updateDeviceConnection(deviceId: string, connected: boolean) {
const index = this.devices.findIndex(d => d.deviceId === deviceId);
if (index >= 0) this.devices[index].connected = connected;
},
// 选择设备并自动发现服务
selectDevice(device: BleDevice) {
// 响应式赋值,确保 UI 刷新
this.selectedDevice = { ...device };
this.discoverServices(device.deviceId);
if (typeof this.$forceUpdate === 'function') {
this.$forceUpdate();
}
},
// 手动发现服务
async manualDiscoverServices(deviceId: string) {
const device = this.devices.find(d => d.deviceId === deviceId) ||
this.connectedDevices.find(d => d.deviceId === deviceId);
if (device) this.selectDevice(device);
},
// 发现服务
async discoverServices(deviceId: string) {
try {
this.services = [];
this.characteristics = {};
this.expandedServices = [];
const services = await bluetoothService.discoverServices(deviceId);
if (!services || !Array.isArray(services)) {
throw new Error('服务发现返回值无效');
}
this.services = services;
for (const service of services) {
if (service && service.uuid && service.uuid.startsWith('bae')) {
this.expandedServices.push(service.uuid);
await this.getCharacteristics(deviceId, service.uuid);
}
}
} catch (e) {
this.showError('发现服务失败', e);
}
},
// 展开/折叠服务
async toggleService(serviceId: string) {
const index = this.expandedServices.indexOf(serviceId);
if (index >= 0) {
this.expandedServices.splice(index, 1);
} else {
this.expandedServices.push(serviceId);
if (!this.characteristics[serviceId] || this.characteristics[serviceId].length === 0) {
await this.getCharacteristics(this.selectedDevice!.deviceId, serviceId);
}
}
},
// 获取服务的特征值
async getCharacteristics(deviceId: string, serviceId: string) {
try {
const chars = await bluetoothService.getCharacteristics(deviceId, serviceId);
this.$set(this.characteristics, serviceId, chars);
for (const char of chars) {
if (char.properties.write) {
this.$set(this.writeValues, char.uuid, '');
}
}
} catch (e) {
this.showError('获取特征值失败', e);
}
},
// 写入特征值
async writeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, value: string) {
if (!value) {
uni.showToast({ title: '请输入要写入的数据', icon: 'none' });
return;
}
try {
const ok = await bluetoothService.writeCharacteristic(deviceId, serviceId, characteristicId, value);
if (ok) {
uni.showToast({ title: '写入成功' });
this.$set(this.writeValues, characteristicId, '');
} else {
uni.showToast({ title: '写入失败', icon: 'none' });
}
} catch (e) {
this.showError('写入特征值失败', e);
}
},
// 订阅特征值变化
async subscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string) {
try {
await bluetoothService.subscribeCharacteristic(
deviceId,
serviceId,
characteristicId,
(data) => {
let displayData;
if (data.data instanceof Uint8Array) {
displayData = Array.from(data.data as Uint8Array)
.map(byte => byte.toString(16).padStart(2, '0'))
.join(' ');
} else {
displayData = data.data.toString();
}
this.$set(this.notifyData, characteristicId, displayData);
}
);
this.$set(this.subscribedCharacteristics, characteristicId, true);
uni.showToast({ title: '订阅成功' });
} catch (e) {
this.showError('订阅特征值失败', e);
}
},
// 检查设备电池状态
async checkBatteryStatus(deviceId: string) {
try {
this.$set(this.checkingBatteryStatus, deviceId, true);
// 查找私有服务和特征
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
// 这里假设私有服务UUID和特征有特定前缀如'bae'),请根据实际协议调整
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
console.log(this.services)
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
// 查找可写和可通知特征
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
console.log(this.characteristics[privateService.uuid])
console.log(writeChar,' aa ',notifyChar)
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
// 初始化协议处理器
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
// 调用私有协议获取电量
const batteryLevel = await handler.testBatteryLevel();
this.$set(this.batteryStatus, deviceId, {
level: batteryLevel,
isCharging: false // 如需充电状态可扩展testChargingStatus
});
uni.showToast({ title: '获取电池信息成功' });
} catch (e: any) {
this.showError('获取电池信息失败', e);
this.$set(this.batteryStatus, deviceId, {
level: -1,
isCharging: false
});
} finally {
this.$set(this.checkingBatteryStatus, deviceId, false);
}
},
// 检查步数
async checkStepCount(deviceId: string) {
try {
// 确保服务和特征已发现
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const stepCount = await handler.testStepCount();
this.$set(this.stepCount, deviceId, stepCount);
uni.showToast({ title: '步数获取成功' });
} catch (e: any) {
this.showError('获取步数失败', e);
this.$set(this.stepCount, deviceId, -1);
}
},
// 步数清零
async clearStepCount(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
await handler.testClearStepCount();
uni.showToast({ title: '步数已清零' });
// 清零后自动刷新步数
await this.checkStepCount(deviceId);
} catch (e: any) {
this.showError('步数清零失败', e);
}
},
// 获取设备时间
async getDeviceTime(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const timeStr = await handler.testGetTime();
this.$set(this.deviceTime, deviceId, timeStr);
uni.showToast({ title: '获取设备时间成功' });
} catch (e: any) {
this.showError('获取设备时间失败', e);
this.$set(this.deviceTime, deviceId, '获取失败');
}
},
// 获取设备时间
async setDeviceTime(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const timeStr = await handler.testTimeSync();
this.$set(this.deviceTime, deviceId, timeStr);
uni.showToast({ title: '设置设备时间成功' });
} catch (e: any) {
this.showError('设置设备时间失败', e);
this.$set(this.deviceTime, deviceId, '获取失败');
}
},
// 查询硬件版本
async checkHwVersion(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const version = await handler.testVersionInfo(true); // true=硬件版本
this.$set(this.hwVersion, deviceId, version);
uni.showToast({ title: '硬件版本获取成功' });
} catch (e: any) {
this.showError('获取硬件版本失败', e);
this.$set(this.hwVersion, deviceId, '获取失败');
}
},
// 查询软件版本
async checkSwVersion(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const version = await handler.testVersionInfo(false); // false=软件版本
this.$set(this.swVersion, deviceId, version);
uni.showToast({ title: '软件版本获取成功' });
} catch (e: any) {
this.showError('获取软件版本失败', e);
this.$set(this.swVersion, deviceId, '获取失败');
}
},
// 测试充电状态
async checkChargingStatus(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
const status = await handler.testChargingStatus();
let statusText = '未知';
if (status === 0) statusText = '未充电';
else if (status === 1) statusText = '充电中';
else if (status === 2) statusText = '已充满';
if (!this.batteryStatus[deviceId]) {
this.$set(this.batteryStatus, deviceId, { level: -1, isCharging: false, chargingStatus: statusText });
} else {
this.$set(this.batteryStatus[deviceId], 'chargingStatus', statusText);
}
uni.showToast({ title: '充电状态: ' + statusText });
} catch (e: any) {
this.showError('获取充电状态失败', e);
if (!this.batteryStatus[deviceId]) {
this.$set(this.batteryStatus, deviceId, { level: -1, isCharging: false, chargingStatus: '未知' });
} else {
this.$set(this.batteryStatus[deviceId], 'chargingStatus', '未知');
}
}
},
// 测试心率(事件流式)
async checkHeartRate(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
// 使用事件流方式
const { stop, onData } = handler.heartRateWithEvents();
let lastHeartRate = null;
let timer = null;
onData((data: Uint8Array) => {
// 假设心率值在data[5],可根据协议调整
if (data.length >= 6 && data[2] === 0x31 && data[3] === 0x00) {
const heartRate = data[5];
lastHeartRate = heartRate;
uni.showToast({ title: '心率: ' + heartRate, icon: 'success' });
// 3秒后自动停止
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
stop();
uni.showToast({ title: '心率测量已结束', icon: 'none' });
}, 3000);
}
});
} catch (e: any) {
this.showError('获取心率失败', e);
}
},
// 测试血氧(事件流式)
async checkOximetryWithEvents(deviceId: string) {
try {
if (!this.services || this.services.length === 0) {
await this.discoverServices(deviceId);
}
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
if (!privateService) throw new Error('未找到私有协议服务');
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
await this.getCharacteristics(deviceId, privateService.uuid);
}
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
const handler = new ProtocolHandler(bluetoothService);
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
await handler.initialize();
// 使用事件流方式
const { stop, onData } = handler.oximetryWithEvents();
let timer = null;
onData((data: Uint8Array) => {
// 协议data[4]=佩戴状态, data[5]=心率, data[6]=血氧, data[7:8]=温度(int16,0.01℃,小端)
if (data.length >= 9 && data[2] === 0x32 && data[3] === 0x00) {
const wearStatus = data[4];
const heartRate = data[5];
const spo2 = data[6];
// 温度为有符号短整型,小端序
let tempRaw = (data[8] << 8) | data[7];
if (tempRaw & 0x8000) tempRaw = tempRaw - 0x10000;
const temp = tempRaw / 100;
console.log({ title: `血氧: ${spo2}% 心率: ${heartRate} 温度: ${temp}℃` });
// 3秒后自动停止
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
stop();
uni.showToast({ title: '血氧测量已结束', icon: 'none' });
}, 3000);
}
});
} catch (e: any) {
this.showError('获取血氧失败', e);
}
},
}
}
</script>
<style>
.container {
padding: 20px;
}
.section-title {
font-size: 16px;
font-weight: bold;
margin: 20px 0 10px;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
.device-item {
margin: 10px 0;
padding: 12px;
border: 1px solid #eee;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.connected-item {
background-color: #f0f9ff;
border-color: #b3e0ff;
}
.device-info {
flex: 1;
}
.device-name {
font-weight: bold;
font-size: 16px;
margin-right: 5px;
}
.device-id {
font-size: 12px;
color: #999;
margin-right: 10px;
}
.status {
font-size: 12px;
color: #999;
}
.status.connected {
color: #4CAF50;
}
.device-actions {
display: flex;
gap: 10px;
}
.btn-connect {
background-color: #4CAF50;
color: white;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
.btn-disconnect {
background-color: #F44336;
color: white;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
.btn-discover {
background-color: #2196F3;
color: white;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
.empty-list {
text-align: center;
padding: 20px;
color: #999;
font-style: italic;
}
/* 设备详情样式 */
.device-detail {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background-color: #f9f9f9;
}
/* 服务列表样式 */
.services-container {
margin-top: 10px;
}
.service-item {
margin-bottom: 10px;
border: 1px solid #eee;
border-radius: 6px;
overflow: hidden;
}
.bae-service {
border-color: #4CAF50;
}
.service-header {
padding: 10px;
background-color: #f5f5f5;
display: flex;
align-items: center;
cursor: pointer;
}
.bae-service .service-header {
background-color: #e8f5e9;
}
.service-uuid {
flex: 1;
font-family: monospace;
}
.service-tag {
background-color: #4CAF50;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
margin-right: 10px;
}
.expand-icon {
margin-left: 10px;
font-weight: bold;
}
/* 特征值列表样式 */
.characteristics-container {
padding: 10px;
background-color: white;
}
.characteristic-item {
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 10px;
}
.char-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.char-uuid {
flex: 1;
font-family: monospace;
font-size: 14px;
}
.char-properties {
display: flex;
gap: 4px;
}
.prop-tag {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
border-radius: 2px;
}
.prop-tag.read {
background-color: #2196F3;
}
.prop-tag.write {
background-color: #FF9800;
}
.prop-tag.notify {
background-color: #9C27B0;
}
.prop-tag.indicate {
background-color: #795548;
}
/* 写入区域样式 */
.char-write {
display: flex;
margin-top: 8px;
}
.write-input {
flex: 1;
padding: 6px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 8px;
}
.btn-write {
background-color: #FF9800;
color: white;
border-radius: 4px;
padding: 6px 12px;
}
/* 通知区域样式 */
.char-notify {
margin-top: 8px;
}
.btn-subscribe {
background-color: #9C27B0;
color: white;
border-radius: 4px;
padding: 6px 12px;
}
.btn-subscribed {
background-color: #7B1FA2;
color: white;
border-radius: 4px;
padding: 6px 12px;
opacity: 0.7;
}
.notify-data {
margin-top: 8px;
padding: 8px;
background-color: #f0f0f0;
border-radius: 4px;
}
.data-label {
font-size: 12px;
color: #666;
margin-right: 8px;
}
.data-value {
font-family: monospace;
word-break: break-all;
}
/* 电池状态样式 */
.battery-status {
margin-top: 8px;
display: flex;
align-items: center;
}
.battery-container {
width: 40px;
height: 16px;
border: 1px solid #999;
border-radius: 2px;
padding: 1px;
position: relative;
margin-right: 8px;
background-color: #f0f0f0;
}
.battery-level {
height: 100%;
background-color: #4CAF50;
border-radius: 1px;
}
.battery-text {
font-size: 12px;
color: #666;
}
.charging-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #FF9800;
font-size: 10px;
}
.btn-battery {
background-color: #607D8B;
color: white;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
</style>

7
platformConfig.json Normal file
View File

@@ -0,0 +1,7 @@
// 参考链接 https://doc.dcloud.net.cn/uni-app-x/tutorial/ls-plugin.html#setting
{
"targets": [
"APP-ANDROID"
]
}

BIN
static/OmFw2509140009.zip Normal file

Binary file not shown.

BIN
static/OmFw2510150943.zip Normal file

Binary file not shown.

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

76
uni.scss Normal file
View File

@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场https://ext.dcloud.net.cn上很多三方插件均使用了这些样式变量
* 如果你是插件开发者建议你使用scss预处理并在插件代码中直接使用这些变量无需 import 这个文件方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;

View File

@@ -0,0 +1,28 @@
# ak-charts
一个简单的 uni_modules 图表插件支持基础柱状图和折线图UTS 插件规范。
## 使用方法
1. 在页面中引用组件:
````vue
<ak-charts :option="option" canvas-id="my-canvas"></ak-charts>
````
2. 通过 AkCharts.render(option, canvasId) 进行全局渲染调用。
option 示例:
```js
{
type: 'bar', // 或 'line'
data: [10, 20, 30],
labels: ['A', 'B', 'C'],
color: '#2196f3'
}
```
## 目录结构
- index.uts 插件主入口
- components/ak-charts/ak-charts.vue 图表组件
- package.json 插件描述
- README.md 插件说明

View File

@@ -0,0 +1,803 @@
<template>
<view class="ak-charts-container">
<canvas :id="canvasId" :canvas-id="canvasId" class="ak-charts-canvas"></canvas>
</view>
</template>
<script lang="uts">
import type { ChartSeries,ChartOption,ChartType,Margin } from '../interface.uts';
export default {
name: 'AkCharts',
props: {
option: {
type: UTSJSONObject, // 修正为 Object避免类型错误
required: true
},
canvasId: {
type: String,
default: 'ak-charts-canvas'
}
},
data() {
return {
canvas: null as UniCanvasElement | null,
canvasContext: null as CanvasContext | null,
renderingContext: null as CanvasRenderingContext2D | null,
dpr: 1,
ctxReady: false,
canvasWidth: 0,
canvasHeight: 0
}
},
watch: {
option: {
handler() {
// 类型检查,确保 option 是对象且有 type/data 字段
if (
this.ctxReady ) {
let aopt:ChartOption = this.toChartOption(this.option)
this.drawChart(aopt);
}
},
deep: true,
immediate: true
}
},
mounted() {
console.log('ak charts')
this.dpr = typeof uni.getWindowInfo === "function" ? uni.getWindowInfo().pixelRatio : 1;
this.$nextTick(() => {
console.log(this.option)
let aopt:ChartOption = this.toChartOption(this.option)
uni.createCanvasContextAsync({
id: this.canvasId,
component: this,
success: (context: CanvasContext) => {
this.canvasContext = context;
this.renderingContext = context.getContext('2d');
this.canvas = this.renderingContext!.canvas;
this.hidpi(this.canvas!);
this.canvasWidth = this.canvas!.width;
this.canvasHeight = this.canvas!.height;
this.ctxReady = true;
this.drawChart(aopt);
}
});
});
uni.$on('ak-charts-render', (payload: any) => {
let option: ChartOption | null = null;
let canvasId: string = '';
let isValid = false;
// 尝试将 payload 转为 UTSJSONObject 处理
let utsPayload: UTSJSONObject | null = null;
try {
// UTSJSONObject 通常有 get 方法
if (payload != null ) {
utsPayload = payload as UTSJSONObject;
}
} catch (e) {
utsPayload = null;
}
if (utsPayload!=null) {
const opt = utsPayload.get('option');
const cid = utsPayload.get('canvasId');
if (opt != null && cid != null) {
option = opt as ChartOption;
canvasId = cid as string;
isValid = true;
}
}
if (isValid && canvasId === this.canvasId && this.ctxReady) {
this.drawChart(option!);
}
});
},
methods: {
hidpi(canvas: UniCanvasElement) {
const context = canvas.getContext("2d")!;
const dpr = this.dpr;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
context.scale(dpr, dpr);
},
// toChartOption 方法
toChartOption(obj: UTSJSONObject): ChartOption {
// 支持 series
if (Array.isArray(obj.series)) {
return {
type: obj.type as ChartType,
series: Array.isArray(obj.series) ? (obj.series as ChartSeries[]) : null,
labels: Array.isArray(obj.labels) ? (obj.labels as string[]) : []
}
}
return {
type: obj.type as ChartType,
data: Array.isArray(obj.data) ? (obj.data as number[]) : [],
labels: Array.isArray(obj.labels) ? (obj.labels as string[]) : [],
color: typeof obj.color === 'string' ? obj.color as string : ''
}
},
drawChart(option: ChartOption) {
console.log('now drawing',option)
if (this.ctxReady != true || this.renderingContext == null) return;
if (option == null || option.data == null) {
return;
}
const ctx = this.renderingContext!;
ctx.clearRect(0, 0, this.canvasWidth / this.dpr, this.canvasHeight / this.dpr);
if (option.type === 'bar') {
this.drawBar(ctx, option);
} else if (option.type === 'line') {
this.drawLine(ctx, option);
} else if (option.type === 'pie') {
this.drawPie(ctx, option);
} else if (option.type === 'doughnut') {
this.drawDoughnut(ctx, option);
} else if (option.type === 'radar') {
this.drawRadar(ctx, option);
}
},
drawBar(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = option.data != null ? option.data : ([] as number[]);
const color = option.color != null ? option.color : '#2196f3';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data == null || data.length == null || data.length === 0) return;
// 使用实际画布尺寸而不是固定值
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表高度合适
const max = Math.max(...(data as number[]), 1);
// 调整条形宽度,确保条形之间有合适的间距
const barWidth = (chartWidth / (data.length as number)) * 0.7; // 条形宽度占用70%的可用空间
const spacing = (chartWidth / (data.length as number)) * 0.3; // 剩余30%用作间距
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ccc';
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制数据条形
data.forEach((v, i) => {
// 计算条形位置
const x = margin.left + (chartWidth / data.length) * i + spacing / 2;
const valueHeight = (v / max) * chartHeight;
const y = height - margin.bottom - valueHeight;
// 使用指定颜色填充条形 - 修复类型错误
ctx.fillStyle = color as any;
ctx.fillRect(x, y, barWidth, valueHeight);
// 在条形上方显示数值
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.fillText(v.toString(), x + barWidth / 2, y - 5);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.fillText(labels[i] as string, x + barWidth / 2, height - margin.bottom + 20);
}
});
ctx.restore();
},
drawLine(ctx: CanvasRenderingContext2D, option: ChartOption) {
// 支持 series
if (option.series != null) {
const labels = option.labels != null ? option.labels : ([] as string[]);
// 计算画布尺寸、margin、chartWidth/chartHeight、max
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
let max = 1;
option.series?.forEach(s => {
if (s.data != null && s.data.length > 0) {
const m = Math.max(...(s.data as number[]));
if (m > max) max = m;
}
});
// 定义默认颜色
const defaultColors = [
'#4caf50', '#f44336', '#2196f3', '#ff9800', '#9c27b0', '#009688'
];
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制每条折线
option.series?.forEach((s, si) => {
const color = s.color != null ? s.color : defaultColors[si % defaultColors.length];
ctx.beginPath();
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
if (s.data != null && s.data.length > 0) {
const len = s.data.length;
s.data.forEach((v, i) => {
const denom = (len - 1) !== 0 ? (len - 1) : 1;
const x = margin.left + (chartWidth / denom) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
}
ctx.stroke();
// 绘制点
if (s.data != null && s.data.length > 0) {
const len = s.data.length;
s.data.forEach((v, i) => {
const denom = (len - 1) !== 0 ? (len - 1) : 1;
const x = margin.left + (chartWidth / denom) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
ctx.beginPath();
ctx.fillStyle = color as any;
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
});
}
});
ctx.restore();
return;
}
// ...原有单组数据逻辑
const data = option.data != null ? option.data : ([] as number[]);
const color = option.color != null ? option.color : '#2196f3';
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data!!.length === 0) return;
// 使用实际画布尺寸而不是固定值
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
const margin: Margin = {
top: 20,
right: 20,
bottom: 40,
left: 40
};
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 找出最大值,确保图表高度合适
const max = Math.max(...data!!, 1);
ctx.save();
// 绘制Y轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, height - margin.bottom);
ctx.stroke();
// 绘制X轴
ctx.beginPath();
ctx.strokeStyle = '#ccc' as any;
ctx.moveTo(margin.left, height - margin.bottom);
ctx.lineTo(width - margin.right, height - margin.bottom);
ctx.stroke();
// 绘制线图主体 - 先完成折线的绘制
ctx.beginPath();
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
data.forEach((v, i) => {
// 计算点的位置
const x = margin.left + (chartWidth / (data.length - 1 | 1)) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
// 先把线画出来
ctx.stroke();
// 然后再单独绘制数据点和标签
data.forEach((v, i) => {
const x = margin.left + (chartWidth / (data.length - 1 | 1)) * i;
const y = height - margin.bottom - (v / max) * chartHeight;
// 在数据点上绘制圆点
ctx.beginPath();
ctx.fillStyle = color as any;
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 在点上方显示数值
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.fillText(v.toString(), x, y - 10);
// 显示标签
if (labels != null && labels.length > i && labels[i] != null && labels[i] != '') {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.fillText(labels[i] as string, x, height - margin.bottom + 20);
}
});
ctx.restore();
},
drawPie(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = option.data != null ? option.data : ([] as number[]);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data!!.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算饼图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const radius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 40;
// 计算数据总和,用于计算每个扇区的角度
let sum = 0;
for (let i = 0; i < data!!.length; i++) {
sum += data[i];
}
// 默认颜色数组,如果没有提供足够的颜色
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
ctx.save();
// 绘制饼图
let startAngle: Double = (-Math.PI / 2) as Double; // 从12点钟方向开始
data!!.forEach((value, i) => {
// 计算扇区角度
const sliceAngle = 2 * Math.PI * (value / sum);
// 确定扇区颜色
let color: string = '';
if (Array.isArray(option.color)) {
// 如果提供了颜色数组,使用对应索引的颜色或循环使用
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
// 如果提供了单个颜色,使用色调变化
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
// 使用默认颜色
color = defaultColors[i % defaultColors.length];
}
// 绘制扇区
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = color as any;
ctx.fill();
// 绘制扇区边框
ctx.strokeStyle = "#fff" as any;
ctx.lineWidth = 2;
ctx.stroke();
// 计算标签位置 - 在扇区中间
const labelAngle = startAngle + sliceAngle / 2;
const labelRadius = radius * 0.7; // 标签位于半径的70%处
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
// 显示数值
const percentage = Math.round((value / sum) * 100);
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = '#fff' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${percentage}%`, labelX, labelY);
// 更新起始角度为当前扇区的结束角度
startAngle += sliceAngle as Double;
});
// 绘制图例
if (labels != null && labels.length > 0) {
const legendX = centerX - radius - 10;
const legendY = centerY + radius + 20;
data.forEach((value, i) => {
if (labels != null && i < labels.length && labels[i] != null && labels[i] != '') {
// 确定图例颜色
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
// 绘制图例颜色方块
ctx.fillStyle = color as any;
ctx.fillRect(legendX, legendY + i * 25, 15, 15);
// 绘制图例文字
const percentage = Math.round((value / sum) * 100);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${labels[i]} (${percentage}%)`, legendX + 25, legendY + i * 25 + 7.5);
}
});
}
ctx.restore();
},
drawDoughnut(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = option.data != null ? option.data : ([] as number[]);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data!!.length === 0) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算环形图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const outerRadius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 40;
// 环形图特有的内圆半径默认为外圆半径的60%
let innerRadiusValue: number | null = option.innerRadius;
const innerRadius: number = innerRadiusValue != null ? innerRadiusValue : outerRadius * 0.6;
// 计算数据总和,用于计算每个扇区的角度
let sum = 0;
for (let i = 0; i < data!!.length; i++) {
sum += data[i];
}
// 默认颜色数组
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
ctx.save();
// 绘制环形图
let startAngle: Double = (-Math.PI / 2) as Double; // 从12点钟方向开始
data!!.forEach((value, i) => {
// 计算扇区角度
const sliceAngle = 2 * Math.PI * (value / sum);
// 确定扇区颜色
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
// 绘制扇区弧段
ctx.beginPath();
ctx.arc(centerX, centerY, outerRadius, startAngle, startAngle + sliceAngle);
ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
ctx.closePath();
ctx.fillStyle = color as any;
ctx.fill();
// 绘制扇区边框
ctx.strokeStyle = "#fff" as any;
ctx.lineWidth = 2;
ctx.stroke();
// 计算标签位置 - 在扇区中间
const labelAngle = startAngle + sliceAngle / 2;
const labelRadius = (outerRadius + innerRadius) / 2; // 标签位于内外圆之间
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
// 显示数值百分比
const percentage = Math.round((value / sum) * 100);
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = '#fff' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${percentage}%`, labelX, labelY);
// 更新起始角度为当前扇区的结束角度
startAngle += sliceAngle as Double;
});
// 绘制图例
if (labels != null && labels.length > 0) {
const legendX = centerX - outerRadius - 10;
const legendY = centerY + outerRadius + 20;
data.forEach((value, i) => {
if (labels != null && i < labels.length && labels[i] != null && labels[i] != '') {
// 确定图例颜色
let color: string = '';
if (Array.isArray(option.color)) {
color = option.color.length > i ? option.color[i] : defaultColors[i % defaultColors.length];
} else if (typeof option.color === 'string') {
color = i === 0 ? option.color : this.shiftHue(option.color as string, i * 30);
} else {
color = defaultColors[i % defaultColors.length];
}
// 绘制图例颜色方块
ctx.fillStyle = color as any;
ctx.fillRect(legendX, legendY + i * 25, 15, 15);
// 绘制图例文字
const percentage = Math.round((value / sum) * 100);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${labels[i]} (${percentage}%)`, legendX + 25, legendY + i * 25 + 7.5);
}
});
}
ctx.restore();
},
drawRadar(ctx: CanvasRenderingContext2D, option: ChartOption) {
const data = option.data != null ? option.data : ([] as number[]);
const labels = option.labels != null ? option.labels : ([] as string[]);
if (data!!.length === 0 || (labels != null && labels.length === 0)) return;
// 使用实际画布尺寸
const width = this.canvasWidth / this.dpr;
const height = this.canvasHeight / this.dpr;
// 计算雷达图中心和半径
let centerXValue: number | null = option.centerX;
const centerX: number = centerXValue != null ? centerXValue : width / 2;
let centerYValue: number | null = option.centerY;
const centerY: number = centerYValue != null ? centerYValue : height / 2;
let radiusValue: number | null = option.radius;
const radius: number = radiusValue != null ? radiusValue : Math.min(width, height) / 2 - 60;
// 计算数据点数量即多边形的边数至少为3
const count = Math.max(data!!.length, 3);
// 找出最大值,用于归一化数据
const max = Math.max(...data, 1);
// 计算每个角的角度
const angleStep = (2 * Math.PI) / count;
// 确定颜色
let color: string = '';
if (typeof option.color === 'string') {
color = option.color;
} else {
color = '#36A2EB';
}
ctx.save();
// 绘制雷达图网格和轴线
for (let level = 1; level <= 5; level++) {
const levelRadius = radius * (level / 5);
// 绘制多边形轮廓
ctx.beginPath();
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + levelRadius * Math.cos(angle);
const y = centerY + levelRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.strokeStyle = '#ddd' as any;
ctx.lineWidth = 1;
ctx.stroke();
}
// 绘制轴线
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(x, y);
ctx.strokeStyle = '#ddd' as any;
ctx.lineWidth = 1;
ctx.stroke();
// 绘制标签
const labelRadius = radius + 15;
const labelX = centerX + labelRadius * Math.cos(angle);
const labelY = centerY + labelRadius * Math.sin(angle);
ctx.font = '14px sans-serif';
ctx.fillStyle = '#666' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 根据标签位置调整对齐方式,使标签不超出画布
if (angle === -Math.PI / 2) { // 顶部
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
} else if (angle === Math.PI / 2) { // 底部
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
} else if (angle === 0) { // 右侧
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
} else if (Math.abs(angle) === Math.PI) { // 左侧
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
} else if (angle > -Math.PI / 2 && angle < 0) { // 右上
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
} else if (angle > 0 && angle < Math.PI / 2) { // 右下
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
} else if (angle > Math.PI / 2 && angle < Math.PI) { // 左下
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
} else if (angle > -Math.PI && angle < -Math.PI / 2) { // 左上
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
}
if (labels != null && i < labels.length && labels[i] != null) {
ctx.fillText(labels[i] as string, labelX, labelY);
}
}
// 绘制数据区域
ctx.beginPath();
for (let i = 0; i < count; i++) {
const value = i < data.length ? data[i] : 0;
const valueRadius = (value / max) * radius;
const angle = -Math.PI / 2 + i * angleStep;
const x = centerX + valueRadius * Math.cos(angle);
const y = centerY + valueRadius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
// 绘制数据点
ctx.fillStyle = color as any;
ctx.beginPath();
ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
// 显示数值
const textRadius = valueRadius + 15;
const textX = centerX + valueRadius * Math.cos(angle);
const textY = centerY + valueRadius * Math.sin(angle);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#333' as any;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(value.toString(), textX, textY - 10);
}
// 闭合并填充数据区域
ctx.closePath();
ctx.fillStyle = `${color}33` as any; // 添加透明度
ctx.fill();
// 描边
ctx.strokeStyle = color as any;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
},
// 辅助函数:根据基础颜色和色调偏移生成新颜色
shiftHue(color: string, degree: number): string {
// 简单实现:返回默认颜色数组中的一个
const defaultColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8AC249', '#EA526F', '#49A6B7', '#C5D86D'
];
return defaultColors[Math.abs(degree) % defaultColors.length];
},
}
}
</script>
<style scoped>
.ak-charts-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.ak-charts-canvas {
width: 600rpx;
height: 400rpx;
background: #ffff7f;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
</style>

View File

@@ -0,0 +1,45 @@
/**
* ak-charts UTS 插件主入口
* 提供注册和渲染图表的基础接口
*/
export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar';
export type ChartSeries = {
name: string;
data: number[];
color?: string;
};
export type ChartOption = {
type: ChartType;
data?: number[];
series?: ChartSeries[]; // 新增
labels?: string[];
color?: string | string[];
centerX?: number;
centerY?: number;
radius?: number;
innerRadius?: number; // 环形图内圆半径
}
export type Margin {
top: number;
right: number;
bottom: number;
left: number;
}
export class AkCharts {
// 注册图表(可扩展)
static register(type: ChartType, render: Function): void {
// 这里可以实现自定义图表类型注册
}
// 渲染图表(实际渲染由组件完成)
static render(option: ChartOption, canvasId: string): void {
// 这里只做参数校验和分发,实际渲染由 ak-charts.vue 组件实现
// 可通过 uni.$emit/uni.$on 或全局事件通信
uni.$emit('ak-charts-render', { option, canvasId });
}
}
export default AkCharts;

View File

@@ -0,0 +1,12 @@
{
"name": "ak-charts",
"version": "0.1.0",
"description": "一个简单的uni_modules图表插件支持基础柱状图和折线图UTS插件规范。",
"uni_modules": {
"uni_modules": true,
"platforms": ["app", "h5", "mp-weixin"],
"uts": true
},
"main": "index.uts",
"keywords": ["charts", "canvas", "uni_modules", "uts"]
}

View File

@@ -0,0 +1,101 @@
{
"id": "ak-sbsrv",
"displayName": "多蓝牙设备连接管理插件",
"version": "1.0.0",
"description": "支持多蓝牙设备连接管理的插件,可扫描、连接、发送数据等",
"keywords": [
"蓝牙",
"设备管理",
"BLE",
"多设备",
"连接管理"
],
"repository": "",
"engines": {
"HBuilderX": "^4.0.0",
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
},
"dcloudext": {
"type": "uts-plugin",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "设备蓝牙"
},
"npmurl": "",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"uni_modules": {
"dependencies": [
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "-",
"vue3": "-"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "-",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}

View File

@@ -0,0 +1,115 @@
# ak-sbsrv
## 介绍
ak-sbsrv 是一个多蓝牙设备连接管理插件,基于 uni-app-x UTS 开发,支持同时连接和管理多个蓝牙设备。
主要特点:
- 支持多蓝牙设备的扫描、连接和通信
- 支持多种蓝牙协议BLE、SLE、BR/EDR等
- 支持多种数据格式JSON、XML、RAW等
- 统一接口多平台支持目前已支持H5平台
## 平台支持
- H5 (Chrome、Edge、Safari等支持Web Bluetooth API的现代浏览器)
- Android (开发中)
- iOS (开发中)
> H5端需要在支持Web Bluetooth API的浏览器及安全上下文HTTPS或localhost中使用
## 安装使用
1. 在插件市场下载或通过HBuilderX导入本插件
2. 导入到项目中
## API列表
### 基础功能
#### 扫描设备
```js
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts';
// 扫描设备
const result = await bluetoothService.scanDevices();
console.log('扫描到设备:', result.devices);
```
#### 连接设备
```js
// 连接设备
await bluetoothService.connectDevice(deviceId);
```
#### 断开连接
```js
// 断开连接
await bluetoothService.disconnectDevice(deviceId);
```
#### 发送数据
```js
// 发送数据
await bluetoothService.sendData({
deviceId: '设备ID',
serviceId: '服务UUID',
characteristicId: '特征值UUID',
data: '要发送的数据',
format: 2 // 2代表RAW格式
});
```
### 事件监听
#### 监听连接状态变化
```js
// 监听连接状态变化
bluetoothService.onConnectionStateChange((deviceId, state) => {
console.log(`设备 ${deviceId} 连接状态变为: ${state}`);
// state: 0-断开1-连接中2-已连接3-断开中
});
```
#### 监听数据接收
```js
// 监听数据接收
bluetoothService.onDataReceived((payload) => {
console.log('收到数据:', payload);
});
```
#### 监听错误
```js
// 监听错误
bluetoothService.onError((error) => {
console.error('蓝牙错误:', error);
});
```
### 其他API
- `getConnectedDevices()` - 获取已连接设备列表
- `getConnectionState(deviceId)` - 获取指定设备的连接状态
- `listenCharacteristicNotify(deviceId, serviceId, characteristicId)` - 监听特征值通知
## 示例项目
参见仓库中的 [control.uvue](pages/control.uvue) 页面了解完整的使用示例。
## 常见问题
1. **H5中无法扫描到设备**
请确保浏览器支持Web Bluetooth API且页面在HTTPS或localhost环境下运行。
2. **扫描后无法连接设备?**
请确保设备在可连接范围内,且蓝牙服务已打开。
3. **发送数据失败?**
请检查serviceId和characteristicId是否正确以及特征值是否支持写入。
## 更新日志
### 1.0.0 (2025-04-24)
- 支持Web平台的蓝牙设备扫描、连接和数据收发
- 支持多设备同时连接管理
- 实现事件监听机制

View File

@@ -0,0 +1,279 @@
import type {
BleDevice,
BleConnectionState,
BleEvent,
BleEventCallback,
BleEventPayload,
BleScanResult,
BleConnectOptionsExt,
AutoBleInterfaces,
BleDataPayload,
SendDataPayload,
BleOptions,
MultiProtocolDevice,
ScanHandler,
BleProtocolType,
ScanDevicesOptions
} from '../interface.uts';
import { ProtocolHandler } from '../protocol_handler.uts';
import { BluetoothService } from '../interface.uts';
import { DeviceManager } from './device_manager.uts';
// Shape used when callers register plain objects as handlers. Using a named
// type keeps member access explicit so the code generator emits valid Kotlin
// member references instead of trying to access properties on Any.
type RawProtocolHandler = {
protocol?: BleProtocolType;
scanDevices?: (options?: ScanDevicesOptions) => Promise<void>;
connect?: (device: BleDevice, options?: BleConnectOptionsExt) => Promise<void>;
disconnect?: (device: BleDevice) => Promise<void>;
sendData?: (device: BleDevice, payload?: SendDataPayload, options?: BleOptions) => Promise<void>;
autoConnect?: (device: BleDevice, options?: BleConnectOptionsExt) => Promise<AutoBleInterfaces>;
}
// 设备上下文
class DeviceContext {
device : BleDevice;
protocol : BleProtocolType;
state : BleConnectionState;
handler : ProtocolHandler;
constructor(device : BleDevice, protocol : BleProtocolType, handler : ProtocolHandler) {
this.device = device;
this.protocol = protocol;
this.state = 0; // DISCONNECTED
this.handler = handler;
}
}
const deviceMap = new Map<string, DeviceContext>(); // key: deviceId|protocol
// Single active protocol handler (no multi-protocol registration)
let activeProtocol: BleProtocolType = 'standard';
let activeHandler: ProtocolHandler | null = null;
// 事件监听注册表
const eventListeners = new Map<BleEvent, Set<BleEventCallback>>();
function emit(event : BleEvent, payload : BleEventPayload) {
if (event === 'connectionStateChanged') {
console.log('[AKBLE][LOG] bluetooth_manager.uts emit connectionStateChanged', payload)
}
const listeners = eventListeners.get(event);
if (listeners != null) {
listeners.forEach(cb => {
try { cb(payload); } catch (e) { }
});
}
}
class ProtocolHandlerWrapper extends ProtocolHandler {
private _raw: RawProtocolHandler | null;
constructor(raw?: RawProtocolHandler) {
// pass a lightweight BluetoothService instance to satisfy generators
super(new BluetoothService());
this._raw = (raw != null) ? raw : null;
}
override async scanDevices(options?: ScanDevicesOptions): Promise<void> {
const rawTyped = this._raw;
if (rawTyped != null && typeof rawTyped.scanDevices === 'function') {
await rawTyped.scanDevices(options);
}
return;
}
override async connect(device: BleDevice, options?: BleConnectOptionsExt): Promise<void> {
const rawTyped = this._raw;
if (rawTyped != null && typeof rawTyped.connect === 'function') {
await rawTyped.connect(device, options);
}
return;
}
override async disconnect(device: BleDevice): Promise<void> {
const rawTyped = this._raw;
if (rawTyped != null && typeof rawTyped.disconnect === 'function') {
await rawTyped.disconnect(device);
}
return;
}
override async sendData(device: BleDevice, payload?: SendDataPayload, options?: BleOptions): Promise<void> {
const rawTyped = this._raw;
if (rawTyped != null && typeof rawTyped.sendData === 'function') {
await rawTyped.sendData(device, payload, options);
}
return;
}
override async autoConnect(device: BleDevice, options?: BleConnectOptionsExt): Promise<AutoBleInterfaces> {
const rawTyped = this._raw;
if (rawTyped != null && typeof rawTyped.autoConnect === 'function') {
return await rawTyped.autoConnect(device, options);
}
return { serviceId: '', writeCharId: '', notifyCharId: '' };
}
}
// Strong runtime detector for plain object handlers (no Type Predicate)
// Note: the UTS bundler doesn't support TypeScript type predicates (x is T),
// and it doesn't accept the 'unknown' type. This returns a boolean and
// callers must cast the value to RawProtocolHandler after the function
// returns true.
function isRawProtocolHandler(x: any): boolean {
if (x == null || typeof x !== 'object') return false;
const r = x as Record<string, unknown>;
if (typeof r['scanDevices'] === 'function') return true;
if (typeof r['connect'] === 'function') return true;
if (typeof r['disconnect'] === 'function') return true;
if (typeof r['sendData'] === 'function') return true;
if (typeof r['autoConnect'] === 'function') return true;
if (typeof r['protocol'] === 'string') return true;
return false;
}
export const registerProtocolHandler = (handler : any) => {
if (handler == null) return;
// Determine protocol value defensively. Default to 'standard' when unknown.
let proto: BleProtocolType = 'standard';
if (handler instanceof ProtocolHandler) {
try { proto = (handler as ProtocolHandler).protocol as BleProtocolType; } catch (e) { }
activeHandler = handler as ProtocolHandler;
} else if (isRawProtocolHandler(handler)) {
try { proto = (handler as RawProtocolHandler).protocol as BleProtocolType; } catch (e) { }
activeHandler = new ProtocolHandlerWrapper(handler as RawProtocolHandler);
(activeHandler as ProtocolHandler).protocol = proto;
} else {
console.warn('[AKBLE] registerProtocolHandler: unsupported handler type, ignoring', handler);
return;
}
activeProtocol = proto;
}
export const scanDevices = async (options ?: ScanDevicesOptions) : Promise<void> => {
console.log('[AKBLE] start scan', options)
// Determine which protocols to run: either user-specified or all registered
// Single active handler flow
if (activeHandler == null) {
console.log('[AKBLE] no active scan handler registered')
return
}
const handler = activeHandler as ProtocolHandler;
const scanOptions : ScanDevicesOptions = {
onDeviceFound: (device : BleDevice) => emit('deviceFound', { event: 'deviceFound', device }),
onScanFinished: () => emit('scanFinished', { event: 'scanFinished' })
}
try {
await handler.scanDevices(scanOptions)
} catch (e) {
console.warn('[AKBLE] scanDevices handler error', e)
}
}
export const connectDevice = async (deviceId : string, protocol : BleProtocolType, options ?: BleConnectOptionsExt) : Promise<void> => {
const handler = activeHandler;
if (handler == null) throw new Error('No protocol handler');
const device : BleDevice = { deviceId, name: '', rssi: 0 }; // 可扩展
await handler.connect(device, options);
const ctx = new DeviceContext(device, protocol, handler);
ctx.state = 2; // CONNECTED
deviceMap.set(getDeviceKey(deviceId, protocol), ctx);
console.log(deviceMap)
emit('connectionStateChanged', { event: 'connectionStateChanged', device, protocol, state: 2 });
}
export const disconnectDevice = async (deviceId : string, protocol : BleProtocolType) : Promise<void> => {
const ctx = deviceMap.get(getDeviceKey(deviceId, protocol));
if (ctx == null || ctx.handler == null) return;
await ctx.handler.disconnect(ctx.device);
ctx.state = 0;
emit('connectionStateChanged', { event: 'connectionStateChanged', device: ctx.device, protocol, state: 0 });
deviceMap.delete(getDeviceKey(deviceId, protocol));
}
export const sendData = async (payload : SendDataPayload, options ?: BleOptions) : Promise<void> => {
const ctx = deviceMap.get(getDeviceKey(payload.deviceId, payload.protocol));
if (ctx == null) throw new Error('Device not connected');
// copy to local non-null variable so generator can smart-cast across awaits
const deviceCtx = ctx as DeviceContext;
if (deviceCtx.handler == null) throw new Error('sendData not supported for this protocol');
await deviceCtx.handler.sendData(deviceCtx.device, payload, options);
emit('dataSent', { event: 'dataSent', device: deviceCtx.device, protocol: payload.protocol, data: payload.data });
}
export const getConnectedDevices = () : MultiProtocolDevice[] => {
const result : MultiProtocolDevice[] = [];
deviceMap.forEach((ctx : DeviceContext) => {
const dev : MultiProtocolDevice = {
deviceId: ctx.device.deviceId,
name: ctx.device.name,
rssi: ctx.device.rssi,
protocol: ctx.protocol
};
result.push(dev);
});
return result;
}
export const getConnectionState = (deviceId : string, protocol : BleProtocolType) : BleConnectionState => {
const ctx = deviceMap.get(getDeviceKey(deviceId, protocol));
if (ctx == null) return 0;
return ctx.state;
}
export const on = (event : BleEvent, callback : BleEventCallback) => {
if (!eventListeners.has(event)) eventListeners.set(event, new Set());
eventListeners.get(event)!.add(callback);
}
export const off = (event : BleEvent, callback ?: BleEventCallback) => {
if (callback == null) {
eventListeners.delete(event);
} else {
eventListeners.get(event)?.delete(callback as BleEventCallback);
}
}
function getDeviceKey(deviceId : string, protocol : BleProtocolType) : string {
return `${deviceId}|${protocol}`;
}
export const autoConnect = async (deviceId : string, protocol : BleProtocolType, options ?: BleConnectOptionsExt) : Promise<AutoBleInterfaces> => {
const handler = activeHandler;
if (handler == null) throw new Error('autoConnect not supported for this protocol');
const device : BleDevice = { deviceId, name: '', rssi: 0 };
// safe call - handler.autoConnect exists on ProtocolHandler
return await handler.autoConnect(device, options) as AutoBleInterfaces;
}
// Ensure there is at least one handler registered so callers can scan/connect
// without needing to import a registry module. This creates a minimal default
// ProtocolHandler backed by a BluetoothService instance.
try {
if (activeHandler == null) {
// Create a DeviceManager-backed raw handler that delegates to native code
const _dm = DeviceManager.getInstance();
const _raw: RawProtocolHandler = {
protocol: 'standard',
scanDevices: (options?: ScanDevicesOptions) => {
try {
const scanOptions = options != null ? options : {} as ScanDevicesOptions;
_dm.startScan(scanOptions);
} catch (e) {
console.warn('[AKBLE] DeviceManager.startScan failed', e);
}
return Promise.resolve();
},
connect: (device, options?: BleConnectOptionsExt) => {
return _dm.connectDevice(device.deviceId, options);
},
disconnect: (device) => {
return _dm.disconnectDevice(device.deviceId);
},
autoConnect: (device, options?: any) => {
// DeviceManager does not provide an autoConnect helper; return default
const result: AutoBleInterfaces = { serviceId: '', writeCharId: '', notifyCharId: '' };
return Promise.resolve(result);
}
};
const _wrapper = new ProtocolHandlerWrapper(_raw);
activeHandler = _wrapper;
activeProtocol = _raw.protocol as BleProtocolType;
console.log('[AKBLE] default protocol handler (BluetoothService-backed) registered', activeProtocol);
}
} catch (e) {
console.warn('[AKBLE] failed to register default protocol handler', e);
}

View File

@@ -0,0 +1,5 @@
{
"dependencies": [
]
}

View File

@@ -0,0 +1,311 @@
import type { BleDevice, BleOptions, BleConnectionState, BleConnectionStateChangeCallback } from '../interface.uts'
import type { BleConnectOptionsExt } from '../interface.uts'
import type { ScanDevicesOptions } from '../interface.uts';
import Context from "android.content.Context";
import BluetoothAdapter from "android.bluetooth.BluetoothAdapter";
import BluetoothManager from "android.bluetooth.BluetoothManager";
import BluetoothDevice from "android.bluetooth.BluetoothDevice";
import BluetoothGatt from "android.bluetooth.BluetoothGatt";
import BluetoothGattCallback from "android.bluetooth.BluetoothGattCallback";
import ScanCallback from "android.bluetooth.le.ScanCallback";
import ScanResult from "android.bluetooth.le.ScanResult";
import ScanSettings from "android.bluetooth.le.ScanSettings";
import Handler from "android.os.Handler";
import Looper from "android.os.Looper";
import ContextCompat from "androidx.core.content.ContextCompat";
import PackageManager from "android.content.pm.PackageManager";
// 定义 PendingConnect 类型和实现类
interface PendingConnect {
resolve: () => void;
reject: (err?: any) => void; // Changed to make err optional
timer?: number;
}
class PendingConnectImpl implements PendingConnect {
resolve: () => void;
reject: (err?: any) => void; // Changed to make err optional
timer?: number;
constructor(resolve: () => void, reject: (err?: any) => void, timer?: number) {
this.resolve = resolve;
this.reject = reject;
this.timer = timer;
}
}
// 引入全局回调管理
import { gattCallback } from './service_manager.uts'
const pendingConnects = new Map<string, PendingConnect>();
const STATE_DISCONNECTED = 0;
const STATE_CONNECTING = 1;
const STATE_CONNECTED = 2;
const STATE_DISCONNECTING = 3;
export class DeviceManager {
private static instance: DeviceManager | null = null;
private devices = new Map<string, BleDevice>();
private connectionStates = new Map<string, BleConnectionState>();
private connectionStateChangeListeners: BleConnectionStateChangeCallback[] = []
private gattMap = new Map<string, BluetoothGatt | null>();
private scanCallback: ScanCallback | null = null
private isScanning: boolean = false
private constructor() {}
static getInstance(): DeviceManager {
if (DeviceManager.instance == null) {
DeviceManager.instance = new DeviceManager();
}
return DeviceManager.instance!;
}
startScan(options: ScanDevicesOptions): void {
console.log('ak startscan now')
const adapter = this.getBluetoothAdapter();
if (adapter == null) {
throw new Error('未找到蓝牙适配器');
}
if (!adapter.isEnabled) {
// 尝试请求用户开启蓝牙
try {
adapter.enable(); // 直接调用,无需可选链和括号
} catch (e) {
// 某些设备可能不支持 enable
}
setTimeout(() => {
if (!adapter.isEnabled) {
throw new Error('蓝牙未开启');
}
}, 1500);
throw new Error('正在开启蓝牙,请重试');
}
const foundDevices = this.devices; // 直接用全局 devices
class MyScanCallback extends ScanCallback {
private foundDevices: Map<string, BleDevice>;
private onDeviceFound: (device: BleDevice) => void;
constructor(foundDevices: Map<string, BleDevice>, onDeviceFound: (device: BleDevice) => void) {
super();
this.foundDevices = foundDevices;
this.onDeviceFound = onDeviceFound;
}
override onScanResult(callbackType: Int, result: ScanResult): void {
const device = result.getDevice();
if (device != null) {
const deviceId = device.getAddress();
let bleDevice = foundDevices.get(deviceId);
if (bleDevice == null) {
bleDevice = {
deviceId,
name: device.getName() ?? 'Unknown',
rssi: result.getRssi(),
lastSeen: Date.now()
};
foundDevices.set(deviceId, bleDevice);
this.onDeviceFound(bleDevice);
} else {
// 更新属性(已确保 bleDevice 非空)
bleDevice.rssi = result.getRssi();
bleDevice.name = device.getName() ?? bleDevice.name;
bleDevice.lastSeen = Date.now();
}
}
}
override onScanFailed(errorCode: Int): void {
console.log('ak scan fail')
}
}
this.scanCallback = new MyScanCallback(foundDevices, options.onDeviceFound ?? (() => {}));
const scanner = adapter.getBluetoothLeScanner();
if (scanner == null) {
throw new Error('无法获取扫描器');
}
const scanSettings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build();
scanner.startScan(null, scanSettings, this.scanCallback);
this.isScanning = true;
// 默认10秒后停止扫描
new Handler(Looper.getMainLooper()).postDelayed(() => {
if (this.isScanning && this.scanCallback != null) {
scanner.stopScan(this.scanCallback);
this.isScanning = false;
// this.devices = foundDevices;
if (options.onScanFinished != null) options.onScanFinished?.invoke();
}
}, 40000);
}
async connectDevice(deviceId: string, options?: BleConnectOptionsExt): Promise<void> {
console.log('[AKBLE] connectDevice called, deviceId:', deviceId, 'options:', options, 'connectionStates:')
const adapter = this.getBluetoothAdapter();
if (adapter == null) {
console.error('[AKBLE] connectDevice failed: 蓝牙适配器不可用')
throw new Error('蓝牙适配器不可用');
}
const device = adapter.getRemoteDevice(deviceId);
if (device == null) {
console.error('[AKBLE] connectDevice failed: 未找到设备', deviceId)
throw new Error('未找到设备');
}
this.connectionStates.set(deviceId, STATE_CONNECTING);
console.log('[AKBLE] connectDevice set STATE_CONNECTING, deviceId:', deviceId, 'connectionStates:')
this.emitConnectionStateChange(deviceId, STATE_CONNECTING);
const activity = UTSAndroid.getUniActivity();
const timeout = options?.timeout ?? 15000;
const key = `${deviceId}|connect`;
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
console.error('[AKBLE] connectDevice 超时:', deviceId)
pendingConnects.delete(key);
this.connectionStates.set(deviceId, STATE_DISCONNECTED);
this.gattMap.set(deviceId, null);
this.emitConnectionStateChange(deviceId, STATE_DISCONNECTED);
reject(new Error('连接超时'));
}, timeout);
// 创建一个适配器函数来匹配类型签名
const resolveAdapter = () => {
console.log('[AKBLE] connectDevice resolveAdapter:', deviceId)
resolve();
};
const rejectAdapter = (err?: any) => {
console.error('[AKBLE] connectDevice rejectAdapter:', deviceId, err)
reject(err);
};
pendingConnects.set(key, new PendingConnectImpl(resolveAdapter, rejectAdapter, timer));
try {
console.log('[AKBLE] connectGatt 调用前:', deviceId)
const gatt = device.connectGatt(activity, false, gattCallback);
this.gattMap.set(deviceId, gatt);
console.log('[AKBLE] connectGatt 调用后:', deviceId, gatt)
} catch (e) {
console.error('[AKBLE] connectGatt 异常:', deviceId, e)
clearTimeout(timer);
pendingConnects.delete(key);
this.connectionStates.set(deviceId, STATE_DISCONNECTED);
this.gattMap.set(deviceId, null);
this.emitConnectionStateChange(deviceId, STATE_DISCONNECTED);
reject(e);
}
});
}
// 统一分发连接回调(应在 gattCallback.onConnectionStateChange 内调用)
static handleConnectionStateChange(deviceId: string, newState: number, error?: any) {
console.log('[AKBLE] handleConnectionStateChange:', deviceId, 'newState:', newState, 'error:', error, 'pendingConnects:')
const key = `${deviceId}|connect`;
const cb = pendingConnects.get(key);
if (cb != null) {
// 修复 timer 的空安全问题,使用临时变量
const timerValue = cb.timer;
if (timerValue != null) {
clearTimeout(timerValue);
}
// 修复 error 处理
if (newState === STATE_CONNECTED) {
console.log('[AKBLE] handleConnectionStateChange: 连接成功', deviceId)
cb.resolve();
} else {
// 正确处理可空值
const errorToUse = error != null ? error : new Error('连接断开');
console.error('[AKBLE] handleConnectionStateChange: 连接失败', deviceId, errorToUse)
cb.reject(errorToUse);
}
pendingConnects.delete(key);
} else {
console.warn('[AKBLE] handleConnectionStateChange: 未找到 pendingConnects', deviceId, newState)
}
}
async disconnectDevice(deviceId: string, isActive: boolean = true): Promise<void> {
console.log('[AKBLE] disconnectDevice called, deviceId:', deviceId, 'isActive:', isActive)
let gatt = this.gattMap.get(deviceId);
if (gatt != null) {
gatt.disconnect();
gatt.close();
// gatt=null;
this.gattMap.set(deviceId, null);
this.connectionStates.set(deviceId, STATE_DISCONNECTED);
console.log('[AKBLE] disconnectDevice set STATE_DISCONNECTED, deviceId:', deviceId, 'connectionStates:')
this.emitConnectionStateChange(deviceId, STATE_DISCONNECTED);
return;
} else {
console.log('[AKBLE] disconnectDevice: gatt is null, deviceId:', deviceId)
return;
}
}
async reconnectDevice(deviceId: string, options?: BleConnectOptionsExt): Promise<void> {
let attempts = 0;
const maxAttempts = options?.maxAttempts ?? 3;
const interval = options?.interval ?? 3000;
while (attempts < maxAttempts) {
try {
await this.disconnectDevice(deviceId, false);
await this.connectDevice(deviceId, options);
return;
} catch (e) {
attempts++;
if (attempts >= maxAttempts) throw new Error('重连失败');
// 修复 setTimeout 问题,使用旧式 Promise + setTimeout 解决
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, interval);
});
}
}
}
getConnectedDevices(): BleDevice[] {
// 创建一个空数组来存储结果
const result: BleDevice[] = [];
// 遍历 devices Map 并检查连接状态
this.devices.forEach((device, deviceId) => {
if (this.connectionStates.get(deviceId) === STATE_CONNECTED) {
result.push(device);
}
});
return result;
}
onConnectionStateChange(listener: BleConnectionStateChangeCallback) {
console.log('[AKBLE][LOG] onConnectionStateChange 注册, 当前监听数:', this.connectionStateChangeListeners.length + 1, listener)
this.connectionStateChangeListeners.push(listener)
}
protected emitConnectionStateChange(deviceId: string, state: BleConnectionState) {
console.log('[AKBLE][LOG] emitConnectionStateChange', deviceId, state, 'listeners:', this.connectionStateChangeListeners.length, 'connectionStates:', this.connectionStates)
for (const listener of this.connectionStateChangeListeners) {
try {
console.log('[AKBLE][LOG] emitConnectionStateChange 调用 listener', listener)
listener(deviceId, state)
} catch (e) {
console.error('[AKBLE][LOG] emitConnectionStateChange listener error', e)
}
}
}
getGattInstance(deviceId: string): BluetoothGatt | null {
return this.gattMap.get(deviceId) ?? null;
}
private getBluetoothAdapter(): BluetoothAdapter | null {
const context = UTSAndroid.getAppContext();
if (context == null) return null;
const manager = context?.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager;
return manager.getAdapter();
}
/**
* 获取指定ID的设备如果存在
*/
public getDevice(deviceId: string): BleDevice | null {
console.log(deviceId,this.devices)
return this.devices.get(deviceId) ?? null;
}
}

View File

@@ -0,0 +1,734 @@
import { BleService } from '../interface.uts'
import type { WriteCharacteristicOptions, DfuOptions, ControlParserResult } from '../interface.uts'
import { DeviceManager } from './device_manager.uts'
import { ServiceManager } from './service_manager.uts'
import BluetoothGatt from 'android.bluetooth.BluetoothGatt'
import BluetoothGattCharacteristic from 'android.bluetooth.BluetoothGattCharacteristic'
import BluetoothGattDescriptor from 'android.bluetooth.BluetoothGattDescriptor'
import UUID from 'java.util.UUID'
// 通用 Nordic DFU UUIDs (常见设备可能使用这些;如厂商自定义请替换)
const DFU_SERVICE_UUID = '0000fe59-0000-1000-8000-00805f9b34fb'
const DFU_CONTROL_POINT_UUID = '8ec90001-f315-4f60-9fb8-838830daea50'
const DFU_PACKET_UUID = '8ec90002-f315-4f60-9fb8-838830daea50'
type DfuSession = {
resolve : () => void;
reject : (err ?: any) => void;
onProgress ?: (p : number) => void;
onLog ?: (s : string) => void;
controlParser ?: (data : Uint8Array) => ControlParserResult | null;
// Nordic 专用字段
bytesSent ?: number;
totalBytes ?: number;
useNordic ?: boolean;
// PRN (packet receipt notification) support
prn ?: number;
packetsSincePrn ?: number;
prnResolve ?: () => void;
prnReject ?: (err ?: any) => void;
}
export class DfuManager {
// 会话表,用于把 control-point 通知路由到当前 DFU 流程
private sessions : Map<string, DfuSession> = new Map();
// 简化:只实现最基本的 GATT-based DFU 上传逻辑,需按设备协议调整 control point 的命令/解析
// Emit a DFU lifecycle event for a session. name should follow Nordic listener names
private _emitDfuEvent(deviceId : string, name : string, payload ?: any) {
console.log('[DFU][Event]', name, deviceId, payload ?? '');
const s = this.sessions.get(deviceId);
if (s == null) return;
if (typeof s.onLog == 'function') {
try {
const logFn = s.onLog as (msg : string) => void;
logFn(`[${name}] ${payload != null ? JSON.stringify(payload) : ''}`);
} catch (e) { }
}
if (name == 'onProgress' && typeof s.onProgress == 'function' && typeof payload == 'number') {
try { s.onProgress(payload); } catch (e) { }
}
}
async startDfu(deviceId : string, firmwareBytes : Uint8Array, options ?: DfuOptions) : Promise<void> {
console.log('startDfu 0')
const deviceManager = DeviceManager.getInstance();
const serviceManager = ServiceManager.getInstance();
console.log('startDfu 1')
const gatt : BluetoothGatt | null = deviceManager.getGattInstance(deviceId);
console.log('startDfu 2')
if (gatt == null) throw new Error('Device not connected');
console.log('[DFU] startDfu start deviceId=', deviceId, 'firmwareBytes=', firmwareBytes != null ? firmwareBytes.length : 0, 'options=', options);
try {
console.log('[DFU] requesting high connection priority for', deviceId);
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH);
} catch (e) {
console.warn('[DFU] requestConnectionPriority failed', e);
}
// 发现服务并特征
// ensure services discovered before accessing GATT; serviceManager exposes Promise-based API
await serviceManager.getServices(deviceId, null);
console.log('[DFU] services ensured for', deviceId);
const dfuService = gatt.getService(UUID.fromString(DFU_SERVICE_UUID));
if (dfuService == null) throw new Error('DFU service not found');
const controlChar = dfuService.getCharacteristic(UUID.fromString(DFU_CONTROL_POINT_UUID));
const packetChar = dfuService.getCharacteristic(UUID.fromString(DFU_PACKET_UUID));
console.log('[DFU] dfuService=', dfuService != null ? dfuService.getUuid().toString() : null, 'controlChar=', controlChar != null ? controlChar.getUuid().toString() : null, 'packetChar=', packetChar != null ? packetChar.getUuid().toString() : null);
if (controlChar == null || packetChar == null) throw new Error('DFU characteristics missing');
const packetProps = packetChar.getProperties();
const supportsWriteWithResponse = (packetProps & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0;
const supportsWriteNoResponse = (packetProps & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0;
console.log('[DFU] packet characteristic props mask=', packetProps, 'supportsWithResponse=', supportsWriteWithResponse, 'supportsNoResponse=', supportsWriteNoResponse);
// Allow caller to request a desired MTU via options for higher throughput
const desiredMtu = (options != null && typeof options.mtu == 'number') ? options.mtu : 247;
try {
console.log('[DFU] requesting MTU=', desiredMtu, 'for', deviceId);
await this._requestMtu(gatt, desiredMtu, 8000);
console.log('[DFU] requestMtu completed for', deviceId);
} catch (e) {
console.warn('[DFU] requestMtu failed or timed out, continue with default.', e);
}
const mtu = desiredMtu; // 假定成功或使用期望值
const chunkSize = Math.max(20, mtu - 3);
// small helper to convert a byte (possibly signed) to a two-digit hex string
const byteToHex = (b : number) => {
const v = (b < 0) ? (b + 256) : b;
let s = v.toString(16);
if (s.length < 2) s = '0' + s;
return s;
};
// Parameterize PRN window and timeout via options early so they are available
// for session logging. Defaults: prn = 12 packets, prnTimeoutMs = 10000 ms
let prnWindow = 0;
if (options != null && typeof options.prn == 'number') {
prnWindow = Math.max(0, Math.floor(options.prn));
}
const prnTimeoutMs = (options != null && typeof options.prnTimeoutMs == 'number') ? Math.max(1000, Math.floor(options.prnTimeoutMs)) : 8000;
const disablePrnOnTimeout = !(options != null && options.disablePrnOnTimeout == false);
// 订阅 control point 通知并将通知路由到会话处理器
const controlHandler = (data : Uint8Array) => {
// 交给会话处理器解析并触发事件
try {
const hexParts: string[] = [];
for (let i = 0; i < data.length; i++) {
const v = data[i] as number;
hexParts.push(byteToHex(v));
}
const hex = hexParts.join(' ');
console.log('[DFU] control notification callback invoked for', deviceId, 'raw=', Array.from(data), 'hex=', hex);
} catch (e) {
console.log('[DFU] control notification callback invoked for', deviceId, 'raw=', Array.from(data));
}
this._handleControlNotification(deviceId, data);
};
console.log('[DFU] subscribing control point for', deviceId);
await serviceManager.subscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, controlHandler);
console.log('[DFU] subscribeCharacteristic returned for', deviceId);
// 保存会话回调(用于 waitForControlEvent; 支持 Nordic 模式追踪已发送字节
this.sessions.set(deviceId, {
resolve: () => { },
reject: (err ?: any) => {console.log(err) },
onProgress: null,
onLog: null,
controlParser: (data : Uint8Array) => this._defaultControlParser(data),
bytesSent: 0,
totalBytes: firmwareBytes.length,
useNordic: options != null && options.useNordic == true,
prn: null,
packetsSincePrn: 0,
prnResolve: null,
prnReject: null
});
console.log('[DFU] session created for', deviceId, 'totalBytes=', firmwareBytes.length);
console.log('[DFU] DFU session details:', { deviceId: deviceId, totalBytes: firmwareBytes.length, chunkSize: chunkSize, prnWindow: prnWindow, prnTimeoutMs: prnTimeoutMs });
// wire options callbacks into the session (if provided)
const sessRef = this.sessions.get(deviceId);
if (sessRef != null) {
sessRef.onProgress = (options != null && typeof options.onProgress == 'function') ? options.onProgress : null;
sessRef.onLog = (options != null && typeof options.onLog == 'function') ? options.onLog : null;
}
// emit initial lifecycle events (Nordic-like)
this._emitDfuEvent(deviceId, 'onDeviceConnecting', null);
// 写入固件数据(非常保守的实现:逐包写入并等待短延迟)
// --- PRN setup (optional, Nordic-style flow) ---
// Parameterize PRN window and timeout via options: options.prn, options.prnTimeoutMs
// Defaults were set earlier; build PRN payload using arithmetic to avoid
// bitwise operators which don't map cleanly to generated Kotlin.
if (prnWindow > 0) {
try {
// send Set PRN to device (format: [OP_CODE_SET_PRN, prn LSB, prn MSB])
// WARNING: Ensure your device uses the same opcode/format; change if needed.
const prnLsb = prnWindow % 256;
const prnMsb = Math.floor(prnWindow / 256) % 256;
const prnPayload = new Uint8Array([0x02, prnLsb, prnMsb]);
await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, prnPayload, null);
const sess0 = this.sessions.get(deviceId);
if (sess0 != null) {
sess0.useNordic = true;
sess0.prn = prnWindow;
sess0.packetsSincePrn = 0;
sess0.controlParser = (data : Uint8Array) => this._nordicControlParser(data);
}
console.log('[DFU] Set PRN sent (prn=', prnWindow, ') for', deviceId);
} catch (e) {
console.warn('[DFU] Set PRN failed (continuing without PRN):', e);
const sessFallback = this.sessions.get(deviceId);
if (sessFallback != null) sessFallback.prn = 0;
}
} else {
console.log('[DFU] PRN disabled (prnWindow=', prnWindow, ') for', deviceId);
}
// 写入固件数据(逐包写入并根据 options.waitForResponse 选择是否等待响应)
let offset = 0;
const total = firmwareBytes.length;
this._emitDfuEvent(deviceId, 'onDfuProcessStarted', null);
this._emitDfuEvent(deviceId, 'onUploadingStarted', null);
// Track outstanding write operations when using fire-and-forget mode so we can
// log and throttle if the Android stack becomes overwhelmed.
let outstandingWrites = 0;
// read tuning parameters from options in a safe, generator-friendly way
let configuredMaxOutstanding = 2;
let writeSleepMs = 0;
let writeRetryDelay = 100;
let writeMaxAttempts = 12;
let writeGiveupTimeout = 60000;
let drainOutstandingTimeout = 3000;
let failureBackoffMs = 0;
// throughput measurement
let throughputWindowBytes = 0;
let lastThroughputTime = Date.now();
function _logThroughputIfNeeded(force ?: boolean) {
try {
const now = Date.now();
const elapsed = now - lastThroughputTime;
if (force == true || elapsed >= 1000) {
const bytes = throughputWindowBytes;
const bps = Math.floor((bytes * 1000) / Math.max(1, elapsed));
// reset window
throughputWindowBytes = 0;
lastThroughputTime = now;
const human = `${bps} B/s`;
console.log('[DFU] throughput:', human, 'elapsedMs=', elapsed);
const s = this.sessions.get(deviceId);
if (s != null && typeof s.onLog == 'function') {
try { s.onLog?.invoke('[DFU] throughput: ' + human); } catch (e) { }
}
}
} catch (e) { }
}
function _safeErr(e ?: any) {
try {
if (e == null) return '';
if (typeof e == 'string') return e;
try { return JSON.stringify(e); } catch (e2) { }
try { return (e as any).toString(); } catch (e3) { }
return '';
} catch (e4) { return ''; }
}
try {
if (options != null) {
try {
if (options.maxOutstanding != null) {
const parsed = Math.floor(options.maxOutstanding as number);
if (!isNaN(parsed) && parsed > 0) configuredMaxOutstanding = parsed;
}
} catch (e) { }
try {
if (options.writeSleepMs != null) {
const parsedWs = Math.floor(options.writeSleepMs as number);
if (!isNaN(parsedWs) && parsedWs >= 0) writeSleepMs = parsedWs;
}
} catch (e) { }
try {
if (options.writeRetryDelayMs != null) {
const parsedRetry = Math.floor(options.writeRetryDelayMs as number);
if (!isNaN(parsedRetry) && parsedRetry >= 0) writeRetryDelay = parsedRetry;
}
} catch (e) { }
try {
if (options.writeMaxAttempts != null) {
const parsedAttempts = Math.floor(options.writeMaxAttempts as number);
if (!isNaN(parsedAttempts) && parsedAttempts > 0) writeMaxAttempts = parsedAttempts;
}
} catch (e) { }
try {
if (options.writeGiveupTimeoutMs != null) {
const parsedGiveupTimeout = Math.floor(options.writeGiveupTimeoutMs as number);
if (!isNaN(parsedGiveupTimeout) && parsedGiveupTimeout > 0) writeGiveupTimeout = parsedGiveupTimeout;
}
} catch (e) { }
try {
if (options.drainOutstandingTimeoutMs != null) {
const parsedDrain = Math.floor(options.drainOutstandingTimeoutMs as number);
if (!isNaN(parsedDrain) && parsedDrain >= 0) drainOutstandingTimeout = parsedDrain;
}
} catch (e) { }
}
if (configuredMaxOutstanding < 1) configuredMaxOutstanding = 1;
if (writeSleepMs < 0) writeSleepMs = 0;
} catch (e) { }
if (supportsWriteWithResponse == false && supportsWriteNoResponse == true) {
if (configuredMaxOutstanding > 1) configuredMaxOutstanding = 1;
if (writeSleepMs < 15) writeSleepMs = 15;
if (writeRetryDelay < 150) writeRetryDelay = 150;
if (writeMaxAttempts < 40) writeMaxAttempts = 40;
if (writeGiveupTimeout < 120000) writeGiveupTimeout = 120000;
console.log('[DFU] packet char only supports WRITE_NO_RESPONSE; serializing writes with conservative pacing');
}
const maxOutstandingCeiling = configuredMaxOutstanding;
let adaptiveMaxOutstanding = configuredMaxOutstanding;
const minOutstandingWindow = 1;
while (offset < total) {
const end = Math.min(offset + chunkSize, total);
const slice = firmwareBytes.subarray(offset, end);
// Decide whether to wait for response per-chunk. Honor characteristic support.
// Generator-friendly: avoid 'undefined' and use explicit boolean check.
let finalWaitForResponse = true;
if (options != null) {
try {
const maybe = options.waitForResponse;
if (maybe == true && supportsWriteWithResponse == false && supportsWriteNoResponse == true) {
// caller requested response but characteristic cannot provide it; keep true to ensure we await the write promise.
finalWaitForResponse = true;
} else if (maybe == false) {
finalWaitForResponse = false;
} else if (maybe == true) {
finalWaitForResponse = true;
}
} catch (e) { finalWaitForResponse = true; }
}
const writeOpts: WriteCharacteristicOptions = {
waitForResponse: finalWaitForResponse,
retryDelayMs: writeRetryDelay,
maxAttempts: writeMaxAttempts,
giveupTimeoutMs: writeGiveupTimeout,
forceWriteTypeNoResponse: finalWaitForResponse == false
};
console.log('[DFU] writing packet chunk offset=', offset, 'len=', slice.length, 'waitForResponse=', finalWaitForResponse, 'outstanding=', outstandingWrites);
// Fire-and-forget path: do not await the write if waitForResponse == false.
if (finalWaitForResponse == false) {
if (failureBackoffMs > 0) {
console.log('[DFU] applying failure backoff', failureBackoffMs, 'ms before next write for', deviceId);
await this._sleep(failureBackoffMs);
failureBackoffMs = Math.floor(failureBackoffMs / 2);
}
while (outstandingWrites >= adaptiveMaxOutstanding) {
await this._sleep(Math.max(1, writeSleepMs));
}
// increment outstanding counter and kick the write without awaiting.
outstandingWrites = outstandingWrites + 1;
// fire-and-forget: start the write but don't await its Promise
const writeOffset = offset;
serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_PACKET_UUID, slice, writeOpts).then((res) => {
outstandingWrites = Math.max(0, outstandingWrites - 1);
if (res == true) {
if (adaptiveMaxOutstanding < maxOutstandingCeiling) {
adaptiveMaxOutstanding = Math.min(maxOutstandingCeiling, adaptiveMaxOutstanding + 1);
}
if (failureBackoffMs > 0) failureBackoffMs = Math.floor(failureBackoffMs / 2);
}
// log occasional completions
if ((outstandingWrites & 0x1f) == 0) {
console.log('[DFU] write completion callback, outstandingWrites=', outstandingWrites, 'adaptiveWindow=', adaptiveMaxOutstanding, 'device=', deviceId);
}
// detect write failure signaled by service manager
if (res !== true) {
adaptiveMaxOutstanding = Math.max(minOutstandingWindow, Math.floor(adaptiveMaxOutstanding / 2));
failureBackoffMs = Math.min(200, Math.max(failureBackoffMs, Math.max(5, writeRetryDelay)));
console.error('[DFU] writeCharacteristic returned false for device=', deviceId, 'offset=', writeOffset, 'adaptiveWindow now=', adaptiveMaxOutstanding);
try { this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: writeOffset, reason: 'write returned false' }); } catch (e) { }
}
}).catch((e) => {
outstandingWrites = Math.max(0, outstandingWrites - 1);
adaptiveMaxOutstanding = Math.max(minOutstandingWindow, Math.floor(adaptiveMaxOutstanding / 2));
failureBackoffMs = Math.min(200, Math.max(failureBackoffMs, Math.max(5, writeRetryDelay)));
console.warn('[DFU] fire-and-forget write failed for device=', deviceId, e, 'adaptiveWindow now=', adaptiveMaxOutstanding);
try {
const errMsg ='[DFU] fire-and-forget write failed for device=';
this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: writeOffset, reason: errMsg });
} catch (e2) { }
});
// account bytes for throughput
throughputWindowBytes += slice.length;
_logThroughputIfNeeded(false);
} else {
console.log('[DFU] awaiting write for chunk offset=', offset);
try {
const writeResult = await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_PACKET_UUID, slice, writeOpts);
if (writeResult !== true) {
console.error('[DFU] writeCharacteristic(await) returned false at offset=', offset, 'device=', deviceId);
try { this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: offset, reason: 'write returned false' }); } catch (e) { }
// abort DFU by throwing
throw new Error('write failed');
}
} catch (e) {
console.error('[DFU] awaiting write failed at offset=', offset, 'device=', deviceId, e);
try {
const errMsg = '[DFU] awaiting write failed ';
this._emitDfuEvent(deviceId, 'onError', { phase: 'write', offset: offset, reason: errMsg });
} catch (e2) { }
throw e;
}
// account bytes for throughput
throughputWindowBytes += slice.length;
_logThroughputIfNeeded(false);
}
// update PRN counters and wait when window reached
const sessAfter = this.sessions.get(deviceId);
if (sessAfter != null && sessAfter.useNordic == true && typeof sessAfter.prn == 'number' && (sessAfter.prn ?? 0) > 0) {
sessAfter.packetsSincePrn = (sessAfter.packetsSincePrn ?? 0) + 1;
if ((sessAfter.packetsSincePrn ?? 0) >= (sessAfter.prn ?? 0) && (sessAfter.prn ?? 0) > 0) {
// wait for PRN (device notification) before continuing
try {
console.log('[DFU] reached PRN window, waiting for PRN for', deviceId, 'packetsSincePrn=', sessAfter.packetsSincePrn, 'prn=', sessAfter.prn);
await this._waitForPrn(deviceId, prnTimeoutMs);
console.log('[DFU] PRN received, resuming transfer for', deviceId);
} catch (e) {
console.warn('[DFU] PRN wait failed/timed out, continuing anyway for', deviceId, e);
if (disablePrnOnTimeout) {
console.warn('[DFU] disabling PRN waits after timeout for', deviceId);
sessAfter.prn = 0;
}
}
// reset counter
sessAfter.packetsSincePrn = 0;
}
}
offset = end;
// 如果启用 nordic 模式,统计已发送字节
const sess = this.sessions.get(deviceId);
if (sess != null && typeof sess.bytesSent == 'number') {
sess.bytesSent = (sess.bytesSent ?? 0) + slice.length;
}
// 简单节流与日志,避免过快。默认睡眠非常短以提高吞吐量; 可在设备上调节
console.log('[DFU] wrote chunk for', deviceId, 'offset=', offset, '/', total, 'chunkSize=', slice.length, 'bytesSent=', sess != null ? sess.bytesSent : null, 'outstanding=', outstandingWrites);
// emit upload progress event (percent) if available
if (sess != null && typeof sess.bytesSent == 'number' && typeof sess.totalBytes == 'number') {
const p = Math.floor((sess.bytesSent / sess.totalBytes) * 100);
this._emitDfuEvent(deviceId, 'onProgress', p);
}
// yield to event loop and avoid starving the Android BLE stack
await this._sleep(Math.max(0, writeSleepMs));
}
// wait for outstanding writes to drain before continuing with control commands
if (outstandingWrites > 0) {
const drainStart = Date.now();
while (outstandingWrites > 0 && (Date.now() - drainStart) < drainOutstandingTimeout) {
await this._sleep(Math.max(0, writeSleepMs));
}
if (outstandingWrites > 0) {
console.warn('[DFU] outstandingWrites remain after drain timeout, continuing with', outstandingWrites);
} else {
console.log('[DFU] outstandingWrites drained before control phase');
}
}
this._emitDfuEvent(deviceId, 'onUploadingCompleted', null);
// force final throughput log before activate/validate
_logThroughputIfNeeded(true);
// 发送 activate/validate 命令到 control point需根据设备协议实现
// 下面为占位:请替换为实际的 opcode
// 在写入前先启动控制结果等待,防止设备快速响应导致丢失通知
const controlTimeout = 20000;
const controlResultPromise = this._waitForControlResult(deviceId, controlTimeout);
try {
// control writes: pass undefined options explicitly to satisfy the generator/typechecker
const activatePayload = new Uint8Array([0x04]);
console.log('[DFU] sending activate/validate payload=', Array.from(activatePayload));
await serviceManager.writeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID, activatePayload, null);
console.log('[DFU] activate/validate write returned for', deviceId);
} catch (e) {
// 写入失败时取消控制等待,避免悬挂定时器
try {
const sessOnWriteFail = this.sessions.get(deviceId);
if (sessOnWriteFail != null && typeof sessOnWriteFail.reject == 'function') {
sessOnWriteFail.reject(e);
}
} catch (rejectErr) { }
await controlResultPromise.catch(() => { });
try { await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID); } catch (e2) { }
this.sessions.delete(deviceId);
throw e;
}
console.log('[DFU] sent control activate/validate command to control point for', deviceId);
this._emitDfuEvent(deviceId, 'onValidating', null);
// 等待 control-point 返回最终结果(成功或失败),超时可配置
console.log('[DFU] waiting for control result (timeout=', controlTimeout, ') for', deviceId);
try {
await controlResultPromise;
console.log('[DFU] control result resolved for', deviceId);
} catch (err) {
// 清理订阅后抛出
try { await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID); } catch (e) { }
this.sessions.delete(deviceId);
throw err;
}
// 取消订阅
try {
await serviceManager.unsubscribeCharacteristic(deviceId, DFU_SERVICE_UUID, DFU_CONTROL_POINT_UUID);
} catch (e) { }
console.log('[DFU] unsubscribed control point for', deviceId);
// 清理会话
this.sessions.delete(deviceId);
console.log('[DFU] session cleared for', deviceId);
return;
}
async _requestMtu(gatt : BluetoothGatt, mtu : number, timeoutMs : number) : Promise<void> {
return new Promise<void>((resolve, reject) => {
// 在当前项目BluetoothGattCallback.onMtuChanged 未被封装;简单发起请求并等待短超时
try {
const ok = gatt.requestMtu(Math.floor(mtu) as Int);
if (!ok) {
return reject(new Error('requestMtu failed'));
}
} catch (e) {
return reject(e);
}
// 无 callback 监听时退回,等待一小段时间以便成功
setTimeout(() => resolve(), Math.min(2000, timeoutMs));
});
}
_sleep(ms : number) {
return new Promise<void>((r) => { setTimeout(() => { r() }, ms); });
}
_waitForPrn(deviceId : string, timeoutMs : number) : Promise<void> {
const session = this.sessions.get(deviceId);
if (session == null) return Promise.reject(new Error('no dfu session'));
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
// timeout waiting for PRN
// clear pending handlers
session.prnResolve = null;
session.prnReject = null;
reject(new Error('PRN timeout'));
}, timeoutMs);
const prnResolve = () => {
clearTimeout(timer);
resolve();
};
const prnReject = (err ?: any) => {
clearTimeout(timer);
reject(err);
};
session.prnResolve = prnResolve;
session.prnReject = prnReject;
});
}
// 默认 control point 解析器(非常通用的尝试解析:如果设备发送 progress byte 或成功码)
_defaultControlParser(data : Uint8Array) : ControlParserResult | null {
// 假设协议:第一个字节为 opcode, 第二字节可为状态或进度
if (data == null || data.length === 0) return null;
const op = data[0];
// Nordic-style response: [0x10, requestOp, resultCode]
if (op === 0x10 && data.length >= 3) {
const requestOp = data[1];
const resultCode = data[2];
if (resultCode === 0x01) {
return { type: 'success' };
}
return { type: 'error', error: { requestOp: requestOp, resultCode: resultCode, raw: Array.from(data) } };
}
// Nordic PRN notification: [0x11, LSB, MSB] -> return as progress (bytes received)
if (op === 0x11 && data.length >= 3) {
const lsb = data[1];
const msb = data[2];
const received = (msb << 8) | lsb;
return { type: 'progress', progress: received };
}
// vendor-specific opcode example: 0x60 may mean 'response/progress' for some firmwares
if (op === 0x60) {
if (data.length >= 3) {
const requestOp = data[1];
const status = data[2];
// Known success/status codes observed in field devices
if (status === 0x00 || status === 0x01 || status === 0x0A) {
return { type: 'success' };
}
return { type: 'error', error: { requestOp: requestOp, resultCode: status, raw: Array.from(data) } };
}
if (data.length >= 2) {
return { type: 'progress', progress: data[1] };
}
}
// 通用进度回退:若第二字节位于 0-100 之间,当作百分比
if (data.length >= 2) {
const maybeProgress = data[1];
if (maybeProgress >= 0 && maybeProgress <= 100) {
return { type: 'progress', progress: maybeProgress };
}
}
// 若找到明显的 success opcode (示例 0x01) 或 error 0xFF
if (op === 0x01) return { type: 'success' };
if (op === 0xFF) return { type: 'error', error: data };
return { type: 'info' };
}
// Nordic DFU control-parser支持 Response and Packet Receipt Notification
_nordicControlParser(data : Uint8Array) : ControlParserResult | null {
// Nordic opcodes (简化):
// - 0x10 : Response (opcode, requestOp, resultCode)
// - 0x11 : Packet Receipt Notification (opcode, value LSB, value MSB)
if (data == null || data.length == 0) return null;
const op = data[0];
if (op == 0x11 && data.length >= 3) {
// packet receipt notif: bytes received (little endian)
const lsb = data[1];
const msb = data[2];
const received = (msb << 8) | lsb;
// Return received bytes as progress value; parser does not resolve device-specific session here.
return { type: 'progress', progress: received };
}
// Nordic vendor-specific progress/response opcode (example 0x60)
if (op == 0x60) {
if (data.length >= 3) {
const requestOp = data[1];
const status = data[2];
if (status == 0x00 || status == 0x01 || status == 0x0A) {
return { type: 'success' };
}
return { type: 'error', error: { requestOp, resultCode: status, raw: Array.from(data) } };
}
if (data.length >= 2) {
return { type: 'progress', progress: data[1] };
}
}
// Response: check result code for success (0x01 may indicate success in some stacks)
if (op == 0x10 && data.length >= 3) {
const requestOp = data[1];
const resultCode = data[2];
// Nordic resultCode 0x01 = SUCCESS typically
if (resultCode == 0x01) return { type: 'success' };
else return { type: 'error', error: { requestOp, resultCode } };
}
return null;
}
_handleControlNotification(deviceId : string, data : Uint8Array) {
const session = this.sessions.get(deviceId);
if (session == null) {
console.warn('[DFU] control notification received but no session for', deviceId, 'data=', Array.from(data));
return;
}
try {
// human readable opcode mapping
let opcodeName = 'unknown';
switch (data[0]) {
case 0x10: opcodeName = 'Response'; break;
case 0x11: opcodeName = 'PRN'; break;
case 0x60: opcodeName = 'VendorProgress'; break;
case 0x01: opcodeName = 'SuccessOpcode'; break;
case 0xFF: opcodeName = 'ErrorOpcode'; break;
}
console.log('[DFU] _handleControlNotification deviceId=', deviceId, 'opcode=0x' + data[0].toString(16), 'name=', opcodeName, 'raw=', Array.from(data));
const parsed = session.controlParser != null ? session.controlParser(data) : null;
if (session.onLog != null) session.onLog('DFU control notify: ' + Array.from(data).join(','));
console.log('[DFU] parsed control result=', parsed);
if (parsed == null) return;
if (parsed.type == 'progress' && parsed.progress != null) {
// 如果在 nordic 模式 parsed.progress 可能是已接收字节数,则转换为百分比
if (session.useNordic == true && session.totalBytes != null && session.totalBytes > 0) {
const percent = Math.floor((parsed.progress / session.totalBytes) * 100);
session.onProgress?.(percent);
// If we have written all bytes locally, log that event
if (session.bytesSent != null && session.totalBytes != null && session.bytesSent >= session.totalBytes) {
console.log('[DFU] all bytes written locally for', deviceId, 'bytesSent=', session.bytesSent, 'total=', session.totalBytes);
// emit uploading completed once
this._emitDfuEvent(deviceId, 'onUploadingCompleted', null);
}
// If a PRN wait is pending, resolve it (PRN indicates device received packets)
if (typeof session.prnResolve == 'function') {
try { session.prnResolve(); } catch (e) { }
session.prnResolve = null;
session.prnReject = null;
session.packetsSincePrn = 0;
}
} else {
const progress = parsed.progress
if (progress != null) {
console.log('[DFU] progress for', deviceId, 'progress=', progress);
session.onProgress?.(progress);
// also resolve PRN if was waiting (in case device reports numeric progress)
if (typeof session.prnResolve == 'function') {
try { session.prnResolve(); } catch (e) { }
session.prnResolve = null;
session.prnReject = null;
session.packetsSincePrn = 0;
}
}
}
} else if (parsed.type == 'success') {
console.log('[DFU] parsed success for', deviceId, 'resolving session');
session.resolve();
// Log final device-acknowledged success
console.log('[DFU] device reported DFU success for', deviceId);
this._emitDfuEvent(deviceId, 'onDfuCompleted', null);
} else if (parsed.type == 'error') {
console.error('[DFU] parsed error for', deviceId, parsed.error);
session.reject(parsed.error ?? new Error('DFU device error'));
this._emitDfuEvent(deviceId, 'onError', parsed.error ?? {});
} else {
// info - just log
}
} catch (e) {
session.onLog?.('control parse error: ' + e);
console.error('[DFU] control parse exception for', deviceId, e);
}
}
_waitForControlResult(deviceId : string, timeoutMs : number) : Promise<void> {
const session = this.sessions.get(deviceId);
if (session == null) return Promise.reject(new Error('no dfu session'));
return new Promise<void>((resolve, reject) => {
// wrap resolve/reject to clear timer
const timer = setTimeout(() => {
// 超时
console.error('[DFU] _waitForControlResult timeout for', deviceId);
reject(new Error('DFU control timeout'));
}, timeoutMs);
const origResolve = () => {
clearTimeout(timer);
console.log('[DFU] _waitForControlResult resolved for', deviceId);
resolve();
};
const origReject = (err ?: any) => {
clearTimeout(timer);
console.error('[DFU] _waitForControlResult rejected for', deviceId, 'err=', err);
reject(err);
};
// replace session handlers temporarily (guard nullable)
if (session != null) {
session.resolve = origResolve;
session.reject = origReject;
}
});
}
}
export const dfuManager = new DfuManager();

View File

@@ -0,0 +1,110 @@
import * as BluetoothManager from './bluetooth_manager.uts';
import { ServiceManager } from './service_manager.uts';
import type { ScanDevicesOptions, BleConnectOptionsExt, MultiProtocolDevice, BleEvent, BleEventCallback, BleService, BleCharacteristic, WriteCharacteristicOptions, AutoBleInterfaces, BleDataReceivedCallback } from '../interface.uts';
import { DeviceManager } from './device_manager.uts';
const serviceManager = ServiceManager.getInstance();
export class BluetoothService {
scanDevices(options?: ScanDevicesOptions) { return BluetoothManager.scanDevices(options); }
connectDevice(deviceId: string, protocol: string, options?: BleConnectOptionsExt) { return BluetoothManager.connectDevice(deviceId, protocol, options); }
disconnectDevice(deviceId: string, protocol: string) { return BluetoothManager.disconnectDevice(deviceId, protocol); }
getConnectedDevices(): MultiProtocolDevice[] { return BluetoothManager.getConnectedDevices(); }
on(event: BleEvent, callback: BleEventCallback) { return BluetoothManager.on(event, callback); }
off(event: BleEvent, callback?: BleEventCallback) { return BluetoothManager.off(event, callback); }
getServices(deviceId: string): Promise<BleService[]> {
return new Promise((resolve, reject) => {
serviceManager.getServices(deviceId, (list, err) => {
console.log('getServices:', list, err); // 新增日志
if (err != null) reject(err);
else resolve((list as BleService[]) ?? []);
});
});
}
getCharacteristics(deviceId: string, serviceId: string): Promise<BleCharacteristic[]> {
return new Promise((resolve, reject) => {
console.log(deviceId,serviceId)
serviceManager.getCharacteristics(deviceId, serviceId, (list, err) => {
if (err != null) reject(err);
else resolve((list as BleCharacteristic[]) ?? []);
});
});
}
/**
* 自动发现服务和特征返回可用的写入和通知特征ID
* @param deviceId 设备ID
* @returns {Promise<AutoBleInterfaces>}
*/
async getAutoBleInterfaces(deviceId: string): Promise<AutoBleInterfaces> {
// 1. 获取服务列表
const services = await this.getServices(deviceId);
if (services == null || services.length == 0) throw new Error('未发现服务');
// 2. 选择目标服务优先bae前缀可根据需要调整
let serviceId = '';
for (let i = 0; i < services.length; i++) {
const s = services[i];
const uuidCandidate: string | null = (s.uuid != null ? s.uuid : null)
const uuid: string = uuidCandidate != null ? uuidCandidate : ''
// prefer regex test to avoid nullable receiver calls in generated Kotlin
if (/^bae/i.test(uuid)) {
serviceId = uuid
break;
}
}
console.log(serviceId)
if (serviceId == null || serviceId == '') serviceId = services[0].uuid;
// 3. 获取特征列表
const characteristics = await this.getCharacteristics(deviceId, serviceId);
console.log(characteristics)
if (characteristics == null || characteristics.length == 0) throw new Error('未发现特征值');
// 4. 筛选write和notify特征
let writeCharId = '';
let notifyCharId = '';
for (let i = 0; i < characteristics.length; i++) {
const c = characteristics[i];
console.log(c)
if ((writeCharId == null || writeCharId == '') && c.properties != null && (c.properties.write || c.properties.writeWithoutResponse==true)) writeCharId = c.uuid;
if ((notifyCharId == null || notifyCharId == '') && c.properties != null && (c.properties.notify || c.properties.indicate)) notifyCharId = c.uuid;
}
console.log(serviceId, writeCharId, notifyCharId);
if ((writeCharId == null || writeCharId == '') || (notifyCharId == null || notifyCharId == '')) throw new Error('未找到合适的写入或通知特征');
console.log(serviceId, writeCharId, notifyCharId);
// // 发现服务和特征后
const deviceManager = DeviceManager.getInstance();
console.log(deviceManager);
const device = deviceManager.getDevice(deviceId);
console.log(deviceId,device)
device!.serviceId = serviceId;
device!.writeCharId = writeCharId;
device!.notifyCharId = notifyCharId;
console.log(device);
return { serviceId, writeCharId, notifyCharId };
}
async subscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, onData: BleDataReceivedCallback): Promise<void> {
return serviceManager.subscribeCharacteristic(deviceId, serviceId, characteristicId, onData);
}
async readCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<ArrayBuffer> {
return serviceManager.readCharacteristic(deviceId, serviceId, characteristicId);
}
async writeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, data: Uint8Array, options?: WriteCharacteristicOptions): Promise<boolean> {
return serviceManager.writeCharacteristic(deviceId, serviceId, characteristicId, data, options);
}
async unsubscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<void> {
return serviceManager.unsubscribeCharacteristic(deviceId, serviceId, characteristicId);
}
async autoDiscoverAll(deviceId: string): Promise<any> {
return serviceManager.autoDiscoverAll(deviceId);
}
async subscribeAllNotifications(deviceId: string, onData: BleDataReceivedCallback): Promise<void> {
return serviceManager.subscribeAllNotifications(deviceId, onData);
}
}
export const bluetoothService = new BluetoothService();
// Ensure protocol handlers are registered when this module is imported.
// import './protocol_registry.uts';

View File

@@ -0,0 +1,663 @@
import type { BleService, BleCharacteristic, BleDataReceivedCallback, BleCharacteristicProperties, WriteCharacteristicOptions, ByteArray } from '../interface.uts'
import BluetoothGatt from "android.bluetooth.BluetoothGatt";
import BluetoothGattService from "android.bluetooth.BluetoothGattService";
import BluetoothGattCharacteristic from "android.bluetooth.BluetoothGattCharacteristic";
import BluetoothGattDescriptor from "android.bluetooth.BluetoothGattDescriptor";
import BluetoothGattCallback from "android.bluetooth.BluetoothGattCallback";
import UUID from "java.util.UUID";
import { DeviceManager } from './device_manager.uts'
import { AkBleErrorImpl, AkBluetoothErrorCode } from '../unierror.uts'
import { AutoDiscoverAllResult } from '../interface.uts'
// 补全UUID格式将短格式转换为标准格式
function getFullUuid(shortUuid : string) : string {
return `0000${shortUuid}-0000-1000-8000-00805f9b34fb`
}
const deviceWriteQueues = new Map<string, Promise<void>>();
function enqueueDeviceWrite<T>(deviceId : string, work : () => Promise<T>) : Promise<T> {
const previous = deviceWriteQueues.get(deviceId) ?? Promise.resolve();
const next = (async () : Promise<T> => {
try {
await previous;
} catch (e) { /* ignore previous rejection to keep queue alive */ }
return await work();
})();
const queued = next.then(() => { }, () => { });
deviceWriteQueues.set(deviceId, queued);
return next.finally(() => {
if (deviceWriteQueues.get(deviceId) == queued) {
deviceWriteQueues.delete(deviceId);
}
});
}
function createCharProperties(props : number) : BleCharacteristicProperties {
const result : BleCharacteristicProperties = {
read: false,
write: false,
notify: false,
indicate: false,
canRead: false,
canWrite: false,
canNotify: false,
writeWithoutResponse: false
};
result.read = (props & BluetoothGattCharacteristic.PROPERTY_READ) !== 0;
result.write = (props & BluetoothGattCharacteristic.PROPERTY_WRITE) !== 0;
result.notify = (props & BluetoothGattCharacteristic.PROPERTY_NOTIFY) !== 0;
result.indicate = (props & BluetoothGattCharacteristic.PROPERTY_INDICATE) !== 0;
result.writeWithoutResponse = (props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) !== 0;
result.canRead = result.read;
const writeWithoutResponse = result.writeWithoutResponse;
result.canWrite = (result.write != null && result.write) || (writeWithoutResponse != null && writeWithoutResponse);
result.canNotify = result.notify;
return result;
}
// 定义 PendingCallback 类型和实现类
interface PendingCallback {
resolve : (data : any) => void;
reject : (err ?: any) => void;
timer ?: number; // Changed from any to number
}
class PendingCallbackImpl implements PendingCallback {
resolve : (data : any) => void;
reject : (err ?: any) => void;
timer ?: number; // Changed from any to number
constructor(resolve : (data : any) => void, reject : (err ?: any) => void, timer ?: number) {
this.resolve = resolve;
this.reject = reject;
this.timer = timer;
}
}
// 全局回调管理(必须在类外部声明)
let pendingCallbacks : Map<string, PendingCallback>;
let notifyCallbacks : Map<string, BleDataReceivedCallback>;
// 在全局范围内初始化
pendingCallbacks = new Map<string, PendingCallback>();
notifyCallbacks = new Map<string, BleDataReceivedCallback>();
// 服务发现等待队列deviceId -> 回调数组
const serviceDiscoveryWaiters = new Map<string, ((services : BleService[] | null, error ?: Error) => void)[]>();
// 服务发现状态deviceId -> 是否已发现
const serviceDiscovered = new Map<string, boolean>();
// 特征发现等待队列deviceId|serviceId -> 回调数组
const characteristicDiscoveryWaiters = new Map<string, ((characteristics : BleCharacteristic[] | null, error ?: Error) => void)[]>();
class GattCallback extends BluetoothGattCallback {
constructor() {
super();
}
override onServicesDiscovered(gatt : BluetoothGatt, status : Int) : void {
console.log('ak onServicesDiscovered')
const deviceId = gatt.getDevice().getAddress();
if (status == BluetoothGatt.GATT_SUCCESS) {
console.log(`服务发现成功: ${deviceId}`);
serviceDiscovered.set(deviceId, true);
// 统一回调所有等待 getServices 的调用
const waiters = serviceDiscoveryWaiters.get(deviceId);
if (waiters != null && waiters.length > 0) {
const services = gatt.getServices();
const result : BleService[] = [];
if (services != null) {
const servicesList = services;
const size = servicesList.size;
for (let i = 0; i < size; i++) {
const service = servicesList.get(i as Int);
if (service != null) {
const bleService : BleService = {
uuid: service.getUuid().toString(),
isPrimary: service.getType() == BluetoothGattService.SERVICE_TYPE_PRIMARY
};
result.push(bleService);
}
}
}
for (let i = 0; i < waiters.length; i++) {
const cb = waiters[i];
if (cb != null) { cb(result, null); }
}
serviceDiscoveryWaiters.delete(deviceId);
}
} else {
console.log(`服务发现失败: ${deviceId}, status: ${status}`);
// 失败时也要通知等待队列
const waiters = serviceDiscoveryWaiters.get(deviceId);
if (waiters != null && waiters.length > 0) {
for (let i = 0; i < waiters.length; i++) {
const cb = waiters[i];
if (cb != null) { cb(null, new Error('服务发现失败')); }
}
serviceDiscoveryWaiters.delete(deviceId);
}
}
}
override onConnectionStateChange(gatt : BluetoothGatt, status : Int, newState : Int) : void {
const deviceId = gatt.getDevice().getAddress();
if (newState == BluetoothGatt.STATE_CONNECTED) {
console.log(`设备已连接: ${deviceId}`);
DeviceManager.handleConnectionStateChange(deviceId, 2, null); // 2 = STATE_CONNECTED
} else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
console.log(`设备已断开: ${deviceId}`);
serviceDiscovered.delete(deviceId);
DeviceManager.handleConnectionStateChange(deviceId, 0, null); // 0 = STATE_DISCONNECTED
}
}
override onCharacteristicChanged(gatt : BluetoothGatt, characteristic : BluetoothGattCharacteristic) : void {
console.log('ak onCharacteristicChanged')
const deviceId = gatt.getDevice().getAddress();
const serviceId = characteristic.getService().getUuid().toString();
const charId = characteristic.getUuid().toString();
const key = `${deviceId}|${serviceId}|${charId}|notify`;
const callback = notifyCallbacks.get(key);
const value = characteristic.getValue();
console.log('[onCharacteristicChanged]', key, value);
if (callback != null && value != null) {
const valueLength = value.size;
const arr = new Uint8Array(valueLength);
for (let i = 0 as Int; i < valueLength; i++) {
const v = value[i as Int];
arr[i] = v != null ? v : 0;
}
// 保存接收日志
console.log(`
INSERT INTO ble_data_log (device_id, service_id, char_id, direction, data, timestamp)
VALUES ('${deviceId}', '${serviceId}', '${charId}', 'recv', '${Array.from(arr).join(',')}', ${Date.now()})
`)
callback(arr);
}
}
override onCharacteristicRead(gatt : BluetoothGatt, characteristic : BluetoothGattCharacteristic, status : Int) : void {
console.log('ak onCharacteristicRead', status)
const deviceId = gatt.getDevice().getAddress();
const serviceId = characteristic.getService().getUuid().toString();
const charId = characteristic.getUuid().toString();
const key = `${deviceId}|${serviceId}|${charId}|read`;
const pending = pendingCallbacks.get(key);
const value = characteristic.getValue();
console.log('[onCharacteristicRead]', key, 'status=', status, 'value=', value);
if (pending != null) {
try {
const timer = pending.timer;
if (timer != null) {
clearTimeout(timer);
pending.timer = null;
}
pendingCallbacks.delete(key);
if (status == BluetoothGatt.GATT_SUCCESS && value != null) {
const valueLength = value.size
const arr = new Uint8Array(valueLength);
for (let i = 0 as Int; i < valueLength; i++) {
const v = value[i as Int];
arr[i] = v != null ? v : 0;
}
// resolve with ArrayBuffer
pending.resolve(arr.buffer as ArrayBuffer);
} else {
pending.reject(new Error('Characteristic read failed'));
}
} catch (e) {
try { pending.reject(e); } catch (e2) { console.error(e2); }
}
}
}
override onCharacteristicWrite(gatt : BluetoothGatt, characteristic : BluetoothGattCharacteristic, status : Int) : void {
console.log('ak onCharacteristicWrite', status)
const deviceId = gatt.getDevice().getAddress();
const serviceId = characteristic.getService().getUuid().toString();
const charId = characteristic.getUuid().toString();
const key = `${deviceId}|${serviceId}|${charId}|write`;
const pending = pendingCallbacks.get(key);
console.log('[onCharacteristicWrite]', key, 'status=', status);
if (pending != null) {
try {
const timer = pending.timer;
if (timer != null) {
clearTimeout(timer);
}
pendingCallbacks.delete(key);
if (status == BluetoothGatt.GATT_SUCCESS) {
pending.resolve('ok');
} else {
pending.reject(new Error('Characteristic write failed'));
}
} catch (e) {
try { pending.reject(e); } catch (e2) { console.error(e2); }
}
}
}
}
// 导出单例实例供外部使用
export const gattCallback = new GattCallback();
export class ServiceManager {
private static instance : ServiceManager | null = null;
private services = new Map<string, BleService[]>();
private characteristics = new Map<string, Map<string, BleCharacteristic[]>>();
private deviceManager = DeviceManager.getInstance();
private constructor() { }
static getInstance() : ServiceManager {
if (ServiceManager.instance == null) {
ServiceManager.instance = new ServiceManager();
}
return ServiceManager.instance!;
}
getServices(deviceId : string, callback ?: (services : BleService[] | null, error ?: Error) => void) : any | Promise<BleService[]> {
console.log('ak start getservice', deviceId);
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) {
if (callback != null) { callback(null, new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", "")); }
return Promise.reject(new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", ""));
}
console.log('ak serviceDiscovered', gatt)
// 如果服务已发现,直接返回
if (serviceDiscovered.get(deviceId) == true) {
const services = gatt.getServices();
console.log(services)
const result : BleService[] = [];
if (services != null) {
const servicesList = services;
const size = servicesList.size;
if (size > 0) {
for (let i = 0 as Int; i < size; i++) {
const service = servicesList != null ? servicesList.get(i) : servicesList[i];
if (service != null) {
const bleService : BleService = {
uuid: service.getUuid().toString(),
isPrimary: service.getType() == BluetoothGattService.SERVICE_TYPE_PRIMARY
};
result.push(bleService);
if (bleService.uuid == getFullUuid('0001')) {
const device = this.deviceManager.getDevice(deviceId);
if (device != null) {
device.serviceId = bleService.uuid;
this.getCharacteristics(deviceId, device.serviceId, (chars, err) => {
if (err == null && chars != null) {
const writeChar = chars.find(c => c.uuid == getFullUuid('0010'));
const notifyChar = chars.find(c => c.uuid == getFullUuid('0011'));
if (writeChar != null) device.writeCharId = writeChar.uuid;
if (notifyChar != null) device.notifyCharId = notifyChar.uuid;
}
});
}
}
}
}
}
}
if (callback != null) { callback(result, null); }
return Promise.resolve(result);
}
// 未发现则发起服务发现并加入等待队列
if (!serviceDiscoveryWaiters.has(deviceId)) {
serviceDiscoveryWaiters.set(deviceId, []);
gatt.discoverServices();
}
return new Promise<BleService[]>((resolve, reject) => {
const cb = (services : BleService[] | null, error ?: Error) => {
if (error != null) reject(error);
else resolve(services ?? []);
if (callback != null) callback(services, error);
};
const arr = serviceDiscoveryWaiters.get(deviceId);
if (arr != null) arr.push(cb);
});
}
getCharacteristics(deviceId : string, serviceId : string, callback : (characteristics : BleCharacteristic[] | null, error ?: Error) => void) : void {
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) return callback(null, new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", ""));
// 如果服务还没发现,等待服务发现后再查特征
if (serviceDiscovered.get(deviceId) !== true) {
// 先注册到服务发现等待队列
this.getServices(deviceId, (services, err) => {
if (err != null) {
callback(null, err);
} else {
this.getCharacteristics(deviceId, serviceId, callback);
}
});
return;
}
// 服务已发现,正常获取特征
const service = gatt.getService(UUID.fromString(serviceId));
if (service == null) return callback(null, new AkBleErrorImpl(AkBluetoothErrorCode.ServiceNotFound, "Service not found", ""));
const chars = service.getCharacteristics();
console.log(chars)
const result : BleCharacteristic[] = [];
if (chars != null) {
const characteristicsList = chars;
const size = characteristicsList.size;
const bleService : BleService = {
uuid: serviceId,
isPrimary: service.getType() == BluetoothGattService.SERVICE_TYPE_PRIMARY
};
for (let i = 0 as Int; i < size; i++) {
const char = characteristicsList != null ? characteristicsList.get(i as Int) : characteristicsList[i];
if (char != null) {
const props = char.getProperties();
try {
const charUuid = char.getUuid() != null ? char.getUuid().toString() : '';
console.log('[ServiceManager] characteristic uuid=', charUuid);
} catch (e) { console.warn('[ServiceManager] failed to read char uuid', e); }
console.log(props);
const bleCharacteristic : BleCharacteristic = {
uuid: char.getUuid().toString(),
service: bleService,
properties: createCharProperties(props)
};
result.push(bleCharacteristic);
}
}
}
callback(result, null);
}
public async readCharacteristic(deviceId : string, serviceId : string, characteristicId : string) : Promise<ArrayBuffer> {
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", "");
const service = gatt.getService(UUID.fromString(serviceId));
if (service == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.ServiceNotFound, "Service not found", "");
const char = service.getCharacteristic(UUID.fromString(characteristicId));
if (char == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.CharacteristicNotFound, "Characteristic not found", "");
const key = `${deviceId}|${serviceId}|${characteristicId}|read`;
console.log(key)
return new Promise<ArrayBuffer>((resolve, reject) => {
const timer = setTimeout(() => {
pendingCallbacks.delete(key);
reject(new AkBleErrorImpl(AkBluetoothErrorCode.ConnectionTimeout, "Connection timeout", ""));
}, 5000);
const resolveAdapter = (data : any) => { console.log('read resolve:', data); resolve(data as ArrayBuffer); };
const rejectAdapter = (err ?: any) => { reject(new AkBleErrorImpl(AkBluetoothErrorCode.UnknownError, "Unknown error occurred", "")); };
pendingCallbacks.set(key, new PendingCallbackImpl(resolveAdapter, rejectAdapter, timer));
if (gatt.readCharacteristic(char) == false) {
clearTimeout(timer);
pendingCallbacks.delete(key);
reject(new AkBleErrorImpl(AkBluetoothErrorCode.UnknownError, "Unknown error occurred", ""));
}
else {
console.log('read should be succeed', key)
}
});
}
public async writeCharacteristic(deviceId : string, serviceId : string, characteristicId : string, data : Uint8Array, options ?: WriteCharacteristicOptions) : Promise<boolean> {
console.log('[writeCharacteristic] deviceId:', deviceId, 'serviceId:', serviceId, 'characteristicId:', characteristicId, 'data:', data);
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) {
console.error('[writeCharacteristic] gatt is null');
throw new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", "");
}
const service = gatt.getService(UUID.fromString(serviceId));
if (service == null) {
console.error('[writeCharacteristic] service is null');
throw new AkBleErrorImpl(AkBluetoothErrorCode.ServiceNotFound, "Service not found", "");
}
const char = service.getCharacteristic(UUID.fromString(characteristicId));
if (char == null) {
console.error('[writeCharacteristic] characteristic is null');
throw new AkBleErrorImpl(AkBluetoothErrorCode.CharacteristicNotFound, "Characteristic not found", "");
}
const key = `${deviceId}|${serviceId}|${characteristicId}|write`;
const wantsNoResponse = options != null && options.waitForResponse == false;
let retryMaxAttempts = 20;
let retryDelay = 100;
let giveupTimeout = 20000;
if (options != null) {
try {
if (options.maxAttempts != null) {
const parsedAttempts = Math.floor(options.maxAttempts as number);
if (!isNaN(parsedAttempts) && parsedAttempts > 0) retryMaxAttempts = parsedAttempts;
}
} catch (e) { }
try {
if (options.retryDelayMs != null) {
const parsedDelay = Math.floor(options.retryDelayMs as number);
if (!isNaN(parsedDelay) && parsedDelay >= 0) retryDelay = parsedDelay;
}
} catch (e) { }
try {
if (options.giveupTimeoutMs != null) {
const parsedGiveup = Math.floor(options.giveupTimeoutMs as number);
if (!isNaN(parsedGiveup) && parsedGiveup > 0) giveupTimeout = parsedGiveup;
}
} catch (e) { }
}
const gattInstance = gatt;
const executeWrite = () : Promise<boolean> => {
return new Promise<boolean>((resolve) => {
const initialTimeout = Math.max(giveupTimeout + 5000, 10000);
let timer = setTimeout(() => {
pendingCallbacks.delete(key);
console.error('[writeCharacteristic] timeout');
resolve(false);
}, initialTimeout);
console.log('[writeCharacteristic] initial timeout set to', initialTimeout, 'ms for', key);
const resolveAdapter = (data : any) => {
console.log('[writeCharacteristic] resolveAdapter called');
resolve(true);
};
const rejectAdapter = (err ?: any) => {
console.error('[writeCharacteristic] rejectAdapter called', err);
resolve(false);
};
pendingCallbacks.set(key, new PendingCallbackImpl(resolveAdapter, rejectAdapter, timer));
const byteArray = new ByteArray(data.length as Int);
for (let i = 0 as Int; i < data.length; i++) {
byteArray[i] = data[i].toByte();
}
const forceWriteTypeNoResponse = options != null && options.forceWriteTypeNoResponse == true;
let usesNoResponse = forceWriteTypeNoResponse || wantsNoResponse;
try {
const props = char.getProperties();
console.log('[writeCharacteristic] characteristic properties mask=', props);
if (usesNoResponse == false) {
const supportsWriteWithResponse = (props & BluetoothGattCharacteristic.PROPERTY_WRITE) !== 0;
const supportsWriteNoResponse = (props & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) !== 0;
if (supportsWriteWithResponse == false && supportsWriteNoResponse == true) {
usesNoResponse = true;
}
}
if (usesNoResponse) {
try { char.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); } catch (e) { }
console.log('[writeCharacteristic] using WRITE_TYPE_NO_RESPONSE');
} else {
try { char.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); } catch (e) { }
console.log('[writeCharacteristic] using WRITE_TYPE_DEFAULT');
}
} catch (e) {
console.warn('[writeCharacteristic] failed to inspect/set write type', e);
}
const maxAttempts = retryMaxAttempts;
function attemptWrite(att : Int) : void {
try {
let setOk = true;
try {
const setRes = char.setValue(byteArray);
if (typeof setRes == 'boolean' && setRes == false) {
setOk = false;
console.warn('[writeCharacteristic] setValue returned false for', key, 'attempt', att);
}
} catch (e) {
setOk = false;
console.warn('[writeCharacteristic] setValue threw for', key, 'attempt', att, e);
}
if (setOk == false) {
if (att >= maxAttempts) {
try { clearTimeout(timer); } catch (e) { }
pendingCallbacks.delete(key);
resolve(false);
return;
}
setTimeout(() => { attemptWrite((att + 1) as Int); }, retryDelay);
return;
}
try {
console.log('[writeCharacteristic] attempt', att, 'calling gatt.writeCharacteristic');
const r = gattInstance.writeCharacteristic(char);
console.log('[writeCharacteristic] attempt', att, 'result=', r);
if (r == true) {
if (usesNoResponse) {
console.log('[writeCharacteristic] WRITE_TYPE_NO_RESPONSE success for', key);
try { clearTimeout(timer); } catch (e) { }
pendingCallbacks.delete(key);
resolve(true);
return;
}
try { clearTimeout(timer); } catch (e) { }
const extra = 20000;
timer = setTimeout(() => {
pendingCallbacks.delete(key);
console.error('[writeCharacteristic] timeout after write initiated');
resolve(false);
}, extra);
const pendingEntry = pendingCallbacks.get(key);
if (pendingEntry != null) pendingEntry.timer = timer;
return;
}
} catch (e) {
console.error('[writeCharacteristic] attempt', att, 'exception when calling writeCharacteristic', e);
}
if (att < maxAttempts) {
const nextAtt = (att + 1) as Int;
setTimeout(() => { attemptWrite(nextAtt); }, retryDelay);
return;
}
if (usesNoResponse) {
try { clearTimeout(timer); } catch (e) { }
pendingCallbacks.delete(key);
console.warn('[writeCharacteristic] all attempts failed with WRITE_NO_RESPONSE for', key);
resolve(false);
return;
}
try { clearTimeout(timer); } catch (e) { }
const giveupTimeoutLocal = giveupTimeout;
console.warn('[writeCharacteristic] all attempts failed; waiting for late callback up to', giveupTimeoutLocal, 'ms for', key);
const giveupTimer = setTimeout(() => {
pendingCallbacks.delete(key);
console.error('[writeCharacteristic] giveup timeout expired for', key);
resolve(false);
}, giveupTimeoutLocal);
const pendingEntryAfter = pendingCallbacks.get(key);
if (pendingEntryAfter != null) pendingEntryAfter.timer = giveupTimer;
} catch (e) {
clearTimeout(timer);
pendingCallbacks.delete(key);
console.error('[writeCharacteristic] Exception in attemptWrite', e);
resolve(false);
}
}
try {
attemptWrite(1 as Int);
} catch (e) {
clearTimeout(timer);
pendingCallbacks.delete(key);
console.error('[writeCharacteristic] Exception before attempting write', e);
resolve(false);
}
});
};
return enqueueDeviceWrite(deviceId, executeWrite);
}
public async subscribeCharacteristic(deviceId : string, serviceId : string, characteristicId : string, onData : BleDataReceivedCallback) : Promise<void> {
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", "");
const service = gatt.getService(UUID.fromString(serviceId));
if (service == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.ServiceNotFound, "Service not found", "");
const char = service.getCharacteristic(UUID.fromString(characteristicId));
if (char == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.CharacteristicNotFound, "Characteristic not found", "");
const key = `${deviceId}|${serviceId}|${characteristicId}|notify`;
notifyCallbacks.set(key, onData);
if (gatt.setCharacteristicNotification(char, true) == false) {
notifyCallbacks.delete(key);
throw new AkBleErrorImpl(AkBluetoothErrorCode.UnknownError, "Failed to unsubscribe characteristic", "");
} else {
// 写入 CCCD 描述符,启用 notify
const descriptor = char.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (descriptor != null) {
// 设置描述符值
const value =
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
descriptor.setValue(value);
const writedescript = gatt.writeDescriptor(descriptor);
console.log('subscribeCharacteristic: CCCD written for notify', writedescript);
} else {
console.warn('subscribeCharacteristic: CCCD descriptor not found!');
}
console.log('subscribeCharacteristic ok!!');
}
}
public async unsubscribeCharacteristic(deviceId : string, serviceId : string, characteristicId : string) : Promise<void> {
const gatt = this.deviceManager.getGattInstance(deviceId);
if (gatt == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.DeviceNotFound, "Device not found", "");
const service = gatt.getService(UUID.fromString(serviceId));
if (service == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.ServiceNotFound, "Service not found", "");
const char = service.getCharacteristic(UUID.fromString(characteristicId));
if (char == null) throw new AkBleErrorImpl(AkBluetoothErrorCode.CharacteristicNotFound, "Characteristic not found", "");
const key = `${deviceId}|${serviceId}|${characteristicId}|notify`;
notifyCallbacks.delete(key);
if (gatt.setCharacteristicNotification(char, false) == false) {
throw new AkBleErrorImpl(AkBluetoothErrorCode.UnknownError, "Failed to unsubscribe characteristic", "");
}
}
// 自动发现所有服务和特征
public async autoDiscoverAll(deviceId : string) : Promise<AutoDiscoverAllResult> {
const services = await this.getServices(deviceId, null) as BleService[];
const allCharacteristics : BleCharacteristic[] = [];
for (const service of services) {
await new Promise<void>((resolve, reject) => {
this.getCharacteristics(deviceId, service.uuid, (chars, err) => {
if (err != null) reject(err);
else {
if (chars != null) allCharacteristics.push(...chars);
resolve();
}
});
});
}
return { services, characteristics: allCharacteristics };
}
// 自动订阅所有支持 notify/indicate 的特征
public async subscribeAllNotifications(deviceId : string, onData : BleDataReceivedCallback) : Promise<void> {
const { services, characteristics } = await this.autoDiscoverAll(deviceId);
for (const char of characteristics) {
if (char.properties.notify || char.properties.indicate) {
try {
await this.subscribeCharacteristic(deviceId, char.service.uuid, char.uuid, onData);
} catch (e) {
// 可以选择忽略单个特征订阅失败
console.warn(`订阅特征 ${char.uuid} 失败:`, e);
}
}
}
}
}

View File

@@ -0,0 +1,481 @@
// 蓝牙相关接口和类型定义
// 基础设备信息类型
export type BleDeviceInfo = {
deviceId : string;
name : string;
RSSI ?: number;
connected ?: boolean;
// 新增
serviceId ?: string;
writeCharId ?: string;
notifyCharId ?: string;
}
export type AutoDiscoverAllResult = {
services : BleService[];
characteristics : BleCharacteristic[];
}
// 服务信息类型
export type BleServiceInfo = {
uuid : string;
isPrimary : boolean;
}
// 特征值属性类型
export type BleCharacteristicProperties = {
read : boolean;
write : boolean;
notify : boolean;
indicate : boolean;
writeWithoutResponse ?: boolean;
canRead ?: boolean;
canWrite ?: boolean;
canNotify ?: boolean;
}
// 特征值信息类型
export type BleCharacteristicInfo = {
uuid : string;
serviceId : string;
properties : BleCharacteristicProperties;
}
// 错误状态码
export enum BleErrorCode {
UNKNOWN_ERROR = 0,
BLUETOOTH_UNAVAILABLE = 1,
PERMISSION_DENIED = 2,
DEVICE_NOT_CONNECTED = 3,
SERVICE_NOT_FOUND = 4,
CHARACTERISTIC_NOT_FOUND = 5,
OPERATION_TIMEOUT = 6
}
// 命令类型
export enum CommandType {
BATTERY = 1,
DEVICE_INFO = 2,
CUSTOM = 99,
TestBatteryLevel = 0x01
}
// 错误接口
export type BleError {
errCode : number;
errMsg : string;
errSubject ?: string;
}
// 连接选项
export type BleConnectOptions = {
deviceId : string;
timeout ?: number;
success ?: (result : any) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 断开连接选项
export type BleDisconnectOptions = {
deviceId : string;
success ?: (result : any) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 获取特征值选项
export type BleCharacteristicOptions = {
deviceId : string;
serviceId : string;
characteristicId : string;
success ?: (result : any) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 写入特征值选项
export type BleWriteOptions = {
deviceId : string;
serviceId : string;
characteristicId : string;
value : Uint8Array;
writeType ?: number;
success ?: (result : any) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// Options for writeCharacteristic helper
export type WriteCharacteristicOptions = {
waitForResponse ?: boolean;
maxAttempts ?: number;
retryDelayMs ?: number;
giveupTimeoutMs ?: number;
forceWriteTypeNoResponse ?: boolean;
}
// 通知特征值回调函数
export type BleNotifyCallback = (data : Uint8Array) => void;
// 通知特征值选项
export type BleNotifyOptions = {
deviceId : string;
serviceId : string;
characteristicId : string;
state ?: boolean; // true: 启用通知false: 禁用通知
onCharacteristicValueChange : BleNotifyCallback;
success ?: (result : any) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 获取服务选项
export type BleDeviceServicesOptions = {
deviceId : string;
success ?: (result : BleServicesResult) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 获取特征值选项
export type BleDeviceCharacteristicsOptions = {
deviceId : string;
serviceId : string;
success ?: (result : BleCharacteristicsResult) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 蓝牙扫描选项
export type BluetoothScanOptions = {
services ?: string[];
timeout ?: number;
onDeviceFound ?: (device : BleDeviceInfo) => void;
success ?: (result : BleScanResult) => void;
fail ?: (error : BleError) => void;
complete ?: (result : any) => void;
}
// 扫描结果
// 服务结果
export type BleServicesResult = {
services : BleServiceInfo[];
errMsg ?: string;
}
// 特征值结果
export type BleCharacteristicsResult = {
characteristics : BleCharacteristicInfo[];
errMsg ?: string;
}
// 定义连接状态枚举
export enum BLE_CONNECTION_STATE {
DISCONNECTED = 0,
CONNECTING = 1,
CONNECTED = 2,
DISCONNECTING = 3
}
// 电池状态类型定义
export type BatteryStatus = {
batteryLevel : number; // 电量百分比
isCharging : boolean; // 充电状态
}
// 蓝牙服务接口类型定义 - 转换为type类型
export type BleService = {
uuid : string;
isPrimary : boolean;
}
// 蓝牙特征值接口定义 - 转换为type类型
export type BleCharacteristic = {
uuid : string;
service : BleService;
properties : BleCharacteristicProperties;
}
// PendingPromise接口定义
export interface PendingCallback {
resolve : (data : any) => void;
reject : (err ?: any) => void;
timer ?: number;
}
// 蓝牙相关接口和类型定义
export type BleDevice = {
deviceId : string;
name : string;
rssi ?: number;
lastSeen ?: number; // 新增
// 新增
serviceId ?: string;
writeCharId ?: string;
notifyCharId ?: string;
}
// BLE常规选项
export type BleOptions = {
timeout ?: number;
success ?: (result : any) => void;
fail ?: (error : any) => void;
complete ?: () => void;
}
export type BleConnectionState = number; // 0: DISCONNECTED, 1: CONNECTING, 2: CONNECTED, 3: DISCONNECTING
export type BleConnectOptionsExt = {
timeout ?: number;
services ?: string[];
requireResponse ?: boolean;
autoReconnect ?: boolean;
maxAttempts ?: number;
interval ?: number;
};
// 回调函数类型
export type BleDeviceFoundCallback = (device : BleDevice) => void;
export type BleConnectionStateChangeCallback = (deviceId : string, state : BleConnectionState) => void;
export type BleDataPayload = {
deviceId : string;
serviceId ?: string;
characteristicId ?: string;
data : string | ArrayBuffer;
format ?: number; // 0: JSON, 1: XML, 2: RAW
}
export type BleDataSentCallback = (payload : BleDataPayload, success : boolean, error ?: BleError) => void;
export type BleErrorCallback = (error : BleError) => void;
// 健康数据类型定义
export enum HealthDataType {
HEART_RATE = 1,
BLOOD_OXYGEN = 2,
TEMPERATURE = 3,
STEP_COUNT = 4,
SLEEP_DATA = 5,
HEALTH_DATA = 6
}
// Platform-specific services should be imported from per-platform entrypoints
// (e.g. './app-android/index.uts' or './web/index.uts').
// Avoid re-exporting platform modules at the SDK root to prevent bundlers
// Platform-specific services should be imported from per-platform entrypoints
// (e.g. './app-android/index.uts' or './web/index.uts').
// Avoid re-exporting platform modules at the SDK root to prevent bundlers
// from pulling android.* symbols into web bundles.
// If a typed ambient reference is required, declare the shape here instead of importing implementation.
// Example lightweight typed placeholder (do not import platform code here):
// export type BluetoothService = any; // platform-specific implementation exported from platform index files
// ====== 新增多协议、统一事件、协议适配、状态管理支持 ======
export type BleProtocolType =
| 'standard'
| 'custom'
| 'health'
| 'ibeacon'
| 'mesh';
export type BleEvent =
| 'deviceFound'
| 'scanFinished'
| 'connectionStateChanged'
| 'dataReceived'
| 'dataSent'
| 'error'
| 'servicesDiscovered'
| 'connected' // 新增
| 'disconnected'; // 新增
// 事件回调参数
export type BleEventPayload = {
event : BleEvent;
device ?: BleDevice;
protocol ?: BleProtocolType;
state ?: BleConnectionState;
data ?: ArrayBuffer | string | object;
format ?: string;
error ?: BleError;
extra ?: any;
}
// 事件回调函数
export type BleEventCallback = (payload : BleEventPayload) => void;
// 多协议设备信息(去除交叉类型,直接展开字段)
export type MultiProtocolDevice = {
deviceId : string;
name : string;
rssi ?: number;
protocol : BleProtocolType;
};
export type ScanDevicesOptions = {
protocols ?: BleProtocolType[];
optionalServices ?: string[];
timeout ?: number;
onDeviceFound ?: (device : BleDevice) => void;
onScanFinished ?: () => void;
};
// Named payload type used by sendData
export type SendDataPayload = {
deviceId : string;
serviceId ?: string;
characteristicId ?: string;
data : string | ArrayBuffer;
format ?: number;
protocol : BleProtocolType;
}
// 协议处理器接口(为 protocol-handler 适配器预留)
export type ScanHandler = {
protocol : BleProtocolType;
scanDevices ?: (options : ScanDevicesOptions) => Promise<void>;
connect : (device : BleDevice, options ?: BleConnectOptionsExt) => Promise<void>;
disconnect : (device : BleDevice) => Promise<void>;
// Optional: send arbitrary data via the protocol's write characteristic
sendData ?: (device : BleDevice, payload : SendDataPayload, options ?: BleOptions) => Promise<void>;
// Optional: try to connect and discover service/characteristic ids for this device
autoConnect ?: (device : BleDevice, options ?: BleConnectOptionsExt) => Promise<AutoBleInterfaces>;
}
// 自动发现服务和特征返回类型
export type AutoBleInterfaces = {
serviceId : string;
writeCharId : string;
notifyCharId : string;
}
export type ResponseCallbackEntry = {
cb : (data : Uint8Array) => boolean | void;
multi : boolean;
};
// Result returned by a DFU control parser. Use a plain string `type` to keep
// the generated Kotlin simple and avoid inline union types which the generator
// does not handle well.
export type ControlParserResult = {
type : string; // e.g. 'progress', 'success', 'error', 'info'
progress ?: number;
error ?: any;
}
// DFU types
export type DfuOptions = {
mtu ?: number;
useNordic ?: boolean;
// If true, the DFU upload will await a write response per-packet. Set false to use
// WRITE_NO_RESPONSE (fire-and-forget) for higher throughput. Default: false.
waitForResponse ?: boolean;
// Maximum number of outstanding NO_RESPONSE writes to allow before throttling.
// This implements a simple sliding window. Default: 32.
maxOutstanding ?: number;
// Per-chunk sleep (ms) to yield to event loop / Android BLE stack. Default: 2.
writeSleepMs ?: number;
// Retry delay (ms) used by the Android write helper when gatt.writeCharacteristic
// returns false. Smaller values can improve throughput on congested stacks.
writeRetryDelayMs ?: number;
// Maximum number of immediate write attempts before falling back to the give-up timeout.
writeMaxAttempts ?: number;
// Timeout (ms) to wait for a late onCharacteristicWrite callback after all retries fail.
writeGiveupTimeoutMs ?: number;
// Packet Receipt Notification (PRN) window size in packets. If set, DFU
// manager will send a Set PRN command to the device and wait for PRN
// notifications after this many packets. Default: 12.
prn ?: number;
// Timeout (ms) to wait for a PRN notification once the window is reached.
// Default: 10000 (10s).
prnTimeoutMs ?: number;
// When true, disable PRN waits automatically after the first timeout to prevent
// repeated long stalls on devices that do not send PRNs. Default: true.
disablePrnOnTimeout ?: boolean;
// Time (ms) to wait for outstanding fire-and-forget writes to drain before issuing
// the activate/validate control command. Default: 3000.
drainOutstandingTimeoutMs ?: number;
controlTimeout ?: number;
onProgress ?: (percent : number) => void;
onLog ?: (message : string) => void;
controlParser ?: (data : Uint8Array) => ControlParserResult | null;
}
export type DfuManagerType = {
startDfu : (deviceId : string, firmwareBytes : Uint8Array, options ?: DfuOptions) => Promise<void>;
}
// Lightweight runtime / UTS shims and missing types
// These are conservative placeholders to satisfy typings used across platform files.
// UTSJSONObject: bundler environments used by the build may not support
// TypeScript-style index signatures in this .uts context. Use a conservative
// 'any' alias so generated code doesn't rely on unsupported syntax while
// preserving a usable type at the source level.
export type UTSJSONObject = any;
// ByteArray / Int are used in the Android platform code to interop with Java APIs.
// Define minimal aliases so source can compile. Runtime uses Uint8Array and number.
export type ByteArray = any; // runtime will use Java byte[] via UTS bridge; keep as any here
// Callback types used by service_manager and index wrappers
export type BleDataReceivedCallback = (data: Uint8Array) => void;
export type BleScanResult = {
deviceId: string;
name?: string;
rssi?: number;
advertising?: any;
};
// Minimal UI / framework placeholders (some files reference these in types only)
export type ComponentPublicInstance = any;
export type UniElement = any;
export type UniPage = any;
// Platform service placeholder (actual implementation exported from platform index files)
// Provide a lightweight, strongly-shaped class skeleton so source-level code
// (and the code generator) can rely on concrete method names and signatures.
// Implementations are platform-specific and exported from per-platform index
// files (e.g. './app-android/index.uts' or './web/index.uts'). This class is
// intentionally thin — it declares method signatures used across pages and
// platform shims so the generator emits resolvable Kotlin symbols.
export class BluetoothService {
// Event emitter style
on(event: BleEvent | string, callback: BleEventCallback): void {}
off(event: BleEvent | string, callback?: BleEventCallback): void {}
// Scanning / discovery
scanDevices(options?: ScanDevicesOptions): Promise<void> { return Promise.resolve(); }
// Connection management
connectDevice(deviceId: string, protocol?: string, options?: BleConnectOptionsExt): Promise<void> { return Promise.resolve(); }
disconnectDevice(deviceId: string, protocol?: string): Promise<void> { return Promise.resolve(); }
getConnectedDevices(): MultiProtocolDevice[] { return []; }
// Services / characteristics
getServices(deviceId: string): Promise<BleService[]> { return Promise.resolve([]); }
getCharacteristics(deviceId: string, serviceId: string): Promise<BleCharacteristic[]> { return Promise.resolve([]); }
// Read / write / notify
readCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<ArrayBuffer> { return Promise.resolve(new ArrayBuffer(0)); }
writeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, value: Uint8Array | ArrayBuffer, options?: WriteCharacteristicOptions): Promise<boolean> { return Promise.resolve(true); }
subscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, callback: BleNotifyCallback): Promise<void> { return Promise.resolve(); }
unsubscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<void> { return Promise.resolve(); }
// Convenience helpers
getAutoBleInterfaces(deviceId: string): Promise<AutoBleInterfaces> {
const res: AutoBleInterfaces = { serviceId: '', writeCharId: '', notifyCharId: '' };
return Promise.resolve(res);
}
}
// Runtime protocol handler base class. Exporting a concrete class ensures the
// generator emits a resolvable runtime type that platform handlers can extend.
// Source-level code can still use the ScanHandler type for typing.
// Runtime ProtocolHandler is implemented in `protocol_handler.uts`.
// Keep the public typing in this file minimal to avoid duplicate runtime
// declarations. Consumers that need the runtime class should import it from
// './protocol_handler.uts'.

View File

@@ -0,0 +1,115 @@
// Minimal ProtocolHandler runtime class used by pages and components.
// This class adapts the platform `BluetoothService` to a small protocol API
// expected by pages: setConnectionParameters, initialize, testBatteryLevel,
// testVersionInfo. Implemented conservatively to avoid heavy dependencies.
import type { BluetoothService, AutoBleInterfaces, AutoDiscoverAllResult, BleService, BleCharacteristic, BleProtocolType, BleDevice, ScanDevicesOptions, BleConnectOptionsExt, SendDataPayload, BleOptions } from './interface.uts'
export class ProtocolHandler {
// bluetoothService may be omitted for lightweight wrappers; allow null
bluetoothService: BluetoothService | null = null
protocol: BleProtocolType = 'standard'
deviceId: string | null = null
serviceId: string | null = null
writeCharId: string | null = null
notifyCharId: string | null = null
initialized: boolean = false
// Accept an optional BluetoothService so wrapper subclasses can call
// `super()` without forcing a runtime instance.
constructor(bluetoothService?: BluetoothService) {
if (bluetoothService != null) this.bluetoothService = bluetoothService
}
setConnectionParameters(deviceId: string, serviceId: string, writeCharId: string, notifyCharId: string) {
this.deviceId = deviceId
this.serviceId = serviceId
this.writeCharId = writeCharId
this.notifyCharId = notifyCharId
}
// initialize: optional setup, returns a Promise that resolves when ready
async initialize(): Promise<void> {
// Simple async initializer — keep implementation minimal and generator-friendly.
try {
// If bluetoothService exposes any protocol-specific setup, call it here.
this.initialized = true
return
} catch (e) {
throw e
}
}
// Protocol lifecycle / operations — default no-ops so generated code has
// concrete member references and platform-specific handlers can override.
async scanDevices(options?: ScanDevicesOptions): Promise<void> { return; }
async connect(device: BleDevice, options?: BleConnectOptionsExt): Promise<void> { return; }
async disconnect(device: BleDevice): Promise<void> { return; }
async sendData(device: BleDevice, payload?: SendDataPayload, options?: BleOptions): Promise<void> { return; }
async autoConnect(device: BleDevice, options?: BleConnectOptionsExt): Promise<AutoBleInterfaces> { return { serviceId: '', writeCharId: '', notifyCharId: '' }; }
// Example: testBatteryLevel will attempt to read the battery characteristic
// if write/notify-based protocol is not available. Returns number percentage.
async testBatteryLevel(): Promise<number> {
if (this.deviceId == null) throw new Error('deviceId not set')
// copy to local so Kotlin generator can smart-cast the value across awaits
const deviceId = this.deviceId
// try reading standard Battery characteristic (180F -> 2A19)
if (this.bluetoothService == null) throw new Error('bluetoothService not set')
const services = await this.bluetoothService.getServices(deviceId)
let found: BleService | null = null
for (let i = 0; i < services.length; i++) {
const s = services[i]
const uuidCandidate: string | null = (s != null && s.uuid != null ? s.uuid : null)
const uuid = uuidCandidate != null ? ('' + uuidCandidate).toLowerCase() : ''
if (uuid.indexOf('180f') !== -1) { found = s; break }
}
if (found == null) {
// fallback: if writeCharId exists and notify available use protocol (not implemented)
return 0
}
const foundUuid = found!.uuid
const charsRaw = await this.bluetoothService.getCharacteristics(deviceId, foundUuid)
const chars: BleCharacteristic[] = charsRaw
const batChar = chars.find((c: BleCharacteristic) => ((c.properties != null && c.properties.read) || (c.uuid != null && ('' + c.uuid).toLowerCase().includes('2a19'))))
if (batChar == null) return 0
const buf = await this.bluetoothService.readCharacteristic(deviceId, foundUuid, batChar.uuid)
const arr = new Uint8Array(buf)
if (arr.length > 0) {
return arr[0]
}
return 0
}
// testVersionInfo: try to read Device Information characteristics or return empty
async testVersionInfo(hw: boolean): Promise<string> {
// copy to local so Kotlin generator can smart-cast the value across awaits
const deviceId = this.deviceId
if (deviceId == null) return ''
// Device Information service 180A, characteristics: 2A26 (SW), 2A27 (HW) sometimes
if (this.bluetoothService == null) return ''
const _services = await this.bluetoothService.getServices(deviceId)
const services2: BleService[] = _services
let found2: BleService | null = null
for (let i = 0; i < services2.length; i++) {
const s = services2[i]
const uuidCandidate: string | null = (s != null && s.uuid != null ? s.uuid : null)
const uuid = uuidCandidate != null ? ('' + uuidCandidate).toLowerCase() : ''
if (uuid.indexOf('180a') !== -1) { found2 = s; break }
}
if (found2 == null) return ''
const _found2 = found2
const found2Uuid = _found2!.uuid
const chars = await this.bluetoothService.getCharacteristics(deviceId, found2Uuid)
const target = chars.find((c) => {
const id = ('' + c.uuid).toLowerCase()
if (hw) return id.includes('2a27') || id.includes('hardware')
return id.includes('2a26') || id.includes('software')
})
if (target == null) return ''
const buf = await this.bluetoothService.readCharacteristic(deviceId, found2Uuid, target.uuid)
try { return new TextDecoder().decode(new Uint8Array(buf)) } catch (e) { return '' }
}
}

View File

@@ -0,0 +1,34 @@
// Minimal error definitions used across the BLE module.
// Keep this file small and avoid runtime dependencies; it's mainly for typing and
// simple runtime error construction used by native platform code.
export enum AkBluetoothErrorCode {
UnknownError = 0,
DeviceNotFound = 1,
ServiceNotFound = 2,
CharacteristicNotFound = 3,
ConnectionTimeout = 4,
Unspecified = 99
}
export class AkBleErrorImpl extends Error {
public code: AkBluetoothErrorCode;
public detail: any|null;
constructor(code: AkBluetoothErrorCode, message?: string, detail: any|null = null) {
super(message ?? AkBleErrorImpl.defaultMessage(code));
this.name = 'AkBleError';
this.code = code;
this.detail = detail;
}
static defaultMessage(code: AkBluetoothErrorCode) {
switch (code) {
case AkBluetoothErrorCode.DeviceNotFound: return 'Device not found';
case AkBluetoothErrorCode.ServiceNotFound: return 'Service not found';
case AkBluetoothErrorCode.CharacteristicNotFound: return 'Characteristic not found';
case AkBluetoothErrorCode.ConnectionTimeout: return 'Connection timed out';
case AkBluetoothErrorCode.UnknownError: default: return 'Unknown Bluetooth error';
}
}
}
export default AkBleErrorImpl;

View File

@@ -0,0 +1,91 @@
// H5平台 Web Bluetooth 设备扫描实现
import { DeviceManager } from './device-manager.uts';
import { ServiceManager } from './service-manager.uts';
import { BleDataProcessor } from '../data-processor.uts';
import * as BleUtils from '../ble-utils.uts';
import type { BleDevice, BleOptions, BleConnectOptionsExt, BleDataReceivedCallback, BleConnectionStateChangeCallback } from '../interface.uts'
export const BLE_SERVICE_PREFIXES = ['bae']; // 这里写你的实际前缀
// 实例化各个管理器
const deviceManager = new DeviceManager();
const serviceManager = new ServiceManager();
const dataProcessor = BleDataProcessor.getInstance();
// 导出简化接口
export const scanDevices = async (options?: { optionalServices?: string[] }) => deviceManager.startScan(options);
export const connectDevice = async (deviceId: string, options?: BleConnectOptionsExt) => deviceManager.connectDevice(deviceId, options);
export const disconnectDevice = async (deviceId: string) => deviceManager.disconnectDevice(deviceId);
export const getConnectedDevices = () => deviceManager.getConnectedDevices();
export const discoverServices = async (deviceId: string) => {
// 获取 server 实例
const server = deviceManager.servers[deviceId];
if (!server) throw new Error('设备未连接');
return serviceManager.discoverServices(deviceId, server);
};
export const getCharacteristics = async (deviceId: string, serviceId: string) => serviceManager.getCharacteristics(deviceId, serviceId);
export const writeCharacteristic = async (deviceId: string, serviceId: string, characteristicId: string, data: string | ArrayBuffer) => serviceManager.writeCharacteristic(deviceId, serviceId, characteristicId, data);
export const subscribeCharacteristic = async (deviceId: string, serviceId: string, characteristicId: string, callback) => serviceManager.subscribeCharacteristic(deviceId, serviceId, characteristicId, callback);
export const readCharacteristic = async (deviceId: string, serviceId: string, characteristicId: string) => serviceManager.readCharacteristic(deviceId, serviceId, characteristicId);
export const sendCommand = async (deviceId: string, serviceId: string, writeCharId: string, notifyCharId: string, command: string, params: any = null, timeout: number = 5000) => dataProcessor.sendAndReceive(deviceId, serviceId, writeCharId, notifyCharId, command, params, timeout);
// Event adapter helpers: translate DeviceManager callbacks into payload objects
export const onDeviceFound = (listener) => deviceManager.onDeviceFound((device) => {
try { listener({ device }); } catch (e) { /* ignore listener errors */ }
});
export const onScanFinished = (listener) => deviceManager.onScanFinished(() => {
try { listener({}); } catch (e) {}
});
export const onConnectionStateChange = (listener) => deviceManager.onConnectionStateChange((deviceId, state) => {
try { listener({ device: { deviceId }, state }); } catch (e) {}
});
/**
* 自动连接并初始化蓝牙设备获取可用serviceId、writeCharId、notifyCharId
* @param deviceId 设备ID
* @returns {Promise<{serviceId: string, writeCharId: string, notifyCharId: string}>}
*/
export const autoConnect = async (deviceId: string): Promise<{serviceId: string, writeCharId: string, notifyCharId: string}> => {
// 1. 连接设备
await connectDevice(deviceId);
// 2. 服务发现
const services = await discoverServices(deviceId);
if (!services || services.length === 0) throw new Error('未发现服务');
// 3. 获取私有serviceId优先bae前缀或通过dataProcessor模板
let serviceId = '';
for (const s of services) {
if (s.uuid && BLE_SERVICE_PREFIXES.some(prefix => s.uuid.startsWith(prefix))) {
serviceId = s.uuid;
break;
}
}
if (!serviceId) {
// 可扩展通过dataProcessor获取模板serviceId
serviceId = services[0].uuid;
}
// 4. 获取特征值
const characteristics = await getCharacteristics(deviceId, serviceId);
if (!characteristics || characteristics.length === 0) throw new Error('未发现特征值');
// 5. 找到write和notify特征
let writeCharId = '';
let notifyCharId = '';
for (const c of characteristics) {
if (!writeCharId && (c.properties.write || c.properties.writeWithoutResponse)) writeCharId = c.uuid;
if (!notifyCharId && (c.properties.notify || c.properties.indicate)) notifyCharId = c.uuid;
}
if (!writeCharId || !notifyCharId) throw new Error('未找到可用的写/通知特征');
// 6. 注册notification
await subscribeCharacteristic(deviceId, serviceId, notifyCharId, (data) => {
// 可在此处分发/处理notification
// console.log('Notification:', data);
});
// 7. 返回结果
return { serviceId, writeCharId, notifyCharId };
};

View File

@@ -0,0 +1,188 @@
// 设备管理相关:扫描、连接、断开、重连
import { BleDevice, BLE_CONNECTION_STATE } from '../interface.uts';
import type { BleConnectOptionsExt } from '../interface.uts';
export class DeviceManager {
private devices = {};
private servers = {};
private connectionStates = {};
private allowedServices = {};
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 5;
private reconnectDelay: number = 2000;
private reconnectTimeoutId: number = 0;
private autoReconnect: boolean = false;
private connectionStateChangeListeners: Function[] = [];
private deviceFoundListeners: ((device: BleDevice) => void)[] = [];
private scanFinishedListeners: (() => void)[] = [];
onDeviceFound(listener: (device: BleDevice) => void) {
this.deviceFoundListeners.push(listener);
}
onScanFinished(listener: () => void) {
this.scanFinishedListeners.push(listener);
}
private emitDeviceFound(device: BleDevice) {
for (const listener of this.deviceFoundListeners) {
try { listener(device); } catch (e) {}
}
}
private emitScanFinished() {
for (const listener of this.scanFinishedListeners) {
try { listener(); } catch (e) {}
}
}
async startScan(options?: { optionalServices?: string[] } ): Promise<void> {
if (!navigator.bluetooth) throw new Error('Web Bluetooth API not supported');
try {
const scanOptions: any = { acceptAllDevices: true };
// allow callers to request optionalServices (required by Web Bluetooth to access custom services)
if (options && Array.isArray(options.optionalServices) && options.optionalServices.length > 0) {
scanOptions.optionalServices = options.optionalServices;
}
// Log the exact options passed to requestDevice for debugging optionalServices propagation
try {
console.log('[DeviceManager] requestDevice options:', JSON.stringify(scanOptions));
} catch (e) {
console.log('[DeviceManager] requestDevice options (raw):', scanOptions);
}
const device = await navigator.bluetooth.requestDevice(scanOptions);
try {
console.log('[DeviceManager] requestDevice result:', device);
} catch (e) {
console.log('[DeviceManager] requestDevice result (raw):', device);
}
if (device) {
console.log(device)
// 格式化 deviceId 为 MAC 地址格式
const formatDeviceId = (id: string): string => {
// 如果是12位16进制字符串如 'AABBCCDDEEFF'),转为 'AA:BB:CC:DD:EE:FF'
if (/^[0-9A-Fa-f]{12}$/.test(id)) {
return id.match(/.{1,2}/g)!.join(":").toUpperCase();
}
// 如果是base64无法直接转MAC保留原样
// 你可以根据实际情况扩展此处
return id;
};
const isConnected = !!this.servers[device.id];
const formattedId = formatDeviceId(device.id);
const bleDevice = { deviceId: formattedId, name: device.name, connected: isConnected };
this.devices[formattedId] = device;
this.emitDeviceFound(bleDevice);
}
this.emitScanFinished();
} catch (e) {
this.emitScanFinished();
throw e;
}
}
onConnectionStateChange(listener: (deviceId: string, state: string) => void) {
this.connectionStateChangeListeners.push(listener);
}
private emitConnectionStateChange(deviceId: string, state: string) {
for (const listener of this.connectionStateChangeListeners) {
try {
listener(deviceId, state);
} catch (e) {
// 忽略单个回调异常
}
}
}
async connectDevice(deviceId: string, options?: BleConnectOptionsExt): Promise<boolean> {
this.autoReconnect = options?.autoReconnect ?? false;
try {
const device = this.devices[deviceId];
if (!device) throw new Error('设备未找到');
const server = await device.gatt.connect();
this.servers[deviceId] = server;
this.connectionStates[deviceId] = BLE_CONNECTION_STATE.CONNECTED;
this.reconnectAttempts = 0;
this.emitConnectionStateChange(deviceId, 'connected');
// 监听物理断开
if (device.gatt) {
device.gatt.onconnectionstatechanged = null;
device.gatt.onconnectionstatechanged = () => {
if (!device.gatt.connected) {
this.emitConnectionStateChange(deviceId, 'disconnected');
}
};
}
return true;
} catch (error) {
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
return this.scheduleReconnect(deviceId);
}
throw error;
}
}
async disconnectDevice(deviceId: string): Promise<void> {
const device = this.devices[deviceId];
if (!device) throw new Error('设备未找到');
try {
if (device.gatt && device.gatt.connected) {
device.gatt.disconnect();
}
delete this.servers[deviceId];
this.connectionStates[deviceId] = BLE_CONNECTION_STATE.DISCONNECTED;
this.emitConnectionStateChange(deviceId, 'disconnected');
} catch (e) {
throw e;
}
}
getConnectedDevices(): BleDevice[] {
const connectedDevices: BleDevice[] = [];
for (const deviceId in this.servers) {
const device = this.devices[deviceId];
if (device) {
connectedDevices.push({ deviceId: device.id, name: device.name || '未知设备', connected: true });
}
}
return connectedDevices;
}
handleDisconnect(deviceId: string) {
this.connectionStates[deviceId] = BLE_CONNECTION_STATE.DISCONNECTED;
this.emitConnectionStateChange(deviceId, 'disconnected');
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect(deviceId);
}
}
private scheduleReconnect(deviceId: string): Promise<boolean> {
this.reconnectAttempts++;
return new Promise((resolve, reject) => {
this.reconnectTimeoutId = setTimeout(() => {
this.connectDevice(deviceId, { autoReconnect: true })
.then(resolve)
.catch(reject);
}, this.reconnectDelay);
});
}
cancelReconnect() {
if (this.reconnectTimeoutId) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = 0;
}
this.autoReconnect = false;
this.reconnectAttempts = 0;
}
setMaxReconnectAttempts(attempts: number) {
this.maxReconnectAttempts = attempts;
}
setReconnectDelay(delay: number) {
this.reconnectDelay = delay;
}
isDeviceConnected(deviceId: string): boolean {
return !!this.servers[deviceId];
}
}

View File

@@ -0,0 +1,136 @@
import * as BluetoothManager from './bluetooth_manager.uts'
// 默认 Nordic DFU UUIDs (web 模式也可用,如设备使用自定义请传入 options)
const DFU_SERVICE_UUID = '00001530-1212-EFDE-1523-785FEABCD123'
const DFU_CONTROL_POINT_UUID = '00001531-1212-EFDE-1523-785FEABCD123'
const DFU_PACKET_UUID = '00001532-1212-EFDE-1523-785FEABCD123'
export class WebDfuManager {
// startDfu: deviceId, firmwareBytes (Uint8Array), options
// options: { serviceId?, writeCharId?, notifyCharId?, chunkSize?, onProgress?, onLog?, useNordic?, controlParser?, controlTimeout? }
async startDfu(deviceId: string, firmwareBytes: Uint8Array, options?: any): Promise<void> {
options = options || {};
// 1. ensure connected and discover services
let svcInfo;
if (options.serviceId && options.writeCharId && options.notifyCharId) {
svcInfo = { serviceId: options.serviceId, writeCharId: options.writeCharId, notifyCharId: options.notifyCharId };
} else {
svcInfo = await BluetoothManager.autoConnect(deviceId);
}
const serviceId = svcInfo.serviceId;
const writeCharId = svcInfo.writeCharId;
const notifyCharId = svcInfo.notifyCharId;
const chunkSize = options.chunkSize ?? 20;
// control parser
const controlParser = options.controlParser ?? (options.useNordic ? this._nordicControlParser.bind(this) : this._defaultControlParser.bind(this));
// subscribe notifications on control/notify char
let finalizeSub;
let resolved = false;
const promise = new Promise<void>(async (resolve, reject) => {
const cb = (payload) => {
try {
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
options.onLog?.('control notify: ' + Array.from(data).join(','));
const parsed = controlParser(data);
if (!parsed) return;
if (parsed.type === 'progress' && parsed.progress != null) {
if (options.useNordic && svcInfo && svcInfo.totalBytes) {
const percent = Math.floor((parsed.progress / svcInfo.totalBytes) * 100);
options.onProgress?.(percent);
} else {
options.onProgress?.(parsed.progress);
}
} else if (parsed.type === 'success') {
resolved = true;
resolve();
} else if (parsed.type === 'error') {
reject(parsed.error ?? new Error('DFU device error'));
}
} catch (e) {
options.onLog?.('control handler error: ' + e);
}
};
await BluetoothManager.subscribeCharacteristic(deviceId, serviceId, notifyCharId, cb);
finalizeSub = async () => { try { await BluetoothManager.subscribeCharacteristic(deviceId, serviceId, notifyCharId, () => {}); } catch(e){} };
// write firmware in chunks
try {
let offset = 0;
const total = firmwareBytes.length;
// attach totalBytes for nordic if needed
svcInfo.totalBytes = total;
while (offset < total) {
const end = Math.min(offset + chunkSize, total);
const slice = firmwareBytes.subarray(offset, end);
// writeValue accepts ArrayBuffer
await BluetoothManager.writeCharacteristic(deviceId, serviceId, writeCharId, slice.buffer);
offset = end;
// optimistic progress
options.onProgress?.(Math.floor((offset / total) * 100));
await this._sleep(options.chunkDelay ?? 6);
}
// send validate/activate command to control point (placeholder)
try {
await BluetoothManager.writeCharacteristic(deviceId, serviceId, writeCharId, new Uint8Array([0x04]).buffer);
} catch (e) {
// ignore
}
// wait for control success or timeout
const timeoutMs = options.controlTimeout ?? 20000;
const t = setTimeout(() => {
if (!resolved) reject(new Error('DFU control timeout'));
}, timeoutMs);
} catch (e) {
reject(e);
}
});
try {
await promise;
} finally {
// unsubscribe notifications
try { await BluetoothManager.unsubscribeCharacteristic(deviceId, serviceId, notifyCharId); } catch(e) {}
}
}
_sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
_defaultControlParser(data: Uint8Array) {
if (!data || data.length === 0) return null;
if (data.length >= 2) {
const maybeProgress = data[1];
if (maybeProgress >= 0 && maybeProgress <= 100) return { type: 'progress', progress: maybeProgress };
}
const op = data[0];
if (op === 0x01) return { type: 'success' };
if (op === 0xFF) return { type: 'error', error: data };
return { type: 'info' };
}
_nordicControlParser(data: Uint8Array) {
if (!data || data.length === 0) return null;
const op = data[0];
// 0x11 = Packet Receipt Notification
if (op === 0x11 && data.length >= 3) {
const lsb = data[1];
const msb = data[2];
const received = (msb << 8) | lsb;
return { type: 'progress', progress: received };
}
// 0x10 = Response
if (op === 0x10 && data.length >= 3) {
const resultCode = data[2];
if (resultCode === 0x01) return { type: 'success' };
return { type: 'error', error: { resultCode } };
}
return null;
}
}
export const dfuManager = new WebDfuManager();

View File

@@ -0,0 +1,46 @@
import * as BluetoothManager from './bluetooth_manager.uts';
export const bluetoothService = {
scanDevices: BluetoothManager.scanDevices,
connectDevice: BluetoothManager.connectDevice,
disconnectDevice: BluetoothManager.disconnectDevice,
getConnectedDevices: BluetoothManager.getConnectedDevices,
discoverServices: BluetoothManager.discoverServices,
// compatibility aliases used by app code
getServices: BluetoothManager.discoverServices,
getCharacteristics: BluetoothManager.getCharacteristics,
readCharacteristic: BluetoothManager.readCharacteristic,
writeCharacteristic: BluetoothManager.writeCharacteristic,
subscribeCharacteristic: BluetoothManager.subscribeCharacteristic,
unsubscribeCharacteristic: BluetoothManager.unsubscribeCharacteristic,
sendCommand: BluetoothManager.sendCommand,
onConnectionStateChange: BluetoothManager.onConnectionStateChange,
// 兼容旧接口,如有 readCharacteristic 可补充
};
// Provide a minimal EventEmitter-style `.on(eventName, handler)` to match app code
// Supported events: 'deviceFound', 'scanFinished', 'connectionStateChanged'
bluetoothService.on = function(eventName: string, handler: Function) {
if (!eventName || typeof handler !== 'function') return;
switch (eventName) {
case 'deviceFound':
return BluetoothManager.onDeviceFound(handler);
case 'scanFinished':
return BluetoothManager.onScanFinished(handler);
case 'connectionStateChanged':
return BluetoothManager.onConnectionStateChange(handler);
default:
// no-op for unsupported events
return;
}
};
// Backwards-compat: getAutoBleInterfaces expected by pages -> maps to autoConnect
if (!bluetoothService.getAutoBleInterfaces) {
bluetoothService.getAutoBleInterfaces = function(deviceId: string) {
return BluetoothManager.autoConnect(deviceId);
}
}
import { dfuManager as webDfuManager } from './dfu_manager.uts'
export const dfuManager = webDfuManager;

View File

@@ -0,0 +1,186 @@
// 服务与特征值操作相关:服务发现、特征值读写、订阅
import { BleService, BleCharacteristic } from '../interface.uts';
import { BLE_SERVICE_PREFIXES } from './bluetooth_manager.uts';
// Helper: normalize UUIDs (accept 16-bit like '180F' and expand to full 128-bit)
function normalizeUuid(uuid: string): string {
if (!uuid) return uuid;
const u = uuid.toLowerCase();
// already full form
if (u.length === 36 && u.indexOf('-') > 0) return u;
// allow forms like '180f' or '0x180f'
const hex = u.replace(/^0x/, '').replace(/[^0-9a-f]/g, '');
if (/^[0-9a-f]{4}$/.test(hex)) {
return `0000${hex}-0000-1000-8000-00805f9b34fb`;
}
return uuid;
}
export class ServiceManager {
private services = {};
private characteristics = {};
private characteristicCallbacks = {};
private characteristicListeners = {};
constructor() {
}
async discoverServices(deviceId: string, server: any): Promise<BleService[]> {
// 获取设备的 GATT 服务器
console.log(deviceId)
// 由外部传入 server
if (!server) throw new Error('设备未连接');
const bleServices: BleService[] = [];
if (!this.services[deviceId]) this.services[deviceId] = {};
try {
console.log('[ServiceManager] discoverServices called for', deviceId)
console.log('[ServiceManager] server param:', server)
let services = null;
// Typical case: server is a BluetoothRemoteGATTServer and has getPrimaryServices
if (server && typeof server.getPrimaryServices === 'function') {
console.log('server.getPrimaryServices')
services = await server.getPrimaryServices();
}
if (server && server.gatt && typeof server.gatt.getPrimaryServices === 'function') {
// sometimes a BluetoothDevice object is passed instead of the server
console.log('server.gatt.getPrimaryServices')
services = await server.gatt.getPrimaryServices();
}
if (server && server.device && server.device.gatt && typeof server.device.gatt.getPrimaryServices === 'function') {
console.log('server.device.gatt.getPrimaryServices')
services = await server.device.gatt.getPrimaryServices();
} else {
console.log('other getPrimaryServices')
// Last resort: if server is a wrapper with a connect method, try to ensure connected
if (server && typeof server.connect === 'function') {
console.log('[ServiceManager] attempting to connect via server.connect()')
try {
const s = await server.connect();
if (s && typeof s.getPrimaryServices === 'function') services = await s.getPrimaryServices();
} catch (e) {
console.warn('[ServiceManager] server.connect() failed', e)
}
}
}
console.log('[ServiceManager] services resolved:', services)
if (!services) throw new Error('无法解析 GATT services 对象 —— server 参数不包含 getPrimaryServices');
for (let i = 0; i < services.length; i++) {
const service = services[i];
const rawUuid = service.uuid;
const uuid = normalizeUuid(rawUuid);
bleServices.push({ uuid, isPrimary: true });
this.services[deviceId][uuid] = service;
// ensure service UUID detection supports standard BLE services like Battery (0x180F)
const lower = uuid.toLowerCase();
const isBattery = lower === '0000180f-0000-1000-8000-00805f9b34fb';
if (isBattery || isBaeService(uuid) || isBleService(uuid, BLE_SERVICE_PREFIXES)) {
await this.getCharacteristics(deviceId, uuid);
}
}
return bleServices;
} catch (err) {
console.error('[ServiceManager] discoverServices error:', err)
throw err;
}
}
async getCharacteristics(deviceId: string, serviceId: string): Promise<BleCharacteristic[]> {
const service = this.services[deviceId]?.[serviceId];
if (!service) throw new Error('服务未找到');
const characteristics = await service.getCharacteristics();
console.log(characteristics)
const bleCharacteristics: BleCharacteristic[] = [];
if (!this.characteristics[deviceId]) this.characteristics[deviceId] = {};
if (!this.characteristics[deviceId][serviceId]) this.characteristics[deviceId][serviceId] = {};
for (const characteristic of characteristics) {
const properties = {
read: characteristic.properties.read || false,
write: characteristic.properties.write || characteristic.properties.writableAuxiliaries || characteristic.properties.reliableWrite || characteristic.properties.writeWithoutResponse || false,
notify: characteristic.properties.notify || false,
indicate: characteristic.properties.indicate || false
};
console.log(characteristic.properties)
console.log(properties)
// Construct a BleCharacteristic-shaped object including the required `service` property
const bleCharObj = {
uuid: characteristic.uuid,
service: { uuid: serviceId, isPrimary: true },
properties
};
bleCharacteristics.push(bleCharObj);
// keep native characteristic reference for read/write/notify operations
this.characteristics[deviceId][serviceId][characteristic.uuid] = characteristic;
}
return bleCharacteristics;
}
async writeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, data: string | ArrayBuffer): Promise<void> {
const characteristic = this.characteristics[deviceId]?.[serviceId]?.[characteristicId];
if (!characteristic) throw new Error('特征值未找到');
let buffer;
if (typeof data === 'string') {
buffer = new TextEncoder().encode(data).buffer;
} else {
buffer = data;
}
await characteristic.writeValue(buffer);
}
async subscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, callback): Promise<void> {
const characteristic = this.characteristics[deviceId]?.[serviceId]?.[characteristicId];
if (!characteristic) throw new Error('特征值未找到');
if (!characteristic.properties.notify && !characteristic.properties.indicate) {
throw new Error('特征值不支持通知');
}
if (!this.characteristicCallbacks[deviceId]) this.characteristicCallbacks[deviceId] = {};
if (!this.characteristicCallbacks[deviceId][serviceId]) this.characteristicCallbacks[deviceId][serviceId] = {};
this.characteristicCallbacks[deviceId][serviceId][characteristicId] = callback;
await characteristic.startNotifications();
const listener = (event) => {
const value = event.target.value;
const data = new Uint8Array(value.buffer);
const cb = this.characteristicCallbacks[deviceId][serviceId][characteristicId];
if (cb) {
cb({ deviceId, serviceId, characteristicId, data });
}
};
// store listener so it can be removed later
if (!this.characteristicListeners[deviceId]) this.characteristicListeners[deviceId] = {};
if (!this.characteristicListeners[deviceId][serviceId]) this.characteristicListeners[deviceId][serviceId] = {};
this.characteristicListeners[deviceId][serviceId][characteristicId] = { characteristic, listener };
characteristic.addEventListener('characteristicvaluechanged', listener);
}
async unsubscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<void> {
const entry = this.characteristicListeners[deviceId]?.[serviceId]?.[characteristicId];
if (!entry) return;
try {
const { characteristic, listener } = entry;
characteristic.removeEventListener('characteristicvaluechanged', listener);
await characteristic.stopNotifications();
} catch (e) {
// ignore
}
// cleanup
delete this.characteristicListeners[deviceId][serviceId][characteristicId];
delete this.characteristicCallbacks[deviceId][serviceId][characteristicId];
}
// Read a characteristic value and return ArrayBuffer
async readCharacteristic(deviceId: string, serviceId: string, characteristicId: string): Promise<ArrayBuffer> {
const characteristic = this.characteristics[deviceId]?.[serviceId]?.[characteristicId];
if (!characteristic) throw new Error('特征值未找到');
// Web Bluetooth returns a DataView from readValue()
const value = await characteristic.readValue();
if (!value) return new ArrayBuffer(0);
// DataView.buffer is a shared ArrayBuffer; return a copy slice to be safe
try {
return value.buffer ? value.buffer.slice(0) : new Uint8Array(value).buffer;
} catch (e) {
// fallback
const arr = new Uint8Array(value);
return arr.buffer;
}
}
}

View File

View File

@@ -0,0 +1,92 @@
{
"id": "ak-sqlite",
"displayName": "适用于UNIAPP-X的SQLite API",
"version": "1.0.0",
"description": "createSQLiteContext",
"keywords": [
"sqlite",
"uni-ext-api",
"uniapp-x",
"uts"
],
"repository": "",
"engines": {
"HBuilderX": "^4.19"
},
"dcloudext": {
"category": [
"UTS插件",
"API插件"
],
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "none",
"permissions": "none"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"uni-ext-api": {
"uni": {
"createSQLiteContext": {
"name": "createSQLiteContext",
"app": {
"js": false,
"kotlin": true,
"swift": false
}
}
}
},
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "u",
"app-ios": "u"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "n",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "n",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
},
"name": "适用于UNIAPP-X的SQLite API"
}

View File

@@ -0,0 +1,56 @@
# uni-createSQLiteContext
### 开发文档
[UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
[UTS API插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
[UTS 组件插件](https://uniapp.dcloud.net.cn/plugin/uts-component.html)
[Hello UTS](https://gitcode.net/dcloud/hello-uts)
### 注意事项
本插件本质上是一个uni ext api所以直接使用即可无需显式调用import导入。
### 使用方法
```javascript
//创建查询的上下文
const sqliteContext = uni.createSQLiteContext({
name: 'test.db',
});
//执行查询
sqliteContext.selectSql({
sql: 'select * from test',
success: function(res) {
console.log(res);
},
fail: function(err) {
console.log(err);
}
})
//执行事务
sqliteContext.transaction({
operation:"begin", //begin,commit,rollback
success: function(res) {
console.log(res);
},
fail: function(err) {
console.log(err);
}
})
//执行增删改
sqliteContext.executeSql({
sql: 'insert into test values(1, "test")',
success: function(res) {
console.log(res);
},
fail: function(err) {
console.log(err);
}
})
//关闭数据库
sqliteContext.close()
```

View File

@@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@@ -0,0 +1,178 @@
import Cursor from 'android.database.Cursor';
import SQLiteDatabase from 'android.database.sqlite.SQLiteDatabase';
import SQLiteOpenHelper from 'android.database.sqlite.SQLiteOpenHelper';
import { createSQLiteContextOptions, executeSqlOptions, selectSqlOptions, executeSqlOptionsResult, selectSqlOptionsResult, transactionOptions,transactionResult } from '../interface.uts';
class SQLiteContext extends SQLiteOpenHelper {
private databaseName: string | null;
constructor(name: string) {
super(UTSAndroid.getAppContext()!, name, null, 1);
this.databaseName = name;
}
public executeSql(options: executeSqlOptions): void {
const database: SQLiteDatabase = this.getReadableDatabase();
const SqlArray = options.sql.split(';');
let result: executeSqlOptionsResult = {
data: [] as boolean[],
errMsg: 'executeSql:ok',
errCode: 0,
errSubject: '',
cause: null
}
try {
for (let i = 0; i < SqlArray.length; i++) {
if (SqlArray[i].length > 0) {
const sql = SqlArray[i].replace(/^\s+/, '');
try {
database.execSQL(sql);
result.data.push(true);
} catch (err) {
console.error('database.execSQL 出错:', err, 'SQL:', sql);
result.data.push(false);
// 立即调用 fail 并返回
result.errMsg = 'executeSql:fail';
result.errCode = 1000002;
result.cause = err;
options.fail?.(result);
options.complete?.(result);
return;
}
}
}
options.success?.(result);
} catch (e) {
console.error('executeSql 外层出错:', e);
const data = result.data;
result = {
errCode: 1000002,
errMsg: 'executeSql:fail',
errSubject: '',
cause: e,
data: data
};
options.fail?.(result);
}
options.complete?.(result);
}
public selectSql(options: selectSqlOptions): void {
const database: SQLiteDatabase = this.getReadableDatabase();
const SqlArray = options.sql.split(';');
let result: selectSqlOptionsResult = {
data: [] as string[][],
errMsg: 'selectSql:ok',
errCode: 0,
errSubject: '',
cause: null
}
try {
for (let i = 0; i < SqlArray.length; i++) {
if (SqlArray[i].length > 0) {
const sql = SqlArray[i].replace(/^\s+/, '');
try {
const cursor: Cursor = database.rawQuery(sql, null);
//获取查询结果的字符串并push到result.data中
if (cursor.moveToFirst()) {
do {
const row = cursor.getColumnCount();
const rowArray = [] as string[];
for (let j = 0; j < row; j++) {
rowArray.push(cursor.getString(j.toInt()));
}
result.data.push(rowArray);
} while (cursor.moveToNext());
}
cursor.close();
} catch {
result.data.push([] as string[]);
}
}
}
options.success?.(result);
} catch (e) {
const data = result.data;
result = {
errCode: 1000003,
errMsg: 'selectSql:fail',
errSubject: '',
cause: e,
data: data
};
options.fail?.(result);
}
options.complete?.(result);
}
public transaction(options: transactionOptions): void {
const database: SQLiteDatabase = this.getReadableDatabase();
const transaction = options.transaction;
let result: transactionResult = {
errMsg: 'transaction:ok',
errCode: 0,
errSubject: '',
cause: null
};
try {
if (transaction == 'begin') {
database.execSQL('BEGIN TRANSACTION');
} else if (transaction == 'commit') {
database.execSQL('COMMIT');
} else if (transaction == 'rollback') {
database.execSQL('ROLLBACK');
}
options.success?.(result);
} catch (e) {
let errCode = 1000008;
if (transaction == 'begin') {
errCode = 1000004;
} else if (transaction == 'commit') {
errCode = 1000005;
} else if (transaction == 'rollback') {
errCode = 1000006;
}
result = {
errCode: errCode,
errMsg: 'transaction:fail',
errSubject: '',
cause: e
};
options.fail?.(result);
}
options.complete?.(result);
}
public override onCreate(db: SQLiteDatabase): void {
// 可选:初始化表结构
}
public override onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): void {
// 可选:数据库升级逻辑
}
}
export const createSQLiteContext = function (options: createSQLiteContextOptions) {
const name = options.name + '.db';
return new SQLiteContext(name); // 必须返回对象
}
export type executeSqlOptionsResultType = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
date?: boolean[];
};
export type selectSqlOptionsResultType = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
date?: string[];
};

View File

@@ -0,0 +1,13 @@
{
"deploymentTarget": "9.0",
"dependencies-pods": [
{
"name": "FMDB",
"version": "2.7.8",
"repo": {
"git": "https://github.com/ccgus/fmdb.git",
"tag": "2.7.8"
}
}
]
}

View File

@@ -0,0 +1,116 @@
import { FMDatabase } from 'FMDB';
import { createSQLiteContextOptions, executeSqlOptions, selectSqlOptions, executeSqlOptionsResult, selectSqlOptionsResult, CreateSQLiteContext, transactionOptions } from '../interface.uts';
import { createSQLiteContextFailImpl } from '../unierror.uts';
class SQLiteContext extends FMDatabase {
private databaseName: string | null;
constructor(name: string) {
let version = 1;
const path = UTSiOS.getDataPath() + '/sqlite/' + name;
super(path);
this.databaseName = name;
}
public executeSql(options: executeSqlOptions) {
const SqlArray = options.sql.split(';');
let result: executeSqlOptionsResult = {
data: [] as boolean[],
errMsg: 'executeSql:ok',
}
try {
for (let i = 0; i < SqlArray.length; i++) {
if (SqlArray[i].length > 0) {
const sql = SqlArray[i].replace(/^\s+/, '');
try {
this.executeQuery(sql);
result.data.push(true);
} catch {
result.data.push(false);
}
}
}
options.success?.(result);
} catch (e) {
const data = result.data;
result = new createSQLiteContextFailImpl(1000002);
result.data = data;
options.fail?.(result);
}
options.complete?.(result);
return result;
}
public selectSql(options: selectSqlOptions) {
const SqlArray = options.sql.split(';');
let result: selectSqlOptionsResult = {
data: [] as boolean[],
errMsg: 'selectSql:ok',
}
try {
for (let i = 0; i < SqlArray.length; i++) {
if (SqlArray[i].length > 0) {
const sql = SqlArray[i].replace(/^\s+/, '');
try {
const cursor = this.executeQueryWithFormat(sql);
//获取查询结果的字符串并push到result.data中
while (cursor.next()) {
const row = cursor.getRow();
result.data.push(row);
}
cursor.close();
} catch {
result.data.push("");
}
}
}
options.success?.(result);
} catch (e) {
const data = result.data;
result = new createSQLiteContextFailImpl(1000003);
result.data = data;
options.fail?.(result);
}
options.complete?.(result);
return result;
}
public transaction(options: transactionOptions) {
const transaction = options.transaction;
let result: executeSqlOptionsResult = {
errMsg: 'transaction:ok',
}
try {
if (transaction == 'begin') {
//开启事务
this.beginTransaction();
} else if (transaction == 'commit') {
//提交事务
this.commit();
} else if (transaction == 'rollback') {
//回滚事务
this.rollback();
}
options.success?.(result);
} catch (e) {
let errCode = 1000008;
if (transaction == 'begin') {
errCode = 1000004;
} else if (transaction == 'commit') {
errCode = 1000005;
} else if (transaction == 'rollback') {
errCode = 1000006;
}
result = new createSQLiteContextFailImpl(errCode);
options.fail?.(result);
}
options.complete?.(result);
return result;
}
}
export const createSQLiteContext: CreateSQLiteContext = function (options: createSQLiteContextOptions) {
const name = options.name + '.db';
return new SQLiteContext(name);
}

View File

@@ -0,0 +1,153 @@
/**
* 初始化数据库时的相关配置
* @param name 数据库名称
*/
export type createSQLiteContextOptions = {
/**
* 数据库名称
*/
name: string,
}
/**
* 执行增删改等操作的SQL语句的相关配置
* @param sql SQL语句
* @param success 成功回调
* @param fail 失败回调
* @param complete 完成回调
*/
export type executeSqlOptions = {
/**
* SQL语句
*/
sql: string,
/**
* 执行增删改等操作的SQL语句的成功回调
*/
success?: executeSqlOptionsSuccessCallback | null,
/**
* 执行增删改等操作的SQL语句的失败回调
*/
fail?: executeSqlOptionsFailCallback | null,
/**
* 执行增删改等操作的SQL语句的完成回调
*/
complete?: executeSqlOptionsCompleteCallback | null,
}
/**
* 执行增删改等操作的SQL语句的成功回调
*/
export type executeSqlOptionsSuccessCallback = (res: executeSqlOptionsResult) => void
/**
* 执行增删改等操作的SQL语句的失败回调
*/
export type executeSqlOptionsFailCallback = (res: executeSqlOptionsResult) => void
/**
* 执行增删改等操作的SQL语句的完成回调
*/
export type executeSqlOptionsCompleteCallback = (res: executeSqlOptionsResult) => void
export type transactionResult = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
};
export type ICreateSQLiteContextError = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
};
export type executeSqlOptionsResult = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
data: boolean[];
}
/**
* 执行查询操作的SQL语句的相关配置
*/
export type selectSqlOptions = {
/**
* SQL语句
*/
sql: string,
/**
* 执行查询操作的SQL语句的成功回调
*/
success?: selectSqlOptionsSuccessCallback | null,
/**
* 执行查询操作的SQL语句的失败回调
*/
fail?: selectSqlOptionsFailCallback | null,
/**
* 执行查询操作的SQL语句的完成回调
*/
complete?: selectSqlOptionsCompleteCallback | null,
}
/**
* 执行查询操作的SQL语句的成功回调
*/
export type selectSqlOptionsSuccessCallback = (res: selectSqlOptionsResult) => void
/**
* 执行查询操作的SQL语句的失败回调
*/
export type selectSqlOptionsFailCallback = (res: selectSqlOptionsResult) => void
/**
* 执行查询操作的SQL语句的完成回调
*/
export type selectSqlOptionsCompleteCallback = (res: selectSqlOptionsResult) => void
export type selectSqlOptionsResult = {
errCode: number;
errSubject?: string;
cause?: any;
errMsg: string;
data: string[][];
}
export type transactionOptions = {
transaction: transactionOperation;
success?: (res: transactionResult) => void;
fail?: (res: transactionResult) => void;
complete?: (res: transactionResult) => void;
}
/**
* 事务操作类型
* @param begin 开始事务
* @param commit 提交事务
* @param rollback 回滚事务
*/
export type transactionOperation = 'begin' | 'commit' | 'rollback'
/**
* 事务执行的成功回调
*/
export type transactionSuccessCallback = (res: transactionResult) => void
/**
* 事务执行的失败回调
*/
export type transactionFailCallback = (res: transactionResult) => void
/**
* 事务执行的完成回调
*/
export type transactionCompleteCallback = (res: transactionResult) => void

View File

@@ -0,0 +1,65 @@
import { ICreateSQLiteContextError } from "./interface.uts"
/**
* 错误主题
*/
export const UniErrorSubject = 'uni-create-sql-context';
/**
* 错误码
* @UniError
*/
export const UniErrors: Map<number, string> = new Map([
/**
* 数据库启动失败
*/
[1000001, 'Database startup failed'],
/**
* 执行SQL增删改语句失败
*/
[1000002, 'Failed to execute SQL insert, update, delete statement'],
/**
* 执行SQL查询语句失败
*/
[1000003, 'Failed to execute SQL query statement'],
/**
* 事务开始失败
*/
[1000004, 'Transaction start failed'],
/**
* 事务提交失败
*/
[1000005, 'Transaction commit failed'],
/**
* 事务回滚失败
*/
[1000006, 'Transaction rollback failed'],
/**
* 数据库关闭失败
*/
[1000007, 'Database shutdown failed'],
/**
* 未知错误
*/
[1000008, 'Unknown error'],
]);
export class createSQLiteContextFailImpl extends UniError {
override errCode: number;
override errSubject: string;
override cause?: UTSError | null;
override errMsg: string;
constructor(
errCode: number,
errMsg: string = '',
errSubject: string = '',
cause?: UTSError | null
) {
super();
this.errCode = errCode;
this.errMsg = errMsg;
this.errSubject = errSubject;
this.cause = cause ?? null;
}
}

View File

@@ -0,0 +1,14 @@
## 1.3.12023-09-15
app端适配使用UniError
## 1.3.02023-05-30
新增 同步获取电量api
## 1.2.02022-10-17
实现百度小程序/支付宝小程序/QQ小程序获取电量
## 1.1.02022-10-17
实现ios平台获取电量
## 1.0.02022-09-01
实现android/web/微信小程序平台获取电量

View File

@@ -0,0 +1,93 @@
{
"id": "uni-getbatteryinfo",
"displayName": "uni-getbatteryinfo",
"version": "1.3.1",
"description": "使用uts开发实现在多个平台获取电池电量功能",
"keywords": [
"battery"
],
"repository": "",
"engines": {
"HBuilderX": "^3.9.0"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"uni-ext-api": {
"uni": {
"getBatteryInfo": "getBatteryInfo",
"getBatteryInfoSync": {
"web": false
}
}
},
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "n",
"vue3": "y"
},
"App": {
"app-android": {
"minVersion": "21"
},
"app-ios": {
"minVersion": "9"
}
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "u",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
# uni-getbatteryinfo
## 使用文档
```ts
// 获取电量信息
uni.getBatteryInfo({
success(res) {
console.log(res);
uni.showToast({
title: "当前电量:" + res.level + '%',
icon: 'none'
});
}
})
```
### 参数
Object object
|属性|类型|必填|说明|
|----|---|----|----|
|success|function|否|接口调用成功的回调函数|
|fail|function|否|接口调用失败的回调函数|
|complete|function|否|接口调用结束的回调函数(调用成功、失败都会执行)|
object.success 回调函数
|属性|类型|说明|
|----|---|----|
|level|number|设备电量,范围 1 - 100|
|isCharging|boolean|是否正在充电中|

View File

@@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@@ -0,0 +1,76 @@
import Context from "android.content.Context";
import BatteryManager from "android.os.BatteryManager";
import { GetBatteryInfo, GetBatteryInfoOptions, GetBatteryInfoSuccess, GetBatteryInfoResult, GetBatteryInfoSync } from '../interface.uts'
import IntentFilter from 'android.content.IntentFilter';
import Intent from 'android.content.Intent';
import { GetBatteryInfoFailImpl } from '../unierror';
/**
* 异步获取电量
*/
export const getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInfoOptions) {
const context = UTSAndroid.getAppContext();
if (context != null) {
const manager = context.getSystemService(
Context.BATTERY_SERVICE
) as BatteryManager;
const level = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
);
let ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
let batteryStatus = context.registerReceiver(null, ifilter);
let status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
let isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;
const res : GetBatteryInfoSuccess = {
errMsg: 'getBatteryInfo:ok',
level,
isCharging: isCharging
}
options.success?.(res)
options.complete?.(res)
} else {
let res = new GetBatteryInfoFailImpl(1001);
options.fail?.(res)
options.complete?.(res)
}
}
/**
* 同步获取电量
*/
export const getBatteryInfoSync : GetBatteryInfoSync = function () : GetBatteryInfoResult {
const context = UTSAndroid.getAppContext();
if (context != null) {
const manager = context.getSystemService(
Context.BATTERY_SERVICE
) as BatteryManager;
const level = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
);
let ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
let batteryStatus = context.registerReceiver(null, ifilter);
let status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
let isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;
const res : GetBatteryInfoResult = {
level: level,
isCharging: isCharging
};
return res;
}
else {
/**
* 无有效上下文
*/
const res : GetBatteryInfoResult = {
level: -1,
isCharging: false
};
return res;
}
}

View File

@@ -0,0 +1,3 @@
{
"deploymentTarget": "9"
}

View File

@@ -0,0 +1,36 @@
// 引用 iOS 原生平台 api
import { UIDevice } from "UIKit";
import { GetBatteryInfo, GetBatteryInfoSuccess, GetBatteryInfoResult, GetBatteryInfoSync } from '../interface.uts';
/**
* 异步获取电量
*/
export const getBatteryInfo : GetBatteryInfo = function (options) {
// 开启电量检测
UIDevice.current.isBatteryMonitoringEnabled = true
// 返回数据
const res : GetBatteryInfoSuccess = {
errMsg: "getBatteryInfo:ok",
level: Math.abs(Number(UIDevice.current.batteryLevel * 100)),
isCharging: UIDevice.current.batteryState == UIDevice.BatteryState.charging,
};
options.success?.(res);
options.complete?.(res);
}
/**
* 同步获取电量
*/
export const getBatteryInfoSync : GetBatteryInfoSync = function () : GetBatteryInfoResult {
// 开启电量检测
UIDevice.current.isBatteryMonitoringEnabled = true
// 返回数据
const res : GetBatteryInfoResult = {
level: Math.abs(Number(UIDevice.current.batteryLevel * 100)),
isCharging: UIDevice.current.batteryState == UIDevice.BatteryState.charging,
};
return res;
}

View File

@@ -0,0 +1,43 @@
declare namespace UniNamespace {
interface GetBatteryInfoSuccessCallbackResult {
/**
* 是否正在充电中
*/
isCharging: boolean;
/**
* 设备电量,范围 1 - 100
*/
level: number;
errMsg: string;
}
interface GetBatteryInfoOption {
/**
* 接口调用结束的回调函数(调用成功、失败都会执行)
*/
complete?: Function
/**
* 接口调用失败的回调函数
*/
fail?: Function
/**
* 接口调用成功的回调函数
*/
success?: (result: GetBatteryInfoSuccessCallbackResult) => void
}
}
declare interface Uni {
/**
* 获取设备电量
*
* @tutorial https://uniapp.dcloud.net.cn/api/system/batteryInfo.html
*/
getBatteryInfo(option?: UniNamespace.GetBatteryInfoOption): void;
/**
* 同步获取电池电量信息
* @tutorial https://uniapp.dcloud.net.cn/api/system/batteryInfo.html
*/
getBatteryInfoSync(): UniNamespace.GetBatteryInfoSuccessCallbackResult;
}

View File

@@ -0,0 +1,138 @@
export type GetBatteryInfoSuccess = {
errMsg : string,
/**
* 设备电量范围1 - 100
*/
level : number,
/**
* 是否正在充电中
*/
isCharging : boolean
}
export type GetBatteryInfoOptions = {
/**
* 接口调用结束的回调函数(调用成功、失败都会执行)
*/
success ?: (res : GetBatteryInfoSuccess) => void
/**
* 接口调用失败的回调函数
*/
fail ?: (res : UniError) => void
/**
* 接口调用成功的回调
*/
complete ?: (res : any) => void
}
export type GetBatteryInfoResult = {
/**
* 设备电量范围1 - 100
*/
level : number,
/**
* 是否正在充电中
*/
isCharging : boolean
}
/**
* 错误码
* - 1001 getAppContext is null
*/
export type GetBatteryInfoErrorCode = 1001 ;
/**
* GetBatteryInfo 的错误回调参数
*/
export interface GetBatteryInfoFail extends IUniError {
errCode : GetBatteryInfoErrorCode
};
/**
* 获取电量信息
* @param {GetBatteryInfoOptions} options
*
*
* @tutorial https://uniapp.dcloud.net.cn/api/system/batteryInfo.html
* @platforms APP-IOS = ^9.0,APP-ANDROID = ^22
* @since 3.6.11
*
* @assert () => success({errCode: 0, errSubject: "uni-getBatteryInfo", errMsg: "getBatteryInfo:ok", level: 60, isCharging: false })
* @assert () => fail({errCode: 1001, errSubject: "uni-getBatteryInfo", errMsg: "getBatteryInfo:fail getAppContext is null" })
*/
export type GetBatteryInfo = (options : GetBatteryInfoOptions) => void
export type GetBatteryInfoSync = () => GetBatteryInfoResult
interface Uni {
/**
* 获取电池电量信息
* @description 获取电池电量信息
* @param {GetBatteryInfoOptions} options
* @example
* ```typescript
* uni.getBatteryInfo({
* success(res) {
* console.log(res);
* }
* })
* ```
* @remark
* - 该接口需要同步调用
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.6.11",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "12.0",
* "uniVer": "3.6.11",
* "unixVer": "4.11"
* }
* },
* "web": {
* "uniVer": "3.6.11",
* "unixVer": "4.0"
* }
* }
* @uniVueVersion 2,3 //支持的vue版本
*
*/
getBatteryInfo (options : GetBatteryInfoOptions) : void,
/**
* 同步获取电池电量信息
* @description 获取电池电量信息
* @example
* ```typescript
* uni.getBatteryInfo()
* ```
* @remark
* - 该接口需要同步调用
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.6.11",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "12.0",
* "uniVer": "3.6.11",
* "unixVer": "4.11"
* }
* },
* "web": {
* "uniVer": "3.6.11",
* "unixVer": "4.0"
* }
* }
* @uniVueVersion 2,3 //支持的vue版本
*
*/
getBatteryInfoSync():GetBatteryInfoResult
}

View File

@@ -0,0 +1,6 @@
export function getBatteryInfo(options) {
return my.getBatteryInfo(options)
}
export function getBatteryInfoSync(options) {
return my.getBatteryInfoSync(options)
}

View File

@@ -0,0 +1,6 @@
export function getBatteryInfo(options) {
return swan.getBatteryInfo(options)
}
export function getBatteryInfoSync(options) {
return swan.getBatteryInfoSync(options)
}

View File

@@ -0,0 +1,6 @@
export function getBatteryInfo(options) {
return qq.getBatteryInfo(options)
}
export function getBatteryInfoSync(options) {
return qq.getBatteryInfoSync(options)
}

View File

@@ -0,0 +1,6 @@
export function getBatteryInfo(options) {
return wx.getBatteryInfo(options)
}
export function getBatteryInfoSync(options) {
return wx.getBatteryInfoSync(options)
}

View File

@@ -0,0 +1,34 @@
import { GetBatteryInfoErrorCode, GetBatteryInfoFail } from "./interface.uts"
/**
* 错误主题
*/
export const UniErrorSubject = 'uni-getBatteryInfo';
/**
* 错误信息
* @UniError
*/
export const UniErrors : Map<GetBatteryInfoErrorCode, string> = new Map([
/**
* 错误码及对应的错误信息
*/
[1001, 'getBatteryInfo:fail getAppContext is null'],
]);
/**
* 错误对象实现
*/
export class GetBatteryInfoFailImpl extends UniError implements GetBatteryInfoFail {
/**
* 错误对象构造函数
*/
constructor(errCode : GetBatteryInfoErrorCode) {
super();
this.errSubject = UniErrorSubject;
this.errCode = errCode;
this.errMsg = UniErrors[errCode] ?? "";
}
}

View File

@@ -0,0 +1,20 @@
import { GetBatteryInfo, GetBatteryInfoOptions, GetBatteryInfoSuccess } from '../interface.uts'
export const getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInfoOptions) {
if (navigator.getBattery) {
navigator.getBattery().then(battery => {
const res = {
errCode: 0,
errSubject: "uni-getBatteryInfo",
errMsg: 'getBatteryInfo:ok',
level: battery.level * 100,
isCharging: battery.charging
} as GetBatteryInfoSuccess
options.success && options.success(res)
options.complete && options.complete(res)
})
} else {
const res = new UniError("uni-getBatteryInfo", 1002, "getBatteryInfo:fail navigator.getBattery is unsupported")
options.fail && options.fail(res)
options.complete && options.complete(res)
}
}

Binary file not shown.

32
unpackage/aa.txt Normal file
View File

@@ -0,0 +1,32 @@
20:00:59.350 [sendCommandWithResponse] 发送命令: [io.dcloud.uts.Uint8Array] {BYTES_PER_ELEMENT: 1} cmdId: [number] 89 at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:111
20:00:59.362 05-08 20:00:58.378 17863 17863 E UniDomManager: batch--start-----midd---5
20:00:59.362 05-08 20:00:58.379 17863 17863 E UniDomManager: layoutUpdateRecursive--耗时1
20:00:59.362 05-08 20:00:58.379 17863 17863 E UniDomManager: batch----time=6
20:00:59.374 05-08 20:00:58.386 17863 17863 E UniDomManager: flushPendingBatches--tiem=7 task size=5
20:00:59.374 05-08 20:00:58.393 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"ak check entry::"},{"type":"number","subType":"number","value":"89"},{"className":"uni.UNI95B2570.ResponseCallbackEntry","type":"object","subType":"object","__$originalPosition":{"name":"ResponseCallbackEntry","file":"uni_modules/ak-sbsrv/utssdk/interface.uts","column":13,"line":293},"value":{"properties":[{"type":"function","name":"cb","parameter":["io.dcloud.uts.Uint8Array"],"returned":"kotlin.Boolean"},{"type":"boolean","name":"multi","value":"true"}],"methods":[]}},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:114"}]---END:CONSOLE---
20:00:59.382 ak check entry:: [number] 89 [ResponseCallbackEntry] {cb: function (io.dcloud.uts.Uint8Array) : kotlin.Boolean { [native code] }, multi: true} at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:114
20:00:59.382 05-08 20:00:58.395 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"className":"io.dcloud.uts.Uint8Array","type":"object","subType":"object","value":{"properties":[{"type":"number","subType":"number","name":"BYTES_PER_ELEMENT","value":"1"}],"methods":[]}},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:119"}]---END:CONSOLE---
20:00:59.382 [io.dcloud.uts.Uint8Array] {BYTES_PER_ELEMENT: 1} at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:119
20:00:59.382 [writeCharacteristic] deviceId: B0:03:12:00:09:B8 serviceId: bae80001-4f05-4503-8e65-3af1f7329d1f characteristicId: bae80010-4f05-4503-8e65-3af1f7329d1f data: [io.dcloud.uts.Uint8Array] {BYTES_PER_ELEMENT: 1} at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:298
20:00:59.393 [writeCharacteristic] setValue [kotlin.ByteArray] [ 0, 89, 50, 0, 30, ... ] at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:335
20:00:59.401 [writeCharacteristic] writeCharacteristic result: [boolean] true at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:338
20:00:59.401 writeCharacteristic ok at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:347
20:00:59.401 05-08 20:00:58.405 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"[writeCharacteristic] deviceId:"},{"type":"string","value":"B0:03:12:00:09:B8"},{"type":"string","value":"serviceId:"},{"type":"string","value":"bae80001-4f05-4503-8e65-3af1f7329d1f"},{"type":"string","value":"characteristicId:"},{"type":"string","value":"bae80010-4f05-4503-8e65-3af1f7329d1f"},{"type":"string","value":"data:"},{"className":"io.dcloud.uts.Uint8Array","type":"object","subType":"object","value":{"properties":[{"type":"number","subType":"number","name":"BYTES_PER_ELEMENT","value":"1"}],"methods":[]}},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:298"}]---END:CONSOLE---
20:00:59.401 05-08 20:00:58.406 17863 19385 E libc : MUNMTEST11 pthread_join thread t=491651396848 thread=0x7278b4f4f0 tid = 0 addr=0x7278a52000 size=1040384
20:00:59.401 05-08 20:00:58.406 17863 19385 E libc : MUNMTEST11 __pthread_internal_free thread thread=0x7278b4f4f0 tid = 0 addr=0x7278a52000 size=1040384 join_state=2
20:00:59.401 05-08 20:00:58.406 17863 19385 W bionic-munmap: addr = 0x7278a52000, size = 0x1040384
20:00:59.405 05-08 20:00:58.406 17863 19385 E libc : pthread_create thread thread=0x7278b4f4f0 tid = 19532 addr=0x7278a52000 size=1040384
20:00:59.405 05-08 20:00:58.408 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"[writeCharacteristic] setValue"},{"className":"kotlin.ByteArray","type":"object","subType":"array","value":{"properties":[{"type":"number","subType":"number","value":"0","name":0},{"type":"number","subType":"number","value":"89","name":1},{"type":"number","subType":"number","value":"50","name":2},{"type":"number","subType":"number","value":"0","name":3},{"type":"number","subType":"number","value":"30","name":4},{"type":"number","subType":"number","value":"50","name":5},{"type":"number","subType":"number","value":"1","name":6},{"type":"number","subType":"number","value":"1","name":7}]}},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:335"}]---END:CONSOLE---
20:00:59.405 05-08 20:00:58.409 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"[writeCharacteristic] writeCharacteristic result:"},{"type":"boolean","value":"true"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:338"}]---END:CONSOLE---
20:00:59.405 05-08 20:00:58.411 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"writeCharacteristic ok"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:347"}]---END:CONSOLE---
20:00:59.405 05-08 20:00:58.412 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"[sendCommandWithResponse] 写入成功"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:126"}]---END:CONSOLE---
20:00:59.405 [sendCommandWithResponse] 写入成功 at uni_modules/ak-sbsrv/utssdk/protocol-handler.uts:126
20:01:05.086 05-08 20:01:04.091 17863 19377 D BluetoothGatt: onClientConnectionState() - status=8 clientIf=7 device=B0:03:12:00:09:B8
20:01:05.086 05-08 20:01:04.093 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"设备已断开: B0:03:12:00:09:B8"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:126"}]---END:CONSOLE---
20:01:05.090 设备已断开: B0:03:12:00:09:B8 at uni_modules/ak-sbsrv/utssdk/app-android/service_manager.uts:126
20:01:05.090 05-08 20:01:04.094 17863 19473 I console : [LOG]---BEGIN:CONSOLE---[{"type":"string","value":"[AKBLE] handleConnectionStateChange:"},{"type":"string","value":"B0:03:12:00:09:B8"},{"type":"string","value":"newState:"},{"type":"number","subType":"number","value":"0"},{"type":"string","value":"error:"},{"type":"null","value":"null"},{"type":"string","value":"pendingConnects:"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/device_manager.uts:196"}]---END:CONSOLE---
20:01:05.090 [AKBLE] handleConnectionStateChange: B0:03:12:00:09:B8 newState: [number] 0 error: null pendingConnects: at uni_modules/ak-sbsrv/utssdk/app-android/device_manager.uts:196
20:01:05.090 05-08 20:01:04.095 17863 19473 I console : [WARN]---BEGIN:CONSOLE---[{"type":"string","value":"[AKBLE] handleConnectionStateChange: 未找到 pendingConnects"},{"type":"string","value":"B0:03:12:00:09:B8"},{"type":"number","subType":"number","value":"0"},{"type":"string","value":" at uni_modules/ak-sbsrv/utssdk/app-android/device_manager.uts:218"}]---END:CONSOLE---
20:01:05.094 [AKBLE] handleConnectionStateChange: 未找到 pendingConnects B0:03:12:00:09:B8 [number] 0 at uni_modules/ak-sbsrv/utssdk/app-android/device_manager.uts:218
20:01:09.391 05-08 20:01:08.408 17863 19532 E libc : MUNMTEST pthread_exit thread tid = 19532 sigaddress=0x7276e70000 size=5000
20:01:09.391 05-08 20:01:08.408 17863 19532 E libc : MUNMTEST __pthread_unmap_tls thread tid = 19532 allocation addr=0x7279b8a000 size=5000

Some files were not shown because too many files have changed in this diff Show More