evan.liu 3e87d333cb App: adapt to firmware V1.00.23 BLE protocol + field debugging fixes
- 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
2026-09-05 06:45:04 +08:00

576 lines
14 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- #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>