const { buildReadFrame, buildWriteMultipleRegistersFrame, buildWriteSingleCoilFrame, buildWriteSingleRegisterFrame, formatHex, getReadResponseByteLength, MODBUS_CRC_OPTIONS, MAX_MODBUS_DMA_BYTES, hasValidCrc16Modbus } = require('./modbus-rtu') const { BYTE_ORDER_HIGH, hasValidCrc16Ccitt } = require('./crc') const { getBootloaderResponseLength, isBootloaderFrame } = require('./bootloader-frame') const { notifyPageToast } = require('./page-toast') const { padHex } = require('./base-utils') const { bytesToWords } = require('./binary-utils') const SCAN_TIMEOUT = 15000 const CONNECT_TIMEOUT = 10000 const RSSI_REFRESH_INTERVAL = 2000 const DEFAULT_PACKET_SIZE = 20 const MODULE_PACKET_SIZES = [ { packetSize: 0, patterns: [/HC[-_ ]?05/i] }, { packetSize: 320, patterns: [/BT[-_ ]?24/i, /\bBT24\b/i] } ] 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: 'readDiscreteInputs', label: '02 读取离散输入', functionCode: 0x02, 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: 2, 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 rssiTimer = null let isReadingRssi = false let pendingRequest = null let sendQueue = [] let isProcessingSendQueue = false let sendQueueGeneration = 0 let sendJobSequence = 0 let deviceMap = {} let deviceSequence = 0 let logSequence = 0 const subscribers = [] const rawResponseSubscribers = [] 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 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 subscribeRawResponse(subscriber) { if (typeof subscriber !== 'function') return () => {} rawResponseSubscribers.push(subscriber) return () => { const index = rawResponseSubscribers.indexOf(subscriber) if (index >= 0) rawResponseSubscribers.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 packetSize = inferPacketSize({ displayName, localName: device.localName, name: device.name }) const isTargetAdvertised = hasTargetAdvertisedUuid({ advertisServiceUUIDs }) return { deviceId: device.deviceId, name: device.name || '', localName: device.localName || '', RSSI: device.RSSI, advertisServiceUUIDs, displayName, isTargetAdvertised, packetSize, signalText: formatSignalText(device.RSSI), serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务', targetText: isTargetAdvertised ? '广播含目标 UUID' : '', lastSeenAt: Date.now() } } function formatSignalText(RSSI) { return typeof RSSI === 'number' ? `${RSSI} dBm` : '--' } function inferPacketSize(device = {}) { const text = [device.displayName, device.name, device.localName] .map((value) => String(value || '')) .join(' ') .toUpperCase() for (const item of MODULE_PACKET_SIZES) { const matchedPattern = (item.patterns || []).some((pattern) => pattern.test(text)) if (matchedPattern) { return item.packetSize } } return DEFAULT_PACKET_SIZE } function resolvePacketSize(packetSize, frameLength) { if (packetSize === 0) return frameLength || DEFAULT_PACKET_SIZE if (Number.isInteger(packetSize) && packetSize > 0) return packetSize return DEFAULT_PACKET_SIZE } 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 normalizeMaxFrameBytes(maxFrameBytes) { const numberValue = Number(maxFrameBytes) if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0 if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue) return MAX_MODBUS_DMA_BYTES } function getResponseBufferLimit(expected, maxFrameBytes) { const responseLength = expected ? getReadResponseByteLength(expected.functionCode, expected.quantity) : 0 const frameLimit = normalizeMaxFrameBytes(maxFrameBytes) if (frameLimit === 0) { return Math.max(MAX_RESPONSE_BUFFER_BYTES, responseLength + 8) } return Math.max(MAX_RESPONSE_BUFFER_BYTES, frameLimit + 8, responseLength + 8) } 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 parseModbusResponse(bytes) { if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) 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 || functionCode === 0x02) { 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 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) 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 === 0x02 || 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) { const maxFrameBytes = normalizeMaxFrameBytes(expected && expected.maxFrameBytes) if (maxFrameBytes === 0) return '' if (bytes.length > maxFrameBytes) { return `发送帧长度 ${bytes.length} 字节,超过最大包长 ${maxFrameBytes} 字节限制` } if (!expected) return '' const responseLength = getReadResponseByteLength(expected.functionCode, expected.quantity) if (responseLength > maxFrameBytes) { return `预计返回帧长度 ${responseLength} 字节,超过最大包长 ${maxFrameBytes} 字节限制` } 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 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 getReceiveCrcState(rawBytes) { if (!rawBytes || rawBytes.length < 4) return '' if (isBootloaderFrame(rawBytes)) { const expectedLength = getBootloaderResponseLength(rawBytes) if (expectedLength && rawBytes.length < expectedLength) return '片段' return hasValidCrc16Ccitt(rawBytes, { byteOrder: BYTE_ORDER_HIGH }) ? 'CRC OK' : 'CRC ERR' } return hasValidCrc16Modbus(rawBytes, MODBUS_CRC_OPTIONS) ? 'CRC OK' : (pendingRequest ? '片段' : 'CRC ERR') } 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, packetSize: nextDevice.packetSize || previousDevice.packetSize || DEFAULT_PACKET_SIZE, seenIndex, serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务', targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '') } }) refreshDeviceList() } function refreshDeviceList() { const deviceList = getSortedDeviceList() setState({ devices: deviceList.slice(0, 30) }) } function getSortedDeviceList() { return 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 }) } 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 = {}) { stopRssiRefresh() resetSendRuntimeState() setState({ characteristicText: '未选择', connectedDevice: null, connectedServiceCount: 0, connectingDeviceId: '', isConnecting: false, writeCharacteristicId: '', writeServiceId: '', writeType: '', ...changedData }) } function stopRssiRefresh() { if (rssiTimer) { clearInterval(rssiTimer) rssiTimer = null } isReadingRssi = false } function applyRssiUpdate(deviceId, rssi) { if (!state.connectedDevice || state.connectedDevice.deviceId !== deviceId || typeof rssi !== 'number') { return } const signalText = formatSignalText(rssi) const updatedDevice = { ...state.connectedDevice, RSSI: rssi, lastSeenAt: Date.now(), signalText } deviceMap[deviceId] = { ...(deviceMap[deviceId] || {}), RSSI: rssi, lastSeenAt: updatedDevice.lastSeenAt, signalText } setState({ connectedDevice: updatedDevice, devices: getSortedDeviceList().slice(0, 30) }) } async function refreshConnectedRssi() { const { connectedDevice } = state if (!connectedDevice || typeof wx === 'undefined' || typeof wx.getBLEDeviceRSSI !== 'function') return if (isReadingRssi) return isReadingRssi = true try { const result = await callWx('getBLEDeviceRSSI', { deviceId: connectedDevice.deviceId }) if (!state.connectedDevice || state.connectedDevice.deviceId !== connectedDevice.deviceId) return applyRssiUpdate(connectedDevice.deviceId, result && result.RSSI) } catch (error) { if (isConnectionLostError(error)) { clearConnectedState({ errorText: formatBluetoothError(error) }) } } finally { isReadingRssi = false } } function startRssiRefresh() { stopRssiRefresh() if (!state.connectedDevice || typeof wx === 'undefined' || typeof wx.getBLEDeviceRSSI !== 'function') { return } refreshConnectedRssi() rssiTimer = setInterval(() => { refreshConnectedRssi() }, RSSI_REFRESH_INTERVAL) } 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 || response.functionCode === 0x02) { 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 || responseFunctionCode === 0x02) { 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 const frameLimit = normalizeMaxFrameBytes(pending.expected && pending.expected.maxFrameBytes) if (frameLimit > 0 && responseLength > frameLimit) { const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过最大包长 ${frameLimit} 字节限制,已丢弃` 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) const bufferLimit = pendingRequest.responseBufferLimit || MAX_RESPONSE_BUFFER_BYTES if (pendingRequest.responseBuffer.length > bufferLimit) { 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, responseBufferLimit: getResponseBufferLimit(expected, options.maxFrameBytes), 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 = getReceiveCrcState(rawBytes) setState({ rxCount: state.rxCount + byteLength }) addLog('RX', hex, crcState) rawResponseSubscribers.slice().forEach((subscriber) => { subscriber(rawBytes, res) }) 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, packetSize: device.packetSize || inferPacketSize(device), 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 }) startRssiRefresh() 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() stopRssiRefresh() resetSendRuntimeState() if (state.isDiscovering) { stopScan() } } async function handleAppShow() { init() const connected = await refreshNativeConnectionState() if (connected && state.connectedDevice) { startRssiRefresh() } } 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 = options.skipDmaCheck ? '' : 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 = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options.expected) if (dmaFrameLengthError) { setState({ errorText: dmaFrameLengthError }) return false } const chunkSize = resolvePacketSize( options.chunkSize === undefined ? connectedDevice.packetSize : options.chunkSize, bytes.length ) 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: expected ? { ...expected, maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes } : expected, showModal: options.showModal !== false, timeout: options.timeout || RESPONSE_TIMEOUT, maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes }) } function sendRawFrameExact(frameBytes, source) { const bytes = frameBytes instanceof Uint8Array ? frameBytes : new Uint8Array(frameBytes || []) return enqueueSendFrame(formatHex(Array.prototype.slice.call(bytes)), source, { chunkSize: 0, skipDmaCheck: true }) } 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 = { clearDevices, clearInput, clearLogs, connectDeviceById, disconnectDevice, getState, handleAppHide, handleAppShow, init, sendGeneratedFrame, sendHexFrame, sendManagedFrame, sendRawFrameExact, setCommandIndex, setProtocolInput, setSendHex, showCommandAlert, startScan, stopScan, subscribe, subscribeRawResponse }