- 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;运行改用标准基座
389 lines
9.2 KiB
Vue
389 lines
9.2 KiB
Vue
<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>
|