|
|
@@ -0,0 +1,1698 @@
|
|
|
+const {
|
|
|
+ buildReadFrame,
|
|
|
+ buildWriteMultipleRegistersFrame,
|
|
|
+ buildWriteSingleCoilFrame,
|
|
|
+ buildWriteSingleRegisterFrame,
|
|
|
+ formatHex,
|
|
|
+ getReadResponseByteLength,
|
|
|
+ MAX_MODBUS_DMA_BYTES,
|
|
|
+ hasValidCrc
|
|
|
+} = require('./modbus-rtu')
|
|
|
+const {
|
|
|
+ notifyPageToast
|
|
|
+} = require('./page-toast')
|
|
|
+
|
|
|
+const SCAN_TIMEOUT = 15000
|
|
|
+const CONNECT_TIMEOUT = 10000
|
|
|
+const DEFAULT_PACKET_SIZE = 20
|
|
|
+const RESPONSE_TIMEOUT = 1000
|
|
|
+const MAX_RESPONSE_BUFFER_BYTES = 128
|
|
|
+const MAX_LOG_COUNT = 100
|
|
|
+const TARGET_BLE_UUIDS = ['FFE0', 'FFE1']
|
|
|
+
|
|
|
+const MODBUS_EXCEPTION_MESSAGES = {
|
|
|
+ 0x01: '非法功能',
|
|
|
+ 0x02: '非法数据地址',
|
|
|
+ 0x03: '非法数据值',
|
|
|
+ 0x04: '从站设备故障',
|
|
|
+ 0x05: '确认',
|
|
|
+ 0x06: '从站设备忙',
|
|
|
+ 0x08: '存储奇偶性错误',
|
|
|
+ 0x0A: '网关路径不可用',
|
|
|
+ 0x0B: '网关目标设备响应失败'
|
|
|
+}
|
|
|
+
|
|
|
+const MODBUS_COMMANDS = [
|
|
|
+ { key: 'readCoils', label: '01 读取线圈', functionCode: 0x01, inputMode: 'quantity' },
|
|
|
+ { key: 'readHolding', label: '03 读取保持寄存器', functionCode: 0x03, inputMode: 'quantity' },
|
|
|
+ { key: 'readInput', label: '04 读取输入寄存器', functionCode: 0x04, inputMode: 'quantity' },
|
|
|
+ { key: 'writeCoil', label: '05 写单线圈', functionCode: 0x05, inputMode: 'coil' },
|
|
|
+ { key: 'writeRegister', label: '06 写单寄存器', functionCode: 0x06, inputMode: 'single' },
|
|
|
+ { key: 'writeRegisters', label: '10 写多寄存器', functionCode: 0x10, inputMode: 'multiple' }
|
|
|
+]
|
|
|
+
|
|
|
+const bluetoothErrorMap = {
|
|
|
+ 10000: '蓝牙模块未初始化,请重新扫描',
|
|
|
+ 10001: '蓝牙不可用,请开启手机蓝牙',
|
|
|
+ 10002: '未找到指定设备,请重新扫描',
|
|
|
+ 10003: '连接失败,请靠近设备后重试',
|
|
|
+ 10004: '未发现设备服务',
|
|
|
+ 10005: '未发现设备特征值',
|
|
|
+ 10006: '当前连接已断开',
|
|
|
+ 10007: '当前特征值不支持此操作',
|
|
|
+ 10008: '系统蓝牙异常,请稍后重试',
|
|
|
+ 10009: '当前系统不支持 BLE',
|
|
|
+ 10012: '蓝牙操作超时,请重试',
|
|
|
+ 10013: '设备 ID 无效,请重新扫描'
|
|
|
+}
|
|
|
+
|
|
|
+const state = {
|
|
|
+ adapterAvailable: false,
|
|
|
+ adapterOpened: false,
|
|
|
+ characteristicText: '未选择',
|
|
|
+ connectedDevice: null,
|
|
|
+ connectedServiceCount: 0,
|
|
|
+ connectingDeviceId: '',
|
|
|
+ devices: [],
|
|
|
+ errorText: '',
|
|
|
+ isAwaitingResponse: false,
|
|
|
+ isConnecting: false,
|
|
|
+ isDiscovering: false,
|
|
|
+ isSending: false,
|
|
|
+ logScrollTarget: '',
|
|
|
+ logs: [],
|
|
|
+ commandIndex: 1,
|
|
|
+ commandValue: '0001',
|
|
|
+ commandValueLabel: '读取数量',
|
|
|
+ coilEnabled: true,
|
|
|
+ generatedHex: '',
|
|
|
+ rxCount: 0,
|
|
|
+ sendHex: '',
|
|
|
+ sendQueueLength: 0,
|
|
|
+ protocolCommands: MODBUS_COMMANDS,
|
|
|
+ protocolErrorText: '',
|
|
|
+ registerAddress: '0000',
|
|
|
+ showCoilValue: false,
|
|
|
+ showCommandValue: true,
|
|
|
+ systemTip: '',
|
|
|
+ txCount: 0,
|
|
|
+ slaveAddress: 'F0',
|
|
|
+ writeCharacteristicId: '',
|
|
|
+ writeServiceId: '',
|
|
|
+ writeType: ''
|
|
|
+}
|
|
|
+
|
|
|
+let initialized = false
|
|
|
+let scanTimer = null
|
|
|
+let pendingRequest = null
|
|
|
+let sendQueue = []
|
|
|
+let isProcessingSendQueue = false
|
|
|
+let sendQueueGeneration = 0
|
|
|
+let sendJobSequence = 0
|
|
|
+let deviceMap = {}
|
|
|
+let deviceSequence = 0
|
|
|
+let logSequence = 0
|
|
|
+const subscribers = []
|
|
|
+
|
|
|
+function setState(changedData) {
|
|
|
+ Object.assign(state, changedData)
|
|
|
+ subscribers.slice().forEach((subscriber) => {
|
|
|
+ subscriber(getState())
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function getState() {
|
|
|
+ return {
|
|
|
+ ...state,
|
|
|
+ devices: state.devices.slice(),
|
|
|
+ logs: state.logs.slice()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function getSlaveAddress() {
|
|
|
+ return parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
|
|
|
+}
|
|
|
+
|
|
|
+function subscribe(subscriber) {
|
|
|
+ if (typeof subscriber !== 'function') return () => {}
|
|
|
+
|
|
|
+ subscribers.push(subscriber)
|
|
|
+ subscriber(getState())
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ const index = subscribers.indexOf(subscriber)
|
|
|
+ if (index >= 0) subscribers.splice(index, 1)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function callWx(apiName, params = {}) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const api = wx[apiName]
|
|
|
+
|
|
|
+ if (typeof api !== 'function') {
|
|
|
+ reject(new Error(`${apiName} 不可用`))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ api({
|
|
|
+ ...params,
|
|
|
+ success: resolve,
|
|
|
+ fail: reject
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function formatBluetoothError(error) {
|
|
|
+ if (!error) return '操作失败'
|
|
|
+
|
|
|
+ const message = bluetoothErrorMap[error.errCode]
|
|
|
+ if (message) return message
|
|
|
+
|
|
|
+ return error.errMsg || error.message || '蓝牙操作失败'
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeDevice(device) {
|
|
|
+ const advertisServiceUUIDs = device.advertisServiceUUIDs || []
|
|
|
+ const displayName = String(device.name || device.localName || '').trim() || '未命名设备'
|
|
|
+ const isTargetAdvertised = hasTargetAdvertisedUuid({
|
|
|
+ advertisServiceUUIDs
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ deviceId: device.deviceId,
|
|
|
+ name: device.name || '',
|
|
|
+ localName: device.localName || '',
|
|
|
+ RSSI: device.RSSI,
|
|
|
+ advertisServiceUUIDs,
|
|
|
+ displayName,
|
|
|
+ isTargetAdvertised,
|
|
|
+ signalText: typeof device.RSSI === 'number' ? `${device.RSSI} dBm` : '--',
|
|
|
+ serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
|
|
|
+ targetText: isTargetAdvertised ? '广播含目标 UUID' : '',
|
|
|
+ lastSeenAt: Date.now()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeUuid(value) {
|
|
|
+ return String(value || '').replace(/-/g, '').toUpperCase()
|
|
|
+}
|
|
|
+
|
|
|
+function isTargetUuid(value) {
|
|
|
+ const uuid = normalizeUuid(value)
|
|
|
+
|
|
|
+ return TARGET_BLE_UUIDS.some((target) => uuid.indexOf(target) >= 0)
|
|
|
+}
|
|
|
+
|
|
|
+function hasTargetAdvertisedUuid(device) {
|
|
|
+ return (device.advertisServiceUUIDs || []).some(isTargetUuid)
|
|
|
+}
|
|
|
+
|
|
|
+function mergeAdvertisedServiceUUIDs(left = [], right = []) {
|
|
|
+ const uuidMap = {}
|
|
|
+ const uuids = []
|
|
|
+
|
|
|
+ left.concat(right).forEach((uuid) => {
|
|
|
+ const key = normalizeUuid(uuid)
|
|
|
+ if (!key || uuidMap[key]) return
|
|
|
+
|
|
|
+ uuidMap[key] = true
|
|
|
+ uuids.push(uuid)
|
|
|
+ })
|
|
|
+
|
|
|
+ return uuids
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeHex(value) {
|
|
|
+ return String(value || '')
|
|
|
+ .replace(/0x/gi, '')
|
|
|
+ .replace(/[\s,;:_-]/g, '')
|
|
|
+ .toUpperCase()
|
|
|
+}
|
|
|
+
|
|
|
+function validateHex(value) {
|
|
|
+ const trimmed = String(value || '').trim()
|
|
|
+ const withoutPrefix = trimmed.replace(/0x/gi, '')
|
|
|
+ const compact = normalizeHex(trimmed)
|
|
|
+
|
|
|
+ if (!compact) return '请输入要发送的十六进制数据'
|
|
|
+ if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return '只支持十六进制字符'
|
|
|
+ if (compact.length % 2 !== 0) return '十六进制长度必须为偶数'
|
|
|
+
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function parseHexNumber(value, label, maxValue) {
|
|
|
+ const text = String(value || '').trim().replace(/^0x/i, '')
|
|
|
+
|
|
|
+ if (!text || !/^[0-9a-fA-F]+$/.test(text)) {
|
|
|
+ throw new Error(`${label}请输入十六进制数值`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsedValue = parseInt(text, 16)
|
|
|
+ if (parsedValue > maxValue) {
|
|
|
+ throw new Error(`${label}超出范围`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return parsedValue
|
|
|
+}
|
|
|
+
|
|
|
+function parseRegisterValues(value) {
|
|
|
+ const text = String(value || '').trim()
|
|
|
+ if (!text) throw new Error('请输入寄存器写入值')
|
|
|
+
|
|
|
+ return text.split(/[\s,;]+/)
|
|
|
+ .filter(Boolean)
|
|
|
+ .map((item) => parseHexNumber(item, '写入值', 0xFFFF))
|
|
|
+}
|
|
|
+
|
|
|
+function getCommand(index) {
|
|
|
+ return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
|
|
|
+}
|
|
|
+
|
|
|
+function getDefaultCommandValue(command) {
|
|
|
+ if (command.inputMode === 'quantity') return '0001'
|
|
|
+ if (command.inputMode === 'coil') return 'ON'
|
|
|
+ if (command.inputMode === 'multiple') return '0000'
|
|
|
+
|
|
|
+ return '0000'
|
|
|
+}
|
|
|
+
|
|
|
+function generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled) {
|
|
|
+ const slave = parseHexNumber(slaveAddress, '从站地址', 0xFF)
|
|
|
+ const address = parseHexNumber(registerAddress, '协议寄存器', 0xFFFF)
|
|
|
+
|
|
|
+ if (command.inputMode === 'quantity') {
|
|
|
+ const quantity = parseHexNumber(commandValue, '读取数量', 0xFFFF)
|
|
|
+ return buildReadFrame(slave, command.functionCode, address, quantity)
|
|
|
+ }
|
|
|
+ if (command.inputMode === 'coil') {
|
|
|
+ return buildWriteSingleCoilFrame(slave, address, coilEnabled)
|
|
|
+ }
|
|
|
+ if (command.inputMode === 'single') {
|
|
|
+ return buildWriteSingleRegisterFrame(slave, address, parseHexNumber(commandValue, '写入值', 0xFFFF))
|
|
|
+ }
|
|
|
+
|
|
|
+ return buildWriteMultipleRegistersFrame(slave, address, parseRegisterValues(commandValue))
|
|
|
+}
|
|
|
+
|
|
|
+function createProtocolState(commandIndex, slaveAddress, registerAddress, commandValue, coilEnabled) {
|
|
|
+ const command = getCommand(commandIndex)
|
|
|
+ const commandValueLabel = command.inputMode === 'quantity' ? '读取数量' : '写入值'
|
|
|
+
|
|
|
+ try {
|
|
|
+ return {
|
|
|
+ commandValueLabel,
|
|
|
+ generatedHex: formatHex(generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled)),
|
|
|
+ protocolErrorText: '',
|
|
|
+ showCoilValue: command.inputMode === 'coil',
|
|
|
+ showCommandValue: command.inputMode !== 'coil'
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ commandValueLabel,
|
|
|
+ generatedHex: '',
|
|
|
+ protocolErrorText: error.message,
|
|
|
+ showCoilValue: command.inputMode === 'coil',
|
|
|
+ showCommandValue: command.inputMode !== 'coil'
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function hexToArrayBuffer(hexText) {
|
|
|
+ const hex = normalizeHex(hexText)
|
|
|
+ const buffer = new ArrayBuffer(hex.length / 2)
|
|
|
+ const view = new Uint8Array(buffer)
|
|
|
+
|
|
|
+ for (let index = 0; index < view.length; index += 1) {
|
|
|
+ view[index] = parseInt(hex.substr(index * 2, 2), 16)
|
|
|
+ }
|
|
|
+
|
|
|
+ return buffer
|
|
|
+}
|
|
|
+
|
|
|
+function arrayBufferToHex(buffer) {
|
|
|
+ if (!buffer) return ''
|
|
|
+
|
|
|
+ return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
|
|
|
+}
|
|
|
+
|
|
|
+function bytesToWords(bytes) {
|
|
|
+ const words = []
|
|
|
+
|
|
|
+ for (let index = 0; index + 1 < bytes.length; index += 2) {
|
|
|
+ words.push(((bytes[index] << 8) | bytes[index + 1]) & 0xFFFF)
|
|
|
+ }
|
|
|
+
|
|
|
+ return words
|
|
|
+}
|
|
|
+
|
|
|
+function parseModbusResponse(bytes) {
|
|
|
+ if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc(bytes)) return null
|
|
|
+
|
|
|
+ const slaveAddress = bytes[0]
|
|
|
+ const functionCode = bytes[1]
|
|
|
+
|
|
|
+ if (functionCode & 0x80) {
|
|
|
+ return {
|
|
|
+ exceptionCode: bytes[2],
|
|
|
+ functionCode,
|
|
|
+ isException: true,
|
|
|
+ slaveAddress,
|
|
|
+ sourceFunctionCode: functionCode & 0x7F
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (functionCode === 0x01) {
|
|
|
+ const byteCount = bytes[2]
|
|
|
+ const dataEnd = 3 + byteCount
|
|
|
+ if (bytes.length < dataEnd + 2) return null
|
|
|
+
|
|
|
+ return {
|
|
|
+ byteCount,
|
|
|
+ dataBytes: bytes.slice(3, dataEnd),
|
|
|
+ functionCode,
|
|
|
+ isException: false,
|
|
|
+ slaveAddress
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (functionCode === 0x03 || functionCode === 0x04) {
|
|
|
+ const byteCount = bytes[2]
|
|
|
+ const dataEnd = 3 + byteCount
|
|
|
+ if (bytes.length < dataEnd + 2) return null
|
|
|
+
|
|
|
+ return {
|
|
|
+ byteCount,
|
|
|
+ dataBytes: bytes.slice(3, dataEnd),
|
|
|
+ functionCode,
|
|
|
+ isException: false,
|
|
|
+ slaveAddress,
|
|
|
+ words: bytesToWords(bytes.slice(3, dataEnd))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (functionCode === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
|
|
|
+ return {
|
|
|
+ address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
|
|
|
+ functionCode,
|
|
|
+ isException: false,
|
|
|
+ quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
|
|
|
+ slaveAddress
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ functionCode,
|
|
|
+ isException: false,
|
|
|
+ slaveAddress
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseModbusRequest(bytes) {
|
|
|
+ if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc(bytes)) return null
|
|
|
+
|
|
|
+ const slaveAddress = bytes[0]
|
|
|
+ const functionCode = bytes[1]
|
|
|
+ const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
|
|
|
+ let quantity = 1
|
|
|
+ let value
|
|
|
+
|
|
|
+ if (functionCode === 0x01 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
|
|
|
+ quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
|
|
|
+ }
|
|
|
+ if (functionCode === 0x05 || functionCode === 0x06) {
|
|
|
+ value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ address,
|
|
|
+ functionCode,
|
|
|
+ kind: 'raw-hex',
|
|
|
+ quantity,
|
|
|
+ value,
|
|
|
+ slaveAddress
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function validateDmaFrameLength(bytes, expected) {
|
|
|
+ if (bytes.length > MAX_MODBUS_DMA_BYTES) {
|
|
|
+ return `发送帧长度 ${bytes.length} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!expected) return ''
|
|
|
+
|
|
|
+ const responseLength = getReadResponseByteLength(expected.functionCode, expected.quantity)
|
|
|
+
|
|
|
+ if (responseLength > MAX_MODBUS_DMA_BYTES) {
|
|
|
+ return `预计返回帧长度 ${responseLength} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
|
|
|
+ }
|
|
|
+
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(timestamp) {
|
|
|
+ const date = new Date(timestamp)
|
|
|
+ const pad = (value, length = 2) => String(value).padStart(length, '0')
|
|
|
+
|
|
|
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
|
|
|
+}
|
|
|
+
|
|
|
+function getCharacteristicRole(properties = {}) {
|
|
|
+ const canWrite = !!(properties.write || properties.writeNoResponse)
|
|
|
+ const canNotify = !!(properties.notify || properties.indicate)
|
|
|
+
|
|
|
+ if (canWrite && canNotify) return '收发'
|
|
|
+ if (canWrite) return '发送'
|
|
|
+ if (canNotify) return '接收'
|
|
|
+ if (properties.read) return '读取'
|
|
|
+ return '其他'
|
|
|
+}
|
|
|
+
|
|
|
+function buildCharacteristicText(serviceId, characteristicId) {
|
|
|
+ if (!serviceId || !characteristicId) return '未选择'
|
|
|
+
|
|
|
+ return `${serviceId.slice(0, 8)} / ${characteristicId.slice(0, 8)}`
|
|
|
+}
|
|
|
+
|
|
|
+function hasTargetCharacteristic(discovery) {
|
|
|
+ return (discovery.services || []).some((service) => (
|
|
|
+ isTargetUuid(service.uuid) || (service.characteristics || []).some((item) => isTargetUuid(item.uuid))
|
|
|
+ ))
|
|
|
+}
|
|
|
+
|
|
|
+function padHex(value, length = 4) {
|
|
|
+ return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
|
|
|
+}
|
|
|
+
|
|
|
+function getExceptionText(code) {
|
|
|
+ return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
|
|
|
+}
|
|
|
+
|
|
|
+function addLog(direction, payload, note = '') {
|
|
|
+ logSequence += 1
|
|
|
+ const logItem = {
|
|
|
+ id: `log-${Date.now()}-${logSequence}`,
|
|
|
+ direction,
|
|
|
+ note,
|
|
|
+ payload,
|
|
|
+ time: formatTime(Date.now())
|
|
|
+ }
|
|
|
+ const nextLogs = state.logs.concat(logItem).slice(-MAX_LOG_COUNT)
|
|
|
+
|
|
|
+ setState({
|
|
|
+ logScrollTarget: logItem.id,
|
|
|
+ logs: nextLogs
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function showCommandAlert(title, content) {
|
|
|
+ const message = content || title || '操作失败'
|
|
|
+
|
|
|
+ notifyPageToast(message, 'error')
|
|
|
+ setState({
|
|
|
+ errorText: message
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function clearScanTimer() {
|
|
|
+ if (!scanTimer) return
|
|
|
+
|
|
|
+ clearTimeout(scanTimer)
|
|
|
+ scanTimer = null
|
|
|
+}
|
|
|
+
|
|
|
+async function stopScan() {
|
|
|
+ clearScanTimer()
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('stopBluetoothDevicesDiscovery')
|
|
|
+ } catch (error) {
|
|
|
+ if (error.errCode !== 10000) {
|
|
|
+ setState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setState({
|
|
|
+ isDiscovering: false
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function resetScanTimer() {
|
|
|
+ clearScanTimer()
|
|
|
+
|
|
|
+ scanTimer = setTimeout(() => {
|
|
|
+ stopScan()
|
|
|
+
|
|
|
+ if (!state.devices.length) {
|
|
|
+ setState({
|
|
|
+ systemTip: '安卓真机请确认系统定位已开启,并允许微信使用附近设备或位置信息。'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }, SCAN_TIMEOUT)
|
|
|
+}
|
|
|
+
|
|
|
+function mergeDevices(devices) {
|
|
|
+ if (!devices.length) return
|
|
|
+
|
|
|
+ devices.forEach((device) => {
|
|
|
+ if (!device.deviceId) return
|
|
|
+ const previousDevice = deviceMap[device.deviceId] || {}
|
|
|
+ const nextDevice = normalizeDevice(device)
|
|
|
+ const advertisServiceUUIDs = mergeAdvertisedServiceUUIDs(
|
|
|
+ previousDevice.advertisServiceUUIDs,
|
|
|
+ nextDevice.advertisServiceUUIDs
|
|
|
+ )
|
|
|
+ const isTargetAdvertised = !!previousDevice.isTargetAdvertised || hasTargetAdvertisedUuid({
|
|
|
+ advertisServiceUUIDs
|
|
|
+ })
|
|
|
+ const isTargetDevice = !!previousDevice.isTargetDevice
|
|
|
+ const seenIndex = previousDevice.seenIndex || (deviceSequence += 1)
|
|
|
+
|
|
|
+ deviceMap[device.deviceId] = {
|
|
|
+ ...previousDevice,
|
|
|
+ ...nextDevice,
|
|
|
+ advertisServiceUUIDs,
|
|
|
+ displayName: nextDevice.displayName === '未命名设备' && previousDevice.displayName
|
|
|
+ ? previousDevice.displayName
|
|
|
+ : nextDevice.displayName,
|
|
|
+ isTargetAdvertised,
|
|
|
+ isTargetDevice,
|
|
|
+ seenIndex,
|
|
|
+ serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
|
|
|
+ targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '')
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ refreshDeviceList()
|
|
|
+}
|
|
|
+
|
|
|
+function refreshDeviceList() {
|
|
|
+ const deviceList = Object.keys(deviceMap)
|
|
|
+ .map((deviceId) => deviceMap[deviceId])
|
|
|
+ .sort((left, right) => {
|
|
|
+ const leftIndex = Number(left.seenIndex) || 0
|
|
|
+ const rightIndex = Number(right.seenIndex) || 0
|
|
|
+
|
|
|
+ return leftIndex - rightIndex
|
|
|
+ })
|
|
|
+
|
|
|
+ setState({
|
|
|
+ devices: deviceList.slice(0, 30)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function clearPendingRequest() {
|
|
|
+ if (!pendingRequest) return null
|
|
|
+
|
|
|
+ const pending = pendingRequest
|
|
|
+ clearTimeout(pendingRequest.timer)
|
|
|
+ pendingRequest = null
|
|
|
+ setState({
|
|
|
+ isAwaitingResponse: false
|
|
|
+ })
|
|
|
+
|
|
|
+ return pending
|
|
|
+}
|
|
|
+
|
|
|
+function cancelPendingRequest() {
|
|
|
+ const pending = clearPendingRequest()
|
|
|
+
|
|
|
+ if (pending) {
|
|
|
+ pending.resolve(false)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function clearSendQueue() {
|
|
|
+ if (!sendQueue.length) return
|
|
|
+
|
|
|
+ const queuedJobs = sendQueue.splice(0)
|
|
|
+ queuedJobs.forEach((job) => {
|
|
|
+ job.resolve(false)
|
|
|
+ })
|
|
|
+ setState({
|
|
|
+ sendQueueLength: 0
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function resetSendRuntimeState() {
|
|
|
+ sendQueueGeneration += 1
|
|
|
+ cancelPendingRequest()
|
|
|
+ clearSendQueue()
|
|
|
+ isProcessingSendQueue = false
|
|
|
+ setState({
|
|
|
+ isAwaitingResponse: false,
|
|
|
+ isSending: false,
|
|
|
+ sendQueueLength: 0
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function clearConnectedState(changedData = {}) {
|
|
|
+ resetSendRuntimeState()
|
|
|
+ setState({
|
|
|
+ characteristicText: '未选择',
|
|
|
+ connectedDevice: null,
|
|
|
+ connectedServiceCount: 0,
|
|
|
+ connectingDeviceId: '',
|
|
|
+ isConnecting: false,
|
|
|
+ writeCharacteristicId: '',
|
|
|
+ writeServiceId: '',
|
|
|
+ writeType: '',
|
|
|
+ ...changedData
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function isConnectionLostError(error) {
|
|
|
+ if (!error) return false
|
|
|
+ if ([10000, 10001, 10006, 10013].includes(error.errCode)) return true
|
|
|
+
|
|
|
+ const message = String(error.errMsg || error.message || '').toLowerCase()
|
|
|
+ return message.includes('disconnect') || message.includes('not connected')
|
|
|
+}
|
|
|
+
|
|
|
+function isExpectedResponse(response, expected) {
|
|
|
+ if (response.functionCode === 0x01) {
|
|
|
+ return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.functionCode === 0x03 || response.functionCode === 0x04) {
|
|
|
+ return Array.isArray(response.words) && response.words.length >= expected.quantity
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.functionCode === 0x10) {
|
|
|
+ return response.address === expected.address && response.quantityOrValue === expected.quantity
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.functionCode === 0x05 || response.functionCode === 0x06) {
|
|
|
+ if (response.address !== expected.address) return false
|
|
|
+ if (Number.isInteger(expected.value)) return response.quantityOrValue === expected.value
|
|
|
+
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+function getExpectedResponseLength(expected, responseFunctionCode, responseBytes) {
|
|
|
+ if (!expected) return 0
|
|
|
+
|
|
|
+ if (responseFunctionCode === (expected.functionCode | 0x80)) {
|
|
|
+ return 5
|
|
|
+ }
|
|
|
+
|
|
|
+ if (responseFunctionCode === 0x01) {
|
|
|
+ if (responseBytes.length < 3) return 0
|
|
|
+
|
|
|
+ return 3 + Number(responseBytes[2] || 0) + 2
|
|
|
+ }
|
|
|
+
|
|
|
+ if (responseFunctionCode === 0x03 || responseFunctionCode === 0x04) {
|
|
|
+ if (responseBytes.length < 3) return 0
|
|
|
+
|
|
|
+ return 3 + Number(responseBytes[2] || 0) + 2
|
|
|
+ }
|
|
|
+
|
|
|
+ if (responseFunctionCode === 0x05 || responseFunctionCode === 0x06 || responseFunctionCode === 0x10) {
|
|
|
+ return 8
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0
|
|
|
+}
|
|
|
+
|
|
|
+function alignResponseBuffer(buffer, expected) {
|
|
|
+ if (!Array.isArray(buffer) || !buffer.length || !expected) return
|
|
|
+
|
|
|
+ const expectedFunctionCodes = [expected.functionCode, expected.functionCode | 0x80]
|
|
|
+ let matchIndex = -1
|
|
|
+
|
|
|
+ for (let index = 0; index < buffer.length - 1; index += 1) {
|
|
|
+ if (buffer[index] !== expected.slaveAddress) continue
|
|
|
+ if (!expectedFunctionCodes.includes(buffer[index + 1])) continue
|
|
|
+
|
|
|
+ matchIndex = index
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ if (matchIndex > 0) {
|
|
|
+ buffer.splice(0, matchIndex)
|
|
|
+ } else if (matchIndex < 0 && buffer.length > 2) {
|
|
|
+ buffer.splice(0, buffer.length - 1)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function finishPendingRequest(resolveValue) {
|
|
|
+ const pending = clearPendingRequest()
|
|
|
+
|
|
|
+ if (pending) {
|
|
|
+ pending.resolve(resolveValue)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function consumePendingResponseBuffer() {
|
|
|
+ const pending = pendingRequest
|
|
|
+ if (!pending || !Array.isArray(pending.responseBuffer)) return
|
|
|
+
|
|
|
+ const buffer = pending.responseBuffer
|
|
|
+ alignResponseBuffer(buffer, pending.expected)
|
|
|
+
|
|
|
+ if (buffer.length < 2) return
|
|
|
+
|
|
|
+ const responseFunctionCode = buffer[1]
|
|
|
+ const responseLength = getExpectedResponseLength(pending.expected, responseFunctionCode, buffer)
|
|
|
+
|
|
|
+ if (!responseLength) return
|
|
|
+
|
|
|
+ if (responseLength > MAX_MODBUS_DMA_BYTES) {
|
|
|
+ const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过 DMA 限制,已丢弃`
|
|
|
+ addLog('SYS', content)
|
|
|
+ finishPendingRequest(false)
|
|
|
+ if (pending.showModal) {
|
|
|
+ showCommandAlert('通讯异常', content)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (buffer.length < responseLength) return
|
|
|
+
|
|
|
+ const frameBytes = buffer.slice(0, responseLength)
|
|
|
+ const response = parseModbusResponse(frameBytes)
|
|
|
+ if (!response) {
|
|
|
+ const content = `${pending.label} 收到无效响应帧,已丢弃`
|
|
|
+ addLog('SYS', content)
|
|
|
+ finishPendingRequest(false)
|
|
|
+ if (pending.showModal) {
|
|
|
+ showCommandAlert('通讯异常', content)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const responseCode = response.isException ? response.sourceFunctionCode : response.functionCode
|
|
|
+ if (response.slaveAddress !== pending.expected.slaveAddress || responseCode !== pending.expected.functionCode) {
|
|
|
+ buffer.shift()
|
|
|
+ consumePendingResponseBuffer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (response.isException) {
|
|
|
+ const exceptionText = getExceptionText(response.exceptionCode)
|
|
|
+ const content = `设备返回异常帧:功能码 0x${padHex(response.sourceFunctionCode, 2)},异常码 0x${padHex(response.exceptionCode, 2)}(${exceptionText})`
|
|
|
+
|
|
|
+ addLog('SYS', content)
|
|
|
+ finishPendingRequest(false)
|
|
|
+ if (pending.showModal) {
|
|
|
+ showCommandAlert('设备返回故障帧', content)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isExpectedResponse(response, pending.expected)) {
|
|
|
+ const content = `${pending.label} 收到不匹配响应,已丢弃`
|
|
|
+ addLog('SYS', content)
|
|
|
+ finishPendingRequest(false)
|
|
|
+ if (pending.showModal) {
|
|
|
+ showCommandAlert('通讯异常', content)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ buffer.splice(0, responseLength)
|
|
|
+ finishPendingRequest(response)
|
|
|
+
|
|
|
+ if (buffer.length) {
|
|
|
+ consumePendingResponseBuffer()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleModbusResponse(bytes) {
|
|
|
+ if (!pendingRequest || !Array.isArray(bytes) || !bytes.length) return
|
|
|
+
|
|
|
+ pendingRequest.responseBuffer = pendingRequest.responseBuffer.concat(bytes)
|
|
|
+ if (pendingRequest.responseBuffer.length > MAX_RESPONSE_BUFFER_BYTES) {
|
|
|
+ const pending = pendingRequest
|
|
|
+ const content = `${pending.label} 返回数据超过缓冲区,已丢弃`
|
|
|
+
|
|
|
+ addLog('SYS', content)
|
|
|
+ finishPendingRequest(false)
|
|
|
+ if (pending.showModal) {
|
|
|
+ showCommandAlert('通讯异常', content)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ consumePendingResponseBuffer()
|
|
|
+}
|
|
|
+
|
|
|
+function createPendingRequest(label, expected, options = {}) {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ const pending = clearPendingRequest()
|
|
|
+ if (!pending) return
|
|
|
+
|
|
|
+ addLog('SYS', `${label} 超时`)
|
|
|
+ if (options.showModal !== false) {
|
|
|
+ showCommandAlert('通讯超时', `${label} 1秒内没有收到回复`)
|
|
|
+ }
|
|
|
+ resolve(false)
|
|
|
+ }, options.timeout || RESPONSE_TIMEOUT)
|
|
|
+
|
|
|
+ pendingRequest = {
|
|
|
+ expected,
|
|
|
+ label,
|
|
|
+ resolve,
|
|
|
+ timer,
|
|
|
+ showModal: options.showModal !== false,
|
|
|
+ responseBuffer: []
|
|
|
+ }
|
|
|
+ setState({
|
|
|
+ isAwaitingResponse: true
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function init() {
|
|
|
+ if (initialized) return
|
|
|
+
|
|
|
+ wx.onBluetoothDeviceFound((res) => {
|
|
|
+ mergeDevices(res.devices || [])
|
|
|
+ })
|
|
|
+
|
|
|
+ wx.onBluetoothAdapterStateChange((res) => {
|
|
|
+ setState({
|
|
|
+ adapterAvailable: !!res.available,
|
|
|
+ isDiscovering: !!res.discovering
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!res.available) {
|
|
|
+ clearScanTimer()
|
|
|
+ clearConnectedState({
|
|
|
+ adapterAvailable: false,
|
|
|
+ adapterOpened: false,
|
|
|
+ errorText: '请开启手机蓝牙后重新扫描',
|
|
|
+ isDiscovering: false,
|
|
|
+ sendQueueLength: 0
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ wx.onBLEConnectionStateChange((res) => {
|
|
|
+ const { connectedDevice } = state
|
|
|
+ if (!connectedDevice || connectedDevice.deviceId !== res.deviceId) return
|
|
|
+
|
|
|
+ if (!res.connected) {
|
|
|
+ addLog('SYS', '连接已断开')
|
|
|
+ clearConnectedState({
|
|
|
+ errorText: '',
|
|
|
+ sendQueueLength: 0
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ wx.onBLECharacteristicValueChange((res) => {
|
|
|
+ const hex = arrayBufferToHex(res.value)
|
|
|
+ const byteLength = res.value ? res.value.byteLength : 0
|
|
|
+ const rawBytes = Array.prototype.slice.call(new Uint8Array(res.value || new ArrayBuffer(0)))
|
|
|
+ const crcState = rawBytes.length >= 4
|
|
|
+ ? (hasValidCrc(rawBytes) ? 'CRC OK' : (pendingRequest ? '片段' : 'CRC ERR'))
|
|
|
+ : ''
|
|
|
+
|
|
|
+ setState({
|
|
|
+ rxCount: state.rxCount + byteLength
|
|
|
+ })
|
|
|
+ addLog('RX', hex, crcState)
|
|
|
+ handleModbusResponse(rawBytes)
|
|
|
+ })
|
|
|
+
|
|
|
+ initialized = true
|
|
|
+}
|
|
|
+
|
|
|
+async function getAuthSetting() {
|
|
|
+ return callWx('getSetting')
|
|
|
+ .then((res) => res.authSetting || {})
|
|
|
+ .catch(() => ({}))
|
|
|
+}
|
|
|
+
|
|
|
+function showPermissionModal(title, content) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ wx.showModal({
|
|
|
+ title,
|
|
|
+ content,
|
|
|
+ confirmText: '去设置',
|
|
|
+ success: async (res) => {
|
|
|
+ if (!res.confirm) {
|
|
|
+ reject(new Error('用户取消授权'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('openSetting')
|
|
|
+ resolve()
|
|
|
+ } catch (error) {
|
|
|
+ reject(error)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail: reject
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureBluetoothAuthorized() {
|
|
|
+ const authSetting = await getAuthSetting()
|
|
|
+
|
|
|
+ if (authSetting['scope.bluetooth']) return
|
|
|
+
|
|
|
+ if (authSetting['scope.bluetooth'] === false) {
|
|
|
+ await showPermissionModal('需要蓝牙权限', '请在设置中允许使用蓝牙,用于扫描并连接附近设备。')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('authorize', {
|
|
|
+ scope: 'scope.bluetooth'
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ await showPermissionModal('需要蓝牙权限', '请在设置中允许使用蓝牙,用于扫描并连接附近设备。')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureAndroidLocationAuthorized() {
|
|
|
+ const systemInfo = wx.getSystemInfoSync ? wx.getSystemInfoSync() : wx.getDeviceInfo()
|
|
|
+ if (systemInfo.platform !== 'android') return
|
|
|
+
|
|
|
+ const authSetting = await getAuthSetting()
|
|
|
+ if (authSetting['scope.userLocation']) return
|
|
|
+
|
|
|
+ setState({
|
|
|
+ systemTip: '安卓系统扫描 BLE 设备通常需要开启系统定位权限。'
|
|
|
+ })
|
|
|
+
|
|
|
+ if (authSetting['scope.userLocation'] === false) {
|
|
|
+ await showPermissionModal('需要定位权限', '安卓系统要求定位权限开启后才能搜索附近 BLE 设备。')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('authorize', {
|
|
|
+ scope: 'scope.userLocation'
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ await showPermissionModal('需要定位权限', '安卓系统要求定位权限开启后才能搜索附近 BLE 设备。')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function openAdapter() {
|
|
|
+ if (state.adapterOpened) {
|
|
|
+ try {
|
|
|
+ const adapterState = await callWx('getBluetoothAdapterState')
|
|
|
+
|
|
|
+ setState({
|
|
|
+ adapterAvailable: !!adapterState.available,
|
|
|
+ isDiscovering: !!adapterState.discovering
|
|
|
+ })
|
|
|
+
|
|
|
+ if (adapterState.available) return
|
|
|
+ } catch (error) {
|
|
|
+ setState({
|
|
|
+ adapterAvailable: false,
|
|
|
+ adapterOpened: false
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('openBluetoothAdapter', {
|
|
|
+ mode: 'central'
|
|
|
+ })
|
|
|
+
|
|
|
+ const adapterState = await callWx('getBluetoothAdapterState')
|
|
|
+
|
|
|
+ setState({
|
|
|
+ adapterAvailable: !!adapterState.available,
|
|
|
+ adapterOpened: true,
|
|
|
+ isDiscovering: !!adapterState.discovering
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!adapterState.available) {
|
|
|
+ throw {
|
|
|
+ errCode: 10001,
|
|
|
+ errMsg: 'bluetooth adapter not available'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (error.errCode === 10001) {
|
|
|
+ setState({
|
|
|
+ adapterOpened: true,
|
|
|
+ adapterAvailable: false
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function startDiscovery() {
|
|
|
+ try {
|
|
|
+ await callWx('startBluetoothDevicesDiscovery', {
|
|
|
+ allowDuplicatesKey: true,
|
|
|
+ interval: 600,
|
|
|
+ powerLevel: 'high'
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ await callWx('startBluetoothDevicesDiscovery', {
|
|
|
+ allowDuplicatesKey: true,
|
|
|
+ interval: 600
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function startScan() {
|
|
|
+ if (state.isConnecting) return
|
|
|
+
|
|
|
+ deviceMap = {}
|
|
|
+ deviceSequence = 0
|
|
|
+ setState({
|
|
|
+ devices: [],
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ init()
|
|
|
+ await ensureBluetoothAuthorized()
|
|
|
+ await ensureAndroidLocationAuthorized()
|
|
|
+ await openAdapter()
|
|
|
+ await startDiscovery()
|
|
|
+
|
|
|
+ setState({
|
|
|
+ isDiscovering: true
|
|
|
+ })
|
|
|
+
|
|
|
+ resetScanTimer()
|
|
|
+ addLog('SYS', '开始扫描 BLE 设备')
|
|
|
+ } catch (error) {
|
|
|
+ clearScanTimer()
|
|
|
+ setState({
|
|
|
+ isDiscovering: false,
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function clearDevices() {
|
|
|
+ deviceMap = {}
|
|
|
+ deviceSequence = 0
|
|
|
+ setState({
|
|
|
+ devices: [],
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function closeConnectedDevice(nextDeviceId, options = {}) {
|
|
|
+ const { connectedDevice } = state
|
|
|
+
|
|
|
+ if (!connectedDevice) {
|
|
|
+ resetSendRuntimeState()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (connectedDevice.deviceId === nextDeviceId && !options.force) return
|
|
|
+
|
|
|
+ resetSendRuntimeState()
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('closeBLEConnection', {
|
|
|
+ deviceId: connectedDevice.deviceId
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ if (error.errCode !== 10006) throw error
|
|
|
+ }
|
|
|
+
|
|
|
+ clearConnectedState()
|
|
|
+}
|
|
|
+
|
|
|
+async function discoverCharacteristics(deviceId) {
|
|
|
+ const serviceResult = await callWx('getBLEDeviceServices', {
|
|
|
+ deviceId
|
|
|
+ })
|
|
|
+ const services = []
|
|
|
+ let writeServiceId = ''
|
|
|
+ let writeCharacteristicId = ''
|
|
|
+ let writeType = ''
|
|
|
+ let notifyServiceId = ''
|
|
|
+ let notifyCharacteristicId = ''
|
|
|
+
|
|
|
+ for (const service of serviceResult.services || []) {
|
|
|
+ const characteristicResult = await callWx('getBLEDeviceCharacteristics', {
|
|
|
+ deviceId,
|
|
|
+ serviceId: service.uuid
|
|
|
+ })
|
|
|
+ const characteristics = (characteristicResult.characteristics || []).map((item) => ({
|
|
|
+ uuid: item.uuid,
|
|
|
+ role: getCharacteristicRole(item.properties),
|
|
|
+ properties: item.properties || {}
|
|
|
+ }))
|
|
|
+
|
|
|
+ services.push({
|
|
|
+ uuid: service.uuid,
|
|
|
+ primary: service.isPrimary,
|
|
|
+ characteristics
|
|
|
+ })
|
|
|
+
|
|
|
+ characteristics.forEach((item) => {
|
|
|
+ const isPreferredService = isTargetUuid(service.uuid)
|
|
|
+ const isPreferredCharacteristic = isTargetUuid(item.uuid)
|
|
|
+ const canWrite = item.properties.write || item.properties.writeNoResponse
|
|
|
+ const canNotify = item.properties.notify || item.properties.indicate
|
|
|
+
|
|
|
+ if (isPreferredService && isPreferredCharacteristic && canWrite) {
|
|
|
+ writeServiceId = service.uuid
|
|
|
+ writeCharacteristicId = item.uuid
|
|
|
+ writeType = item.properties.write ? 'write' : 'writeNoResponse'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isPreferredService && isPreferredCharacteristic && canNotify) {
|
|
|
+ notifyServiceId = service.uuid
|
|
|
+ notifyCharacteristicId = item.uuid
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!writeCharacteristicId && canWrite) {
|
|
|
+ writeServiceId = service.uuid
|
|
|
+ writeCharacteristicId = item.uuid
|
|
|
+ writeType = item.properties.write ? 'write' : 'writeNoResponse'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!notifyCharacteristicId && canNotify) {
|
|
|
+ notifyServiceId = service.uuid
|
|
|
+ notifyCharacteristicId = item.uuid
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ services,
|
|
|
+ writeServiceId,
|
|
|
+ writeCharacteristicId,
|
|
|
+ writeType,
|
|
|
+ notifyServiceId,
|
|
|
+ notifyCharacteristicId
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function enableNotify(deviceId, serviceId, characteristicId) {
|
|
|
+ try {
|
|
|
+ await callWx('notifyBLECharacteristicValueChange', {
|
|
|
+ deviceId,
|
|
|
+ serviceId,
|
|
|
+ characteristicId,
|
|
|
+ state: true
|
|
|
+ })
|
|
|
+ addLog('SYS', `已开启通知 ${characteristicId}`)
|
|
|
+ return true
|
|
|
+ } catch (error) {
|
|
|
+ addLog('SYS', `开启通知失败:${formatBluetoothError(error)}`)
|
|
|
+ if (isConnectionLostError(error)) {
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function connectDeviceById(deviceId) {
|
|
|
+ const device = deviceMap[deviceId]
|
|
|
+
|
|
|
+ if (!device || state.isConnecting) return
|
|
|
+
|
|
|
+ resetSendRuntimeState()
|
|
|
+ setState({
|
|
|
+ connectingDeviceId: deviceId,
|
|
|
+ errorText: '',
|
|
|
+ isConnecting: true
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await stopScan()
|
|
|
+ await closeConnectedDevice(deviceId, {
|
|
|
+ force: state.connectedDevice && state.connectedDevice.deviceId === deviceId
|
|
|
+ })
|
|
|
+ await openAdapter()
|
|
|
+ await callWx('createBLEConnection', {
|
|
|
+ deviceId,
|
|
|
+ timeout: CONNECT_TIMEOUT
|
|
|
+ })
|
|
|
+
|
|
|
+ const discovery = await discoverCharacteristics(deviceId)
|
|
|
+ const notifyEnabled = discovery.notifyServiceId && discovery.notifyCharacteristicId
|
|
|
+ ? await enableNotify(deviceId, discovery.notifyServiceId, discovery.notifyCharacteristicId)
|
|
|
+ : false
|
|
|
+ const isTargetDevice = hasTargetCharacteristic(discovery)
|
|
|
+ const connectedDevice = {
|
|
|
+ ...device,
|
|
|
+ isTargetDevice,
|
|
|
+ targetText: isTargetDevice ? '已发现目标特征' : device.targetText
|
|
|
+ }
|
|
|
+ deviceMap[deviceId] = connectedDevice
|
|
|
+ refreshDeviceList()
|
|
|
+
|
|
|
+ setState({
|
|
|
+ characteristicText: buildCharacteristicText(discovery.writeServiceId, discovery.writeCharacteristicId),
|
|
|
+ connectedDevice,
|
|
|
+ connectedServiceCount: discovery.services.length,
|
|
|
+ connectingDeviceId: '',
|
|
|
+ errorText: discovery.writeServiceId
|
|
|
+ ? (notifyEnabled ? '' : '已连接,但未成功开启通知,可能收不到设备回复')
|
|
|
+ : '已连接,但未找到可写特征值',
|
|
|
+ isConnecting: false,
|
|
|
+ writeCharacteristicId: discovery.writeCharacteristicId,
|
|
|
+ writeServiceId: discovery.writeServiceId,
|
|
|
+ writeType: discovery.writeType
|
|
|
+ })
|
|
|
+
|
|
|
+ addLog('SYS', `已连接 ${device.displayName}`)
|
|
|
+ } catch (error) {
|
|
|
+ resetSendRuntimeState()
|
|
|
+ setState({
|
|
|
+ connectingDeviceId: '',
|
|
|
+ isConnecting: false,
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function disconnectDevice() {
|
|
|
+ const { connectedDevice } = state
|
|
|
+ if (!connectedDevice) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callWx('closeBLEConnection', {
|
|
|
+ deviceId: connectedDevice.deviceId
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ if (error.errCode !== 10006) {
|
|
|
+ setState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ addLog('SYS', '主动断开连接')
|
|
|
+ clearConnectedState({
|
|
|
+ errorText: '',
|
|
|
+ sendQueueLength: 0
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshNativeConnectionState() {
|
|
|
+ if (!state.connectedDevice || typeof wx.getConnectedBluetoothDevices !== 'function') return true
|
|
|
+
|
|
|
+ try {
|
|
|
+ const services = state.writeServiceId ? [state.writeServiceId] : []
|
|
|
+ const result = await callWx('getConnectedBluetoothDevices', {
|
|
|
+ services
|
|
|
+ })
|
|
|
+ const isConnected = (result.devices || []).some((device) => device.deviceId === state.connectedDevice.deviceId)
|
|
|
+
|
|
|
+ if (isConnected) return true
|
|
|
+
|
|
|
+ addLog('SYS', '蓝牙连接状态已失效')
|
|
|
+ clearConnectedState({
|
|
|
+ errorText: '蓝牙连接已失效,请重新连接'
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ } catch (error) {
|
|
|
+ if (isConnectionLostError(error)) {
|
|
|
+ clearConnectedState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleAppHide() {
|
|
|
+ clearScanTimer()
|
|
|
+ resetSendRuntimeState()
|
|
|
+ if (state.isDiscovering) {
|
|
|
+ stopScan()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleAppShow() {
|
|
|
+ init()
|
|
|
+ refreshNativeConnectionState()
|
|
|
+}
|
|
|
+
|
|
|
+async function openSetting() {
|
|
|
+ try {
|
|
|
+ await callWx('openSetting')
|
|
|
+ setState({
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ setState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function setSendHex(sendHex) {
|
|
|
+ setState({
|
|
|
+ sendHex,
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function setCommandIndex(value) {
|
|
|
+ const commandIndex = Number(value)
|
|
|
+ const command = getCommand(commandIndex)
|
|
|
+ const commandValue = getDefaultCommandValue(command)
|
|
|
+ const nextState = {
|
|
|
+ commandIndex,
|
|
|
+ commandValue,
|
|
|
+ coilEnabled: true
|
|
|
+ }
|
|
|
+
|
|
|
+ setState({
|
|
|
+ ...nextState,
|
|
|
+ ...createProtocolState(
|
|
|
+ nextState.commandIndex,
|
|
|
+ state.slaveAddress,
|
|
|
+ state.registerAddress,
|
|
|
+ nextState.commandValue,
|
|
|
+ nextState.coilEnabled
|
|
|
+ )
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function setProtocolInput(changedData) {
|
|
|
+ const nextState = {
|
|
|
+ commandIndex: state.commandIndex,
|
|
|
+ slaveAddress: state.slaveAddress,
|
|
|
+ registerAddress: state.registerAddress,
|
|
|
+ commandValue: state.commandValue,
|
|
|
+ coilEnabled: state.coilEnabled,
|
|
|
+ ...changedData
|
|
|
+ }
|
|
|
+
|
|
|
+ setState({
|
|
|
+ ...changedData,
|
|
|
+ ...createProtocolState(
|
|
|
+ nextState.commandIndex,
|
|
|
+ nextState.slaveAddress,
|
|
|
+ nextState.registerAddress,
|
|
|
+ nextState.commandValue,
|
|
|
+ nextState.coilEnabled
|
|
|
+ )
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function buildGeneratedExpectedResponse() {
|
|
|
+ try {
|
|
|
+ const command = getCommand(state.commandIndex)
|
|
|
+ const address = parseHexNumber(state.registerAddress, '协议寄存器', 0xFFFF)
|
|
|
+ const slaveAddress = parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
|
|
|
+ const quantity = command.inputMode === 'quantity'
|
|
|
+ ? parseHexNumber(state.commandValue, '读取数量', 0xFFFF)
|
|
|
+ : (command.inputMode === 'multiple' ? parseRegisterValues(state.commandValue).length : 1)
|
|
|
+ const value = command.inputMode === 'coil'
|
|
|
+ ? (state.coilEnabled ? 0xFF00 : 0x0000)
|
|
|
+ : (command.inputMode === 'single' ? parseHexNumber(state.commandValue, '写入值', 0xFFFF) : undefined)
|
|
|
+
|
|
|
+ return {
|
|
|
+ address,
|
|
|
+ functionCode: command.functionCode,
|
|
|
+ kind: 'manual-rtu',
|
|
|
+ quantity,
|
|
|
+ value,
|
|
|
+ slaveAddress
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function clearInput() {
|
|
|
+ setState({
|
|
|
+ sendHex: '',
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function clearLogs() {
|
|
|
+ setState({
|
|
|
+ logScrollTarget: '',
|
|
|
+ logs: [],
|
|
|
+ rxCount: 0,
|
|
|
+ txCount: 0
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function enqueueSendFrame(hexFrame, source, options = {}) {
|
|
|
+ if (!state.connectedDevice) {
|
|
|
+ setState({
|
|
|
+ errorText: '请先连接蓝牙透传设备'
|
|
|
+ })
|
|
|
+ return Promise.resolve(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!state.writeServiceId || !state.writeCharacteristicId) {
|
|
|
+ setState({
|
|
|
+ errorText: '当前设备没有可写特征值'
|
|
|
+ })
|
|
|
+ return Promise.resolve(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ const errorText = validateHex(hexFrame)
|
|
|
+
|
|
|
+ if (errorText) {
|
|
|
+ setState({
|
|
|
+ errorText
|
|
|
+ })
|
|
|
+ return Promise.resolve(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ const buffer = hexToArrayBuffer(hexFrame)
|
|
|
+ const bytes = new Uint8Array(buffer)
|
|
|
+ const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
|
|
|
+
|
|
|
+ if (dmaFrameLengthError) {
|
|
|
+ setState({
|
|
|
+ errorText: dmaFrameLengthError
|
|
|
+ })
|
|
|
+ return Promise.resolve(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ sendJobSequence += 1
|
|
|
+ sendQueue.push({
|
|
|
+ id: sendJobSequence,
|
|
|
+ hexFrame,
|
|
|
+ options,
|
|
|
+ resolve,
|
|
|
+ source
|
|
|
+ })
|
|
|
+
|
|
|
+ setState({
|
|
|
+ sendQueueLength: sendQueue.length
|
|
|
+ })
|
|
|
+
|
|
|
+ processSendQueue()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function processSendQueue() {
|
|
|
+ if (isProcessingSendQueue) return
|
|
|
+
|
|
|
+ const generation = sendQueueGeneration
|
|
|
+ isProcessingSendQueue = true
|
|
|
+
|
|
|
+ try {
|
|
|
+ while (sendQueue.length && generation === sendQueueGeneration) {
|
|
|
+ const job = sendQueue.shift()
|
|
|
+
|
|
|
+ setState({
|
|
|
+ sendQueueLength: sendQueue.length
|
|
|
+ })
|
|
|
+
|
|
|
+ let result = false
|
|
|
+ try {
|
|
|
+ result = await executeSendFrame(job.hexFrame, job.source, job.options)
|
|
|
+ } catch (error) {
|
|
|
+ cancelPendingRequest()
|
|
|
+ setState({
|
|
|
+ errorText: error.message || '发送失败'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ job.resolve(result)
|
|
|
+
|
|
|
+ if (!state.connectedDevice) {
|
|
|
+ clearSendQueue()
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (generation === sendQueueGeneration) {
|
|
|
+ isProcessingSendQueue = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function executeSendFrame(hexFrame, source, options = {}) {
|
|
|
+ const {
|
|
|
+ connectedDevice,
|
|
|
+ writeCharacteristicId,
|
|
|
+ writeServiceId,
|
|
|
+ writeType
|
|
|
+ } = state
|
|
|
+ const errorText = validateHex(hexFrame)
|
|
|
+
|
|
|
+ if (!connectedDevice) {
|
|
|
+ setState({
|
|
|
+ errorText: '请先连接蓝牙透传设备'
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!writeServiceId || !writeCharacteristicId) {
|
|
|
+ setState({
|
|
|
+ errorText: '当前设备没有可写特征值'
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (errorText) {
|
|
|
+ setState({
|
|
|
+ errorText
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ const buffer = hexToArrayBuffer(hexFrame)
|
|
|
+ const bytes = new Uint8Array(buffer)
|
|
|
+ const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
|
|
|
+
|
|
|
+ if (dmaFrameLengthError) {
|
|
|
+ setState({
|
|
|
+ errorText: dmaFrameLengthError
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ const chunkSize = DEFAULT_PACKET_SIZE
|
|
|
+ const waitResponse = !!options.expected
|
|
|
+ const responsePromise = waitResponse
|
|
|
+ ? createPendingRequest(source, options.expected, options)
|
|
|
+ : null
|
|
|
+
|
|
|
+ setState({
|
|
|
+ isSending: true,
|
|
|
+ errorText: ''
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
|
+ const chunk = bytes.slice(offset, offset + chunkSize)
|
|
|
+
|
|
|
+ await callWx('writeBLECharacteristicValue', {
|
|
|
+ deviceId: connectedDevice.deviceId,
|
|
|
+ serviceId: writeServiceId,
|
|
|
+ characteristicId: writeCharacteristicId,
|
|
|
+ value: chunk.buffer,
|
|
|
+ writeType
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ setState({
|
|
|
+ txCount: state.txCount + bytes.length
|
|
|
+ })
|
|
|
+ addLog('TX', arrayBufferToHex(buffer), source)
|
|
|
+
|
|
|
+ if (waitResponse) {
|
|
|
+ return responsePromise
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+ } catch (error) {
|
|
|
+ if (waitResponse) {
|
|
|
+ cancelPendingRequest()
|
|
|
+ }
|
|
|
+ if (isConnectionLostError(error)) {
|
|
|
+ clearConnectedState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ setState({
|
|
|
+ errorText: formatBluetoothError(error)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ } finally {
|
|
|
+ setState({
|
|
|
+ isSending: false
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function sendManagedFrame(frameBytes, label, expected, options = {}) {
|
|
|
+ return enqueueSendFrame(formatHex(frameBytes), label, {
|
|
|
+ expected,
|
|
|
+ showModal: options.showModal !== false,
|
|
|
+ timeout: options.timeout || RESPONSE_TIMEOUT
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function sendFrame(hexFrame, source, options = {}) {
|
|
|
+ return enqueueSendFrame(hexFrame, source, options)
|
|
|
+}
|
|
|
+
|
|
|
+function sendHexFrame() {
|
|
|
+ const errorText = validateHex(state.sendHex)
|
|
|
+ const expected = errorText ? null : parseModbusRequest(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
|
|
|
+
|
|
|
+ return enqueueSendFrame(state.sendHex, 'HEX', expected ? {
|
|
|
+ expected
|
|
|
+ } : {})
|
|
|
+}
|
|
|
+
|
|
|
+function sendGeneratedFrame() {
|
|
|
+ if (!state.generatedHex) return false
|
|
|
+
|
|
|
+ const expected = buildGeneratedExpectedResponse()
|
|
|
+
|
|
|
+ return enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
|
|
|
+ expected
|
|
|
+ } : {})
|
|
|
+}
|
|
|
+
|
|
|
+setState(createProtocolState(
|
|
|
+ state.commandIndex,
|
|
|
+ state.slaveAddress,
|
|
|
+ state.registerAddress,
|
|
|
+ state.commandValue,
|
|
|
+ state.coilEnabled
|
|
|
+))
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+ arrayBufferToHex,
|
|
|
+ buildReadFrame,
|
|
|
+ buildWriteMultipleRegistersFrame,
|
|
|
+ buildWriteSingleCoilFrame,
|
|
|
+ buildWriteSingleRegisterFrame,
|
|
|
+ clearDevices,
|
|
|
+ clearInput,
|
|
|
+ clearLogs,
|
|
|
+ connectDeviceById,
|
|
|
+ disconnectDevice,
|
|
|
+ formatHex,
|
|
|
+ getState,
|
|
|
+ getSlaveAddress,
|
|
|
+ handleAppHide,
|
|
|
+ handleAppShow,
|
|
|
+ hexToArrayBuffer,
|
|
|
+ init,
|
|
|
+ normalizeHex,
|
|
|
+ openSetting,
|
|
|
+ parseModbusRequest,
|
|
|
+ sendFrame,
|
|
|
+ sendGeneratedFrame,
|
|
|
+ sendHexFrame,
|
|
|
+ sendManagedFrame,
|
|
|
+ setCommandIndex,
|
|
|
+ setProtocolInput,
|
|
|
+ setSendHex,
|
|
|
+ showCommandAlert,
|
|
|
+ startScan,
|
|
|
+ stopScan,
|
|
|
+ subscribe,
|
|
|
+ validateHex
|
|
|
+}
|