浏览代码

协议调整与UI调整

avery 5 天之前
父节点
当前提交
ca039df280

+ 10 - 0
app.wxss

@@ -271,6 +271,7 @@ page {
 .theme-dark .generic-register-row,
 .theme-dark .generic-config-row,
 .theme-dark .generic-struct-section,
+.theme-dark .generic-group-inline-registers,
 .theme-dark .storage-code-info-inline {
   border-color: #263241;
 }
@@ -1304,6 +1305,15 @@ page {
   transform: rotate(-45deg);
 }
 
+.generic-group-inline-registers {
+  border-top: 1rpx solid #edf2f7;
+}
+
+.generic-group-inline-registers .generic-register-row {
+  border-top: 0;
+  border-radius: 0;
+}
+
 .storage-code-info-inline {
   display: flex;
   flex-direction: column;

+ 86 - 22
domain/parameter-groups/model.js

@@ -150,8 +150,50 @@ function padWordHex(value) {
   return numberValue.toString(16).toUpperCase().padStart(length, '0')
 }
 
-function padStorageHex(value) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(8, '0')
+function normalizeStorageAddressWidth(value, address = 0) {
+  const numberValue = Number(value)
+  if (numberValue === 16 || numberValue === 2) return 16
+  if (numberValue === 32 || numberValue === 4) return 32
+
+  return Number(address) > MAX_MODBUS_ADDRESS ? 32 : 16
+}
+
+function resolveStorageAddressWidth(source = {}, address = 0) {
+  const codeInfoContext = source.codeInfoContext || {}
+  const explicitWidth = source.sourceAddressWidth
+    || source.storageAddressWidth
+    || source.addressWidth
+    || codeInfoContext.storageAddressWidth
+    || codeInfoContext.addressWidth
+    || codeInfoContext.codeInfoAddressWidth
+  const explicitByteLength = source.sourceAddressByteLength
+    || source.addressByteLength
+    || codeInfoContext.sourceAddressByteLength
+    || codeInfoContext.addressByteLength
+
+  if (explicitWidth) return normalizeStorageAddressWidth(explicitWidth, address)
+  if (explicitByteLength) return normalizeStorageAddressWidth(explicitByteLength, address)
+
+  const memoryArea = String(source.sourceMemoryArea || '').trim().toUpperCase()
+  if (memoryArea === 'ADDR32' || memoryArea === 'ADDRESS32') return 32
+
+  return normalizeStorageAddressWidth(0, address)
+}
+
+function getStorageHexWidth(addressWidth, address = 0) {
+  return normalizeStorageAddressWidth(addressWidth, address) === 16 ? 4 : 8
+}
+
+function getStorageAddressMax(addressWidth, address = 0) {
+  return normalizeStorageAddressWidth(addressWidth, address) === 16
+    ? MAX_MODBUS_ADDRESS
+    : MAX_STORAGE_ADDRESS
+}
+
+function padStorageHex(value, addressWidth = 0) {
+  const numberValue = Math.max(0, Math.floor(Number(value) || 0))
+
+  return numberValue.toString(16).toUpperCase().padStart(getStorageHexWidth(addressWidth, numberValue), '0')
 }
 
 function formatAddressRange(startAddress, wordCount) {
@@ -166,16 +208,17 @@ function formatAddressRange(startAddress, wordCount) {
   return `0x${padWordHex(address)}-0x${padWordHex(safeEndAddress)}${overflowText}`
 }
 
-function formatStorageAddressRange(startAddress, byteCount) {
+function formatStorageAddressRange(startAddress, byteCount, addressWidth = 0) {
   const address = normalizeStorageAddress(startAddress, 0)
   const count = Math.max(1, Number(byteCount) || 1)
+  const addressMax = getStorageAddressMax(addressWidth, address)
   const endAddress = address + count - 1
-  const safeEndAddress = Math.min(endAddress, MAX_STORAGE_ADDRESS)
-  const overflowText = endAddress > MAX_STORAGE_ADDRESS ? '+' : ''
+  const safeEndAddress = Math.min(endAddress, addressMax)
+  const overflowText = endAddress > addressMax ? '+' : ''
 
-  if (count <= 1) return `0x${padStorageHex(address)}`
+  if (count <= 1) return `0x${padStorageHex(address, addressWidth)}${overflowText}`
 
-  return `0x${padStorageHex(address)}-0x${padStorageHex(safeEndAddress)}${overflowText}`
+  return `0x${padStorageHex(address, addressWidth)}-0x${padStorageHex(safeEndAddress, addressWidth)}${overflowText}`
 }
 
 function isByteAddressedGroup(group = {}) {
@@ -210,11 +253,12 @@ function isAddressRangeOverflow(startAddress, wordCount) {
   return address + count - 1 > MAX_MODBUS_ADDRESS
 }
 
-function isStorageAddressRangeOverflow(startAddress, byteCount) {
+function isStorageAddressRangeOverflow(startAddress, byteCount, addressWidth = 0) {
   const address = normalizeStorageAddress(startAddress, 0)
   const count = Math.max(1, Number(byteCount) || 1)
+  const addressMax = getStorageAddressMax(addressWidth, address)
 
-  return address + count - 1 > MAX_STORAGE_ADDRESS
+  return address + count - 1 > addressMax
 }
 
 function getMaxQuantity() {
@@ -391,8 +435,8 @@ function formatRegisterStartAddressText(address) {
   return `0x${padWordHex(address)}`
 }
 
-function formatStorageStartAddressText(address) {
-  return `0x${padStorageHex(address)}`
+function formatStorageStartAddressText(address, addressWidth = 0) {
+  return `0x${padStorageHex(address, addressWidth)}`
 }
 
 function formatStartAddressText(address, maxAddress = MAX_MODBUS_ADDRESS) {
@@ -434,6 +478,14 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
   const inputValue = savedValue === null ? defaultValue : savedValue
   const rawValue = register.rawValue === undefined ? null : register.rawValue
   const memoryEndian = isStorageMemory ? normalizeMemoryEndian(register.memoryEndian || group.codeInfoContext && group.codeInfoContext.memoryEndian) : 'big'
+  const storageAddressWidth = isStorageMemory
+    ? resolveStorageAddressWidth({
+      ...group,
+      sourceAddressByteLength: register.sourceAddressByteLength || group.sourceAddressByteLength,
+      sourceAddressWidth: register.sourceAddressWidth || group.sourceAddressWidth,
+      sourceMemoryArea: register.sourceMemoryArea || group.sourceMemoryArea
+    }, address)
+    : 0
   const byteLength = isBitRegisterType(registerType)
     ? 1
     : getRegisterByteLength(dataType, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
@@ -476,22 +528,22 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     ? `0x${padHex(address)}`
     : (byteAddressed
       ? (isStorageMemory
-        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        ? formatStorageAddressRange(address, Math.max(1, byteLength), storageAddressWidth)
         : formatAddressRange(address, Math.max(1, byteLength)))
       : formatAddressRange(address, registerCount))
   const addressText = isBitField
     ? (byteAddressed
       ? `${isStorageMemory
-        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        ? formatStorageAddressRange(address, Math.max(1, byteLength), storageAddressWidth)
         : formatAddressRange(address, Math.max(1, byteLength))}.b${bitOffset}${bitWidth > 1 ? `..b${bitOffset + bitWidth - 1}` : ''}`
       : formatBitFieldAddressText(address, byteOffset, bitOffset, bitWidth))
     : (byteAddressed
       ? (isStorageMemory
-        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        ? formatStorageAddressRange(address, Math.max(1, byteLength), storageAddressWidth)
         : formatAddressRange(address, Math.max(1, byteLength)))
       : formatRegisterAddressText(address, byteOffset, byteLength, registerType))
   const registerStartAddressText = isStorageMemory
-    ? formatStorageStartAddressText(address)
+    ? formatStorageStartAddressText(address, storageAddressWidth)
     : formatRegisterStartAddressText(address)
   const metaText = isStorageMemory
     ? `${registerStartAddressText} ${rawValueText}`
@@ -639,16 +691,18 @@ function normalizeGroup(group) {
     : (byteAddressed ? Math.max(1, byteLength) : Math.max(1, paddedByteLength / 2))
   const addressSpan = byteAddressed ? Math.max(1, byteLength) : wordQuantity
   const storageMemory = isStorageMemoryGroup(baseGroup)
+  const storageAddressWidth = storageMemory ? resolveStorageAddressWidth(baseGroup, startAddress) : 0
+  const storageAddressMax = getStorageAddressMax(storageAddressWidth, startAddress)
   const addressOverflow = storageMemory
-    ? isStorageAddressRangeOverflow(startAddress, addressSpan)
+    ? isStorageAddressRangeOverflow(startAddress, addressSpan, storageAddressWidth)
     : isAddressRangeOverflow(startAddress, addressSpan)
   const endAddress = startAddress + addressSpan - 1
-  const addressMax = storageMemory ? MAX_STORAGE_ADDRESS : MAX_MODBUS_ADDRESS
+  const addressMax = storageMemory ? storageAddressMax : MAX_MODBUS_ADDRESS
   const startAddressText = storageMemory
-    ? formatStorageStartAddressText(startAddress)
+    ? formatStorageStartAddressText(startAddress, storageAddressWidth)
     : formatStartAddressText(startAddress, addressMax)
   const endAddressText = storageMemory
-    ? (addressOverflow ? `0x${padStorageHex(addressMax)}+` : `0x${padStorageHex(endAddress)}`)
+    ? (addressOverflow ? `0x${padStorageHex(addressMax, storageAddressWidth)}+` : `0x${padStorageHex(endAddress, storageAddressWidth)}`)
     : (addressOverflow ? `0x${padWordHex(addressMax)}+` : `0x${padWordHex(endAddress)}`)
   const displayName = storageMemory
     ? stripStorageAreaPrefix(baseGroup.name)
@@ -664,10 +718,12 @@ function normalizeGroup(group) {
   return {
     ...baseGroup,
     addressRangeText: storageMemory
-      ? formatStorageAddressRange(startAddress, addressSpan)
+      ? formatStorageAddressRange(startAddress, addressSpan, storageAddressWidth)
       : formatAddressRange(startAddress, addressSpan),
     addressOverflow,
-    addressWarningText: addressOverflow ? `地址超出 0x${padWordHex(addressMax)}` : '',
+    addressWarningText: addressOverflow
+      ? `地址超出 0x${storageMemory ? padStorageHex(addressMax, storageAddressWidth) : padWordHex(addressMax)}`
+      : '',
     detailMetaText,
     detailTitleText: displayName,
     displayName,
@@ -696,8 +752,16 @@ function normalizeGroupConfig(config = {}) {
   const isStorageConfig = !!String(config.sourceMemoryArea || '').trim()
     || config.addressUnit === 'byte'
     || config.addressUnit === 'bytes'
+  const storageAddressWidth = isStorageConfig ? resolveStorageAddressWidth(config, 0) : 0
+  const storageAddressByteLength = storageAddressWidth === 16 ? 2 : 4
 
   return {
+    ...(isStorageConfig ? {
+      addressUnit: config.addressUnit || 'byte',
+      sourceAddressByteLength: config.sourceAddressByteLength || storageAddressByteLength,
+      sourceAddressWidth: storageAddressWidth,
+      sourceMemoryArea: config.sourceMemoryArea || 'XDATA'
+    } : {}),
     layout: isStructLayout(config.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
     name: String(config.name || config.groupName || '寄存器组').trim() || '寄存器组',
     pollEnabled: config.pollEnabled === false ? false : true,
@@ -705,7 +769,7 @@ function normalizeGroupConfig(config = {}) {
     registerType: registerType.key,
     startAddress: parseConfigAddress(config.startAddress, {
       label: isStorageConfig ? '内存起始地址' : '寄存器起始地址',
-      maxAddress: isStorageConfig ? MAX_STORAGE_ADDRESS : MAX_MODBUS_ADDRESS
+      maxAddress: isStorageConfig ? getStorageAddressMax(storageAddressWidth) : MAX_MODBUS_ADDRESS
     })
   }
 }

+ 19 - 9
domain/storage-access/code-info-parser.js

@@ -5,7 +5,8 @@ const {
 } = require('../../utils/binary-utils.js')
 
 const CODE_INFO_TLV_HEADER_BYTE_LENGTH = 2
-const CODE_INFO_ENTRY_NAME_BYTE_LENGTH = 32
+const CODE_INFO_ENTRY_NAME_LENGTH_BYTE_LENGTH = 1
+const CODE_INFO_ENTRY_MAX_NAME_BYTE_LENGTH = 0xFF
 const CODE_INFO_TLV = {
   DATA_STRUCT: 0x01,
   DATA_VARIABLE: 0x02,
@@ -149,7 +150,8 @@ function getEntryLayout(type) {
 
   const addressWidth = layout.addressWidth
   const addressByteLength = addressWidth === 16 ? 2 : 4
-  const valueByteLength = addressByteLength + 2 + CODE_INFO_ENTRY_NAME_BYTE_LENGTH
+  const nameLengthOffset = addressByteLength + 2
+  const nameOffset = nameLengthOffset + CODE_INFO_ENTRY_NAME_LENGTH_BYTE_LENGTH
 
   return {
     addressByteLength,
@@ -157,9 +159,9 @@ function getEntryLayout(type) {
     entryKind: layout.entryKind,
     hasMemoryType: false,
     memoryArea: layout.memoryArea,
-    nameByteLength: CODE_INFO_ENTRY_NAME_BYTE_LENGTH,
-    nameOffset: addressByteLength + 2,
-    valueByteLength
+    minValueByteLength: nameOffset,
+    nameLengthOffset,
+    nameOffset
   }
 }
 
@@ -203,8 +205,14 @@ function resolveCodeInfoByteLength(sourceLength, options = {}) {
 function parseMemoryEntry(valueBytes, index, type) {
   const layout = getEntryLayout(type)
   if (!layout) return null
-  if (valueBytes.length !== layout.valueByteLength) {
-    throw new Error(`CodeInfo 内存入口 TLV 长度无效,期望 ${layout.valueByteLength} 字节`)
+  if (valueBytes.length < layout.minValueByteLength) {
+    throw new Error(`CodeInfo 内存入口 TLV 长度无效,至少需要 ${layout.minValueByteLength} 字节`)
+  }
+
+  const nameByteLength = valueBytes[layout.nameLengthOffset] & 0xFF
+  const expectedValueByteLength = layout.minValueByteLength + nameByteLength
+  if (nameByteLength > CODE_INFO_ENTRY_MAX_NAME_BYTE_LENGTH || valueBytes.length !== expectedValueByteLength) {
+    throw new Error(`CodeInfo 内存入口 TLV 名称长度无效,声明 ${nameByteLength} 字节,实际 ${Math.max(0, valueBytes.length - layout.minValueByteLength)} 字节`)
   }
 
   const memType = 0
@@ -217,7 +225,7 @@ function parseMemoryEntry(valueBytes, index, type) {
   const entryKind = layout.entryKind
   const entryKindText = getEntryKindText(entryKind)
   const typeName = normalizeTypeName(
-    readTlvText(valueBytes.slice(nameOffset, nameOffset + layout.nameByteLength)),
+    readTlvText(valueBytes.slice(nameOffset, nameOffset + nameByteLength)),
     `${entryKindText === 'variable' ? 'var' : 'struct'}_${index + 1}`
   )
   const memoryArea = layout.memoryArea || 'UNKNOWN'
@@ -234,6 +242,7 @@ function parseMemoryEntry(valueBytes, index, type) {
     isVariableEntry: entryKind === CODE_INFO_ENTRY_KIND.VARIABLE,
     memType,
     memoryArea,
+    nameByteLength,
     rawByteLength: valueBytes.length,
     sourceAddressText: formatAddress(byteAddr, layout.addressWidth),
     tlvType: Number(type) & 0xFF,
@@ -474,7 +483,8 @@ function createGroupsFromCodeInfo(codeInfo, options = {}) {
 
 module.exports = {
   CODE_INFO_ENTRY_KIND,
-  CODE_INFO_ENTRY_NAME_BYTE_LENGTH,
+  CODE_INFO_ENTRY_MAX_NAME_BYTE_LENGTH,
+  CODE_INFO_ENTRY_NAME_LENGTH_BYTE_LENGTH,
   CODE_INFO_TLV_HEADER_BYTE_LENGTH,
   CODE_INFO_TLV,
   MEMORY_ENDIAN,

+ 0 - 1
features/communication/index.js

@@ -12,7 +12,6 @@ module.exports = {
   getProtocolModeState: viewModel.getProtocolModeState,
   getStorageAccessAreaOptions: viewModel.getStorageAccessAreaOptions,
   normalizeSerialState: viewModel.normalizeSerialState,
-  normalizeStorageAccessSpecialState: viewModel.normalizeStorageAccessSpecialState,
   normalizeStorageAccessState: viewModel.normalizeStorageAccessState,
   executeStorageAccessProtocol: service.executeStorageAccessProtocol,
   executeStorageAccessSpecialCommand: service.executeStorageAccessSpecialCommand,

+ 4 - 11
features/communication/service.js

@@ -1,4 +1,4 @@
-const storageAccessService = require('../storage-access/service.js')
+const storageAccessManualCommand = require('../storage-access/manual-command.js')
 const transport = require('../../transport/ble-core.js')
 const {
   bytesToHex,
@@ -79,7 +79,7 @@ async function executeStorageAccessProtocol(data = {}) {
   }
 
   try {
-    return await storageAccessService.executeMemoryCommand(data, {
+    return await storageAccessManualCommand.executeMemoryCommand(data, {
       maxPacketLength: data.parameterMaxPacketLength,
       showModal: true
     })
@@ -91,7 +91,7 @@ async function executeStorageAccessProtocol(data = {}) {
   }
 }
 
-async function executeStorageAccessSpecialCommand(command = {}, data = {}) {
+async function executeStorageAccessSpecialCommand(commandKey = 'reset', data = {}) {
   if (!data.connectedDevice) {
     return {
       errorText: '请先连接蓝牙设备',
@@ -99,14 +99,7 @@ async function executeStorageAccessSpecialCommand(command = {}, data = {}) {
     }
   }
 
-  if (!command || !command.op) {
-    return {
-      errorText: '特殊指令无效',
-      ok: false
-    }
-  }
-
-  return storageAccessService.executeControlCommand(command.key, data, {
+  return storageAccessManualCommand.executeControlCommand(commandKey || 'reset', data, {
     maxPacketLength: data.parameterMaxPacketLength,
     showModal: true
   })

+ 3 - 17
features/communication/view-model.js

@@ -1,4 +1,4 @@
-const storageAccessService = require('../storage-access/service.js')
+const storageAccessManualCommand = require('../storage-access/manual-command.js')
 const settingsService = require('../../store/settings-store.js')
 const {
   bytesToHex,
@@ -15,12 +15,8 @@ const LOG_MODE_OPTIONS = [
   { key: 'text', label: '文本' }
 ]
 
-function getStorageAccessSpecialCommands() {
-  return storageAccessService.getControlCommands()
-}
-
 function getStorageAccessAreaOptions() {
-  return storageAccessService.getMemoryAreaOptions()
+  return storageAccessManualCommand.getMemoryAreaOptions()
 }
 
 function getBinaryModeLabel(mode) {
@@ -72,16 +68,7 @@ function normalizeSerialState(current = {}, changed = {}) {
 }
 
 function normalizeStorageAccessState(current = {}, changed = {}) {
-  return storageAccessService.normalizeMemoryCommandState(current, changed)
-}
-
-function normalizeStorageAccessSpecialState(current = {}, changed = {}) {
-  const controlState = storageAccessService.normalizeControlState(current, changed)
-
-  return {
-    storageAccessSpecialCommands: getStorageAccessSpecialCommands(),
-    ...controlState
-  }
+  return storageAccessManualCommand.normalizeMemoryCommandState(current, changed)
 }
 
 function getLogPayloadText(log, mode) {
@@ -146,6 +133,5 @@ module.exports = {
   getNextSerialMode: getNextBinaryMode,
   getProtocolModeState,
   normalizeSerialState,
-  normalizeStorageAccessSpecialState,
   normalizeStorageAccessState
 }

+ 16 - 6
features/parameter-groups/dialog-handlers.js

@@ -176,12 +176,22 @@ function createDialogHandlers(parameterGroupService) {
         if (group) {
           if (this.pageToast) this.pageToast.show(`${group.name}已添加`)
           this.closeParameterDraft()
-          this.setData({
-            activeParameterGroup: group,
-            activeParameterGroupId: group.id,
-            activeParamView: 'parameterGroup',
-            activeParameterRegisterRows: buildActiveParameterRegisterRows(group, this.parameterRegisterDrag)
-          })
+          if (this.data.parameterCardControlEnabled === false) {
+            parameterGroupService.setGroupExpanded(group.id, true)
+            this.setData({
+              activeParameterGroup: null,
+              activeParameterGroupId: '',
+              activeParamView: 'parameterGroups',
+              activeParameterRegisterRows: []
+            })
+          } else {
+            this.setData({
+              activeParameterGroup: group,
+              activeParameterGroupId: group.id,
+              activeParamView: 'parameterGroup',
+              activeParameterRegisterRows: buildActiveParameterRegisterRows(group, this.parameterRegisterDrag)
+            })
+          }
         }
         return
       }

+ 12 - 11
features/parameter-groups/io.js

@@ -6,7 +6,8 @@ const {
 } = require('../../utils/binary-utils.js')
 const transport = require('../../transport/ble-core.js')
 const modbusClient = require('../modbus-rtu/service.js')
-const storageAccessService = require('../storage-access/service.js')
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const storageAccessProtocolIo = require('../storage-access/protocol-io.js')
 const {
   decodeRegisterFromByteCache,
   decodeRegisterFromWordCache,
@@ -30,16 +31,16 @@ const {
   splitWordSpans
 } = require('../../domain/parameter-groups/model.js')
 
-const STORAGE_ACCESS_READ_ONLY_AREAS = [storageAccessService.AREA.CODEINFO, storageAccessService.AREA.CODE]
+const STORAGE_ACCESS_READ_ONLY_AREAS = [storageAccessProtocol.AREA.CODEINFO, storageAccessProtocol.AREA.CODE]
 const MAX_STORAGE_ACCESS_ADDRESS = 0xFFFFFFFF
 const MAX_STORAGE_ACCESS_BYTE_LENGTH = 0xFFFFFFFF
 
 function getMemoryType(group = {}) {
   const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
-  if (memoryArea === 'ADDR32' || memoryArea === 'ADDRESS32') return storageAccessService.AREA.ADDR32
-  if (memoryArea === 'BIT') return storageAccessService.AREA.DATA
+  if (memoryArea === 'ADDR32' || memoryArea === 'ADDRESS32') return storageAccessProtocol.AREA.ADDR32
+  if (memoryArea === 'BIT') return storageAccessProtocol.AREA.DATA
 
-  const area = storageAccessService.AREA[memoryArea]
+  const area = storageAccessProtocol.AREA[memoryArea]
 
   return area === undefined ? null : area
 }
@@ -78,7 +79,7 @@ function shouldUseStorageAccess(options = {}, group = {}) {
 }
 
 function getStorageMaxPacketLength(group = {}, options = {}) {
-  const configuredMaxPacketLength = storageAccessService.resolveMaxPacketLength(options.maxPacketLength)
+  const configuredMaxPacketLength = storageAccessProtocolIo.resolveMaxPacketLength(options.maxPacketLength)
   const deviceMaxPacketLength = Number(group.codeInfoContext && group.codeInfoContext.maxPacketLength)
   if (!Number.isFinite(deviceMaxPacketLength) || deviceMaxPacketLength <= 0) return configuredMaxPacketLength
   if (configuredMaxPacketLength === 0) return Math.round(deviceMaxPacketLength)
@@ -443,7 +444,7 @@ async function writeMemoryRegister(group, register, options = {}) {
 
   try {
     if (register.isBitField) {
-      const baseBytes = await storageAccessService.readMemory(
+      const baseBytes = await storageAccessProtocolIo.readMemory(
         memoryType,
         address,
         byteLength,
@@ -480,7 +481,7 @@ async function writeMemoryRegister(group, register, options = {}) {
   bytes = bytes.slice(0, byteLength)
   while (bytes.length < byteLength) bytes.push(0)
 
-  const ok = await storageAccessService.writeMemory(
+  const ok = await storageAccessProtocolIo.writeMemory(
     memoryType,
     address,
     bytes,
@@ -509,7 +510,7 @@ async function readMemoryGroup(group, options = {}) {
     return null
   }
 
-  const bytes = await storageAccessService.readMemory(
+  const bytes = await storageAccessProtocolIo.readMemory(
     memoryType,
     address,
     byteLength,
@@ -551,7 +552,7 @@ async function writeMemoryGroup(group, options = {}) {
   try {
     let baseBytes = null
     if (groupHasBitFields(group)) {
-      baseBytes = await storageAccessService.readMemory(
+      baseBytes = await storageAccessProtocolIo.readMemory(
         memoryType,
         address,
         byteLength,
@@ -571,7 +572,7 @@ async function writeMemoryGroup(group, options = {}) {
   }
 
   bytes = bytes.slice(0, byteLength)
-  const ok = await storageAccessService.writeMemory(
+  const ok = await storageAccessProtocolIo.writeMemory(
     memoryType,
     address,
     bytes,

+ 52 - 14
features/parameter-groups/service.js

@@ -8,7 +8,7 @@ const {
   mergeImportedGroups,
   parseStructDefinition
 } = require('./imports.js')
-const storageAccessService = require('../storage-access/service.js')
+const storageAccessCodeInfo = require('../storage-access/code-info-sync.js')
 const parameterGroupIo = require('./io.js')
 const settingsService = require('../../store/settings-store.js')
 const store = require('./store.js')
@@ -18,6 +18,7 @@ const {
 const transport = require('../../transport/ble-core.js')
 const {
   DATA_TYPE_OPTIONS,
+  MAX_MODBUS_ADDRESS,
   MAX_STORAGE_ADDRESS,
   REGISTER_TYPE_OPTIONS,
   cloneImportedGroup,
@@ -49,6 +50,38 @@ function isStorageAccessProtocolMode(protocolMode) {
   return settingsService.isStorageAccessProtocol(protocolMode)
 }
 
+function getGroupStorageAddressWidth(group = {}) {
+  const codeInfoContext = group.codeInfoContext || {}
+  const explicitWidth = group.sourceAddressWidth
+    || group.storageAddressWidth
+    || group.addressWidth
+    || codeInfoContext.storageAddressWidth
+    || codeInfoContext.addressWidth
+    || codeInfoContext.codeInfoAddressWidth
+  const explicitByteLength = group.sourceAddressByteLength
+    || group.addressByteLength
+    || codeInfoContext.sourceAddressByteLength
+    || codeInfoContext.addressByteLength
+  const width = Number(explicitWidth)
+  if (width === 16 || width === 2) return 16
+  if (width === 32 || width === 4) return 32
+
+  const byteLength = Number(explicitByteLength)
+  if (byteLength === 2) return 16
+  if (byteLength === 4) return 32
+
+  const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
+  if (memoryArea === 'ADDR32' || memoryArea === 'ADDRESS32') return 32
+
+  return Math.floor(Number(group.startAddress) || 0) > MAX_MODBUS_ADDRESS ? 32 : 16
+}
+
+function getGroupStorageAddressMax(group = {}) {
+  return getGroupStorageAddressWidth(group) === 16
+    ? MAX_MODBUS_ADDRESS
+    : MAX_STORAGE_ADDRESS
+}
+
 function isGroupAddressRangeOverflow(group = {}, protocolMode = getActiveProtocolMode()) {
   if (!isStorageAccessProtocolMode(protocolMode)) {
     return isAddressRangeOverflow(group.startAddress, group.quantity)
@@ -56,14 +89,19 @@ function isGroupAddressRangeOverflow(group = {}, protocolMode = getActiveProtoco
 
   const startAddress = Math.max(0, Math.floor(Number(group.startAddress) || 0))
   const addressSpan = Math.max(1, Math.floor(Number(group.byteLength || group.sourceByteLength || group.quantity) || 1))
+  const maxAddress = getGroupStorageAddressMax(group)
 
-  return startAddress + addressSpan - 1 > MAX_STORAGE_ADDRESS
+  return startAddress + addressSpan - 1 > maxAddress
 }
 
-function getAddressOverflowText(protocolMode = getActiveProtocolMode()) {
-  return isStorageAccessProtocolMode(protocolMode)
-    ? '地址范围超出 0xFFFFFFFF'
-    : '地址范围超出 0xFFFF'
+function getAddressOverflowText(protocolMode = getActiveProtocolMode(), group = {}) {
+  if (isStorageAccessProtocolMode(protocolMode)) {
+    return getGroupStorageAddressMax(group) === MAX_MODBUS_ADDRESS
+      ? '地址范围超出 0xFFFF'
+      : '地址范围超出 0xFFFFFFFF'
+  }
+
+  return '地址范围超出 0xFFFF'
 }
 
 function isUnconfiguredRegister(register = {}) {
@@ -195,7 +233,7 @@ async function saveJsonToChat() {
 }
 
 async function syncFromStorageAccessCodeInfo(options = {}) {
-  const result = await storageAccessService.syncCodeInfo(options)
+  const result = await storageAccessCodeInfo.syncCodeInfo(options)
   if (!result || !result.ok) return result
 
   setStorageCodeInfo(result.codeInfo)
@@ -229,7 +267,7 @@ function addGroupFromConfig(config = {}) {
   }
 
   if (isGroupAddressRangeOverflow(groupConfig, protocolMode)) {
-    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode, groupConfig))
     return null
   }
 
@@ -242,7 +280,7 @@ function addGroupFromConfig(config = {}) {
   })
 
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode, group))
     return null
   }
 
@@ -268,7 +306,7 @@ function updateGroupConfig(groupId, config = {}) {
   }
 
   if (isGroupAddressRangeOverflow(nextConfig, protocolMode)) {
-    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode, nextConfig))
     return null
   }
 
@@ -280,7 +318,7 @@ function updateGroupConfig(groupId, config = {}) {
   })
 
   if (updatedGroup.addressOverflow) {
-    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode, updatedGroup))
     return null
   }
 
@@ -387,7 +425,7 @@ async function readGroup(groupId, options = {}) {
   const group = findGroup(groupId, protocolMode)
   if (!group) return false
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组读取', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组读取', getAddressOverflowText(protocolMode, group))
     return false
   }
 
@@ -418,7 +456,7 @@ async function writeRegister(groupId, registerIndex) {
     return false
   }
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode, group))
     return false
   }
   if (isUnconfiguredRegister(register)) {
@@ -451,7 +489,7 @@ async function writeGroup(groupId, options = {}) {
     return false
   }
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode))
+    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode, group))
     return false
   }
   const unconfigured = findUnconfiguredRegister(group)

+ 3 - 3
features/parameter-groups/store.js

@@ -11,7 +11,7 @@ const {
   REGISTER_TYPE_OPTIONS,
   normalizeGroup
 } = require('../../domain/parameter-groups/model.js')
-const storageAccessService = require('../storage-access/service.js')
+const storageAccessProtocolIo = require('../storage-access/protocol-io.js')
 const {
   PROTOCOL_MODE,
   normalizeProtocolMode
@@ -136,7 +136,7 @@ function readStorageCodeInfoCard() {
 
 function setStorageCodeInfo(codeInfo, options = {}) {
   const card = normalizeStorageCodeInfoCard(codeInfo)
-  storageAccessService.updateSyncedDeviceCaps(card.codeInfoContext || {})
+  storageAccessProtocolIo.updateSyncedDeviceCaps(card.codeInfoContext || {})
   const storageGroups = state.parameterGroupsByProtocol[PROTOCOL_MODE.STORAGE_ACCESS] || []
   const normalizedStorageGroups = normalizeGroupsForProtocol(storageGroups, PROTOCOL_MODE.STORAGE_ACCESS, {
     storageCodeInfoCard: card
@@ -189,7 +189,7 @@ function init(protocolMode = state.activeProtocolMode) {
   if (initialized) return
 
   const storageCodeInfoCard = readStorageCodeInfoCard()
-  storageAccessService.updateSyncedDeviceCaps(storageCodeInfoCard.codeInfoContext || {})
+  storageAccessProtocolIo.updateSyncedDeviceCaps(storageCodeInfoCard.codeInfoContext || {})
   const protocolOrder = normalizedProtocolMode === PROTOCOL_MODE.MODBUS_RTU
     ? [PROTOCOL_MODE.MODBUS_RTU, PROTOCOL_MODE.STORAGE_ACCESS]
     : [PROTOCOL_MODE.STORAGE_ACCESS, PROTOCOL_MODE.MODBUS_RTU]

+ 5 - 3
features/parameter-groups/view-model.js

@@ -58,7 +58,9 @@ function getPageState() {
   }
 }
 
-function resolveActiveParamView(currentView) {
+function resolveActiveParamView(currentView, settingsState = settingsService.getState()) {
+  if (settingsState.parameterCardControlEnabled === false) return 'parameterGroups'
+
   return currentView === 'parameterGroup' ? currentView : 'parameterGroups'
 }
 
@@ -69,7 +71,7 @@ function getSettingsPageState(currentData, settingsState) {
 
   return {
     ...settingsState,
-    activeParamView: resolveActiveParamView(currentData.activeParamView),
+    activeParamView: resolveActiveParamView(currentData.activeParamView, settingsState),
     isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol
@@ -94,7 +96,7 @@ function getVisiblePageState(currentData) {
 
   return {
     ...pageState,
-    activeParamView: resolveActiveParamView(currentData.activeParamView)
+    activeParamView: resolveActiveParamView(currentData.activeParamView, settingsState)
   }
 }
 

+ 1 - 1
features/settings/protocol-implementation.js

@@ -15,7 +15,7 @@ const GUIDE = {
     { id: 'addr', text: '普通区域 0x00 为 codeinfo 只读描述符;0x01/0x02/0x03/0x04 为 DATA、IDATA、XDATA、CODE 的 16 位地址;0x07 为 ADDR32 的 32 位地址;0x05~0x06 保留。' },
     { id: 'info', text: 'bit6=1 时 bit0~bit5 表示特殊指令码;当前仅定义 0x01 复位,因此复位帧 CMD=0x41。' },
     { id: 'control', text: 'CodeInfo 同步先发送 00+CRC 读取 area=0x00 描述符,数据区返回 TLV 起始 addr32、len16、地址位宽和最大包长。' },
-    { id: 'struct', text: 'CodeInfo 使用 TYPE + LEN8 + VALUE 的纯 TLV 信息块;0x01~0x08 为固定内存入口,VALUE 为 addr(2/4)+len16+name[32];单独变量由 TLV 给长度,UI 或 enum 导入配置解释类型。' },
+    { id: 'struct', text: 'CodeInfo 使用 TYPE + LEN8 + VALUE 的纯 TLV 信息块;0x01~0x08 为固定内存入口,VALUE 为 addr(2/4)+len16+name_len8+name;单独变量由 TLV 给长度,UI 或 enum 导入配置解释类型。' },
     { id: 'source', text: '协议实现源码已暂时移除,设置页仅保留文件占位与说明,避免给出过期从机实现。' }
   ]
 }

+ 174 - 0
features/storage-access/code-info-sync.js

@@ -0,0 +1,174 @@
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const transport = require('../../transport/ble-core.js')
+const {
+  createGroupsFromCodeInfo,
+  parseCodeInfo
+} = require('../../domain/storage-access/code-info-parser.js')
+const {
+  cloneImportedGroup,
+  normalizeGroup
+} = require('../../domain/parameter-groups/model.js')
+const protocolIo = require('./protocol-io.js')
+
+function formatDwordHex(value) {
+  const numberValue = Math.max(0, Math.floor(Number(value) || 0))
+
+  return numberValue.toString(16).toUpperCase().padStart(8, '0')
+}
+
+function showCodeInfoError(label, message, options = {}) {
+  if (options.showModal === false) return
+
+  transport.showCommandAlert(label, message)
+}
+
+async function readCodeInfoBlock(label = '同步CodeInfo', kind = 'storage-code-info-read', options = {}) {
+  const protocolIoOptions = protocolIo.normalizeProtocolIoOptions({
+    ...options,
+    useDeviceCaps: false
+  })
+  const descriptorBytes = await protocolIo.readMemory(
+    storageAccessProtocol.AREA.CODEINFO,
+    storageAccessProtocol.CODE_INFO_DESCRIPTOR_ADDRESS,
+    storageAccessProtocol.CODE_INFO_DESCRIPTOR_BYTE_LENGTH,
+    label,
+    `${kind}-descriptor`,
+    {
+      ...protocolIoOptions,
+      showModal: false,
+      useDeviceCaps: false
+    }
+  )
+  if (!descriptorBytes) {
+    showCodeInfoError(label, 'CodeInfo 描述符读取失败或超时', protocolIoOptions)
+    return null
+  }
+
+  let descriptor
+  try {
+    descriptor = storageAccessProtocol.parseCodeInfoDescriptorBytes(descriptorBytes)
+  } catch (error) {
+    showCodeInfoError(label, error.message || 'CodeInfo 描述符长度无效', protocolIoOptions)
+    return null
+  }
+
+  let codeInfoAddressWidth
+  try {
+    codeInfoAddressWidth = storageAccessProtocol.normalizeDescriptorAddressWidth(
+      descriptor.codeInfoAddressWidth
+    )
+  } catch (error) {
+    showCodeInfoError(label, error.message || 'CodeInfo 描述符地址长度无效', protocolIoOptions)
+    return null
+  }
+
+  const codeInfoAddress = Number(descriptor.codeInfoAddress || 0)
+  const codeInfoByteLength = Number(descriptor.codeInfoByteLength || 0)
+  const codeInfoMaxPacketLength = Number(descriptor.codeInfoMaxPacketLength || 0) & 0xFFFF
+  const codeInfoMemoryEndian = String(descriptor.codeInfoMemoryEndian || 'big').trim()
+  const codeInfoMemoryType = codeInfoAddressWidth === 32
+    ? storageAccessProtocol.AREA.ADDR32
+    : storageAccessProtocol.AREA.CODE
+  const codeInfoReadAddress = codeInfoAddressWidth === 32 ? codeInfoAddress : (codeInfoAddress & 0xFFFF)
+  const codeInfoMaxFrameBytes = storageAccessProtocol.resolveDescriptorMaxFrameBytes(
+    protocolIoOptions.maxFrameBytes,
+    codeInfoMaxPacketLength
+  )
+
+  if (!codeInfoByteLength || codeInfoByteLength > 0xFFFF) {
+    showCodeInfoError(label, 'CodeInfo 信息块长度无效', protocolIoOptions)
+    return null
+  }
+
+  const codeInfoBytes = await protocolIo.readMemory(
+    codeInfoMemoryType,
+    codeInfoReadAddress,
+    codeInfoByteLength,
+    label,
+    kind,
+    {
+      ...protocolIoOptions,
+      maxFrameBytes: codeInfoMaxFrameBytes,
+      useDeviceCaps: false
+    }
+  )
+  if (!codeInfoBytes) return null
+
+  return {
+    codeInfoAddress,
+    codeInfoAddressWidth,
+    codeInfoByteLength,
+    codeInfoBytes,
+    codeInfoDescriptorBytes: descriptorBytes,
+    codeInfoMaxPacketLength,
+    codeInfoMemoryEndian,
+    codeInfoMemoryEndianMark: Number(descriptor.codeInfoMemoryEndianMark || 0) & 0xFFFF,
+    codeInfoMemoryType
+  }
+}
+
+async function syncCodeInfo(options = {}) {
+  const result = await readCodeInfoBlock(
+    options.label || '同步CodeInfo',
+    options.kind || 'storage-code-info-read',
+    {
+      maxPacketLength: options.maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+  if (!result) {
+    return {
+      ok: false
+    }
+  }
+
+  const descriptorCaps = {
+    addressWidth: result.codeInfoAddressWidth,
+    codeInfoByteLength: result.codeInfoByteLength,
+    maxPacketLength: result.codeInfoMaxPacketLength,
+    memoryEndian: result.codeInfoMemoryEndian,
+    memoryEndianMark: result.codeInfoMemoryEndianMark
+  }
+  let codeInfo
+  let importedGroups
+  try {
+    codeInfo = parseCodeInfo(result.codeInfoBytes, descriptorCaps)
+    importedGroups = createGroupsFromCodeInfo(codeInfo, options)
+      .map(cloneImportedGroup)
+      .map(normalizeGroup)
+  } catch (error) {
+    const errorText = error.message || 'CodeInfo 解析失败'
+
+    showCodeInfoError(options.label || '同步CodeInfo', errorText, options)
+    return {
+      errorText,
+      ok: false
+    }
+  }
+
+  protocolIo.updateSyncedDeviceCaps(descriptorCaps)
+
+  return {
+    codeInfoAddress: result.codeInfoAddress,
+    codeInfoAddressText: formatDwordHex(result.codeInfoAddress),
+    codeInfoAddressWidth: result.codeInfoAddressWidth,
+    codeInfoByteLength: result.codeInfoByteLength,
+    codeInfoByteLengthText: formatDwordHex(result.codeInfoByteLength),
+    codeInfoBytes: result.codeInfoBytes,
+    codeInfo,
+    codeInfoDescriptorBytes: result.codeInfoDescriptorBytes,
+    codeInfoMaxPacketLength: result.codeInfoMaxPacketLength,
+    codeInfoMemoryEndian: result.codeInfoMemoryEndian,
+    codeInfoMemoryEndianMark: result.codeInfoMemoryEndianMark,
+    codeInfoMemoryType: result.codeInfoMemoryType,
+    groupCount: importedGroups.length,
+    importedGroups,
+    ok: true,
+    structCount: codeInfo.structCount
+  }
+}
+
+module.exports = {
+  readCodeInfoBlock,
+  syncCodeInfo
+}

+ 274 - 0
features/storage-access/manual-command.js

@@ -0,0 +1,274 @@
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const {
+  bytesToHex
+} = require('../../utils/binary-utils.js')
+const {
+  normalizeHexDwordText,
+  normalizeHexText,
+  normalizeHexWordText,
+  parseHexBytes,
+  parseHexDword,
+  parseHexNumber,
+  validateHexDwordText,
+  validateHexText,
+  validateHexWordText
+} = require('../../utils/validation.js')
+const protocolIo = require('./protocol-io.js')
+
+const MEMORY_COMMANDS = [
+  { key: 'read', label: '读取', description: '按字节读取内存' },
+  { key: 'write', label: '写入', description: '按字节写入内存' }
+]
+
+const MEMORY_AREAS = [
+  { key: storageAccessProtocol.AREA.ADDR32, label: 'addr32', name: 'ADDR32', addressWidth: 32 },
+  { key: storageAccessProtocol.AREA.DATA, label: 'data', name: 'DATA' },
+  { key: storageAccessProtocol.AREA.IDATA, label: 'idata', name: 'IDATA' },
+  { key: storageAccessProtocol.AREA.XDATA, label: 'xdata', name: 'XDATA' },
+  { key: storageAccessProtocol.AREA.CODE, label: 'code', name: 'CODE', readOnly: true }
+]
+
+const CONTROL_COMMANDS = [
+  { key: 'reset', label: '复位', op: storageAccessProtocol.CONTROL_OP.RESET }
+]
+
+function formatDwordHex(value) {
+  const numberValue = Math.max(0, Math.floor(Number(value) || 0))
+
+  return numberValue.toString(16).toUpperCase().padStart(8, '0')
+}
+
+function getMemoryAreaOptions() {
+  return MEMORY_AREAS.map((area) => ({ ...area }))
+}
+
+function resolveMemoryCommand(index) {
+  const commandIndex = Number(index) === 1 ? 1 : 0
+
+  return {
+    command: MEMORY_COMMANDS[commandIndex],
+    index: commandIndex
+  }
+}
+
+function resolveMemoryArea(index, commandKey = 'read') {
+  const memoryAreas = getMemoryAreaOptions()
+  let areaIndex = Number(index) || 0
+
+  if (commandKey === 'write' && memoryAreas[areaIndex] && memoryAreas[areaIndex].readOnly) {
+    areaIndex = 0
+  }
+  if (!memoryAreas[areaIndex]) areaIndex = 0
+
+  return {
+    area: memoryAreas[areaIndex] || memoryAreas[0],
+    index: areaIndex,
+    options: memoryAreas
+  }
+}
+
+function normalizeDisplayHexText(value, addressWidth = 16) {
+  const text = addressWidth === 32
+    ? normalizeHexDwordText(value)
+    : normalizeHexWordText(value)
+
+  return text.toUpperCase()
+}
+
+function buildMemoryPreview(commandKey, area, address, length, dataBytes) {
+  try {
+    const frameOptions = {
+      maxFrameBytes: 0
+    }
+
+    if (commandKey === 'write') {
+      return bytesToHex(storageAccessProtocol.buildWriteFrame(area, address, dataBytes, frameOptions), ' ')
+    }
+
+    return bytesToHex(storageAccessProtocol.buildReadFrame(area, address, length, frameOptions), ' ')
+  } catch (error) {
+    return ''
+  }
+}
+
+function normalizeMemoryCommandState(current = {}, changed = {}) {
+  const next = {
+    storageAccessAddress: current.storageAccessAddress || '',
+    storageAccessAreaIndex: current.storageAccessAreaIndex || 0,
+    storageAccessCommandIndex: current.storageAccessCommandIndex || 0,
+    storageAccessDataText: current.storageAccessDataText || '',
+    storageAccessLength: current.storageAccessLength || '',
+    ...changed
+  }
+  const resolvedCommand = resolveMemoryCommand(next.storageAccessCommandIndex)
+  const command = resolvedCommand.command
+  const resolvedArea = resolveMemoryArea(next.storageAccessAreaIndex, command.key)
+  const area = resolvedArea.area
+  const addressWidth = Number(area.addressWidth) === 32 ? 32 : 16
+  const addressText = normalizeDisplayHexText(next.storageAccessAddress, addressWidth)
+  const lengthText = normalizeDisplayHexText(next.storageAccessLength, 16)
+  const dataText = normalizeHexText(next.storageAccessDataText)
+  const dataBytes = dataText ? parseHexBytes(dataText) : []
+  const address = parseInt(addressText || '0', 16)
+  const byteLength = parseInt(lengthText || '0', 16)
+  const hasAddressText = !!addressText.trim()
+  const hasLengthText = !!lengthText.trim()
+  const addressError = hasAddressText
+    ? (addressWidth === 32
+      ? validateHexDwordText(addressText, '地址')
+      : validateHexWordText(addressText, '地址'))
+    : ''
+  const lengthError = hasLengthText ? validateHexWordText(lengthText, '长度') : ''
+  let errorText = addressError || lengthError
+
+  if (!errorText && hasLengthText && byteLength <= 0) {
+    errorText = '长度必须大于 0'
+  } else if (!errorText && command.key === 'write') {
+    const hexError = validateHexText(next.storageAccessDataText)
+    if (hexError) {
+      errorText = hexError
+    } else if (area.readOnly) {
+      errorText = `${area.label} 区不可写`
+    } else if (dataBytes.length !== byteLength) {
+      errorText = `写入长度为 ${byteLength} 字节,当前数据为 ${dataBytes.length} 字节`
+    }
+  }
+
+  const canPreview = !errorText && hasAddressText && hasLengthText && byteLength > 0
+
+  return {
+    storageAccessAddress: addressText,
+    storageAccessAddressMaxLength: addressWidth === 32 ? 8 : 4,
+    storageAccessAddressWidthText: `${addressWidth}bit`,
+    storageAccessAreaIndex: resolvedArea.index,
+    storageAccessAreaLabel: area.label,
+    storageAccessAreaLocked: false,
+    storageAccessAreaOptions: resolvedArea.options,
+    storageAccessCommandIndex: resolvedCommand.index,
+    storageAccessCommandLabel: command.label,
+    storageAccessDataText: dataText,
+    storageAccessErrorText: errorText,
+    storageAccessLength: lengthText,
+    storageAccessPreviewHexText: canPreview
+      ? buildMemoryPreview(command.key, area.key, address, byteLength, dataBytes)
+      : '',
+    storageAccessPreviewText: canPreview
+      ? `${area.label} 0x${addressWidth === 32 ? formatDwordHex(address) : address.toString(16).toUpperCase().padStart(4, '0')} / ${byteLength} bytes`
+      : '',
+    storageAccessShowWriteData: command.key === 'write',
+    storageAccessTitleText: `${command.label}命令`
+  }
+}
+
+function parseMemoryCommandInput(data = {}) {
+  const state = normalizeMemoryCommandState(data)
+  const command = resolveMemoryCommand(state.storageAccessCommandIndex).command
+  const area = resolveMemoryArea(state.storageAccessAreaIndex, command.key).area
+
+  if (state.storageAccessErrorText) {
+    throw new Error(state.storageAccessErrorText)
+  }
+  if (!String(state.storageAccessAddress || '').trim()) {
+    throw new Error('地址请输入十六进制')
+  }
+  if (!String(state.storageAccessLength || '').trim()) {
+    throw new Error('长度请输入十六进制')
+  }
+
+  return {
+    area,
+    areaValue: area.key,
+    byteLength: parseHexNumber(state.storageAccessLength, '长度', 0xFFFF),
+    command,
+    commandKey: command.key,
+    dataBytes: command.key === 'write' ? parseHexBytes(state.storageAccessDataText || '') : [],
+    startAddress: Number(area.addressWidth) === 32
+      ? parseHexDword(state.storageAccessAddress, '地址')
+      : parseHexNumber(state.storageAccessAddress, '地址', 0xFFFF),
+    state
+  }
+}
+
+async function executeMemoryCommand(data = {}, options = {}) {
+  const command = parseMemoryCommandInput(data)
+
+  if (command.commandKey === 'read') {
+    const bytes = await protocolIo.readMemory(
+      command.areaValue,
+      command.startAddress,
+      command.byteLength,
+      options.label || '存储访问协议读取',
+      options.kind || 'communication-storage-read',
+      options
+    )
+
+    return {
+      bytes,
+      command,
+      ok: !!bytes,
+      previewHex: bytes ? bytesToHex(bytes, ' ') : '',
+      state: command.state
+    }
+  }
+
+  const ok = await protocolIo.writeMemory(
+    command.areaValue,
+    command.startAddress,
+    command.dataBytes,
+    options.label || '存储访问协议写入',
+    options.kind || 'communication-storage-write',
+    options
+  )
+
+  return {
+    command,
+    ok,
+    state: command.state
+  }
+}
+
+function getControlCommand(commandKey) {
+  return CONTROL_COMMANDS.find((command) => command.key === commandKey) || null
+}
+
+async function executeControlCommand(commandKey, data = {}, options = {}) {
+  const command = getControlCommand(commandKey)
+  if (!command) {
+    return {
+      errorText: '特殊指令无效',
+      ok: false
+    }
+  }
+
+  const response = await protocolIo.executeControl(
+    command.op,
+    [],
+    command.label || '特殊指令',
+    `storage-control-${command.key}`,
+    {
+      maxPacketLength: options.maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+
+  if (!response) {
+    return {
+      errorText: '指令执行失败或超时',
+      ok: false
+    }
+  }
+
+  return {
+    command,
+    ok: true,
+    response
+  }
+}
+
+module.exports = {
+  executeControlCommand,
+  executeMemoryCommand,
+  getControlCommand,
+  getMemoryAreaOptions,
+  normalizeMemoryCommandState
+}

+ 155 - 0
features/storage-access/protocol-io.js

@@ -0,0 +1,155 @@
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const settingsService = require('../../store/settings-store.js')
+const transport = require('../../transport/ble-core.js')
+
+let syncedDeviceCaps = {
+  addressWidth: 0,
+  maxPacketLength: 0,
+  memoryEndian: ''
+}
+
+function getConfiguredMaxPacketLength(value) {
+  const settings = settingsService.getState()
+  const numberValue = Number(value === undefined ? settings.parameterMaxPacketLength : value)
+
+  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
+  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
+
+  return 64
+}
+
+function resolveMaxPacketLength(value) {
+  const configuredMaxPacketLength = getConfiguredMaxPacketLength(value)
+  const deviceMaxPacketLength = Number(syncedDeviceCaps.maxPacketLength || 0)
+
+  if (!Number.isFinite(deviceMaxPacketLength) || deviceMaxPacketLength <= 0) return configuredMaxPacketLength
+  if (configuredMaxPacketLength === 0) return Math.round(deviceMaxPacketLength)
+
+  return Math.min(configuredMaxPacketLength, Math.round(deviceMaxPacketLength))
+}
+
+function normalizeProtocolIoOptions(options = {}) {
+  const maxPacketLength = options.maxPacketLength === undefined ? options.maxFrameBytes : options.maxPacketLength
+
+  return {
+    expectedByteLength: options.expectedByteLength,
+    maxFrameBytes: options.useDeviceCaps === false
+      ? getConfiguredMaxPacketLength(maxPacketLength)
+      : resolveMaxPacketLength(maxPacketLength),
+    onChunk: options.onChunk,
+    showModal: options.showModal !== false
+  }
+}
+
+function updateSyncedDeviceCaps(caps = {}) {
+  const addressWidth = Number(caps.addressWidth)
+  const maxPacketLength = Number(caps.maxPacketLength)
+  const memoryEndian = String(caps.memoryEndian || '').trim().toLowerCase()
+
+  syncedDeviceCaps = {
+    addressWidth: addressWidth === 16 || addressWidth === 32 ? addressWidth : 0,
+    maxPacketLength: Number.isFinite(maxPacketLength) && maxPacketLength > 0
+      ? Math.round(maxPacketLength)
+      : 0,
+    memoryEndian: memoryEndian === 'little' ? 'little' : (memoryEndian === 'big' ? 'big' : '')
+  }
+}
+
+function getSyncedDeviceCaps() {
+  return {
+    ...syncedDeviceCaps
+  }
+}
+
+async function readMemory(area, startAddress, byteLength, label, kind, options = {}) {
+  const protocolIoOptions = normalizeProtocolIoOptions(options)
+  const normalizedArea = storageAccessProtocol.normalizeMemoryArea(area)
+  const bytes = []
+  const chunks = storageAccessProtocol.getReadChunks(startAddress, byteLength, {
+    ...protocolIoOptions,
+    area: normalizedArea
+  })
+
+  for (const chunk of chunks) {
+    const response = await transport.sendManagedFrame(
+      storageAccessProtocol.buildReadFrame(normalizedArea, chunk.address, chunk.quantity, {
+        maxFrameBytes: protocolIoOptions.maxFrameBytes
+      }),
+      storageAccessProtocol.getChunkLabel(label, chunks, chunk),
+      storageAccessProtocol.createExpected(normalizedArea, chunk.address, chunk.quantity, false, kind),
+      {
+        maxFrameBytes: protocolIoOptions.maxFrameBytes,
+        showModal: protocolIoOptions.showModal
+      }
+    )
+    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 protocolIoOptions.onChunk === 'function') {
+      protocolIoOptions.onChunk(response, chunk)
+    }
+  }
+
+  return bytes
+}
+
+async function writeMemory(area, startAddress, bytes, label, kind, options = {}) {
+  const protocolIoOptions = normalizeProtocolIoOptions(options)
+  const normalizedArea = storageAccessProtocol.normalizeMemoryArea(area)
+  const chunks = storageAccessProtocol.getWriteChunks(startAddress, bytes, {
+    ...protocolIoOptions,
+    area: normalizedArea
+  })
+
+  for (const chunk of chunks) {
+    const response = await transport.sendManagedFrame(
+      storageAccessProtocol.buildWriteFrame(normalizedArea, chunk.address, chunk.dataBytes, {
+        maxFrameBytes: protocolIoOptions.maxFrameBytes
+      }),
+      storageAccessProtocol.getChunkLabel(label, chunks, chunk),
+      storageAccessProtocol.createExpected(normalizedArea, chunk.address, chunk.quantity, true, kind),
+      {
+        maxFrameBytes: protocolIoOptions.maxFrameBytes,
+        showModal: protocolIoOptions.showModal
+      }
+    )
+    if (!response) return false
+
+    if (typeof protocolIoOptions.onChunk === 'function') {
+      protocolIoOptions.onChunk(response, chunk)
+    }
+  }
+
+  return true
+}
+
+async function executeControl(operation, dataBytes, label, kind, options = {}) {
+  const protocolIoOptions = normalizeProtocolIoOptions(options)
+  const response = await transport.sendManagedFrame(
+    storageAccessProtocol.buildControlFrame(operation, dataBytes),
+    label,
+    storageAccessProtocol.createControlExpected(operation, kind, {
+      expectedByteLength: protocolIoOptions.expectedByteLength
+    }),
+    {
+      maxFrameBytes: protocolIoOptions.maxFrameBytes,
+      showModal: protocolIoOptions.showModal
+    }
+  )
+
+  return response || null
+}
+
+module.exports = {
+  executeControl,
+  getSyncedDeviceCaps,
+  normalizeProtocolIoOptions,
+  readMemory,
+  resolveMaxPacketLength,
+  updateSyncedDeviceCaps,
+  writeMemory
+}

+ 0 - 614
features/storage-access/service.js

@@ -1,614 +0,0 @@
-const storageAccessProtocol = require('../../protocols/storage-access/index.js')
-const settingsService = require('../../store/settings-store.js')
-const transport = require('../../transport/ble-core.js')
-const {
-  createGroupsFromCodeInfo,
-  parseCodeInfo
-} = require('../../domain/storage-access/code-info-parser.js')
-const {
-  cloneImportedGroup,
-  normalizeGroup
-} = require('../../domain/parameter-groups/model.js')
-const {
-  bytesToHex
-} = require('../../utils/binary-utils.js')
-const {
-  normalizeHexDwordText,
-  normalizeHexText,
-  normalizeHexWordText,
-  parseHexBytes,
-  parseHexDword,
-  parseHexNumber,
-  validateHexDwordText,
-  validateHexText,
-  validateHexWordText
-} = require('../../utils/validation.js')
-
-const MEMORY_COMMANDS = [
-  { key: 'read', label: '读取', description: '按字节读取内存' },
-  { key: 'write', label: '写入', description: '按字节写入内存' }
-]
-
-const MEMORY_AREAS = [
-  { key: storageAccessProtocol.AREA.ADDR32, label: 'addr32', name: 'ADDR32', addressWidth: 32 },
-  { key: storageAccessProtocol.AREA.CODEINFO, label: 'codeinfo', name: 'CODEINFO', readOnly: true },
-  { key: storageAccessProtocol.AREA.DATA, label: 'data', name: 'DATA' },
-  { key: storageAccessProtocol.AREA.IDATA, label: 'idata', name: 'IDATA' },
-  { key: storageAccessProtocol.AREA.XDATA, label: 'xdata', name: 'XDATA' },
-  { key: storageAccessProtocol.AREA.CODE, label: 'code', name: 'CODE', readOnly: true }
-]
-const MEMORY_READ_INDEX = 0
-const MEMORY_WRITE_INDEX = 1
-
-const CONTROL_COMMANDS = [
-  { key: 'reset', label: '复位', op: storageAccessProtocol.CONTROL_OP.RESET }
-]
-
-let syncedDeviceCaps = {
-  addressWidth: 0,
-  maxPacketLength: 0,
-  memoryEndian: ''
-}
-
-function resolveMaxPacketLength(value) {
-  const settings = settingsService.getState()
-  const numberValue = Number(value === undefined ? settings.parameterMaxPacketLength : value)
-  const deviceMaxPacketLength = Number(syncedDeviceCaps.maxPacketLength || 0)
-  const configuredMaxPacketLength = Number.isFinite(numberValue) && Math.round(numberValue) === 0
-    ? 0
-    : (Number.isFinite(numberValue) && numberValue > 0 ? Math.round(numberValue) : 64)
-
-  if (!Number.isFinite(deviceMaxPacketLength) || deviceMaxPacketLength <= 0) return configuredMaxPacketLength
-  if (configuredMaxPacketLength === 0) return Math.round(deviceMaxPacketLength)
-
-  return Math.min(configuredMaxPacketLength, Math.round(deviceMaxPacketLength))
-}
-
-function resolveConfiguredMaxPacketLength(value) {
-  const settings = settingsService.getState()
-  const numberValue = Number(value === undefined ? settings.parameterMaxPacketLength : value)
-  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
-  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
-
-  return 64
-}
-
-function normalizeTransferOptions(options = {}) {
-  const maxPacketLength = options.maxPacketLength === undefined ? options.maxFrameBytes : options.maxPacketLength
-
-  return {
-    expectedByteLength: options.expectedByteLength,
-    maxFrameBytes: options.useDeviceCaps === false
-      ? resolveConfiguredMaxPacketLength(maxPacketLength)
-      : resolveMaxPacketLength(maxPacketLength),
-    onChunk: options.onChunk,
-    showModal: options.showModal !== false
-  }
-}
-
-function updateSyncedDeviceCaps(caps = {}) {
-  const addressWidth = Number(caps.addressWidth)
-  const maxPacketLength = Number(caps.maxPacketLength)
-  const memoryEndian = String(caps.memoryEndian || '').trim().toLowerCase()
-
-  syncedDeviceCaps = {
-    addressWidth: addressWidth === 16 || addressWidth === 32 ? addressWidth : 0,
-    maxPacketLength: Number.isFinite(maxPacketLength) && maxPacketLength > 0
-      ? Math.round(maxPacketLength)
-      : 0,
-    memoryEndian: memoryEndian === 'little' ? 'little' : (memoryEndian === 'big' ? 'big' : '')
-  }
-}
-
-async function readMemory(area, startAddress, byteLength, label, kind, options = {}) {
-  const transferOptions = normalizeTransferOptions(options)
-  const normalizedArea = storageAccessProtocol.normalizeMemoryArea(area)
-  const bytes = []
-  const chunks = storageAccessProtocol.getReadChunks(startAddress, byteLength, {
-    ...transferOptions,
-    area: normalizedArea
-  })
-
-  for (const chunk of chunks) {
-    const response = await transport.sendManagedFrame(
-      storageAccessProtocol.buildReadFrame(normalizedArea, chunk.address, chunk.quantity, {
-        maxFrameBytes: transferOptions.maxFrameBytes
-      }),
-      storageAccessProtocol.getChunkLabel(label, chunks, chunk),
-      storageAccessProtocol.createExpected(normalizedArea, chunk.address, chunk.quantity, false, kind),
-      {
-        maxFrameBytes: transferOptions.maxFrameBytes,
-        showModal: transferOptions.showModal
-      }
-    )
-    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 transferOptions.onChunk === 'function') {
-      transferOptions.onChunk(response, chunk)
-    }
-  }
-
-  return bytes
-}
-
-async function writeMemory(area, startAddress, bytes, label, kind, options = {}) {
-  const transferOptions = normalizeTransferOptions(options)
-  const normalizedArea = storageAccessProtocol.normalizeMemoryArea(area)
-  const chunks = storageAccessProtocol.getWriteChunks(startAddress, bytes, {
-    ...transferOptions,
-    area: normalizedArea
-  })
-
-  for (const chunk of chunks) {
-    const response = await transport.sendManagedFrame(
-      storageAccessProtocol.buildWriteFrame(normalizedArea, chunk.address, chunk.dataBytes, {
-        maxFrameBytes: transferOptions.maxFrameBytes
-      }),
-      storageAccessProtocol.getChunkLabel(label, chunks, chunk),
-      storageAccessProtocol.createExpected(normalizedArea, chunk.address, chunk.quantity, true, kind),
-      {
-        maxFrameBytes: transferOptions.maxFrameBytes,
-        showModal: transferOptions.showModal
-      }
-    )
-    if (!response) return false
-
-    if (typeof transferOptions.onChunk === 'function') {
-      transferOptions.onChunk(response, chunk)
-    }
-  }
-
-  return true
-}
-
-async function readCodeInfoBlock(label, kind, options = {}) {
-  const transferOptions = normalizeTransferOptions({
-    ...options,
-    useDeviceCaps: false
-  })
-  const descriptorBytes = await readMemory(
-    storageAccessProtocol.AREA.CODEINFO,
-    storageAccessProtocol.CODE_INFO_DESCRIPTOR_ADDRESS,
-    storageAccessProtocol.CODE_INFO_DESCRIPTOR_BYTE_LENGTH,
-    label,
-    `${kind}-descriptor`,
-    {
-      ...transferOptions,
-      useDeviceCaps: false,
-      showModal: false
-    }
-  )
-  if (!descriptorBytes) {
-    if (transferOptions.showModal !== false) {
-      transport.showCommandAlert(label, 'CodeInfo 描述符读取失败或超时')
-    }
-    return null
-  }
-
-  let descriptorResponse
-  try {
-    descriptorResponse = storageAccessProtocol.parseCodeInfoDescriptorBytes(descriptorBytes)
-  } catch (error) {
-    if (transferOptions.showModal !== false) {
-      transport.showCommandAlert(label, error.message || 'CodeInfo 描述符长度无效')
-    }
-    return null
-  }
-
-  const codeInfoAddress = Number(descriptorResponse.codeInfoAddress || 0)
-  const codeInfoByteLength = Number(descriptorResponse.codeInfoByteLength || 0)
-  let codeInfoAddressWidth
-  try {
-    codeInfoAddressWidth = storageAccessProtocol.normalizeDescriptorAddressWidth(
-      descriptorResponse.codeInfoAddressWidth
-    )
-  } catch (error) {
-    if (transferOptions.showModal !== false) {
-      transport.showCommandAlert(label, error.message || 'CodeInfo 描述符地址长度无效')
-    }
-    return null
-  }
-  const codeInfoMemoryEndian = String(descriptorResponse.codeInfoMemoryEndian || 'big').trim()
-  const codeInfoMaxPacketLength = Number(descriptorResponse.codeInfoMaxPacketLength || 0) & 0xFFFF
-  const codeInfoArea = codeInfoAddressWidth === 32 ? storageAccessProtocol.AREA.ADDR32 : storageAccessProtocol.AREA.CODE
-  const codeInfoReadAddress = codeInfoAddressWidth === 32 ? codeInfoAddress : (codeInfoAddress & 0xFFFF)
-  const codeInfoMaxFrameBytes = storageAccessProtocol.resolveDescriptorMaxFrameBytes(
-    transferOptions.maxFrameBytes,
-    codeInfoMaxPacketLength
-  )
-  if (!codeInfoByteLength || codeInfoByteLength > 0xFFFF) {
-    if (transferOptions.showModal !== false) {
-      transport.showCommandAlert(label, 'CodeInfo 信息块长度无效')
-    }
-    return null
-  }
-
-  const codeInfoBytes = await readMemory(
-    codeInfoArea,
-    codeInfoReadAddress,
-    codeInfoByteLength,
-    label,
-    kind,
-    {
-      ...transferOptions,
-      maxFrameBytes: codeInfoMaxFrameBytes,
-      useDeviceCaps: false
-    }
-  )
-  if (!codeInfoBytes) return null
-
-  return {
-    codeInfoAddress,
-    codeInfoAddressWidth,
-    codeInfoByteLength,
-    codeInfoBytes,
-    codeInfoDescriptorBytes: descriptorBytes,
-    codeInfoMaxPacketLength,
-    codeInfoMemoryEndian,
-    codeInfoMemoryEndianMark: Number(descriptorResponse.codeInfoMemoryEndianMark || 0) & 0xFFFF,
-    codeInfoMemoryType: codeInfoArea
-  }
-}
-
-async function executeControl(operation, dataBytes, label, kind, options = {}) {
-  const transferOptions = normalizeTransferOptions(options)
-  const response = await transport.sendManagedFrame(
-    storageAccessProtocol.buildControlFrame(operation, dataBytes),
-    label,
-    storageAccessProtocol.createControlExpected(operation, kind, {
-      expectedByteLength: transferOptions.expectedByteLength
-    }),
-    {
-      maxFrameBytes: transferOptions.maxFrameBytes,
-      showModal: transferOptions.showModal
-    }
-  )
-
-  return response || null
-}
-
-function formatDwordHex(value) {
-  const numberValue = Math.max(0, Math.floor(Number(value) || 0))
-
-  return numberValue.toString(16).toUpperCase().padStart(8, '0')
-}
-
-function getAreaAddressWidth(area) {
-  return Number(area && area.addressWidth) === 32 || Number(area && area.key) === storageAccessProtocol.AREA.ADDR32
-    ? 32
-    : 16
-}
-
-function getMemoryAreaOptions() {
-  return MEMORY_AREAS.map((area) => ({ ...area }))
-}
-
-function resolveMemoryCommand(index) {
-  const commandIndex = Number(index) === 2 ? MEMORY_WRITE_INDEX : Number(index)
-
-  return {
-    command: MEMORY_COMMANDS[commandIndex] || MEMORY_COMMANDS[MEMORY_READ_INDEX],
-    index: commandIndex === MEMORY_WRITE_INDEX ? MEMORY_WRITE_INDEX : MEMORY_READ_INDEX
-  }
-}
-
-function resolveMemoryArea(index, commandKey = 'read') {
-  const memoryAreas = getMemoryAreaOptions()
-  let areaIndex = Number(index) || 0
-
-  if (commandKey === 'write' && memoryAreas[areaIndex] && memoryAreas[areaIndex].readOnly) {
-    areaIndex = 0
-  }
-  if (!memoryAreas[areaIndex]) areaIndex = 0
-
-  return {
-    area: memoryAreas[areaIndex] || memoryAreas[0],
-    index: areaIndex,
-    options: memoryAreas
-  }
-}
-
-function normalizeDisplayHexText(value, addressWidth = 16) {
-  const text = addressWidth === 32
-    ? normalizeHexDwordText(value)
-    : normalizeHexWordText(value)
-
-  return text.toUpperCase()
-}
-
-function buildMemoryPreview(commandKey, area, address, length, dataBytes) {
-  try {
-    const frameOptions = {
-      maxFrameBytes: 0
-    }
-
-    if (commandKey === 'write') {
-      return bytesToHex(storageAccessProtocol.buildWriteFrame(area, address, dataBytes, frameOptions), ' ')
-    }
-
-    return bytesToHex(storageAccessProtocol.buildReadFrame(area, address, length, frameOptions), ' ')
-  } catch (error) {
-    return ''
-  }
-}
-
-function normalizeMemoryCommandState(current = {}, changed = {}) {
-  const next = {
-    storageAccessAreaIndex: current.storageAccessAreaIndex || 0,
-    storageAccessAddress: current.storageAccessAddress || '',
-    storageAccessCommandIndex: current.storageAccessCommandIndex || 0,
-    storageAccessDataText: current.storageAccessDataText || '',
-    storageAccessLength: current.storageAccessLength || '',
-    ...changed
-  }
-
-  const resolvedCommand = resolveMemoryCommand(next.storageAccessCommandIndex)
-  const commandIndex = resolvedCommand.index
-  const command = resolvedCommand.command
-  const resolvedArea = resolveMemoryArea(next.storageAccessAreaIndex, command.key)
-  const areaIndex = resolvedArea.index
-  const area = resolvedArea.area
-  const isCodeInfoDescriptor = command.key === 'read' && area.key === storageAccessProtocol.AREA.CODEINFO
-  const addressWidth = getAreaAddressWidth(area)
-  const addressText = isCodeInfoDescriptor
-    ? '0000'
-    : normalizeDisplayHexText(next.storageAccessAddress, addressWidth)
-  const lengthText = isCodeInfoDescriptor
-    ? storageAccessProtocol.CODE_INFO_DESCRIPTOR_BYTE_LENGTH.toString(16).toUpperCase().padStart(4, '0')
-    : normalizeDisplayHexText(next.storageAccessLength, 16)
-  const dataText = normalizeHexText(next.storageAccessDataText)
-  const dataBytes = dataText ? parseHexBytes(dataText) : []
-  const address = parseInt(addressText || '0', 16)
-  const byteLength = parseInt(lengthText || '0', 16)
-  const hasAddressText = !!addressText.trim()
-  const hasLengthText = !!lengthText.trim()
-
-  let errorText = ''
-  const addressError = hasAddressText
-    ? (addressWidth === 32
-      ? validateHexDwordText(addressText, '地址')
-      : validateHexWordText(addressText, '地址'))
-    : ''
-  const lengthError = hasLengthText ? validateHexWordText(lengthText, '长度') : ''
-  if (addressError) {
-    errorText = addressError
-  } else if (lengthError) {
-    errorText = lengthError
-  } else if (hasLengthText && byteLength <= 0) {
-    errorText = '长度必须大于 0'
-  } else if (command.key === 'write') {
-    const hexError = validateHexText(next.storageAccessDataText)
-    if (hexError) {
-      errorText = hexError
-    } else if (area.readOnly) {
-      errorText = `${area.label} 区不可写`
-    } else if (dataBytes.length !== byteLength) {
-      errorText = `写入长度为 ${byteLength} 字节,当前数据为 ${dataBytes.length} 字节`
-    }
-  }
-
-  const canPreview = !errorText && hasAddressText && hasLengthText && byteLength > 0
-  const previewHex = canPreview
-    ? buildMemoryPreview(command.key, area.key, address, byteLength, dataBytes)
-    : ''
-
-  return {
-    storageAccessAddress: addressText,
-    storageAccessAddressMaxLength: addressWidth === 32 ? 8 : 4,
-    storageAccessAddressWidthText: `${addressWidth}bit`,
-    storageAccessAreaLocked: false,
-    storageAccessAreaOptions: resolvedArea.options,
-    storageAccessAreaIndex: areaIndex,
-    storageAccessAreaLabel: area.label,
-    storageAccessCommandIndex: commandIndex,
-    storageAccessCommandLabel: command.label,
-    storageAccessDataText: dataText,
-    storageAccessErrorText: errorText,
-    storageAccessLength: lengthText,
-    storageAccessPreviewHexText: previewHex,
-    storageAccessPreviewText: canPreview
-      ? (isCodeInfoDescriptor
-        ? 'codeinfo descriptor'
-        : `${area.label} 0x${addressWidth === 32 ? formatDwordHex(address) : address.toString(16).toUpperCase().padStart(4, '0')} / ${byteLength} bytes`)
-      : '',
-    storageAccessShowWriteData: command.key === 'write',
-    storageAccessTitleText: `${command.label}命令`
-  }
-}
-
-function getSyncedDeviceCaps() {
-  return {
-    ...syncedDeviceCaps
-  }
-}
-
-function parseMemoryCommandInput(data = {}) {
-  const state = normalizeMemoryCommandState(data)
-  const command = resolveMemoryCommand(state.storageAccessCommandIndex).command
-  const area = resolveMemoryArea(state.storageAccessAreaIndex, command.key).area
-  const addressWidth = getAreaAddressWidth(area)
-
-  if (state.storageAccessErrorText) {
-    throw new Error(state.storageAccessErrorText)
-  }
-  if (!String(state.storageAccessAddress || '').trim()) {
-    throw new Error('地址请输入十六进制')
-  }
-  if (!String(state.storageAccessLength || '').trim()) {
-    throw new Error('长度请输入十六进制')
-  }
-
-  return {
-    area,
-    areaValue: area.key,
-    byteLength: parseHexNumber(state.storageAccessLength, '长度', 0xFFFF),
-    command,
-    commandKey: command.key,
-    dataBytes: command.key === 'write' ? parseHexBytes(state.storageAccessDataText || '') : [],
-    startAddress: addressWidth === 32
-      ? parseHexDword(state.storageAccessAddress, '地址')
-      : parseHexNumber(state.storageAccessAddress, '地址', 0xFFFF),
-    state
-  }
-}
-
-async function executeMemoryCommand(data = {}, options = {}) {
-  const command = parseMemoryCommandInput(data)
-
-  if (command.commandKey === 'read') {
-    const bytes = await readMemory(
-      command.areaValue,
-      command.startAddress,
-      command.byteLength,
-      options.label || '存储访问协议读取',
-      options.kind || 'communication-storage-read',
-      options
-    )
-
-    return {
-      bytes,
-      command,
-      ok: !!bytes,
-      previewHex: bytes ? bytesToHex(bytes, ' ') : '',
-      state: command.state
-    }
-  }
-
-  const ok = await writeMemory(
-    command.areaValue,
-    command.startAddress,
-    command.dataBytes,
-    options.label || '存储访问协议写入',
-    options.kind || 'communication-storage-write',
-    options
-  )
-
-  return {
-    command,
-    ok,
-    state: command.state
-  }
-}
-
-function getControlCommand(commandKey) {
-  return CONTROL_COMMANDS.find((command) => command.key === commandKey) || null
-}
-
-function normalizeControlState() {
-  return {}
-}
-
-function getControlCommands() {
-  return CONTROL_COMMANDS.map((command) => ({
-    ...command,
-    previewHexText: bytesToHex(storageAccessProtocol.buildControlFrame(command.op), ' ')
-  }))
-}
-
-async function syncCodeInfo(options = {}) {
-  const result = await readCodeInfoBlock(
-    options.label || '同步CodeInfo',
-    options.kind || 'storage-code-info-read',
-    {
-      maxPacketLength: options.maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-  if (!result) {
-    return {
-      ok: false
-    }
-  }
-
-  const descriptorCaps = {
-    addressWidth: result.codeInfoAddressWidth,
-    codeInfoByteLength: result.codeInfoByteLength,
-    maxPacketLength: result.codeInfoMaxPacketLength,
-    memoryEndian: result.codeInfoMemoryEndian,
-    memoryEndianMark: result.codeInfoMemoryEndianMark
-  }
-  const codeInfo = parseCodeInfo(result.codeInfoBytes, descriptorCaps)
-  const importedGroups = createGroupsFromCodeInfo(codeInfo, options)
-    .map(cloneImportedGroup)
-    .map(normalizeGroup)
-  updateSyncedDeviceCaps(descriptorCaps)
-
-  return {
-    codeInfoAddress: result.codeInfoAddress,
-    codeInfoAddressWidth: result.codeInfoAddressWidth,
-    codeInfoAddressText: formatDwordHex(result.codeInfoAddress),
-    codeInfoByteLength: result.codeInfoByteLength,
-    codeInfoByteLengthText: formatDwordHex(result.codeInfoByteLength),
-    codeInfoBytes: result.codeInfoBytes,
-    codeInfo,
-    codeInfoDescriptorBytes: result.codeInfoDescriptorBytes,
-    codeInfoMaxPacketLength: result.codeInfoMaxPacketLength,
-    codeInfoMemoryEndian: result.codeInfoMemoryEndian,
-    codeInfoMemoryEndianMark: result.codeInfoMemoryEndianMark,
-    groupCount: importedGroups.length,
-    importedGroups,
-    codeInfoMemoryType: result.codeInfoMemoryType,
-    ok: true,
-    structCount: codeInfo.structCount
-  }
-}
-
-async function executeControlCommand(commandKey, data = {}, options = {}) {
-  const command = getControlCommand(commandKey)
-  if (!command) {
-    return {
-      errorText: '特殊指令无效',
-      ok: false
-    }
-  }
-
-  const response = await executeControl(
-    command.op,
-    [],
-    command.label || '特殊指令',
-    `storage-control-${command.key}`,
-    {
-      maxPacketLength: options.maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-
-  if (!response) {
-    return {
-      errorText: '指令执行失败或超时',
-      ok: false
-    }
-  }
-
-  return {
-    command,
-    ok: true,
-    response
-  }
-}
-
-module.exports = {
-  AREA: storageAccessProtocol.AREA,
-  CONTROL_OP: storageAccessProtocol.CONTROL_OP,
-  executeMemoryCommand,
-  executeControl,
-  executeControlCommand,
-  formatDwordHex,
-  getControlCommand,
-  getControlCommands,
-  getMemoryAreaOptions,
-  getSyncedDeviceCaps,
-  normalizeMemoryCommandState,
-  normalizeControlState,
-  readCodeInfoBlock,
-  readMemory,
-  resolveMaxPacketLength,
-  syncCodeInfo,
-  updateSyncedDeviceCaps,
-  writeMemory
-}

+ 30 - 21
pages/communication/communication.js

@@ -20,7 +20,6 @@ const {
   getStorageAccessAreaOptions,
   manualRtuService,
   sendSerialFrame,
-  normalizeStorageAccessSpecialState,
   normalizeStorageAccessState,
   normalizeSerialState
 } = require('../../features/communication/index.js')
@@ -50,7 +49,6 @@ Page({
       storageAccessDataText: '',
       storageAccessLength: ''
     }),
-    ...normalizeStorageAccessSpecialState(),
     serialTitleText: '串口发送',
     ...INITIAL_SERIAL_STATE,
     toastText: '',
@@ -350,23 +348,35 @@ Page({
       return
     }
 
-    const result = await parameterGroupService.syncFromStorageAccessCodeInfo({
-      maxPacketLength: this.data.parameterMaxPacketLength
-    })
-    if (result && result.ok && this.pageToast) {
-      this.setData(normalizeStorageAccessState(this.data, {
-        storageAccessCommandIndex: this.data.storageAccessCommandIndex,
-        storageAccessAreaIndex: 0
-      }))
-      const codeInfoAddressText = result.codeInfoAddressText || Number(result.codeInfoAddress || 0).toString(16).toUpperCase()
-      const codeInfoByteLengthText = result.codeInfoByteLengthText || Number(result.codeInfoByteLength || 0).toString(16).toUpperCase()
-      const addedCount = Number(result.addedGroups || 0) + Number(result.addedRegisters || 0)
-      const updatedCount = Number(result.updatedGroups || 0) + Number(result.updatedRegisters || 0)
-      const changedText = [
-        addedCount ? `新增 ${addedCount}` : '',
-        updatedCount ? `更新 ${updatedCount}` : ''
-      ].filter(Boolean).join(',')
+    let result
+    try {
+      result = await parameterGroupService.syncFromStorageAccessCodeInfo({
+        maxPacketLength: this.data.parameterMaxPacketLength
+      })
+    } catch (error) {
+      if (this.pageToast) this.pageToast.show(error.message || 'CodeInfo 同步失败', 'error')
+      return
+    }
 
+    if (!result || !result.ok) {
+      if (this.pageToast && result && result.errorText) this.pageToast.show(result.errorText, 'error')
+      return
+    }
+
+    this.setData(normalizeStorageAccessState(this.data, {
+      storageAccessCommandIndex: this.data.storageAccessCommandIndex,
+      storageAccessAreaIndex: 0
+    }))
+    const codeInfoAddressText = result.codeInfoAddressText || Number(result.codeInfoAddress || 0).toString(16).toUpperCase()
+    const codeInfoByteLengthText = result.codeInfoByteLengthText || Number(result.codeInfoByteLength || 0).toString(16).toUpperCase()
+    const addedCount = Number(result.addedGroups || 0) + Number(result.addedRegisters || 0)
+    const updatedCount = Number(result.updatedGroups || 0) + Number(result.updatedRegisters || 0)
+    const changedText = [
+      addedCount ? `新增 ${addedCount}` : '',
+      updatedCount ? `更新 ${updatedCount}` : ''
+    ].filter(Boolean).join(',')
+
+    if (this.pageToast) {
       this.pageToast.show(`同步完成 codeInfo 0x${codeInfoAddressText} / 0x${codeInfoByteLengthText},${result.structCount} 项${changedText ? `,${changedText}` : ''}`)
     }
   },
@@ -375,16 +385,15 @@ Page({
     const commandKey = event && event.currentTarget && event.currentTarget.dataset
       ? event.currentTarget.dataset.command
       : ''
-    const command = (this.data.storageAccessSpecialCommands || []).find((item) => item.key === commandKey)
 
     try {
-      const result = await executeStorageAccessSpecialCommand(command, this.data)
+      const result = await executeStorageAccessSpecialCommand(commandKey || 'reset', this.data)
       if (!result.ok) {
         if (this.pageToast) this.pageToast.show(result.errorText || '指令执行失败', 'error')
         return
       }
 
-      if (this.pageToast) this.pageToast.show(`${command.label || '特殊指令'}已执行`)
+      if (this.pageToast) this.pageToast.show(`${result.command && result.command.label ? result.command.label : '特殊指令'}已执行`)
     } catch (error) {
       if (this.pageToast) this.pageToast.show(error.message || '指令执行失败', 'error')
     }

+ 1 - 15
pages/communication/communication.wxml

@@ -98,6 +98,7 @@
         </view>
         <view class="panel-title">存储访问协议</view>
         <view class="panel-actions communication-actions">
+          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-command="reset" bindtap="sendStorageAccessSpecialCommand">复位</view>
           <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="syncStorageAccessCodeInfo">同步</view>
           <view class="panel-action-button" bindtap="switchStorageAccessCommandMode">{{storageAccessCommandLabel}}</view>
           <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="sendStorageAccessProtocolFrame">执行</view>
@@ -139,21 +140,6 @@
           <view class="generated-value">{{storageAccessPreviewHexText || '--'}}</view>
           <view wx:if="{{storageAccessPreviewText}}" class="generated-meta">{{storageAccessPreviewText}}</view>
         </view>
-        <view class="storage-special-section">
-          <view class="storage-special-section-title">特殊指令</view>
-        </view>
-        <view class="storage-special-actions">
-          <view
-            wx:for="{{storageAccessSpecialCommands}}"
-            wx:key="key"
-            wx:if="{{!item.hidden}}"
-            class="panel-action-button storage-special-button {{connectedDevice ? '' : 'is-disabled'}}"
-            data-command="{{item.key}}"
-            bindtap="sendStorageAccessSpecialCommand"
-          >
-            {{item.label}}
-          </view>
-        </view>
       </view>
     </view>
 

+ 0 - 29
pages/communication/communication.wxss

@@ -224,35 +224,6 @@
   word-break: break-all;
 }
 
-.storage-special-section {
-  margin-top: 16rpx;
-  padding-top: 4rpx;
-}
-
-.storage-special-section-title {
-  color: #111827;
-  font-size: 24rpx;
-  line-height: 1.35;
-  font-weight: 900;
-}
-
-.storage-special-actions {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  flex-wrap: wrap;
-  gap: 8rpx;
-  margin-top: 12rpx;
-}
-
-.storage-special-button {
-  width: 82rpx;
-}
-
-.theme-dark .storage-special-section-title {
-  color: #e5e7eb;
-}
-
 .protocol-multiple-dialog {
   max-height: 86vh;
 }

+ 22 - 19
pages/params/params.js

@@ -89,7 +89,9 @@ Page({
       const activeParamView = protocolChanged ? 'parameterGroups' : nextState.activeParamView
       const parameterGroups = getParameterGroupsFromState(parameterGroupService.getState())
 
-      const activeParameterGroupId = protocolChanged ? '' : this.data.activeParameterGroupId
+      const activeParameterGroupId = activeParamView === 'parameterGroup' && !protocolChanged
+        ? this.data.activeParameterGroupId
+        : ''
       const activeParameterGroup = getActiveParameterGroup(parameterGroups, activeParameterGroupId)
       const safeActiveView = activeParamView === 'parameterGroup' && !activeParameterGroup
         ? 'parameterGroups'
@@ -117,13 +119,15 @@ Page({
     }
 
     const pageState = getVisiblePageState(this.data)
+    const activeParameterGroupId = pageState.activeParamView === 'parameterGroup'
+      ? this.data.activeParameterGroupId
+      : ''
+    const activeParameterGroup = getActiveParameterGroup(getParameterGroupsFromState(pageState), activeParameterGroupId)
     this.setData({
       ...pageState,
-      activeParameterGroup: getActiveParameterGroup(getParameterGroupsFromState(pageState), this.data.activeParameterGroupId),
-      activeParameterRegisterRows: buildActiveParameterRegisterRows(
-        getActiveParameterGroup(getParameterGroupsFromState(pageState), this.data.activeParameterGroupId),
-        this.parameterRegisterDrag
-      )
+      activeParameterGroup,
+      activeParameterGroupId,
+      activeParameterRegisterRows: buildActiveParameterRegisterRows(activeParameterGroup, this.parameterRegisterDrag)
     })
     this.pageToast.showFromState(pageState)
     this.scheduleVisibleParameterAutoReads()
@@ -187,8 +191,19 @@ Page({
     const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
     if (!group) return
 
+    if (this.parameterGroupLongPressGuard === groupId) {
+      this.parameterGroupLongPressGuard = ''
+      return
+    }
+
     if (this.pageToast) this.pageToast.clear()
     this.closeParameterDraft()
+
+    if (this.data.parameterCardControlEnabled === false) {
+      parameterGroupService.setGroupExpanded(groupId, !group.expanded)
+      return
+    }
+
     this.setData({
       activeParameterGroup: group,
       activeParameterGroupId: groupId,
@@ -242,7 +257,7 @@ Page({
       activeParameterRegisterRows: [],
       activeParamView: 'parameterGroups'
     })
-    if (this.pageToast) this.pageToast.show('已清除结构体组')
+    if (this.pageToast) this.pageToast.show('已清除结构体组与变量')
   },
 
   async saveParameterGroupsJson() {
@@ -254,18 +269,6 @@ Page({
     }
   },
 
-  toggleParameterGroup(event) {
-    const groupId = event.currentTarget.dataset.groupId
-    if (this.parameterGroupLongPressGuard === groupId) {
-      this.parameterGroupLongPressGuard = ''
-      return
-    }
-    const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
-    if (!group) return
-
-    parameterGroupService.setGroupExpanded(groupId, !group.expanded)
-  },
-
   onParameterRegisterValueInput(event) {
     parameterGroupService.updateRegisterValue(
       event.currentTarget.dataset.groupId,

+ 48 - 2
pages/params/params.wxml

@@ -5,13 +5,14 @@
 <view class="subpage-fixed-header subpage-fixed-header--generic {{themeClass}}">
   <view class="subpage-page-header">
     <view wx:if="{{activeParamView == 'parameterGroups'}}" class="panel-actions subpage-actions generic-protocol-actions">
+      <view wx:if="{{isStorageAccessProtocol}}" class="panel-action-button" bindtap="clearStorageAccessGroups">清除</view>
       <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="readAllParameterGroups">读取</view>
       <view class="panel-action-button" bindtap="saveParameterGroupsJson">保存</view>
       <view class="panel-action-button" bindtap="importParameterGroupsJson">加载</view>
       <view wx:if="{{isStorageAccessProtocol}}" class="panel-action-button" bindtap="completeParameterStructs">结构</view>
       <view wx:if="{{isModbusProtocol}}" class="panel-action-button panel-action-button--icon" bindtap="openParameterDraft">+</view>
     </view>
-    <view wx:elif="{{activeParamView == 'parameterGroup' && isModbusProtocol}}" class="panel-actions subpage-actions">
+    <view wx:elif="{{activeParamView == 'parameterGroup'}}" class="panel-actions subpage-actions">
       <view
         class="panel-action-button {{connectedDevice && !activeParameterGroup.addressOverflow ? '' : 'is-disabled'}}"
         data-group-id="{{activeParameterGroup.id}}"
@@ -80,7 +81,52 @@
               >
                 写入
               </view>
-              <view class="entry-chevron"></view>
+              <view wx:if="{{parameterCardControlEnabled}}" class="entry-chevron"></view>
+            </view>
+          </view>
+          <view wx:if="{{!parameterCardControlEnabled && group.expanded}}" class="generic-group-inline-registers">
+            <view
+              wx:for="{{group.registers}}"
+              wx:for-item="register"
+              wx:for-index="registerIndex"
+              wx:key="id"
+              class="generic-register-row generic-register-row--inline"
+            >
+              <view class="generic-register-layout-spacer"></view>
+              <view class="generic-register-main">
+                <view
+                  class="generic-register-name"
+                  data-group-id="{{group.id}}"
+                  data-index="{{registerIndex}}"
+                  bindtap="openParameterRegisterInfo"
+                  catchlongpress="openParameterRegisterEdit"
+                >
+                  {{register.name}}
+                </view>
+                <view class="generic-register-meta">
+                  <text>{{register.metaText || (register.addressText + ' ' + register.rawValueText)}}</text>
+                </view>
+              </view>
+              <view class="generic-register-input-wrap {{register.showUnit && register.unit ? 'generic-register-input-wrap--unit' : ''}}">
+                <block wx:if="{{group.writable}}">
+                  <block wx:if="{{register.conversionFormula}}">
+                    <view class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
+                  </block>
+                  <block wx:else>
+                    <input
+                      class="value-input generic-register-value {{register.isDirty ? 'value-input--dirty' : ''}}"
+                      data-group-id="{{group.id}}"
+                      data-index="{{registerIndex}}"
+                      value="{{register.inputValue}}"
+                      bindinput="onParameterRegisterValueInput"
+                      bindblur="onParameterRegisterValueBlur"
+                    />
+                    <view wx:if="{{register.showUnit && register.unit}}" class="generic-register-unit">{{register.unit}}</view>
+                  </block>
+                </block>
+                <view wx:else class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
+                <view wx:if="{{register.displayMetaText}}" class="generic-register-display-meta">{{register.displayMetaText}}</view>
+              </view>
             </view>
           </view>
 

+ 4 - 0
pages/settings/settings.js

@@ -111,6 +111,10 @@ Page({
     settingsService.setNightModeFollowSystem(!!event.detail.value)
   },
 
+  onParameterCardControlChange(event) {
+    settingsService.setParameterCardControlEnabled(!!event.detail.value)
+  },
+
   onSettingsDraftInput(event) {
     const field = event && event.currentTarget && event.currentTarget.dataset
       ? event.currentTarget.dataset.field

+ 11 - 0
pages/settings/settings.wxml

@@ -759,6 +759,17 @@
           bindchange="onNightModeFollowSystemChange"
         />
       </view>
+      <view class="settings-row">
+        <view class="settings-row-main">
+          <view class="param-name">卡片控制</view>
+          <view class="param-meta">{{parameterCardControlEnabled ? '点击进入组界面' : '点击展开组内容'}}</view>
+        </view>
+        <switch
+          checked="{{parameterCardControlEnabled}}"
+          color="#0f766e"
+          bindchange="onParameterCardControlChange"
+        />
+      </view>
     </view>
 
     <view class="panel settings-section-panel">

+ 10 - 0
store/settings-store.js

@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS = {
   nightModeEnabled: false,
   nightModeFollowSystem: true,
   parameterAutoPollEnabled: false,
+  parameterCardControlEnabled: true,
   parameterMaxPacketLength: 64,
   parameterPollInterval: 100,
   protocolMode: DEFAULT_PROTOCOL_MODE
@@ -79,6 +80,7 @@ function normalizeSettings(settings = {}) {
     nightModeEnabled: !!settings.nightModeEnabled,
     nightModeFollowSystem: settings.nightModeFollowSystem !== false,
     parameterAutoPollEnabled,
+    parameterCardControlEnabled: settings.parameterCardControlEnabled !== false,
     parameterMaxPacketLength,
     parameterPollInterval,
     protocolMode
@@ -192,6 +194,13 @@ function setParameterAutoPollEnabled(value) {
   })
 }
 
+function setParameterCardControlEnabled(value) {
+  init()
+  setState({
+    parameterCardControlEnabled: !!value
+  })
+}
+
 function setParameterMaxPacketLength(value) {
   init()
   setState({
@@ -222,6 +231,7 @@ module.exports = {
   setNightModeEnabled,
   setNightModeFollowSystem,
   setParameterAutoPollEnabled,
+  setParameterCardControlEnabled,
   setParameterMaxPacketLength,
   setParameterPollInterval,
   setProtocolMode,

+ 17 - 14
协议架构说明.md

@@ -25,7 +25,7 @@ BLE 透传链路
 功能服务
   features/communication/
   features/modbus-rtu/service.js
-  features/storage-access/service.js
+  features/storage-access/
   features/parameter-groups/
   features/bootloader/
   features/tools/
@@ -42,7 +42,7 @@ BLE 透传链路
 维护原则:
 
 1. 一个协议只保留一个协议入口文件,不再拆 `frame.js`、`request.js`、`response.js` 这类细粒度文件。
-2. 功能服务按页面或业务域聚合,不为每个按钮、每个弹窗再单独建模块。
+2. 功能服务按页面或业务域聚合,只有 UI 状态、协议传输、数据同步边界清晰时才拆分模块。
 3. 领域模型可以按“值类型、编解码、结构体解析、参数组规范化”拆分,因为这些逻辑可测试且复用度高。
 4. 页面不直接拼协议帧,不直接读写本地存储格式。
 5. 设置页的协议模式只保留当前字段 `protocolMode`,不维护历史字段迁移逻辑。
@@ -105,7 +105,7 @@ BLE 透传链路
 - 特殊指令使用 `CMD=0x40 | special_code`,当前仅定义 `special_code=0x01` 复位。
 - CodeInfo 同步先发送 `00 + CRC` 读取 `area=0x00 CODEINFO`,获取 `TLV_ADDR32 + TLV_LEN16 + ADDR_WIDTH8 + MAX_PACKET16`,再按 `ADDR_WIDTH` 用 CODE 或 ADDR32 读取完整信息块。
 - 协议控制字段始终固定大端;结构体字段和单独变量等目标内存值当前默认按大端编解码。
-- 不直接调用 BLE 发送,不负责弹窗;发包编排由 `features/storage-access/service.js` 处理
+- 不直接调用 BLE 发送,不负责弹窗;普通内存协议 IO 由 `features/storage-access/protocol-io.js` 编排
 
 完整帧格式和从机实现参考见 `存储访问协议.md`。
 
@@ -162,7 +162,7 @@ BLE 透传链路
 - 解析 CodeInfo 纯 TLV 信息块,未知 TLV 类型跳过。
 - 使用 CODEINFO 描述符返回的 `TLV_LEN` 决定 CodeInfo 总长度,并使用 `ADDR_WIDTH/MAX_PACKET` 作为同步上下文;内存入口地址宽度和区域由 TLV `TYPE` 自描述。
 - 解析 UTF-8/ASCII 电机型号、芯片型号和转换相关可选 TLV 参数。
-- 固定 TLV `0x01~0x08` 映射真实存储区域、地址宽度和结构体/变量类型,并按结构体、单独变量成对排列;VALUE 固定为 `addr(2/4) + byte_len16 + name[32]`。
+- 固定 TLV `0x01~0x08` 映射真实存储区域、地址宽度和结构体/变量类型,并按结构体、单独变量成对排列;VALUE 为 `addr(2/4) + byte_len16 + name_len8 + name`。
 - `0x20~0x3F` 为自定义 TLV,板卡参数从 `0x40` 开始递增。
 - 生成参数组初始结构,结构体未导入定义时按字节占位,单独变量按 TLV `byte_len` 显示为未配置原始字节,后续由 UI 或 enum 导入确定同长度的解释类型。
 
@@ -185,15 +185,17 @@ BLE 透传链路
 
 ### 6.2 存储访问服务
 
-入口:`features/storage-access/service.js`
+目录:`features/storage-access/`
 
-职责:
+当前模块:
+
+| 文件 | 职责 |
+|---|---|
+| `protocol-io.js` | 普通内存读写、特殊指令发送、协议分包和设备协议包长 |
+| `manual-command.js` | 通讯页手动读写表单状态、校验、预览和执行;不暴露 CODEINFO 描述符读取 |
+| `code-info-sync.js` | 同步按钮触发的 CodeInfo 描述符读取、TLV 信息块读取、解析和参数组导入模型生成 |
 
-- 包装 `protocols/storage-access/index.js` 的读写、特殊指令和 CodeInfo 同步能力。
-- 根据设置的最大包长决定分片长度。
-- 读取 `area=0x00 CODEINFO` 描述符,再按 `ADDR_WIDTH` 用 `area=0x04 CODE` 或 `area=0x07 ADDR32` 完整读取 CodeInfo。
-- 将解析结果转换为参数组。
-- 提供复位特殊指令。
+CodeInfo 读取只从同步流程进入:先读取 `area=0x00 CODEINFO` 描述符,再按 `ADDR_WIDTH` 用 `area=0x04 CODE` 或 `area=0x07 ADDR32` 完整读取信息块。通讯页手动读写只面向 `ADDR32/DATA/IDATA/XDATA/CODE` 普通内存区域。
 
 ### 6.3 标准 Modbus 服务
 
@@ -270,8 +272,9 @@ BLE 透传链路
 用户点击同步
   -> pages/communication/communication.js
   -> features/parameter-groups/service.syncFromStorageAccessCodeInfo
-  -> features/storage-access/service.syncCodeInfo
-  -> features/storage-access/service.readCodeInfoBlock
+  -> features/storage-access/code-info-sync.syncCodeInfo
+  -> features/storage-access/code-info-sync.readCodeInfoBlock
+  -> features/storage-access/protocol-io.readMemory
   -> protocols/storage-access/index.js 构建普通读帧
   -> 发送 00 + CRC 读取 area=0x00 CODEINFO 描述符,获得 TLV_ADDR32 + TLV_LEN16 + ADDR_WIDTH8 + MAX_PACKET16
   -> 按描述符和设置页最大包长分片读取 CodeInfo TLV 信息块
@@ -289,7 +292,7 @@ BLE 透传链路
   -> features/parameter-groups/service.readGroup
   -> features/parameter-groups/io.readGroup
   -> 标准 Modbus: protocols/modbus-rtu/index.js
-  -> 存储访问: features/storage-access/service.readMemory
+  -> 存储访问: features/storage-access/protocol-io.readMemory
   -> 读缓存映射回参数组显示态
 ```
 

+ 6 - 4
存储访问协议.md

@@ -261,9 +261,10 @@ TYPE LEN VALUE...
 |---|---:|---|
 | `byte_addr` | 2 | 结构体实例或单独变量所在区域的字节地址 |
 | `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
-| `name` | 32 | 固定 32 字节名称字段,UTF-8 或 ASCII,建议 0 结尾或 0 填充;结构体定义名、变量名或 enum 类型名 |
+| `name_len` | 1 | 名称字节长度,`0..255`;实际不能超过本 TLV `LEN` 剩余字节 |
+| `name` | `name_len` | 动态名称字段,UTF-8 或 ASCII;结构体定义名、变量名或 enum 类型名 |
 
-因此 16 位地址固定内存入口 TLV 的 `LEN` 固定为 `0x24`。
+因此 16 位地址固定内存入口 TLV 的 `LEN = 0x05 + name_len`。
 
 32 位地址入口 `VALUE`:
 
@@ -271,9 +272,10 @@ TYPE LEN VALUE...
 |---|---:|---|
 | `byte_addr` | 4 | 结构体实例或单独变量所在统一地址空间内的字节地址 |
 | `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
-| `name` | 32 | 固定 32 字节名称字段,UTF-8 或 ASCII,建议 0 结尾或 0 填充;结构体定义名、变量名或 enum 类型名 |
+| `name_len` | 1 | 名称字节长度,`0..255`;实际不能超过本 TLV `LEN` 剩余字节 |
+| `name` | `name_len` | 动态名称字段,UTF-8 或 ASCII;结构体定义名、变量名或 enum 类型名 |
 
-因此 32 位地址固定内存入口 TLV 的 `LEN` 固定为 `0x26`。
+因此 32 位地址固定内存入口 TLV 的 `LEN = 0x07 + name_len`。
 
 ### 9.2 自定义 TLV
 

+ 10 - 10
完整协议说明.md

@@ -166,18 +166,18 @@ TYPE LEN VALUE...
 
 | TYPE | 名称 | VALUE |
 |---:|---|---|
-| `0x01` | DATA 结构体实例 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x02` | DATA 单独变量 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x03` | IDATA 结构体实例 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x04` | IDATA 单独变量 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x05` | XDATA 结构体实例 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x06` | XDATA 单独变量 | `byte_addr16 + byte_len16 + name[32]`,`LEN=0x24` |
-| `0x07` | ADDR32 结构体实例 | `byte_addr32 + byte_len16 + name[32]`,`LEN=0x26` |
-| `0x08` | ADDR32 单独变量 | `byte_addr32 + byte_len16 + name[32]`,`LEN=0x26` |
+| `0x01` | DATA 结构体实例 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x02` | DATA 单独变量 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x03` | IDATA 结构体实例 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x04` | IDATA 单独变量 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x05` | XDATA 结构体实例 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x06` | XDATA 单独变量 | `byte_addr16 + byte_len16 + name_len8 + name`,`LEN=0x05+name_len` |
+| `0x07` | ADDR32 结构体实例 | `byte_addr32 + byte_len16 + name_len8 + name`,`LEN=0x07+name_len` |
+| `0x08` | ADDR32 单独变量 | `byte_addr32 + byte_len16 + name_len8 + name`,`LEN=0x07+name_len` |
 | `0x20..0x3F` | 自定义 TLV | 上位机保留原始项,当前跳过业务解析 |
 | `0x40..` | 板卡参数 | `0x40` 起按 `cave_freq/ref_volt/...` 递增 |
 
-TLV `LEN` 为 1 字节,表示单项 `VALUE` 字节数;整段 CodeInfo 总长度仍由 CODEINFO 描述符 `TLV_LEN16` 决定。内存入口 TLV 的地址宽度和区域由 `TYPE` 决定,名称字段固定 32 字节。
+TLV `LEN` 为 1 字节,表示单项 `VALUE` 字节数;整段 CodeInfo 总长度仍由 CODEINFO 描述符 `TLV_LEN16` 决定。内存入口 TLV 的地址宽度和区域由 `TYPE` 决定,名称字段由 `name_len8` 声明动态字节长度,单项 TLV 总长仍不能超过 255 字节。
 
 同步到参数页后的规则:
 
@@ -244,6 +244,6 @@ ACK 为 `0x06`,NAK 为 `0x15`。固件文件加载、芯片型号识别、升
 
 1. 标准 Modbus、存储访问、Bootloader 分别只保留一个协议入口:`protocols/modbus-rtu/index.js`、`protocols/storage-access/index.js`、`protocols/bootloader/index.js`。
 2. 页面只负责 UI 展示和事件转发,不直接拼帧、不直接解析二进制响应。
-3. 通讯页业务聚合在 `features/communication/`,参数页业务聚合在 `features/parameter-groups/`,存储访问业务聚合在 `features/storage-access/service.js`
+3. 通讯页业务聚合在 `features/communication/`,参数页业务聚合在 `features/parameter-groups/`,存储访问按 `features/storage-access/manual-command.js`、`features/storage-access/protocol-io.js`、`features/storage-access/code-info-sync.js` 分离 UI 状态、协议传输和数据同步
 4. 可复用且有明确领域边界的逻辑保留在 `domain/parameter-groups/` 和 `domain/storage-access/`。
 5. 不再为单个按钮、单个字段、单个导入步骤新增小模块;新增能力优先并入现有协议模块、功能服务或领域模型。