BLE协议V1.1适配:INFO_QUERY信息查询、显式GATT UUID、修复notify数据解析(NotityData);页面调整:Status并入Device、CLI终端改为底部tab;增加[MCM]收发报文日志

This commit is contained in:
evan.liu 2026-09-03 08:44:04 +08:00
parent b4e8cafa61
commit 4af93dca6c
14 changed files with 384 additions and 476 deletions

View File

@ -10,13 +10,17 @@ import * as McmChannel from "@/src/mcm/McmChannel.uts"
globalData: {
ble: new BleLib(), // 蓝牙全局变量
// 接收蓝牙数据的回调
// 接收蓝牙数据的回调(显式指定 CAIIC-MCM 服务/notify UUID,不依赖插件自动识别;
// 用当前接口 onNotityBleData,旧接口 onNotityReadBleData 插件已标记弃用)
setBleNotity(){
getApp().globalData.ble.onNotityReadBleData(getApp().globalData.ble.getSericUUID(),
getApp().globalData.ble.getNotityUUID(),
getApp().globalData.ble.onNotityBleData(Ble.MCM_SERVICE_UUID,
Ble.MCM_NOTIFY_UUID,
true,
function(res:MyApiResult){
// console.log(res)
if (res.type != 0) {
// 1000=订阅成功 1001=订阅失败(type=0 的 notify 数据由 McmChannel 打印)
console.log("[MCM] notify subscribe result: type=" + res.type + " msg=" + res.message)
}
uni.$emit("bleData", res)
}
)
@ -25,12 +29,10 @@ import * as McmChannel from "@/src/mcm/McmChannel.uts"
// 连接蓝牙
ConnectBle(address: string, autoReconnect: boolean){
getApp().globalData.ble.connect(address, autoReconnect, function(res:MyApiResult){
// console.log(res)
console.log("[MCM] connect " + address + ": type=" + res.type + " msg=" + res.message)
if(res.type==0){
getApp().globalData.ble.scanServices(function(resSevice:MyApiResult){
// console.log(resSevice);
// 显式选中 CAIIC-MCM 的 CLS 服务组合(自动识别可能选错)
Ble.selectService("0000CA10")
console.log("[MCM] scanServices: type=" + resSevice.type + " msg=" + resSevice.message)
getApp().globalData.setBleNotity()
// getApp().globalData.ble.readRssi(function(b:MyApiResult){

View File

@ -0,0 +1,72 @@
# 修改记录 2026-09-03:BLE 协议 V1.1 适配
设备协议升级到 `D:\caic-workspace\mothercup\docs\ble_protocol.md`(CAIIC BLE 通信协议 V1.1),
APP 侧同步适配。差异与改动如下。
## 协议差异(V1.0 → V1.1)
- GATT 更换为 SDK rdtss 自定义 128-bit UUID:Service `00002760-08C2-11E1-9073-0E8AC72E1001`,
下行写特征(Write Without Response),上行 notify;广播名 `CAIIC-MCM-20260902`。
MTU=247 由设备连接后主动发起,手机端也可自行 requestMtu。
**注意**:协议文档表格中下行/上行 UUID 写作 `...C72EE001` / `...C72EE002`,
与固件 `app_rdtss.h` 实际值 `...C72E0001` / `...C72E0002` 不符,APP 以固件为准
(协议文档此处需修正)。
- 新增设备信息查询帧:`INFO_QUERY(0x20)` / `INFO_RSP(0x21)`,payload 为 TLV 序列
`{id u8, len u8, value…}×n`(数值小端)。信息项:0x01 FW_VERSION(u32)、
0x02 CHIP_TEMP(i16, 0.1℃, 0x7FFF=读取失败)、0x03 FAN_RPM(u16, 0xFFFF=无硬件)、
0x04 VDD_MV(u16, 0=失败)、0x05 UPTIME_S(u32)、0x06 FREE_HEAP(u32)。
空 payload 或单个 0xFF = 查询全部;设备不认识的 id 不出现在应答中。
- 帧格式(SOF 0xCA / CRC16-CCITT)、CLI 透传(0x01/0x02/0x03)、OTA(0x10/0x11/0x12/0x1F)
与 V1.0 一致,既有 `FrameCodec`/`McmChannel` CLI 通道/`OtaManager` 无需改动。
## 修改文件
- `src/mcm/FrameCodec.uts` — 新增 `TYPE_INFO_QUERY=0x20` / `TYPE_INFO_RSP=0x21` 常量,
新增 `readU16LE` / `readI16LE`(i16 处理符号位)解包工具。
- `src/mcm/McmChannel.uts` — 新增 INFO_QUERY 请求-响应通道:`queryInfo(items, timeoutMs)`
串行执行(infoChain),单 pending infoJob,超时/发送失败/断连 resolve null;
`dispatchFrame` 分发 INFO_RSP;`resetChannel` 清理挂起的信息查询。
**修复 notify 数据解析**:插件 `onNotityReadBleData` 数据回调(type=0)的 `res.data`
是 `NotityData` 结构(`{data: number[], mac, serviceId, characteristicsId}`),
此前按裸 `number[]` 解析导致设备应答字节永远进不了帧重组器,表现为
"写入成功但 help 无应答(status=-1)"。
CLI 命令**不追加** `\r\n`:固件 `app_ble_proto.c` 把 CLI_REQ payload 直接交给
`cli_exec_line`(只按空格/Tab 分词),尾随 `\r\n` 会被当作命令名一部分导致
unknown command(曾按"行编辑器需要结束符"的假设加过,查固件源码后撤销)。
- `src/BluetoothManager.uts` — 新增 `MCM_SERVICE_UUID` / `MCM_WRITE_UUID` /
`MCM_NOTIFY_UUID` 常量(取固件 `app_rdtss.h` 实际值,小写);
`sendDataBytes` 与 `onNotityReadBleData` 改用显式 UUID,不再依赖插件的
service/notify/write 自动识别(下行特征只有 Write Without Response 属性,
自动识别的写特征可能为空,表现为"发送 help 无应答")。
notify 订阅从旧接口 `onNotityReadBleData` 换成插件推荐的 `onNotityBleData`
(旧接口已标记弃用,数据统一走 `NotityData` 结构)。
- `App.uvue` — `setBleNotity()` 改用 `Ble.MCM_SERVICE_UUID` / `Ble.MCM_NOTIFY_UUID`
显式订阅 notify;`ConnectBle()` 移除 `selectService(...)` 调用(已不再需要)。
- 调试日志:BLE 收发全链路加了 `[MCM]` 前缀的 console 打印——`McmChannel` 的
TX 帧(完整 hex)、写入失败、RX notify 原始字节、RX 重组帧;
`App.uvue` 的 connect/scanServices/notify 订阅结果。真机调试时在 HBuilderX
控制台可直接观察报文流。
- `src/mcm/McmInfo.uts`(新增)— 信息项 id 常量、`parseTlv`(未知 id 按 len 跳过、
长度不符项忽略、0x7FFF/0xFFFF/0 等无效值归一为 null)、`DeviceInfo` 类型、
`queryDeviceInfo(items)`(空数组=查全部)、`formatVersion`(u32 → "V1.00.01")。
- `src/mcm/McmCommands.uts` — `getTemperature`/`getFanSpeed`/`getVersion` 改走
INFO_QUERY(函数签名不变);废弃未实现的 CLI `temp`/`fan`/`version` 命令常量;
`getSysInfo`/`getBankInfo`/`execCli` 保持 CLI 透传不变。
- `pages/tabbar/home.uvue` — 扫描过滤前缀更新为 `CAIIC-MCM-20260902`;
参数轮询合并为一次 `queryDeviceInfo([CHIP_TEMP, FAN_RPM])`(温度显示 1 位小数,
风扇无硬件显示 `N/A`)。
## 未改动
- `src/mcm/OtaManager.uts`:OTA 流程与 V1.1 第 6 节一致(BEGIN→DATA 扇区级 ack 流控→END
整镜像 CRC32 校验,status=2 按应答 offset 重同步)。
- `pages/tabbar/sysStatus.uvue` / `pages/tabbar/ota.uvue` / CLI 调试终端:
调用的 `McmCommands` API 签名不变,无需修改。
## 验证
- 用 Node 脚本按协议 8.2 节参考实现复算 8.3 节全部字节级示例(CLI_REQ "help"、
INFO_QUERY 查全部/温度+风扇、OTA_BEGIN/OTA_DATA/OTA_END),与 `encodeFrame`/CRC16
输出逐字节一致;INFO_RSP 样例(26.6℃ + 无风扇)经 `parseTlv` 解析结果正确。
- 真机回归(HBuilderX 编译):扫描连接 `CAIIC-MCM-20260902`、主页温度/风扇显示、
系统状态页、CLI 终端、本地 bin OTA 升级。

View File

@ -0,0 +1,34 @@
# 修改记录 2026-09-03:页面结构调整(Status 合并进 Device,CLI 终端改为底部 Tab)
## 改动
- **Status 页合并进 Device 页**:`pages/tabbar/home.uvue` 新增 sysinfo 控制台卡片
(固件版本/运行 Bank 卡片下方),`loadDeviceInfo()` 顺带拉取 `sysinfo`;
已连接时下拉刷新改为重新拉取设备信息(版本/bank/sysinfo);断开时清空 sysinfo 显示。
新增 `consoleTitle`/`consoleText` 样式(沿用原 sysStatus 页样式)。
- **CLI 终端改为底部 Tab**:`pages/bleTest/bluetoothOperation/bluetoothOperation.uvue`
进入 tabbar(text "CLI",图标复用原 Status 的 `mapDefault/mapActive.png`)。
- 连接地址不再有页面参数来源:`onConnect()` 在未连接时取 `bleInfo` 中最近连接过的
MAC,没有记录则提示先去 Device 页扫描连接;
- 生命周期钩子由 `onPageShow/onPageHide` 改为 tabbar 适用的 `onShow/onHide`;
- `onShow` 时若应用内已连接(如在 Device 页连上后切过来)自动进入可用状态。
- **my 页移除 "CLI Terminal" 入口**(已成为 tab),删除不再使用的 `onBluetooth`、
Ble 相关 import 与 `cardBle/cardBox/textType` 样式。
## pages.json
- tabbar 四 tab:Device / OTA / CLI / my(原 Status 位替换为 CLI)。
- 移除 `pages/tabbar/sysStatus` 与 `pages/bleTest/bluetoothList/bluetoothList` 的页面注册;
`bluetoothOperation` 保留注册(现为 tab 页),导航栏背景色与整体风格统一为 `#3A6FA1`。
## 删除
- `pages/tabbar/sysStatus.uvue`(内容已并入 home.uvue)。
- `pages/bleTest/bluetoothList/`(扫描选设备页:唯一入口是 my 页的 CLI Terminal,
设备选择统一由 Device 主页承担后该页成为死代码)。
## 验证
- 无 CLI 构建手段,需 HBuilderX 编译真机验证:四个 tab 切换正常;Device 页连接后
sysinfo 控制台有内容;切到 CLI tab 自动可用、发 `help` 有应答;未连接时 CLI tab
点 connect 给出提示。

View File

@ -8,14 +8,6 @@
"enablePullDownRefresh": true
}
},
{
"path": "pages/tabbar/sysStatus",
"style": {
"navigationBarTitleText": "System Status",
"navigationBarBackgroundColor": "#3A6FA1",
"enablePullDownRefresh": true
}
},
{
"path": "pages/tabbar/ota",
"style": {
@ -53,17 +45,11 @@
"navigationBarBackgroundColor": "#3A6FA1"
}
},
{
"path": "pages/bleTest/bluetoothList/bluetoothList",
"style": {
"navigationBarTitleText": "bluetooth list",
"enablePullDownRefresh": true
}
},
{
"path": "pages/bleTest/bluetoothOperation/bluetoothOperation",
"style": {
"navigationBarTitleText": "CLI Terminal"
"navigationBarTitleText": "CLI Terminal",
"navigationBarBackgroundColor": "#3A6FA1"
}
}
],
@ -79,18 +65,18 @@
"iconPath": "/static/tabbar/homeDefault.png",
"selectedIconPath": "/static/tabbar/homeActive.png"
},
{
"pagePath": "pages/tabbar/sysStatus",
"text": "Status",
"iconPath": "/static/tabbar/mapDefault.png",
"selectedIconPath": "/static/tabbar/mapActive.png"
},
{
"pagePath": "pages/tabbar/ota",
"text": "OTA",
"iconPath": "/static/tabbar/chargeDefault.png",
"selectedIconPath": "/static/tabbar/chargeActive.png"
},
{
"pagePath": "pages/bleTest/bluetoothOperation/bluetoothOperation",
"text": "CLI",
"iconPath": "/static/tabbar/mapDefault.png",
"selectedIconPath": "/static/tabbar/mapActive.png"
},
{
"pagePath": "pages/tabbar/my",
"text": "my",

View File

@ -1,142 +0,0 @@
<template>
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<view class="page">
<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 }}</text>
<text>{{ item.device.address }}</text>
</view>
</view>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="uts">
import {BleLib,MyApiResult,BleScanResult,BleServices,ScanRssiBR,WNuuid} from "@/uni_modules/android-ble"
import * as Ble from "../../../src/BluetoothManager"
const scanArray = ref<BleScanResult[]>([]) // 蓝牙扫描结果
const refreshing = ref<boolean>(false)
// 连接蓝牙
function onLinkBluetooth(item: BleScanResult) {
console.log(item.device.name)
uni.navigateTo({
url: `/pages/bleTest/bluetoothOperation/bluetoothOperation?address=${encodeURIComponent(item.device.address)}`
})
}
function onBleStarScan() {
console.log("list")
if (refreshing.value) return
refreshing.value = true
const scanMs = 10*1000
scanArray.value = []
Ble.startScan(scanMs,function(res:MyApiResult){
// type 为0 扫描成功 10001 表示无蓝牙权限 10002 表示蓝牙未开启
// console.log(res.type)
if(res.type==0){
var scan = res.data as BleScanResult;
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(() => {
refreshing.value = false
uni.stopPullDownRefresh()
}, scanMs + 200)
}
// ======= 生命周期 ========
onLoad((options: UTSJSONObject | null) => {
if (options != null) {
}
})
onPageShow(() => {
onBleStarScan()
})
onPageHide(() => {
Ble.stopScan()
})
onUnload(() => {
console.log("stop")
Ble.stopScan()
})
// 下拉刷新
onPullDownRefresh(() => {
onBleStarScan()
})
</script>
<style scoped>
.page{
display:flex;
flex-direction:column;
margin-bottom: 16px;
}
/* 卡片 */
.card {
border-radius: 36rpx;
background-color: #ffffff;
padding: 20rpx;
margin-top: 20rpx;
/* 阴影效果 */
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
/* 横向布局:左图右文 */
display: flex;
flex-direction: column;
}
.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; /* 很关键:让 text-overflow 生效 */
}
.displyType {
color: #0000ff;
}
</style>

View File

@ -50,6 +50,7 @@ import * as Ble from "../../../src/BluetoothManager"
import * as McmChannel from "../../../src/mcm/McmChannel.uts"
import * as McmCmd from "../../../src/mcm/McmCommands.uts"
import { CliResult } from "../../../src/mcm/McmChannel.uts"
import * as BleINfo from "../../../common/bleInfo.uts"
const bleAddress = ref('')
const bleIsConnect = ref(false) // 蓝牙连接标志 true-已连接 false-未连接
@ -120,19 +121,29 @@ function onDisConnect() {
McmChannel.resetChannel()
}
// 连接蓝牙(走全局 ConnectBle:connect → scanServices → 选中 CA10 服务 → 订阅 notify)
// 连接蓝牙(走全局 ConnectBle:connect → scanServices → 订阅 notify,显式 CAIIC-MCM UUID)
// 本页是 tab 页,地址取最近连接过的设备(bleInfo),没有则提示去 Device 页连接
function onConnect() {
if (Ble.isConnected()) {
uni.hideLoading()
bleIsConnect.value = true
return
}
let addr = bleAddress.value
if (addr.length == 0) {
addr = BleINfo.getMac() ?? ""
}
if (addr.length == 0) {
pushMsg("message", "No device. Please scan and connect a device on the Device tab first")
return
}
bleAddress.value = addr
uni.showLoading({
title: "Connecting...",
mask: true
})
pushMsg("start", "connect: " + bleAddress.value)
getApp().globalData.ConnectBle(bleAddress.value, false)
pushMsg("start", "connect: " + addr)
getApp().globalData.ConnectBle(addr, false)
}
// 发送 CLI 命令
@ -190,11 +201,16 @@ onLoad((options: UTSJSONObject | null) => {
}
})
onPageShow(() => {
onShow(() => {
subscribe()
// 从其他 tab 切回时同步连接状态
if (Ble.isConnected() && !bleIsConnect.value) {
bleIsConnect.value = true
pushMsg("message", "already connected: " + Ble.getConnectMac())
}
})
onPageHide(() => {
onHide(() => {
unsubscribe()
})

View File

@ -41,6 +41,12 @@
</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">
@ -70,9 +76,10 @@ import { MyApiResult, BleScanResult } from "@/uni_modules/android-ble"
import * as Ble from "../../src/BluetoothManager"
import * as McmChannel from "../../src/mcm/McmChannel.uts"
import * as McmCmd from "../../src/mcm/McmCommands.uts"
import * as McmInfo from "../../src/mcm/McmInfo.uts"
import * as BleINfo from "../../common/bleInfo.uts"
const DEVICE_NAME_FILTER = "CAIIC-MCM"
const DEVICE_NAME_FILTER = "CAIIC-MCM-20260902"
const POLL_INTERVAL = 2000
const scanArray = ref<BleScanResult[]>([])
@ -85,6 +92,7 @@ const temperature = ref<string>("--")
const fanSpeed = ref<string>("--")
const fwVersion = ref<string>("--")
const bankInfo = ref<string>("--")
const sysInfo = ref<string>("--")
let pollTimer = 0
let pageVisible = false
@ -93,11 +101,12 @@ let pageVisible = false
async function pollParams() {
if (!connected.value || !pageVisible) return
const temp = await McmCmd.getTemperature()
temperature.value = temp != null ? temp.toString() : "--"
if (!connected.value || !pageVisible) return
const fan = await McmCmd.getFanSpeed()
fanSpeed.value = fan != null ? fan.toString() : "--"
// 一次 INFO_QUERY 同时取温度+风扇(协议 V1.1 第 5 节)
const info = await McmInfo.queryDeviceInfo([McmInfo.INFO_CHIP_TEMP, McmInfo.INFO_FAN_RPM])
const t = info.chipTemp
temperature.value = t != null ? t.toFixed(1) : "--"
const f = info.fanRpm
fanSpeed.value = f != null ? f.toString() : "N/A"
}
function stopPolling() {
@ -129,6 +138,8 @@ async function loadDeviceInfo() {
// bankinfo 原文较长,取首个 "APP1/APP2" 关键词展示
bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : b)
}
const s = await McmCmd.getSysInfo()
sysInfo.value = s != null ? s : "--"
}
// ================= 蓝牙事件 =================
@ -157,6 +168,7 @@ function onDisConnectUi() {
fanSpeed.value = "--"
fwVersion.value = "--"
bankInfo.value = "--"
sysInfo.value = "--"
}
function onBleStatueListener(state: number) {
@ -188,6 +200,8 @@ function onBleStarScan() {
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
@ -277,7 +291,10 @@ onPullDownRefresh(() => {
if (!connected.value) {
onBleStarScan()
} else {
uni.stopPullDownRefresh()
// 已连接:下拉刷新设备信息(版本/bank/sysinfo)
loadDeviceInfo().then(() => {
uni.stopPullDownRefresh()
})
}
})
</script>
@ -424,4 +441,17 @@ onPullDownRefresh(() => {
font-size: 26rpx;
color: #999999;
}
.consoleTitle {
font-size: 26rpx;
color: #888888;
margin-bottom: 12rpx;
}
.consoleText {
font-size: 22rpx;
color: #333333;
font-family: monospace;
line-height: 1.5;
}
</style>

View File

@ -26,14 +26,6 @@
<view class="verType">
<text class="colorType">{{ "version: 1.0.1" }}</text>
</view>
<view class="cardBle">
<!-- CLI 调试终端 -->
<view class="cardBox" @tap="onBluetooth">
<uni-icons type="link" size="20" class="input-icon" />
<text class="textType">CLI Terminal</text>
</view>
</view>
</view>
<!-- #ifdef APP -->
</scroll-view>
@ -45,8 +37,6 @@
import { checkToken, getUserName } from '../../common/auth';
import { SERVER_LIST, getBaseUrl, setBaseUrl } from '../../api/httpInstance';
import { onMounted } from 'vue'
import {BleLib,MyApiResult,BleScanResult,BleServices,ScanRssiBR,WNuuid} from "@/uni_modules/android-ble"
import * as Ble from "../../src/BluetoothManager"
const isLogin = ref(false)
const userInfo = ref("login/register")
@ -114,22 +104,6 @@ function onCardTap () {
}
}
// CLI 调试终端(先扫描选择设备,连接后进入终端)
function onBluetooth() {
var open=Ble.isEnabled();// true 为蓝牙开启 false 为关闭
if(open) {
uni.navigateTo({
url: "/pages/bleTest/bluetoothList/bluetoothList"
})
} else {
uni.showToast({
title: 'please turn on bluetooth',
icon: 'error',
duration: 2000
})
}
}
// ======= 生命周期 ========
onLoad((options: UTSJSONObject | null) => {
if (options != null) {
@ -193,32 +167,6 @@ onUnload(() => {
color: #333333;
}
/* 卡片 */
.cardBle {
border-radius: 36rpx;
background-color: #ffffff;
padding: 20rpx;
margin-top: 20rpx;
/* 阴影效果 */
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
/* 横向布局:左图右文 */
display: flex;
flex-direction: row;
align-items: center;
}
.cardBox {
padding: 35rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.textType {
font-size: 25rpx;
}
.verType {
margin-top: 500rpx;
align-items: center;

View File

@ -1,196 +0,0 @@
<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">Firmware</text>
<text class="infoValue">{{ version }}</text>
</view>
<view class="infoRow">
<text class="infoLabel">Bank Info</text>
<text class="infoValue">{{ bankInfo }}</text>
</view>
<view class="btnRow">
<button class="btn" size="mini" :disabled="loading" @tap="refreshAll">
{{ loading ? 'Loading...' : 'Refresh' }}
</button>
</view>
</view>
<!-- sysinfo 控制台输出 -->
<view class="card">
<text class="consoleTitle">sysinfo (system runtime status)</text>
<text class="consoleText">{{ sysInfo }}</text>
</view>
</template>
</view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="uts">
import { MyApiResult } from "@/uni_modules/android-ble"
import * as Ble from "../../src/BluetoothManager"
import * as McmCmd from "../../src/mcm/McmCommands.uts"
const connected = ref<boolean>(false)
const loading = ref<boolean>(false)
const version = ref<string>("--")
const bankInfo = ref<string>("--")
const sysInfo = ref<string>("--")
async function refreshAll() {
uni.stopPullDownRefresh()
if (!Ble.isConnected()) {
connected.value = false
return
}
connected.value = true
loading.value = true
const v = await McmCmd.getVersion()
version.value = v != null ? v : "--"
const b = await McmCmd.getBankInfo()
bankInfo.value = b != null ? b.trim() : "--"
const s = await McmCmd.getSysInfo()
sysInfo.value = s != null ? s : "--"
loading.value = false
}
// ================= 蓝牙状态监听 =================
function onBleStatueListener(state: number) {
if (state == 10000 || state == 10001) {
connected.value = false
version.value = "--"
bankInfo.value = "--"
sysInfo.value = "--"
}
}
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: UTSJSONObject | null) => {
subscribe()
})
onShow(() => {
subscribe()
if (Ble.isConnected()) {
refreshAll()
} else {
connected.value = false
}
})
onHide(() => {
unsubscribe()
})
onUnload(() => {
unsubscribe()
})
onPullDownRefresh(() => {
refreshAll()
})
</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;
}
.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;
}
.consoleTitle {
font-size: 26rpx;
color: #888888;
margin-bottom: 12rpx;
}
.consoleText {
font-size: 22rpx;
color: #333333;
font-family: monospace;
line-height: 1.5;
}
</style>

View File

@ -1,5 +1,11 @@
import {BleLib,MyApiResult,BleScanResult,BleServices,ScanRssiBR,WNuuid,ScanPara,WriteData} from "@/uni_modules/android-ble"
// CAIIC-MCM 设备 GATT(固件 mcm-ddc-ble app_rdtss.h 实际值,小写与 Android UUID.toString 格式一致)
// 注意:ble_protocol.md V1.1 表格写作 ...C72EE001/...C72EE002,与固件实际 ...C72E0001/...C72E0002 不符,以固件为准
export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001"
export const MCM_WRITE_UUID = "00002760-08c2-11e1-9073-0e8ac72e0001" // Write Without Response
export const MCM_NOTIFY_UUID = "00002760-08c2-11e1-9073-0e8ac72e0002" // Notify
// 统一拿到全局 ble 单例(仍然是 App.globalData.ble)
function ble(): BleLib {
const app = getApp()
@ -93,10 +99,11 @@ export function disConnect() {
ble().close()
}
// 开启服务消息通知
// 开启服务消息通知(显式使用 CAIIC-MCM 的服务/notify UUID;onNotityBleData 为当前接口,
// 旧接口 onNotityReadBleData 插件已标记弃用)
export function onNotityReadBleData(readNotification: boolean , cb: (res: MyApiResult) => void) {
const cBle = ble()
cBle.onNotityReadBleData(cBle.getSericUUID(), cBle.getNotityUUID(), readNotification, cb)
cBle.onNotityBleData(MCM_SERVICE_UUID, MCM_NOTIFY_UUID, readNotification, cb)
}
// 获取自动识别读写服务uuid
@ -122,12 +129,14 @@ export function sendData(data: number[], cb: (res: MyApiResult) => void) {
}
// 发送数据(显式写类型):writeType 0=WRITE(有响应) 1=WRITE_NO_RESPONSE(无响应)
// CAIIC-MCM 设备的下行特征仅支持 Write Without Response,必须用 writeType=1
// CAIIC-MCM 设备的下行特征仅支持 Write Without Response,必须用 writeType=1。
// 服务/特征 UUID 用固件实际值显式指定,不依赖插件自动识别
//(下行特征只有 WRITE_NO_RESPONSE 属性,自动识别的 wuuid 可能为空导致写入失败)
export function sendDataBytes(data: number[], writeType: number, cb: (res: MyApiResult) => void) {
const b = ble()
const wd: WriteData = {
serviceId: b.getServiceUUID(),
characteristicId: b.getWriteUUID(),
serviceId: MCM_SERVICE_UUID,
characteristicId: MCM_WRITE_UUID,
writeType: writeType,
data: data,
fenbao: false
@ -135,7 +144,7 @@ export function sendDataBytes(data: number[], writeType: number, cb: (res: MyApi
b.sendData(wd, cb)
}
// 按服务 UUID 前缀选择 service+notify+write 组合(如 "0000CA10"),找到返回 true
// 按服务 UUID 前缀选择 service+notify+write 组合(如 "00002760"),找到返回 true
export function selectService(serviceUuidPrefix: string): boolean {
const b = ble()
const list = b.getUUIDs() as any as WNuuid[]

View File

@ -1,5 +1,5 @@
/**
* CAIIC BLE 协议 V1.0 帧编解码
* CAIIC BLE 协议 V1.1 帧编解码
* 帧格式(小端):[0]=0xCA SOF, [1]=TYPE, [2]=SEQ, [3:4]=LEN(u16 LE),
* [5..]=PAYLOAD(LEN 字节), 末尾 2B CRC16-CCITT(覆盖 TYPE..PAYLOAD, LE)
* 帧开销 7 字节(头 5 + CRC 2),payload 上限 480。
@ -19,6 +19,8 @@ export const TYPE_OTA_BEGIN = 0x10 // {total_size u32, image_crc32 u32, versi
export const TYPE_OTA_DATA = 0x11 // {offset u32, data…}
export const TYPE_OTA_END = 0x12 // {crc32 u32}
export const TYPE_OTA_RSP = 0x1F // {cmd_echo u8, status u8, offset u32}
export const TYPE_INFO_QUERY = 0x20 // {item_id u8}×n;空 payload 或单个 0xFF = 查询全部
export const TYPE_INFO_RSP = 0x21 // TLV 序列 {id u8, len u8, value…}×n
export type BleFrame = {
type: number,
@ -111,3 +113,14 @@ export function readU32LE(data: number[], offset: number): number {
(data[offset + 2] & 0xFF) * 65536 +
(data[offset + 3] & 0xFF) * 16777216
}
/** 小端 u16 解包 */
export function readU16LE(data: number[], offset: number): number {
return (data[offset] & 0xFF) + (data[offset + 1] & 0xFF) * 256
}
/** 小端 i16 解包(处理符号位) */
export function readI16LE(data: number[], offset: number): number {
const v = readU16LE(data, offset)
return v >= 0x8000 ? v - 0x10000 : v
}

View File

@ -8,12 +8,12 @@
* 约定:所有接口的 Promise 只 resolve 不 reject,失败通过返回值表达(uts 兼容性考虑)。
*/
import * as Ble from "../BluetoothManager.uts"
import { MyApiResult } from "@/uni_modules/android-ble"
import { MyApiResult, NotityData } from "@/uni_modules/android-ble"
import {
FrameDecoder, BleFrame, encodeFrame,
TYPE_CLI_REQ, TYPE_CLI_RSP, TYPE_CLI_RSP_END, TYPE_OTA_RSP
TYPE_CLI_REQ, TYPE_CLI_RSP, TYPE_CLI_RSP_END, TYPE_OTA_RSP, TYPE_INFO_QUERY, TYPE_INFO_RSP
} from "./FrameCodec.uts"
import { asciiToBytes, bytesToAscii } from "../commonFunctions.uts"
import { asciiToBytes, bytesToAscii, bytesToHex } from "../commonFunctions.uts"
export type CliResult = {
text: string, // 聚合的应答文本
@ -26,6 +26,11 @@ type CliJob = {
timer: number
}
type InfoJob = {
resolve: (payload: number[] | null) => void,
timer: number
}
const CLI_MAX_CMD_BYTES = 63
let txSeq = 0
@ -37,12 +42,16 @@ let cliJob: CliJob | null = null
let cliChain: Promise<boolean> = Promise.resolve(true)
let writeChain: Promise<boolean> = Promise.resolve(true)
let infoJob: InfoJob | null = null
let infoChain: Promise<boolean> = Promise.resolve(true)
let otaRspHandler: ((payload: number[]) => void) | null = null
let disconnectHandler: (() => void) | null = null
// ================= 接收 =================
function dispatchFrame(frame: BleFrame) {
console.log("[MCM] RX frame type=0x" + frame.type.toString(16) + " seq=" + frame.seq + " payload=" + bytesToHex(frame.payload))
if (frame.type == TYPE_CLI_RSP) {
const job = cliJob
if (job != null) {
@ -65,13 +74,23 @@ function dispatchFrame(frame: BleFrame) {
if (h != null) {
h(frame.payload)
}
} else if (frame.type == TYPE_INFO_RSP) {
const job = infoJob
if (job != null) {
infoJob = null
clearTimeout(job.timer)
job.resolve(frame.payload)
}
}
}
function onBleData(res: MyApiResult) {
if (res.type != 0) return
const data = res.data as number[]
const frames = decoder.feed(data)
// 插件 notify 回调的 data 是 NotityData 结构({data: number[], mac, serviceId, ...}),
// 不是裸字节数组,需取 .data 字段
const nd = res.data as NotityData
console.log("[MCM] RX notify " + nd.data.length + "B: " + bytesToHex(nd.data))
const frames = decoder.feed(nd.data)
for (let i = 0; i < frames.length; i++) {
dispatchFrame(frames[i])
}
@ -86,7 +105,7 @@ function onBleState(state: number) {
}
}
/** 断连/异常时复位通道:挂起的 CLI 命令以 -4 结束 */
/** 断连/异常时复位通道:挂起的 CLI 命令以 -4 结束,挂起的信息查询以 null 结束 */
export function resetChannel() {
decoder.reset()
txSeq = 0
@ -100,6 +119,12 @@ export function resetChannel() {
}
job.resolve({ text: text, status: -4 } as CliResult)
}
const iJob = infoJob
if (iJob != null) {
infoJob = null
clearTimeout(iJob.timer)
iJob.resolve(null)
}
}
// ================= 生命周期 =================
@ -144,6 +169,7 @@ function sendOnce(chunk: number[]): Promise<MyApiResult> {
async function sendFrameImpl(type: number, payload: number[]): Promise<boolean> {
const frame = encodeFrame(type, txSeq & 0xFF, payload)
txSeq = (txSeq + 1) & 0xFF
console.log("[MCM] TX frame type=0x" + type.toString(16) + " len=" + frame.length + ": " + bytesToHex(frame))
let offset = 0
while (offset < frame.length) {
const chunkLen = Math.min(frame.length - offset, maxWriteBytes)
@ -153,6 +179,7 @@ async function sendFrameImpl(type: number, payload: number[]): Promise<boolean>
}
const res = await sendOnce(chunk)
if (res.type != 0) {
console.log("[MCM] TX write failed: type=" + res.type + " msg=" + res.message)
return false
}
offset += chunkLen
@ -169,6 +196,7 @@ export function sendFrame(type: number, payload: number[]): Promise<boolean> {
function sendCliImpl(cmd: string, timeoutMs: number): Promise<CliResult> {
return new Promise((resolve) => {
// 固件 CLI_REQ 为整行 payload 直接执行(协议 V1.1:无结束符)
const payload = asciiToBytes(cmd)
if (payload.length > CLI_MAX_CMD_BYTES) {
resolve({ text: "", status: -2 } as CliResult)
@ -207,3 +235,40 @@ export function sendCli(cmd: string, timeoutMs: number = 3000): Promise<CliResul
cliChain = p.then((): boolean => true)
return p
}
// ================= 信息查询(INFO_QUERY / INFO_RSP) =================
function queryInfoImpl(items: number[], timeoutMs: number): Promise<number[] | null> {
return new Promise((resolve) => {
const job: InfoJob = {
resolve: resolve,
timer: 0
} as InfoJob
job.timer = setTimeout(() => {
if (infoJob == job) {
infoJob = null
resolve(null)
}
}, timeoutMs)
infoJob = job
// payload = {item_id u8}×n;空数组 = 查询全部
sendFrame(TYPE_INFO_QUERY, items).then((ok) => {
if (!ok && infoJob == job) {
infoJob = null
clearTimeout(job.timer)
resolve(null)
}
})
})
}
/**
* 设备信息查询:串行执行,返回 INFO_RSP 的 TLV payload;
* 超时/发送失败/断连返回 null;只 resolve 不 reject
*/
export function queryInfo(items: number[], timeoutMs: number = 2000): Promise<number[] | null> {
attach()
const p = infoChain.then((): Promise<number[] | null> => queryInfoImpl(items, timeoutMs))
infoChain = p.then((): boolean => true)
return p
}

View File

@ -1,34 +1,22 @@
/**
* CAIIC-MCM 业务命令封装(基于 CLI 透传)
* 命令名集中在顶部常量,固件侧命令定名后只需改这里。
* 注意:固件当前已实现 version/sysinfo/bankinfo;
* 温度/风扇命令(默认 temp/fan)待固件新增,解析失败返回 null 由页面显示 "--"。
* CAIIC-MCM 业务命令封装
* 温度/风扇/固件版本走 INFO_QUERY(协议 V1.1 第 5 节);
* sysinfo/bankinfo 等系统状态仍走 CLI 透传,命令名集中在顶部常量。
*/
import { sendCli, CliResult } from "./McmChannel.uts"
import {
queryDeviceInfo, formatVersion,
INFO_FW_VERSION, INFO_CHIP_TEMP, INFO_FAN_RPM
} from "./McmInfo.uts"
const CMD_VERSION = "version"
const CMD_SYSINFO = "sysinfo"
const CMD_BANKINFO = "bankinfo"
const CMD_TEMPERATURE = "temp"
const CMD_FAN_SPEED = "fan"
/** 从应答文本中提取第一个数值(整数或小数),提取不到返回 null */
function parseFirstNumber(text: string): number | null {
const m = text.match(/-?\d+(\.\d+)?/)
if (m == null) return null
const m0 = m[0]
if (m0 == null) return null
const v = parseFloat(m0)
if (isNaN(v)) return null
return v
}
/** 固件版本号原文,如 "V1.00.01";失败返回 null */
/** 固件版本号,如 "V1.00.01"(来自 INFO_QUERY FW_VERSION);失败返回 null */
export async function getVersion(): Promise<string | null> {
const r: CliResult = await sendCli(CMD_VERSION)
if (r.status != 0) return null
const t = r.text.trim()
return t.length > 0 ? t : null
const info = await queryDeviceInfo([INFO_FW_VERSION])
const v = info.fwVersion
return v != null ? formatVersion(v) : null
}
/** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */
@ -45,18 +33,16 @@ export async function getBankInfo(): Promise<string | null> {
return r.text
}
/** 温度(单位由固件定义,℃),失败返回 null */
/** 温度(℃),读取失败返回 null */
export async function getTemperature(): Promise<number | null> {
const r: CliResult = await sendCli(CMD_TEMPERATURE)
if (r.status != 0) return null
return parseFirstNumber(r.text)
const info = await queryDeviceInfo([INFO_CHIP_TEMP])
return info.chipTemp
}
/** 风扇转速(RPM),失败返回 null */
/** 风扇转速(RPM),无测速硬件/读取失败返回 null */
export async function getFanSpeed(): Promise<number | null> {
const r: CliResult = await sendCli(CMD_FAN_SPEED)
if (r.status != 0) return null
return parseFirstNumber(r.text)
const info = await queryDeviceInfo([INFO_FAN_RPM])
return info.fanRpm
}
/** 通用 CLI 命令(调试终端用) */

85
src/mcm/McmInfo.uts Normal file
View File

@ -0,0 +1,85 @@
/**
* CAIIC-MCM 设备信息查询(协议 V1.1 第 5 节:INFO_QUERY / INFO_RSP)
* INFO_RSP payload 为 TLV 序列 {id u8, len u8, value…}×n,数值均小端;
* 设备不认识的 id 不出现在应答中;解析时未知 id 按 len 跳过。
*/
import { queryInfo } from "./McmChannel.uts"
import { readU16LE, readI16LE, readU32LE } from "./FrameCodec.uts"
// 信息项 id
export const INFO_FW_VERSION = 0x01 // u32 固件版本号,如 0x00010001 = V1.00.01
export const INFO_CHIP_TEMP = 0x02 // i16 芯片温度,单位 0.1℃(0x7FFF = 读取失败)
export const INFO_FAN_RPM = 0x03 // u16 风扇转速 rpm(0xFFFF = 无此硬件/未接入)
export const INFO_VDD_MV = 0x04 // u16 电源电压 mV(0 = 读取失败)
export const INFO_UPTIME_S = 0x05 // u32 系统运行时间 s
export const INFO_FREE_HEAP = 0x06 // u32 FreeRTOS 堆剩余 B
export type DeviceInfo = {
fwVersion: number | null, // 原始 u32 版本号
chipTemp: number | null, // 温度,已换算为 ℃
fanRpm: number | null, // 风扇转速 rpm;null = 无硬件/未应答
vddMv: number | null, // 电源电压 mV
uptimeS: number | null, // 运行时间 s
freeHeap: number | null // 堆剩余 B
}
function emptyInfo(): DeviceInfo {
return {
fwVersion: null,
chipTemp: null,
fanRpm: null,
vddMv: null,
uptimeS: null,
freeHeap: null
} as DeviceInfo
}
/** 解析 INFO_RSP 的 TLV payload;未知 id 按 len 跳过,数值长度不符的项忽略 */
export function parseTlv(payload: number[]): DeviceInfo {
const info = emptyInfo()
let i = 0
while (i + 2 <= payload.length) {
const id = payload[i]
const len = payload[i + 1]
if (i + 2 + len > payload.length) break // 长度越界,丢弃剩余
const off = i + 2
if (id == INFO_FW_VERSION && len == 4) {
info.fwVersion = readU32LE(payload, off)
} else if (id == INFO_CHIP_TEMP && len == 2) {
const raw = readI16LE(payload, off)
info.chipTemp = raw == 0x7FFF ? null : raw / 10.0
} else if (id == INFO_FAN_RPM && len == 2) {
const raw = readU16LE(payload, off)
info.fanRpm = raw == 0xFFFF ? null : raw
} else if (id == INFO_VDD_MV && len == 2) {
const raw = readU16LE(payload, off)
info.vddMv = raw == 0 ? null : raw
} else if (id == INFO_UPTIME_S && len == 4) {
info.uptimeS = readU32LE(payload, off)
} else if (id == INFO_FREE_HEAP && len == 4) {
info.freeHeap = readU32LE(payload, off)
}
i += 2 + len
}
return info
}
/**
* 查询设备信息;items 为空数组 = 查询全部。
* 超时/断连/发送失败返回各字段全 null 的 DeviceInfo。
*/
export async function queryDeviceInfo(items: number[]): Promise<DeviceInfo> {
const payload = await queryInfo(items, 2000)
if (payload == null) return emptyInfo()
return parseTlv(payload)
}
/** 固件版本号 u32 格式化,如 0x00010001 → "V1.00.01" */
export function formatVersion(v: number): string {
const major = Math.floor(v / 65536) % 256
const minor = Math.floor(v / 256) % 256
const patch = v % 256
const ms = minor < 10 ? "0" + minor : "" + minor
const ps = patch < 10 ? "0" + patch : "" + patch
return "V" + major + "." + ms + "." + ps
}