- BluetoothManager: per-characteristic isolated channels (per-UUID read queue/timeout/dispatch, no cross-char write blocking), 10007 delayed retry for read/write, 300ms link settle after discovery, full tx/rx hex logging - McmCli/McmInfo/McmOta: command/response, TLV parse and OTA frame logs - home.vue: Auto refresh switch (polling off by default, no GATT interference with OTA), MTU-then-load sequencing - ota.vue: SAF file picker via plus.android (ACTION_GET_CONTENT) with Scanner whole-stream read + magic self-check + per-byte fallback; default path moved to _doc (Android 10+ scoped storage) - docs: change logs for 2026-09-04 protocol adaptation and 2026-09-05 field debugging; AGENTS.md updated
576 lines
14 KiB
Vue
576 lines
14 KiB
Vue
<template>
|
||
<!-- #ifdef APP -->
|
||
<scroll-view style="flex:1">
|
||
<!-- #endif -->
|
||
<view class="page">
|
||
|
||
<!-- 连接状态卡片 -->
|
||
<view class="card">
|
||
<view class="statusRow">
|
||
<view class="statusDot" :class="connected ? 'dotOn' : 'dotOff'"></view>
|
||
<text class="statusText">{{ connected ? 'Connected' : (connecting ? 'Connecting...' : 'Disconnected') }}</text>
|
||
<text class="deviceText" v-if="connected">{{ deviceName }} ({{ deviceMac }})</text>
|
||
</view>
|
||
<view class="btnRow">
|
||
<button class="btn" size="mini" v-if="!connected && !connecting" @tap="onBleStarScan">{{ scanning ? 'Scanning...' : 'Scan' }}</button>
|
||
<button class="btn btnWarn" size="mini" v-if="connected" @tap="onDisconnect">Disconnect</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 参数显示 -->
|
||
<view class="card paramCard" v-if="connected">
|
||
<view class="paramItem">
|
||
<text class="paramLabel">Auto refresh (2s)</text>
|
||
<switch :checked="autoRefresh" @change="onAutoRefreshChange" style="transform:scale(0.7)" />
|
||
</view>
|
||
<view class="paramItem">
|
||
<text class="paramLabel">Temperature</text>
|
||
<text class="paramValue">{{ temperature }}<text class="paramUnit"> ℃</text></text>
|
||
</view>
|
||
<view class="paramItem">
|
||
<text class="paramLabel">Voltage</text>
|
||
<text class="paramValue">{{ voltage }}<text class="paramUnit"> mV</text></text>
|
||
</view>
|
||
<view class="paramItem">
|
||
<text class="paramLabel">Fan Speed</text>
|
||
<text class="paramValue">{{ fanSpeed }}<text class="paramUnit"> RPM</text></text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 设备信息(devinfo:版本/bank/运行时长/堆剩余) -->
|
||
<view class="card" v-if="connected">
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Firmware</text>
|
||
<text class="infoValue">{{ fwVersion }}</text>
|
||
</view>
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Bank</text>
|
||
<text class="infoValue">{{ bankInfo }}</text>
|
||
</view>
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Uptime</text>
|
||
<text class="infoValue">{{ uptime }}</text>
|
||
</view>
|
||
<view class="infoRow">
|
||
<text class="infoLabel">Free Heap</text>
|
||
<text class="infoValue">{{ freeHeap }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 设备参数(APP_DATA:appget/appset) -->
|
||
<view class="card" v-if="connected">
|
||
<text class="consoleTitle">Params (APP_DATA{{ paramValid ? '' : ', defaults not saved' }})</text>
|
||
<view class="paramSetRow">
|
||
<text class="infoLabel">LED blink (ms)</text>
|
||
<input class="paramInput" type="number" v-model="paramLed" :disabled="paramBusy" />
|
||
<button class="btn" size="mini" :disabled="paramBusy" @tap="onSetParam('led')">Set</button>
|
||
</view>
|
||
<view class="paramSetRow">
|
||
<text class="infoLabel">PWM duty (%)</text>
|
||
<input class="paramInput" type="number" v-model="paramPwm" :disabled="paramBusy" />
|
||
<button class="btn" size="mini" :disabled="paramBusy" @tap="onSetParam('pwm')">Set</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- sysinfo 控制台输出(原 System Status 页内容) -->
|
||
<view class="card" v-if="connected">
|
||
<text class="consoleTitle">sysinfo (system runtime status)</text>
|
||
<text class="consoleText">{{ sysInfo }}</text>
|
||
</view>
|
||
|
||
<!-- 扫描结果列表 -->
|
||
<view v-if="!connected">
|
||
<view v-for="(item, index) in scanArray" :key="index" class="card">
|
||
<view class="cardBox" @tap="onLinkBluetooth(item)">
|
||
<view class="leftIcon">
|
||
<uni-icons type="paperclip" size="20"/>
|
||
</view>
|
||
<view class="rightContent">
|
||
<text>{{ item.device.name ?? 'N/A' }}</text>
|
||
<text>{{ item.device.address }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="!scanning && scanArray.length == 0" class="emptyBox">
|
||
<text class="emptyText">No device found. Tap "Scan" to retry</text>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
<!-- #ifdef APP -->
|
||
</scroll-view>
|
||
<!-- #endif -->
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
|
||
import { MyApiResult, BleScanResult } from "../../src/BluetoothManager"
|
||
import * as Ble from "../../src/BluetoothManager"
|
||
import * as McmCmd from "../../src/mcm/McmCommands"
|
||
import * as McmInfo from "../../src/mcm/McmInfo"
|
||
import * as BleINfo from "../../common/bleInfo"
|
||
|
||
const DEVICE_NAME_FILTER = "CAIIC-MCM-20260902"
|
||
const POLL_INTERVAL = 2000
|
||
|
||
const scanArray = ref<BleScanResult[]>([])
|
||
const scanning = ref<boolean>(false)
|
||
const connecting = ref<boolean>(false)
|
||
const connected = ref<boolean>(false)
|
||
const deviceName = ref<string>("")
|
||
const deviceMac = ref<string>("")
|
||
const temperature = ref<string>("--")
|
||
const fanSpeed = ref<string>("--")
|
||
const voltage = ref<string>("--")
|
||
const fwVersion = ref<string>("--")
|
||
const bankInfo = ref<string>("--")
|
||
const uptime = ref<string>("--")
|
||
const freeHeap = ref<string>("--")
|
||
const sysInfo = ref<string>("--")
|
||
const paramLed = ref<string>("")
|
||
const paramPwm = ref<string>("")
|
||
const paramValid = ref<boolean>(false)
|
||
const paramBusy = ref<boolean>(false)
|
||
// 定时查询开关:缺省关闭,避免轮询占用 GATT 干扰 OTA/CLI 等操作
|
||
const autoRefresh = ref<boolean>(false)
|
||
|
||
let pollTimer = 0
|
||
let pageVisible = false
|
||
|
||
// ================= 参数轮询 =================
|
||
|
||
async function pollParams() {
|
||
if (!connected.value || !pageVisible) return
|
||
// 读只读参数特征(MCM_INFO_UUID,TLV 同 INFO_RSP 全部项,含 CUR_BANK)
|
||
const info = await McmInfo.readDeviceInfo()
|
||
const t = info.chipTemp
|
||
temperature.value = t != null ? t.toFixed(1) : "--"
|
||
const f = info.fanRpm
|
||
fanSpeed.value = f != null ? f.toString() : "N/A"
|
||
const v = info.vddMv
|
||
voltage.value = v != null ? v.toString() : "--"
|
||
if (info.curBank != null) bankInfo.value = "APP" + info.curBank
|
||
if (info.uptimeS != null) uptime.value = info.uptimeS + " s"
|
||
if (info.freeHeap != null) freeHeap.value = info.freeHeap + " B"
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer != 0) {
|
||
clearTimeout(pollTimer)
|
||
pollTimer = 0
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling()
|
||
// 注意:uts 的 setTimeout 不接受 async 回调,用 then 链式调度
|
||
pollTimer = setTimeout(() => {
|
||
pollParams().then(() => {
|
||
if (connected.value && pageVisible && autoRefresh.value) {
|
||
startPolling()
|
||
}
|
||
})
|
||
}, POLL_INTERVAL)
|
||
}
|
||
|
||
// 定时查询开关(switch 事件):开=立即查一次并开始轮询,关=停掉轮询
|
||
function onAutoRefreshChange(e: any) {
|
||
autoRefresh.value = e.detail.value as boolean
|
||
console.log("[HOME] auto refresh " + (autoRefresh.value ? "ON" : "OFF"))
|
||
if (autoRefresh.value && connected.value) {
|
||
pollParams()
|
||
startPolling()
|
||
} else {
|
||
stopPolling()
|
||
}
|
||
}
|
||
|
||
// ================= 设备信息 =================
|
||
|
||
async function loadDeviceInfo() {
|
||
// 版本/bank 走参数特征 TLV(devinfo 全项)
|
||
const info = await McmCmd.getDevInfo()
|
||
if (info.fwVersion != null) fwVersion.value = McmCmd.formatVersion(info.fwVersion)
|
||
if (info.curBank != null) bankInfo.value = "APP" + info.curBank
|
||
if (info.uptimeS != null) uptime.value = info.uptimeS + " s"
|
||
if (info.freeHeap != null) freeHeap.value = info.freeHeap + " B"
|
||
// sysinfo(FreeRTOS 运行状态)走 CLI 透传
|
||
const s = await McmCmd.getSysInfo()
|
||
sysInfo.value = s != null ? s : "--"
|
||
// APP_DATA 参数(appget json)
|
||
loadAppParams()
|
||
}
|
||
|
||
async function loadAppParams() {
|
||
const p = await McmCmd.getAppParams()
|
||
if (p != null) {
|
||
paramLed.value = p.led.toString()
|
||
paramPwm.value = p.pwm.toString()
|
||
paramValid.value = p.valid == 1
|
||
}
|
||
}
|
||
|
||
async function onSetParam(key: string) {
|
||
if (paramBusy.value) return
|
||
const raw = key == "led" ? paramLed.value : paramPwm.value
|
||
const val = parseInt(raw)
|
||
if (isNaN(val)) {
|
||
uni.showToast({ title: 'Invalid number', icon: 'none' })
|
||
return
|
||
}
|
||
paramBusy.value = true
|
||
const okSet = await McmCmd.setAppParam(key, val)
|
||
paramBusy.value = false
|
||
if (okSet) {
|
||
uni.showToast({ title: key + ' saved', icon: 'success' })
|
||
loadAppParams()
|
||
} else {
|
||
uni.showToast({ title: 'Set failed (range?)', icon: 'error' })
|
||
}
|
||
}
|
||
|
||
// ================= 蓝牙事件 =================
|
||
|
||
// 连接就绪(bleState 0:connect + 服务/特征校验已通过):先协商 MTU,
|
||
// 完成后再加载设备信息——安卓 GATT 单操作约束,并发会互挤(读丢失/写 10007)
|
||
function onConnectedReady() {
|
||
connected.value = true
|
||
connecting.value = false
|
||
deviceMac.value = Ble.getConnectMac()
|
||
Ble.setMtu(247, (resMtu: MyApiResult) => {
|
||
console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message)
|
||
loadDeviceInfo()
|
||
// 缺省不启动定时查询,用户在页面上手动打开 Auto refresh 才轮询
|
||
if (autoRefresh.value) {
|
||
startPolling()
|
||
}
|
||
})
|
||
}
|
||
|
||
function onDisConnectUi() {
|
||
connected.value = false
|
||
connecting.value = false
|
||
stopPolling()
|
||
temperature.value = "--"
|
||
fanSpeed.value = "--"
|
||
voltage.value = "--"
|
||
fwVersion.value = "--"
|
||
bankInfo.value = "--"
|
||
uptime.value = "--"
|
||
freeHeap.value = "--"
|
||
sysInfo.value = "--"
|
||
paramValid.value = false
|
||
}
|
||
|
||
function onBleStatueListener(state: number) {
|
||
console.log("home bleState = ", state)
|
||
if (state == 0) { // 连接就绪
|
||
onConnectedReady()
|
||
} else if (state == 10000) { // 连接失败
|
||
connecting.value = false
|
||
connected.value = false
|
||
uni.showToast({ title: 'Connection failed', icon: 'error' })
|
||
} else if (state == 10001) { // 已断开
|
||
onDisConnectUi()
|
||
}
|
||
}
|
||
|
||
// ================= 扫描与连接 =================
|
||
|
||
function onBleStarScan() {
|
||
if (scanning.value) return
|
||
if (!Ble.isEnabled()) {
|
||
Ble.openBluetooth((res: MyApiResult) => {
|
||
if (res.type == 0) {
|
||
onBleStarScan()
|
||
} else if (res.type == 10003) {
|
||
uni.showToast({ title: 'No Bluetooth permission, grant it in system settings', icon: 'none' })
|
||
} else {
|
||
uni.showToast({ title: 'Please turn on Bluetooth first', icon: 'none' })
|
||
}
|
||
})
|
||
return
|
||
}
|
||
scanning.value = true
|
||
scanArray.value = []
|
||
const scanMs = 10 * 1000
|
||
Ble.startScan(scanMs, function(res: MyApiResult) {
|
||
if (res.type == 0) {
|
||
const scan = res.data as BleScanResult
|
||
const name = scan.device.name ?? ""
|
||
if (name.indexOf(DEVICE_NAME_FILTER) != 0) return // 只列出自家设备(CAIIC-MCM-20260902 前缀)
|
||
// 去重
|
||
for (let i = 0; i < scanArray.value.length; i++) {
|
||
if (scanArray.value[i].device.address == scan.device.address) return
|
||
}
|
||
scanArray.value.push(scan)
|
||
} else if (res.type == 10003) {
|
||
uni.showToast({ title: 'No Bluetooth permission', icon: 'none' })
|
||
} else if (res.type == 10002) {
|
||
uni.showToast({ title: 'Bluetooth is not turned on', icon: 'none' })
|
||
}
|
||
})
|
||
setTimeout(() => {
|
||
scanning.value = false
|
||
uni.stopPullDownRefresh()
|
||
}, scanMs + 200)
|
||
}
|
||
|
||
function onLinkBluetooth(item: BleScanResult) {
|
||
Ble.stopScan()
|
||
scanning.value = false
|
||
connecting.value = true
|
||
deviceName.value = item.device.name ?? DEVICE_NAME_FILTER
|
||
BleINfo.setName(deviceName.value)
|
||
BleINfo.setMac(item.device.address);
|
||
(getApp().globalData as any).ConnectBle(item.device.address, false)
|
||
}
|
||
|
||
function onDisconnect() {
|
||
stopPolling()
|
||
Ble.disConnect()
|
||
onDisConnectUi()
|
||
}
|
||
|
||
// ================= 生命周期 =================
|
||
|
||
let subscribed = false
|
||
function subscribe() {
|
||
if (subscribed) return
|
||
uni.$on("bleState", onBleStatueListener)
|
||
subscribed = true
|
||
}
|
||
|
||
function unsubscribe() {
|
||
if (!subscribed) return
|
||
uni.$off("bleState", onBleStatueListener)
|
||
subscribed = false
|
||
}
|
||
|
||
onLoad((options: any) => {
|
||
subscribe()
|
||
})
|
||
|
||
onShow(() => {
|
||
subscribe()
|
||
pageVisible = true
|
||
if (Ble.isConnected()) {
|
||
if (!connected.value) {
|
||
// 应用内已连接(如从其他页返回)
|
||
connected.value = true
|
||
deviceMac.value = Ble.getConnectMac()
|
||
deviceName.value = BleINfo.getName() ?? ""
|
||
loadDeviceInfo()
|
||
}
|
||
if (autoRefresh.value) {
|
||
startPolling()
|
||
}
|
||
} else {
|
||
onBleStarScan()
|
||
}
|
||
})
|
||
|
||
onHide(() => {
|
||
pageVisible = false
|
||
Ble.stopScan()
|
||
stopPolling()
|
||
unsubscribe()
|
||
})
|
||
|
||
onUnload(() => {
|
||
pageVisible = false
|
||
Ble.stopScan()
|
||
stopPolling()
|
||
unsubscribe()
|
||
})
|
||
|
||
onPullDownRefresh(() => {
|
||
if (!connected.value) {
|
||
onBleStarScan()
|
||
} else {
|
||
// 已连接:下拉刷新设备信息(版本/bank/sysinfo)
|
||
loadDeviceInfo().then(() => {
|
||
uni.stopPullDownRefresh()
|
||
})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page{
|
||
display:flex;
|
||
flex-direction:column;
|
||
padding: 0 20rpx;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card {
|
||
border-radius: 36rpx;
|
||
background-color: #ffffff;
|
||
padding: 24rpx;
|
||
margin-top: 20rpx;
|
||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.statusRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.statusDot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: 8rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
.dotOn { background-color: #4cd964; }
|
||
.dotOff { background-color: #cccccc; }
|
||
|
||
.statusText {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.deviceText {
|
||
font-size: 24rpx;
|
||
color: #888888;
|
||
margin-left: 16rpx;
|
||
flex: 1;
|
||
}
|
||
|
||
.btnRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: flex-end;
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.btn {
|
||
background-color: #3A6FA1;
|
||
color: #ffffff;
|
||
}
|
||
.btnWarn {
|
||
background-color: #dd524d;
|
||
}
|
||
|
||
.paramCard {
|
||
flex-direction: row;
|
||
justify-content: space-around;
|
||
}
|
||
|
||
.paramItem {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.paramLabel {
|
||
font-size: 26rpx;
|
||
color: #888888;
|
||
}
|
||
|
||
.paramValue {
|
||
font-size: 52rpx;
|
||
font-weight: bold;
|
||
color: #3A6FA1;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.paramUnit {
|
||
font-size: 24rpx;
|
||
font-weight: normal;
|
||
color: #888888;
|
||
}
|
||
|
||
.infoRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
padding: 8rpx 0;
|
||
}
|
||
|
||
.infoLabel {
|
||
font-size: 26rpx;
|
||
color: #888888;
|
||
}
|
||
|
||
.infoValue {
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
flex: 1;
|
||
text-align: right;
|
||
margin-left: 20rpx;
|
||
}
|
||
|
||
.cardBox {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.leftIcon {
|
||
width: 28px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.rightContent {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.emptyBox {
|
||
padding: 60rpx 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.emptyText {
|
||
font-size: 26rpx;
|
||
color: #999999;
|
||
}
|
||
|
||
.consoleTitle {
|
||
font-size: 26rpx;
|
||
color: #888888;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.paramSetRow {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 8rpx 0;
|
||
}
|
||
|
||
.paramSetRow .infoLabel {
|
||
width: 220rpx;
|
||
}
|
||
|
||
.paramInput {
|
||
flex: 1;
|
||
border: 1px solid #dddddd;
|
||
border-radius: 12rpx;
|
||
padding: 8rpx 16rpx;
|
||
font-size: 24rpx;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.consoleText {
|
||
font-size: 22rpx;
|
||
color: #333333;
|
||
font-family: monospace;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|