feat: add user deactivation and vendor BLE protocol
这个提交包含在:
+1
@@ -10,4 +10,5 @@ admin-console/dist/
|
|||||||
docs/reference/小程序及后台管理软件开发资料/
|
docs/reference/小程序及后台管理软件开发资料/
|
||||||
软件系统说明.docx
|
软件系统说明.docx
|
||||||
*.docx
|
*.docx
|
||||||
|
!docs/protocols/协议简述.docx
|
||||||
cloud/sql/
|
cloud/sql/
|
||||||
|
|||||||
@@ -84,6 +84,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
<view class="page-card" v-if="user">
|
<view class="page-card" v-if="user">
|
||||||
<view class="page-header">
|
<view class="page-header">
|
||||||
<text class="page-title">👥 用户详情 - {{ user.nickname || user.user_id }}</text>
|
<text class="page-title">👥 用户详情 - {{ user.nickname || user.user_id }}</text>
|
||||||
|
<view class="header-actions">
|
||||||
|
<button class="btn-default btn-sm" v-if="user.devices && user.devices.length > 0" @click="onUnbindAll">解绑全部设备</button>
|
||||||
|
<button class="btn-danger btn-sm" v-if="user.status !== 3" @click="onDeactivate">注销用户</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="user-profile">
|
<view class="user-profile">
|
||||||
@@ -12,7 +16,10 @@
|
|||||||
<view class="profile-info">
|
<view class="profile-info">
|
||||||
<text class="user-name">{{ user.nickname || '-' }}</text>
|
<text class="user-name">{{ user.nickname || '-' }}</text>
|
||||||
<text class="user-phone">{{ user.phone || '-' }}</text>
|
<text class="user-phone">{{ user.phone || '-' }}</text>
|
||||||
<text class="badge badge-success">{{ subStatusText(user.subscription_status) }}</text>
|
<view class="profile-badges">
|
||||||
|
<text :class="statusBadge(user.status)">{{ statusText(user.status) }}</text>
|
||||||
|
<text :class="user.subscription_status === 1 ? 'badge badge-success' : 'badge badge-default'">{{ subStatusText(user.subscription_status) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -35,6 +42,10 @@
|
|||||||
<view class="page-card" v-if="user">
|
<view class="page-card" v-if="user">
|
||||||
<view class="card-title">基本信息</view>
|
<view class="card-title">基本信息</view>
|
||||||
<view class="info-grid">
|
<view class="info-grid">
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="info-label">用户状态</text>
|
||||||
|
<text class="info-value"><text :class="statusBadge(user.status)">{{ statusText(user.status) }}</text></text>
|
||||||
|
</view>
|
||||||
<view class="info-item">
|
<view class="info-item">
|
||||||
<text class="info-label">绑定设备</text>
|
<text class="info-label">绑定设备</text>
|
||||||
<text class="info-value">{{ user.devices && user.devices.length > 0 ? user.devices[0].device_name || user.devices[0].device_id : '无' }}</text>
|
<text class="info-value">{{ user.devices && user.devices.length > 0 ? user.devices[0].device_name || user.devices[0].device_id : '无' }}</text>
|
||||||
@@ -54,6 +65,29 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="page-card" v-if="user && user.devices && user.devices.length > 0">
|
||||||
|
<view class="card-title">绑定设备</view>
|
||||||
|
<view class="data-table">
|
||||||
|
<view class="t-header">
|
||||||
|
<view class="t-row">
|
||||||
|
<text class="t-th flex2">设备编号</text>
|
||||||
|
<text class="t-th flex2">设备名称</text>
|
||||||
|
<text class="t-th flex1">操作</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="t-body">
|
||||||
|
<view class="t-row" v-for="device in user.devices" :key="device.device_id">
|
||||||
|
<text class="t-td flex2">{{ device.device_id }}</text>
|
||||||
|
<text class="t-td flex2">{{ device.device_name || '-' }}</text>
|
||||||
|
<text class="t-td flex1">
|
||||||
|
<text class="action-link" @click="onDeviceDetail(device)">详情</text>
|
||||||
|
<text class="action-link danger-link" @click="onUnbindDevice(device)">解绑</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="page-card" v-if="user && user.recent_treatments">
|
<view class="page-card" v-if="user && user.recent_treatments">
|
||||||
<view class="card-title">护理记录</view>
|
<view class="card-title">护理记录</view>
|
||||||
<view class="data-table">
|
<view class="data-table">
|
||||||
@@ -80,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { get } from '../utils/request'
|
import { get, post } from '../utils/request'
|
||||||
import { formatDateShort } from '../utils/format'
|
import { formatDateShort } from '../utils/format'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -115,6 +149,68 @@ export default {
|
|||||||
goRecords() {
|
goRecords() {
|
||||||
this.$emit('navigate', 'record', { user_id: this.userId })
|
this.$emit('navigate', 'record', { user_id: this.userId })
|
||||||
},
|
},
|
||||||
|
onDeviceDetail(device) {
|
||||||
|
this.$emit('navigate', 'device-detail', { device_id: device.device_id })
|
||||||
|
},
|
||||||
|
onUnbindDevice(device) {
|
||||||
|
var self = this
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认解绑',
|
||||||
|
content: '确定要解绑设备 ' + device.device_id + ' 吗?',
|
||||||
|
success: async function (res) {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
await post('/api/v1/admin/users/' + self.userId + '/unbind', { device_id: device.device_id })
|
||||||
|
uni.showToast({ title: '解绑成功', icon: 'success' })
|
||||||
|
self.loadUser()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '解绑失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUnbindAll() {
|
||||||
|
var self = this
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认解绑',
|
||||||
|
content: '确定要解绑该用户当前绑定的全部设备吗?',
|
||||||
|
success: async function (res) {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
await post('/api/v1/admin/users/' + self.userId + '/unbind', {})
|
||||||
|
uni.showToast({ title: '解绑成功', icon: 'success' })
|
||||||
|
self.loadUser()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '解绑失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeactivate() {
|
||||||
|
var self = this
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认注销',
|
||||||
|
content: '注销后该用户将无法继续登录,当前绑定设备也会被解绑。确定继续吗?',
|
||||||
|
success: async function (res) {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
await post('/api/v1/admin/users/' + self.userId + '/deactivate', {})
|
||||||
|
uni.showToast({ title: '已注销', icon: 'success' })
|
||||||
|
self.loadUser()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: '注销失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
statusText(status) {
|
||||||
|
const map = { 1: '正常', 2: '禁用', 3: '已注销' }
|
||||||
|
return map[status] || '未知'
|
||||||
|
},
|
||||||
|
statusBadge(status) {
|
||||||
|
const map = { 1: 'badge badge-success', 2: 'badge badge-warning', 3: 'badge badge-error' }
|
||||||
|
return map[status] || 'badge badge-default'
|
||||||
|
},
|
||||||
subStatusText(status) {
|
subStatusText(status) {
|
||||||
return status === 1 ? '已订阅' : '未订阅'
|
return status === 1 ? '已订阅' : '未订阅'
|
||||||
},
|
},
|
||||||
@@ -144,6 +240,12 @@ export default {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -187,7 +289,9 @@ export default {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.profile-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,4 +368,8 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-link {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<text class="t-th flex1">绑定设备</text>
|
<text class="t-th flex1">绑定设备</text>
|
||||||
<text class="t-th flex1">护理次数</text>
|
<text class="t-th flex1">护理次数</text>
|
||||||
<text class="t-th flex1">订阅状态</text>
|
<text class="t-th flex1">订阅状态</text>
|
||||||
|
<text class="t-th flex1">用户状态</text>
|
||||||
<text class="t-th flex2">注册时间</text>
|
<text class="t-th flex2">注册时间</text>
|
||||||
<text class="t-th flex1">操作</text>
|
<text class="t-th flex1">操作</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -41,6 +42,9 @@
|
|||||||
<text class="t-td flex1">
|
<text class="t-td flex1">
|
||||||
<text :class="subBadge(item.subscription_status)">{{ subStatusText(item.subscription_status) }}</text>
|
<text :class="subBadge(item.subscription_status)">{{ subStatusText(item.subscription_status) }}</text>
|
||||||
</text>
|
</text>
|
||||||
|
<text class="t-td flex1">
|
||||||
|
<text :class="userStatusBadge(item.status)">{{ userStatusText(item.status) }}</text>
|
||||||
|
</text>
|
||||||
<text class="t-td flex2">{{ formatDate(item.created_at) }}</text>
|
<text class="t-td flex2">{{ formatDate(item.created_at) }}</text>
|
||||||
<text class="t-td flex1">
|
<text class="t-td flex1">
|
||||||
<text class="action-link" @click="onDetail(item.user_id)">详情</text>
|
<text class="action-link" @click="onDetail(item.user_id)">详情</text>
|
||||||
@@ -114,15 +118,24 @@ export default {
|
|||||||
subBadge(status) {
|
subBadge(status) {
|
||||||
return status === 1 ? 'badge badge-success' : 'badge badge-default'
|
return status === 1 ? 'badge badge-success' : 'badge badge-default'
|
||||||
},
|
},
|
||||||
|
userStatusText(status) {
|
||||||
|
const map = { 1: '正常', 2: '禁用', 3: '已注销' }
|
||||||
|
return map[status] || '未知'
|
||||||
|
},
|
||||||
|
userStatusBadge(status) {
|
||||||
|
const map = { 1: 'badge badge-success', 2: 'badge badge-warning', 3: 'badge badge-error' }
|
||||||
|
return map[status] || 'badge badge-default'
|
||||||
|
},
|
||||||
formatDate: formatDateShort,
|
formatDate: formatDateShort,
|
||||||
async onExport() {
|
async onExport() {
|
||||||
try {
|
try {
|
||||||
const data = await get('/api/v1/admin/users', { page: 1, page_size: 9999, keyword: this.keyword })
|
const data = await get('/api/v1/admin/users', { page: 1, page_size: 9999, keyword: this.keyword })
|
||||||
const records = data.records || []
|
const records = data.records || []
|
||||||
exportCSV('users_' + new Date().toISOString().slice(0, 10) + '.csv',
|
exportCSV('users_' + new Date().toISOString().slice(0, 10) + '.csv',
|
||||||
['用户ID', '昵称', '手机号', '绑定设备数', '护理次数', '订阅状态', '注册时间'],
|
['用户ID', '昵称', '手机号', '绑定设备数', '护理次数', '订阅状态', '用户状态', '注册时间'],
|
||||||
records.map(function (r) {
|
records.map(function (r) {
|
||||||
return [r.user_id, r.nickname || '', r.phone || '', r.device_count || 0, r.treatment_count || 0, r.subscription_status === 1 ? '已订阅' : '未订阅', r.created_at ? r.created_at.slice(0, 10) : '']
|
var userStatus = { 1: '正常', 2: '禁用', 3: '已注销' }[r.status] || '未知'
|
||||||
|
return [r.user_id, r.nickname || '', r.phone || '', r.device_count || 0, r.treatment_count || 0, r.subscription_status === 1 ? '已订阅' : '未订阅', userStatus, r.created_at ? r.created_at.slice(0, 10) : '']
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
- `requirements/软件系统说明.docx`
|
- `requirements/软件系统说明.docx`
|
||||||
- `requirements/光子美容仪软件系统说明.docx`
|
- `requirements/光子美容仪软件系统说明.docx`
|
||||||
- `design/01-BLE通信协议明细.md`
|
- `design/01-BLE通信协议明细.md`
|
||||||
|
- `protocols/BLE协议冲突与问题清单.md`
|
||||||
|
- `protocols/协议简述-补充解析.md`
|
||||||
- `design/02-小程序业务流程与状态机.md`
|
- `design/02-小程序业务流程与状态机.md`
|
||||||
- `planning/需求缺口清单.md`
|
- `planning/需求缺口清单.md`
|
||||||
- `reviews/代码与设计文档对比分析.md` — 历史阶段评审,不能代表当前实现
|
- `reviews/代码与设计文档对比分析.md` — 历史阶段评审,不能代表当前实现
|
||||||
|
|||||||
@@ -14,6 +14,36 @@
|
|||||||
- `FFE2`:OTA 升级服务
|
- `FFE2`:OTA 升级服务
|
||||||
- 业务流程中至少存在以下设备交互阶段:连接设备、确认佩戴、自动扫描、开始治疗
|
- 业务流程中至少存在以下设备交互阶段:连接设备、确认佩戴、自动扫描、开始治疗
|
||||||
|
|
||||||
|
## 供应商补充协议摘录(2026-05-11)
|
||||||
|
|
||||||
|
根目录新增的 `协议简述.docx` 已归档到 `docs/protocols/协议简述.docx`,解析稿见 `docs/protocols/协议简述-补充解析.md`。
|
||||||
|
|
||||||
|
补充文档主要给出 `FFE1` 数据通信相关信息:
|
||||||
|
|
||||||
|
| UUID/字段 | 名称 | 属性/说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FFE1` | 数据通信服务 / Command | 文档中同时写作服务和 Command,`Write+Read`,下发控制指令 |
|
||||||
|
| `FFE4` | Status | `Read + Notify`,设备状态及 ADC 值数据上报 |
|
||||||
|
|
||||||
|
Command 参数区被描述为 33 字节:
|
||||||
|
|
||||||
|
- IO1-IO5,每个 IO 含 4 个光强字段:红灯、红外、紫外、暖黄
|
||||||
|
- IO1-IO5,每个 IO 含 2 字节电流增益
|
||||||
|
- Byte31-Byte32:保持时间高/低字节
|
||||||
|
- Byte33:异或校验
|
||||||
|
|
||||||
|
### 与当前实现的关键差异
|
||||||
|
|
||||||
|
由于项目当前优先目标是尽快跑通真机,新补充协议先作为当前执行协议:`FFE1` 写 Command,`FFE4` 订阅 Status,Command 直接写 33 字节参数块。旧 `0xAA 0x55 | length | type | payload | XOR checksum` 帧协议暂作为历史参考和后续兼容方案保留。
|
||||||
|
|
||||||
|
需要硬件侧优先确认:
|
||||||
|
|
||||||
|
1. `FFE1`、`FFE4`、`FFE5` 的最终服务/特征分配。
|
||||||
|
2. Command 写入是否为裸 33 字节,还是仍需现有帧封装。
|
||||||
|
3. Byte33 XOR 的计算范围。
|
||||||
|
4. Byte31-Byte32 保持时间单位和字节序。
|
||||||
|
5. Status/ADC 上报的完整字节布局。
|
||||||
|
|
||||||
## 帧结构草案
|
## 帧结构草案
|
||||||
|
|
||||||
### 帧总体格式
|
### 帧总体格式
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# BLE 协议冲突与问题清单
|
||||||
|
|
||||||
|
更新时间:2026-05-11
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前补充协议与旧 BLE 协议、原小程序实现存在实质冲突。由于项目当前优先目标是尽快真机跑通,执行口径调整为:**先以新补充协议为准,旧协议作为历史参考和后续逐步补齐的扩展协议**。
|
||||||
|
|
||||||
|
小程序已先接入新协议的最小可跑通路径:`FFE1` 数据通信服务 / Command 写入,`FFE4` Status 订阅,Command 直接写 33 字节参数块。旧协议中的 `0xAA 0x55 | length | type | payload | XOR checksum` 帧结构暂时保留在代码中,但不作为当前治疗参数下发主路径。
|
||||||
|
|
||||||
|
新补充的 `协议简述.docx` 仍然缺少绑定、停止、查询、ACK、Status/ADC 完整字节表等内容;这些不再阻塞首轮跑通,但会影响后续完整交互和状态展示,需要继续补齐。
|
||||||
|
|
||||||
|
## 资料来源
|
||||||
|
|
||||||
|
| 来源 | 当前用途 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docs/protocols/通信协议_小程序与光子美容仪设备.docx` | 旧 BLE 协议来源 | 现有 BLE 代码和架构文档主要基于此协议 |
|
||||||
|
| `docs/protocols/协议简述.docx` | 供应商补充协议 | 2026-05-11 归档,内容较短,主要描述 33 字节 Command 和 Status notify |
|
||||||
|
| `docs/protocols/协议简述-补充解析.md` | 补充协议解析稿 | 已抽取补充协议中的 UUID、特征、33 字节字段 |
|
||||||
|
| `miniprogram/services/ble/protocol.js` | 当前实现 | 新协议优先:`PROTOCOL_MODE = vendor_33`,生成 33 字节 Command |
|
||||||
|
| `miniprogram/services/ble/commands.js` | 当前实现 | `setParams()` 直接写 33 字节;旧命令保留为后续兼容 |
|
||||||
|
| `miniprogram/services/ble/connection.js` | 当前实现 | 发现 `FFE1` command,订阅 `FFE4` status |
|
||||||
|
|
||||||
|
## P0 阻塞问题
|
||||||
|
|
||||||
|
这些问题不解决,真机 BLE 联调大概率不通。
|
||||||
|
|
||||||
|
| 问题 | 旧协议/当前代码 | 补充协议 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| UUID 分配冲突 | `FFE1` 是数据通信服务,`FFE4` 是 Command,`FFE5` 是 Status | `FFE1` 同时写作数据通信服务和 Command,`FFE4` 是 Status | 小程序可能写错 characteristic,也可能订阅错 notify |
|
||||||
|
| Command 写入格式冲突 | 写入完整帧:`0xAA55 + length + type + payload + checksum` | 只描述 33 字节 Command 参数块 | 如果固件只收裸 33 字节,当前写入会被拒绝;如果固件收旧帧,按补充协议改也会失败 |
|
||||||
|
| 命令字机制缺失 | 有 `0x01` 设置参数、`0x02` 启动、`0x03` 停止、`0x04` 查询、`0x05` 绑定、`0x06` 解绑 | 未出现命令 type | 无法判断 33 字节是“设置参数”还是“设置并启动”,也无法覆盖停止/查询/绑定 |
|
||||||
|
| ACK 机制冲突 | 每次写命令会等待 `0x22 ACK`,5 秒超时后失败 | 未说明 ACK/NACK | 即使设备执行了命令,只要不回 ACK,小程序仍会判定失败并重试 |
|
||||||
|
| Status notify 格式缺失 | 当前解析 `0x21 STATUS_REPORT` 的 14 字节 payload | 只写“设备状态及 ADC 值,Read + Notify” | 小程序无法正确解析设备状态、电量、温度、ADC 或错误码 |
|
||||||
|
| 校验规则不一致 | 当前 XOR 覆盖帧头、长度、type、payload | Byte33 是异或校验,但未写范围 | 校验范围不同会导致设备丢包或小程序丢通知 |
|
||||||
|
|
||||||
|
## P1 高风险不一致
|
||||||
|
|
||||||
|
这些问题不一定让连接失败,但会让参数含义、治疗流程或数据记录出错。
|
||||||
|
|
||||||
|
| 问题 | 当前实现 | 补充协议/文档缺口 | 风险 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 治疗参数模型不同 | `region_mask + wavelength + brightness + duration_ms + mode` | IO1-IO5 各自包含红灯、红外、紫外、暖黄光强和电流增益 | 当前 UI 和协议无法表达每个 IO 的独立光强/电流 |
|
||||||
|
| 时间字段不同 | `duration_ms` 为 4 字节大端毫秒 | Byte31-32 为保持时间高/低字节 | 单位、范围和字节序不明,可能导致治疗时长错误 |
|
||||||
|
| IO 与面部区域无映射 | 当前用 7 个区域 bit:左脸、右脸、额头、下巴、鼻部、左眼、右眼 | 只定义 IO1-IO5 | 无法从小程序区域选择稳定生成 IO 参数 |
|
||||||
|
| 光强取值范围不明 | 当前 brightness 默认 200,波长单选 | 四种灯光每个 IO 一个强度 | 不知道是 0-100、0-255、PWM、DAC 还是其他单位 |
|
||||||
|
| 电流增益定义不明 | 当前没有电流增益字段 | 每个 IO 2 字节电流增益 | 不知道单位、范围、字节序和安全上限 |
|
||||||
|
| 启动/停止语义不明 | `SET_PARAMS` 后还会发 `START`,停止发 `STOP` | 补充协议只描述 Command 参数块 | 如果写 33 字节会立即启动,当前双命令流程不适用;如果不会启动,还缺 START 定义 |
|
||||||
|
| 绑定命令长度疑似错误 | 架构文档写 `userId(8B)`,代码实际用 `uint32ToBytes()` 生成 4B | 补充协议未覆盖绑定 | 绑定流程可能与固件预期不一致,需要单独确认 |
|
||||||
|
|
||||||
|
## P2 文档和实现需要收敛的问题
|
||||||
|
|
||||||
|
| 问题 | 当前状态 | 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 权威协议未指定 | 旧协议、架构文档、补充协议并存 | 明确“最终以哪一份为准”,或把补充协议定义为旧协议的某个命令 payload |
|
||||||
|
| `FFE1` 被同时当服务和特征 | 补充协议中表述冲突 | 要求供应商给出完整 GATT 表:service UUID、characteristic UUID、properties |
|
||||||
|
| Status/ADC 字节表缺失 | 只有功能描述,无字段定义 | 补齐每个字节的含义、长度、单位、字节序和上报频率 |
|
||||||
|
| 错误码缺失 | 当前代码有 `0x00-0x0C` 错误码 | 确认固件是否实现这些错误码,或给出新错误码表 |
|
||||||
|
| 分包/MTU 未定义 | 当前默认一条写入就是完整命令 | 33 字节裸包可能超过默认 20 字节 BLE payload,需要确认 MTU 或分包策略 |
|
||||||
|
| OTA 未受补充协议覆盖 | 当前旧协议包含 OTA 特征 | 补充协议没有 OTA 说明 | OTA 是否仍按旧协议,需要确认 |
|
||||||
|
|
||||||
|
## 对当前代码的具体影响
|
||||||
|
|
||||||
|
### `protocol.js`
|
||||||
|
|
||||||
|
- `SERVICE.DATA_COMM = FFE1` 与补充协议的 `FFE1 Command` 表述冲突。
|
||||||
|
- `CHAR.COMMAND = FFE4` 与补充协议的 `FFE4 Status` 冲突。
|
||||||
|
- `CHAR.STATUS = FFE5` 在补充协议中未出现。
|
||||||
|
- `buildFrame()` 固定生成 `0xAA55` 帧,无法生成补充协议的裸 33 字节 Command。
|
||||||
|
- `parseFrame()` 只接受 `0xAA55` 帧,无法解析补充协议可能上报的裸 Status/ADC 数据。
|
||||||
|
- `parseStatusReport()` 固定要求 14 字节旧格式,与补充协议的 ADC 上报不匹配。
|
||||||
|
|
||||||
|
### `commands.js`
|
||||||
|
|
||||||
|
- `writeCommand()` 每次自动追加 `seq` 并等待 ACK;补充协议没有 `seq` 和 ACK。
|
||||||
|
- `setParams()` 只能下发单一波长、全局亮度和全局时长,无法生成 IO1-IO5 的 33 字节参数矩阵。
|
||||||
|
- `startTreatment()`、`stopTreatment()`、`queryStatus()`、`bindDevice()`、`unbindDevice()` 都依赖旧命令字;补充协议没有对应定义。
|
||||||
|
- `bindDevice()` 目前把 `userId` 编成 4 字节,但旧架构文档写的是 8 字节,存在独立的实现疑点。
|
||||||
|
|
||||||
|
### `connection.js`
|
||||||
|
|
||||||
|
- 发现特征时会把 `FFE4` 当命令写入特征,把 `FFE5` 当状态通知特征。
|
||||||
|
- 如果新固件实际用 `FFE1` 写命令、`FFE4` 发通知,当前连接后会找不到正确 command/status,或者写入/订阅目标错误。
|
||||||
|
- notify 回调会先调用 `parseFrame()`;如果新固件直接上报 Status/ADC 裸数据,当前代码会直接丢弃。
|
||||||
|
|
||||||
|
## 需要供应商/固件侧确认的问题
|
||||||
|
|
||||||
|
优先级按联调阻塞程度排序:
|
||||||
|
|
||||||
|
1. 最终 GATT 表:`FFE0`、`FFE1`、`FFE2`、`FFE3`、`FFE4`、`FFE5`、`FFE6`、`FFE7`、`FFE8`、`FFE9` 分别是什么,属性是 read/write/notify 哪些。
|
||||||
|
2. Command 写入到底是旧帧格式,还是直接写 33 字节。
|
||||||
|
3. 如果是 33 字节,是否需要分包;如果需要,分包格式是什么。
|
||||||
|
4. Byte33 XOR 对哪些字节计算,初始值是多少,校验字节本身是否参与最终校验。
|
||||||
|
5. 33 字节 Command 的语义:只设置参数,还是设置后立即启动。
|
||||||
|
6. 停止、暂停、恢复、查询状态、绑定、解绑是否仍有命令;命令格式是什么。
|
||||||
|
7. 是否需要 ACK/NACK;如果需要,ACK/NACK 的 characteristic、字节格式、错误码表是什么。
|
||||||
|
8. Byte31-32 保持时间的单位、范围和字节序。
|
||||||
|
9. IO1-IO5 与灯板/面部区域的对应关系。
|
||||||
|
10. 红灯、红外、紫外、暖黄光强的取值范围、单位和安全限制。
|
||||||
|
11. 电流增益高/低字节的单位、范围、字节序和安全限制。
|
||||||
|
12. Status/ADC notify 的完整字节布局、上报周期和单位。
|
||||||
|
13. 旧协议中的 `FFE5`、`FFE6`、OTA 相关特征是否还保留。
|
||||||
|
14. 绑定流程是否由 BLE 完成;`userId` 是 4 字节还是 8 字节。
|
||||||
|
|
||||||
|
## 建议处理方案
|
||||||
|
|
||||||
|
### 方案 A:补充协议只是 `SET_PARAMS` 的新 payload
|
||||||
|
|
||||||
|
如果供应商确认旧帧、旧 UUID、旧 ACK 都保留,只是 `0x01 SET_PARAMS` 的 payload 变成 33 字节:
|
||||||
|
|
||||||
|
- 保留 `FFE1` 服务、`FFE4` command、`FFE5` status。
|
||||||
|
- 保留 `0xAA55` 帧封装、`type`、`seq`、ACK。
|
||||||
|
- 新增 `buildVendorCommand33()` 生成 33 字节参数块。
|
||||||
|
- 修改 `setParams()`,让旧 UI 参数转换成 IO1-IO5 默认矩阵。
|
||||||
|
- 补 Status/ADC 新解析逻辑。
|
||||||
|
|
||||||
|
这是对当前代码影响最小的路线。
|
||||||
|
|
||||||
|
### 方案 B:补充协议完全替代旧协议
|
||||||
|
|
||||||
|
如果供应商确认新固件只接受 `FFE1` 裸 33 字节写入、`FFE4` 裸 Status notify:
|
||||||
|
|
||||||
|
- `protocol.js` 需要新增或切换成新协议模式。
|
||||||
|
- `connection.js` 的 command/status characteristic 发现逻辑要改。
|
||||||
|
- `commands.js` 的 ACK、seq、旧命令字重试机制不能直接沿用。
|
||||||
|
- 绑定、停止、查询状态、异常处理、治疗完成都需要供应商补齐协议后再实现。
|
||||||
|
|
||||||
|
这个方案会影响小程序治疗主链路,不能只改一个 UUID。
|
||||||
|
|
||||||
|
## 当前建议
|
||||||
|
|
||||||
|
当前已经按方案 B 的最小版本推进。后续建议:
|
||||||
|
|
||||||
|
1. 真机联调优先验证 `FFE1` 写入 33 字节是否能启动设备。
|
||||||
|
2. 抓取 `FFE4` notify 原始字节,补 Status/ADC 字节表。
|
||||||
|
3. 要求供应商补停止、查询、异常、完成、绑定这些命令是否存在。
|
||||||
|
4. 后续再决定是否把旧 `0xAA55` 帧协议作为兼容模式保留。
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 协议简述补充解析
|
||||||
|
|
||||||
|
来源:仓库根目录 `协议简述.docx`,已归档到 `docs/protocols/协议简述.docx`。
|
||||||
|
|
||||||
|
本文只记录该补充文档的可读内容和与当前实现的差异判断。原 DOCX 内容较短,主要描述 `FFE1` 数据通信服务下的控制/状态特征,以及一组 33 字节控制参数。
|
||||||
|
|
||||||
|
## 原文抽取
|
||||||
|
|
||||||
|
### 服务/功能摘要
|
||||||
|
|
||||||
|
| UUID | 名称 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FFE1` | 数据通信服务 | 控制指令 |
|
||||||
|
| `FFE4` | 设备状态及 ADC 值 | 数据上报 |
|
||||||
|
|
||||||
|
### 特征值摘要
|
||||||
|
|
||||||
|
| UUID | 名称 | 属性 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `FFE1` | `Command` | `Write+Read` | 下发控制指令 |
|
||||||
|
| `FFE4` | `Status` | `Read + Notify` | 数据上报 |
|
||||||
|
|
||||||
|
> 注意:补充文档将 `FFE1` 同时写作“数据通信服务”和 `Command` 特征 UUID;这与当前代码中的 `FFE1` 服务、`FFE4` 命令特征、`FFE5` 状态特征不一致,需硬件侧确认最终 UUID 分配。
|
||||||
|
|
||||||
|
### Command 33 字节定义
|
||||||
|
|
||||||
|
| 字节 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| Byte1 | IO1 红灯光强 |
|
||||||
|
| Byte2 | IO1 红外灯光强 |
|
||||||
|
| Byte3 | IO1 紫外灯光强 |
|
||||||
|
| Byte4 | IO1 暖黄灯光强 |
|
||||||
|
| Byte5 | IO1 电流增益高字节 |
|
||||||
|
| Byte6 | IO1 电流增益低字节 |
|
||||||
|
| Byte7 | IO2 红灯光强 |
|
||||||
|
| Byte8 | IO2 红外灯光强 |
|
||||||
|
| Byte9 | IO2 紫外灯光强 |
|
||||||
|
| Byte10 | IO2 暖黄灯光强 |
|
||||||
|
| Byte11 | IO2 电流增益高字节 |
|
||||||
|
| Byte12 | IO2 电流增益低字节 |
|
||||||
|
| Byte13 | IO3 红灯光强 |
|
||||||
|
| Byte14 | IO3 红外灯光强 |
|
||||||
|
| Byte15 | IO3 紫外灯光强 |
|
||||||
|
| Byte16 | IO3 暖黄灯光强 |
|
||||||
|
| Byte17 | IO3 电流增益高字节 |
|
||||||
|
| Byte18 | IO3 电流增益低字节 |
|
||||||
|
| Byte19 | IO4 红灯光强 |
|
||||||
|
| Byte20 | IO4 红外灯光强 |
|
||||||
|
| Byte21 | IO4 紫外灯光强 |
|
||||||
|
| Byte22 | IO4 暖黄灯光强 |
|
||||||
|
| Byte23 | IO4 电流增益高字节 |
|
||||||
|
| Byte24 | IO4 电流增益低字节 |
|
||||||
|
| Byte25 | IO5 红灯光强 |
|
||||||
|
| Byte26 | IO5 红外灯光强 |
|
||||||
|
| Byte27 | IO5 紫外灯光强 |
|
||||||
|
| Byte28 | IO5 暖黄灯光强 |
|
||||||
|
| Byte29 | IO5 电流增益高字节 |
|
||||||
|
| Byte30 | IO5 电流增益低字节 |
|
||||||
|
| Byte31 | 保持时间高字节 |
|
||||||
|
| Byte32 | 保持时间低字节 |
|
||||||
|
| Byte33 | 异或校验 |
|
||||||
|
|
||||||
|
## 对当前实现的影响
|
||||||
|
|
||||||
|
当前小程序 BLE 实现位于 `miniprogram/services/ble/`。它采用的是帧协议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0xAA 0x55 | length | type | payload | XOR checksum
|
||||||
|
```
|
||||||
|
|
||||||
|
并且 `setParams()` 当前下发的 payload 是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
region_mask | wavelength | brightness | duration_ms(4B) | mode | seq
|
||||||
|
```
|
||||||
|
|
||||||
|
补充文档描述的 Command 更像“裸 33 字节参数块”,没有出现 `0xAA 0x55` 帧头、命令 type、seq、ACK 或 `region_mask`。当前因赶进度先以该 33 字节协议为准,旧帧协议暂作为历史兼容代码保留。
|
||||||
|
|
||||||
|
主要差异如下:
|
||||||
|
|
||||||
|
| 项目 | 当前代码/文档 | 补充文档 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 命令写入格式 | 帧格式 `0xAA55 + length + type + payload + checksum` | 33 字节参数块 | 需要确认设备固件实际接收哪一种格式 |
|
||||||
|
| 数据通信 UUID | `FFE1` 服务,`FFE4` 命令,`FFE5` 状态 | `FFE1` 服务/Command,`FFE4` Status | UUID 表存在冲突,需要硬件确认 |
|
||||||
|
| 治疗参数模型 | 区域 mask + 单一波长 + 全局亮度 | IO1-IO5 各自四种灯光强度 + 电流增益 | 当前 UI/协议无法表达每个 IO 的独立参数 |
|
||||||
|
| 时间字段 | `duration_ms` 4 字节大端毫秒 | 保持时间 2 字节 | 时间单位、范围、字节序均需确认 |
|
||||||
|
| 状态上报 | 当前解析 14 字节状态 | 仅说明“设备状态及 ADC 值” | 需要补齐 Status 字节表,尤其 ADC 字段 |
|
||||||
|
| 校验 | 当前 XOR 覆盖帧头/长度/type/payload | Byte33 为异或校验 | 需确认 Byte33 是否 XOR Byte1-Byte32 |
|
||||||
|
|
||||||
|
## 需要补充确认的问题
|
||||||
|
|
||||||
|
优先级从高到低:
|
||||||
|
|
||||||
|
1. `FFE1`、`FFE4`、`FFE5` 的最终含义:哪个是服务 UUID,哪个是 Command characteristic,哪个是 Status characteristic。
|
||||||
|
2. Command 写入时是否仍需要 `0xAA 0x55` 帧封装,还是直接写 33 字节参数块。
|
||||||
|
3. Byte33 XOR 校验范围:是否对 Byte1-Byte32 做 XOR,初始值是否为 `0x00`。
|
||||||
|
4. Byte31-Byte32 保持时间的单位:秒、100ms、分钟,或其他单位;以及字节序是否为高字节在前。
|
||||||
|
5. IO1-IO5 与面部区域/灯板通道的对应关系。
|
||||||
|
6. 红灯、红外、紫外、暖黄光强的取值范围和含义:是否为 PWM、百分比、DAC 值,范围是 `0-100` 还是 `0-255`。
|
||||||
|
7. 电流增益高/低字节的取值范围、字节序和单位。
|
||||||
|
8. Status/ADC 数据上报的完整字节布局:状态码、电量、温度、ADC 通道数、每通道字节序和单位。
|
||||||
|
9. 是否存在开始、停止、查询状态、绑定等命令字;如果没有,33 字节写入是否同时承担“设置参数并启动”的语义。
|
||||||
|
10. 是否需要 ACK/NACK;如果有,ACK 格式和错误码表是什么。
|
||||||
|
|
||||||
|
## 建议后续处理
|
||||||
|
|
||||||
|
- 当前已先接入 `buildVendorCommand33()`,由“区域 + 单一波长 + 全局亮度”生成 IO1-IO5 的默认 33 字节参数。
|
||||||
|
- 旧帧协议暂保留,后续如供应商补齐旧协议或要求兼容,可再做协议模式切换。
|
||||||
|
- 真机联调时优先抓取/打印实际写入的字节数组和 Status notify 原始数据,再补 Status/ADC 解析。
|
||||||
二进制文件未显示。
@@ -1,3 +1,7 @@
|
|||||||
|
global.wx = global.wx || {
|
||||||
|
onBLEConnectionStateChange: function () {}
|
||||||
|
}
|
||||||
|
|
||||||
const ble = require('../services/ble')
|
const ble = require('../services/ble')
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
@@ -40,8 +44,20 @@ function testBindPayloadLength() {
|
|||||||
assert(tokenBytes.length === 8, 'bind token must be 8 bytes')
|
assert(tokenBytes.length === 8, 'bind token must be 8 bytes')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testVendorCommand33() {
|
||||||
|
const command = ble.buildVendorCommand33({
|
||||||
|
region_mask: 0x1F,
|
||||||
|
wavelength: ble.WAVELENGTH.R,
|
||||||
|
brightness: 200,
|
||||||
|
duration_ms: 600000
|
||||||
|
})
|
||||||
|
assert(command.length === 33, 'vendor command must be 33 bytes')
|
||||||
|
assert(command[32] === xor(command.slice(0, 32)), 'vendor command checksum mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
testBuildFrameChecksum()
|
testBuildFrameChecksum()
|
||||||
testParseStatusFrame()
|
testParseStatusFrame()
|
||||||
testBindPayloadLength()
|
testBindPayloadLength()
|
||||||
|
testVendorCommand33()
|
||||||
|
|
||||||
console.log('BLE frame tests passed')
|
console.log('BLE frame tests passed')
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ function writeCommand(type, payload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeRawCommand(bytes) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (!connection.isConnected()) {
|
||||||
|
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var chars = connection.getChars()
|
||||||
|
if (!chars.command) {
|
||||||
|
reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.writeBLECharacteristicValue({
|
||||||
|
deviceId: connection.getDeviceId(),
|
||||||
|
serviceId: chars.command.serviceId,
|
||||||
|
characteristicId: chars.command.uuid,
|
||||||
|
value: protocol.bytesToBuffer(bytes),
|
||||||
|
success: function () {
|
||||||
|
resolve({ message: 'success', bytes: bytes })
|
||||||
|
},
|
||||||
|
fail: function () {
|
||||||
|
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function writeCommandWithRetry(type, payload, maxRetries) {
|
function writeCommandWithRetry(type, payload, maxRetries) {
|
||||||
if (maxRetries === undefined) maxRetries = 3
|
if (maxRetries === undefined) maxRetries = 3
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
@@ -122,6 +148,10 @@ function readDeviceInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setParams(options) {
|
function setParams(options) {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
return writeRawCommand(protocol.buildVendorCommand33(options || {}))
|
||||||
|
}
|
||||||
|
|
||||||
var regionMask = options.region_mask || protocol.REGION.FULL_FACE
|
var regionMask = options.region_mask || protocol.REGION.FULL_FACE
|
||||||
var wavelength = options.wavelength || protocol.WAVELENGTH.R
|
var wavelength = options.wavelength || protocol.WAVELENGTH.R
|
||||||
var brightness = options.brightness || 200
|
var brightness = options.brightness || 200
|
||||||
@@ -142,19 +172,41 @@ function setParams(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startTreatment(regionMask) {
|
function startTreatment(regionMask) {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
return Promise.resolve({ message: 'vendor_33 uses setParams write as start', region_mask: regionMask })
|
||||||
|
}
|
||||||
var mask = regionMask || protocol.REGION.FULL_FACE
|
var mask = regionMask || protocol.REGION.FULL_FACE
|
||||||
return writeCommandWithRetry(protocol.CMD.START, [mask])
|
return writeCommandWithRetry(protocol.CMD.START, [mask])
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTreatment() {
|
function stopTreatment() {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
return writeRawCommand(protocol.buildVendorCommand33({
|
||||||
|
region_mask: 0,
|
||||||
|
wavelength: protocol.WAVELENGTH.R,
|
||||||
|
brightness: 0,
|
||||||
|
current_gain: 0,
|
||||||
|
hold_time: 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
return writeCommandWithRetry(protocol.CMD.STOP, [])
|
return writeCommandWithRetry(protocol.CMD.STOP, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryStatus() {
|
function queryStatus() {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
return Promise.resolve({ message: 'query status not defined by vendor_33 protocol' })
|
||||||
|
}
|
||||||
return writeCommandWithRetry(protocol.CMD.QUERY_STATUS, [])
|
return writeCommandWithRetry(protocol.CMD.QUERY_STATUS, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindDevice(userId, bindToken) {
|
function bindDevice(userId, bindToken) {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
setTimeout(function () {
|
||||||
|
connection.emit('bind_result', { success: true, skipped_ble_bind: true })
|
||||||
|
}, 0)
|
||||||
|
return Promise.resolve({ message: 'BLE bind not defined by vendor_33 protocol', user_id: userId, bind_token: bindToken })
|
||||||
|
}
|
||||||
|
|
||||||
var userBytes = protocol.uint32ToBytes(parseInt(userId, 10))
|
var userBytes = protocol.uint32ToBytes(parseInt(userId, 10))
|
||||||
var tokenBytes = protocol.hexToBytes(bindToken)
|
var tokenBytes = protocol.hexToBytes(bindToken)
|
||||||
var ts = Math.floor(Date.now() / 1000)
|
var ts = Math.floor(Date.now() / 1000)
|
||||||
@@ -165,6 +217,9 @@ function bindDevice(userId, bindToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unbindDevice(userId) {
|
function unbindDevice(userId) {
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
return Promise.resolve({ message: 'BLE unbind not defined by vendor_33 protocol', user_id: userId })
|
||||||
|
}
|
||||||
var userBytes = protocol.uint32ToBytes(parseInt(userId, 10))
|
var userBytes = protocol.uint32ToBytes(parseInt(userId, 10))
|
||||||
var payload = [0x02].concat(userBytes)
|
var payload = [0x02].concat(userBytes)
|
||||||
return writeCommandWithRetry(protocol.CMD.UNBIND, payload)
|
return writeCommandWithRetry(protocol.CMD.UNBIND, payload)
|
||||||
@@ -175,6 +230,7 @@ module.exports = {
|
|||||||
clearPendingAcks: clearPendingAcks,
|
clearPendingAcks: clearPendingAcks,
|
||||||
|
|
||||||
writeCommand: writeCommand,
|
writeCommand: writeCommand,
|
||||||
|
writeRawCommand: writeRawCommand,
|
||||||
writeCommandWithRetry: writeCommandWithRetry,
|
writeCommandWithRetry: writeCommandWithRetry,
|
||||||
|
|
||||||
readDeviceInfo: readDeviceInfo,
|
readDeviceInfo: readDeviceInfo,
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ function handleNotification(frame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleValueChange(res) {
|
||||||
|
var frame = protocol.parseFrame(res.value)
|
||||||
|
if (frame) {
|
||||||
|
handleNotification(frame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (protocol.PROTOCOL_MODE === 'vendor_33') {
|
||||||
|
emit('status', protocol.parseVendorStatus(res.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- service/characteristic discovery ---
|
// --- service/characteristic discovery ---
|
||||||
|
|
||||||
function discoverChars(deviceId, serviceId, group) {
|
function discoverChars(deviceId, serviceId, group) {
|
||||||
@@ -118,10 +129,10 @@ function subscribeToNotifications(deviceId, serviceId) {
|
|||||||
characteristicId: _chars.status.uuid,
|
characteristicId: _chars.status.uuid,
|
||||||
state: true,
|
state: true,
|
||||||
success: function () {
|
success: function () {
|
||||||
wx.onBLECharacteristicValueChange(function (res) {
|
if (wx.offBLECharacteristicValueChange) {
|
||||||
var frame = protocol.parseFrame(res.value)
|
wx.offBLECharacteristicValueChange(handleValueChange)
|
||||||
if (frame) handleNotification(frame)
|
}
|
||||||
})
|
wx.onBLECharacteristicValueChange(handleValueChange)
|
||||||
resolve()
|
resolve()
|
||||||
},
|
},
|
||||||
fail: function () { resolve() }
|
fail: function () { resolve() }
|
||||||
@@ -129,6 +140,20 @@ function subscribeToNotifications(deviceId, serviceId) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestMtu(deviceId) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
if (!wx.setBLEMTU) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.setBLEMTU({
|
||||||
|
deviceId: deviceId,
|
||||||
|
mtu: 64,
|
||||||
|
complete: function () { resolve() }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function discoverServices(deviceId, callbacks) {
|
function discoverServices(deviceId, callbacks) {
|
||||||
wx.getBLEDeviceServices({
|
wx.getBLEDeviceServices({
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
@@ -216,7 +241,9 @@ function connect(deviceId, callbacks) {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
success: function () {
|
success: function () {
|
||||||
_connected = true
|
_connected = true
|
||||||
discoverServices(deviceId, callbacks)
|
requestMtu(deviceId).then(function () {
|
||||||
|
discoverServices(deviceId, callbacks)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
fail: function () {
|
fail: function () {
|
||||||
_connected = false
|
_connected = false
|
||||||
@@ -251,46 +278,48 @@ function attemptReconnect(deviceId, retriesLeft) {
|
|||||||
success: function () {
|
success: function () {
|
||||||
_connected = true
|
_connected = true
|
||||||
_chars = {}
|
_chars = {}
|
||||||
wx.getBLEDeviceServices({
|
requestMtu(deviceId).then(function () {
|
||||||
deviceId: deviceId,
|
wx.getBLEDeviceServices({
|
||||||
success: function (res) {
|
deviceId: deviceId,
|
||||||
var services = res.services
|
success: function (res) {
|
||||||
var serviceMap = {}
|
var services = res.services
|
||||||
for (var i = 0; i < services.length; i++) {
|
var serviceMap = {}
|
||||||
var uuid = services[i].uuid.toUpperCase()
|
for (var i = 0; i < services.length; i++) {
|
||||||
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
|
var uuid = services[i].uuid.toUpperCase()
|
||||||
serviceMap.deviceInfo = services[i].uuid
|
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
|
||||||
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
|
serviceMap.deviceInfo = services[i].uuid
|
||||||
serviceMap.dataComm = services[i].uuid
|
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
|
||||||
|
serviceMap.dataComm = services[i].uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = []
|
||||||
|
if (serviceMap.deviceInfo) {
|
||||||
|
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
|
||||||
|
}
|
||||||
|
if (serviceMap.dataComm) {
|
||||||
|
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(tasks).then(function () {
|
||||||
|
var serviceId = serviceMap.dataComm || null
|
||||||
|
return subscribeToNotifications(deviceId, serviceId)
|
||||||
|
}).then(function () {
|
||||||
|
_reconnecting = false
|
||||||
|
emit('reconnected', { deviceId: deviceId })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: function () {
|
||||||
|
if (retriesLeft > 1) {
|
||||||
|
setTimeout(function () {
|
||||||
|
attemptReconnect(deviceId, retriesLeft - 1)
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
_reconnecting = false
|
||||||
|
emit('reconnect_failed', { deviceId: deviceId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
var tasks = []
|
|
||||||
if (serviceMap.deviceInfo) {
|
|
||||||
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
|
|
||||||
}
|
|
||||||
if (serviceMap.dataComm) {
|
|
||||||
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(tasks).then(function () {
|
|
||||||
var serviceId = serviceMap.dataComm || null
|
|
||||||
return subscribeToNotifications(deviceId, serviceId)
|
|
||||||
}).then(function () {
|
|
||||||
_reconnecting = false
|
|
||||||
emit('reconnected', { deviceId: deviceId })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: function () {
|
|
||||||
if (retriesLeft > 1) {
|
|
||||||
setTimeout(function () {
|
|
||||||
attemptReconnect(deviceId, retriesLeft - 1)
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
_reconnecting = false
|
|
||||||
emit('reconnect_failed', { deviceId: deviceId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fail: function () {
|
fail: function () {
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ module.exports = {
|
|||||||
unbindDevice: commands.unbindDevice,
|
unbindDevice: commands.unbindDevice,
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
|
PROTOCOL_MODE: protocol.PROTOCOL_MODE,
|
||||||
buildFrame: protocol.buildFrame,
|
buildFrame: protocol.buildFrame,
|
||||||
parseFrame: protocol.parseFrame,
|
parseFrame: protocol.parseFrame,
|
||||||
|
buildVendorCommand33: protocol.buildVendorCommand33,
|
||||||
|
parseVendorStatus: protocol.parseVendorStatus,
|
||||||
parseStatusReport: protocol.parseStatusReport,
|
parseStatusReport: protocol.parseStatusReport,
|
||||||
parseAck: protocol.parseAck,
|
parseAck: protocol.parseAck,
|
||||||
parseTreatmentComplete: protocol.parseTreatmentComplete,
|
parseTreatmentComplete: protocol.parseTreatmentComplete,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// BLE protocol: frame encoding/decoding, checksum, constants
|
// BLE protocol: frame encoding/decoding, checksum, constants
|
||||||
|
|
||||||
|
var PROTOCOL_MODE = 'vendor_33'
|
||||||
|
|
||||||
var SERVICE = {
|
var SERVICE = {
|
||||||
DEVICE_INFO: 'FFE0',
|
DEVICE_INFO: 'FFE0',
|
||||||
DATA_COMM: 'FFE1',
|
DATA_COMM: 'FFE1',
|
||||||
@@ -8,8 +10,8 @@ var SERVICE = {
|
|||||||
|
|
||||||
var CHAR = {
|
var CHAR = {
|
||||||
DEVICE_INFO: 'FFE3',
|
DEVICE_INFO: 'FFE3',
|
||||||
COMMAND: 'FFE4',
|
COMMAND: 'FFE1',
|
||||||
STATUS: 'FFE5',
|
STATUS: 'FFE4',
|
||||||
BOND_INFO: 'FFE6',
|
BOND_INFO: 'FFE6',
|
||||||
OTA_CONTROL: 'FFE7',
|
OTA_CONTROL: 'FFE7',
|
||||||
OTA_DATA: 'FFE8',
|
OTA_DATA: 'FFE8',
|
||||||
@@ -112,6 +114,18 @@ function xorChecksum(bytes) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampByte(value) {
|
||||||
|
var n = parseInt(value, 10)
|
||||||
|
if (isNaN(n)) n = 0
|
||||||
|
return Math.max(0, Math.min(255, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUInt16(value) {
|
||||||
|
var n = parseInt(value, 10)
|
||||||
|
if (isNaN(n)) n = 0
|
||||||
|
return Math.max(0, Math.min(65535, n))
|
||||||
|
}
|
||||||
|
|
||||||
function uint32ToBytes(value) {
|
function uint32ToBytes(value) {
|
||||||
return [
|
return [
|
||||||
(value >> 24) & 0xFF,
|
(value >> 24) & 0xFF,
|
||||||
@@ -141,6 +155,54 @@ function bytesToHex(bytes) {
|
|||||||
return hex.toUpperCase()
|
return hex.toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- vendor 33-byte command protocol ---
|
||||||
|
|
||||||
|
function buildVendorCommand33(options) {
|
||||||
|
options = options || {}
|
||||||
|
var regionMask = options.region_mask || REGION.FULL_FACE
|
||||||
|
var wavelength = options.wavelength || WAVELENGTH.R
|
||||||
|
var brightness = clampByte(options.brightness === undefined ? 200 : options.brightness)
|
||||||
|
var gain = clampUInt16(options.current_gain === undefined ? brightness : options.current_gain)
|
||||||
|
var durationMs = options.duration_ms || 600000
|
||||||
|
var holdSeconds = clampUInt16(options.hold_time === undefined ? Math.round(durationMs / 1000) : options.hold_time)
|
||||||
|
var regions = [
|
||||||
|
REGION.LEFT_CHEEK,
|
||||||
|
REGION.RIGHT_CHEEK,
|
||||||
|
REGION.FOREHEAD,
|
||||||
|
REGION.CHIN,
|
||||||
|
REGION.NOSE
|
||||||
|
]
|
||||||
|
var bytes = []
|
||||||
|
|
||||||
|
for (var i = 0; i < regions.length; i++) {
|
||||||
|
var enabled = (regionMask & regions[i]) !== 0
|
||||||
|
var red = 0
|
||||||
|
var infrared = 0
|
||||||
|
var uv = 0
|
||||||
|
var warmYellow = 0
|
||||||
|
if (enabled) {
|
||||||
|
if (wavelength === WAVELENGTH.R) red = brightness
|
||||||
|
if (wavelength === WAVELENGTH.IR) infrared = brightness
|
||||||
|
if (wavelength === WAVELENGTH.UV) uv = brightness
|
||||||
|
if (wavelength === WAVELENGTH.Y) warmYellow = brightness
|
||||||
|
}
|
||||||
|
bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.push((holdSeconds >> 8) & 0xFF, holdSeconds & 0xFF)
|
||||||
|
bytes.push(xorChecksum(bytes))
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVendorStatus(buffer) {
|
||||||
|
var bytes = bufferToBytes(buffer)
|
||||||
|
return {
|
||||||
|
mode_state: bytes.length > 0 ? bytes[0] : MODE_STATE.IDLE,
|
||||||
|
raw_bytes: bytes,
|
||||||
|
raw_hex: bytesToHex(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- frame encoding/decoding ---
|
// --- frame encoding/decoding ---
|
||||||
|
|
||||||
function buildFrame(type, payload) {
|
function buildFrame(type, payload) {
|
||||||
@@ -240,6 +302,7 @@ function getModeStateName(code) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
PROTOCOL_MODE: PROTOCOL_MODE,
|
||||||
SERVICE: SERVICE,
|
SERVICE: SERVICE,
|
||||||
CHAR: CHAR,
|
CHAR: CHAR,
|
||||||
CMD: CMD,
|
CMD: CMD,
|
||||||
@@ -258,9 +321,11 @@ module.exports = {
|
|||||||
bytesToUint32: bytesToUint32,
|
bytesToUint32: bytesToUint32,
|
||||||
hexToBytes: hexToBytes,
|
hexToBytes: hexToBytes,
|
||||||
bytesToHex: bytesToHex,
|
bytesToHex: bytesToHex,
|
||||||
|
buildVendorCommand33: buildVendorCommand33,
|
||||||
|
|
||||||
buildFrame: buildFrame,
|
buildFrame: buildFrame,
|
||||||
parseFrame: parseFrame,
|
parseFrame: parseFrame,
|
||||||
|
parseVendorStatus: parseVendorStatus,
|
||||||
parseStatusReport: parseStatusReport,
|
parseStatusReport: parseStatusReport,
|
||||||
parseAck: parseAck,
|
parseAck: parseAck,
|
||||||
parseTreatmentComplete: parseTreatmentComplete,
|
parseTreatmentComplete: parseTreatmentComplete,
|
||||||
|
|||||||
+40
-2
@@ -1,4 +1,4 @@
|
|||||||
const { query, one, limitClause } = require('../lib/db')
|
const { query, one, transaction, limitClause } = require('../lib/db')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find user by WeChat openid
|
* Find user by WeChat openid
|
||||||
@@ -165,6 +165,42 @@ async function findByIdAdmin(userId) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user status.
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {number} status - 1=active, 2=disabled, 3=cancelled
|
||||||
|
* @returns {Promise<Array>} query result
|
||||||
|
*/
|
||||||
|
async function updateStatus(userId, status) {
|
||||||
|
return query(
|
||||||
|
'UPDATE users SET status = :status WHERE user_id = :user_id',
|
||||||
|
{ user_id: userId, status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-cancel a user account and unbind all active devices.
|
||||||
|
* Historical treatment/subscription/log rows are kept.
|
||||||
|
* @param {number} userId
|
||||||
|
* @returns {Promise<{userAffectedRows: number, unboundRows: number}>}
|
||||||
|
*/
|
||||||
|
async function deactivate(userId) {
|
||||||
|
return transaction(async conn => {
|
||||||
|
const [userResult] = await conn.execute(
|
||||||
|
'UPDATE users SET status = 3 WHERE user_id = ? AND status <> 3',
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
const [unbindResult] = await conn.execute(
|
||||||
|
'UPDATE bindings SET bind_status = 2, unbind_time = NOW() WHERE user_id = ? AND bind_status = 1',
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
userAffectedRows: userResult.affectedRows || 0,
|
||||||
|
unboundRows: unbindResult.affectedRows || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findByOpenid,
|
findByOpenid,
|
||||||
create,
|
create,
|
||||||
@@ -174,5 +210,7 @@ module.exports = {
|
|||||||
updatePhone,
|
updatePhone,
|
||||||
listAdmin,
|
listAdmin,
|
||||||
countAdmin,
|
countAdmin,
|
||||||
findByIdAdmin
|
findByIdAdmin,
|
||||||
|
updateStatus,
|
||||||
|
deactivate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,37 @@ router.get('/users/:user_id', requireAdmin, wrap(async (req, res) => {
|
|||||||
res.json(ok(result))
|
res.json(ok(result))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
router.post('/users/:user_id/unbind', requireAdmin, wrap(async (req, res) => {
|
||||||
|
const userId = req.params.user_id
|
||||||
|
const targetUser = await userDao.findById(userId)
|
||||||
|
if (!targetUser) return res.json(fail(1004, 'USER_NOT_FOUND'))
|
||||||
|
const deviceId = req.body.device_id ? String(req.body.device_id).trim() : null
|
||||||
|
const result = await bindingDao.unbindByUser(userId, deviceId)
|
||||||
|
await logDao.write({
|
||||||
|
admin_id: req.admin.admin_id,
|
||||||
|
user_id: userId,
|
||||||
|
action: 'admin_user_unbind',
|
||||||
|
detail: '后台解绑用户设备: user=' + userId + ', device=' + (deviceId || 'all'),
|
||||||
|
ip: req.ip
|
||||||
|
})
|
||||||
|
res.json(ok({ message: 'success', affected_rows: result.affectedRows || 0 }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.post('/users/:user_id/deactivate', requireAdmin, wrap(async (req, res) => {
|
||||||
|
const userId = req.params.user_id
|
||||||
|
const targetUser = await userDao.findById(userId)
|
||||||
|
if (!targetUser) return res.json(fail(1004, 'USER_NOT_FOUND'))
|
||||||
|
const result = await userDao.deactivate(userId)
|
||||||
|
await logDao.write({
|
||||||
|
admin_id: req.admin.admin_id,
|
||||||
|
user_id: userId,
|
||||||
|
action: 'admin_user_deactivate',
|
||||||
|
detail: '后台注销用户: user=' + userId + ', unbound=' + result.unboundRows,
|
||||||
|
ip: req.ip
|
||||||
|
})
|
||||||
|
res.json(ok({ message: 'success', unbound_rows: result.unboundRows }))
|
||||||
|
}))
|
||||||
|
|
||||||
// --- Subscriptions ---
|
// --- Subscriptions ---
|
||||||
|
|
||||||
router.get('/subscriptions', requireAdmin, wrap(async (req, res) => {
|
router.get('/subscriptions', requireAdmin, wrap(async (req, res) => {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户