feat:蓝牙绑定和在线客服
This commit is contained in:
@@ -162,6 +162,34 @@ export function rssiToSignalLevel(rssi?: number): string {
|
||||
return '0'
|
||||
}
|
||||
|
||||
export function isBleAlreadyConnectedError(err: unknown): boolean {
|
||||
if (err instanceof BluetoothError) {
|
||||
if (err.errCode === 10010) return true
|
||||
const msg = err.message.toLowerCase()
|
||||
return msg.includes('already connect') || msg.includes('已连接')
|
||||
}
|
||||
|
||||
const fail = err as UniFail
|
||||
if (fail?.errCode === 10010) return true
|
||||
const msg = String(fail?.errMsg || '').toLowerCase()
|
||||
return msg.includes('already connect') || msg.includes('已连接')
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 建立 BLE 连接;若设备已连接则视为成功 */
|
||||
export async function ensureBleConnection(bleDeviceId: string): Promise<void> {
|
||||
try {
|
||||
await createBleConnection(bleDeviceId)
|
||||
} catch (err) {
|
||||
if (!isBleAlreadyConnectedError(err)) throw err
|
||||
}
|
||||
// Android / 微信小程序连接后 GATT 表需短暂就绪
|
||||
await delay(400)
|
||||
}
|
||||
|
||||
export function createBleConnection(bleDeviceId: string): Promise<void> {
|
||||
if (typeof uni.createBLEConnection !== 'function') {
|
||||
return Promise.reject(new BluetoothError('当前环境不支持蓝牙连接'))
|
||||
@@ -202,14 +230,28 @@ export function subscribeBleConnectionStateChange(
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描并匹配指定 MAC 的 BLE 设备 */
|
||||
function deviceMatchesName(
|
||||
device: UniApp.BluetoothDeviceInfo,
|
||||
name: string,
|
||||
): boolean {
|
||||
const target = name.trim().toLowerCase()
|
||||
if (!target) return false
|
||||
const candidates = [device.name, device.localName]
|
||||
.filter(Boolean)
|
||||
.map((item) => String(item).trim().toLowerCase())
|
||||
return candidates.some((item) => item === target || item.includes(target) || target.includes(item))
|
||||
}
|
||||
|
||||
/** 扫描并匹配 MAC 或广播名的 BLE 设备(iOS 需依赖名称) */
|
||||
export async function scanForBleDeviceByMac(
|
||||
mac: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
options: { timeoutMs?: number; name?: string } = {},
|
||||
): Promise<UniApp.BluetoothDeviceInfo | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 15000
|
||||
const normalizedMac = normalizeMacAddress(mac)
|
||||
if (!normalizedMac) return null
|
||||
const targetName = options.name?.trim()
|
||||
|
||||
if (!normalizedMac && !targetName) return null
|
||||
|
||||
await ensureBluetoothReady()
|
||||
|
||||
@@ -217,7 +259,11 @@ export async function scanForBleDeviceByMac(
|
||||
|
||||
const pickMatch = (devices: UniApp.BluetoothDeviceInfo[]) => {
|
||||
for (const device of devices) {
|
||||
if (deviceMatchesMac(device, normalizedMac)) {
|
||||
if (normalizedMac && deviceMatchesMac(device, normalizedMac)) {
|
||||
matched = device
|
||||
return true
|
||||
}
|
||||
if (targetName && deviceMatchesName(device, targetName)) {
|
||||
matched = device
|
||||
return true
|
||||
}
|
||||
@@ -253,6 +299,78 @@ export async function scanForBleDeviceByMac(
|
||||
return matched
|
||||
}
|
||||
|
||||
function deviceMatchesNameStrict(
|
||||
device: UniApp.BluetoothDeviceInfo,
|
||||
name: string,
|
||||
): boolean {
|
||||
const target = name.trim().toLowerCase()
|
||||
if (!target) return false
|
||||
const candidates = [device.name, device.localName]
|
||||
.filter(Boolean)
|
||||
.map((item) => String(item).trim().toLowerCase())
|
||||
return candidates.some((item) => item === target)
|
||||
}
|
||||
|
||||
/** 短时扫描附近全部 BLE 设备(默认仅收集本次扫描窗口内发现的设备,避免系统缓存误判在线) */
|
||||
export async function scanBluetoothDevices(
|
||||
options: { timeoutMs?: number; includeCached?: boolean } = {},
|
||||
): Promise<UniApp.BluetoothDeviceInfo[]> {
|
||||
const timeoutMs = options.timeoutMs ?? 5000
|
||||
const collected = new Map<string, UniApp.BluetoothDeviceInfo>()
|
||||
|
||||
const merge = (devices: UniApp.BluetoothDeviceInfo[]) => {
|
||||
for (const device of devices) {
|
||||
collected.set(device.deviceId, device)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBluetoothReady()
|
||||
await stopBluetoothDiscovery().catch(() => {})
|
||||
|
||||
if (options.includeCached) {
|
||||
merge(await getDiscoveredBluetoothDevices())
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeBluetoothDevicesFound(merge)
|
||||
try {
|
||||
await startBluetoothDiscovery()
|
||||
await delay(timeoutMs)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
await stopBluetoothDiscovery().catch(() => {})
|
||||
}
|
||||
|
||||
return [...collected.values()]
|
||||
}
|
||||
|
||||
/** 在扫描结果中匹配设备(有 MAC 时仅按 MAC 匹配) */
|
||||
export function findDeviceInBleScan(
|
||||
target: { mac?: string; name?: string },
|
||||
scanned: UniApp.BluetoothDeviceInfo[],
|
||||
options: { strictName?: boolean } = { strictName: true },
|
||||
): UniApp.BluetoothDeviceInfo | null {
|
||||
const normalizedMac = target.mac ? normalizeMacAddress(target.mac) : undefined
|
||||
const targetName = target.name?.trim()
|
||||
|
||||
if (normalizedMac) {
|
||||
for (const device of scanned) {
|
||||
if (deviceMatchesMac(device, normalizedMac)) return device
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!targetName) return null
|
||||
|
||||
for (const device of scanned) {
|
||||
if (options.strictName) {
|
||||
if (deviceMatchesNameStrict(device, targetName)) return device
|
||||
} else if (deviceMatchesName(device, targetName)) {
|
||||
return device
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getBluetoothErrorMessage(err: unknown): string {
|
||||
if (err instanceof BluetoothError) return err.message
|
||||
|
||||
@@ -275,6 +393,9 @@ export function getBluetoothErrorMessage(err: unknown): string {
|
||||
case 10007:
|
||||
return '当前特征值不支持此操作'
|
||||
case 10008:
|
||||
if (msg && /no descriptor/i.test(msg)) {
|
||||
return '蓝牙通知特征值不可用,请确认设备 Notify 已配置'
|
||||
}
|
||||
return '蓝牙操作失败,请重试'
|
||||
case 10009:
|
||||
return '本机不支持低功耗蓝牙'
|
||||
|
||||
20
src/utils/device-id.ts
Normal file
20
src/utils/device-id.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** 从路由参数等解析 ID,避免 Number() 导致 int64 精度丢失 */
|
||||
export function parseId(raw: unknown): string {
|
||||
if (raw === undefined || raw === null || raw === '') return ''
|
||||
const id = String(raw).trim()
|
||||
return id === '0' ? '' : id
|
||||
}
|
||||
|
||||
/** 从路由参数等解析设备 ID */
|
||||
export function parseDeviceId(raw: unknown): string {
|
||||
return parseId(raw)
|
||||
}
|
||||
|
||||
export function isValidDeviceId(id: string): boolean {
|
||||
return /^\d+$/.test(id)
|
||||
}
|
||||
|
||||
export function sameId(a: unknown, b: unknown): boolean {
|
||||
if (a == null || b == null || a === '' || b === '') return false
|
||||
return String(a) === String(b)
|
||||
}
|
||||
42
src/utils/map-geocode.ts
Normal file
42
src/utils/map-geocode.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const TENCENT_MAP_KEY = import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined
|
||||
|
||||
interface TencentGeocodeResponse {
|
||||
status?: number
|
||||
message?: string
|
||||
result?: {
|
||||
address?: string
|
||||
formatted_addresses?: {
|
||||
recommend?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 逆地理编码(需配置 VITE_TENCENT_MAP_KEY) */
|
||||
export async function reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): Promise<string | undefined> {
|
||||
if (!TENCENT_MAP_KEY) return undefined
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
method: 'GET',
|
||||
data: {
|
||||
location: `${latitude},${longitude}`,
|
||||
key: TENCENT_MAP_KEY,
|
||||
get_poi: 0,
|
||||
},
|
||||
})
|
||||
const data = res.data as TencentGeocodeResponse
|
||||
if (data.status !== 0) return undefined
|
||||
return data.result?.formatted_addresses?.recommend
|
||||
|| data.result?.address
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCoordinateAddress(latitude: number, longitude: number): string {
|
||||
return `纬度 ${latitude.toFixed(4)},经度 ${longitude.toFixed(4)}`
|
||||
}
|
||||
128
src/utils/message-socket.ts
Normal file
128
src/utils/message-socket.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getStoredToken } from '@/utils/auth-token'
|
||||
import type { MessagePushPayload } from '@/types/message'
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 25000
|
||||
const RECONNECT_DELAY_MS = 25000
|
||||
|
||||
let socketTask: UniApp.SocketTask | null = null
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let connecting = false
|
||||
let manualClosed = false
|
||||
|
||||
const listeners = new Set<(payload: MessagePushPayload) => void>()
|
||||
|
||||
function resolveMessageWsBase(): string {
|
||||
const configured = import.meta.env.VITE_MESSAGE_WS_URL?.trim()
|
||||
if (configured) {
|
||||
return configured.replace(/[?#].*$/, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const envBase = import.meta.env.VITE_API_BASE_URL?.trim() || ''
|
||||
const fallback = import.meta.env.VITE_API_PROXY_TARGET?.trim() || 'http://192.168.0.222:8082'
|
||||
const httpBase = envBase || fallback
|
||||
const wsBase = httpBase.replace(/^https/i, 'wss').replace(/^http/i, 'ws')
|
||||
return `${wsBase}/app/ws/message`
|
||||
}
|
||||
|
||||
function resolveMessageWsUrl(token: string): string {
|
||||
const base = resolveMessageWsBase()
|
||||
return `${base}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatTimer = setInterval(() => {
|
||||
socketTask?.send({ data: 'ping' })
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (manualClosed || !getStoredToken()) return
|
||||
clearReconnectTimer()
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connectMessageSocket()
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
function cleanupSocketTask() {
|
||||
stopHeartbeat()
|
||||
socketTask = null
|
||||
connecting = false
|
||||
}
|
||||
|
||||
function handleSocketMessage(raw: string) {
|
||||
if (!raw || raw === 'ping' || raw === 'pong') return
|
||||
try {
|
||||
const data = JSON.parse(raw) as MessagePushPayload
|
||||
listeners.forEach((listener) => listener(data))
|
||||
if (data.title) {
|
||||
uni.showToast({ title: data.title, icon: 'none' })
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid payload
|
||||
}
|
||||
}
|
||||
|
||||
export function onMessagePush(listener: (payload: MessagePushPayload) => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function connectMessageSocket() {
|
||||
const token = getStoredToken()
|
||||
if (!token || connecting || socketTask) return
|
||||
|
||||
manualClosed = false
|
||||
connecting = true
|
||||
clearReconnectTimer()
|
||||
|
||||
const task = uni.connectSocket({
|
||||
url: resolveMessageWsUrl(token),
|
||||
complete: () => {
|
||||
connecting = false
|
||||
},
|
||||
})
|
||||
|
||||
socketTask = task
|
||||
|
||||
task.onOpen(() => {
|
||||
startHeartbeat()
|
||||
})
|
||||
|
||||
task.onMessage((res) => {
|
||||
const raw = typeof res.data === 'string' ? res.data : ''
|
||||
handleSocketMessage(raw)
|
||||
})
|
||||
|
||||
task.onClose(() => {
|
||||
cleanupSocketTask()
|
||||
scheduleReconnect()
|
||||
})
|
||||
|
||||
task.onError(() => {
|
||||
cleanupSocketTask()
|
||||
scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectMessageSocket() {
|
||||
manualClosed = true
|
||||
clearReconnectTimer()
|
||||
stopHeartbeat()
|
||||
if (socketTask) {
|
||||
socketTask.close({})
|
||||
}
|
||||
socketTask = null
|
||||
connecting = false
|
||||
}
|
||||
@@ -25,6 +25,14 @@ import tabDevice from '@/static/tab-device-active.png'
|
||||
import tabDeviceActive from '@/static/tab-device.png'
|
||||
import tabMine from '@/static/tab-mine-active.png'
|
||||
import tabMineActive from '@/static/tab-mine.png'
|
||||
import iconChevronLeft from '@/static/chevron.left.png'
|
||||
import iconSupportMic from '@/static/组 134.png'
|
||||
import iconSupportTag from '@/static/图标 13.png'
|
||||
import iconSupportSend from '@/static/图标 14.png'
|
||||
import iconMineNotify from '@/static/组 14919@2x.png'
|
||||
import iconMineOrder from '@/static/订单@2x.png'
|
||||
import iconMineCoupon from '@/static/优惠券@2x.png'
|
||||
import iconMineAddress from '@/static/组 14923@2x.png'
|
||||
|
||||
export const staticAssets = {
|
||||
/** 组 15106@2x.png — 登录区中心大图标 */
|
||||
@@ -75,4 +83,20 @@ export const staticAssets = {
|
||||
tabDeviceActive,
|
||||
tabMine,
|
||||
tabMineActive,
|
||||
/** chevron.left.png — 列表右箭头(旋转 180° 使用) */
|
||||
iconChevronLeft,
|
||||
/** 组 134.png — 在线客服页麦克风 */
|
||||
iconSupportMic,
|
||||
/** 图标 13.png — 在线客服页输入框旁标签 */
|
||||
iconSupportTag,
|
||||
/** 图标 14.png — 在线客服页发送 */
|
||||
iconSupportSend,
|
||||
/** 组 14919@2x.png — 我的页消息通知 */
|
||||
iconMineNotify,
|
||||
/** 订单@2x.png — 我的页我的订单 */
|
||||
iconMineOrder,
|
||||
/** 优惠券@2x.png — 我的页优惠券 */
|
||||
iconMineCoupon,
|
||||
/** 组 14923@2x.png — 我的页收货地址 */
|
||||
iconMineAddress,
|
||||
} as const
|
||||
|
||||
59
src/utils/tencent-map-loader.ts
Normal file
59
src/utils/tencent-map-loader.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
const TENCENT_MAP_KEY = (import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined)?.trim()
|
||||
|
||||
let loading: Promise<void> | null = null
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TMap?: {
|
||||
Map: new (
|
||||
container: HTMLElement,
|
||||
options: Record<string, unknown>,
|
||||
) => TencentMapInstance
|
||||
LatLng: new (lat: number, lng: number) => TencentLatLng
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TencentLatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
interface TencentMapInstance {
|
||||
setCenter: (center: TencentLatLng) => void
|
||||
getCenter: () => TencentLatLng
|
||||
on: (event: string, handler: () => void) => void
|
||||
destroy?: () => void
|
||||
}
|
||||
|
||||
export type { TencentMapInstance, TencentLatLng }
|
||||
|
||||
export function getTencentMapKey(): string | undefined {
|
||||
return TENCENT_MAP_KEY || undefined
|
||||
}
|
||||
|
||||
export function loadTencentMapGl(): Promise<void> {
|
||||
const key = getTencentMapKey()
|
||||
if (!key) {
|
||||
return Promise.reject(new Error('未配置 VITE_TENCENT_MAP_KEY'))
|
||||
}
|
||||
if (window.TMap) return Promise.resolve()
|
||||
if (loading) return loading
|
||||
|
||||
loading = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}`
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.TMap) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
reject(new Error('腾讯地图 SDK 初始化失败'))
|
||||
}
|
||||
script.onerror = () => reject(new Error('腾讯地图 SDK 加载失败'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return loading
|
||||
}
|
||||
@@ -9,3 +9,14 @@ export function isValidSmsCode(code: string): boolean {
|
||||
export function isValidPassword(password: string): boolean {
|
||||
return password.length >= 6 && password.length <= 32
|
||||
}
|
||||
|
||||
/** 设置/修改登录密码:8-20 位,且字母、数字、符号至少包含 2 种 */
|
||||
export function isValidStrongPassword(password: string): boolean {
|
||||
const value = password.trim()
|
||||
if (value.length < 8 || value.length > 20) return false
|
||||
let types = 0
|
||||
if (/[a-zA-Z]/.test(value)) types += 1
|
||||
if (/\d/.test(value)) types += 1
|
||||
if (/[^a-zA-Z0-9]/.test(value)) types += 1
|
||||
return types >= 2
|
||||
}
|
||||
|
||||
331
src/utils/z2-ble-protocol.ts
Normal file
331
src/utils/z2-ble-protocol.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/** Z2 空气净化器 BLE 协议(ASCII,嵌入式文档) */
|
||||
|
||||
export const Z2_BLE_SERVICE_UUID =
|
||||
(import.meta.env.VITE_BLE_SERVICE_UUID as string | undefined)?.trim()
|
||||
|| '0000ffe0-0000-1000-8000-00805f9b34fb'
|
||||
|
||||
/** Write 特征(嵌入式文档短 UUID: FFF2) */
|
||||
export const Z2_BLE_WRITE_CHAR_UUID =
|
||||
(import.meta.env.VITE_BLE_WRITE_CHAR_UUID as string | undefined)?.trim()
|
||||
|| '0000fff2-0000-1000-8000-00805f9b34fb'
|
||||
|
||||
/** Notify 特征(嵌入式文档短 UUID: FFF1) */
|
||||
export const Z2_BLE_NOTIFY_CHAR_UUID =
|
||||
(import.meta.env.VITE_BLE_NOTIFY_CHAR_UUID as string | undefined)?.trim()
|
||||
|| '0000fff1-0000-1000-8000-00805f9b34fb'
|
||||
|
||||
/** 实机 / 透传模块可能使用的其它短 UUID */
|
||||
export const Z2_BLE_WRITE_CHAR_ALIASES = ['FFF2', 'FFC1', 'FFE2']
|
||||
export const Z2_BLE_NOTIFY_CHAR_ALIASES = ['FFF1', 'FFC2', 'FFE1']
|
||||
|
||||
export type Z2Gear = 'auto' | 'low' | 'high' | 'stop'
|
||||
export type Z2WorkMode = 'car' | 'desk'
|
||||
export type Z2AirQuality = 'good' | 'medium' | 'poor'
|
||||
export type Z2Power = 'usb' | 'battery'
|
||||
export type Z2Fan = 'stop' | 'low' | 'middle' | 'high'
|
||||
|
||||
export interface Z2DeviceState {
|
||||
gear?: Z2Gear
|
||||
workMode?: Z2WorkMode
|
||||
airQuality?: Z2AirQuality
|
||||
battery?: number
|
||||
power?: Z2Power
|
||||
tempState?: number
|
||||
fan?: Z2Fan
|
||||
plasma?: boolean
|
||||
filterLife?: number
|
||||
}
|
||||
|
||||
export const Z2_BLE_CMD = {
|
||||
GEAR_AUTO: 'A',
|
||||
GEAR_LOW: 'L',
|
||||
GEAR_HIGH: 'H',
|
||||
WORK_MODE_CAR: 'C',
|
||||
WORK_MODE_DESK: 'D',
|
||||
QUERY_ALL: 'Q',
|
||||
QUERY_BATTERY: 'B',
|
||||
QUERY_THERMAL: 'T',
|
||||
QUERY_FILTER: 'I',
|
||||
FILTER_RESET: 'F',
|
||||
} as const
|
||||
|
||||
export function normalizeBleUuid(value: string): string {
|
||||
return value.replace(/-/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
export function bleUuidMatches(
|
||||
actual: string,
|
||||
expected: string,
|
||||
aliases: string[] = [],
|
||||
): boolean {
|
||||
const a = normalizeBleUuid(actual)
|
||||
const e = normalizeBleUuid(expected)
|
||||
if (a === e) return true
|
||||
|
||||
const aShort = a.slice(-4)
|
||||
const eShort = e.slice(-4)
|
||||
if (aShort === eShort) return true
|
||||
|
||||
const aliasSet = new Set(
|
||||
[eShort, ...aliases.map((item) => item.toUpperCase())].filter(Boolean),
|
||||
)
|
||||
return aliasSet.has(aShort)
|
||||
}
|
||||
|
||||
export function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(100, Math.max(0, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 修复多包 Notify 粘连,如 `A=GB=34;P=B` → `A=G` + `B=34;P=B` */
|
||||
export function normalizeZ2NotifyText(raw: string): string {
|
||||
return raw
|
||||
.replace(/([GMP])(B=\d)/g, '$1;$2')
|
||||
.replace(/(P=[UB])(T=\d)/g, '$1;$2')
|
||||
.replace(/(;\d)(B=\d)/g, '$1;$2')
|
||||
.replace(/(E=\d)(FL=)/g, '$1;$2')
|
||||
.replace(/(E=\d)(FILTER=)/g, '$1;$2')
|
||||
}
|
||||
|
||||
/** 将 Notify 原始文本拆成逻辑行(Q 查询标准格式会返回 4 条) */
|
||||
export function splitZ2NotifyRecords(raw: string): string[] {
|
||||
const text = normalizeZ2NotifyText(raw.trim())
|
||||
if (!text) return []
|
||||
|
||||
const byNewline = text.split(/[\r\n]+/).map((item) => item.trim()).filter(Boolean)
|
||||
if (byNewline.length > 1) return byNewline
|
||||
|
||||
// 实机单包合并格式:M=A;B=731;T=0;F=L(勿按 B=/T= 误拆)
|
||||
if (/^M=[^;]+/i.test(text) && !text.startsWith('Q;')) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
// 仅标准多包应答(Q; 开头或独立 FL=/FILTER= 行)才拆分
|
||||
const isMultiRecord = text.startsWith('Q;')
|
||||
|| /(?:^|;)FL=|(?:^|;)FILTER=/.test(text)
|
||||
if (!isMultiRecord) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const records = text
|
||||
.split(/(?=(?:^|(?<=;))(?:Q;|B=\d|T=\d|FL=|FILTER=))/)
|
||||
.map((item) => item.replace(/^;/, '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return records.length ? records : [text]
|
||||
}
|
||||
|
||||
function mapGearCode(code: string): Z2Gear | undefined {
|
||||
switch (normalizeToken(code)) {
|
||||
case 'A': return 'auto'
|
||||
case 'L': return 'low'
|
||||
case 'H': return 'high'
|
||||
case 'S': return 'stop'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkModeCode(code: string): Z2WorkMode | undefined {
|
||||
switch (normalizeToken(code)) {
|
||||
case 'C': return 'car'
|
||||
case 'D': return 'desk'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToken(value: string): string {
|
||||
return value.trim().replace(/\0/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
function mapAirQualityCode(code: string): Z2AirQuality | undefined {
|
||||
const token = normalizeToken(code)
|
||||
switch (token) {
|
||||
case 'G':
|
||||
case 'GOOD':
|
||||
return 'good'
|
||||
case 'M':
|
||||
case 'MEDIUM':
|
||||
return 'medium'
|
||||
case 'P':
|
||||
case 'POOR':
|
||||
return 'poor'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function airQualityToTvoc(quality?: Z2AirQuality): number | undefined {
|
||||
switch (quality) {
|
||||
case 'good': return 0.2
|
||||
case 'medium': return 0.5
|
||||
case 'poor': return 0.8
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function airQualityToLabel(quality?: Z2AirQuality): string {
|
||||
switch (quality) {
|
||||
case 'good': return '优'
|
||||
case 'medium': return '良'
|
||||
case 'poor': return '差'
|
||||
default: return '--'
|
||||
}
|
||||
}
|
||||
|
||||
function mapPowerCode(code: string): Z2Power | undefined {
|
||||
switch (normalizeToken(code)) {
|
||||
case 'U': return 'usb'
|
||||
case 'B': return 'battery'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapFanCode(code: string): Z2Fan | undefined {
|
||||
switch (normalizeToken(code)) {
|
||||
case 'S': return 'stop'
|
||||
case 'L': return 'low'
|
||||
case 'M': return 'middle'
|
||||
case 'H': return 'high'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseBatteryValue(value: string): number | undefined {
|
||||
const matched = value.trim().match(/^(\d{1,4})$/)
|
||||
if (!matched) return undefined
|
||||
const raw = Number(matched[1])
|
||||
if (!Number.isFinite(raw)) return undefined
|
||||
if (raw <= 100) return clampPercent(raw)
|
||||
// 实机可能上报 731 表示 73.1%
|
||||
const scaled = Math.round(raw / 10)
|
||||
if (scaled <= 100) return clampPercent(scaled)
|
||||
return clampPercent(raw % 100)
|
||||
}
|
||||
|
||||
/** 解析单行 Notify,如 `Q;M=L;W=C;A=G` 或 `B=34;P=B` */
|
||||
export function parseZ2NotifyLine(line: string): Partial<Z2DeviceState> {
|
||||
const patch: Partial<Z2DeviceState> = {}
|
||||
const segments = line.split(';').filter(Boolean)
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment === 'Q') continue
|
||||
|
||||
const eqIndex = segment.indexOf('=')
|
||||
if (eqIndex <= 0) continue
|
||||
|
||||
const key = normalizeToken(segment.slice(0, eqIndex))
|
||||
const value = segment.slice(eqIndex + 1).trim().replace(/\0/g, '')
|
||||
if (!value) continue
|
||||
|
||||
switch (key) {
|
||||
case 'M':
|
||||
patch.gear = mapGearCode(value)
|
||||
break
|
||||
case 'W':
|
||||
patch.workMode = mapWorkModeCode(value)
|
||||
break
|
||||
case 'A':
|
||||
patch.airQuality = mapAirQualityCode(value)
|
||||
break
|
||||
case 'B': {
|
||||
const battery = parseBatteryValue(value)
|
||||
if (battery != null) patch.battery = battery
|
||||
break
|
||||
}
|
||||
case 'P':
|
||||
patch.power = mapPowerCode(value)
|
||||
break
|
||||
case 'T': {
|
||||
const tempState = Number(value)
|
||||
if (Number.isFinite(tempState)) patch.tempState = tempState
|
||||
break
|
||||
}
|
||||
case 'F':
|
||||
patch.fan = mapFanCode(value)
|
||||
break
|
||||
case 'E':
|
||||
patch.plasma = value === '1'
|
||||
break
|
||||
case 'FL':
|
||||
case 'FILTER': {
|
||||
const filterLife = parseBatteryValue(value)
|
||||
if (filterLife != null) patch.filterLife = filterLife
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
export function mergeZ2DeviceState(
|
||||
base: Z2DeviceState,
|
||||
patch: Partial<Z2DeviceState>,
|
||||
): Z2DeviceState {
|
||||
return { ...base, ...patch }
|
||||
}
|
||||
|
||||
export function parseZ2NotifyPayload(lines: string[]): Z2DeviceState {
|
||||
let state: Z2DeviceState = {}
|
||||
for (const line of lines) {
|
||||
for (const record of splitZ2NotifyRecords(line)) {
|
||||
state = mergeZ2DeviceState(state, parseZ2NotifyLine(record))
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function mapGearToFanLevel(gear?: Z2Gear): string | undefined {
|
||||
switch (gear) {
|
||||
case 'low': return '2'
|
||||
case 'high': return '1'
|
||||
case 'auto': return '0'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapGearAndWorkModeToRunMode(
|
||||
gear?: Z2Gear,
|
||||
workMode?: Z2WorkMode,
|
||||
): string | undefined {
|
||||
if (workMode === 'car') return '1'
|
||||
if (workMode === 'desk') return '2'
|
||||
if (gear === 'auto' || gear === 'low' || gear === 'high') return '0'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 将 Z2 状态映射为详情页 / updateRuntime 补丁 */
|
||||
export function mapZ2StateToRuntimePatch(state: Z2DeviceState) {
|
||||
const runMode = mapGearAndWorkModeToRunMode(state.gear, state.workMode)
|
||||
const fanLevel = mapGearToFanLevel(state.gear)
|
||||
const tvoc = airQualityToTvoc(state.airQuality)
|
||||
const powerMode = state.power === 'battery' ? '1' : state.power === 'usb' ? '0' : undefined
|
||||
|
||||
return {
|
||||
...(runMode != null ? { runMode } : {}),
|
||||
...(fanLevel != null ? { fanLevel } : {}),
|
||||
...(tvoc != null ? { tvoc } : {}),
|
||||
...(state.airQuality != null ? { airQuality: state.airQuality } : {}),
|
||||
...(powerMode != null ? { powerMode } : {}),
|
||||
...(state.filterLife != null ? { filterLife: clampPercent(state.filterLife) } : {}),
|
||||
...(state.battery != null ? { deviceBattery: clampPercent(state.battery) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeRunModeBleCommand(runMode: string): string {
|
||||
switch (runMode) {
|
||||
case '1': return Z2_BLE_CMD.WORK_MODE_CAR
|
||||
case '2': return Z2_BLE_CMD.WORK_MODE_DESK
|
||||
default: return Z2_BLE_CMD.GEAR_AUTO
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFanLevelBleCommand(fanLevel: string): string {
|
||||
switch (fanLevel) {
|
||||
case '1': return Z2_BLE_CMD.GEAR_HIGH
|
||||
case '2': return Z2_BLE_CMD.GEAR_LOW
|
||||
default: return Z2_BLE_CMD.GEAR_AUTO
|
||||
}
|
||||
}
|
||||
416
src/utils/z2-ble.ts
Normal file
416
src/utils/z2-ble.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { BluetoothError } from '@/utils/bluetooth'
|
||||
import {
|
||||
bleUuidMatches,
|
||||
normalizeZ2NotifyText,
|
||||
parseZ2NotifyPayload,
|
||||
splitZ2NotifyRecords,
|
||||
Z2_BLE_NOTIFY_CHAR_ALIASES,
|
||||
Z2_BLE_NOTIFY_CHAR_UUID,
|
||||
Z2_BLE_SERVICE_UUID,
|
||||
Z2_BLE_WRITE_CHAR_ALIASES,
|
||||
Z2_BLE_WRITE_CHAR_UUID,
|
||||
type Z2DeviceState,
|
||||
} from '@/utils/z2-ble-protocol'
|
||||
|
||||
type BleCharacteristic = UniApp.GetBLEDeviceCharacteristicsSuccess['characteristics'][number]
|
||||
|
||||
function toBluetoothError(err: UniApp.GeneralCallbackResult, fallback: string): BluetoothError {
|
||||
const fail = err as UniApp.GeneralCallbackResult & { errCode?: number }
|
||||
return new BluetoothError(fail.errMsg || fallback, fail.errCode)
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function asciiToArrayBuffer(text: string): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(text.length)
|
||||
const view = new Uint8Array(buffer)
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
view[i] = text.charCodeAt(i)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
function arrayBufferToAscii(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
return String.fromCharCode(...bytes).trim()
|
||||
}
|
||||
|
||||
interface Z2BleHandles {
|
||||
serviceId: string
|
||||
writeCharId: string
|
||||
notifyCharId: string
|
||||
notifyHandle?: number
|
||||
}
|
||||
|
||||
const handleCache = new Map<string, Z2BleHandles>()
|
||||
|
||||
const Z2_BLE_DEBUG = import.meta.env.DEV
|
||||
|| import.meta.env.VITE_BLE_DEBUG === 'true'
|
||||
|
||||
function logZ2BleDebug(message: string, payload?: unknown) {
|
||||
if (!Z2_BLE_DEBUG) return
|
||||
if (payload !== undefined) {
|
||||
console.log(`[Z2 BLE] ${message}`, payload)
|
||||
return
|
||||
}
|
||||
console.log(`[Z2 BLE] ${message}`)
|
||||
}
|
||||
|
||||
function getBleServices(deviceId: string): Promise<UniApp.GetBLEDeviceServicesSuccess['services']> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getBLEDeviceServices({
|
||||
deviceId,
|
||||
success: (res) => resolve(res.services ?? []),
|
||||
fail: (err) => reject(toBluetoothError(err, '获取蓝牙服务失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getBleCharacteristics(
|
||||
deviceId: string,
|
||||
serviceId: string,
|
||||
): Promise<UniApp.GetBLEDeviceCharacteristicsSuccess['characteristics']> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getBLEDeviceCharacteristics({
|
||||
deviceId,
|
||||
serviceId,
|
||||
success: (res) => resolve(res.characteristics ?? []),
|
||||
fail: (err) => reject(toBluetoothError(err, '获取蓝牙特征值失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function withGattRetry<T>(
|
||||
load: () => Promise<T>,
|
||||
isReady: (value: T) => boolean,
|
||||
retries = 6,
|
||||
delayMs = 300,
|
||||
): Promise<T> {
|
||||
let last!: T
|
||||
for (let i = 0; i < retries; i++) {
|
||||
last = await load()
|
||||
if (isReady(last)) return last
|
||||
if (i < retries - 1) await sleep(delayMs)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
function charHasWrite(item: BleCharacteristic): boolean {
|
||||
const props = item.properties
|
||||
return Boolean(props?.write || props?.writeNoResponse)
|
||||
}
|
||||
|
||||
/** 仅 notify / indicate 才具备 CCCD,read 不能用于订阅 */
|
||||
function charSupportsNotify(item: BleCharacteristic): boolean {
|
||||
const props = item.properties
|
||||
return Boolean(props?.notify || props?.indicate)
|
||||
}
|
||||
|
||||
function pickWriteCharacteristic(
|
||||
characteristics: BleCharacteristic[],
|
||||
): BleCharacteristic | undefined {
|
||||
return characteristics.find((item) =>
|
||||
charHasWrite(item)
|
||||
&& bleUuidMatches(item.uuid, Z2_BLE_WRITE_CHAR_UUID, Z2_BLE_WRITE_CHAR_ALIASES),
|
||||
) ?? characteristics.find((item) => charHasWrite(item))
|
||||
}
|
||||
|
||||
function listNotifyCandidates(
|
||||
characteristics: BleCharacteristic[],
|
||||
): BleCharacteristic[] {
|
||||
const notifyable = characteristics.filter(charSupportsNotify)
|
||||
const uuidMatched = notifyable.filter((item) =>
|
||||
bleUuidMatches(item.uuid, Z2_BLE_NOTIFY_CHAR_UUID, Z2_BLE_NOTIFY_CHAR_ALIASES),
|
||||
)
|
||||
return uuidMatched.length ? uuidMatched : notifyable
|
||||
}
|
||||
|
||||
function pickHandlesFromCharacteristics(
|
||||
characteristics: BleCharacteristic[],
|
||||
): { writeChar: BleCharacteristic; notifyCandidates: BleCharacteristic[] } | null {
|
||||
if (!characteristics.length) return null
|
||||
|
||||
const writeChar = pickWriteCharacteristic(characteristics)
|
||||
const notifyCandidates = listNotifyCandidates(characteristics)
|
||||
|
||||
if (writeChar && notifyCandidates.length) {
|
||||
return { writeChar, notifyCandidates }
|
||||
}
|
||||
|
||||
if (characteristics.length === 1) {
|
||||
const only = characteristics[0]
|
||||
if (charHasWrite(only) && charSupportsNotify(only)) {
|
||||
return { writeChar: only, notifyCandidates: [only] }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveZ2BleHandles(deviceId: string): Promise<{
|
||||
serviceId: string
|
||||
writeChar: BleCharacteristic
|
||||
notifyCandidates: BleCharacteristic[]
|
||||
}> {
|
||||
const cached = handleCache.get(deviceId)
|
||||
if (cached) {
|
||||
return {
|
||||
serviceId: cached.serviceId,
|
||||
writeChar: { uuid: cached.writeCharId, properties: {} },
|
||||
notifyCandidates: [{
|
||||
uuid: cached.notifyCharId,
|
||||
properties: { notify: true },
|
||||
...(cached.notifyHandle != null ? { handle: cached.notifyHandle } : {}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
const services = await withGattRetry(
|
||||
() => getBleServices(deviceId),
|
||||
(list) => list.length > 0,
|
||||
)
|
||||
if (!services.length) {
|
||||
throw new BluetoothError('未找到 Z2 蓝牙服务', 10004)
|
||||
}
|
||||
|
||||
const orderedServices = [
|
||||
...services.filter((item) => bleUuidMatches(item.uuid, Z2_BLE_SERVICE_UUID)),
|
||||
...services.filter((item) => !bleUuidMatches(item.uuid, Z2_BLE_SERVICE_UUID)),
|
||||
]
|
||||
|
||||
for (const service of orderedServices) {
|
||||
const characteristics = await withGattRetry(
|
||||
() => getBleCharacteristics(deviceId, service.uuid),
|
||||
(list) => list.length > 0,
|
||||
)
|
||||
const picked = pickHandlesFromCharacteristics(characteristics)
|
||||
if (!picked) continue
|
||||
|
||||
return {
|
||||
serviceId: service.uuid,
|
||||
writeChar: picked.writeChar,
|
||||
notifyCandidates: picked.notifyCandidates,
|
||||
}
|
||||
}
|
||||
|
||||
const serviceSummary = orderedServices.map((item) => item.uuid).join(', ')
|
||||
throw new BluetoothError(
|
||||
`未找到可订阅的蓝牙通知特征值(已发现服务: ${serviceSummary || '无'})`,
|
||||
10005,
|
||||
)
|
||||
}
|
||||
|
||||
function isNotifyDescriptorError(err: unknown): boolean {
|
||||
if (err instanceof BluetoothError) {
|
||||
if (err.errCode === 10007 || err.errCode === 10008) return true
|
||||
const msg = err.message.toLowerCase()
|
||||
return msg.includes('no descriptor') || msg.includes('property not support')
|
||||
}
|
||||
const msg = String((err as UniApp.GeneralCallbackResult)?.errMsg || '').toLowerCase()
|
||||
return msg.includes('no descriptor') || msg.includes('property not support')
|
||||
}
|
||||
|
||||
function notifyOnce(
|
||||
deviceId: string,
|
||||
serviceId: string,
|
||||
characteristic: BleCharacteristic,
|
||||
type: 'notification' | 'indication',
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: UniApp.NotifyBLECharacteristicValueChangeOptions & {
|
||||
type?: 'notification' | 'indication'
|
||||
handle?: number
|
||||
} = {
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: characteristic.uuid,
|
||||
state: true,
|
||||
type,
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(toBluetoothError(err, '开启蓝牙通知失败')),
|
||||
}
|
||||
|
||||
const handle = (characteristic as BleCharacteristic & { handle?: number }).handle
|
||||
if (handle != null) {
|
||||
options.handle = handle
|
||||
}
|
||||
|
||||
uni.notifyBLECharacteristicValueChange(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function enableBleNotify(
|
||||
deviceId: string,
|
||||
serviceId: string,
|
||||
candidates: BleCharacteristic[],
|
||||
): Promise<{ charId: string; handle?: number }> {
|
||||
for (const candidate of candidates) {
|
||||
const types: Array<'notification' | 'indication'> = candidate.properties?.indicate
|
||||
? ['indication', 'notification']
|
||||
: ['notification', 'indication']
|
||||
|
||||
for (const type of types) {
|
||||
try {
|
||||
await notifyOnce(deviceId, serviceId, candidate, type)
|
||||
const handle = (candidate as BleCharacteristic & { handle?: number }).handle
|
||||
return {
|
||||
charId: candidate.uuid,
|
||||
...(handle != null ? { handle } : {}),
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isNotifyDescriptorError(err)) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BluetoothError(
|
||||
'无法订阅蓝牙通知(特征值缺少 Notify 描述符),请确认设备固件已开启 Notify',
|
||||
10008,
|
||||
)
|
||||
}
|
||||
|
||||
function writeBleAscii(
|
||||
deviceId: string,
|
||||
serviceId: string,
|
||||
characteristicId: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.writeBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
value: asciiToArrayBuffer(command) as unknown as number[],
|
||||
success: () => resolve(),
|
||||
fail: (err) => reject(toBluetoothError(err, '蓝牙写入失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function appendNotifyText(buffer: string, chunk: string): string {
|
||||
if (!chunk) return buffer
|
||||
if (!buffer) return chunk
|
||||
const needsSeparator = !/[;\r\n]$/.test(buffer) && !/^(B=\d|T=\d|FL=|FILTER=|Q;)/.test(chunk)
|
||||
return needsSeparator ? `${buffer}${chunk}` : `${buffer}${chunk}`
|
||||
}
|
||||
|
||||
function resolveNotifyRecords(buffer: string): string[] {
|
||||
const normalized = normalizeZ2NotifyText(buffer)
|
||||
const records = splitZ2NotifyRecords(normalized)
|
||||
return records.length ? records : normalized ? [normalized] : []
|
||||
}
|
||||
|
||||
async function collectNotifyLines(
|
||||
deviceId: string,
|
||||
options: { maxWaitMs?: number; idleMs?: number } = {},
|
||||
): Promise<string[]> {
|
||||
const maxWaitMs = options.maxWaitMs ?? 6000
|
||||
const idleMs = options.idleMgs ?? 400
|
||||
let buffer = ''
|
||||
let settled = false
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let maxTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
const records = resolveNotifyRecords(buffer)
|
||||
logZ2BleDebug('notify finished', { raw: buffer, records })
|
||||
resolve(records)
|
||||
}
|
||||
|
||||
const scheduleIdleFinish = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(finish, idleMs)
|
||||
}
|
||||
|
||||
const onValue = (res: { deviceId: string; value: ArrayBuffer }) => {
|
||||
if (res.deviceId !== deviceId) return
|
||||
const text = arrayBufferToAscii(res.value)
|
||||
if (!text) return
|
||||
logZ2BleDebug('notify chunk', text)
|
||||
buffer = appendNotifyText(buffer, text)
|
||||
scheduleIdleFinish()
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
if (maxTimer) clearTimeout(maxTimer)
|
||||
if (typeof uni.offBLECharacteristicValueChange === 'function') {
|
||||
uni.offBLECharacteristicValueChange()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof uni.onBLECharacteristicValueChange !== 'function') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
uni.onBLECharacteristicValueChange(onValue as never)
|
||||
maxTimer = setTimeout(finish, maxWaitMs)
|
||||
})
|
||||
}
|
||||
|
||||
/** 发送 ASCII 命令并收集 Notify 回报 */
|
||||
export async function sendZ2BleCommand(
|
||||
deviceId: string,
|
||||
command: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<Z2DeviceState> {
|
||||
const handles = await resolveZ2BleHandles(deviceId)
|
||||
|
||||
const maxWaitMs = options.timeoutMs ?? 6000
|
||||
const notifyPromise = collectNotifyLines(deviceId, { maxWaitMs, idleMs: 400 })
|
||||
const notifyTarget = await enableBleNotify(
|
||||
deviceId,
|
||||
handles.serviceId,
|
||||
handles.notifyCandidates,
|
||||
)
|
||||
|
||||
handleCache.set(deviceId, {
|
||||
serviceId: handles.serviceId,
|
||||
writeCharId: handles.writeChar.uuid,
|
||||
notifyCharId: notifyTarget.charId,
|
||||
notifyHandle: notifyTarget.handle,
|
||||
})
|
||||
|
||||
// 微信小程序 Android:notify 成功后立即 write 可能触发 10008
|
||||
await sleep(200)
|
||||
|
||||
logZ2BleDebug('write command', command)
|
||||
await writeBleAscii(deviceId, handles.serviceId, handles.writeChar.uuid, command)
|
||||
const lines = await notifyPromise
|
||||
const state = parseZ2NotifyPayload(lines)
|
||||
logZ2BleDebug('parsed state', {
|
||||
command,
|
||||
lines,
|
||||
airQuality: state.airQuality,
|
||||
battery: state.battery,
|
||||
filterLife: state.filterLife,
|
||||
state,
|
||||
})
|
||||
if (command === 'Q' && !state.airQuality) {
|
||||
logZ2BleDebug(
|
||||
'notify 中未包含 A=G/M/P 空气质量字段,请确认固件是否按协议上报',
|
||||
)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** 查询设备全部状态(发送 Q) */
|
||||
export async function queryZ2BleState(deviceId: string): Promise<Z2DeviceState> {
|
||||
return sendZ2BleCommand(deviceId, 'Q', { timeoutMs: 6000 })
|
||||
}
|
||||
|
||||
export function clearZ2BleHandleCache(deviceId?: string) {
|
||||
if (deviceId) {
|
||||
handleCache.delete(deviceId)
|
||||
return
|
||||
}
|
||||
handleCache.clear()
|
||||
}
|
||||
Reference in New Issue
Block a user