feat:蓝牙绑定和在线客服

This commit is contained in:
李伊帆
2026-10-13 16:23:43 +08:00
parent d4b02745e1
commit e22ebd6bef
128 changed files with 10429 additions and 385 deletions

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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

View 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
}

View File

@@ -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
}

View 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
View 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()
}