evan.liu 07509a2387 迁移uni-app x到uni-app vue3,蓝牙去第三方插件改uni内置BLE API
- uni-app x官方无内置蓝牙API(文档明确见插件市场),为实现去插件化,工程整体迁移为uni-app vue3(vue/ts),App端内置完整BLE API,标准基座即可运行
- 新建src/BluetoothManager.ts:基于uni内置BLE API(createBLEConnection/notifyBLECharacteristicValueChange/writeBLECharacteristicValue(writeNoResponse)/setBLEMTU/getBLEDeviceRSSI等),保持原插件type约定,页面业务逻辑不变
- 删除uni_modules/android-ble及未使用的lime-*、uni-wifi;保留uni-icons、uni-scss
- uts模块全部移植为ts(src/mcm、api、common):UTSJSONObject改Record<string,any>,适配vue3 getStorageSync空值返回空串的差异
- 7个页面uvue转vue:模板不变,显式import ref与生命周期钩子;OTA固件读取由java.io改为plus.io+uni.base64ToArrayBuffer
- manifest.json去uni-app-x改app-plus结构,补Android 12+蓝牙权限;删除platformConfig.json;运行改用标准基座
2026-09-04 06:02:29 +08:00

389 lines
9.2 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" v-if="!connected">
<text class="tipText">Not connected. Please connect a CAIIC-MCM device on the Device tab first</text>
</view>
<template v-if="connected">
<!-- 当前设备信息 -->
<view class="card">
<view class="infoRow">
<text class="infoLabel">Current Firmware</text>
<text class="infoValue">{{ curVersion }}</text>
</view>
<view class="infoRow">
<text class="infoLabel">Bank</text>
<text class="infoValue">{{ bankInfo }}</text>
</view>
</view>
<!-- 固件文件 -->
<view class="card">
<text class="sectionTitle">Firmware file (Keil bin, ≤224KB)</text>
<input class="input" v-model="filePath" placeholder="/sdcard/Download/embeddedSrc.bin" :disabled="upgrading" />
<view class="btnRow">
<button class="btn" size="mini" :disabled="upgrading" @tap="onLoadFile">Load File</button>
</view>
<view class="infoRow" v-if="fileLoaded">
<text class="infoLabel">File Size</text>
<text class="infoValue">{{ fileSize }} bytes</text>
</view>
<view class="infoRow" v-if="fileLoaded">
<text class="infoLabel">Image CRC32</text>
<text class="infoValue">0x{{ fileCrcHex }}</text>
</view>
</view>
<!-- 目标版本 -->
<view class="card">
<text class="sectionTitle">Target version (e.g. 1.00.02)</text>
<input class="input" v-model="versionText" placeholder="1.00.02" :disabled="upgrading" />
</view>
<!-- 升级控制 -->
<view class="card">
<view class="progressBox">
<view class="progressBar" :style="'width:' + progress + '%'"></view>
</view>
<text class="progressText">{{ progress }}%</text>
<view class="btnRow">
<button class="btn" size="mini" v-if="!upgrading" :disabled="!fileLoaded" @tap="onStartOta">Start Upgrade</button>
<button class="btn btnWarn" size="mini" v-if="upgrading" @tap="onAbortOta">Abort</button>
</view>
<text class="warnText" v-if="upgrading">Do not disconnect or exit during upgrade</text>
</view>
<!-- 日志 -->
<view class="card" v-if="logs.length > 0">
<text class="sectionTitle">Upgrade Log</text>
<text class="logText" v-for="(log, index) in logs" :key="index">{{ log }}</text>
</view>
</template>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { MyApiResult } from "../../src/BluetoothManager"
import * as Ble from "../../src/BluetoothManager"
import * as McmCmd from "../../src/mcm/McmCommands"
import * as Ota from "../../src/mcm/OtaManager"
import { OtaCallbacks } from "../../src/mcm/OtaManager"
import { crc32Ieee } from "../../src/mcm/Crc"
const connected = ref<boolean>(false)
const curVersion = ref<string>("--")
const bankInfo = ref<string>("--")
const filePath = ref<string>("/sdcard/Download/embeddedSrc.bin")
const fileLoaded = ref<boolean>(false)
const fileSize = ref<number>(0)
const fileCrcHex = ref<string>("")
const versionText = ref<string>("")
const upgrading = ref<boolean>(false)
const progress = ref<number>(0)
const logs = ref<string[]>([])
let imageBytes = [] as number[]
function addLog(msg: string) {
logs.value.push(msg)
}
// ================= 文件读取(App 本地文件,plus.io) =================
function readFileBytes(path: string, cb: (bytes: number[] | null) => void) {
// #ifdef APP-PLUS
const plusIo = (window as any).plus?.io
if (plusIo == null) {
cb(null)
return
}
plusIo.resolveLocalFileSystemURL(path, (entry: any) => {
entry.file((file: any) => {
const reader = new plusIo.FileReader()
reader.onloadend = (e: any) => {
try {
const dataUrl = e.target.result as string
const base64 = dataUrl.substring(dataUrl.indexOf(",") + 1)
const u8 = new Uint8Array(uni.base64ToArrayBuffer(base64))
const out = [] as number[]
for (let i = 0; i < u8.length; i++) {
out.push(u8[i])
}
cb(out)
} catch (err) {
console.log("readFileBytes parse error", err)
cb(null)
}
}
reader.onerror = (e: any) => {
console.log("readFileBytes read error", e)
cb(null)
}
reader.readAsDataURL(file)
}, (err: any) => {
console.log("readFileBytes file error", err)
cb(null)
})
}, (err: any) => {
console.log("readFileBytes resolve error", err)
cb(null)
})
// #endif
// #ifndef APP-PLUS
cb(null)
// #endif
}
function onLoadFile() {
readFileBytes(filePath.value.trim(), (bytes) => {
if (bytes == null) {
fileLoaded.value = false
uni.showToast({ title: 'Failed to read file, check the path', icon: 'error' })
return
}
if (bytes.length > 0x38000) {
fileLoaded.value = false
uni.showToast({ title: 'File exceeds the 224KB limit', icon: 'error' })
return
}
imageBytes = bytes
fileSize.value = bytes.length
fileCrcHex.value = crc32Ieee(bytes).toString(16)
fileLoaded.value = true
addLog("File loaded: " + bytes.length + " bytes, CRC32=0x" + fileCrcHex.value)
})
}
// ================= 版本号解析 "1.00.02" -> 0x010002 =================
function parseVersion(v: string): number | null {
const parts = v.trim().split(".")
if (parts.length != 3) return null
const a = parseInt(parts[0])
const b = parseInt(parts[1])
const c = parseInt(parts[2])
if (isNaN(a) || isNaN(b) || isNaN(c)) return null
if (a < 0 || a > 255 || b < 0 || b > 255 || c < 0 || c > 255) return null
return a * 65536 + b * 256 + c
}
// ================= OTA =================
async function onStartOta() {
const version = parseVersion(versionText.value)
if (version == null) {
uni.showToast({ title: 'Invalid version format (e.g. 1.00.02)', icon: 'error' })
return
}
if (!Ble.isConnected()) {
uni.showToast({ title: 'Device not connected', icon: 'error' })
return
}
upgrading.value = true
progress.value = 0
logs.value = []
addLog("Upgrade started, target version " + versionText.value)
const cbs: OtaCallbacks = {
onProgress: (percent: number) => {
progress.value = percent
},
onState: (msg: string) => {
addLog(msg)
}
} as OtaCallbacks
const ok = await Ota.start(imageBytes, version, cbs)
upgrading.value = false
if (ok) {
uni.showToast({ title: 'Upgrade succeeded, device will reboot', icon: 'success' })
} else {
uni.showToast({ title: 'Upgrade failed', icon: 'error' })
}
}
function onAbortOta() {
Ota.abort()
addLog("Abort requested...")
}
// ================= 设备信息 =================
async function loadDeviceInfo() {
const v = await McmCmd.getVersion()
curVersion.value = v != null ? v : "--"
const b = await McmCmd.getBankInfo()
if (b != null) {
bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : "--")
}
}
// ================= 蓝牙状态监听 =================
function onBleStatueListener(state: number) {
if (state == 10000 || state == 10001) {
connected.value = false
if (upgrading.value) {
addLog("Disconnected, upgrade aborted")
upgrading.value = false
}
}
}
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()
connected.value = Ble.isConnected()
if (connected.value) {
loadDeviceInfo()
}
})
onHide(() => {
unsubscribe()
})
onUnload(() => {
unsubscribe()
// 离开页面时若仍在升级则中止(升级期间不建议离开)
if (Ota.isRunning()) {
Ota.abort()
}
})
</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;
}
.tipText {
font-size: 26rpx;
color: #999999;
text-align: center;
padding: 20rpx 0;
}
.sectionTitle {
font-size: 26rpx;
color: #888888;
margin-bottom: 12rpx;
}
.input {
border: 1px solid #dddddd;
border-radius: 12rpx;
padding: 16rpx;
font-size: 26rpx;
}
.infoRow {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 8rpx 0;
}
.infoLabel {
font-size: 26rpx;
color: #888888;
flex-shrink: 0;
}
.infoValue {
font-size: 24rpx;
color: #333333;
flex: 1;
text-align: right;
margin-left: 20rpx;
}
.btnRow {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 16rpx;
}
.btn {
background-color: #3A6FA1;
color: #ffffff;
}
.btnWarn {
background-color: #dd524d;
}
.progressBox {
height: 24rpx;
border-radius: 12rpx;
background-color: #eeeeee;
overflow: hidden;
margin-top: 8rpx;
}
.progressBar {
height: 100%;
background-color: #3A6FA1;
}
.progressText {
font-size: 26rpx;
color: #3A6FA1;
text-align: center;
margin-top: 8rpx;
}
.warnText {
font-size: 24rpx;
color: #dd524d;
text-align: center;
margin-top: 12rpx;
}
.logText {
font-size: 22rpx;
color: #333333;
font-family: monospace;
line-height: 1.5;
}
</style>