const { padHex } = require('../../utils/base-utils.js') const { bytesToWords, toByteArray } = require('../../utils/binary-utils.js') const { BYTE_ORDER_HIGH, appendCrc16Ccitt, crc16Ccitt, hasValidCrc16Ccitt } = require('../../utils/crc.js') const transport = require('../../transport/ble-core.js') const PROTOCOL_NAME = 'storage-access' const CMD_ERR_MASK = 0x80 const CMD_WRITE_MASK = 0x40 const CMD_AREA_MASK = 0x3F const CMD_INFO = 0x0F const INFO_DATA_BYTE_LENGTH = 4 const AREA = { DATA: 0x01, IDATA: 0x02, XDATA: 0x03, CODE: 0x04, INFO: 0x0F } const AREA_NAMES = { [AREA.DATA]: 'DATA', [AREA.IDATA]: 'IDATA', [AREA.XDATA]: 'XDATA', [AREA.CODE]: 'CODE', [AREA.INFO]: 'INFO' } const AREA_BY_NAME = { DATA: AREA.DATA, IDATA: AREA.IDATA, XDATA: AREA.XDATA, CODE: AREA.CODE, INFO: AREA.INFO, SYNC: AREA.INFO } const EXCEPTION_MESSAGES = { 0x01: '非法命令', 0x02: '非法区域', 0x03: '非法地址', 0x04: '非法长度', 0x05: '写保护', 0x06: '设备忙', 0x07: '格式错误', 0x08: '访问被拒绝', 0x09: '内部错误', 0x0A: '对齐错误', 0x0B: '范围溢出', 0x0C: '不支持的操作' } const DEFAULT_MAX_FRAME_BYTES = 64 const MAX_PAYLOAD_BYTES = 256 const UNLIMITED_FRAME_BYTES = 0 const READ_REQUEST_LENGTH = 7 const WRITE_REQUEST_OVERHEAD = 7 const READ_RESPONSE_OVERHEAD = 7 const WRITE_RESPONSE_LENGTH = 7 const EXCEPTION_RESPONSE_LENGTH = 4 const INFO_REQUEST_LENGTH = READ_REQUEST_LENGTH const INFO_RESPONSE_LENGTH = READ_RESPONSE_OVERHEAD + INFO_DATA_BYTE_LENGTH const STORAGE_CRC_OPTIONS = { byteOrder: BYTE_ORDER_HIGH } const VALID_AREAS = [AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE, AREA.INFO] const MEMORY_AREAS = [AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE] function toByte(value, label) { if (!Number.isInteger(value) || value < 0 || value > 0xFF) { throw new Error(`${label}必须在 0x00 至 0xFF 之间`) } return value } function toWord(value, label) { if (!Number.isInteger(value) || value < 0 || value > 0xFFFF) { throw new Error(`${label}必须在 0x0000 至 0xFFFF 之间`) } return value } function normalizeArea(value) { if (typeof value === 'string') { const area = AREA_BY_NAME[value.trim().toUpperCase()] if (area) return area } const area = toByte(Number(value), '存储区域') if (VALID_AREAS.indexOf(area) < 0) { throw new Error('存储区域必须为 data/idata/xdata/code/info') } return area } function normalizeMemoryArea(value) { const area = normalizeArea(value) if (MEMORY_AREAS.indexOf(area) < 0) { throw new Error('存储读写区域必须为 data/idata/xdata/code') } return area } function toByteLength(value, label = '字节长度', maxPayload = MAX_PAYLOAD_BYTES) { const byteLength = toWord(Number(value), label) if (byteLength === 0) { throw new Error(`${label}必须大于 0`) } if (maxPayload > 0 && byteLength > maxPayload) { throw new Error(`单帧最多访问 ${maxPayload} 字节`) } return byteLength } function splitWord(value) { return [(value >> 8) & 0xFF, value & 0xFF] } function readWord(bytes, offset) { return (((bytes[offset] || 0) << 8) | (bytes[offset + 1] || 0)) & 0xFFFF } function normalizeMaxFrameBytes(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { const numberValue = Number(maxFrameBytes) if (Number.isFinite(numberValue) && Math.round(numberValue) === UNLIMITED_FRAME_BYTES) return UNLIMITED_FRAME_BYTES if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue) return DEFAULT_MAX_FRAME_BYTES } function getPayloadLimitFromFrame(maxFrameBytes, overhead) { const frameBytes = normalizeMaxFrameBytes(maxFrameBytes) if (frameBytes === UNLIMITED_FRAME_BYTES) return MAX_PAYLOAD_BYTES return Math.max(0, Math.min(MAX_PAYLOAD_BYTES, frameBytes - overhead)) } function getMaxReadByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { return getPayloadLimitFromFrame(maxFrameBytes, READ_RESPONSE_OVERHEAD) } function getMaxWriteByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { return getPayloadLimitFromFrame(maxFrameBytes, WRITE_REQUEST_OVERHEAD) } function buildCommand(area, isWrite = false) { const normalizedArea = isWrite ? normalizeMemoryArea(area) : normalizeArea(area) return (isWrite ? CMD_WRITE_MASK : 0x00) | normalizedArea } function decodeCommand(command) { const cmd = toByte(Number(command), '命令字') return { area: cmd & CMD_AREA_MASK, command: cmd, hasError: !!(cmd & CMD_ERR_MASK), isWrite: !!(cmd & CMD_WRITE_MASK) } } function hasValidStorageCrc(bytes) { const frame = toByteArray(bytes) if (frame.length < 3) return false if (frame.length >= 4) return hasValidCrc16Ccitt(frame, STORAGE_CRC_OPTIONS) const expected = crc16Ccitt(frame.slice(0, -2), STORAGE_CRC_OPTIONS) const received = (((frame[frame.length - 2] || 0) << 8) | (frame[frame.length - 1] || 0)) & 0xFFFF return expected === received } function appendStorageCrc(bytes) { return appendCrc16Ccitt(bytes, STORAGE_CRC_OPTIONS) } function buildReadFrame(area, address, byteLength, options = {}) { const command = buildCommand(area, false) const startAddress = toWord(Number(address), '内存地址') const maxByteLength = getMaxReadByteLength(options.maxFrameBytes) const length = toByteLength(Number(byteLength), '读取字节长度', maxByteLength || MAX_PAYLOAD_BYTES) return appendStorageCrc([command].concat(splitWord(startAddress), splitWord(length))) } function buildInfoFrame(address = 0, byteLength = INFO_DATA_BYTE_LENGTH) { return buildReadFrame(AREA.INFO, address, byteLength) } function buildWriteFrame(area, address, bytes, options = {}) { const normalizedArea = normalizeMemoryArea(area) if (normalizedArea === AREA.CODE) { throw new Error('code 区暂不支持写入') } const command = buildCommand(normalizedArea, true) const startAddress = toWord(Number(address), '内存地址') const dataBytes = toByteArray(bytes).map((byte) => toByte(Number(byte), '写入字节')) const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes) const length = toByteLength(dataBytes.length, '写入字节长度', maxByteLength || MAX_PAYLOAD_BYTES) return appendStorageCrc([command].concat(splitWord(startAddress), splitWord(length), dataBytes)) } function formatHex(bytes) { return toByteArray(bytes).map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join(' ') } function parseStorageAccessResponse(bytes) { const frame = toByteArray(bytes) if (frame.length < EXCEPTION_RESPONSE_LENGTH || !hasValidStorageCrc(frame)) return null const command = frame[0] & 0xFF const decoded = decodeCommand(command) if (decoded.hasError) { if (frame.length !== EXCEPTION_RESPONSE_LENGTH) return null return { area: decoded.area, areaName: AREA_NAMES[decoded.area] || 'UNKNOWN', command, exceptionCode: frame[1] & 0xFF, isException: true, isWrite: decoded.isWrite, protocol: PROTOCOL_NAME, sourceCommand: command & ~CMD_ERR_MASK } } if (!AREA_NAMES[decoded.area]) return null if (decoded.isWrite) { if (decoded.area === AREA.INFO) return null if (frame.length !== WRITE_RESPONSE_LENGTH) return null return { address: readWord(frame, 1), area: decoded.area, areaName: AREA_NAMES[decoded.area] || 'UNKNOWN', byteLength: readWord(frame, 3), command, dataBytes: [], isException: false, isWrite: true, protocol: PROTOCOL_NAME } } if (frame.length < READ_RESPONSE_OVERHEAD) return null const byteLength = readWord(frame, 3) const dataStart = 5 const dataEnd = dataStart + byteLength if (frame.length !== dataEnd + 2) return null const dataBytes = frame.slice(dataStart, dataEnd) return { address: readWord(frame, 1), area: decoded.area, areaName: AREA_NAMES[decoded.area] || 'UNKNOWN', byteLength, command, dataBytes, isException: false, isInfo: decoded.area === AREA.INFO, isWrite: false, protocol: PROTOCOL_NAME, words: bytesToWords(dataBytes.length % 2 === 0 ? dataBytes : dataBytes.concat(0)), ...(decoded.area === AREA.INFO && dataBytes.length >= INFO_DATA_BYTE_LENGTH ? { codeInfoAddress: readWord(frame, 5), codeInfoByteLength: readWord(frame, 7), infoBytes: dataBytes.slice(0, INFO_DATA_BYTE_LENGTH) } : {}) } } function parseStorageAccessRequest(bytes) { const frame = toByteArray(bytes) if (frame.length < INFO_REQUEST_LENGTH || !hasValidStorageCrc(frame)) return null const command = frame[0] & 0xFF if (frame.length < READ_REQUEST_LENGTH) return null const decoded = decodeCommand(command) if (decoded.hasError) return null if (!AREA_NAMES[decoded.area]) return null if (decoded.area === AREA.INFO && decoded.isWrite) return null const address = readWord(frame, 1) const byteLength = readWord(frame, 3) const expectedLength = decoded.isWrite ? WRITE_REQUEST_OVERHEAD + byteLength : READ_REQUEST_LENGTH if (byteLength <= 0 || frame.length !== expectedLength) return null return { address, area: decoded.area, areaName: AREA_NAMES[decoded.area] || 'UNKNOWN', byteLength, command, dataBytes: decoded.isWrite ? frame.slice(5, 5 + byteLength) : [], isInfo: decoded.area === AREA.INFO, isWrite: decoded.isWrite, kind: 'raw-hex', operation: decoded.isWrite ? 'write' : 'read', protocol: PROTOCOL_NAME, quantity: byteLength } } function getExpectedResponseLength(expected, responseCommand, responseBytes = []) { if (!expected) return 0 const command = Number(responseCommand) & 0xFF if (command === (expected.command | CMD_ERR_MASK)) return EXCEPTION_RESPONSE_LENGTH if (command !== expected.command) return 0 if (expected.operation === 'write' || expected.isWrite) { return WRITE_RESPONSE_LENGTH } if (responseBytes.length < 5) return 0 return READ_RESPONSE_OVERHEAD + readWord(responseBytes, 3) } function isExpectedResponse(response, expected) { if (!response || !expected) return false const sourceCommand = response.isException ? response.sourceCommand : response.command if (sourceCommand !== expected.command) return false if (!response.isException && response.area !== expected.area) return false if (response.isException) return true if (response.address !== expected.address) return false if (response.byteLength !== expected.byteLength) return false if (!response.isWrite && (!Array.isArray(response.dataBytes) || response.dataBytes.length !== expected.byteLength)) return false return true } function getExceptionText(code) { return EXCEPTION_MESSAGES[code] || '未知异常' } function formatExceptionMessage(response) { const sourceCommand = response && response.sourceCommand const exceptionCode = response && response.exceptionCode const exceptionText = getExceptionText(exceptionCode) return `设备返回异常帧:命令 0x${padHex(sourceCommand, 2)},异常码 0x${padHex(exceptionCode, 2)}(${exceptionText})` } function getReadBufferHint(expected) { if (!expected) return 0 if (expected.operation === 'write' || expected.isWrite) return WRITE_RESPONSE_LENGTH return READ_RESPONSE_OVERHEAD + Number(expected.byteLength || expected.quantity || 0) } function alignResponseBuffer(buffer, expected) { if (!Array.isArray(buffer) || !buffer.length || !expected) return const expectedCommands = [expected.command, expected.command | CMD_ERR_MASK] let matchIndex = -1 for (let index = 0; index < buffer.length; index += 1) { if (expectedCommands.indexOf(buffer[index]) < 0) continue matchIndex = index break } if (matchIndex > 0) { buffer.splice(0, matchIndex) } else if (matchIndex < 0 && buffer.length > 1) { buffer.splice(0, buffer.length - 1) } } function readResponseFromBuffer(buffer, expected, options = {}) { if (!Array.isArray(buffer) || !buffer.length || !expected) { return { status: 'pending' } } alignResponseBuffer(buffer, expected) while (buffer.length >= 1) { const responseCommand = buffer[0] const responseLength = getExpectedResponseLength(expected, responseCommand, buffer) if (!responseLength) { return { status: 'pending' } } const frameLimit = normalizeMaxFrameBytes( options.maxFrameBytes === undefined ? expected.maxFrameBytes : options.maxFrameBytes ) if (frameLimit > 0 && responseLength > frameLimit) { return { frameLimit, responseLength, status: 'frame-too-long' } } if (buffer.length < responseLength) { return { status: 'pending' } } const frameBytes = buffer.slice(0, responseLength) const response = parseStorageAccessResponse(frameBytes) if (!response) { return { frameBytes, status: 'invalid' } } if (!isExpectedResponse(response, expected)) { buffer.shift() alignResponseBuffer(buffer, expected) continue } if (response.isException) { return { message: formatExceptionMessage(response), response, status: 'exception' } } buffer.splice(0, responseLength) return { response, status: 'complete' } } return { status: 'pending' } } function createExpected(area, address, byteLength, isWrite, kind) { const normalizedArea = normalizeMemoryArea(area) const command = buildCommand(normalizedArea, isWrite) return { address, area: normalizedArea, byteLength, command, isWrite, kind, operation: isWrite ? 'write' : 'read', protocol: PROTOCOL_NAME, quantity: byteLength } } function createInfoExpected(kind = 'storage-info-read') { return { address: 0, area: AREA.INFO, byteLength: INFO_DATA_BYTE_LENGTH, command: buildCommand(AREA.INFO, false), frame: buildInfoFrame(0, INFO_DATA_BYTE_LENGTH), isInfo: true, isWrite: false, kind, operation: 'read', protocol: PROTOCOL_NAME } } function formatAddress(value) { return Number(value || 0).toString(16).toUpperCase() } function getChunkLabel(label, chunks, chunk) { if (!label || chunks.length <= 1) return label return `${label} ${formatAddress(chunk.address)}-${formatAddress(chunk.address + chunk.quantity - 1)}` } function splitQuantity(startAddress, quantity, maxQuantity) { const chunks = [] let address = Number(startAddress) || 0 let remaining = Math.max(0, Math.floor(Number(quantity) || 0)) const chunkLimit = Math.max(1, Math.floor(Number(maxQuantity) || remaining || 1)) while (remaining > 0) { const chunkQuantity = Math.min(remaining, chunkLimit) chunks.push({ address, quantity: chunkQuantity }) address += chunkQuantity remaining -= chunkQuantity } return chunks } function getReadChunks(startAddress, byteLength, options = {}) { const maxByteLength = getMaxReadByteLength(options.maxFrameBytes) return splitQuantity(startAddress, byteLength, maxByteLength || byteLength) } function getWriteChunks(startAddress, bytes, options = {}) { const sourceBytes = Array.prototype.slice.call(bytes || []).map((byte) => Number(byte) & 0xFF) const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes) const chunks = splitQuantity(startAddress, sourceBytes.length, maxByteLength || sourceBytes.length) let offset = 0 return chunks.map((chunk) => { const dataBytes = sourceBytes.slice(offset, offset + chunk.quantity) offset += chunk.quantity return { ...chunk, dataBytes } }) } function sendReadChunk(area, chunk, label, kind, options = {}) { const normalizedArea = normalizeMemoryArea(area) return transport.sendManagedFrame( buildReadFrame(normalizedArea, chunk.address, chunk.quantity, { maxFrameBytes: options.maxFrameBytes }), label, createExpected(normalizedArea, chunk.address, chunk.quantity, false, kind), { maxFrameBytes: options.maxFrameBytes, showModal: options.showModal } ) } async function readMemory(area, startAddress, byteLength, label, kind = 'storage-memory-read', options = {}) { const normalizedArea = normalizeMemoryArea(area) const bytes = [] const chunks = getReadChunks(startAddress, byteLength, options) for (const chunk of chunks) { const response = await sendReadChunk( normalizedArea, chunk, getChunkLabel(label, chunks, chunk), kind, options ) if (!response) return null const dataBytes = Array.isArray(response.dataBytes) ? response.dataBytes : [] dataBytes.forEach((byte, index) => { bytes[chunk.address - startAddress + index] = Number(byte) & 0xFF }) if (typeof options.onChunk === 'function') { options.onChunk(response, chunk) } } return bytes } async function writeMemory(area, startAddress, bytes, label, kind = 'storage-memory-write', options = {}) { const normalizedArea = normalizeMemoryArea(area) const chunks = getWriteChunks(startAddress, bytes, options) for (const chunk of chunks) { const response = await transport.sendManagedFrame( buildWriteFrame(normalizedArea, chunk.address, chunk.dataBytes, { maxFrameBytes: options.maxFrameBytes }), getChunkLabel(label, chunks, chunk), createExpected(normalizedArea, chunk.address, chunk.quantity, true, kind), { maxFrameBytes: options.maxFrameBytes, showModal: options.showModal } ) if (!response) return false if (typeof options.onChunk === 'function') { options.onChunk(response, chunk) } } return true } async function readCodeInfoBlock(label = '同步info', kind = 'storage-info-read', options = {}) { const infoResponse = await transport.sendManagedFrame( buildInfoFrame(0, INFO_DATA_BYTE_LENGTH), label, createInfoExpected(`${kind}-info`), { maxFrameBytes: options.maxFrameBytes, showModal: options.showModal } ) if (!infoResponse) return null const codeInfoAddress = Number(infoResponse.codeInfoAddress || 0) const codeInfoByteLength = Number(infoResponse.codeInfoByteLength || 0) if (!codeInfoByteLength || codeInfoByteLength > 0xFFFF) { transport.showCommandAlert(label, 'info 信息块长度无效') return null } const bytes = await readMemory( AREA.CODE, codeInfoAddress, codeInfoByteLength, label, kind, options ) if (!bytes) return null return { codeInfoAddress, codeInfoByteLength, codeInfoBytes: bytes, infoBytes: Array.isArray(infoResponse.infoBytes) ? infoResponse.infoBytes : [], codeInfoMemoryType: AREA.CODE } } const response = { createExpected, createInfoExpected, formatExceptionMessage, getExceptionText, getExpectedResponseLength, getReadBufferHint, isExpectedResponse, parseStorageAccessRequest, parseStorageAccessResponse, readResponseFromBuffer } const client = { AREA, getMaxReadByteLength, getMaxWriteByteLength, getReadChunks, getWriteChunks, readCodeInfoBlock, readMemory, splitQuantity, writeMemory } module.exports = { AREA, AREA_BY_NAME, AREA_NAMES, CMD_AREA_MASK, CMD_ERR_MASK, CMD_INFO, CMD_WRITE_MASK, DEFAULT_MAX_FRAME_BYTES, EXCEPTION_MESSAGES, EXCEPTION_RESPONSE_LENGTH, INFO_REQUEST_LENGTH, INFO_RESPONSE_LENGTH, INFO_DATA_BYTE_LENGTH, MAX_PAYLOAD_BYTES, PROTOCOL_NAME, READ_REQUEST_LENGTH, READ_RESPONSE_OVERHEAD, STORAGE_CRC_OPTIONS, UNLIMITED_FRAME_BYTES, WRITE_REQUEST_OVERHEAD, WRITE_RESPONSE_LENGTH, appendStorageCrc, buildCommand, buildInfoFrame, buildReadFrame, buildWriteFrame, client, decodeCommand, formatHex, getMaxReadByteLength, getMaxWriteByteLength, hasValidStorageCrc, normalizeArea, normalizeMaxFrameBytes, normalizeMemoryArea, response, splitWord, toByte, toByteLength, toWord }