Jelajahi Sumber

存储访问协议更新

avery 6 hari lalu
induk
melakukan
61b23db290
73 mengubah file dengan 6336 tambahan dan 3612 penghapusan
  1. 14 12
      app.wxss
  2. 12 1
      domain/parameter-groups/constants.js
  3. 0 10
      domain/parameter-groups/index.js
  4. 202 70
      domain/parameter-groups/model.js
  5. 2 1
      domain/parameter-groups/register-io.js
  6. 290 2
      domain/parameter-groups/struct-c-syntax.js
  7. 64 6
      domain/parameter-groups/struct-layout.js
  8. 48 6
      domain/parameter-groups/struct-parser.js
  9. 64 13
      domain/parameter-groups/value-codec.js
  10. 5 2
      domain/parameter-groups/value-formula.js
  11. 25 12
      domain/parameter-groups/value-number.js
  12. 44 0
      domain/protocol-mode.js
  13. 332 80
      domain/storage-access/code-info-parser.js
  14. 0 3
      domain/storage-access/index.js
  15. 0 5
      features/bootloader/index.js
  16. 1 1
      features/bootloader/service.js
  17. 15 4
      features/communication/index.js
  18. 612 0
      features/communication/manual-rtu.js
  19. 23 93
      features/communication/service.js
  20. 29 191
      features/communication/view-model.js
  21. 65 4
      features/home/service.js
  22. 0 69
      features/home/view-model.js
  23. 0 165
      features/manual-rtu/frame-builder.js
  24. 0 167
      features/manual-rtu/multiple-registers.js
  25. 0 337
      features/manual-rtu/service.js
  26. 221 0
      features/modbus-rtu/service.js
  27. 12 3
      features/parameter-groups/dialog-handlers.js
  28. 316 12
      features/parameter-groups/imports.js
  29. 3 15
      features/parameter-groups/index.js
  30. 648 0
      features/parameter-groups/io.js
  31. 0 197
      features/parameter-groups/modbus-io.js
  32. 23 92
      features/parameter-groups/persistence.js
  33. 13 6
      features/parameter-groups/poller.js
  34. 78 67
      features/parameter-groups/service.js
  35. 0 136
      features/parameter-groups/state-mappers.js
  36. 0 288
      features/parameter-groups/storage-access-io.js
  37. 23 15
      features/parameter-groups/store.js
  38. 0 151
      features/parameter-groups/struct-completion.js
  39. 16 3
      features/parameter-groups/view-model.js
  40. 131 0
      features/settings/protocol-implementation.js
  41. 9 4
      features/settings/view-model.js
  42. 0 9
      features/storage-access/index.js
  43. 0 59
      features/storage-access/memory-service.js
  44. 658 13
      features/storage-access/service.js
  45. 0 34
      features/tools/handlers/common.js
  46. 7 25
      features/tools/handlers/crc.js
  47. 97 6
      features/tools/index.js
  48. 0 33
      features/tools/navigation.js
  49. 0 42
      features/tools/page.js
  50. 122 45
      pages/communication/communication.js
  51. 58 22
      pages/communication/communication.wxml
  52. 30 6
      pages/communication/communication.wxss
  53. 57 33
      pages/params/params.js
  54. 22 23
      pages/params/params.wxml
  55. 60 1
      pages/settings/settings.js
  56. 96 12
      pages/settings/settings.wxml
  57. 235 0
      pages/settings/settings.wxss
  58. 3 222
      protocols/modbus-rtu/index.js
  59. 397 220
      protocols/storage-access/index.js
  60. 210 35
      repositories/file.js
  61. 28 79
      store/settings-store.js
  62. 1 1
      store/theme-store.js
  63. 14 9
      tools/crc-hash/crc-tool.js
  64. 0 4
      tools/crc-hash/index.js
  65. 3 0
      transport/ble-core.js
  66. 51 1
      utils/base-utils.js
  67. 0 29
      utils/number-format.js
  68. 0 14
      utils/platform-utils.js
  69. 1 1
      utils/register-value-utils.js
  70. 136 0
      utils/validation.js
  71. 178 233
      协议架构说明.md
  72. 277 158
      存储访问协议.md
  73. 255 0
      完整协议说明.md

+ 14 - 12
app.wxss

@@ -271,7 +271,7 @@ page {
 .theme-dark .generic-register-row,
 .theme-dark .generic-config-row,
 .theme-dark .generic-struct-section,
-.theme-dark .storage-code-info-card {
+.theme-dark .storage-code-info-inline {
   border-color: #263241;
 }
 
@@ -1304,14 +1304,15 @@ page {
   transform: rotate(-45deg);
 }
 
-.storage-code-info-card {
+.storage-code-info-inline {
   display: flex;
   flex-direction: column;
   align-items: stretch;
   gap: 8rpx;
-  margin-bottom: 12rpx;
   padding: 16rpx 18rpx;
   border: 1rpx solid #edf2f7;
+  border-radius: 14rpx;
+  background: rgba(15, 143, 135, 0.04);
   box-sizing: border-box;
 }
 
@@ -1378,8 +1379,8 @@ page {
   align-items: center;
   justify-content: space-between;
   gap: 8rpx;
-  min-height: 92rpx;
-  padding: 8rpx 16rpx;
+  min-height: 80rpx;
+  padding: 6rpx 16rpx;
   border-top: 1rpx solid #edf2f7;
   border-radius: 16rpx;
   box-sizing: border-box;
@@ -1412,7 +1413,7 @@ page {
   justify-content: center;
   gap: 4rpx;
   width: 30rpx;
-  min-height: 58rpx;
+  min-height: 50rpx;
   margin-left: -4rpx;
   border-radius: 12rpx;
   transition: background-color 0.18s ease, transform 0.18s ease;
@@ -1421,7 +1422,7 @@ page {
 .generic-register-layout-spacer {
   flex: none;
   width: 12rpx;
-  min-height: 58rpx;
+  min-height: 50rpx;
 }
 
 .generic-register-drag-handle.is-drag-armed {
@@ -1459,12 +1460,12 @@ page {
   flex: 1;
   display: flex;
   flex-direction: column;
-  gap: 3rpx;
+  gap: 2rpx;
 }
 
 .generic-register-name {
   color: #111827;
-  font-size: 25rpx;
+  font-size: 24rpx;
   line-height: 1.25;
   font-weight: 800;
   word-break: break-all;
@@ -1475,7 +1476,7 @@ page {
   align-items: center;
   flex-wrap: wrap;
   gap: 10rpx;
-  margin-top: 2rpx;
+  margin-top: 0;
   color: #64748b;
   font-family: Menlo, Monaco, Consolas, monospace;
   font-size: 20rpx;
@@ -1493,7 +1494,7 @@ page {
 }
 
 .generic-register-display-meta {
-  margin-top: 6rpx;
+  margin-top: 3rpx;
   color: #64748b;
   font-family: Menlo, Monaco, Consolas, monospace;
   font-size: 19rpx;
@@ -1528,7 +1529,7 @@ page {
   width: 210rpx;
   max-width: 40%;
   font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 25rpx;
+  font-size: 24rpx;
 }
 
 .generic-group-detail-panel {
@@ -2352,6 +2353,7 @@ page {
   .generic-readonly-value {
     width: 180rpx;
     max-width: 38%;
+    font-size: 23rpx;
   }
 
   .crc-config-input {

+ 12 - 1
domain/parameter-groups/constants.js

@@ -1,4 +1,5 @@
 const MAX_MODBUS_ADDRESS = 0xFFFF
+const MAX_STORAGE_ADDRESS = 0xFFFFFFFF
 const MAX_PARAMETER_GROUP_ITEMS = 256
 const DEFAULT_TEXT_BYTE_LENGTH = 32
 const MAX_TEXT_BYTE_LENGTH = 32
@@ -109,15 +110,20 @@ const DEFAULT_REGISTER_TYPE = REGISTER_TYPE_OPTIONS[0].key
 const DEFAULT_DATA_TYPE = 'uint16_t'
 const GROUP_LAYOUT_REGISTER = 'register'
 const GROUP_LAYOUT_STRUCT = 'struct'
-const BYTE_ADDRESS_MEMORY_AREAS = ['BIT', 'CODE', 'DATA', 'IDATA', 'XDATA']
+const BYTE_ADDRESS_MEMORY_AREAS = ['ADDR32', 'BIT', 'CODE', 'DATA', 'IDATA', 'XDATA']
 
 const SOURCE_REGISTER_FIELDS = [
   'conversionFormula',
+  'enumName',
+  'enumOptions',
   'sourceAddress',
+  'sourceAddressByteLength',
   'sourceAddressText',
+  'sourceAddressWidth',
   'sourceByteLength',
   'sourceBitOffset',
   'sourceBitWidth',
+  'sourceEntryKind',
   'sourceMemoryArea',
   'sourceMemoryClass',
   'sourceSymbolName',
@@ -135,9 +141,13 @@ const STRUCT_REGISTER_FIELDS = [
 
 const SOURCE_GROUP_FIELDS = [
   'addressUnit',
+  'codeInfoContext',
   'sourceAddress',
+  'sourceAddressByteLength',
   'sourceAddressText',
+  'sourceAddressWidth',
   'sourceByteLength',
+  'sourceEntryKind',
   'sourceMemoryArea',
   'sourceMemoryClass',
   'sourceSegment',
@@ -155,6 +165,7 @@ module.exports = {
   GROUP_LAYOUT_REGISTER,
   GROUP_LAYOUT_STRUCT,
   MAX_MODBUS_ADDRESS,
+  MAX_STORAGE_ADDRESS,
   MAX_PARAMETER_GROUP_ITEMS,
   MAX_TEXT_BYTE_LENGTH,
   REGISTER_TYPE_OPTIONS,

+ 0 - 10
domain/parameter-groups/index.js

@@ -1,10 +0,0 @@
-module.exports = {
-  constants: require('./constants.js'),
-  model: require('./model.js'),
-  registerIo: require('./register-io.js'),
-  structParser: require('./struct-parser.js'),
-  valueCodec: require('./value-codec.js'),
-  valueNumber: require('./value-number.js'),
-  valueText: require('./value-text.js'),
-  valueTypes: require('./value-types.js')
-}

+ 202 - 70
domain/parameter-groups/model.js

@@ -1,8 +1,10 @@
 const {
   clampInteger,
   createId,
+  formatFixedValue,
   normalizeTextValue,
-  padHex
+  padHex,
+  pickFields
 } = require('../../utils/base-utils.js')
 const {
   BYTE_ADDRESS_MEMORY_AREAS,
@@ -14,14 +16,12 @@ const {
   GROUP_LAYOUT_STRUCT,
   MAX_MODBUS_ADDRESS,
   MAX_PARAMETER_GROUP_ITEMS,
+  MAX_STORAGE_ADDRESS,
   REGISTER_TYPE_OPTIONS,
   SOURCE_GROUP_FIELDS,
   SOURCE_REGISTER_FIELDS,
   STRUCT_REGISTER_FIELDS
 } = require('./constants.js')
-const {
-  formatFixedValue
-} = require('../../utils/number-format.js')
 const {
   evaluateValueFormula
 } = require('./value-formula.js')
@@ -84,16 +84,37 @@ function normalizeAddress(value, fallback = 0) {
   return Number.isFinite(numberValue) ? clampInteger(numberValue, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
 }
 
-function parseConfigAddress(value) {
+function normalizeStorageAddress(value, fallback = 0) {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? clampInteger(value, 0, MAX_STORAGE_ADDRESS, fallback) : fallback
+  }
+
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return fallback
+
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+  if (/^[0-9A-F]+$/i.test(hexText)) {
+    const parsedHex = parseInt(hexText, 16)
+    return Number.isFinite(parsedHex) ? clampInteger(parsedHex, 0, MAX_STORAGE_ADDRESS, fallback) : fallback
+  }
+
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? clampInteger(numberValue, 0, MAX_STORAGE_ADDRESS, fallback) : fallback
+}
+
+function parseConfigAddress(value, options = {}) {
+  const maxAddress = Number.isFinite(Number(options.maxAddress)) ? Number(options.maxAddress) : MAX_MODBUS_ADDRESS
+  const maxHexLength = maxAddress > MAX_MODBUS_ADDRESS ? 8 : 4
+  const label = options.label || '寄存器起始地址'
   if (typeof value === 'number') {
-    return clampInteger(value, 0, MAX_MODBUS_ADDRESS, 0)
+    return clampInteger(value, 0, maxAddress, 0)
   }
 
   const text = String(value === undefined || value === null ? '' : value).trim()
   const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
 
-  if (!/^[0-9A-F]{1,4}$/i.test(hexText)) {
-    throw new Error('寄存器起始地址无效')
+  if (!new RegExp(`^[0-9A-F]{1,${maxHexLength}}$`, 'i').test(hexText)) {
+    throw new Error(`${label}无效`)
   }
 
   return parseInt(hexText, 16)
@@ -123,7 +144,14 @@ function isStructLayout(layout) {
 }
 
 function padWordHex(value) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+  const numberValue = Number(value || 0)
+  const length = numberValue > MAX_MODBUS_ADDRESS ? 8 : 4
+
+  return numberValue.toString(16).toUpperCase().padStart(length, '0')
+}
+
+function padStorageHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(8, '0')
 }
 
 function formatAddressRange(startAddress, wordCount) {
@@ -138,6 +166,18 @@ function formatAddressRange(startAddress, wordCount) {
   return `0x${padWordHex(address)}-0x${padWordHex(safeEndAddress)}${overflowText}`
 }
 
+function formatStorageAddressRange(startAddress, byteCount) {
+  const address = normalizeStorageAddress(startAddress, 0)
+  const count = Math.max(1, Number(byteCount) || 1)
+  const endAddress = address + count - 1
+  const safeEndAddress = Math.min(endAddress, MAX_STORAGE_ADDRESS)
+  const overflowText = endAddress > MAX_STORAGE_ADDRESS ? '+' : ''
+
+  if (count <= 1) return `0x${padStorageHex(address)}`
+
+  return `0x${padStorageHex(address)}-0x${padStorageHex(safeEndAddress)}${overflowText}`
+}
+
 function isByteAddressedGroup(group = {}) {
   const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
 
@@ -170,6 +210,13 @@ function isAddressRangeOverflow(startAddress, wordCount) {
   return address + count - 1 > MAX_MODBUS_ADDRESS
 }
 
+function isStorageAddressRangeOverflow(startAddress, byteCount) {
+  const address = normalizeStorageAddress(startAddress, 0)
+  const count = Math.max(1, Number(byteCount) || 1)
+
+  return address + count - 1 > MAX_STORAGE_ADDRESS
+}
+
 function getMaxQuantity() {
   return MAX_PARAMETER_GROUP_ITEMS
 }
@@ -187,16 +234,6 @@ function normalizeRegisterDataType(register, registerType) {
   return getDataType(register.dataType || register.type || DEFAULT_DATA_TYPE).key
 }
 
-function pickFields(source, fields) {
-  return fields.reduce((result, field) => {
-    if (source && source[field] !== undefined && source[field] !== null && source[field] !== '') {
-      result[field] = source[field]
-    }
-
-    return result
-  }, {})
-}
-
 function createRegisterSourceMetaText(register) {
   const bitText = isBitFieldRegister(register)
     ? `bit${normalizeBitOffset(register.bitOffset)}:${normalizeBitWidth(register.bitWidth)}`
@@ -229,56 +266,92 @@ function formatCompactNumber(value, precision = 4) {
 }
 
 function formatCardMetricValue(value) {
-  if (value === '--') return '--'
+  if (value === '' || value === null || value === undefined) return ''
 
   return formatCompactNumber(value)
 }
 
+function readOptionalNumber(value) {
+  if (value === '' || value === null || value === undefined) return null
+
+  const numberValue = Number(value)
+
+  return Number.isFinite(numberValue) ? numberValue : null
+}
+
+function addMetricItem(items, value, unit = '') {
+  const text = formatCardMetricValue(value)
+  if (!text) return
+
+  items.push({
+    text: `${text}${unit}`
+  })
+}
+
+function assignOptionalNumber(target, key, value) {
+  if (value === null || value === undefined) return
+
+  target[key] = value
+}
+
+function normalizeMemoryEndian(value) {
+  const text = String(value || '').trim().toLowerCase()
+  if (text === 'little' || text === 'le' || text === '1') return 'little'
+
+  return 'big'
+}
+
 function normalizeStorageCodeInfoCard(codeInfo = null) {
   const hasCodeInfo = !!codeInfo && codeInfo.hasCodeInfo !== false
-  const refVolt = hasCodeInfo ? Number(codeInfo.refVolt) : NaN
+  const refVolt = hasCodeInfo ? readOptionalNumber(codeInfo.refVolt) : null
+  const refVoltRaw = hasCodeInfo ? readOptionalNumber(codeInfo.refVoltRaw) : null
   const card = {
-    alongDiv: hasCodeInfo ? (Number(codeInfo.alongDiv) || 0) : 0,
-    ampGain: hasCodeInfo ? (Number(codeInfo.ampGain) || 0) : '--',
-    busDiv: hasCodeInfo ? (Number(codeInfo.busDiv) || 0) : 0,
-    caveFreq: hasCodeInfo ? (Number(codeInfo.caveFreq) || 0) : '--',
-    chipModel: hasCodeInfo ? (String(codeInfo.chipModel || '').trim() || '--') : '--',
-    model: hasCodeInfo ? (String(codeInfo.model || '').trim() || '--') : '--',
+    alongDiv: hasCodeInfo ? readOptionalNumber(codeInfo.alongDiv) : null,
+    ampGain: hasCodeInfo ? readOptionalNumber(codeInfo.ampGain) : null,
+    busDiv: hasCodeInfo ? readOptionalNumber(codeInfo.busDiv) : null,
+    caveFreq: hasCodeInfo ? readOptionalNumber(codeInfo.caveFreq) : null,
+    chipModel: hasCodeInfo ? String(codeInfo.chipModel || '').trim() : '',
+    maxPacketLength: hasCodeInfo ? (Number(codeInfo.maxPacketLength) || 0) : 0,
+    memoryEndian: hasCodeInfo ? normalizeMemoryEndian(codeInfo.memoryEndian) : 'big',
+    memoryEndianRaw: hasCodeInfo ? (Number(codeInfo.memoryEndianRaw) || 0) : 0,
+    model: hasCodeInfo ? String(codeInfo.model || '').trim() : '',
     refVolt: hasCodeInfo
-      ? (Number.isFinite(refVolt) ? refVolt : ((Number(codeInfo.refVoltRaw) || 0) / 10))
-      : '--',
-    refVoltRaw: hasCodeInfo ? (Number(codeInfo.refVoltRaw) || 0) : 0,
-    rsShunt: hasCodeInfo ? (Number(codeInfo.rsShunt) || 0) : '--'
+      ? (refVolt !== null ? refVolt : (refVoltRaw !== null ? refVoltRaw / 10 : null))
+      : null,
+    refVoltRaw: refVoltRaw === null ? 0 : refVoltRaw,
+    rsShunt: hasCodeInfo ? readOptionalNumber(codeInfo.rsShunt) : null,
+    addressWidth: hasCodeInfo
+      ? (Number(codeInfo.addressWidth || codeInfo.codeInfoAddressWidth || codeInfo.storageAddressWidth) || 0)
+      : 0
   }
   const codeInfoContext = hasCodeInfo
     ? {
-      alongDiv: card.alongDiv,
-      ampGain: card.ampGain,
-      busDiv: card.busDiv,
-      caveFreq: card.caveFreq,
-      refVolt: card.refVolt,
-      rsShunt: card.rsShunt
+      addressWidth: card.addressWidth,
+      codeInfoAddressWidth: card.addressWidth,
+      maxPacketLength: card.maxPacketLength,
+      memoryEndian: card.memoryEndian,
+      storageAddressWidth: card.addressWidth
     }
     : {}
+  if (hasCodeInfo) {
+    assignOptionalNumber(codeInfoContext, 'alongDiv', card.alongDiv)
+    assignOptionalNumber(codeInfoContext, 'ampGain', card.ampGain)
+    assignOptionalNumber(codeInfoContext, 'busDiv', card.busDiv)
+    assignOptionalNumber(codeInfoContext, 'caveFreq', card.caveFreq)
+    assignOptionalNumber(codeInfoContext, 'refVolt', card.refVolt)
+    assignOptionalNumber(codeInfoContext, 'rsShunt', card.rsShunt)
+  }
+  const metricItems = []
+  addMetricItem(metricItems, card.caveFreq, 'KHz')
+  addMetricItem(metricItems, card.refVolt, 'V')
+  addMetricItem(metricItems, card.ampGain)
+  addMetricItem(metricItems, card.rsShunt, 'mΩ')
 
   return {
     ...card,
     codeInfoContext,
     hasCodeInfo,
-    metricItems: [
-      {
-        text: `${formatCardMetricValue(card.caveFreq)}KHz`
-      },
-      {
-        text: `${formatCardMetricValue(card.refVolt)}V`
-      },
-      {
-        text: formatCardMetricValue(card.ampGain)
-      },
-      {
-        text: `${formatCardMetricValue(card.rsShunt)}mΩ`
-      }
-    ]
+    metricItems
   }
 }
 
@@ -288,6 +361,15 @@ function isStorageStructGroup(group = {}) {
     && !!String(group.sourceMemoryArea || '').trim()
 }
 
+function isStorageMemoryGroup(group = {}) {
+  return isByteAddressedGroup(group)
+    && !!String(group.sourceMemoryArea || '').trim()
+}
+
+function isParameterGroupPollEnabled(group = {}) {
+  return group.pollEnabled !== false
+}
+
 function getStorageAreaText(group = {}, lowercase = false) {
   const area = String(group.sourceMemoryArea || '').trim()
   return lowercase ? area.toLowerCase() : area.toUpperCase()
@@ -301,7 +383,15 @@ function stripStorageAreaPrefix(text) {
 }
 
 function formatRegisterStartAddressText(address) {
-  return `0x${padHex(address)}`
+  return `0x${padWordHex(address)}`
+}
+
+function formatStorageStartAddressText(address) {
+  return `0x${padStorageHex(address)}`
+}
+
+function formatStartAddressText(address, maxAddress = MAX_MODBUS_ADDRESS) {
+  return `0x${padHex(address, maxAddress > MAX_MODBUS_ADDRESS ? 8 : 4)}`
 }
 
 function formatStorageHexBytes(bytes = [], byteLength = 1) {
@@ -324,7 +414,7 @@ function formatStorageRegisterHexValue(register, rawBytes = []) {
 function normalizeRegister(register, group, index, address, byteOffset = 0) {
   const registerType = getRegisterType(group.registerType).key
   const layout = isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER
-  const isStorageStruct = isStorageStructGroup(group)
+  const isStorageMemory = isStorageMemoryGroup(group)
   const byteAddressed = isByteAddressedGroup(group)
   const dataType = normalizeRegisterDataType(register, registerType)
   const bitOffset = normalizeBitOffset(register.bitOffset)
@@ -338,6 +428,7 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
   const savedValue = getRegisterSavedValue(register)
   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 byteLength = isBitRegisterType(registerType)
     ? 1
     : getRegisterByteLength(dataType, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
@@ -356,7 +447,7 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     : (isBitRegisterType(registerType)
       ? formatCoilDisplayValue(rawValue)
       : (byteAddressed ? formatRawByteText(rawBytes) : formatRawWordText(rawWords)))
-  const rawValueText = isStorageStruct
+  const rawValueText = isStorageMemory
     ? formatStorageRegisterHexValue({
       ...register,
       byteLength,
@@ -365,7 +456,7 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     : defaultRawValueText
   const displayValue = rawValue === null
     ? (inputValue.trim() ? inputValue : '--')
-    : formatRegisterValue({ ...register, dataType, byteOffset }, rawValue)
+    : formatRegisterValue({ ...register, dataType, byteOffset, memoryEndian }, rawValue)
   const conversionFormula = normalizeTextValue(register.conversionFormula).trim()
   const conversionResult = conversionFormula && rawValue !== null
     ? evaluateValueFormula(conversionFormula, rawValue, group.codeInfoContext || {})
@@ -379,17 +470,25 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
   const addressRangeText = isBitRegisterType(registerType)
     ? `0x${padHex(address)}`
     : (byteAddressed
-      ? formatAddressRange(address, Math.max(1, byteLength))
+      ? (isStorageMemory
+        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        : formatAddressRange(address, Math.max(1, byteLength)))
       : formatAddressRange(address, registerCount))
   const addressText = isBitField
     ? (byteAddressed
-      ? `${formatAddressRange(address, Math.max(1, byteLength))}.b${bitOffset}${bitWidth > 1 ? `..b${bitOffset + bitWidth - 1}` : ''}`
+      ? `${isStorageMemory
+        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        : formatAddressRange(address, Math.max(1, byteLength))}.b${bitOffset}${bitWidth > 1 ? `..b${bitOffset + bitWidth - 1}` : ''}`
       : formatBitFieldAddressText(address, byteOffset, bitOffset, bitWidth))
     : (byteAddressed
-      ? formatAddressRange(address, Math.max(1, byteLength))
+      ? (isStorageMemory
+        ? formatStorageAddressRange(address, Math.max(1, byteLength))
+        : formatAddressRange(address, Math.max(1, byteLength)))
       : formatRegisterAddressText(address, byteOffset, byteLength, registerType))
-  const registerStartAddressText = formatRegisterStartAddressText(address)
-  const metaText = isStorageStruct
+  const registerStartAddressText = isStorageMemory
+    ? formatStorageStartAddressText(address)
+    : formatRegisterStartAddressText(address)
+  const metaText = isStorageMemory
     ? `${registerStartAddressText} ${rawValueText}`
     : `${addressText} ${rawValueText}`.trim()
 
@@ -423,6 +522,7 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     maxValue: normalizeTextValue(register.maxValue),
     metaText,
     minValue: normalizeTextValue(register.minValue),
+    memoryEndian,
     name: register.name || `寄存器 ${index + 1}`,
     conversionFormula,
     conversionFormulaErrorText: conversionResult && conversionResult.errorText ? conversionResult.errorText : '',
@@ -448,7 +548,16 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
 
 function normalizeGroup(group) {
   const registerType = getRegisterType(group.registerType || group.type || DEFAULT_REGISTER_TYPE)
-  const startAddress = normalizeAddress(group.startAddress, 0)
+  const sourceMemoryArea = String(group.sourceMemoryArea || '').trim()
+  const isStorageGroupSource = !!sourceMemoryArea || group.addressUnit === 'byte' || group.addressUnit === 'bytes'
+  const startAddress = isStorageGroupSource
+    ? normalizeStorageAddress(
+      group.sourceAddress !== undefined && group.sourceAddress !== null && group.sourceAddress !== ''
+        ? group.sourceAddress
+        : group.startAddress,
+      0
+    )
+    : normalizeAddress(group.startAddress, 0)
   const maxQuantity = getMaxQuantity(registerType.key)
   const sourceRegisters = Array.isArray(group.registers) ? group.registers : []
   const codeInfoContext = group.codeInfoContext || {}
@@ -465,6 +574,7 @@ function normalizeGroup(group) {
     isStructLayout: layout === GROUP_LAYOUT_STRUCT,
     layout,
     name: String(group.name || group.groupName || '寄存器组').trim() || '寄存器组',
+    pollEnabled: group.pollEnabled === false ? false : true,
     quantity,
     registerType: registerType.key,
     startAddress,
@@ -523,26 +633,36 @@ function normalizeGroup(group) {
     ? Math.max(1, nextAddress - startAddress)
     : (byteAddressed ? Math.max(1, byteLength) : Math.max(1, paddedByteLength / 2))
   const addressSpan = byteAddressed ? Math.max(1, byteLength) : wordQuantity
-  const addressOverflow = isAddressRangeOverflow(startAddress, addressSpan)
+  const storageMemory = isStorageMemoryGroup(baseGroup)
+  const addressOverflow = storageMemory
+    ? isStorageAddressRangeOverflow(startAddress, addressSpan)
+    : isAddressRangeOverflow(startAddress, addressSpan)
   const endAddress = startAddress + addressSpan - 1
-  const startAddressText = `0x${padHex(startAddress)}`
-  const endAddressText = addressOverflow ? `0x${padHex(MAX_MODBUS_ADDRESS)}+` : `0x${padHex(endAddress)}`
-  const displayName = isStorageStructGroup(baseGroup)
+  const addressMax = storageMemory ? MAX_STORAGE_ADDRESS : MAX_MODBUS_ADDRESS
+  const startAddressText = storageMemory
+    ? formatStorageStartAddressText(startAddress)
+    : formatStartAddressText(startAddress, addressMax)
+  const endAddressText = storageMemory
+    ? (addressOverflow ? `0x${padStorageHex(addressMax)}+` : `0x${padStorageHex(endAddress)}`)
+    : (addressOverflow ? `0x${padWordHex(addressMax)}+` : `0x${padWordHex(endAddress)}`)
+  const displayName = storageMemory
     ? stripStorageAreaPrefix(baseGroup.name)
     : baseGroup.name
-  const listMetaText = isStorageStructGroup(baseGroup)
+  const listMetaText = storageMemory
     ? `${getStorageAreaText(baseGroup)} ${startAddressText}-${endAddressText} ${byteLength}B`
     : `${formatAddressRange(startAddress, addressSpan)} · ${quantity}/${wordQuantity}${createGroupSourceMetaText(baseGroup) ? ` · ${createGroupSourceMetaText(baseGroup)}` : ''}${addressOverflow ? ' · 地址超出 0xFFFF' : ''}`
-  const detailMetaText = isStorageStructGroup(baseGroup)
+  const detailMetaText = storageMemory
     ? `${getStorageAreaText(baseGroup, true)} ${startAddressText} ${quantity}/${byteLength}B`
     : `${formatAddressRange(startAddress, addressSpan)} · ${quantity}/${wordQuantity}${createGroupSourceMetaText(baseGroup) ? ` · ${createGroupSourceMetaText(baseGroup)}` : ''}${addressOverflow ? ' · 地址超出 0xFFFF' : ''}`
   const sourceMetaText = createGroupSourceMetaText(baseGroup)
 
   return {
     ...baseGroup,
-    addressRangeText: formatAddressRange(startAddress, addressSpan),
+    addressRangeText: storageMemory
+      ? formatStorageAddressRange(startAddress, addressSpan)
+      : formatAddressRange(startAddress, addressSpan),
     addressOverflow,
-    addressWarningText: addressOverflow ? '地址超出 0xFFFF' : '',
+    addressWarningText: addressOverflow ? `地址超出 0x${padWordHex(addressMax)}` : '',
     detailMetaText,
     detailTitleText: displayName,
     displayName,
@@ -568,13 +688,20 @@ function normalizeGroupConfig(config = {}) {
     ? (REGISTER_TYPE_OPTIONS[Number(config.registerTypeIndex)] || REGISTER_TYPE_OPTIONS[0])
     : getRegisterType(config.registerType || config.type || DEFAULT_REGISTER_TYPE)
   const maxQuantity = getMaxQuantity(registerType.key)
+  const isStorageConfig = !!String(config.sourceMemoryArea || '').trim()
+    || config.addressUnit === 'byte'
+    || config.addressUnit === 'bytes'
 
   return {
     layout: isStructLayout(config.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
     name: String(config.name || config.groupName || '寄存器组').trim() || '寄存器组',
+    pollEnabled: config.pollEnabled === false ? false : true,
     quantity: parseConfigQuantity(config.quantity, maxQuantity),
     registerType: registerType.key,
-    startAddress: parseConfigAddress(config.startAddress)
+    startAddress: parseConfigAddress(config.startAddress, {
+      label: isStorageConfig ? '内存起始地址' : '寄存器起始地址',
+      maxAddress: isStorageConfig ? MAX_STORAGE_ADDRESS : MAX_MODBUS_ADDRESS
+    })
   }
 }
 
@@ -604,6 +731,7 @@ function cloneImportedGroup(group) {
   return {
     layout: isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
     name: group.name,
+    pollEnabled: group.pollEnabled === false ? false : true,
     quantity: group.quantity,
     registerType: group.registerType || group.type || DEFAULT_REGISTER_TYPE,
     registers: (Array.isArray(group.registers) ? group.registers : []).map((register) => ({
@@ -634,6 +762,7 @@ module.exports = {
   GROUP_LAYOUT_REGISTER,
   GROUP_LAYOUT_STRUCT,
   MAX_MODBUS_ADDRESS,
+  MAX_STORAGE_ADDRESS,
   REGISTER_TYPE_OPTIONS,
   cloneImportedGroup,
   decodeRegisterFromByteCache,
@@ -655,6 +784,9 @@ module.exports = {
   isAddressRangeOverflow,
   isBitRegisterType,
   isByteRegister,
+  isParameterGroupPollEnabled,
+  isStorageMemoryGroup,
+  isStorageStructGroup,
   isTextRegister,
   normalizeGroup,
   normalizeGroupConfig,

+ 2 - 1
domain/parameter-groups/register-io.js

@@ -96,7 +96,8 @@ function decodeRegisterFromBytes(register, bytes) {
   return decodeRegisterValue(
     {
       ...register,
-      byteOffset: 0
+      byteOffset: 0,
+      memoryEndian: register.memoryEndian
     },
     bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
   )

+ 290 - 2
domain/parameter-groups/struct-c-syntax.js

@@ -45,6 +45,11 @@ const STRUCT_PATTERNS = [
   /struct\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}\s*;/g
 ]
 
+const ENUM_PATTERNS = [
+  /typedef\s+enum(?:\s+([A-Za-z_]\w*))?\s*\{([\s\S]*?)\}\s*([A-Za-z_]\w*)\s*;/g,
+  /enum\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}\s*;/g
+]
+
 function normalizeLookupName(value) {
   return String(value || '')
     .replace(/^_+/, '')
@@ -75,7 +80,7 @@ function resolveType(typeText, aliases) {
     .join(' ')
     .trim()
 
-  if (!compact || /^struct\b/.test(compact) || /^enum\b/.test(compact) || compact.indexOf('*') >= 0) {
+  if (!compact || /^struct\b/.test(compact) || compact.indexOf('*') >= 0) {
     return ''
   }
 
@@ -89,7 +94,33 @@ function resolveType(typeText, aliases) {
   return ''
 }
 
-function createAliasMap(source) {
+function getEnumTypeNames(enumInfo = {}) {
+  return [
+    enumInfo.name,
+    enumInfo.typedefName,
+    enumInfo.tagName ? `enum ${enumInfo.tagName}` : '',
+    enumInfo.tagName
+  ]
+    .filter(Boolean)
+    .filter((name, index, list) => list.indexOf(name) === index)
+}
+
+function normalizeEnumTypeKey(value) {
+  return normalizeTypeText(value).toLowerCase()
+}
+
+function createEnumTypeMap(enums = []) {
+  return enums.reduce((map, enumInfo) => {
+    getEnumTypeNames(enumInfo).forEach((typeName) => {
+      map[normalizeTypeText(typeName)] = enumInfo
+      map[normalizeEnumTypeKey(typeName)] = enumInfo
+    })
+
+    return map
+  }, {})
+}
+
+function createAliasMap(source, enums = findEnums(source)) {
   const aliases = {
     ...TYPE_ALIASES
   }
@@ -119,9 +150,263 @@ function createAliasMap(source) {
     if (tagName && typedefName) aliases[`struct ${tagName}`] = typedefName
   }
 
+  enums.forEach((enumInfo) => {
+    getEnumTypeNames(enumInfo).forEach((typeName) => {
+      aliases[typeName] = enumInfo.dataType || 'uint16_t'
+    })
+  })
+
   return aliases
 }
 
+function tokenizeEnumExpression(expression, symbols = {}) {
+  const source = String(expression || '').trim()
+  const tokens = []
+  let index = 0
+
+  while (index < source.length) {
+    const char = source[index]
+    if (/\s/.test(char)) {
+      index += 1
+      continue
+    }
+
+    const numberMatch = source.slice(index).match(/^(?:0x[0-9a-f]+|\d+)(?:u|U|l|L|ul|UL|uL|Ul|lu|LU|lU|Lu)?/i)
+    if (numberMatch) {
+      const raw = numberMatch[0].replace(/(?:u|U|l|L|ul|UL|uL|Ul|lu|LU|lU|Lu)+$/i, '')
+      tokens.push({
+        type: 'number',
+        value: raw.toLowerCase().startsWith('0x') ? parseInt(raw, 16) : Number(raw)
+      })
+      index += numberMatch[0].length
+      continue
+    }
+
+    const identifierMatch = source.slice(index).match(/^[A-Za-z_]\w*/)
+    if (identifierMatch) {
+      const name = identifierMatch[0]
+      if (!Object.prototype.hasOwnProperty.call(symbols, name)) return null
+      tokens.push({
+        type: 'number',
+        value: Number(symbols[name])
+      })
+      index += name.length
+      continue
+    }
+
+    const twoCharOperator = source.slice(index, index + 2)
+    if (twoCharOperator === '<<' || twoCharOperator === '>>') {
+      tokens.push({
+        type: 'operator',
+        value: twoCharOperator
+      })
+      index += 2
+      continue
+    }
+
+    if ('+-*/%&|^~()'.indexOf(char) >= 0) {
+      tokens.push({
+        type: char === '(' || char === ')' ? 'paren' : 'operator',
+        value: char
+      })
+      index += 1
+      continue
+    }
+
+    return null
+  }
+
+  return tokens
+}
+
+function createEnumExpressionParser(tokens) {
+  let index = 0
+  const precedence = {
+    '|': 1,
+    '^': 2,
+    '&': 3,
+    '<<': 4,
+    '>>': 4,
+    '+': 5,
+    '-': 5,
+    '*': 6,
+    '/': 6,
+    '%': 6
+  }
+
+  function peek() {
+    return tokens[index] || null
+  }
+
+  function consume() {
+    const token = tokens[index] || null
+    index += 1
+
+    return token
+  }
+
+  function applyBinaryOperator(operator, left, right) {
+    if (operator === '+') return left + right
+    if (operator === '-') return left - right
+    if (operator === '*') return left * right
+    if (operator === '/') return right === 0 ? NaN : Math.trunc(left / right)
+    if (operator === '%') return right === 0 ? NaN : left % right
+    if (operator === '<<') return left << right
+    if (operator === '>>') return left >> right
+    if (operator === '&') return left & right
+    if (operator === '^') return left ^ right
+    if (operator === '|') return left | right
+
+    return NaN
+  }
+
+  function parsePrimary() {
+    const token = consume()
+    if (!token) return NaN
+    if (token.type === 'number') return Number(token.value)
+    if (token.value === '(') {
+      const value = parseExpression(1)
+      const endToken = consume()
+      return endToken && endToken.value === ')' ? value : NaN
+    }
+
+    return NaN
+  }
+
+  function parseUnary() {
+    const token = peek()
+    if (token && token.type === 'operator' && ['+', '-', '~'].indexOf(token.value) >= 0) {
+      consume()
+      const value = parseUnary()
+      if (token.value === '+') return value
+      if (token.value === '-') return -value
+
+      return ~value
+    }
+
+    return parsePrimary()
+  }
+
+  function parseExpression(minPrecedence) {
+    let left = parseUnary()
+
+    while (Number.isFinite(left)) {
+      const token = peek()
+      const tokenPrecedence = token && token.type === 'operator' ? precedence[token.value] : 0
+      if (!tokenPrecedence || tokenPrecedence < minPrecedence) break
+
+      consume()
+      const right = parseExpression(tokenPrecedence + 1)
+      left = applyBinaryOperator(token.value, left, right)
+    }
+
+    return left
+  }
+
+  return {
+    parse() {
+      const value = parseExpression(1)
+
+      return index === tokens.length && Number.isFinite(value) ? Math.trunc(value) : null
+    }
+  }
+}
+
+function evaluateEnumExpression(expression, symbols = {}) {
+  const tokens = tokenizeEnumExpression(expression, symbols)
+  if (!tokens || !tokens.length) return null
+
+  return createEnumExpressionParser(tokens).parse()
+}
+
+function inferEnumDataType(options = []) {
+  const values = options.map((option) => Number(option.value)).filter((value) => Number.isFinite(value))
+  const minValue = values.length ? Math.min.apply(null, values) : 0
+  const maxValue = values.length ? Math.max.apply(null, values) : 0
+
+  if (minValue < 0) {
+    if (minValue >= -0x8000 && maxValue <= 0x7FFF) return 'int16_t'
+
+    return 'int32_t'
+  }
+  if (maxValue <= 0xFFFF) return 'uint16_t'
+
+  return 'uint32_t'
+}
+
+function parseEnumOptions(body, globalSymbols = {}) {
+  const options = []
+  const symbols = {
+    ...globalSymbols
+  }
+  let currentValue = -1
+
+  String(body || '').split(',').forEach((item) => {
+    const text = item.trim()
+    if (!text) return
+
+    const match = text.match(/^([A-Za-z_]\w*)\s*(?:=\s*(.+))?$/)
+    if (!match) return
+
+    const name = match[1]
+    const explicitValue = match[2] ? evaluateEnumExpression(match[2], symbols) : null
+    currentValue = explicitValue === null ? currentValue + 1 : explicitValue
+    symbols[name] = currentValue
+    options.push({
+      label: name,
+      name,
+      value: currentValue
+    })
+  })
+
+  return {
+    options,
+    symbols
+  }
+}
+
+function findEnums(source) {
+  const enums = []
+  const globalSymbols = {}
+
+  ENUM_PATTERNS.forEach((pattern) => {
+    pattern.lastIndex = 0
+    let match
+
+    while ((match = pattern.exec(source))) {
+      const isTypedef = pattern === ENUM_PATTERNS[0]
+      const tagName = isTypedef ? match[1] : match[1]
+      const body = isTypedef ? match[2] : match[2]
+      const typedefName = isTypedef ? match[3] : ''
+      const parsed = parseEnumOptions(body, globalSymbols)
+      if (!parsed.options.length) continue
+
+      Object.assign(globalSymbols, parsed.symbols)
+      const name = typedefName || tagName || 'enum'
+      const enumInfo = {
+        dataType: inferEnumDataType(parsed.options),
+        name,
+        options: parsed.options,
+        tagName,
+        typedefName,
+        typeNames: []
+      }
+      enumInfo.typeNames = getEnumTypeNames(enumInfo)
+      enums.push(enumInfo)
+    }
+  })
+
+  const seen = {}
+
+  return enums.filter((enumInfo) => {
+    const key = enumInfo.typeNames.map(normalizeEnumTypeKey).join('|')
+    if (!key || seen[key]) return false
+    seen[key] = true
+
+    return true
+  })
+}
+
 function findStruct(source) {
   for (const pattern of STRUCT_PATTERNS) {
     pattern.lastIndex = 0
@@ -248,8 +533,11 @@ function parseDeclarator(text) {
 
 module.exports = {
   createAliasMap,
+  createEnumTypeMap,
   findStruct,
   findStructs,
+  findEnums,
+  getEnumTypeNames,
   normalizeLookupName,
   normalizeTypeText,
   parseDeclarator,

+ 64 - 6
domain/parameter-groups/struct-layout.js

@@ -7,6 +7,22 @@ const {
   splitDeclarators
 } = require('./struct-c-syntax.js')
 
+const FIELD_TYPE_QUALIFIERS = {
+  _I: true,
+  _IO: true,
+  _O: true,
+  code: true,
+  const: true,
+  data: true,
+  extern: true,
+  idata: true,
+  pdata: true,
+  register: true,
+  static: true,
+  volatile: true,
+  xdata: true
+}
+
 function isAsciiArray(typeText, dataType, name, arrayLength) {
   if (!arrayLength || arrayLength < 2 || arrayLength > 32) return false
 
@@ -31,6 +47,42 @@ function getBitFieldDataType(bitWidth) {
   return 'uint32_t'
 }
 
+function cloneEnumOptions(enumInfo) {
+  return (Array.isArray(enumInfo && enumInfo.options) ? enumInfo.options : []).map((option) => ({
+    label: option.label || option.name,
+    name: option.name || option.label,
+    value: Number(option.value) || 0
+  }))
+}
+
+function createEnumMeta(enumInfo) {
+  if (!enumInfo || !Array.isArray(enumInfo.options) || !enumInfo.options.length) return {}
+
+  return {
+    enumName: enumInfo.name,
+    enumOptions: cloneEnumOptions(enumInfo),
+    sourceSymbolType: enumInfo.name
+  }
+}
+
+function normalizeEnumLookupKey(typeText) {
+  return normalizeTypeText(typeText)
+    .split(/\s+/)
+    .filter((token) => !FIELD_TYPE_QUALIFIERS[token])
+    .join(' ')
+    .trim()
+    .toLowerCase()
+}
+
+function resolveEnumInfo(typeText, enumTypes = {}) {
+  const key = normalizeEnumLookupKey(typeText)
+  if (!key) return null
+
+  return enumTypes[key]
+    || enumTypes[key.replace(/^enum\s+/, '')]
+    || null
+}
+
 function isBitType(typeText) {
   return normalizeTypeText(typeText).toLowerCase() === 'bit'
 }
@@ -49,7 +101,7 @@ function advanceLayoutBytes(layoutState, byteLength) {
   layoutState.bitOffset += Math.max(1, Number(byteLength) || 1) * 8
 }
 
-function createBitFieldRegister(field, bitWidth, layoutState, name) {
+function createBitFieldRegister(field, bitWidth, layoutState, name, enumInfo = null) {
   const width = Math.max(0, Math.round(Number(bitWidth) || 0))
 
   if (width === 0) {
@@ -68,13 +120,14 @@ function createBitFieldRegister(field, bitWidth, layoutState, name) {
     bitWidth: width,
     byteStart,
     dataType: getBitFieldDataType(width),
+    ...createEnumMeta(enumInfo),
     isBitField: true,
     name,
     unit: 'bit'
   }]
 }
 
-function createRegisterFromField(field, dataType, originalTypeText, layoutState) {
+function createRegisterFromField(field, dataType, originalTypeText, layoutState, enumInfo = null) {
   const arrayLength = field.arrayDimensions.reduce((total, value) => total * value, 1)
   const hasArray = field.arrayDimensions.length > 0
   const bitFieldWidth = field.bitWidth !== null && field.bitWidth !== undefined
@@ -89,14 +142,15 @@ function createRegisterFromField(field, dataType, originalTypeText, layoutState)
           field,
           bitFieldWidth,
           layoutState,
-          field.name ? `${field.name}[${index}]` : ''
+          field.name ? `${field.name}[${index}]` : '',
+          enumInfo
         ))
       }
 
       return registers
     }
 
-    return createBitFieldRegister(field, bitFieldWidth, layoutState, field.name)
+    return createBitFieldRegister(field, bitFieldWidth, layoutState, field.name, enumInfo)
   }
 
   alignLayoutToByte(layoutState)
@@ -108,6 +162,7 @@ function createRegisterFromField(field, dataType, originalTypeText, layoutState)
     return [{
       byteStart,
       dataType: 'ascii',
+      ...createEnumMeta(enumInfo),
       name: field.name,
       textByteLength: String(arrayLength)
     }]
@@ -120,6 +175,7 @@ function createRegisterFromField(field, dataType, originalTypeText, layoutState)
     return [{
       byteStart,
       dataType,
+      ...createEnumMeta(enumInfo),
       name: field.name
     }]
   }
@@ -131,6 +187,7 @@ function createRegisterFromField(field, dataType, originalTypeText, layoutState)
     registers.push({
       byteStart,
       dataType,
+      ...createEnumMeta(enumInfo),
       name: `${field.name}[${index}]`
     })
   }
@@ -138,7 +195,7 @@ function createRegisterFromField(field, dataType, originalTypeText, layoutState)
   return registers
 }
 
-function parseStructFields(body, aliases) {
+function parseStructFields(body, aliases, enumTypes = {}) {
   const registers = []
   const layoutState = {
     bitOffset: 0
@@ -156,13 +213,14 @@ function parseStructFields(body, aliases) {
 
     const dataType = resolveType(first.typeText, aliases)
     if (!dataType) return
+    const enumInfo = resolveEnumInfo(first.typeText, enumTypes)
 
     const declarators = [first.declarator].concat(parts.slice(1))
     declarators.forEach((declaratorText) => {
       const field = parseDeclarator(declaratorText)
       if (!field) return
 
-      registers.push(...createRegisterFromField(field, dataType, first.typeText, layoutState))
+      registers.push(...createRegisterFromField(field, dataType, first.typeText, layoutState, enumInfo))
     })
   })
 

+ 48 - 6
domain/parameter-groups/struct-parser.js

@@ -1,5 +1,7 @@
 const {
   createAliasMap,
+  createEnumTypeMap,
+  findEnums,
   findStruct,
   findStructs,
   normalizeLookupName,
@@ -26,13 +28,15 @@ const STRUCT_VARIABLE_QUALIFIERS = {
 
 function parseStructDefinition(sourceText) {
   const source = stripComments(sourceText)
-  const aliases = createAliasMap(source)
+  const enums = findEnums(source)
+  const enumTypes = createEnumTypeMap(enums)
+  const aliases = createAliasMap(source, enums)
   const structInfo = findStruct(source)
   if (!structInfo) {
     throw new Error('未找到结构体定义')
   }
 
-  const registers = parseStructFields(structInfo.body, aliases)
+  const registers = parseStructFields(structInfo.body, aliases, enumTypes)
   if (!registers.length) {
     throw new Error('结构体中没有可识别的变量定义')
   }
@@ -106,19 +110,57 @@ function parseStructVariables(source, structs, aliases) {
   return variablesByName
 }
 
+function getEnumVariableTypeNames(enumInfo = {}) {
+  return (Array.isArray(enumInfo.typeNames) ? enumInfo.typeNames : [])
+    .concat(enumInfo.name, enumInfo.typedefName, enumInfo.tagName ? `enum ${enumInfo.tagName}` : '')
+    .filter(Boolean)
+}
+
+function parseEnumVariables(source, enums, aliases) {
+  const variablesByName = {}
+
+  enums.forEach((enumInfo) => {
+    getEnumVariableTypeNames(enumInfo).forEach((enumTypeName) => {
+      const escaped = enumTypeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+      const variablePattern = new RegExp(`(^|[;\\n{}])\\s*([A-Za-z_][\\w\\s]*?\\s+)?${escaped}\\s+([^;{}()]+);`, 'g')
+      let match
+
+      while ((match = variablePattern.exec(source))) {
+        const prefix = normalizeVariableTypeText(match[2] || '', aliases)
+        if (prefix) continue
+
+        splitDeclarators(match[3]).forEach((declaratorText) => {
+          const field = parseDeclarator(declaratorText)
+          if (!field) return
+
+          variablesByName[field.name] = enumInfo
+          variablesByName[field.name.replace(/^_+/, '').toLowerCase()] = enumInfo
+          variablesByName[normalizeLookupName(field.name)] = enumInfo
+        })
+      }
+    })
+  })
+
+  return variablesByName
+}
+
 function parseStructCatalog(sourceText) {
   const source = stripComments(sourceText)
-  const aliases = createAliasMap(source)
+  const enums = findEnums(source)
+  const enumTypes = createEnumTypeMap(enums)
+  const aliases = createAliasMap(source, enums)
   const structs = findStructs(source).map((structInfo) => ({
     ...structInfo,
-    registers: parseStructFields(structInfo.body, aliases)
+    registers: parseStructFields(structInfo.body, aliases, enumTypes)
   })).filter((structInfo) => structInfo.registers.length)
 
-  if (!structs.length) {
-    throw new Error('未找到可识别的结构体定义')
+  if (!structs.length && !enums.length) {
+    throw new Error('未找到可识别的结构体或枚举定义')
   }
 
   return {
+    enums,
+    enumVariablesByName: parseEnumVariables(source, enums, aliases),
     structs,
     variablesByName: parseStructVariables(source, structs, aliases)
   }

+ 64 - 13
domain/parameter-groups/value-codec.js

@@ -81,10 +81,52 @@ function parseCoilValue(value) {
   return Number.isFinite(coilValue) ? (coilValue ? 1 : 0) : null
 }
 
+function normalizeEnumOptions(register = {}) {
+  return (Array.isArray(register.enumOptions) ? register.enumOptions : [])
+    .map((option) => ({
+      label: String(option && (option.label || option.name) || '').trim(),
+      name: String(option && (option.name || option.label) || '').trim(),
+      value: Number(option && option.value)
+    }))
+    .filter((option) => option.label && Number.isFinite(option.value))
+}
+
+function findEnumOptionByText(register, valueText) {
+  const text = normalizeTextValue(valueText).trim()
+  if (!text) return null
+
+  const normalizedText = text.toLowerCase()
+
+  return normalizeEnumOptions(register).find((option) => (
+    option.name.toLowerCase() === normalizedText
+    || option.label.toLowerCase() === normalizedText
+    || `${option.label}(${option.value})`.toLowerCase() === normalizedText
+    || `${option.label} (${option.value})`.toLowerCase() === normalizedText
+    || `${option.label}(${option.value})`.toLowerCase() === normalizedText
+  )) || null
+}
+
+function parseEnumValueText(register, valueText) {
+  const option = findEnumOptionByText(register, valueText)
+
+  return option ? option.value : null
+}
+
+function formatEnumValue(register, rawValue) {
+  const numberValue = Number(rawValue)
+  if (!Number.isFinite(numberValue)) return ''
+
+  const option = normalizeEnumOptions(register).find((item) => Number(item.value) === numberValue)
+  if (!option) return ''
+
+  return `${option.label} (${numberValue})`
+}
+
 function parseBitFieldValue(register, valueText) {
   const text = normalizeTextValue(valueText).trim()
   const bitWidth = normalizeBitWidth(register.bitWidth)
-  let parsed = parseNumberText(text, 'uint32_t')
+  let parsed = parseEnumValueText(register, text)
+  if (parsed === null) parsed = parseNumberText(text, 'uint32_t')
   const maxValue = getBitFieldMaxValue(register)
 
   if (parsed === null && bitWidth === 1) parsed = parseCoilValue(text)
@@ -169,6 +211,7 @@ function encodeRegisterBytes(register) {
   const dataType = getDataType(register.dataType).key
   const valueText = normalizeTextValue(register.inputValue)
   const byteLength = getRegisterByteLength(dataType, register)
+  const memoryEndian = register.memoryEndian || 'big'
 
   if (isBitFieldRegister(register)) {
     return encodeBitFieldBytes(register)
@@ -186,22 +229,23 @@ function encodeRegisterBytes(register) {
     return paddedBytes.slice(0, byteLength)
   }
 
-  const numberValue = parseNumberText(valueText, dataType)
+  let numberValue = parseEnumValueText(register, valueText)
+  if (numberValue === null) numberValue = parseNumberText(valueText, dataType)
   if (numberValue === null) return null
   validateNumericValue(register, numberValue)
 
-  if (dataType === 'float') return floatToBytes(numberValue)
+  if (dataType === 'float') return floatToBytes(numberValue, memoryEndian)
 
   const rounded = Math.round(numberValue)
   if (dataType === 'int8_t' || dataType === 'uint8_t') return [rounded & 0xFF]
   if (dataType === 'int16_t' || dataType === 'uint16_t' || dataType === 'hex') {
-    return unsignedIntegerToBytes(rounded, 2)
+    return unsignedIntegerToBytes(rounded, 2, memoryEndian)
   }
   if (dataType === 'int32_t' || dataType === 'uint32_t') {
-    return unsignedIntegerToBytes(rounded, 4)
+    return unsignedIntegerToBytes(rounded, 4, memoryEndian)
   }
 
-  return unsignedIntegerToBytes(rounded, byteLength)
+  return unsignedIntegerToBytes(rounded, byteLength, memoryEndian)
 }
 
 function encodeRegisterWords(register) {
@@ -216,6 +260,7 @@ function encodeRegisterWords(register) {
 
 function decodeRegisterValue(register, words) {
   const dataType = getDataType(register.dataType).key
+  const memoryEndian = register.memoryEndian || 'big'
 
   if (!Array.isArray(words) || words.length < getRegisterWordCountAtOffset(dataType, register.byteOffset || 0, register)) return null
 
@@ -231,7 +276,7 @@ function decodeRegisterValue(register, words) {
     return decodeTextBytes(bytes.slice(0, getEncodeByteLimit(register)), dataType)
   }
   if (dataType === 'float') {
-    return bytesToFloatValue(bytes)
+    return bytesToFloatValue(bytes, memoryEndian)
   }
   if (dataType === 'int8_t') {
     const byteValue = bytes[0] & 0xFF
@@ -241,25 +286,28 @@ function decodeRegisterValue(register, words) {
     return bytes[0] & 0xFF
   }
   if (dataType === 'int16_t') {
-    return bytesToSignedInteger(bytes.slice(0, 2))
+    return bytesToSignedInteger(bytes.slice(0, 2), memoryEndian)
   }
   if (dataType === 'uint16_t') {
-    return bytesToUnsignedInteger(bytes.slice(0, 2))
+    return bytesToUnsignedInteger(bytes.slice(0, 2), memoryEndian)
   }
   if (dataType === 'hex') {
-    return bytesToUnsignedInteger(bytes.slice(0, 2))
+    return bytesToUnsignedInteger(bytes.slice(0, 2), memoryEndian)
   }
 
   if (dataType === 'int32_t') {
-    return bytesToSignedInteger(bytes.slice(0, 4))
+    return bytesToSignedInteger(bytes.slice(0, 4), memoryEndian)
   }
 
-  return bytesToUnsignedInteger(bytes.slice(0, 4))
+  return bytesToUnsignedInteger(bytes.slice(0, 4), memoryEndian)
 }
 
 function formatRegisterValue(register, rawValue) {
   if (rawValue === null || rawValue === undefined) return '--'
 
+  const enumValue = formatEnumValue(register, rawValue)
+  if (enumValue) return enumValue
+
   const dataType = getDataType(register.dataType).key
   if (isTextRegister(dataType)) return normalizeTextValue(rawValue)
   if (dataType === 'hex') return formatHexValue(rawValue)
@@ -302,7 +350,8 @@ function validateRegisterValue(register, value) {
     return true
   }
 
-  const numberValue = parseNumberText(valueText, dataType)
+  let numberValue = parseEnumValueText(register, valueText)
+  if (numberValue === null) numberValue = parseNumberText(valueText, dataType)
   if (numberValue === null) {
     throw new Error(`${register.name || '寄存器'} 输入值无效`)
   }
@@ -322,6 +371,8 @@ module.exports = {
   formatRawByteTextWithDefault,
   formatRawWordText,
   formatRegisterValue,
+  normalizeEnumOptions,
+  parseEnumValueText,
   getBitFieldByteLength,
   getBitFieldMaxValue,
   getDataType,

+ 5 - 2
domain/parameter-groups/value-formula.js

@@ -1,6 +1,6 @@
 const {
   formatFixedValue
-} = require('../../utils/number-format.js')
+} = require('../../utils/base-utils.js')
 
 const TOKEN_NUMBER = 'number'
 const TOKEN_IDENTIFIER = 'identifier'
@@ -16,7 +16,10 @@ const ALLOWED_IDENTIFIERS = [
   'ampGain',
   'rsShunt',
   'busDiv',
-  'alongDiv'
+  'alongDiv',
+  'maxPacketLength',
+  'addressWidth',
+  'storageAddressWidth'
 ]
 
 function tokenizeFormula(formulaText) {

+ 25 - 12
domain/parameter-groups/value-number.js

@@ -1,6 +1,6 @@
 const {
   formatFixedValue
-} = require('../../utils/number-format.js')
+} = require('../../utils/base-utils.js')
 const {
   getDataType,
   isHexRegister
@@ -36,6 +36,18 @@ function formatFloatValue(value) {
   return formatFixedValue(value, 6).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
 }
 
+function isLittleEndian(byteOrder) {
+  const text = String(byteOrder || '').trim().toLowerCase()
+
+  return text === 'little' || text === 'le' || text === '1'
+}
+
+function applyByteOrder(bytes, byteOrder) {
+  const source = Array.isArray(bytes) ? bytes.slice() : []
+
+  return isLittleEndian(byteOrder) ? source.reverse() : source
+}
+
 function parseIntegerText(value) {
   const text = String(value === undefined || value === null ? '' : value).trim()
   if (!text) return null
@@ -123,34 +135,35 @@ function validateNumericValue(register, value) {
   return true
 }
 
-function floatToBytes(value) {
+function floatToBytes(value, byteOrder = 'big') {
   const buffer = new ArrayBuffer(4)
   const view = new DataView(buffer)
 
   view.setFloat32(0, Number(value), false)
 
-  return [
+  return applyByteOrder([
     view.getUint8(0),
     view.getUint8(1),
     view.getUint8(2),
     view.getUint8(3)
-  ]
+  ], byteOrder)
 }
 
-function bytesToFloatValue(bytes) {
+function bytesToFloatValue(bytes, byteOrder = 'big') {
   if (!Array.isArray(bytes) || bytes.length < 4) return null
 
   const buffer = new ArrayBuffer(4)
   const view = new DataView(buffer)
+  const source = applyByteOrder(bytes.slice(0, 4), byteOrder)
 
   for (let index = 0; index < 4; index += 1) {
-    view.setUint8(index, Number(bytes[index]) & 0xFF)
+    view.setUint8(index, Number(source[index]) & 0xFF)
   }
 
   return view.getFloat32(0, false)
 }
 
-function unsignedIntegerToBytes(value, byteLength) {
+function unsignedIntegerToBytes(value, byteLength, byteOrder = 'big') {
   let numberValue = Math.round(Number(value) || 0)
   const bytes = []
 
@@ -163,15 +176,15 @@ function unsignedIntegerToBytes(value, byteLength) {
     numberValue = Math.floor(numberValue / 0x100)
   }
 
-  return bytes
+  return applyByteOrder(bytes, byteOrder)
 }
 
-function bytesToUnsignedInteger(bytes) {
-  return bytes.reduce((value, byte) => ((value * 0x100) + (Number(byte) & 0xFF)), 0)
+function bytesToUnsignedInteger(bytes, byteOrder = 'big') {
+  return applyByteOrder(bytes, byteOrder).reduce((value, byte) => ((value * 0x100) + (Number(byte) & 0xFF)), 0)
 }
 
-function bytesToSignedInteger(bytes) {
-  const unsignedValue = bytesToUnsignedInteger(bytes)
+function bytesToSignedInteger(bytes, byteOrder = 'big') {
+  const unsignedValue = bytesToUnsignedInteger(bytes, byteOrder)
   const signLimit = Math.pow(2, bytes.length * 8 - 1)
   const fullRange = Math.pow(2, bytes.length * 8)
 

+ 44 - 0
domain/protocol-mode.js

@@ -0,0 +1,44 @@
+const PROTOCOL_MODE = {
+  MODBUS_RTU: 'modbus-rtu',
+  NONE: 'none',
+  STORAGE_ACCESS: 'storage-access'
+}
+
+const PROTOCOL_OPTIONS = [
+  { key: PROTOCOL_MODE.NONE, label: '无协议' },
+  { key: PROTOCOL_MODE.STORAGE_ACCESS, label: '存储访问' },
+  { key: PROTOCOL_MODE.MODBUS_RTU, label: '标准Modbus' }
+]
+
+const DEFAULT_PROTOCOL_MODE = PROTOCOL_MODE.STORAGE_ACCESS
+
+function normalizeProtocolMode(value, fallback = DEFAULT_PROTOCOL_MODE) {
+  const key = String(value || '').trim()
+  const matched = PROTOCOL_OPTIONS.find((option) => option.key === key)
+
+  if (matched) return matched.key
+
+  return fallback
+}
+
+function isModbusProtocolMode(value) {
+  return normalizeProtocolMode(value) === PROTOCOL_MODE.MODBUS_RTU
+}
+
+function isStorageAccessProtocolMode(value) {
+  return normalizeProtocolMode(value) === PROTOCOL_MODE.STORAGE_ACCESS
+}
+
+function isNoProtocolMode(value) {
+  return normalizeProtocolMode(value) === PROTOCOL_MODE.NONE
+}
+
+module.exports = {
+  DEFAULT_PROTOCOL_MODE,
+  PROTOCOL_MODE,
+  PROTOCOL_OPTIONS,
+  isModbusProtocolMode,
+  isNoProtocolMode,
+  isStorageAccessProtocolMode,
+  normalizeProtocolMode
+}

+ 332 - 80
domain/storage-access/code-info-parser.js

@@ -4,14 +4,53 @@ const {
   trimTrailingNullBytes
 } = require('../../utils/binary-utils.js')
 
-const FIXED_HEADER_BYTE_LENGTH = 44
-const STRUCT_ENTRY_MIN_BYTE_LENGTH = 5
+const CODE_INFO_TLV_HEADER_BYTE_LENGTH = 2
+const CODE_INFO_TLV = {
+  CAVE_FREQ: 0x01,
+  REF_VOLT: 0x02,
+  AMP_GAIN: 0x03,
+  RS_SHUNT: 0x04,
+  BUS_DIV: 0x05,
+  ALONG_DIV: 0x06,
+  CHIP_MODEL: 0x07,
+  MODEL: 0x08,
+  STRUCT_ENTRY16: 0x20,
+  VARIABLE_ENTRY16: 0x21,
+  STRUCT_ENTRY32: 0x28,
+  VARIABLE_ENTRY32: 0x29
+}
+const CODE_INFO_TLV_NAMES = {
+  [CODE_INFO_TLV.CAVE_FREQ]: 'cave_freq',
+  [CODE_INFO_TLV.REF_VOLT]: 'ref_volt',
+  [CODE_INFO_TLV.AMP_GAIN]: 'amp_gain',
+  [CODE_INFO_TLV.RS_SHUNT]: 'rs_shunt',
+  [CODE_INFO_TLV.BUS_DIV]: 'bus_div',
+  [CODE_INFO_TLV.ALONG_DIV]: 'along_div',
+  [CODE_INFO_TLV.CHIP_MODEL]: 'chip_model',
+  [CODE_INFO_TLV.MODEL]: 'model',
+  [CODE_INFO_TLV.STRUCT_ENTRY16]: 'struct_entry16',
+  [CODE_INFO_TLV.VARIABLE_ENTRY16]: 'variable_entry16',
+  [CODE_INFO_TLV.STRUCT_ENTRY32]: 'struct_entry32',
+  [CODE_INFO_TLV.VARIABLE_ENTRY32]: 'variable_entry32'
+}
+const CODE_INFO_ENTRY_KIND = {
+  STRUCT: 0x00,
+  VARIABLE: 0x01
+}
+const CODE_INFO_ENTRY_KIND_TEXT = {
+  [CODE_INFO_ENTRY_KIND.STRUCT]: 'struct',
+  [CODE_INFO_ENTRY_KIND.VARIABLE]: 'variable'
+}
 const MEMORY_TYPE_AREAS = {
   0x01: 'DATA',
   0x02: 'IDATA',
   0x03: 'XDATA',
   0x04: 'CODE'
 }
+const MEMORY_ENDIAN = {
+  BIG: 'big',
+  LITTLE: 'little'
+}
 
 function toBytes(bytes) {
   return Array.prototype.slice.call(bytes || []).map((byte) => Number(byte) & 0xFF)
@@ -23,6 +62,17 @@ function readUint16(bytes, offset) {
   return (((bytes[offset] || 0) << 8) | (bytes[offset + 1] || 0)) & 0xFFFF
 }
 
+function readUint32(bytes, offset) {
+  if (offset + 3 >= bytes.length) return 0
+
+  return (
+    ((bytes[offset] || 0) * 0x1000000)
+    + (((bytes[offset + 1] || 0) << 16) >>> 0)
+    + (((bytes[offset + 2] || 0) << 8) >>> 0)
+    + (bytes[offset + 3] || 0)
+  ) >>> 0
+}
+
 function readFloat(bytes, offset) {
   if (offset + 3 >= bytes.length) return 0
 
@@ -35,21 +85,43 @@ function readFloat(bytes, offset) {
   return view.getFloat32(0, false)
 }
 
-function readAscii(bytes, offset, byteLength) {
-  const source = trimTrailingNullBytes(bytes.slice(offset, offset + byteLength))
+function readTlvText(bytes) {
+  return bytesToUtf8Text(trimTrailingNullBytes(bytes)).trim()
+}
+
+function normalizeCodeInfoAddressWidth(value) {
+  const numberValue = Number(value)
+  if (numberValue === 16) return 16
+  if (numberValue === 32) return 32
 
-  return source
-    .map((byte) => (byte >= 0x20 && byte <= 0x7E ? String.fromCharCode(byte) : ''))
-    .join('')
-    .trim()
+  throw new Error('CodeInfo 描述符地址长度必须为 16 或 32')
 }
 
-function readUtf8OrAscii(bytes, offset, byteLength) {
-  return bytesToUtf8Text(bytes.slice(offset, offset + byteLength)).trim()
+function getEntryLayout(type) {
+  const entryType = Number(type) & 0xFF
+  const isStructEntry = entryType === CODE_INFO_TLV.STRUCT_ENTRY16 || entryType === CODE_INFO_TLV.STRUCT_ENTRY32
+  const isVariableEntry = entryType === CODE_INFO_TLV.VARIABLE_ENTRY16 || entryType === CODE_INFO_TLV.VARIABLE_ENTRY32
+  if (!isStructEntry && !isVariableEntry) return null
+
+  const addressWidth = entryType === CODE_INFO_TLV.STRUCT_ENTRY32 || entryType === CODE_INFO_TLV.VARIABLE_ENTRY32
+    ? 32
+    : 16
+  const addressByteLength = addressWidth === 16 ? 2 : 4
+
+  return {
+    addressByteLength,
+    addressWidth,
+    entryKind: isStructEntry ? CODE_INFO_ENTRY_KIND.STRUCT : CODE_INFO_ENTRY_KIND.VARIABLE,
+    hasMemoryType: addressWidth === 16,
+    minByteLength: addressWidth === 16 ? 5 : 6
+  }
 }
 
-function formatAddress(address) {
-  return `0x${Number(address || 0).toString(16).toUpperCase().padStart(4, '0')}`
+function formatAddress(address, addressWidth = 32) {
+  const numberValue = Math.max(0, Math.floor(Number(address) || 0))
+  const hexWidth = normalizeCodeInfoAddressWidth(addressWidth) === 16 ? 4 : 8
+
+  return `0x${numberValue.toString(16).toUpperCase().padStart(hexWidth, '0')}`
 }
 
 function normalizeTypeName(value, fallback) {
@@ -58,72 +130,184 @@ function normalizeTypeName(value, fallback) {
   return text || fallback
 }
 
-function parseStructEntry(bytes, offset, entryLength, index) {
-  const byteAddr = readUint16(bytes, offset)
-  const byteLength = readUint16(bytes, offset + 2)
-  const memType = bytes[offset + 4] & 0xFF
-  const nameByteLength = Math.max(0, entryLength - STRUCT_ENTRY_MIN_BYTE_LENGTH)
+function getEntryKindText(entryKind) {
+  return CODE_INFO_ENTRY_KIND_TEXT[entryKind] || 'unknown'
+}
+
+function normalizeMemoryEndian(value) {
+  const text = String(value || '').trim().toLowerCase()
+  if (text === 'little' || text === 'le' || text === '1') return MEMORY_ENDIAN.LITTLE
+
+  return MEMORY_ENDIAN.BIG
+}
+
+function resolveCodeInfoByteLength(sourceLength, options = {}) {
+  const expectedLength = Number(
+    options.codeInfoByteLength === undefined ? options.byteLength : options.codeInfoByteLength
+  )
+
+  if (!Number.isFinite(expectedLength) || expectedLength <= 0) return sourceLength
+  if (sourceLength < expectedLength) {
+    throw new Error('CodeInfo 数据长度小于描述符声明长度')
+  }
+
+  return Math.floor(expectedLength)
+}
+
+function parseMemoryEntry(valueBytes, index, type) {
+  const layout = getEntryLayout(type)
+  if (!layout) return null
+  if (valueBytes.length < layout.minByteLength) {
+    throw new Error('CodeInfo 内存入口 TLV 长度无效')
+  }
+
+  const memType = layout.hasMemoryType ? (valueBytes[0] & 0xFF) : 0
+  const addressOffset = layout.hasMemoryType ? 1 : 0
+  const byteAddr = layout.addressByteLength === 2
+    ? readUint16(valueBytes, addressOffset)
+    : readUint32(valueBytes, addressOffset)
+  const byteLength = readUint16(valueBytes, addressOffset + layout.addressByteLength)
+  const nameOffset = layout.minByteLength
+  const entryKind = layout.entryKind
+  const entryKindText = getEntryKindText(entryKind)
+  const nameByteLength = Math.max(0, valueBytes.length - layout.minByteLength)
   const typeName = normalizeTypeName(
-    readAscii(bytes, offset + STRUCT_ENTRY_MIN_BYTE_LENGTH, nameByteLength),
-    `struct_${index + 1}`
+    readTlvText(valueBytes.slice(nameOffset, nameOffset + nameByteLength)),
+    `${entryKindText === 'variable' ? 'var' : 'struct'}_${index + 1}`
   )
-  const memoryArea = MEMORY_TYPE_AREAS[memType] || 'UNKNOWN'
+  const memoryArea = layout.addressWidth === 32
+    ? 'ADDR32'
+    : (MEMORY_TYPE_AREAS[memType] || 'UNKNOWN')
 
   return {
+    addressByteLength: layout.addressByteLength,
+    addressWidth: layout.addressWidth,
     byteAddr,
     byteLength,
+    entryKind,
+    entryKindText,
     index,
+    isStructEntry: entryKind === CODE_INFO_ENTRY_KIND.STRUCT,
+    isVariableEntry: entryKind === CODE_INFO_ENTRY_KIND.VARIABLE,
     memType,
     memoryArea,
-    sourceAddressText: formatAddress(byteAddr),
+    rawByteLength: valueBytes.length,
+    sourceAddressText: formatAddress(byteAddr, layout.addressWidth),
+    tlvType: Number(type) & 0xFF,
     typeName
   }
 }
 
-function parseCodeInfo(bytes) {
-  const source = toBytes(bytes)
-  if (source.length < FIXED_HEADER_BYTE_LENGTH) {
-    throw new Error('info 信息块长度不足,无法解析 Modbus_Code_Info_t 固定头')
+function applyCodeInfoTlvValue(target, type, valueBytes) {
+  switch (type) {
+    case CODE_INFO_TLV.CAVE_FREQ:
+      if (valueBytes.length < 1) throw new Error('CodeInfo cave_freq TLV 长度无效')
+      target.caveFreq = valueBytes[0] & 0xFF
+      break
+    case CODE_INFO_TLV.REF_VOLT:
+      if (valueBytes.length < 1) throw new Error('CodeInfo ref_volt TLV 长度无效')
+      target.refVoltRaw = valueBytes[0] & 0xFF
+      target.refVolt = target.refVoltRaw / 10
+      break
+    case CODE_INFO_TLV.AMP_GAIN:
+      if (valueBytes.length < 1) throw new Error('CodeInfo amp_gain TLV 长度无效')
+      target.ampGain = valueBytes[0] & 0xFF
+      break
+    case CODE_INFO_TLV.RS_SHUNT:
+      if (valueBytes.length < 2) throw new Error('CodeInfo rs_shunt TLV 长度无效')
+      target.rsShunt = readUint16(valueBytes, 0)
+      break
+    case CODE_INFO_TLV.BUS_DIV:
+      if (valueBytes.length < 4) throw new Error('CodeInfo bus_div TLV 长度无效')
+      target.busDiv = readFloat(valueBytes, 0)
+      break
+    case CODE_INFO_TLV.ALONG_DIV:
+      if (valueBytes.length < 4) throw new Error('CodeInfo along_div TLV 长度无效')
+      target.alongDiv = readFloat(valueBytes, 0)
+      break
+    case CODE_INFO_TLV.CHIP_MODEL:
+      target.chipModel = readTlvText(valueBytes)
+      break
+    case CODE_INFO_TLV.MODEL:
+      target.model = readTlvText(valueBytes)
+      break
+    default:
+      break
   }
+}
 
-  const byteLength = readUint16(source, 0)
-  const structCount = readUint16(source, 40)
-  const structEntryLength = readUint16(source, 42)
-  if (structEntryLength < STRUCT_ENTRY_MIN_BYTE_LENGTH) {
-    throw new Error('info 信息块 struct_entry_len 无效')
+function parseCodeInfoTlvs(bytes) {
+  const info = {
+    entries: [],
+    tlvItems: []
   }
+  let offset = 0
 
-  const availableTableBytes = Math.max(0, source.length - FIXED_HEADER_BYTE_LENGTH)
-  const tableCapacity = Math.floor(availableTableBytes / structEntryLength)
-  const safeStructCount = Math.min(structCount, tableCapacity)
-  const structTable = []
-
-  for (let index = 0; index < safeStructCount; index += 1) {
-    structTable.push(parseStructEntry(
-      source,
-      FIXED_HEADER_BYTE_LENGTH + index * structEntryLength,
-      structEntryLength,
-      index
-    ))
+  while (offset < bytes.length) {
+    if (offset + CODE_INFO_TLV_HEADER_BYTE_LENGTH > bytes.length) {
+      throw new Error('CodeInfo TLV 项头长度无效')
+    }
+
+    const type = bytes[offset] & 0xFF
+    const byteLength = bytes[offset + 1] & 0xFF
+    const valueOffset = offset + CODE_INFO_TLV_HEADER_BYTE_LENGTH
+    const nextOffset = valueOffset + byteLength
+    if (nextOffset > bytes.length) {
+      throw new Error('CodeInfo TLV 项长度超出声明范围')
+    }
+
+    const valueBytes = bytes.slice(valueOffset, nextOffset)
+    const item = {
+      byteLength,
+      name: CODE_INFO_TLV_NAMES[type] || `tlv_0x${type.toString(16).toUpperCase().padStart(2, '0')}`,
+      rawHex: bytesToHex(valueBytes, ' '),
+      type
+    }
+    info.tlvItems.push(item)
+    const entry = parseMemoryEntry(valueBytes, info.entries.length, type)
+    if (entry) {
+      info.entries.push(entry)
+    } else {
+      applyCodeInfoTlvValue(info, type, valueBytes)
+    }
+    offset = nextOffset
   }
 
+  return info
+}
+
+function parseCodeInfo(bytes, options = {}) {
+  const inputBytes = toBytes(bytes)
+  const codeInfoByteLength = resolveCodeInfoByteLength(inputBytes.length, options)
+  const source = inputBytes.slice(0, codeInfoByteLength)
+
+  const addressWidth = normalizeCodeInfoAddressWidth(options.addressWidth)
+  const maxPacketLength = Number(options.maxPacketLength || 0) & 0xFFFF
+  const tlvInfo = parseCodeInfoTlvs(source)
+  const entries = tlvInfo.entries
+  const entryCount = entries.length
+  const entryAddressByteLengths = entries
+    .map((entry) => Number(entry.addressByteLength) || 0)
+    .filter((value, index, list) => value > 0 && list.indexOf(value) === index)
+
   return {
-    alongDiv: readFloat(source, 12),
-    ampGain: readUint16(source, 4),
-    busDiv: readFloat(source, 8),
-    byteLength,
-    caveFreq: source[2] & 0xFF,
-    chipModel: readAscii(source, 16, 8),
-    model: readUtf8OrAscii(source, 24, 16),
+    ...tlvInfo,
+    addressWidth,
+    addressWidthRaw: addressWidth,
+    byteLength: codeInfoByteLength,
+    entryCount,
+    maxPacketLength,
+    memoryEndian: normalizeMemoryEndian(options.memoryEndian),
+    memoryEndianRaw: Number(options.memoryEndianRaw || options.memoryEndianMark || 0) & 0xFFFF,
     rawHex: bytesToHex(source, ' '),
-    refVolt: (source[3] & 0xFF) / 10,
-    refVoltRaw: source[3] & 0xFF,
-    rsShunt: readUint16(source, 6),
-    structCount,
-    structEntryLength,
-    structTable,
-    tableCapacity,
-    truncated: safeStructCount < structCount
+    structEntryAddressByteLength: entryAddressByteLengths.length === 1 ? entryAddressByteLengths[0] : 0,
+    structCount: entryCount,
+    structTable: entries,
+    tableByteLength: entries.reduce((total, entry) => total + Number(entry.rawByteLength || 0), 0),
+    tableCapacity: entryCount,
+    tableRemainderByteLength: 0,
+    tlvByteLength: source.length,
+    truncated: false
   }
 }
 
@@ -137,9 +321,12 @@ function createRegistersForByteSpan(entry) {
       dataType: 'uint8_t',
       isPlaceholderByteField: true,
       name: offset.toString(16).toUpperCase().padStart(2, '0'),
-      sourceAddress: (entry.byteAddr + offset) & 0xFFFF,
-      sourceAddressText: formatAddress((entry.byteAddr + offset) & 0xFFFF),
+      sourceAddress: entry.byteAddr + offset,
+      sourceAddressByteLength: entry.addressByteLength,
+      sourceAddressText: formatAddress(entry.byteAddr + offset, entry.addressWidth),
+      sourceAddressWidth: entry.addressWidth,
       sourceByteLength: 1,
+      sourceEntryKind: entry.entryKindText,
       sourceMemoryArea: entry.memoryArea,
       sourceMemoryClass: entry.memoryArea,
       sourceSymbolName: entry.typeName,
@@ -150,40 +337,101 @@ function createRegistersForByteSpan(entry) {
   return registers
 }
 
+function inferVariableDataType(byteLength) {
+  const length = Number(byteLength)
+  if (length === 1) return 'uint8_t'
+  if (length === 2) return 'uint16_t'
+  if (length === 4) return 'uint32_t'
+
+  return ''
+}
+
+function createVariableRegisters(entry) {
+  const dataType = inferVariableDataType(entry.byteLength)
+  if (!dataType) return createRegistersForByteSpan(entry)
+
+  return [{
+    byteStart: 0,
+    dataType,
+    name: entry.typeName,
+    sourceAddress: entry.byteAddr,
+    sourceAddressByteLength: entry.addressByteLength,
+    sourceAddressText: formatAddress(entry.byteAddr, entry.addressWidth),
+    sourceAddressWidth: entry.addressWidth,
+    sourceByteLength: entry.byteLength,
+    sourceEntryKind: entry.entryKindText,
+    sourceMemoryArea: entry.memoryArea,
+    sourceMemoryClass: entry.memoryArea,
+    sourceSymbolName: entry.typeName,
+    sourceSymbolType: entry.typeName
+  }]
+}
+
+function createCodeInfoContext(codeInfo = {}) {
+  return {
+    addressWidth: codeInfo.addressWidth,
+    codeInfoAddressWidth: codeInfo.addressWidth,
+    maxPacketLength: codeInfo.maxPacketLength,
+    memoryEndian: codeInfo.memoryEndian,
+    storageAddressWidth: codeInfo.addressWidth
+  }
+}
+
+function createSourceMeta(entry, byteLength) {
+  return {
+    sourceAddress: entry.byteAddr,
+    sourceAddressByteLength: entry.addressByteLength,
+    sourceAddressText: formatAddress(entry.byteAddr, entry.addressWidth),
+    sourceAddressWidth: entry.addressWidth,
+    sourceByteLength: byteLength,
+    sourceEntryKind: entry.entryKindText,
+    sourceMemoryArea: entry.memoryArea,
+    sourceMemoryClass: entry.memoryArea,
+    sourceSymbolName: entry.typeName,
+    sourceSymbolType: entry.typeName
+  }
+}
+
+function createGroupFromEntry(entry, chunkLength, suffix = '', codeInfo = {}) {
+  const isStructEntry = entry.entryKind === CODE_INFO_ENTRY_KIND.STRUCT
+  const registers = isStructEntry
+    ? createRegistersForByteSpan(entry)
+    : createVariableRegisters(entry)
+
+  return {
+    addressUnit: 'byte',
+    codeInfoContext: createCodeInfoContext(codeInfo),
+    layout: isStructEntry ? 'struct' : 'register',
+    name: `${entry.typeName}${suffix}`,
+    quantity: registers.length,
+    registerType: entry.memoryArea === 'CODE' ? 'input' : 'holding',
+    registers,
+    ...createSourceMeta(entry, chunkLength),
+    sourceSegment: 'CodeInfo TLV',
+    sourceSegmentModule: '',
+    startAddress: formatAddress(entry.byteAddr, entry.addressWidth)
+  }
+}
+
 function createGroupsFromCodeInfo(codeInfo, options = {}) {
   const maxRegisters = Math.max(1, Number(options.maxRegistersPerGroup) || 256)
   const groups = []
+  const entries = Array.isArray(codeInfo.entries) ? codeInfo.entries : codeInfo.structTable
 
-  codeInfo.structTable.forEach((entry) => {
+  ;(Array.isArray(entries) ? entries : []).forEach((entry) => {
     if (!entry.byteLength || entry.memoryArea === 'UNKNOWN') return
+    if (!entry.isStructEntry && !entry.isVariableEntry) return
 
     for (let offset = 0; offset < entry.byteLength; offset += maxRegisters) {
       const chunkLength = Math.min(maxRegisters, entry.byteLength - offset)
       const chunkEntry = {
         ...entry,
-        byteAddr: (entry.byteAddr + offset) & 0xFFFF,
+        byteAddr: entry.byteAddr + offset,
         byteLength: chunkLength
       }
       const suffix = entry.byteLength > maxRegisters ? ` #${Math.floor(offset / maxRegisters) + 1}` : ''
 
-      groups.push({
-        addressUnit: 'byte',
-        layout: 'struct',
-        name: `${entry.typeName}${suffix}`,
-        quantity: chunkLength,
-        registerType: entry.memoryArea === 'CODE' ? 'input' : 'holding',
-        registers: createRegistersForByteSpan(chunkEntry),
-        sourceAddress: chunkEntry.byteAddr,
-        sourceAddressText: formatAddress(chunkEntry.byteAddr),
-        sourceByteLength: chunkLength,
-        sourceMemoryArea: entry.memoryArea,
-        sourceMemoryClass: entry.memoryArea,
-        sourceSegment: 'Modbus_Code_Info_t',
-        sourceSegmentModule: '',
-        sourceSymbolName: entry.typeName,
-        sourceSymbolType: entry.typeName,
-        startAddress: formatAddress(chunkEntry.byteAddr)
-      })
+      groups.push(createGroupFromEntry(chunkEntry, chunkLength, suffix, codeInfo))
     }
   })
 
@@ -191,7 +439,11 @@ function createGroupsFromCodeInfo(codeInfo, options = {}) {
 }
 
 module.exports = {
-  FIXED_HEADER_BYTE_LENGTH,
+  CODE_INFO_ENTRY_KIND,
+  CODE_INFO_TLV_HEADER_BYTE_LENGTH,
+  CODE_INFO_TLV,
+  MEMORY_ENDIAN,
   createGroupsFromCodeInfo,
+  normalizeCodeInfoAddressWidth,
   parseCodeInfo
 }

+ 0 - 3
domain/storage-access/index.js

@@ -1,3 +0,0 @@
-module.exports = {
-  codeInfoParser: require('./code-info-parser.js')
-}

+ 0 - 5
features/bootloader/index.js

@@ -1,5 +0,0 @@
-module.exports = {
-  firmware: require('./firmware.js'),
-  service: require('./service.js'),
-  transport: require('./transport.js')
-}

+ 1 - 1
features/bootloader/service.js

@@ -233,7 +233,7 @@ async function handshakeUntilReady() {
   }
 }
 
-async function chooseFirmwareFile(source = 'message') {
+async function chooseFirmwareFile(source = 'auto') {
   if (state.isBootloaderBusy) return false
 
   try {

+ 15 - 4
features/communication/index.js

@@ -1,9 +1,20 @@
 const service = require('./service.js')
 const viewModel = require('./view-model.js')
+const manualRtuService = require('./manual-rtu.js')
 
 module.exports = {
-  service,
-  viewModel,
-  ...service,
-  ...viewModel
+  manualRtuService,
+  decorateLogs: viewModel.decorateLogs,
+  getLogModeToggleText: viewModel.getLogModeToggleText,
+  getManualStatePayload: viewModel.getManualStatePayload,
+  getNextLogMode: viewModel.getNextLogMode,
+  getNextSerialMode: viewModel.getNextSerialMode,
+  getProtocolModeState: viewModel.getProtocolModeState,
+  getStorageAccessAreaOptions: viewModel.getStorageAccessAreaOptions,
+  normalizeSerialState: viewModel.normalizeSerialState,
+  normalizeStorageAccessSpecialState: viewModel.normalizeStorageAccessSpecialState,
+  normalizeStorageAccessState: viewModel.normalizeStorageAccessState,
+  executeStorageAccessProtocol: service.executeStorageAccessProtocol,
+  executeStorageAccessSpecialCommand: service.executeStorageAccessSpecialCommand,
+  sendSerialFrame: service.sendSerialFrame
 }

+ 612 - 0
features/communication/manual-rtu.js

@@ -0,0 +1,612 @@
+const transport = require('../../transport/ble-core.js')
+const {
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  buildWriteSingleRegisterFrame,
+  formatHex
+} = require('../../protocols/modbus-rtu/index.js')
+const {
+  parseHexNumber
+} = require('../../utils/validation.js')
+const {
+  DATA_TYPE_OPTIONS,
+  getDataType,
+  getRegisterEncodedWords,
+  isByteRegister,
+  isTextRegister,
+  normalizeRegister,
+  validateRegisterValue
+} = require('../../domain/parameter-groups/model.js')
+
+const MODBUS_COMMANDS = [
+  { key: 'readCoils', label: '01 读取线圈', functionCode: 0x01, inputMode: 'quantity' },
+  { key: 'readDiscreteInputs', label: '02 读取离散输入', functionCode: 0x02, inputMode: 'quantity' },
+  { key: 'readHolding', label: '03 读取保持寄存器', functionCode: 0x03, inputMode: 'quantity' },
+  { key: 'readInput', label: '04 读取输入寄存器', functionCode: 0x04, inputMode: 'quantity' },
+  { key: 'writeCoil', label: '05 写单线圈', functionCode: 0x05, inputMode: 'coil' },
+  { key: 'writeRegister', label: '06 写单寄存器', functionCode: 0x06, inputMode: 'single' },
+  { key: 'writeRegisters', label: '10 写多寄存器', functionCode: 0x10, inputMode: 'multiple' }
+]
+
+const state = {
+  commandIndex: 2,
+  commandRegisterQuantity: '0001',
+  commandValue: '0001',
+  commandValueLabel: '读取数量',
+  coilEnabled: true,
+  generatedHex: '',
+  protocolCommands: MODBUS_COMMANDS,
+  protocolDataTypeOptions: DATA_TYPE_OPTIONS,
+  protocolErrorText: '',
+  protocolMultipleDialog: {
+    visible: false
+  },
+  protocolMultipleExpanded: false,
+  protocolMultipleValues: [],
+  protocolResponseText: '',
+  protocolStatusText: '',
+  registerAddress: '0000',
+  showCoilValue: false,
+  showCommandValue: true,
+  showRegisterQuantity: false,
+  slaveAddress: 'F0'
+}
+
+const subscribers = []
+
+function parseRegisterValues(value) {
+  const text = String(value || '').trim()
+  if (!text) throw new Error('请输入寄存器写入值')
+
+  return text.split(/[\s,;]+/)
+    .filter(Boolean)
+    .map((item) => parseHexNumber(item, '写入值', 0xFFFF))
+}
+
+function getCommand(index) {
+  return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
+}
+
+function getDefaultCommandValue(command) {
+  if (command.inputMode === 'quantity') return '0001'
+  if (command.inputMode === 'coil') return 'ON'
+  if (command.inputMode === 'multiple') return '0000'
+
+  return '0000'
+}
+
+function generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity) {
+  const slave = parseHexNumber(slaveAddress, '从站地址', 0xFF)
+  const address = parseHexNumber(registerAddress, '起始地址', 0xFFFF)
+
+  if (command.inputMode === 'quantity') {
+    const quantity = parseHexNumber(commandValue, '读取数量', 0xFFFF)
+    return buildReadFrame(slave, command.functionCode, address, quantity)
+  }
+  if (command.inputMode === 'coil') {
+    return buildWriteSingleCoilFrame(slave, address, coilEnabled)
+  }
+  if (command.inputMode === 'single') {
+    return buildWriteSingleRegisterFrame(slave, address, parseHexNumber(commandValue, '写入值', 0xFFFF))
+  }
+
+  const words = parseRegisterValues(commandValue)
+  const quantity = parseHexNumber(commandRegisterQuantity, '寄存器个数', 0xFFFF)
+  if (quantity !== words.length) {
+    throw new Error(`写入值数量应为 ${quantity} 个寄存器`)
+  }
+
+  return buildWriteMultipleRegistersFrame(slave, address, words)
+}
+
+function createProtocolState(commandIndex, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity) {
+  const command = getCommand(commandIndex)
+  const commandValueLabel = command.inputMode === 'quantity' ? '读取数量' : '写入值'
+
+  try {
+    return {
+      commandValueLabel,
+      generatedHex: formatHex(generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity)),
+      protocolErrorText: '',
+      showCoilValue: command.inputMode === 'coil',
+      showRegisterQuantity: command.inputMode === 'multiple',
+      showCommandValue: command.inputMode !== 'coil'
+    }
+  } catch (error) {
+    return {
+      commandValueLabel,
+      generatedHex: '',
+      protocolErrorText: error.message,
+      showCoilValue: command.inputMode === 'coil',
+      showRegisterQuantity: command.inputMode === 'multiple',
+      showCommandValue: command.inputMode !== 'coil'
+    }
+  }
+}
+
+function buildExpectedResponse(sourceState = {}) {
+  try {
+    const command = getCommand(sourceState.commandIndex)
+    const address = parseHexNumber(sourceState.registerAddress, '起始地址', 0xFFFF)
+    const slaveAddress = parseHexNumber(sourceState.slaveAddress, '从站地址', 0xFF)
+    const quantity = command.inputMode === 'quantity'
+      ? parseHexNumber(sourceState.commandValue, '读取数量', 0xFFFF)
+      : (command.inputMode === 'multiple' ? parseHexNumber(sourceState.commandRegisterQuantity, '寄存器个数', 0xFFFF) : 1)
+    const value = command.inputMode === 'coil'
+      ? (sourceState.coilEnabled ? 0xFF00 : 0x0000)
+      : (command.inputMode === 'single' ? parseHexNumber(sourceState.commandValue, '写入值', 0xFFFF) : undefined)
+
+    return {
+      address,
+      functionCode: command.functionCode,
+      kind: 'manual-rtu',
+      protocol: 'modbus-rtu',
+      quantity,
+      value,
+      slaveAddress
+    }
+  } catch (error) {
+    return null
+  }
+}
+
+function formatResponseHex(value, width) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(width, '0')
+}
+
+function formatGeneratedResponse(response) {
+  if (!response) return ''
+
+  const slave = formatResponseHex(response.slaveAddress, 2)
+  const functionCode = formatResponseHex(response.functionCode, 2)
+
+  if (Array.isArray(response.words) && response.words.length) {
+    return `从机 0x${slave} / 功能码 0x${functionCode} / 字 ${response.words.map((word) => formatResponseHex(word, 4)).join(' ')}`
+  }
+
+  if (Array.isArray(response.dataBytes) && response.dataBytes.length) {
+    return `从机 0x${slave} / 功能码 0x${functionCode} / 数据 ${formatHex(response.dataBytes)}`
+  }
+
+  if (Number.isInteger(response.address)) {
+    return `从机 0x${slave} / 功能码 0x${functionCode} / 地址 0x${formatResponseHex(response.address, 4)} / 值 0x${formatResponseHex(response.quantityOrValue, 4)}`
+  }
+
+  return `从机 0x${slave} / 功能码 0x${functionCode}`
+}
+
+function normalizeManualMultipleQuantity(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return 1
+  if (/^[0-9a-fA-F]+$/.test(text)) return Math.max(1, Math.min(parseInt(text, 16), 0x007B))
+
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? Math.max(1, Math.min(Math.round(numberValue), 0x007B)) : 1
+}
+
+function formatManualMultipleQuantity(quantity) {
+  return Number(quantity || 1).toString(16).toUpperCase().padStart(4, '0')
+}
+
+function createManualMultipleRegister(index, value = {}) {
+  const dataType = getDataType(value.dataType || 'hex')
+  const register = normalizeRegister({
+    dataType: dataType.key,
+    inputValue: value.inputValue === undefined ? '' : value.inputValue,
+    name: `寄存器 ${index + 1}`,
+    textByteLength: value.textByteLength || (isTextRegister(dataType.key) ? '32' : '')
+  }, {
+    registerType: 'holding'
+  }, index, Number(value.address || 0), 0)
+
+  return {
+    ...register,
+    dataTypeIndex: DATA_TYPE_OPTIONS.findIndex((item) => item.key === register.dataType),
+    inputValue: value.inputValue === undefined ? '' : value.inputValue
+  }
+}
+
+function getManualRegisterWordCount(register) {
+  return Math.max(1, Number(register && register.registerCount) || 1)
+}
+
+function normalizeManualMultipleValues(wordQuantity, values = [], startAddress = 0) {
+  const result = []
+  let address = Number(startAddress) || 0
+  const endAddress = address + Math.max(1, Number(wordQuantity) || 1)
+  let sourceIndex = 0
+
+  while (address < endAddress) {
+    const current = values[sourceIndex] || {}
+    let register = createManualMultipleRegister(result.length, {
+      ...current,
+      address
+    })
+    const remainingWords = endAddress - address
+    if (getManualRegisterWordCount(register) > remainingWords) {
+      register = createManualMultipleRegister(result.length, {
+        ...current,
+        address,
+        dataType: 'hex',
+        inputValue: ''
+      })
+    }
+    result.push(register)
+    address += getManualRegisterWordCount(register)
+    sourceIndex += 1
+  }
+
+  return result
+}
+
+function getManualMultipleWords(values = []) {
+  const words = []
+  values.forEach((register) => {
+    if (isByteRegister(register.dataType)) {
+      const registerWords = getRegisterEncodedWords(register)
+      if (!Array.isArray(registerWords) || !registerWords.length) throw new Error(`${register.name} 输入值无效`)
+      words.push(Number(registerWords[0]) & 0x00FF)
+      return
+    }
+
+    const registerWords = getRegisterEncodedWords(register)
+    if (!Array.isArray(registerWords) || !registerWords.length) throw new Error(`${register.name} 输入值无效`)
+    registerWords.forEach((word) => words.push(Number(word) & 0xFFFF))
+  })
+
+  return words
+}
+
+function getManualMultipleValueText(values = []) {
+  try {
+    return getManualMultipleWords(values).map((word) => word.toString(16).toUpperCase().padStart(4, '0')).join(' ')
+  } catch (error) {
+    return ''
+  }
+}
+
+function getManualMultipleDataType(dataTypeIndex) {
+  return DATA_TYPE_OPTIONS[Number(dataTypeIndex)] || DATA_TYPE_OPTIONS[0]
+}
+
+function updateManualMultipleValue(values = [], index, value) {
+  const registerIndex = Number(index)
+
+  return values.map((register, currentIndex) => (
+    currentIndex === registerIndex
+      ? {
+        ...register,
+        inputValue: value
+      }
+      : register
+  ))
+}
+
+function updateManualMultipleType(values = [], index, dataTypeIndex) {
+  const registerIndex = Number(index)
+  const dataType = getManualMultipleDataType(dataTypeIndex)
+
+  return values.map((register, currentIndex) => (
+    currentIndex === registerIndex
+      ? createManualMultipleRegister(currentIndex, {
+        ...register,
+        dataType: dataType.key,
+        inputValue: '',
+        textByteLength: isTextRegister(dataType.key) ? (register.textByteLength || '32') : ''
+      })
+      : register
+  ))
+}
+
+function updateManualMultipleTextLength(values = [], index, value) {
+  const registerIndex = Number(index)
+
+  return values.map((register, currentIndex) => (
+    currentIndex === registerIndex
+      ? createManualMultipleRegister(currentIndex, {
+        ...register,
+        textByteLength: value
+      })
+      : register
+  ))
+}
+
+function validateManualMultipleValue(values = [], index, value) {
+  const register = values[Number(index)]
+  if (!register) return false
+
+  return validateRegisterValue(register, value)
+}
+
+function setState(changedData) {
+  Object.assign(state, changedData)
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(getState())
+  })
+}
+
+function getState() {
+  return {
+    ...state,
+    protocolCommands: state.protocolCommands.slice(),
+    protocolDataTypeOptions: state.protocolDataTypeOptions.slice(),
+    protocolMultipleDialog: {
+      ...state.protocolMultipleDialog
+    },
+    protocolMultipleExpanded: !!state.protocolMultipleExpanded,
+    protocolMultipleValues: state.protocolMultipleValues.map((item) => ({
+      ...item
+    })),
+    protocolStatusText: state.protocolStatusText || ''
+  }
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+function setProtocolInput(changedData) {
+  const command = getCommand(changedData.commandIndex === undefined ? state.commandIndex : changedData.commandIndex)
+  let nextMultipleValues = changedData.protocolMultipleValues
+  let nextCommandValue = changedData.commandValue
+  if (
+    command.inputMode === 'multiple'
+    && Object.prototype.hasOwnProperty.call(changedData, 'registerAddress')
+    && !Object.prototype.hasOwnProperty.call(changedData, 'protocolMultipleValues')
+  ) {
+    try {
+      const startAddress = parseHexNumber(changedData.registerAddress, '起始地址', 0xFFFF)
+      nextMultipleValues = normalizeManualMultipleValues(
+        normalizeManualMultipleQuantity(state.commandRegisterQuantity),
+        state.protocolMultipleValues,
+        startAddress
+      )
+      nextCommandValue = getManualMultipleValueText(nextMultipleValues)
+    } catch (error) {}
+  }
+  const nextState = {
+    commandIndex: state.commandIndex,
+    commandRegisterQuantity: state.commandRegisterQuantity,
+    slaveAddress: state.slaveAddress,
+    registerAddress: state.registerAddress,
+    commandValue: state.commandValue,
+    coilEnabled: state.coilEnabled,
+    ...changedData,
+    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
+    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {})
+  }
+
+  setState({
+    ...changedData,
+    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
+    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {}),
+    protocolResponseText: '',
+    protocolStatusText: '',
+    ...createProtocolState(
+      nextState.commandIndex,
+      nextState.slaveAddress,
+      nextState.registerAddress,
+      nextState.commandValue,
+      nextState.coilEnabled,
+      nextState.commandRegisterQuantity
+    )
+  })
+}
+
+function setCommandIndex(index) {
+  const commandIndex = Number(index)
+  const command = getCommand(commandIndex)
+  let protocolMultipleValues = state.protocolMultipleValues
+  let commandValue = getDefaultCommandValue(command)
+
+  if (command.inputMode === 'multiple') {
+    let startAddress = 0
+    try {
+      startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+    } catch (error) {}
+    protocolMultipleValues = normalizeManualMultipleValues(
+      normalizeManualMultipleQuantity(state.commandRegisterQuantity),
+      state.protocolMultipleValues,
+      startAddress
+    )
+    commandValue = getManualMultipleValueText(protocolMultipleValues)
+  }
+
+  setProtocolInput({
+    commandIndex,
+    commandValue,
+    coilEnabled: true,
+    commandRegisterQuantity: state.commandRegisterQuantity,
+    ...(command.inputMode === 'multiple' ? { protocolMultipleValues } : {})
+  })
+}
+
+function openProtocolMultipleDialog() {
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({
+      protocolErrorText: error.message || '起始地址无效'
+    })
+    return
+  }
+
+  const quantity = normalizeManualMultipleQuantity(state.commandRegisterQuantity)
+  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
+  setState({
+    commandRegisterQuantity: formatManualMultipleQuantity(quantity),
+    protocolMultipleDialog: {
+      title: '写多个寄存器',
+      visible: true
+    },
+    protocolMultipleExpanded: false,
+    protocolMultipleValues: values
+  })
+}
+
+function closeProtocolMultipleDialog() {
+  setState({
+    protocolMultipleDialog: {
+      visible: false
+    }
+  })
+}
+
+function toggleProtocolMultipleExpanded() {
+  setState({
+    protocolMultipleExpanded: !state.protocolMultipleExpanded
+  })
+}
+
+function setProtocolMultipleQuantity(value) {
+  const quantity = normalizeManualMultipleQuantity(value)
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({
+      commandRegisterQuantity: value,
+      protocolErrorText: error.message || '起始地址无效'
+    })
+    return
+  }
+  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
+  const commandValue = getManualMultipleValueText(values)
+
+  setProtocolInput({
+    commandRegisterQuantity: value,
+    commandValue,
+    protocolMultipleValues: values
+  })
+}
+
+function setProtocolMultipleValue(index, value) {
+  const values = updateManualMultipleValue(state.protocolMultipleValues, index, value)
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const normalizedValues = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), values, startAddress)
+  const commandValue = getManualMultipleValueText(normalizedValues)
+
+  setProtocolInput({
+    commandValue,
+    protocolMultipleValues: normalizedValues
+  })
+}
+
+function setProtocolMultipleType(index, dataTypeIndex) {
+  const changedValues = updateManualMultipleType(state.protocolMultipleValues, index, dataTypeIndex)
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
+
+  setProtocolInput({
+    commandValue: getManualMultipleValueText(values),
+    protocolMultipleValues: values
+  })
+}
+
+function setProtocolMultipleTextLength(index, value) {
+  const changedValues = updateManualMultipleTextLength(state.protocolMultipleValues, index, value)
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
+
+  setProtocolInput({
+    commandValue: getManualMultipleValueText(values),
+    protocolMultipleValues: values
+  })
+}
+
+function validateProtocolMultipleValue(index, value) {
+  return validateManualMultipleValue(state.protocolMultipleValues, index, value)
+}
+
+function buildGeneratedExpectedResponse() {
+  return buildExpectedResponse(state)
+}
+
+async function sendGeneratedFrame() {
+  if (!state.generatedHex) {
+    setState({
+      protocolStatusText: state.protocolErrorText || '请先生成有效帧'
+    })
+    return false
+  }
+
+  const expected = buildGeneratedExpectedResponse()
+  setState({
+    protocolResponseText: '',
+    protocolStatusText: ''
+  })
+
+  const response = await transport.enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
+    expected
+  } : {})
+
+  if (response) {
+    setState({
+      protocolResponseText: formatGeneratedResponse(response),
+      protocolStatusText: ''
+    })
+  } else {
+    setState({
+      protocolStatusText: '未收到回复'
+    })
+  }
+
+  return response
+}
+
+setState(createProtocolState(
+  state.commandIndex,
+  state.slaveAddress,
+  state.registerAddress,
+  state.commandValue,
+  state.coilEnabled,
+  state.commandRegisterQuantity
+))
+
+module.exports = {
+  MODBUS_COMMANDS,
+  buildGeneratedExpectedResponse,
+  closeProtocolMultipleDialog,
+  formatGeneratedResponse,
+  generateModbusFrame,
+  getState,
+  openProtocolMultipleDialog,
+  sendGeneratedFrame,
+  setCommandIndex,
+  setProtocolInput,
+  setProtocolMultipleQuantity,
+  setProtocolMultipleTextLength,
+  setProtocolMultipleType,
+  setProtocolMultipleValue,
+  subscribe,
+  toggleProtocolMultipleExpanded,
+  validateProtocolMultipleValue
+}

+ 23 - 93
features/communication/service.js

@@ -1,28 +1,17 @@
 const storageAccessService = require('../storage-access/service.js')
 const transport = require('../../transport/ble-core.js')
-const {
-  AREA
-} = require('../../protocols/storage-access/index.js')
 const {
   bytesToHex,
   stringToUtf8Bytes
 } = require('../../utils/binary-utils.js')
 const {
-  STORAGE_ACCESS_AREA_OPTIONS,
-  STORAGE_ACCESS_COMMAND_OPTIONS,
-  normalizeSerialState,
   parseHexBytes,
   validateHexText
+} = require('../../utils/validation.js')
+const {
+  normalizeSerialState
 } = require('./view-model.js')
 
-function getStorageAccessCommand(index) {
-  return STORAGE_ACCESS_COMMAND_OPTIONS[Number(index) || 0] || STORAGE_ACCESS_COMMAND_OPTIONS[0]
-}
-
-function getStorageAccessArea(index) {
-  return STORAGE_ACCESS_AREA_OPTIONS[Number(index) || 0] || STORAGE_ACCESS_AREA_OPTIONS[0]
-}
-
 async function sendSerialFrame(data = {}) {
   const serialInputText = String(data.serialInputText || '')
   const serialState = normalizeSerialState(data)
@@ -81,38 +70,7 @@ async function sendSerialFrame(data = {}) {
   }
 }
 
-async function syncStorageAccessCodeInfo(data = {}) {
-  const result = await storageAccessService.syncCodeInfo({
-    maxPacketLength: data.parameterMaxPacketLength,
-    showModal: true
-  })
-
-  if (!result || !result.ok) {
-    return {
-      ok: false
-    }
-  }
-
-  const codeInfoAddressText = result.codeInfoAddressText || Number(result.codeInfoAddress || 0).toString(16).toUpperCase().padStart(4, '0')
-  const codeInfoByteLengthText = result.codeInfoByteLengthText || Number(result.codeInfoByteLength || 0).toString(16).toUpperCase().padStart(4, '0')
-
-  return {
-    codeInfoAddress: result.codeInfoAddress,
-    codeInfoAddressText,
-    codeInfoByteLength: result.codeInfoByteLength,
-    codeInfoByteLengthText,
-    ok: true,
-    syncInfoText: `0x0F info ${codeInfoAddressText}/${codeInfoByteLengthText}`
-  }
-}
-
 async function executeStorageAccessProtocol(data = {}) {
-  const command = getStorageAccessCommand(data.storageAccessCommandIndex)
-  const area = getStorageAccessArea(data.storageAccessAreaIndex)
-  const address = parseInt(data.storageAccessAddress || '0000', 16) || 0
-  const length = parseInt(data.storageAccessLength || '0004', 16) || 0
-  const dataBytes = parseHexBytes(data.storageAccessDataText || '')
-
   if (!data.connectedDevice) {
     return {
       errorText: '请先连接蓝牙设备',
@@ -120,77 +78,49 @@ async function executeStorageAccessProtocol(data = {}) {
     }
   }
 
-  if (data.storageAccessErrorText) {
+  try {
+    return await storageAccessService.executeMemoryCommand(data, {
+      maxPacketLength: data.parameterMaxPacketLength,
+      showModal: true
+    })
+  } catch (error) {
     return {
-      errorText: data.storageAccessErrorText,
+      errorText: error.message || '存储访问命令无效',
       ok: false
     }
   }
+}
 
-  if (command.key === 'sync') {
-    return syncStorageAccessCodeInfo(data)
-  }
-
-  if (command.key === 'write' && (area.key === AREA.CODE || area.key === AREA.INFO)) {
+async function executeStorageAccessSpecialCommand(command = {}, data = {}) {
+  if (!data.connectedDevice) {
     return {
-      errorText: 'code/info 区暂不支持写入',
+      errorText: '请先连接蓝牙设备',
       ok: false
     }
   }
 
-  if (command.key === 'read') {
-    const bytes = await storageAccessService.readMemory(
-      area.key,
-      address,
-      length,
-      '私有协议读取',
-      'communication-storage-read',
-      {
-        maxPacketLength: data.parameterMaxPacketLength,
-        showModal: true
-      }
-    )
-
-    return {
-      bytes,
-      ok: !!bytes,
-      previewHex: bytes ? bytesToHex(bytes, ' ') : ''
-    }
-  }
-
-  const errorText = validateHexText(data.storageAccessDataText)
-  if (errorText) {
+  if (!command || !command.op) {
     return {
-      errorText,
+      errorText: '特殊指令无效',
       ok: false
     }
   }
 
-  if (dataBytes.length !== length) {
+  if (command.key === 'controlRef' && data.storageAccessControlRefErrorText) {
     return {
-      errorText: `写入长度为 ${length} 字节,当前数据为 ${dataBytes.length} 字节`,
+      errorText: data.storageAccessControlRefErrorText,
       ok: false
     }
   }
 
-  const ok = await storageAccessService.writeMemory(
-    area.key,
-    address,
-    dataBytes,
-    '私有协议写入',
-    'communication-storage-write',
-    {
-      maxPacketLength: data.parameterMaxPacketLength,
-      showModal: true
-    }
-  )
-
-  return {
-    ok
-  }
+  return storageAccessService.executeControlCommand(command.key, data, {
+    maxPacketLength: data.parameterMaxPacketLength,
+    showModal: true
+  })
 }
 
 module.exports = {
   executeStorageAccessProtocol,
+  executeStorageAccessSpecialCommand,
   sendSerialFrame
 }

+ 29 - 191
features/communication/view-model.js

@@ -1,89 +1,38 @@
-const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const storageAccessService = require('../storage-access/service.js')
 const settingsService = require('../../store/settings-store.js')
-const {
-  AREA
-} = storageAccessProtocol
 const {
   bytesToHex,
   bytesToUtf8Text,
   stringToUtf8Bytes
 } = require('../../utils/binary-utils.js')
+const {
+  parseHexBytes,
+  validateHexText
+} = require('../../utils/validation.js')
 
 const LOG_MODE_OPTIONS = [
   { key: 'hex', label: 'HEX' },
   { key: 'text', label: '文本' }
 ]
 
-const STORAGE_ACCESS_COMMAND_OPTIONS = [
-  { key: 'sync', label: '同步', description: '读取 info 并同步 code' },
-  { key: 'read', label: '读取', description: '按字节读取内存' },
-  { key: 'write', label: '写入', description: '按字节写入内存' }
-]
-
-const STORAGE_ACCESS_AREA_OPTIONS = [
-  { key: AREA.DATA, label: 'data' },
-  { key: AREA.IDATA, label: 'idata' },
-  { key: AREA.XDATA, label: 'xdata' },
-  { key: AREA.CODE, label: 'code' }
-]
-
-function normalizeHexText(value) {
-  return String(value === undefined || value === null ? '' : value)
-    .replace(/0x/gi, '')
-    .replace(/[\s,;:_-]/g, ' ')
-    .replace(/\s+/g, ' ')
-    .trim()
-    .toUpperCase()
+function getStorageAccessSpecialCommands() {
+  return storageAccessService.getControlCommands()
 }
 
-function validateHexText(value) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  const withoutPrefix = text.replace(/0x/gi, '')
-  const compact = withoutPrefix.replace(/[\s,;:_-]/g, '')
-
-  if (!compact) return '请输入十六进制数据'
-  if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return '只支持十六进制字符'
-  if (compact.length % 2 !== 0) return '十六进制长度必须为偶数'
-
-  return ''
+function getStorageAccessAreaOptions() {
+  return storageAccessService.getMemoryAreaOptions()
 }
 
-function parseHexBytes(value) {
-  const compact = String(value === undefined || value === null ? '' : value)
-    .trim()
-    .replace(/0x/gi, '')
-    .replace(/[\s,;:_-]/g, '')
-  const bytes = []
-
-  for (let index = 0; index < compact.length; index += 2) {
-    bytes.push(parseInt(compact.slice(index, index + 2), 16) & 0xFF)
-  }
-
-  return bytes
-}
-
-function getSerialModeLabel(mode) {
+function getBinaryModeLabel(mode) {
   return mode === 'text' ? '文本' : 'HEX'
 }
 
-function getNextSerialMode(mode) {
+function getNextBinaryMode(mode) {
   return mode === 'hex' ? 'text' : 'hex'
 }
 
-function getSerialModeToggleText(mode) {
-  return getSerialModeLabel(getNextSerialMode(mode))
-}
-
-function getLogModeLabel(mode) {
-  return mode === 'text' ? '文本' : 'HEX'
-}
-
-function getNextLogMode(mode) {
-  return mode === 'hex' ? 'text' : 'hex'
-}
-
-function getLogModeToggleText(mode) {
-  return getLogModeLabel(getNextLogMode(mode))
+function getBinaryModeToggleText(mode) {
+  return getBinaryModeLabel(getNextBinaryMode(mode))
 }
 
 function normalizeSerialState(current = {}, changed = {}) {
@@ -115,133 +64,23 @@ function normalizeSerialState(current = {}, changed = {}) {
     serialErrorText: errorText,
     serialInputText: inputText,
     serialMode: mode,
-    serialModeLabel: getSerialModeLabel(mode),
-    serialModeToggleText: getSerialModeToggleText(mode),
+    serialModeLabel: getBinaryModeLabel(mode),
+    serialModeToggleText: getBinaryModeToggleText(mode),
     serialPreviewHex: previewHex,
     serialPreviewLengthText: bytes.length ? `${bytes.length} bytes` : '--'
   }
 }
 
-function formatAreaLabel(area) {
-  const matched = STORAGE_ACCESS_AREA_OPTIONS.find((item) => item.key === area)
-  return matched ? matched.label : 'data'
-}
-
-function buildStorageAccessPreview(commandMode, area, address, length, dataBytes) {
-  try {
-    if (commandMode === 'sync') {
-      return bytesToHex(storageAccessProtocol.buildInfoFrame(), ' ')
-    }
-
-    if (commandMode === 'write') {
-      return bytesToHex(storageAccessProtocol.buildWriteFrame(area, address, dataBytes), ' ')
-    }
-
-    return bytesToHex(storageAccessProtocol.buildReadFrame(area, address, length), ' ')
-  } catch (error) {
-    return ''
-  }
-}
-
-function normalizeStorageAccessWordText(value, fallback) {
-  const text = String(value === undefined || value === null ? '' : value).trim().replace(/^0x/i, '').toUpperCase()
-  return text || fallback
-}
-
-function validateStorageAccessWordText(value, label) {
-  const text = String(value || '').trim()
-  if (!text) return `${label}请输入十六进制`
-  if (!/^[0-9A-F]+$/i.test(text)) return `${label}只支持十六进制`
-  if (text.length > 4) return `${label}最多 4 位十六进制`
-  return ''
-}
-
-function formatStorageAccessWordText(value) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
-}
-
 function normalizeStorageAccessState(current = {}, changed = {}) {
-  const next = {
-    storageAccessAreaIndex: current.storageAccessAreaIndex || 0,
-    storageAccessAddress: current.storageAccessAddress || '0000',
-    storageAccessCommandIndex: current.storageAccessCommandIndex || 0,
-    storageAccessDataText: current.storageAccessDataText || '',
-    storageAccessLength: current.storageAccessLength || '0004',
-    ...changed
-  }
-
-  const command = STORAGE_ACCESS_COMMAND_OPTIONS[Number(next.storageAccessCommandIndex) || 0] || STORAGE_ACCESS_COMMAND_OPTIONS[0]
-  const area = STORAGE_ACCESS_AREA_OPTIONS[Number(next.storageAccessAreaIndex) || 0] || STORAGE_ACCESS_AREA_OPTIONS[0]
-  const address = normalizeStorageAccessWordText(next.storageAccessAddress, '0000')
-  const length = normalizeStorageAccessWordText(next.storageAccessLength, '0004')
-  const dataText = normalizeHexText(next.storageAccessDataText)
-  const dataBytes = dataText ? parseHexBytes(dataText) : []
-  const previewArea = area.key
-  const previewAddress = parseInt(address || '0', 16)
-  const previewLength = parseInt(length || '0', 16)
-
-  let errorText = ''
-  if (command.key === 'read' || command.key === 'write') {
-    const addressError = validateStorageAccessWordText(address, '地址')
-    const lengthError = validateStorageAccessWordText(length, '长度')
-    if (addressError) {
-      errorText = addressError
-    } else if (lengthError) {
-      errorText = lengthError
-    } else if (previewLength <= 0) {
-      errorText = '长度必须大于 0'
-    } else if (command.key === 'write') {
-      const hexError = validateHexText(next.storageAccessDataText)
-      if (hexError) {
-        errorText = hexError
-      } else if (dataBytes.length !== previewLength) {
-        errorText = `写入长度为 ${previewLength} 字节,当前数据为 ${dataBytes.length} 字节`
-      }
-    }
-  }
-
-  if (command.key === 'sync') {
-    const previewHex = buildStorageAccessPreview('sync', AREA.INFO, 0, 4, [])
-
-    return {
-      storageAccessAddress: '0000',
-      storageAccessAreaIndex: 0,
-      storageAccessAreaLabel: 'info',
-      storageAccessCommandIndex: next.storageAccessCommandIndex,
-      storageAccessCommandLabel: command.label,
-      storageAccessDataText: '',
-      storageAccessErrorText: '',
-      storageAccessGeneratedHex: previewHex,
-      storageAccessLength: '',
-      storageAccessPreviewAreaText: 'info',
-      storageAccessPreviewHexText: previewHex,
-      storageAccessPreviewText: 'info 0x0000 / 4 bytes',
-      storageAccessSendLabel: '同步',
-      storageAccessSyncInfoText: '0x0F 读取 info[0:4]'
-    }
-  }
+  return storageAccessService.normalizeMemoryCommandState(current, changed)
+}
 
-  const previewHex = command.key === 'write'
-    ? buildStorageAccessPreview('write', previewArea, previewAddress, previewLength, dataBytes)
-    : buildStorageAccessPreview('read', previewArea, previewAddress, previewLength, dataBytes)
+function normalizeStorageAccessSpecialState(current = {}, changed = {}) {
+  const controlState = storageAccessService.normalizeControlState(current, changed)
 
   return {
-    storageAccessAddress: address,
-    storageAccessAreaIndex: next.storageAccessAreaIndex,
-    storageAccessAreaLabel: area.label,
-    storageAccessCommandIndex: next.storageAccessCommandIndex,
-    storageAccessCommandLabel: command.label,
-    storageAccessDataText: dataText,
-    storageAccessErrorText: errorText,
-    storageAccessGeneratedHex: errorText ? '' : previewHex,
-    storageAccessLength: length,
-    storageAccessPreviewAreaText: formatAreaLabel(previewArea),
-    storageAccessPreviewHexText: errorText ? '' : previewHex,
-    storageAccessPreviewText: errorText
-      ? '--'
-      : `${formatAreaLabel(previewArea)} 0x${formatStorageAccessWordText(previewAddress)} / ${previewLength} bytes`,
-    storageAccessSendLabel: command.label,
-    storageAccessSyncInfoText: ''
+    storageAccessSpecialCommands: getStorageAccessSpecialCommands(),
+    ...controlState
   }
 }
 
@@ -272,10 +111,12 @@ function decorateLogs(logs, mode) {
 }
 
 function getProtocolModeState(settingsState = {}) {
+  const isNoProtocol = settingsService.isNoProtocol(settingsState.protocolMode)
   const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
   const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
 
   return {
+    isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol
   }
@@ -297,17 +138,14 @@ function getManualStatePayload(manualState = {}) {
 
 module.exports = {
   LOG_MODE_OPTIONS,
-  STORAGE_ACCESS_AREA_OPTIONS,
-  STORAGE_ACCESS_COMMAND_OPTIONS,
   decorateLogs,
-  getLogModeToggleText,
+  getStorageAccessAreaOptions,
+  getLogModeToggleText: getBinaryModeToggleText,
   getManualStatePayload,
-  getNextLogMode,
-  getNextSerialMode,
+  getNextLogMode: getNextBinaryMode,
+  getNextSerialMode: getNextBinaryMode,
   getProtocolModeState,
   normalizeSerialState,
-  normalizeHexText,
-  normalizeStorageAccessState,
-  parseHexBytes,
-  validateHexText
+  normalizeStorageAccessSpecialState,
+  normalizeStorageAccessState
 }

+ 65 - 4
features/home/service.js

@@ -1,12 +1,69 @@
 const transport = require('../../transport/ble-core.js')
 const themeService = require('../../store/theme-store.js')
-const {
-  DEFAULT_DEVICE_FILTER,
-  getHomePageState
-} = require('./view-model.js')
+
+const DEFAULT_DEVICE_FILTER = 'all'
+const DEVICE_FILTER_OPTIONS = [
+  { key: 'all', label: '全部' },
+  { key: 'target', label: '目标' }
+]
 
 let initScheduled = false
 
+function isTargetDevice(device) {
+  return !!(device && (device.isTargetDevice || device.isTargetAdvertised))
+}
+
+function filterDevices(devices, filterMode) {
+  if (filterMode === 'target') return devices.filter(isTargetDevice)
+
+  return devices
+}
+
+function getHomePageState(
+  transportState = transport.getState(),
+  deviceFilterMode = DEFAULT_DEVICE_FILTER,
+  themeState = themeService.getState()
+) {
+  const { connectedDevice } = transportState
+  const filteredDevices = filterDevices(transportState.devices, deviceFilterMode)
+  const allDeviceCount = transportState.devices.length
+  const filteredDeviceCount = filteredDevices.length
+  const connectionStatusText = connectedDevice
+    ? '已连接'
+    : (transportState.isConnecting ? '连接中' : '未连接')
+
+  return {
+    ...transportState,
+    ...themeState,
+    allDeviceCount,
+    canClearDevices: !!allDeviceCount && !transportState.isConnecting,
+    canDisconnectDevice: !!connectedDevice,
+    canStartScan: !transportState.isConnecting,
+    connectionCharacteristicText: connectedDevice ? transportState.characteristicText : '--',
+    connectionDeviceId: connectedDevice ? connectedDevice.deviceId : '--',
+    connectionName: connectedDevice ? connectedDevice.displayName : '',
+    connectionServiceCount: connectedDevice ? transportState.connectedServiceCount : '--',
+    connectionSignalText: connectedDevice
+      ? (transportState.signalText || connectedDevice.signalText || '--')
+      : '--',
+    connectionStatusText,
+    devices: filteredDevices,
+    deviceCountText: allDeviceCount
+      ? (deviceFilterMode === 'target' ? `(${filteredDeviceCount}/${allDeviceCount})` : `(${allDeviceCount})`)
+      : '',
+    deviceFilterMode,
+    deviceFilterOptions: DEVICE_FILTER_OPTIONS,
+    emptyDeviceText: allDeviceCount && deviceFilterMode === 'target'
+      ? '当前扫描结果中没有广播目标 UUID 的设备,可切回全部后连接确认特征值。'
+      : '请确认设备已上电并处于可广播或配网状态。',
+    emptyDeviceTitle: allDeviceCount && deviceFilterMode === 'target'
+      ? '没有匹配目标特征的设备'
+      : '还没有发现设备',
+    scanButtonText: transportState.isDiscovering ? '停止' : '扫描',
+    showDeviceSection: true
+  }
+}
+
 function deferStartupWork(task) {
   if (typeof task !== 'function') return
 
@@ -64,11 +121,15 @@ function toggleScan(isDiscovering) {
 
 module.exports = {
   DEFAULT_DEVICE_FILTER,
+  DEVICE_FILTER_OPTIONS,
   clearDevices: transport.clearDevices,
   connectDeviceById: transport.connectDeviceById,
   disconnectDevice: transport.disconnectDevice,
+  filterDevices,
+  getHomePageState,
   getState,
   init,
+  isTargetDevice,
   subscribeState,
   toggleScan
 }

+ 0 - 69
features/home/view-model.js

@@ -1,69 +0,0 @@
-const transport = require('../../transport/ble-core.js')
-const themeService = require('../../store/theme-store.js')
-
-const DEFAULT_DEVICE_FILTER = 'all'
-const DEVICE_FILTER_OPTIONS = [
-  { key: 'all', label: '全部' },
-  { key: 'target', label: '目标' }
-]
-
-function isTargetDevice(device) {
-  return !!(device && (device.isTargetDevice || device.isTargetAdvertised))
-}
-
-function filterDevices(devices, filterMode) {
-  if (filterMode === 'target') return devices.filter(isTargetDevice)
-
-  return devices
-}
-
-function getHomePageState(
-  transportState = transport.getState(),
-  deviceFilterMode = DEFAULT_DEVICE_FILTER,
-  themeState = themeService.getState()
-) {
-  const { connectedDevice } = transportState
-  const filteredDevices = filterDevices(transportState.devices, deviceFilterMode)
-  const allDeviceCount = transportState.devices.length
-  const filteredDeviceCount = filteredDevices.length
-  const connectionStatusText = connectedDevice
-    ? '已连接'
-    : (transportState.isConnecting ? '连接中' : '未连接')
-
-  return {
-    ...transportState,
-    ...themeState,
-    allDeviceCount,
-    canClearDevices: !!allDeviceCount && !transportState.isConnecting,
-    canDisconnectDevice: !!connectedDevice,
-    canStartScan: !transportState.isConnecting,
-    connectionCharacteristicText: connectedDevice ? transportState.characteristicText : '--',
-    connectionDeviceId: connectedDevice ? connectedDevice.deviceId : '--',
-    connectionName: connectedDevice ? connectedDevice.displayName : '',
-    connectionServiceCount: connectedDevice ? transportState.connectedServiceCount : '--',
-    connectionSignalText: connectedDevice ? connectedDevice.signalText : '--',
-    connectionStatusText,
-    devices: transportState.isDiscovering ? filteredDevices : [],
-    deviceCountText: allDeviceCount
-      ? (deviceFilterMode === 'target' ? `(${filteredDeviceCount}/${allDeviceCount})` : `(${allDeviceCount})`)
-      : '',
-    deviceFilterMode,
-    deviceFilterOptions: DEVICE_FILTER_OPTIONS,
-    emptyDeviceText: allDeviceCount && deviceFilterMode === 'target'
-      ? '当前扫描结果中没有广播目标 UUID 的设备,可切回全部后连接确认特征值。'
-      : '请确认设备已上电并处于可广播或配网状态。',
-    emptyDeviceTitle: allDeviceCount && deviceFilterMode === 'target'
-      ? '没有匹配目标特征的设备'
-      : '还没有发现设备',
-    scanButtonText: transportState.isDiscovering ? '停止' : '扫描',
-    showDeviceSection: transportState.isDiscovering
-  }
-}
-
-module.exports = {
-  DEFAULT_DEVICE_FILTER,
-  DEVICE_FILTER_OPTIONS,
-  filterDevices,
-  getHomePageState,
-  isTargetDevice
-}

+ 0 - 165
features/manual-rtu/frame-builder.js

@@ -1,165 +0,0 @@
-const {
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  formatHex
-} = require('../../protocols/modbus-rtu/index.js')
-
-const MODBUS_COMMANDS = [
-  { key: 'readCoils', label: '01 读取线圈', functionCode: 0x01, inputMode: 'quantity' },
-  { key: 'readDiscreteInputs', label: '02 读取离散输入', functionCode: 0x02, inputMode: 'quantity' },
-  { key: 'readHolding', label: '03 读取保持寄存器', functionCode: 0x03, inputMode: 'quantity' },
-  { key: 'readInput', label: '04 读取输入寄存器', functionCode: 0x04, inputMode: 'quantity' },
-  { key: 'writeCoil', label: '05 写单线圈', functionCode: 0x05, inputMode: 'coil' },
-  { key: 'writeRegister', label: '06 写单寄存器', functionCode: 0x06, inputMode: 'single' },
-  { key: 'writeRegisters', label: '10 写多寄存器', functionCode: 0x10, inputMode: 'multiple' }
-]
-
-function parseHexNumber(value, label, maxValue) {
-  const text = String(value || '').trim().replace(/^0x/i, '')
-
-  if (!text || !/^[0-9a-fA-F]+$/.test(text)) {
-    throw new Error(`${label}请输入十六进制数值`)
-  }
-
-  const parsedValue = parseInt(text, 16)
-  if (parsedValue > maxValue) {
-    throw new Error(`${label}超出范围`)
-  }
-
-  return parsedValue
-}
-
-function parseRegisterValues(value) {
-  const text = String(value || '').trim()
-  if (!text) throw new Error('请输入寄存器写入值')
-
-  return text.split(/[\s,;]+/)
-    .filter(Boolean)
-    .map((item) => parseHexNumber(item, '写入值', 0xFFFF))
-}
-
-function getCommand(index) {
-  return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
-}
-
-function getDefaultCommandValue(command) {
-  if (command.inputMode === 'quantity') return '0001'
-  if (command.inputMode === 'coil') return 'ON'
-  if (command.inputMode === 'multiple') return '0000'
-
-  return '0000'
-}
-
-function generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity) {
-  const slave = parseHexNumber(slaveAddress, '从站地址', 0xFF)
-  const address = parseHexNumber(registerAddress, '起始地址', 0xFFFF)
-
-  if (command.inputMode === 'quantity') {
-    const quantity = parseHexNumber(commandValue, '读取数量', 0xFFFF)
-    return buildReadFrame(slave, command.functionCode, address, quantity)
-  }
-  if (command.inputMode === 'coil') {
-    return buildWriteSingleCoilFrame(slave, address, coilEnabled)
-  }
-  if (command.inputMode === 'single') {
-    return buildWriteSingleRegisterFrame(slave, address, parseHexNumber(commandValue, '写入值', 0xFFFF))
-  }
-
-  const words = parseRegisterValues(commandValue)
-  const quantity = parseHexNumber(commandRegisterQuantity, '寄存器个数', 0xFFFF)
-  if (quantity !== words.length) {
-    throw new Error(`写入值数量应为 ${quantity} 个寄存器`)
-  }
-
-  return buildWriteMultipleRegistersFrame(slave, address, words)
-}
-
-function createProtocolState(commandIndex, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity) {
-  const command = getCommand(commandIndex)
-  const commandValueLabel = command.inputMode === 'quantity' ? '读取数量' : '写入值'
-
-  try {
-    return {
-      commandValueLabel,
-      generatedHex: formatHex(generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled, commandRegisterQuantity)),
-      protocolErrorText: '',
-      showCoilValue: command.inputMode === 'coil',
-      showRegisterQuantity: command.inputMode === 'multiple',
-      showCommandValue: command.inputMode !== 'coil'
-    }
-  } catch (error) {
-    return {
-      commandValueLabel,
-      generatedHex: '',
-      protocolErrorText: error.message,
-      showCoilValue: command.inputMode === 'coil',
-      showRegisterQuantity: command.inputMode === 'multiple',
-      showCommandValue: command.inputMode !== 'coil'
-    }
-  }
-}
-
-function buildGeneratedExpectedResponse(state = {}) {
-  try {
-    const command = getCommand(state.commandIndex)
-    const address = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-    const slaveAddress = parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
-    const quantity = command.inputMode === 'quantity'
-      ? parseHexNumber(state.commandValue, '读取数量', 0xFFFF)
-      : (command.inputMode === 'multiple' ? parseHexNumber(state.commandRegisterQuantity, '寄存器个数', 0xFFFF) : 1)
-    const value = command.inputMode === 'coil'
-      ? (state.coilEnabled ? 0xFF00 : 0x0000)
-      : (command.inputMode === 'single' ? parseHexNumber(state.commandValue, '写入值', 0xFFFF) : undefined)
-
-    return {
-      address,
-      functionCode: command.functionCode,
-      kind: 'manual-rtu',
-      protocol: 'modbus-rtu',
-      quantity,
-      value,
-      slaveAddress
-    }
-  } catch (error) {
-    return null
-  }
-}
-
-function formatResponseHex(value, width) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(width, '0')
-}
-
-function formatGeneratedResponse(response) {
-  if (!response) return ''
-
-  const slave = formatResponseHex(response.slaveAddress, 2)
-  const functionCode = formatResponseHex(response.functionCode, 2)
-
-  if (Array.isArray(response.words) && response.words.length) {
-    return `从机 0x${slave} / 功能码 0x${functionCode} / 字 ${response.words.map((word) => formatResponseHex(word, 4)).join(' ')}`
-  }
-
-  if (Array.isArray(response.dataBytes) && response.dataBytes.length) {
-    return `从机 0x${slave} / 功能码 0x${functionCode} / 数据 ${formatHex(response.dataBytes)}`
-  }
-
-  if (Number.isInteger(response.address)) {
-    return `从机 0x${slave} / 功能码 0x${functionCode} / 地址 0x${formatResponseHex(response.address, 4)} / 值 0x${formatResponseHex(response.quantityOrValue, 4)}`
-  }
-
-  return `从机 0x${slave} / 功能码 0x${functionCode}`
-}
-
-module.exports = {
-  MODBUS_COMMANDS,
-  buildGeneratedExpectedResponse,
-  createProtocolState,
-  formatGeneratedResponse,
-  generateModbusFrame,
-  getCommand,
-  getDefaultCommandValue,
-  parseHexNumber,
-  parseRegisterValues
-}

+ 0 - 167
features/manual-rtu/multiple-registers.js

@@ -1,167 +0,0 @@
-const {
-  DATA_TYPE_OPTIONS,
-  getDataType,
-  getRegisterEncodedWords,
-  isByteRegister,
-  isTextRegister,
-  normalizeRegister,
-  validateRegisterValue
-} = require('../../domain/parameter-groups/model.js')
-
-function normalizeManualMultipleQuantity(value) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text) return 1
-  if (/^[0-9a-fA-F]+$/.test(text)) return Math.max(1, Math.min(parseInt(text, 16), 0x007B))
-
-  const numberValue = Number(text)
-  return Number.isFinite(numberValue) ? Math.max(1, Math.min(Math.round(numberValue), 0x007B)) : 1
-}
-
-function formatManualMultipleQuantity(quantity) {
-  return Number(quantity || 1).toString(16).toUpperCase().padStart(4, '0')
-}
-
-function createManualMultipleRegister(index, value = {}) {
-  const dataType = getDataType(value.dataType || 'hex')
-  const register = normalizeRegister({
-    dataType: dataType.key,
-    inputValue: value.inputValue === undefined ? '' : value.inputValue,
-    name: `寄存器 ${index + 1}`,
-    textByteLength: value.textByteLength || (isTextRegister(dataType.key) ? '32' : '')
-  }, {
-    registerType: 'holding'
-  }, index, Number(value.address || 0), 0)
-
-  return {
-    ...register,
-    dataTypeIndex: DATA_TYPE_OPTIONS.findIndex((item) => item.key === register.dataType),
-    inputValue: value.inputValue === undefined ? '' : value.inputValue
-  }
-}
-
-function getManualRegisterWordCount(register) {
-  return Math.max(1, Number(register && register.registerCount) || 1)
-}
-
-function normalizeManualMultipleValues(wordQuantity, values = [], startAddress = 0) {
-  const result = []
-  let address = Number(startAddress) || 0
-  const endAddress = address + Math.max(1, Number(wordQuantity) || 1)
-  let sourceIndex = 0
-
-  while (address < endAddress) {
-    const current = values[sourceIndex] || {}
-    let register = createManualMultipleRegister(result.length, {
-      ...current,
-      address
-    })
-    const remainingWords = endAddress - address
-    if (getManualRegisterWordCount(register) > remainingWords) {
-      register = createManualMultipleRegister(result.length, {
-        ...current,
-        address,
-        dataType: 'hex',
-        inputValue: ''
-      })
-    }
-    result.push(register)
-    address += getManualRegisterWordCount(register)
-    sourceIndex += 1
-  }
-
-  return result
-}
-
-function getManualMultipleWords(values = []) {
-  const words = []
-  values.forEach((register) => {
-    if (isByteRegister(register.dataType)) {
-      const registerWords = getRegisterEncodedWords(register)
-      if (!Array.isArray(registerWords) || !registerWords.length) throw new Error(`${register.name} 输入值无效`)
-      words.push(Number(registerWords[0]) & 0x00FF)
-      return
-    }
-
-    const registerWords = getRegisterEncodedWords(register)
-    if (!Array.isArray(registerWords) || !registerWords.length) throw new Error(`${register.name} 输入值无效`)
-    registerWords.forEach((word) => words.push(Number(word) & 0xFFFF))
-  })
-
-  return words
-}
-
-function getManualMultipleValueText(values = []) {
-  try {
-    return getManualMultipleWords(values).map((word) => word.toString(16).toUpperCase().padStart(4, '0')).join(' ')
-  } catch (error) {
-    return ''
-  }
-}
-
-function getManualMultipleDataType(dataTypeIndex) {
-  return DATA_TYPE_OPTIONS[Number(dataTypeIndex)] || DATA_TYPE_OPTIONS[0]
-}
-
-function updateManualMultipleValue(values = [], index, value) {
-  const registerIndex = Number(index)
-
-  return values.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? {
-        ...register,
-        inputValue: value
-      }
-      : register
-  ))
-}
-
-function updateManualMultipleType(values = [], index, dataTypeIndex) {
-  const registerIndex = Number(index)
-  const dataType = getManualMultipleDataType(dataTypeIndex)
-
-  return values.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? createManualMultipleRegister(currentIndex, {
-        ...register,
-        dataType: dataType.key,
-        inputValue: '',
-        textByteLength: isTextRegister(dataType.key) ? (register.textByteLength || '32') : ''
-      })
-      : register
-  ))
-}
-
-function updateManualMultipleTextLength(values = [], index, value) {
-  const registerIndex = Number(index)
-
-  return values.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? createManualMultipleRegister(currentIndex, {
-        ...register,
-        textByteLength: value
-      })
-      : register
-  ))
-}
-
-function validateManualMultipleValue(values = [], index, value) {
-  const register = values[Number(index)]
-  if (!register) return false
-
-  return validateRegisterValue(register, value)
-}
-
-module.exports = {
-  DATA_TYPE_OPTIONS,
-  createManualMultipleRegister,
-  formatManualMultipleQuantity,
-  getManualMultipleDataType,
-  getManualMultipleValueText,
-  getManualMultipleWords,
-  normalizeManualMultipleQuantity,
-  normalizeManualMultipleValues,
-  updateManualMultipleTextLength,
-  updateManualMultipleType,
-  updateManualMultipleValue,
-  validateManualMultipleValue
-}

+ 0 - 337
features/manual-rtu/service.js

@@ -1,337 +0,0 @@
-const transport = require('../../transport/ble-core.js')
-const {
-  DATA_TYPE_OPTIONS,
-  formatManualMultipleQuantity,
-  getManualMultipleValueText,
-  normalizeManualMultipleQuantity,
-  normalizeManualMultipleValues,
-  updateManualMultipleTextLength,
-  updateManualMultipleType,
-  updateManualMultipleValue,
-  validateManualMultipleValue
-} = require('./multiple-registers.js')
-const {
-  MODBUS_COMMANDS,
-  buildGeneratedExpectedResponse: buildExpectedResponse,
-  createProtocolState,
-  formatGeneratedResponse,
-  getCommand,
-  getDefaultCommandValue,
-  parseHexNumber
-} = require('./frame-builder.js')
-
-const state = {
-  commandIndex: 2,
-  commandRegisterQuantity: '0001',
-  commandValue: '0001',
-  commandValueLabel: '读取数量',
-  coilEnabled: true,
-  generatedHex: '',
-  protocolCommands: MODBUS_COMMANDS,
-  protocolDataTypeOptions: DATA_TYPE_OPTIONS,
-  protocolErrorText: '',
-  protocolMultipleDialog: {
-    visible: false
-  },
-  protocolMultipleExpanded: false,
-  protocolMultipleValues: [],
-  protocolResponseText: '',
-  protocolStatusText: '',
-  registerAddress: '0000',
-  showCoilValue: false,
-  showCommandValue: true,
-  showRegisterQuantity: false,
-  slaveAddress: 'F0'
-}
-
-const subscribers = []
-
-function setState(changedData) {
-  Object.assign(state, changedData)
-  subscribers.slice().forEach((subscriber) => {
-    subscriber(getState())
-  })
-}
-
-function getState() {
-  return {
-    ...state,
-    protocolCommands: state.protocolCommands.slice(),
-    protocolDataTypeOptions: state.protocolDataTypeOptions.slice(),
-    protocolMultipleDialog: {
-      ...state.protocolMultipleDialog
-    },
-    protocolMultipleExpanded: !!state.protocolMultipleExpanded,
-    protocolMultipleValues: state.protocolMultipleValues.map((item) => ({
-      ...item
-    })),
-    protocolStatusText: state.protocolStatusText || ''
-  }
-}
-
-function subscribe(subscriber) {
-  if (typeof subscriber !== 'function') return () => {}
-
-  subscribers.push(subscriber)
-  subscriber(getState())
-
-  return () => {
-    const index = subscribers.indexOf(subscriber)
-    if (index >= 0) subscribers.splice(index, 1)
-  }
-}
-
-function setProtocolInput(changedData) {
-  const command = getCommand(changedData.commandIndex === undefined ? state.commandIndex : changedData.commandIndex)
-  let nextMultipleValues = changedData.protocolMultipleValues
-  let nextCommandValue = changedData.commandValue
-  if (
-    command.inputMode === 'multiple'
-    && Object.prototype.hasOwnProperty.call(changedData, 'registerAddress')
-    && !Object.prototype.hasOwnProperty.call(changedData, 'protocolMultipleValues')
-  ) {
-    try {
-      const startAddress = parseHexNumber(changedData.registerAddress, '起始地址', 0xFFFF)
-      nextMultipleValues = normalizeManualMultipleValues(
-        normalizeManualMultipleQuantity(state.commandRegisterQuantity),
-        state.protocolMultipleValues,
-        startAddress
-      )
-      nextCommandValue = getManualMultipleValueText(nextMultipleValues)
-    } catch (error) {}
-  }
-  const nextState = {
-    commandIndex: state.commandIndex,
-    commandRegisterQuantity: state.commandRegisterQuantity,
-    slaveAddress: state.slaveAddress,
-    registerAddress: state.registerAddress,
-    commandValue: state.commandValue,
-    coilEnabled: state.coilEnabled,
-    ...changedData,
-    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
-    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {})
-  }
-
-  setState({
-    ...changedData,
-    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
-    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {}),
-    protocolResponseText: '',
-    protocolStatusText: '',
-    ...createProtocolState(
-      nextState.commandIndex,
-      nextState.slaveAddress,
-      nextState.registerAddress,
-      nextState.commandValue,
-      nextState.coilEnabled,
-      nextState.commandRegisterQuantity
-    )
-  })
-}
-
-function setCommandIndex(index) {
-  const commandIndex = Number(index)
-  const command = getCommand(commandIndex)
-  let protocolMultipleValues = state.protocolMultipleValues
-  let commandValue = getDefaultCommandValue(command)
-
-  if (command.inputMode === 'multiple') {
-    let startAddress = 0
-    try {
-      startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-    } catch (error) {}
-    protocolMultipleValues = normalizeManualMultipleValues(
-      normalizeManualMultipleQuantity(state.commandRegisterQuantity),
-      state.protocolMultipleValues,
-      startAddress
-    )
-    commandValue = getManualMultipleValueText(protocolMultipleValues)
-  }
-
-  setProtocolInput({
-    commandIndex,
-    commandValue,
-    coilEnabled: true,
-    commandRegisterQuantity: state.commandRegisterQuantity,
-    ...(command.inputMode === 'multiple' ? { protocolMultipleValues } : {})
-  })
-}
-
-function openProtocolMultipleDialog() {
-  let startAddress = 0
-  try {
-    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-  } catch (error) {
-    setState({
-      protocolErrorText: error.message || '起始地址无效'
-    })
-    return
-  }
-
-  const quantity = normalizeManualMultipleQuantity(state.commandRegisterQuantity)
-  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
-  setState({
-    commandRegisterQuantity: formatManualMultipleQuantity(quantity),
-    protocolMultipleDialog: {
-      title: '写多个寄存器',
-      visible: true
-    },
-    protocolMultipleExpanded: false,
-    protocolMultipleValues: values
-  })
-}
-
-function closeProtocolMultipleDialog() {
-  setState({
-    protocolMultipleDialog: {
-      visible: false
-    }
-  })
-}
-
-function toggleProtocolMultipleExpanded() {
-  setState({
-    protocolMultipleExpanded: !state.protocolMultipleExpanded
-  })
-}
-
-function setProtocolMultipleQuantity(value) {
-  const quantity = normalizeManualMultipleQuantity(value)
-  let startAddress = 0
-  try {
-    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-  } catch (error) {
-    setState({
-      commandRegisterQuantity: value,
-      protocolErrorText: error.message || '起始地址无效'
-    })
-    return
-  }
-  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
-  const commandValue = getManualMultipleValueText(values)
-
-  setProtocolInput({
-    commandRegisterQuantity: value,
-    commandValue,
-    protocolMultipleValues: values
-  })
-}
-
-function setProtocolMultipleValue(index, value) {
-  const values = updateManualMultipleValue(state.protocolMultipleValues, index, value)
-  let startAddress = 0
-  try {
-    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-  } catch (error) {
-    setState({ protocolErrorText: error.message || '起始地址无效' })
-    return
-  }
-  const normalizedValues = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), values, startAddress)
-  const commandValue = getManualMultipleValueText(normalizedValues)
-
-  setProtocolInput({
-    commandValue,
-    protocolMultipleValues: normalizedValues
-  })
-}
-
-function setProtocolMultipleType(index, dataTypeIndex) {
-  const changedValues = updateManualMultipleType(state.protocolMultipleValues, index, dataTypeIndex)
-  let startAddress = 0
-  try {
-    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-  } catch (error) {
-    setState({ protocolErrorText: error.message || '起始地址无效' })
-    return
-  }
-  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
-
-  setProtocolInput({
-    commandValue: getManualMultipleValueText(values),
-    protocolMultipleValues: values
-  })
-}
-
-function setProtocolMultipleTextLength(index, value) {
-  const changedValues = updateManualMultipleTextLength(state.protocolMultipleValues, index, value)
-  let startAddress = 0
-  try {
-    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-  } catch (error) {
-    setState({ protocolErrorText: error.message || '起始地址无效' })
-    return
-  }
-  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
-
-  setProtocolInput({
-    commandValue: getManualMultipleValueText(values),
-    protocolMultipleValues: values
-  })
-}
-
-function validateProtocolMultipleValue(index, value) {
-  return validateManualMultipleValue(state.protocolMultipleValues, index, value)
-}
-
-function buildGeneratedExpectedResponse() {
-  return buildExpectedResponse(state)
-}
-
-async function sendGeneratedFrame() {
-  if (!state.generatedHex) {
-    setState({
-      protocolStatusText: state.protocolErrorText || '请先生成有效帧'
-    })
-    return false
-  }
-
-  const expected = buildGeneratedExpectedResponse()
-  setState({
-    protocolResponseText: '',
-    protocolStatusText: ''
-  })
-
-  const response = await transport.enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
-    expected
-  } : {})
-
-  if (response) {
-    setState({
-      protocolResponseText: formatGeneratedResponse(response),
-      protocolStatusText: ''
-    })
-  } else {
-    setState({
-      protocolStatusText: '未收到回复'
-    })
-  }
-
-  return response
-}
-
-setState(createProtocolState(
-  state.commandIndex,
-  state.slaveAddress,
-  state.registerAddress,
-  state.commandValue,
-  state.coilEnabled,
-  state.commandRegisterQuantity
-))
-
-module.exports = {
-  buildGeneratedExpectedResponse,
-  closeProtocolMultipleDialog,
-  formatGeneratedResponse,
-  getState,
-  openProtocolMultipleDialog,
-  sendGeneratedFrame,
-  setCommandIndex,
-  setProtocolInput,
-  setProtocolMultipleQuantity,
-  setProtocolMultipleTextLength,
-  setProtocolMultipleType,
-  setProtocolMultipleValue,
-  subscribe,
-  toggleProtocolMultipleExpanded,
-  validateProtocolMultipleValue
-}

+ 221 - 0
features/modbus-rtu/service.js

@@ -0,0 +1,221 @@
+const settingsService = require('../../store/settings-store.js')
+const transport = require('../../transport/ble-core.js')
+const modbusProtocol = require('../../protocols/modbus-rtu/index.js')
+const {
+  addCoilReadValues,
+  addWordReadValues
+} = require('../../utils/register-value-utils.js')
+
+function getSharedSlaveAddress(title = '从机地址错误') {
+  try {
+    return settingsService.getModbusSlaveAddress()
+  } catch (error) {
+    transport.showCommandAlert(title, error.message)
+    return null
+  }
+}
+
+function formatAddress(value) {
+  return Number(value || 0).toString(16).toUpperCase()
+}
+
+function getChunkLabel(label, chunks, chunk) {
+  if (!label || chunks.length <= 1) return label
+
+  return `${label} ${formatAddress(chunk.address)}-${formatAddress(chunk.address + chunk.quantity - 1)}`
+}
+
+function isBroadcastAddress(slaveAddress) {
+  return Number(slaveAddress) === 0
+}
+
+function showBroadcastReadAlert(label) {
+  transport.showCommandAlert(label || '读取命令错误', '广播地址 0x00 不支持读取')
+}
+
+async function sendReadChunk(slaveAddress, functionCode, chunk, label, kind, options = {}) {
+  if (isBroadcastAddress(slaveAddress)) {
+    showBroadcastReadAlert(label)
+    return false
+  }
+
+  return transport.sendManagedFrame(
+    modbusProtocol.buildReadFrame(slaveAddress, functionCode, chunk.address, chunk.quantity, {
+      maxFrameBytes: options.maxFrameBytes
+    }),
+    label,
+    {
+      address: chunk.address,
+      functionCode,
+      kind,
+      protocol: modbusProtocol.PROTOCOL_NAME,
+      quantity: chunk.quantity,
+      slaveAddress
+    },
+    {
+      maxFrameBytes: options.maxFrameBytes,
+      showModal: options.showModal
+    }
+  )
+}
+
+async function readSpans(slaveAddress, functionCode, spans, label, kind, options = {}) {
+  const readValues = {
+    coils: {},
+    words: {}
+  }
+  const normalizedSpans = (spans || []).filter((span) => span && span.quantity > 0)
+
+  for (const span of normalizedSpans) {
+    const chunks = modbusProtocol.getReadChunks(functionCode, span.address, span.quantity, options)
+
+    for (const chunk of chunks) {
+      const responseValue = await sendReadChunk(
+        slaveAddress,
+        functionCode,
+        chunk,
+        getChunkLabel(label, chunks, chunk),
+        kind,
+        options
+      )
+      if (!responseValue) return null
+
+      if (functionCode === 0x01 || functionCode === 0x02) {
+        addCoilReadValues(readValues, chunk.address, chunk.quantity, responseValue)
+      } else {
+        addWordReadValues(readValues, chunk.address, responseValue)
+      }
+
+      if (typeof options.onChunk === 'function') {
+        options.onChunk(responseValue, chunk)
+      }
+    }
+  }
+
+  return readValues
+}
+
+async function readRegisterWords(slaveAddress, functionCode, startAddress, quantity, label, kind, options = {}) {
+  const words = []
+  const chunks = modbusProtocol.getReadChunks(functionCode, startAddress, quantity, options)
+
+  for (const chunk of chunks) {
+    const responseValue = await sendReadChunk(
+      slaveAddress,
+      functionCode,
+      chunk,
+      getChunkLabel(label, chunks, chunk),
+      kind,
+      options
+    )
+    if (!responseValue) return null
+
+    const chunkWords = responseValue.words || []
+    chunkWords.forEach((word, index) => {
+      words[chunk.address - startAddress + index] = Number(word) & 0xFFFF
+    })
+
+    if (typeof options.onChunk === 'function') {
+      options.onChunk(responseValue, chunk)
+    }
+  }
+
+  return words
+}
+
+async function readBitValues(slaveAddress, functionCode, startAddress, quantity, label, kind, options = {}) {
+  const result = await readSpans(
+    slaveAddress,
+    functionCode,
+    [{ address: startAddress, quantity }],
+    label,
+    kind,
+    options
+  )
+
+  return result ? result.coils : null
+}
+
+async function readSingleHoldingWord(slaveAddress, address, label = '读取配对寄存器', kind = 'holding-word-read') {
+  const words = await readRegisterWords(slaveAddress, 0x03, address, 1, label, kind)
+
+  return words && Number.isInteger(words[0]) ? words[0] & 0xFFFF : null
+}
+
+function writeSingleCoil(slaveAddress, address, checked, label, kind = 'coil-write', options = {}) {
+  const coilValue = checked ? 0xFF00 : 0x0000
+
+  return transport.sendManagedFrame(
+    modbusProtocol.buildWriteSingleCoilFrame(slaveAddress, address, checked),
+    label,
+    isBroadcastAddress(slaveAddress) ? null : {
+      address,
+      functionCode: 0x05,
+      kind,
+      protocol: modbusProtocol.PROTOCOL_NAME,
+      quantity: 1,
+      slaveAddress,
+      value: coilValue
+    },
+    {
+      maxFrameBytes: options.maxFrameBytes,
+      showModal: options.showModal
+    }
+  )
+}
+
+function writeSingleRegister(slaveAddress, address, value, label, kind = 'register-write', options = {}) {
+  return transport.sendManagedFrame(
+    modbusProtocol.buildWriteSingleRegisterFrame(slaveAddress, address, value),
+    label,
+    isBroadcastAddress(slaveAddress) ? null : {
+      address,
+      functionCode: 0x06,
+      kind,
+      protocol: modbusProtocol.PROTOCOL_NAME,
+      quantity: 1,
+      slaveAddress,
+      value
+    },
+    {
+      maxFrameBytes: options.maxFrameBytes,
+      showModal: options.showModal
+    }
+  )
+}
+
+function writeMultipleRegisters(slaveAddress, address, values, label, kind = 'registers-write', options = {}) {
+  return transport.sendManagedFrame(
+    modbusProtocol.buildWriteMultipleRegistersFrame(slaveAddress, address, values, {
+      maxFrameBytes: options.maxFrameBytes
+    }),
+    label,
+    isBroadcastAddress(slaveAddress) ? null : {
+      address,
+      functionCode: 0x10,
+      kind,
+      protocol: modbusProtocol.PROTOCOL_NAME,
+      quantity: values.length,
+      slaveAddress
+    },
+    {
+      maxFrameBytes: options.maxFrameBytes,
+      showModal: options.showModal
+    }
+  )
+}
+
+module.exports = {
+  getMaxReadQuantity: modbusProtocol.getMaxReadQuantity,
+  getMaxWriteMultipleRegisterQuantity: modbusProtocol.getMaxWriteMultipleRegisterQuantity,
+  getReadChunks: modbusProtocol.getReadChunks,
+  getSharedSlaveAddress,
+  readBitValues,
+  readRegisterWords,
+  readSingleHoldingWord,
+  readSpans,
+  splitQuantity: modbusProtocol.splitQuantity,
+  writeMultipleRegisters,
+  writeSingleCoil,
+  writeSingleRegister
+}

+ 12 - 3
features/parameter-groups/dialog-handlers.js

@@ -57,11 +57,20 @@ function createDialogHandlers(parameterGroupService) {
       })
     },
 
+    onParameterDraftSwitchChange(event) {
+      const field = event.currentTarget.dataset.field
+      if (!field) return
+
+      this.updateParameterDialog({
+        [field]: !!event.detail.value
+      })
+    },
+
     parseParameterStructDefinition() {
       const dialog = this.data.parameterDialog || createParameterDialogState()
       const sourceText = dialog.structDefinition || ''
       if (!sourceText.trim()) {
-        if (this.pageToast) this.pageToast.show('请先粘贴结构体定义', 'error')
+        if (this.pageToast) this.pageToast.show('请先粘贴结构体/枚举定义', 'error')
         return
       }
 
@@ -86,9 +95,9 @@ function createDialogHandlers(parameterGroupService) {
           registerTypeText: inputRegisterType.label || '',
           structParsedSummary: `${parsed.structName} · ${parsed.registers.length} 个字段`
         })
-        if (this.pageToast) this.pageToast.show('结构体解析完成')
+        if (this.pageToast) this.pageToast.show('结构体/枚举解析完成')
       } catch (error) {
-        if (this.pageToast) this.pageToast.show(error.message || '结构体解析失败', 'error')
+        if (this.pageToast) this.pageToast.show(error.message || '结构体/枚举解析失败', 'error')
       }
     },
 

+ 316 - 12
features/parameter-groups/import-merge.js → features/parameter-groups/imports.js

@@ -1,12 +1,279 @@
 const {
+  getDataType,
+  isStorageStructGroup,
   normalizeGroup
 } = require('../../domain/parameter-groups/model.js')
 const {
-  getRegistersByteLength
-} = require('./struct-completion.js')
+  parseStructCatalog,
+  parseStructDefinition: parseStructDefinitionSource
+} = require('../../domain/parameter-groups/struct-parser.js')
 
-function formatAddress(address) {
-  return `0x${Number(address || 0).toString(16).toUpperCase().padStart(4, '0')}`
+function getRegisterByteLengthFromConfig(register) {
+  const dataType = getDataType(register.dataType).key
+  if (dataType === 'ascii' || dataType === 'utf8') return Math.max(1, Number(register.textByteLength) || 1)
+  if (dataType === 'float' || dataType === 'int32_t' || dataType === 'uint32_t') return 4
+  if (dataType === 'int8_t' || dataType === 'uint8_t') return 1
+
+  return 2
+}
+
+function getRegistersByteLength(registers = []) {
+  const explicitByteEnds = registers.map((register) => {
+    const byteStart = Number(register && register.byteStart)
+    if (!Number.isFinite(byteStart)) return null
+
+    if (register.isBitField) {
+      const bitOffset = Math.min(Math.max(Math.floor(Number(register.bitOffset) || 0), 0), 7)
+      const bitWidth = Math.max(1, Math.round(Number(register.bitWidth) || 1))
+
+      return Math.max(0, Math.floor(byteStart)) + Math.max(1, Math.ceil((bitOffset + bitWidth) / 8))
+    }
+
+    return Math.max(0, Math.floor(byteStart)) + getRegisterByteLengthFromConfig(register)
+  }).filter((value) => Number.isFinite(value))
+
+  if (explicitByteEnds.length) return Math.max.apply(null, explicitByteEnds)
+
+  return registers.reduce((total, register) => total + getRegisterByteLengthFromConfig(register), 0)
+}
+
+function normalizeSymbolText(value) {
+  return String(value || '')
+    .replace(/^(?:IDATA|XDATA|DATA|CODE)[\s:_-]+/i, '')
+    .replace(/^_+/, '')
+    .replace(/[^A-Za-z0-9]/g, '')
+    .toLowerCase()
+}
+
+function getStorageStructTypeName(group = {}) {
+  return String(group.sourceSymbolType || group.sourceSymbolName || group.name || '')
+}
+
+function isStructCodeInfoEntry(group = {}) {
+  const entryKind = String(group.sourceEntryKind || '').trim().toLowerCase()
+
+  return !entryKind || entryKind === 'struct'
+}
+
+function isVariableCodeInfoEntry(group = {}) {
+  return String(group.sourceEntryKind || '').trim().toLowerCase() === 'variable'
+}
+
+function structDefinitionNameMatches(group = {}, structInfo = {}) {
+  const expectedName = normalizeSymbolText(getStorageStructTypeName(group))
+  const structName = normalizeSymbolText(structInfo.name)
+
+  return !!expectedName && !!structName && expectedName === structName
+}
+
+function findStructCompletion(group, catalog) {
+  if (isStorageStructGroup(group) && isStructCodeInfoEntry(group)) {
+    const matchedStruct = catalog.structs.find((structInfo) => structDefinitionNameMatches(group, structInfo))
+
+    return matchedStruct
+      ? {
+        name: group.sourceSymbolName || group.name,
+        registers: matchedStruct.registers,
+        structName: matchedStruct.name
+      }
+      : null
+  }
+
+  const symbolName = group.sourceSymbolName || group.name
+  const direct = catalog.variablesByName[normalizeSymbolText(symbolName)]
+    || catalog.variablesByName[symbolName]
+
+  if (direct) return direct
+
+  const normalizedSymbol = normalizeSymbolText(symbolName)
+  const normalizedType = normalizeSymbolText(group.sourceSymbolType)
+  const expectedBytes = Number(group.sourceByteLength || group.byteLength || 0)
+
+  const matchedStruct = catalog.structs.find((structInfo) => {
+    const normalizedStructName = normalizeSymbolText(structInfo.name)
+    if (normalizedType && normalizedType === normalizedStructName) {
+      return true
+    }
+    if (normalizedSymbol && (
+      normalizedSymbol === normalizedStructName
+      || normalizedSymbol.indexOf(normalizedStructName) >= 0
+      || normalizedStructName.indexOf(normalizedSymbol) >= 0
+    )) {
+      return true
+    }
+
+    return expectedBytes > 0 && getRegistersByteLength(structInfo.registers) === expectedBytes
+  })
+
+  return matchedStruct
+    ? {
+      name: symbolName,
+      registers: matchedStruct.registers,
+      structName: matchedStruct.name
+    }
+    : null
+}
+
+function getEnumLookupNames(group = {}) {
+  const registers = Array.isArray(group.registers) ? group.registers : []
+  const names = [
+    group.sourceSymbolType,
+    group.sourceSymbolName,
+    group.name
+  ]
+
+  registers.forEach((register) => {
+    names.push(register.sourceSymbolType, register.sourceSymbolName, register.name)
+  })
+
+  return names.map(normalizeSymbolText).filter(Boolean)
+}
+
+function findEnumCompletion(group, catalog = {}) {
+  const enums = Array.isArray(catalog.enums) ? catalog.enums : []
+  if (!isVariableCodeInfoEntry(group) || !enums.length) return null
+
+  const names = getEnumLookupNames(group)
+  if (!names.length) return null
+
+  const enumVariablesByName = catalog.enumVariablesByName || {}
+  for (const name of names) {
+    const variableEnum = enumVariablesByName[name]
+    if (variableEnum) return variableEnum
+  }
+
+  return enums.find((enumInfo) => (
+    [enumInfo.name, enumInfo.typedefName, enumInfo.tagName]
+      .concat(enumInfo.typeNames || [])
+      .map(normalizeSymbolText)
+      .filter(Boolean)
+      .some((name) => names.indexOf(name) >= 0)
+  )) || null
+}
+
+function getIntegerDataTypeForByteLength(byteLength, fallback = 'uint16_t') {
+  const length = Number(byteLength)
+  if (length === 1) return 'uint8_t'
+  if (length === 2) return 'uint16_t'
+  if (length === 4) return 'uint32_t'
+
+  return fallback
+}
+
+function cloneEnumOptions(enumInfo) {
+  return (Array.isArray(enumInfo && enumInfo.options) ? enumInfo.options : []).map((option) => ({
+    label: option.label || option.name,
+    name: option.name || option.label,
+    value: Number(option.value) || 0
+  }))
+}
+
+function completeEnumVariableGroup(group, enumInfo) {
+  if (!enumInfo) return group
+
+  const enumOptions = cloneEnumOptions(enumInfo)
+  if (!enumOptions.length) return group
+
+  const registers = (Array.isArray(group.registers) ? group.registers : []).map((register) => ({
+    ...register,
+    dataType: getIntegerDataTypeForByteLength(
+      register.sourceByteLength || register.byteLength || group.sourceByteLength || group.byteLength,
+      register.dataType || enumInfo.dataType
+    ),
+    enumName: enumInfo.name,
+    enumOptions,
+    sourceSymbolType: enumInfo.name || register.sourceSymbolType
+  }))
+
+  return normalizeGroup({
+    ...group,
+    registers
+  })
+}
+
+function createCompletedRegisters(group, completion) {
+  const existingRemarksByByteStart = (Array.isArray(group.registers) ? group.registers : []).reduce((remarks, register) => {
+    const byteStart = Number(register && register.byteStart)
+    const remark = String(register && register.remark ? register.remark : '').trim()
+    if (Number.isFinite(byteStart) && remark) remarks[Math.floor(byteStart)] = remark
+
+    return remarks
+  }, {})
+
+  return completion.registers.map((register) => {
+    const sourceAddress = (Number(group.sourceAddress) || Number(group.startAddress) || 0) + getRegisterByteStart(register)
+
+    return {
+      ...register,
+      isStructField: true,
+      remark: register.remark || existingRemarksByByteStart[Math.floor(Number(register.byteStart) || 0)] || '',
+      sourceAddress,
+      sourceAddressByteLength: group.sourceAddressByteLength,
+      sourceAddressText: formatAddress(sourceAddress, group.sourceAddressWidth),
+      sourceAddressWidth: group.sourceAddressWidth,
+      sourceEntryKind: group.sourceEntryKind,
+      sourceMemoryArea: group.sourceMemoryArea,
+      sourceMemoryClass: group.sourceMemoryClass,
+      sourceSymbolName: group.sourceSymbolName,
+      sourceSymbolType: completion.structName || register.sourceSymbolType
+    }
+  })
+}
+
+function completeStructInstanceGroups(groups, sourceText, options = {}) {
+  const catalog = parseStructCatalog(sourceText)
+  let completedCount = 0
+  let skippedCount = 0
+
+  const nextGroups = groups.map((group) => {
+    if (!group.sourceSymbolName || !group.sourceMemoryArea) return group
+    if (isVariableCodeInfoEntry(group)) {
+      const enumInfo = findEnumCompletion(group, catalog)
+      if (!enumInfo) return group
+
+      completedCount += 1
+      return completeEnumVariableGroup(group, enumInfo)
+    }
+    if (!isStructCodeInfoEntry(group)) return group
+
+    const completion = findStructCompletion(group, catalog)
+    if (!completion || !completion.registers || !completion.registers.length) {
+      skippedCount += 1
+      return group
+    }
+
+    const expectedBytes = Number(group.sourceByteLength || group.byteLength || 0)
+    const actualBytes = getRegistersByteLength(completion.registers)
+    if (expectedBytes > 0 && actualBytes !== expectedBytes && options.strictLength !== false) {
+      skippedCount += 1
+      return group
+    }
+
+    completedCount += 1
+
+    return normalizeGroup({
+      ...group,
+      layout: 'struct',
+      quantity: completion.registers.length,
+      registers: createCompletedRegisters(group, completion)
+    })
+  })
+
+  return {
+    completedCount,
+    groups: nextGroups,
+    skippedCount,
+    structCount: catalog.structs.length,
+    enumCount: Array.isArray(catalog.enums) ? catalog.enums.length : 0,
+    variableCount: Object.keys(catalog.variablesByName).length
+  }
+}
+
+function formatAddress(address, addressWidth) {
+  const numberValue = Math.max(0, Math.floor(Number(address) || 0))
+  const length = Number(addressWidth) === 32 || numberValue > 0xFFFF ? 8 : 4
+
+  return `0x${numberValue.toString(16).toUpperCase().padStart(length, '0')}`
 }
 
 function normalizeDuplicateText(value) {
@@ -74,12 +341,12 @@ function isSingleRegisterAggregateGroup(group = {}) {
 function getGroupDuplicateKey(group = {}) {
   const area = normalizeDuplicateText(group.sourceMemoryArea || '')
   const symbolName = normalizeDuplicateText(group.sourceSymbolName || group.name || '')
-  if (area && symbolName) return ['group', area, symbolName].join('|')
-
   const addressKey = normalizeAddressKey(
     group.sourceAddress !== undefined ? group.sourceAddress : group.startAddress,
     group.sourceAddressText || group.startAddressText
   )
+  if (area && symbolName && addressKey) return ['group', area, symbolName, addressKey].join('|')
+  if (area && symbolName) return ['group', area, symbolName].join('|')
 
   if (!area && !symbolName && !addressKey) return ''
 
@@ -175,6 +442,24 @@ function structsMatchByByteLength(existingGroup = {}, incomingGroup = {}) {
   return existingLengths.some((length) => incomingLengths.indexOf(length) >= 0)
 }
 
+function structsMatchByLocation(existingGroup = {}, incomingGroup = {}) {
+  const existingArea = normalizeDuplicateText(existingGroup.sourceMemoryArea || '')
+  const incomingArea = normalizeDuplicateText(incomingGroup.sourceMemoryArea || '')
+  const existingAddress = normalizeAddressKey(
+    existingGroup.sourceAddress !== undefined ? existingGroup.sourceAddress : existingGroup.startAddress,
+    existingGroup.sourceAddressText || existingGroup.startAddressText
+  )
+  const incomingAddress = normalizeAddressKey(
+    incomingGroup.sourceAddress !== undefined ? incomingGroup.sourceAddress : incomingGroup.startAddress,
+    incomingGroup.sourceAddressText || incomingGroup.startAddressText
+  )
+
+  if (existingArea && incomingArea && existingArea !== incomingArea) return false
+  if (existingAddress && incomingAddress && existingAddress !== incomingAddress) return false
+
+  return true
+}
+
 function isIncomingPlaceholderStructGroup(group = {}) {
   const registers = Array.isArray(group.registers) ? group.registers : []
 
@@ -197,6 +482,7 @@ function canPreserveExistingStructLayout(existingGroup, incomingGroup, options =
     && isIncomingPlaceholderStructGroup(incomingGroup)
     && structsMatchByName(existingGroup, incomingGroup)
     && structsMatchByByteLength(existingGroup, incomingGroup)
+    && structsMatchByLocation(existingGroup, incomingGroup)
 }
 
 function getRegisterByteStart(register = {}) {
@@ -207,7 +493,7 @@ function getRegisterByteStart(register = {}) {
 
 function mergePreservedStructRegister(register = {}, incomingGroup = {}) {
   const byteStart = getRegisterByteStart(register)
-  const sourceAddress = ((Number(incomingGroup.sourceAddress) || Number(incomingGroup.startAddress) || 0) + byteStart) & 0xFFFF
+  const sourceAddress = (Number(incomingGroup.sourceAddress) || Number(incomingGroup.startAddress) || 0) + byteStart
   const sourceSymbolName = incomingGroup.sourceSymbolName || register.sourceSymbolName
   const sourceSymbolType = incomingGroup.sourceSymbolType || register.sourceSymbolType || sourceSymbolName
 
@@ -217,7 +503,10 @@ function mergePreservedStructRegister(register = {}, incomingGroup = {}) {
     rawValue: null,
     rawWords: [],
     sourceAddress,
-    sourceAddressText: formatAddress(sourceAddress),
+    sourceAddressByteLength: incomingGroup.sourceAddressByteLength || register.sourceAddressByteLength,
+    sourceAddressText: formatAddress(sourceAddress, incomingGroup.sourceAddressWidth || register.sourceAddressWidth),
+    sourceAddressWidth: incomingGroup.sourceAddressWidth || register.sourceAddressWidth,
+    sourceEntryKind: incomingGroup.sourceEntryKind,
     sourceMemoryArea: incomingGroup.sourceMemoryArea,
     sourceMemoryClass: incomingGroup.sourceMemoryClass,
     sourceSymbolName,
@@ -225,7 +514,13 @@ function mergePreservedStructRegister(register = {}, incomingGroup = {}) {
   }
 }
 
-function mergePreservedStructGroupState(existingGroup, incomingGroup) {
+function resolveMergedPollEnabled(existingGroup = {}, incomingGroup = {}, options = {}) {
+  if (options.preserveExistingPollEnabled && existingGroup.pollEnabled === false) return false
+
+  return incomingGroup.pollEnabled === false ? false : true
+}
+
+function mergePreservedStructGroupState(existingGroup, incomingGroup, options = {}) {
   const preservedRegisters = (Array.isArray(existingGroup.registers) ? existingGroup.registers : [])
     .map((register) => mergePreservedStructRegister(register, incomingGroup))
 
@@ -234,6 +529,7 @@ function mergePreservedStructGroupState(existingGroup, incomingGroup) {
     deleteVisible: false,
     expanded: existingGroup.expanded === true,
     id: existingGroup.id,
+    pollEnabled: resolveMergedPollEnabled(existingGroup, incomingGroup, options),
     quantity: preservedRegisters.length,
     registers: preservedRegisters
   }
@@ -277,7 +573,7 @@ function mergeImportedGroupState(existingGroup, incomingGroup, options = {}) {
   if (!existingGroup) return incomingGroup
 
   if (canPreserveExistingStructLayout(existingGroup, incomingGroup, options)) {
-    return mergePreservedStructGroupState(existingGroup, incomingGroup)
+    return mergePreservedStructGroupState(existingGroup, incomingGroup, options)
   }
 
   const existingRegisters = Array.isArray(existingGroup.registers) ? existingGroup.registers : []
@@ -288,6 +584,7 @@ function mergeImportedGroupState(existingGroup, incomingGroup, options = {}) {
     deleteVisible: false,
     expanded: existingGroup.expanded === true,
     id: existingGroup.id,
+    pollEnabled: resolveMergedPollEnabled(existingGroup, incomingGroup, options),
     registers: incomingRegisters.map((incomingRegister, index) => mergeImportedRegisterState(
       existingRegisters[index],
       incomingRegister,
@@ -342,7 +639,7 @@ function mergeAggregateImportedGroup(nextGroups, incomingGroup, indexes, options
       ...incomingGroup,
       quantity: targetRegisters.length,
       registers: targetRegisters
-    }))
+    }, options))
   } else if (targetRegisters.length) {
     targetGroup = normalizeGroup({
       ...incomingGroup,
@@ -415,6 +712,13 @@ function mergeImportedGroups(existingGroups = [], incomingGroups = [], options =
   return result
 }
 
+function parseStructDefinition(sourceText) {
+  return parseStructDefinitionSource(sourceText)
+}
+
 module.exports = {
-  mergeImportedGroups
+  completeStructInstanceGroups,
+  getRegistersByteLength,
+  mergeImportedGroups,
+  parseStructDefinition
 }

+ 3 - 15
features/parameter-groups/index.js

@@ -1,22 +1,10 @@
-const domain = require('../../domain/parameter-groups/index.js')
 const dialogHandlers = require('./dialog-handlers.js')
-const dragViewModel = require('./drag-view-model.js')
 const poller = require('./poller.js')
 const service = require('./service.js')
-const stateMappers = require('./state-mappers.js')
-const viewModel = require('./view-model.js')
 
 module.exports = {
-  domain,
-  dialogHandlers,
-  dragViewModel,
-  model: domain.model,
-  poller,
-  service,
-  stateMappers,
-  viewModel,
-  ...dialogHandlers,
+  createDialogHandlers: dialogHandlers.createDialogHandlers,
+  createParameterGroupPoller: poller.createParameterGroupPoller,
   parameterGroupService: service,
-  ...poller,
-  ...viewModel
+  service
 }

+ 648 - 0
features/parameter-groups/io.js

@@ -0,0 +1,648 @@
+const {
+  parseHexInteger
+} = require('../../utils/base-utils.js')
+const {
+  bytesToWords
+} = 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 {
+  decodeRegisterFromByteCache,
+  decodeRegisterFromWordCache,
+  decodeRegisterValue,
+  formatCoilDisplayValue,
+  formatRegisterValue,
+  getDataType,
+  getGroupEncodedBytes,
+  getGroupEncodedWords,
+  getRegisterBytesFromByteCache,
+  getRegisterEncodedBytes,
+  getRegisterEncodedWords,
+  getRegisterWordsFromByteCache,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  isBitRegisterType,
+  isByteRegister,
+  normalizeRegister,
+  parseCoilValue,
+  registerTypeIsBit,
+  splitWordSpans
+} = require('../../domain/parameter-groups/model.js')
+
+const STORAGE_ACCESS_CODE_AREA = storageAccessService.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
+
+  const area = storageAccessService.AREA[memoryArea]
+
+  return area || null
+}
+
+function isMemoryGroup(group = {}) {
+  return getMemoryType(group) !== null
+}
+
+function isByteAddressedGroup(group = {}) {
+  return group.addressUnit === 'byte' || group.addressUnit === 'bytes' || isMemoryGroup(group)
+}
+
+function getMemoryAddress(group = {}) {
+  const sourceAddress = Number(group.sourceAddress)
+  if (Number.isFinite(sourceAddress)) return Math.min(MAX_STORAGE_ACCESS_ADDRESS, Math.max(0, Math.floor(sourceAddress)))
+
+  return Math.min(MAX_STORAGE_ACCESS_ADDRESS, Math.max(0, Math.floor(Number(group.startAddress) || 0)))
+}
+
+function getMemoryByteLength(group = {}) {
+  const sourceByteLength = Number(group.sourceByteLength)
+  if (Number.isFinite(sourceByteLength) && sourceByteLength > 0) return Math.min(MAX_STORAGE_ACCESS_BYTE_LENGTH, Math.ceil(sourceByteLength))
+
+  const byteLength = Number(group.byteLength)
+  if (Number.isFinite(byteLength) && byteLength > 0) return Math.min(MAX_STORAGE_ACCESS_BYTE_LENGTH, Math.ceil(byteLength))
+
+  return Math.max(1, Math.ceil((Number(group.wordQuantity) || 1) * 2))
+}
+
+function shouldUseStorageAccess(options = {}, group = {}) {
+  return !!options.useStorageAccess && isMemoryGroup(group)
+}
+
+function getStorageMaxPacketLength(group = {}, options = {}) {
+  const configuredMaxPacketLength = storageAccessService.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)
+
+  return Math.min(configuredMaxPacketLength, Math.round(deviceMaxPacketLength))
+}
+
+function normalizeNumericCache(source = {}) {
+  const cache = {}
+
+  Object.keys(source || {}).forEach((addressText) => {
+    const numericAddress = Number(addressText)
+    if (Number.isFinite(numericAddress)) {
+      cache[numericAddress] = Number(source[addressText]) & 0xFFFF
+    }
+  })
+
+  return cache
+}
+
+function createRegisterStateFromCache(group, register, wordCache) {
+  const rawBytes = registerTypeIsBit(register)
+    ? []
+    : (isByteAddressedGroup(group) ? getRegisterBytesFromByteCache(register, wordCache) : [])
+  const rawWords = registerTypeIsBit(register)
+    ? []
+    : (isByteAddressedGroup(group)
+      ? getRegisterWordsFromByteCache(register, wordCache)
+      : getRegisterWordsFromWordCache(register, wordCache))
+  const rawValue = registerTypeIsBit(register)
+    ? decodeRegisterFromWordCache(register, wordCache)
+    : (isByteAddressedGroup(group)
+      ? decodeRegisterFromByteCache(register, wordCache)
+      : (rawWords ? decodeRegisterValue(register, rawWords) : null))
+  const displayValue = rawValue === null || rawValue === undefined
+    ? '--'
+    : (registerTypeIsBit(register)
+      ? formatCoilDisplayValue(rawValue)
+      : formatRegisterValue(register, rawValue))
+  const preNormalizedRegister = {
+    ...register,
+    displayValue,
+    isDirty: false,
+    rawBytes: rawBytes || [],
+    rawValue,
+    rawWords: rawWords || []
+  }
+  const nextRegister = normalizeRegister(preNormalizedRegister, group, 0, register.address, register.byteOffset)
+
+  return {
+    ...nextRegister,
+    id: register.id,
+    inputValue: group.writable ? displayValue : register.inputValue
+  }
+}
+
+function applyReadCacheToGroup(group, wordCache) {
+  return {
+    ...group,
+    registers: group.registers.map((register) => createRegisterStateFromCache(group, register, wordCache))
+  }
+}
+
+function applyWrittenSnapshotToRegister(register, snapshot = {}) {
+  const hasDisplayValue = Object.prototype.hasOwnProperty.call(snapshot, 'displayValue')
+  const hasRawBytes = Object.prototype.hasOwnProperty.call(snapshot, 'rawBytes')
+  const hasRawValue = Object.prototype.hasOwnProperty.call(snapshot, 'rawValue')
+  const hasRawWords = Object.prototype.hasOwnProperty.call(snapshot, 'rawWords')
+
+  return {
+    ...register,
+    displayValue: hasDisplayValue ? snapshot.displayValue : register.displayValue,
+    inputValue: hasDisplayValue ? snapshot.displayValue : register.inputValue,
+    isDirty: false,
+    rawBytes: hasRawBytes ? snapshot.rawBytes : register.rawBytes,
+    rawValue: hasRawValue ? snapshot.rawValue : register.rawValue,
+    rawWords: hasRawWords ? snapshot.rawWords : register.rawWords
+  }
+}
+
+function applyWrittenSnapshotsToGroup(group, snapshots = []) {
+  let writtenIndex = 0
+
+  return {
+    ...group,
+    registers: group.registers.map((register, index) => {
+      const snapshot = snapshots[writtenIndex] || {}
+      writtenIndex += 1
+
+      const nextRegister = applyWrittenSnapshotToRegister(register, snapshot)
+      const normalized = normalizeRegister(nextRegister, group, index, nextRegister.address, nextRegister.byteOffset)
+
+      return {
+        ...normalized,
+        id: register.id,
+        inputValue: group.writable ? nextRegister.displayValue : nextRegister.inputValue
+      }
+    })
+  }
+}
+
+function applyWrittenSnapshotToGroupRegister(group, registerIndex, snapshot) {
+  return {
+    ...group,
+    registers: group.registers.map((register, currentIndex) => (
+      currentIndex === registerIndex
+        ? (() => {
+          const nextRegister = applyWrittenSnapshotToRegister(register, snapshot || {})
+          const normalized = normalizeRegister(nextRegister, group, currentIndex, nextRegister.address, nextRegister.byteOffset)
+
+          return {
+            ...normalized,
+            id: register.id,
+            inputValue: group.writable ? nextRegister.displayValue : nextRegister.inputValue
+          }
+        })()
+        : register
+    ))
+  }
+}
+
+function getWriteSpanMaxQuantity(totalQuantity, maxPacketLength) {
+  if (maxPacketLength === 0) return Math.max(1, totalQuantity)
+
+  return Math.max(1, modbusClient.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
+}
+
+async function readModbusGroup(group, options = {}) {
+  const totalQuantity = Math.max(1, group.wordQuantity || group.quantity || 0)
+  const wordCache = {}
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
+  if (slaveAddress === null) return null
+
+  const values = await modbusClient.readSpans(
+    slaveAddress,
+    group.functionCode,
+    [{
+      address: group.startAddress,
+      quantity: totalQuantity
+    }],
+    group.name || '参数组读取',
+    'parameter-group-read',
+    {
+      maxFrameBytes: options.maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+  if (!values) return null
+
+  if (isBitRegisterType(group.registerType)) {
+    Object.keys(values.coils || {}).forEach((addressText) => {
+      wordCache[parseHexInteger(addressText)] = Number(values.coils[addressText]) ? 1 : 0
+    })
+  } else {
+    Object.keys(values.words || {}).forEach((addressText) => {
+      wordCache[parseHexInteger(addressText)] = Number(values.words[addressText]) & 0xFFFF
+    })
+  }
+
+  return wordCache
+}
+
+function getRegisterWordsForModbusWrite(group) {
+  const words = group.isStructLayout
+    ? getGroupEncodedWords(group)
+    : Array.from({ length: Math.max(1, group.wordQuantity || 1) }, () => 0)
+
+  if (group.isStructLayout) return words
+
+  for (let index = 0; index < group.registers.length; index += 1) {
+    const register = group.registers[index]
+    const registerWords = getRegisterEncodedWords(register)
+
+    if (!Array.isArray(registerWords) || !registerWords.length) {
+      throw new Error(`${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
+    }
+
+    const dataType = getDataType(register.dataType).key
+    const relativeAddress = Math.max(0, register.address - group.startAddress)
+    if (isByteRegister(dataType)) {
+      const byteValue = Number(registerWords[0]) & 0xFF
+      const currentWord = words[relativeAddress] || 0
+      words[relativeAddress] = register.byteOffset === 0
+        ? (((byteValue << 8) | (currentWord & 0x00FF)) & 0xFFFF)
+        : (((currentWord & 0xFF00) | byteValue) & 0xFFFF)
+    } else {
+      for (let offset = 0; offset < register.registerCount; offset += 1) {
+        words[relativeAddress + offset] = Number(registerWords[offset]) & 0xFFFF
+      }
+    }
+  }
+
+  return words
+}
+
+function createModbusWrittenRegisterSnapshots(group, words = []) {
+  const writtenWordCache = words.reduce((cache, word, offset) => {
+    cache[group.startAddress + offset] = word
+    return cache
+  }, {})
+
+  return group.registers.map((register) => {
+    const rawWords = getRegisterWordsFromWordCache(register, writtenWordCache) || []
+    const rawValue = decodeRegisterValue(register, rawWords)
+    const displayValue = formatRegisterValue(register, rawValue)
+
+    return {
+      rawWords,
+      rawValue,
+      displayValue
+    }
+  })
+}
+
+async function writeModbusCoilGroup(group, options = {}) {
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
+  if (slaveAddress === null) return null
+
+  const writtenRegisters = []
+  for (let index = 0; index < group.registers.length; index += 1) {
+    const register = group.registers[index]
+    const coilValue = parseCoilValue(getRegisterWriteValueText(register))
+
+    if (coilValue === null) {
+      transport.showCommandAlert('参数组写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
+      return null
+    }
+
+    const response = await modbusClient.writeSingleCoil(
+      slaveAddress,
+      group.startAddress + index,
+      !!coilValue,
+      register.name || group.name || '参数组写入',
+      'parameter-group-coil-write',
+      {
+        maxFrameBytes: options.maxPacketLength
+      }
+    )
+    if (!response) return null
+
+    writtenRegisters.push({
+      rawBytes: [],
+      rawValue: coilValue,
+      rawWords: [],
+      displayValue: formatCoilDisplayValue(coilValue)
+    })
+  }
+
+  return writtenRegisters
+}
+
+async function writeModbusRegisterGroup(group, options = {}) {
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
+  if (slaveAddress === null) return null
+
+  let words
+  try {
+    words = getRegisterWordsForModbusWrite(group)
+  } catch (error) {
+    transport.showCommandAlert('参数组写入', error.message || '寄存器组没有有效写入值')
+    return null
+  }
+
+  const writtenRegisters = createModbusWrittenRegisterSnapshots(group, words)
+  const maxWriteQuantity = getWriteSpanMaxQuantity(words.length, options.maxPacketLength)
+  const spans = splitWordSpans(group.startAddress, words.length, maxWriteQuantity)
+  let cursor = 0
+
+  for (const span of spans) {
+    const spanWords = words.slice(cursor, cursor + span.quantity)
+    cursor += span.quantity
+
+    const response = await modbusClient.writeMultipleRegisters(
+      slaveAddress,
+      span.address,
+      spanWords,
+      group.name || '参数组写入',
+      'parameter-group-write',
+      {
+        maxFrameBytes: options.maxPacketLength
+      }
+    )
+    if (!response) return null
+  }
+
+  return writtenRegisters
+}
+
+async function writeModbusGroup(group, options = {}) {
+  return group.registerType === 'coil'
+    ? writeModbusCoilGroup(group, options)
+    : writeModbusRegisterGroup(group, options)
+}
+
+function bytesToPaddedWords(bytes = []) {
+  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
+}
+
+function fillByteCacheFromBytes(byteCache, startAddress, bytes = []) {
+  bytes.forEach((byte, offset) => {
+    byteCache[startAddress + offset] = Number(byte) & 0xFF
+  })
+}
+
+function fillWordCacheFromBytes(wordCache, startAddress, bytes = []) {
+  const words = bytesToPaddedWords(bytes)
+
+  words.forEach((word, offset) => {
+    wordCache[startAddress + offset] = Number(word) & 0xFFFF
+  })
+}
+
+function createStorageWrittenRegisterSnapshots(group, wordCache) {
+  return group.registers.map((register) => {
+    const rawBytes = isByteAddressedGroup(group)
+      ? (getRegisterBytesFromByteCache(register, wordCache) || [])
+      : []
+    const rawWords = isByteAddressedGroup(group)
+      ? (getRegisterWordsFromByteCache(register, wordCache) || [])
+      : (getRegisterWordsFromWordCache(register, wordCache) || [])
+    const rawValue = isByteAddressedGroup(group)
+      ? decodeRegisterFromByteCache(register, wordCache)
+      : decodeRegisterValue(register, rawWords)
+
+    return {
+      rawBytes,
+      rawWords,
+      rawValue,
+      displayValue: formatRegisterValue(register, rawValue)
+    }
+  })
+}
+
+function createStorageWrittenRegisterSnapshot(group, register, byteCache) {
+  const snapshots = createStorageWrittenRegisterSnapshots({
+    ...group,
+    registers: [register]
+  }, byteCache)
+
+  return snapshots[0] || null
+}
+
+function groupHasBitFields(group = {}) {
+  return (Array.isArray(group.registers) ? group.registers : []).some((register) => !!register.isBitField)
+}
+
+async function writeMemoryRegister(group, register, options = {}) {
+  const memoryType = getMemoryType(group)
+  const maxPacketLength = getStorageMaxPacketLength(group, options)
+  const byteLength = Math.max(1, Number(register.byteLength) || 1)
+  const address = Math.min(MAX_STORAGE_ACCESS_ADDRESS, Math.max(0, Math.floor(Number(register.address) || getMemoryAddress(group))))
+  let bytes
+
+  if (memoryType === null) {
+    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
+    return null
+  }
+  if (memoryType === STORAGE_ACCESS_CODE_AREA) {
+    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
+    return null
+  }
+
+  try {
+    if (register.isBitField) {
+      const baseBytes = await storageAccessService.readMemory(
+        memoryType,
+        address,
+        byteLength,
+        register.name ? `${register.name} 读改写` : '变量读改写',
+        'parameter-group-memory-register-rmw-read',
+        {
+          maxFrameBytes: maxPacketLength
+        }
+      )
+      if (!baseBytes) return null
+
+      bytes = getGroupEncodedBytes({
+        ...group,
+        paddedByteLength: byteLength,
+        registers: [{
+          ...register,
+          address,
+          byteStart: 0
+        }]
+      }, baseBytes).slice(0, byteLength)
+    } else {
+      bytes = getRegisterEncodedBytes(register)
+    }
+  } catch (error) {
+    transport.showCommandAlert('内存写入', error.message || '变量没有有效写入值')
+    return null
+  }
+
+  if (!Array.isArray(bytes) || !bytes.length) {
+    transport.showCommandAlert('内存写入', `${register.name || '变量'} 没有有效写入值`)
+    return null
+  }
+
+  bytes = bytes.slice(0, byteLength)
+  while (bytes.length < byteLength) bytes.push(0)
+
+  const ok = await storageAccessService.writeMemory(
+    memoryType,
+    address,
+    bytes,
+    register.name || group.name || '变量写入',
+    'parameter-group-memory-register-write',
+    {
+      maxFrameBytes: maxPacketLength
+    }
+  )
+  if (!ok) return null
+
+  const byteCache = {}
+  fillByteCacheFromBytes(byteCache, address, bytes)
+
+  return createStorageWrittenRegisterSnapshot(group, register, byteCache)
+}
+
+async function readMemoryGroup(group, options = {}) {
+  const memoryType = getMemoryType(group)
+  const address = getMemoryAddress(group)
+  const byteLength = getMemoryByteLength(group)
+  const maxPacketLength = getStorageMaxPacketLength(group, options)
+
+  if (memoryType === null) {
+    transport.showCommandAlert('内存读取', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
+    return null
+  }
+
+  const bytes = await storageAccessService.readMemory(
+    memoryType,
+    address,
+    byteLength,
+    group.name || '内存读取',
+    'parameter-group-memory-read',
+    {
+      maxFrameBytes: maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+  if (!bytes) return null
+
+  const wordCache = {}
+  if (isByteAddressedGroup(group)) {
+    fillByteCacheFromBytes(wordCache, address, bytes)
+  } else {
+    fillWordCacheFromBytes(wordCache, address, bytes)
+  }
+
+  return wordCache
+}
+
+async function writeMemoryGroup(group, options = {}) {
+  const memoryType = getMemoryType(group)
+  const address = getMemoryAddress(group)
+  const byteLength = getMemoryByteLength(group)
+  const maxPacketLength = getStorageMaxPacketLength(group, options)
+  let bytes
+
+  if (memoryType === null) {
+    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
+    return null
+  }
+  if (memoryType === STORAGE_ACCESS_CODE_AREA) {
+    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
+    return null
+  }
+
+  try {
+    let baseBytes = null
+    if (groupHasBitFields(group)) {
+      baseBytes = await storageAccessService.readMemory(
+        memoryType,
+        address,
+        byteLength,
+        group.name ? `${group.name} 读改写` : '内存读改写',
+        'parameter-group-memory-rmw-read',
+        {
+          maxFrameBytes: maxPacketLength
+        }
+      )
+      if (!baseBytes) return null
+    }
+
+    bytes = getGroupEncodedBytes(group, baseBytes)
+  } catch (error) {
+    transport.showCommandAlert('内存写入', error.message || '寄存器组没有有效写入值')
+    return null
+  }
+
+  bytes = bytes.slice(0, byteLength)
+  const ok = await storageAccessService.writeMemory(
+    memoryType,
+    address,
+    bytes,
+    group.name || '内存写入',
+    'parameter-group-memory-write',
+    {
+      maxFrameBytes: maxPacketLength
+    }
+  )
+  if (!ok) return null
+
+  const wordCache = {}
+  if (isByteAddressedGroup(group)) {
+    fillByteCacheFromBytes(wordCache, address, bytes)
+  } else {
+    fillWordCacheFromBytes(wordCache, address, bytes)
+  }
+
+  return createStorageWrittenRegisterSnapshots(group, wordCache)
+}
+
+async function readGroup(group, options = {}) {
+  const wordCache = shouldUseStorageAccess(options, group)
+    ? await readMemoryGroup(group, options)
+    : await readModbusGroup(group, options)
+  if (!wordCache) return null
+
+  const normalizedCache = normalizeNumericCache(wordCache)
+
+  return {
+    applyToGroup(currentGroup) {
+      return applyReadCacheToGroup(currentGroup, normalizedCache)
+    }
+  }
+}
+
+async function writeRegister(group, registerIndex, options = {}) {
+  const register = group && Array.isArray(group.registers) ? group.registers[registerIndex] : null
+  if (!register) return null
+
+  if (shouldUseStorageAccess(options, group)) {
+    const written = await writeMemoryRegister(group, register, options)
+
+    return written
+      ? {
+        applyToGroup(currentGroup) {
+          return applyWrittenSnapshotToGroupRegister(currentGroup, registerIndex, written)
+        }
+      }
+      : null
+  }
+
+  return writeGroup(group, options)
+}
+
+async function writeGroup(group, options = {}) {
+  const snapshots = shouldUseStorageAccess(options, group)
+    ? await writeMemoryGroup(group, options)
+    : await writeModbusGroup(group, options)
+  if (!snapshots) return null
+
+  return {
+    applyToGroup(currentGroup) {
+      return applyWrittenSnapshotsToGroup(currentGroup, snapshots)
+    }
+  }
+}
+
+module.exports = {
+  getMemoryAddress,
+  getMemoryByteLength,
+  getMemoryType,
+  isByteAddressedGroup,
+  isMemoryGroup,
+  readGroup,
+  writeGroup,
+  writeRegister
+}

+ 0 - 197
features/parameter-groups/modbus-io.js

@@ -1,197 +0,0 @@
-const {
-  parseHexInteger
-} = require('../../utils/base-utils.js')
-const transport = require('../../transport/ble-core.js')
-const modbusProtocol = require('../../protocols/modbus-rtu/index.js')
-const {
-  decodeRegisterValue,
-  formatCoilDisplayValue,
-  formatRegisterValue,
-  getDataType,
-  getGroupEncodedWords,
-  getRegisterEncodedWords,
-  getRegisterWordsFromWordCache,
-  getRegisterWriteValueText,
-  isBitRegisterType,
-  isByteRegister,
-  parseCoilValue,
-  splitWordSpans
-} = require('../../domain/parameter-groups/model.js')
-
-function getWriteSpanMaxQuantity(totalQuantity, maxPacketLength) {
-  if (maxPacketLength === 0) return Math.max(1, totalQuantity)
-
-  return Math.max(1, modbusProtocol.client.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
-}
-
-async function readGroup(group, options = {}) {
-  const totalQuantity = Math.max(1, group.wordQuantity || group.quantity || 0)
-  const wordCache = {}
-  const slaveAddress = modbusProtocol.client.getSharedSlaveAddress()
-  if (slaveAddress === null) return null
-
-  const values = await modbusProtocol.client.readSpans(
-    slaveAddress,
-    group.functionCode,
-    [{
-      address: group.startAddress,
-      quantity: totalQuantity
-    }],
-    group.name || '参数组读取',
-    'parameter-group-read',
-    {
-      maxFrameBytes: options.maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-  if (!values) return null
-
-  if (isBitRegisterType(group.registerType)) {
-    Object.keys(values.coils || {}).forEach((addressText) => {
-      wordCache[parseHexInteger(addressText)] = Number(values.coils[addressText]) ? 1 : 0
-    })
-  } else {
-    Object.keys(values.words || {}).forEach((addressText) => {
-      wordCache[parseHexInteger(addressText)] = Number(values.words[addressText]) & 0xFFFF
-    })
-  }
-
-  return wordCache
-}
-
-function getRegisterWordsForWrite(group) {
-  const words = group.isStructLayout
-    ? getGroupEncodedWords(group)
-    : Array.from({ length: Math.max(1, group.wordQuantity || 1) }, () => 0)
-
-  if (group.isStructLayout) return words
-
-  for (let index = 0; index < group.registers.length; index += 1) {
-    const register = group.registers[index]
-    const registerWords = getRegisterEncodedWords(register)
-
-    if (!Array.isArray(registerWords) || !registerWords.length) {
-      throw new Error(`${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
-    }
-
-    const dataType = getDataType(register.dataType).key
-    const relativeAddress = Math.max(0, register.address - group.startAddress)
-    if (isByteRegister(dataType)) {
-      const byteValue = Number(registerWords[0]) & 0xFF
-      const currentWord = words[relativeAddress] || 0
-      words[relativeAddress] = register.byteOffset === 0
-        ? (((byteValue << 8) | (currentWord & 0x00FF)) & 0xFFFF)
-        : (((currentWord & 0xFF00) | byteValue) & 0xFFFF)
-    } else {
-      for (let offset = 0; offset < register.registerCount; offset += 1) {
-        words[relativeAddress + offset] = Number(registerWords[offset]) & 0xFFFF
-      }
-    }
-  }
-
-  return words
-}
-
-function createWrittenRegisterSnapshots(group, words = []) {
-  const writtenWordCache = words.reduce((cache, word, offset) => {
-    cache[group.startAddress + offset] = word
-    return cache
-  }, {})
-
-  return group.registers.map((register) => {
-    const rawWords = getRegisterWordsFromWordCache(register, writtenWordCache) || []
-    const rawValue = decodeRegisterValue(register, rawWords)
-    const displayValue = formatRegisterValue(register, rawValue)
-
-    return {
-      rawWords,
-      rawValue,
-      displayValue
-    }
-  })
-}
-
-async function writeCoilGroup(group, options = {}) {
-  const slaveAddress = modbusProtocol.client.getSharedSlaveAddress()
-  if (slaveAddress === null) return null
-
-  const writtenRegisters = []
-  for (let index = 0; index < group.registers.length; index += 1) {
-    const register = group.registers[index]
-    const coilValue = parseCoilValue(getRegisterWriteValueText(register))
-
-    if (coilValue === null) {
-      transport.showCommandAlert('参数组写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
-      return null
-    }
-
-    const response = await modbusProtocol.client.writeSingleCoil(
-      slaveAddress,
-      group.startAddress + index,
-      !!coilValue,
-      register.name || group.name || '参数组写入',
-      'parameter-group-coil-write',
-      {
-        maxFrameBytes: options.maxPacketLength
-      }
-    )
-    if (!response) return null
-
-    writtenRegisters.push({
-      rawBytes: [],
-      rawValue: coilValue,
-      rawWords: [],
-      displayValue: formatCoilDisplayValue(coilValue)
-    })
-  }
-
-  return writtenRegisters
-}
-
-async function writeRegisterGroup(group, options = {}) {
-  const slaveAddress = modbusProtocol.client.getSharedSlaveAddress()
-  if (slaveAddress === null) return null
-
-  let words
-  try {
-    words = getRegisterWordsForWrite(group)
-  } catch (error) {
-    transport.showCommandAlert('参数组写入', error.message || '寄存器组没有有效写入值')
-    return null
-  }
-
-  const writtenRegisters = createWrittenRegisterSnapshots(group, words)
-  const maxWriteQuantity = getWriteSpanMaxQuantity(words.length, options.maxPacketLength)
-  const spans = splitWordSpans(group.startAddress, words.length, maxWriteQuantity)
-  let cursor = 0
-
-  for (const span of spans) {
-    const spanWords = words.slice(cursor, cursor + span.quantity)
-    cursor += span.quantity
-
-    const response = await modbusProtocol.client.writeMultipleRegisters(
-      slaveAddress,
-      span.address,
-      spanWords,
-      group.name || '参数组写入',
-      'parameter-group-write',
-      {
-        maxFrameBytes: options.maxPacketLength
-      }
-    )
-    if (!response) return null
-  }
-
-  return writtenRegisters
-}
-
-async function writeGroup(group, options = {}) {
-  return group.registerType === 'coil'
-    ? writeCoilGroup(group, options)
-    : writeRegisterGroup(group, options)
-}
-
-module.exports = {
-  readGroup,
-  writeGroup
-}

+ 23 - 92
features/parameter-groups/persistence.js

@@ -5,67 +5,31 @@ const {
   saveTextFileToChat
 } = require('../../repositories/file.js')
 const {
-  getWxApi
-} = require('../../utils/platform-utils.js')
+  getWxApi,
+  pickFields
+} = require('../../utils/base-utils.js')
 const {
   cloneImportedGroup,
   getRegisterJsonValue
 } = require('../../domain/parameter-groups/model.js')
+const {
+  SOURCE_GROUP_FIELDS,
+  SOURCE_REGISTER_FIELDS,
+  STRUCT_REGISTER_FIELDS
+} = require('../../domain/parameter-groups/constants.js')
+const {
+  PROTOCOL_MODE,
+  isModbusProtocolMode
+} = require('../../domain/protocol-mode.js')
 
 const STORAGE_KEY = 'parameter-groups-json'
-const STORAGE_MIGRATION_KEY = 'parameter-groups-json-protocol-migration'
 const JSON_DOCUMENT_TYPE = 'parameter-groups'
 const JSON_SCHEMA_VERSION = 2
-const PROTOCOL_MODE = {
-  MODBUS_RTU: 'modbus-rtu',
-  STORAGE_ACCESS: 'storage-access'
-}
-const GROUP_SOURCE_FIELDS = [
-  'addressUnit',
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSegment',
-  'sourceSegmentModule',
-  'sourceSymbolName'
-]
-const REGISTER_SOURCE_FIELDS = [
-  'conversionFormula',
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceBitOffset',
-  'sourceBitWidth',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSymbolName',
-  'sourceSymbolType'
-]
-const REGISTER_STRUCT_FIELDS = [
-  'bitOffset',
-  'bitWidth',
-  'byteStart',
-  'isPlaceholderByteField',
-  'isBitField',
-  'structByteLength'
-]
-
-function pickFields(source, fields) {
-  return fields.reduce((result, field) => {
-    if (source && source[field] !== undefined && source[field] !== null && source[field] !== '') {
-      result[field] = source[field]
-    }
-
-    return result
-  }, {})
-}
-
 function toPersistedGroups(groups = []) {
   return groups.map((group) => ({
     layout: group.layout,
     name: group.name,
+    ...(group.pollEnabled === false ? { pollEnabled: false } : {}),
     registerType: group.registerType,
     startAddress: group.startAddress,
     quantity: group.quantity,
@@ -81,10 +45,10 @@ function toPersistedGroups(groups = []) {
       remark: register.remark,
       unit: register.unit,
       value: getRegisterJsonValue(register),
-      ...pickFields(register, REGISTER_STRUCT_FIELDS),
-      ...pickFields(register, REGISTER_SOURCE_FIELDS)
+      ...pickFields(register, STRUCT_REGISTER_FIELDS),
+      ...pickFields(register, SOURCE_REGISTER_FIELDS)
     })),
-    ...pickFields(group, GROUP_SOURCE_FIELDS)
+    ...pickFields(group, SOURCE_GROUP_FIELDS)
   }))
 }
 
@@ -107,7 +71,7 @@ function toJsonText(groups = [], options = {}) {
 }
 
 function normalizeProtocolMode(protocolMode) {
-  return protocolMode === PROTOCOL_MODE.MODBUS_RTU
+  return isModbusProtocolMode(protocolMode)
     ? PROTOCOL_MODE.MODBUS_RTU
     : PROTOCOL_MODE.STORAGE_ACCESS
 }
@@ -161,46 +125,12 @@ function persistGroups(groups = [], protocolMode = PROTOCOL_MODE.STORAGE_ACCESS)
   } catch (error) {}
 }
 
-function legacyGroupsNeedMigration() {
-  const wxApi = getWxApi()
-  if (typeof wxApi.getStorageSync !== 'function') return false
-
-  try {
-    return !!wxApi.getStorageSync(STORAGE_KEY) && !wxApi.getStorageSync(STORAGE_MIGRATION_KEY)
-  } catch (error) {
-    return false
-  }
-}
-
-function migrateLegacyGroupsToProtocol(protocolMode = PROTOCOL_MODE.STORAGE_ACCESS) {
-  const wxApi = getWxApi()
-  if (typeof wxApi.getStorageSync !== 'function' || typeof wxApi.setStorageSync !== 'function') return false
-
-  try {
-    if (!legacyGroupsNeedMigration()) return false
-
-    const normalizedProtocolMode = normalizeProtocolMode(protocolMode)
-    const legacyJsonText = wxApi.getStorageSync(STORAGE_KEY)
-    const targetStorageKey = getProtocolStorageKey(normalizedProtocolMode)
-    const targetJsonText = wxApi.getStorageSync(targetStorageKey)
-
-    if (legacyJsonText && !targetJsonText) {
-      persistGroups(parseJsonGroups(legacyJsonText).map(cloneImportedGroup), normalizedProtocolMode)
-    }
-
-    wxApi.setStorageSync(STORAGE_MIGRATION_KEY, normalizedProtocolMode)
-    return true
-  } catch (error) {
-    return false
-  }
-}
-
 function getShareFileName() {
   return `parameter-groups-${formatExportStamp()}.json`
 }
 
 async function loadGroupsFromMessageFile() {
-  const file = await loadSelectedFile('message', {
+  const file = await loadSelectedFile('auto', {
     encoding: 'utf8',
     extensionMessage: '请选择 .json 寄存器配置文件',
     extensions: ['json'],
@@ -219,9 +149,12 @@ async function saveGroupsToChat(groups = []) {
     includeExportedAt: true
   })
 
-  await saveTextFileToChat(getShareFileName(), jsonText)
+  const result = await saveTextFileToChat(getShareFileName(), jsonText)
 
-  return groups.length
+  return {
+    count: groups.length,
+    ...result
+  }
 }
 
 module.exports = {
@@ -229,9 +162,7 @@ module.exports = {
   JSON_SCHEMA_VERSION,
   STORAGE_KEY,
   isCancelError,
-  legacyGroupsNeedMigration,
   loadGroupsFromMessageFile,
-  migrateLegacyGroupsToProtocol,
   parseJsonGroups,
   persistGroups,
   readStoredGroups,

+ 13 - 6
features/parameter-groups/poller.js

@@ -1,12 +1,16 @@
 const parameterGroupService = require('./service.js')
+const {
+  isParameterGroupPollEnabled
+} = require('../../domain/parameter-groups/model.js')
 
 const POLL_TIMER_ID = '__parameterPoll'
 const MEMORY_AREA_ORDER = {
-  DATA: 0,
-  IDATA: 1,
-  XDATA: 2,
-  CODE: 3,
-  BIT: 4
+  ADDR32: 0,
+  DATA: 1,
+  IDATA: 2,
+  XDATA: 3,
+  CODE: 4,
+  BIT: 5
 }
 
 function getMemoryAreaOrder(group = {}) {
@@ -38,8 +42,11 @@ function getPollableGroups(data) {
   return getParameterGroups(data)
     .filter((group) => {
       if (!group || group.addressOverflow) return false
+      if (!isParameterGroupPollEnabled(group)) return false
       if ((group.registers || []).some((register) => register && register.isDirty)) return false
-      if (data.isStorageAccessProtocol) return !!group.sourceMemoryArea
+      if (data.isStorageAccessProtocol) {
+        return !!group.sourceMemoryArea
+      }
 
       return !!group.functionCode
     })

+ 78 - 67
features/parameter-groups/service.js

@@ -3,25 +3,22 @@ const {
   loadGroupsFromMessageFile,
   saveGroupsToChat
 } = require('./persistence.js')
-const {
-  mergeImportedGroups
-} = require('./import-merge.js')
 const {
   completeStructInstanceGroups,
+  mergeImportedGroups,
   parseStructDefinition
-} = require('./struct-completion.js')
+} = require('./imports.js')
 const storageAccessService = require('../storage-access/service.js')
-const storageAccessIo = require('./storage-access-io.js')
-const modbusIo = require('./modbus-io.js')
+const parameterGroupIo = require('./io.js')
 const settingsService = require('../../store/settings-store.js')
 const store = require('./store.js')
-const stateMappers = require('./state-mappers.js')
 const {
   loadSelectedFile
 } = require('../../repositories/file.js')
 const transport = require('../../transport/ble-core.js')
 const {
   DATA_TYPE_OPTIONS,
+  MAX_STORAGE_ADDRESS,
   REGISTER_TYPE_OPTIONS,
   cloneImportedGroup,
   isAddressRangeOverflow,
@@ -51,6 +48,23 @@ function isStorageAccessProtocolMode(protocolMode) {
   return settingsService.isStorageAccessProtocol(protocolMode)
 }
 
+function isGroupAddressRangeOverflow(group = {}, protocolMode = getActiveProtocolMode()) {
+  if (!isStorageAccessProtocolMode(protocolMode)) {
+    return isAddressRangeOverflow(group.startAddress, group.quantity)
+  }
+
+  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))
+
+  return startAddress + addressSpan - 1 > MAX_STORAGE_ADDRESS
+}
+
+function getAddressOverflowText(protocolMode = getActiveProtocolMode()) {
+  return isStorageAccessProtocolMode(protocolMode)
+    ? '地址范围超出 0xFFFFFFFF'
+    : '地址范围超出 0xFFFF'
+}
+
 function initParameterGroups() {
   settingsService.init()
   init(getActiveProtocolMode())
@@ -105,19 +119,28 @@ function mergeImportedGroupsIntoState(importedGroups = [], options = {}) {
   return merged
 }
 
+function clearStorageAccessGroups() {
+  setGroups([], {
+    protocolMode: settingsService.PROTOCOL_MODE.STORAGE_ACCESS
+  })
+  setStorageCodeInfo(null)
+
+  return true
+}
+
 async function completeStructInstanceGroupsWithStructFile(options = {}) {
   try {
-    const file = await loadSelectedFile('message', {
+    const file = await loadSelectedFile('auto', {
       encoding: 'utf8',
-      extensionMessage: '请选择 .h 或 .c 结构体定义文件',
+      extensionMessage: '请选择 .h 或 .c 结构体/枚举定义文件',
       extensions: ['h', 'c', 'txt'],
       fallbackName: 'structs.h'
     })
 
     return completeStructInstanceGroupsWithStructSource(file.text, options)
   } catch (error) {
-    const message = error && error.message ? error.message : '结构体补全失败'
-    transport.showCommandAlert('结构体补全', message)
+    const message = error && error.message ? error.message : '结构体/枚举补全失败'
+    transport.showCommandAlert('结构体/枚举补全', message)
 
     return {
       completedCount: 0,
@@ -130,7 +153,13 @@ async function completeStructInstanceGroupsWithStructFile(options = {}) {
 
 async function saveJsonToChat() {
   try {
-    return saveGroupsToChat(getGroups())
+    const result = await saveGroupsToChat(getGroups())
+
+    return result && result.count
+      ? result
+      : {
+        count: 0
+      }
   } catch (error) {
     const message = error && error.message ? error.message : '保存参数组配置失败'
 
@@ -138,7 +167,9 @@ async function saveJsonToChat() {
       transport.showCommandAlert('参数组保存', message)
     }
 
-    return 0
+    return {
+      count: 0
+    }
   }
 }
 
@@ -149,6 +180,7 @@ async function syncFromStorageAccessCodeInfo(options = {}) {
   setStorageCodeInfo(result.codeInfo)
   const merged = mergeImportedGroupsIntoState(result.importedGroups || [], {
     preserveExistingRemarks: true,
+    preserveExistingPollEnabled: true,
     preserveExistingStructLayout: true
   })
 
@@ -162,17 +194,21 @@ async function syncFromStorageAccessCodeInfo(options = {}) {
 }
 
 function addGroupFromConfig(config = {}) {
+  const protocolMode = getActiveProtocolMode()
   let groupConfig
 
   try {
-    groupConfig = normalizeGroupConfig(config)
+    groupConfig = normalizeGroupConfig({
+      ...(isStorageAccessProtocolMode(protocolMode) ? { addressUnit: 'byte', sourceMemoryArea: config.sourceMemoryArea || 'XDATA' } : {}),
+      ...config
+    })
   } catch (error) {
     transport.showCommandAlert('参数组添加', error.message || '寄存器组配置无效')
     return null
   }
 
-  if (isAddressRangeOverflow(groupConfig.startAddress, groupConfig.quantity)) {
-    transport.showCommandAlert('参数组添加', '地址范围超出 0xFFFF')
+  if (isGroupAddressRangeOverflow(groupConfig, protocolMode)) {
+    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode))
     return null
   }
 
@@ -185,7 +221,7 @@ function addGroupFromConfig(config = {}) {
   })
 
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组添加', '地址范围超出 0xFFFF')
+    transport.showCommandAlert('参数组添加', getAddressOverflowText(protocolMode))
     return null
   }
 
@@ -195,6 +231,7 @@ function addGroupFromConfig(config = {}) {
 }
 
 function updateGroupConfig(groupId, config = {}) {
+  const protocolMode = getActiveProtocolMode()
   const group = findGroup(groupId)
   if (!group) return null
 
@@ -209,8 +246,8 @@ function updateGroupConfig(groupId, config = {}) {
     return null
   }
 
-  if (isAddressRangeOverflow(nextConfig.startAddress, nextConfig.quantity)) {
-    transport.showCommandAlert('参数组更新', '地址范围超出 0xFFFF')
+  if (isGroupAddressRangeOverflow(nextConfig, protocolMode)) {
+    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode))
     return null
   }
 
@@ -222,7 +259,7 @@ function updateGroupConfig(groupId, config = {}) {
   })
 
   if (updatedGroup.addressOverflow) {
-    transport.showCommandAlert('参数组更新', '地址范围超出 0xFFFF')
+    transport.showCommandAlert('参数组更新', getAddressOverflowText(protocolMode))
     return null
   }
 
@@ -329,28 +366,20 @@ async function readGroup(groupId, options = {}) {
   const group = findGroup(groupId, protocolMode)
   if (!group) return false
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组读取', '地址范围超出 0xFFFF')
+    transport.showCommandAlert('参数组读取', getAddressOverflowText(protocolMode))
     return false
   }
 
-  let wordCache = {}
-
-  if (isStorageAccessProtocolMode(protocolMode) && storageAccessIo.isMemoryGroup(group)) {
-    const memoryWordCache = await storageAccessIo.readMemoryGroup(group, options)
-    if (!memoryWordCache) return false
-
-    wordCache = stateMappers.normalizeNumericCache(memoryWordCache)
-  } else {
-    const modbusWordCache = await modbusIo.readGroup(group, options)
-    if (!modbusWordCache) return false
-
-    wordCache = stateMappers.normalizeNumericCache(modbusWordCache)
-  }
+  const readResult = await parameterGroupIo.readGroup(group, {
+    ...options,
+    useStorageAccess: isStorageAccessProtocolMode(protocolMode)
+  })
+  if (!readResult) return false
 
   updateGroups((item) => {
     if (item.id !== groupId) return item
 
-    return stateMappers.applyReadCacheToGroup(item, wordCache)
+    return readResult.applyToGroup(item)
   }, {
     protocolMode
   })
@@ -368,26 +397,19 @@ async function writeRegister(groupId, registerIndex) {
     return false
   }
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组写入', '地址范围超出 0xFFFF')
+    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode))
     return false
   }
 
-  const written = isStorageAccessProtocolMode(protocolMode) && storageAccessIo.isMemoryGroup(group)
-    ? await storageAccessIo.writeMemoryRegister(group, register)
-    : null
-  if (!written) {
-    if (!isStorageAccessProtocolMode(protocolMode) || !storageAccessIo.isMemoryGroup(group)) {
-      return writeGroup(groupId, {
-        protocolMode
-      })
-    }
-    return false
-  }
+  const writeResult = await parameterGroupIo.writeRegister(group, registerIndex, {
+    useStorageAccess: isStorageAccessProtocolMode(protocolMode)
+  })
+  if (!writeResult) return false
 
   updateGroups((item) => {
     if (item.id !== groupId) return item
 
-    return stateMappers.applyWrittenSnapshotToGroupRegister(item, registerIndex, written)
+    return writeResult.applyToGroup(item)
   }, {
     protocolMode
   })
@@ -404,32 +426,20 @@ async function writeGroup(groupId, options = {}) {
     return false
   }
   if (group.addressOverflow) {
-    transport.showCommandAlert('参数组写入', '地址范围超出 0xFFFF')
+    transport.showCommandAlert('参数组写入', getAddressOverflowText(protocolMode))
     return false
   }
 
-  const writtenRegisters = []
-
-  if (isStorageAccessProtocolMode(protocolMode) && storageAccessIo.isMemoryGroup(group)) {
-    const snapshots = await storageAccessIo.writeMemoryGroup(group)
-    if (!snapshots) return false
-
-    snapshots.forEach((snapshot) => {
-      writtenRegisters.push(snapshot)
-    })
-  } else {
-    const snapshots = await modbusIo.writeGroup(group, options)
-    if (!snapshots) return false
-
-    snapshots.forEach((snapshot) => {
-      writtenRegisters.push(snapshot)
-    })
-  }
+  const writeResult = await parameterGroupIo.writeGroup(group, {
+    ...options,
+    useStorageAccess: isStorageAccessProtocolMode(protocolMode)
+  })
+  if (!writeResult) return false
 
   updateGroups((item) => {
     if (item.id !== groupId) return item
 
-    return stateMappers.applyWrittenSnapshotsToGroup(item, writtenRegisters)
+    return writeResult.applyToGroup(item)
   }, {
     protocolMode
   })
@@ -441,6 +451,7 @@ module.exports = {
   DATA_TYPE_OPTIONS,
   REGISTER_TYPE_OPTIONS,
   addGroupFromConfig,
+  clearStorageAccessGroups,
   completeStructInstanceGroups,
   completeStructInstanceGroupsWithStructFile,
   completeStructInstanceGroupsWithStructSource,

+ 0 - 136
features/parameter-groups/state-mappers.js

@@ -1,136 +0,0 @@
-const storageAccessIo = require('./storage-access-io.js')
-const {
-  decodeRegisterFromByteCache,
-  decodeRegisterFromWordCache,
-  decodeRegisterValue,
-  formatCoilDisplayValue,
-  formatRegisterValue,
-  normalizeRegister,
-  getRegisterBytesFromByteCache,
-  getRegisterWordsFromByteCache,
-  getRegisterWordsFromWordCache,
-  registerTypeIsBit
-} = require('../../domain/parameter-groups/model.js')
-
-function normalizeNumericCache(source = {}) {
-  const cache = {}
-
-  Object.keys(source || {}).forEach((addressText) => {
-    const numericAddress = Number(addressText)
-    if (Number.isFinite(numericAddress)) {
-      cache[numericAddress] = Number(source[addressText]) & 0xFFFF
-    }
-  })
-
-  return cache
-}
-
-function createRegisterStateFromCache(group, register, wordCache) {
-  const rawBytes = registerTypeIsBit(register)
-    ? []
-    : (storageAccessIo.isByteAddressedGroup(group) ? getRegisterBytesFromByteCache(register, wordCache) : [])
-  const rawWords = registerTypeIsBit(register)
-    ? []
-    : (storageAccessIo.isByteAddressedGroup(group)
-      ? getRegisterWordsFromByteCache(register, wordCache)
-      : getRegisterWordsFromWordCache(register, wordCache))
-  const rawValue = registerTypeIsBit(register)
-    ? decodeRegisterFromWordCache(register, wordCache)
-    : (storageAccessIo.isByteAddressedGroup(group)
-      ? decodeRegisterFromByteCache(register, wordCache)
-      : (rawWords ? decodeRegisterValue(register, rawWords) : null))
-  const displayValue = rawValue === null || rawValue === undefined
-    ? '--'
-    : (registerTypeIsBit(register)
-      ? formatCoilDisplayValue(rawValue)
-      : formatRegisterValue(register, rawValue))
-  const preNormalizedRegister = {
-    ...register,
-    displayValue,
-    isDirty: false,
-    rawBytes: rawBytes || [],
-    rawValue,
-    rawWords: rawWords || []
-  }
-  const nextRegister = normalizeRegister(preNormalizedRegister, group, 0, register.address, register.byteOffset)
-
-  return {
-    ...nextRegister,
-    id: register.id,
-    inputValue: group.writable ? displayValue : register.inputValue
-  }
-}
-
-function applyReadCacheToGroup(group, wordCache) {
-  return {
-    ...group,
-    registers: group.registers.map((register) => createRegisterStateFromCache(group, register, wordCache))
-  }
-}
-
-function applyWrittenSnapshotToRegister(register, snapshot = {}) {
-  const hasDisplayValue = Object.prototype.hasOwnProperty.call(snapshot, 'displayValue')
-  const hasRawBytes = Object.prototype.hasOwnProperty.call(snapshot, 'rawBytes')
-  const hasRawValue = Object.prototype.hasOwnProperty.call(snapshot, 'rawValue')
-  const hasRawWords = Object.prototype.hasOwnProperty.call(snapshot, 'rawWords')
-
-  const nextRegister = {
-    ...register,
-    displayValue: hasDisplayValue ? snapshot.displayValue : register.displayValue,
-    inputValue: hasDisplayValue ? snapshot.displayValue : register.inputValue,
-    isDirty: false,
-    rawBytes: hasRawBytes ? snapshot.rawBytes : register.rawBytes,
-    rawValue: hasRawValue ? snapshot.rawValue : register.rawValue,
-    rawWords: hasRawWords ? snapshot.rawWords : register.rawWords
-  }
-
-  return nextRegister
-}
-
-function applyWrittenSnapshotsToGroup(group, snapshots = []) {
-  let writtenIndex = 0
-
-  return {
-    ...group,
-    registers: group.registers.map((register, index) => {
-      const snapshot = snapshots[writtenIndex] || {}
-      writtenIndex += 1
-
-      const nextRegister = applyWrittenSnapshotToRegister(register, snapshot)
-      const normalized = normalizeRegister(nextRegister, group, index, nextRegister.address, nextRegister.byteOffset)
-
-      return {
-        ...normalized,
-        id: register.id,
-        inputValue: group.writable ? nextRegister.displayValue : nextRegister.inputValue
-      }
-    })
-  }
-}
-
-function applyWrittenSnapshotToGroupRegister(group, registerIndex, snapshot) {
-  return {
-    ...group,
-    registers: group.registers.map((register, currentIndex) => (
-      currentIndex === registerIndex
-        ? (() => {
-          const nextRegister = applyWrittenSnapshotToRegister(register, snapshot || {})
-          const normalized = normalizeRegister(nextRegister, group, currentIndex, nextRegister.address, nextRegister.byteOffset)
-
-          return {
-            ...normalized,
-            id: register.id,
-            inputValue: group.writable ? nextRegister.displayValue : nextRegister.inputValue
-          }
-        })()
-        : register
-    ))
-  }
-}
-
-module.exports = {
-  applyReadCacheToGroup,
-  applyWrittenSnapshotToGroupRegister,
-  applyWrittenSnapshotsToGroup,
-  normalizeNumericCache
-}

+ 0 - 288
features/parameter-groups/storage-access-io.js

@@ -1,288 +0,0 @@
-const {
-  bytesToWords
-} = require('../../utils/binary-utils.js')
-const transport = require('../../transport/ble-core.js')
-const storageAccessMemory = require('../storage-access/memory-service.js')
-const {
-  decodeRegisterFromByteCache,
-  decodeRegisterValue,
-  formatRegisterValue,
-  getGroupEncodedBytes,
-  getRegisterBytesFromByteCache,
-  getRegisterEncodedBytes,
-  getRegisterWordsFromByteCache,
-  getRegisterWordsFromWordCache
-} = require('../../domain/parameter-groups/model.js')
-
-const STORAGE_ACCESS_MEMORY_TYPES = {
-  DATA: 0x01,
-  BIT: 0x01,
-  IDATA: 0x02,
-  XDATA: 0x03,
-  CODE: 0x04
-}
-const STORAGE_ACCESS_CODE_AREA = 0x04
-
-function getMemoryType(group = {}) {
-  const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
-
-  return Object.prototype.hasOwnProperty.call(STORAGE_ACCESS_MEMORY_TYPES, memoryArea)
-    ? STORAGE_ACCESS_MEMORY_TYPES[memoryArea]
-    : null
-}
-
-function isMemoryGroup(group = {}) {
-  return getMemoryType(group) !== null
-}
-
-function isByteAddressedGroup(group = {}) {
-  return group.addressUnit === 'byte' || group.addressUnit === 'bytes' || isMemoryGroup(group)
-}
-
-function getMemoryAddress(group = {}) {
-  const sourceAddress = Number(group.sourceAddress)
-  if (Number.isFinite(sourceAddress)) return Math.max(0, Math.floor(sourceAddress)) & 0xFFFF
-
-  return Math.max(0, Number(group.startAddress) || 0) & 0xFFFF
-}
-
-function getMemoryByteLength(group = {}) {
-  const sourceByteLength = Number(group.sourceByteLength)
-  if (Number.isFinite(sourceByteLength) && sourceByteLength > 0) return Math.min(0xFFFF, Math.ceil(sourceByteLength))
-
-  const byteLength = Number(group.byteLength)
-  if (Number.isFinite(byteLength) && byteLength > 0) return Math.min(0xFFFF, Math.ceil(byteLength))
-
-  return Math.max(1, Math.ceil((Number(group.wordQuantity) || 1) * 2))
-}
-
-function bytesToPaddedWords(bytes = []) {
-  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
-}
-
-function fillByteCacheFromBytes(byteCache, startAddress, bytes = []) {
-  bytes.forEach((byte, offset) => {
-    byteCache[startAddress + offset] = Number(byte) & 0xFF
-  })
-}
-
-function fillWordCacheFromBytes(wordCache, startAddress, bytes = []) {
-  const words = bytesToPaddedWords(bytes)
-
-  words.forEach((word, offset) => {
-    wordCache[startAddress + offset] = Number(word) & 0xFFFF
-  })
-}
-
-function createWrittenRegisterSnapshots(group, wordCache) {
-  return group.registers.map((register) => {
-    const rawBytes = isByteAddressedGroup(group)
-      ? (getRegisterBytesFromByteCache(register, wordCache) || [])
-      : []
-    const rawWords = isByteAddressedGroup(group)
-      ? (getRegisterWordsFromByteCache(register, wordCache) || [])
-      : (getRegisterWordsFromWordCache(register, wordCache) || [])
-    const rawValue = isByteAddressedGroup(group)
-      ? decodeRegisterFromByteCache(register, wordCache)
-      : decodeRegisterValue(register, rawWords)
-
-    return {
-      rawBytes,
-      rawWords,
-      rawValue,
-      displayValue: formatRegisterValue(register, rawValue)
-    }
-  })
-}
-
-function createWrittenRegisterSnapshot(group, register, byteCache) {
-  const snapshots = createWrittenRegisterSnapshots({
-    ...group,
-    registers: [register]
-  }, byteCache)
-
-  return snapshots[0] || null
-}
-
-function groupHasBitFields(group = {}) {
-  return (Array.isArray(group.registers) ? group.registers : []).some((register) => !!register.isBitField)
-}
-
-async function writeMemoryRegister(group, register, options = {}) {
-  const memoryType = getMemoryType(group)
-  const maxPacketLength = storageAccessMemory.resolveMaxPacketLength(options.maxPacketLength)
-  const byteLength = Math.max(1, Number(register.byteLength) || 1)
-  const address = Math.max(0, Math.floor(Number(register.address) || getMemoryAddress(group))) & 0xFFFF
-  let bytes
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-  if (memoryType === STORAGE_ACCESS_CODE_AREA) {
-    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
-    return null
-  }
-
-  try {
-    if (register.isBitField) {
-      const baseBytes = await storageAccessMemory.readMemory(
-        memoryType,
-        address,
-        byteLength,
-        register.name ? `${register.name} 读改写` : '变量读改写',
-        'parameter-group-memory-register-rmw-read',
-        {
-          maxFrameBytes: maxPacketLength
-        }
-      )
-      if (!baseBytes) return null
-
-      bytes = getGroupEncodedBytes({
-        ...group,
-        paddedByteLength: byteLength,
-        registers: [{
-          ...register,
-          address,
-          byteStart: 0
-        }]
-      }, baseBytes).slice(0, byteLength)
-    } else {
-      bytes = getRegisterEncodedBytes(register)
-    }
-  } catch (error) {
-    transport.showCommandAlert('内存写入', error.message || '变量没有有效写入值')
-    return null
-  }
-
-  if (!Array.isArray(bytes) || !bytes.length) {
-    transport.showCommandAlert('内存写入', `${register.name || '变量'} 没有有效写入值`)
-    return null
-  }
-
-  bytes = bytes.slice(0, byteLength)
-  while (bytes.length < byteLength) bytes.push(0)
-
-  const ok = await storageAccessMemory.writeMemory(
-    memoryType,
-    address,
-    bytes,
-    register.name || group.name || '变量写入',
-    'parameter-group-memory-register-write',
-    {
-      maxFrameBytes: maxPacketLength
-    }
-  )
-  if (!ok) return null
-
-  const byteCache = {}
-  fillByteCacheFromBytes(byteCache, address, bytes)
-
-  return createWrittenRegisterSnapshot(group, register, byteCache)
-}
-
-async function readMemoryGroup(group, options = {}) {
-  const memoryType = getMemoryType(group)
-  const address = getMemoryAddress(group)
-  const byteLength = getMemoryByteLength(group)
-  const maxPacketLength = storageAccessMemory.resolveMaxPacketLength(options.maxPacketLength)
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存读取', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-
-  const bytes = await storageAccessMemory.readMemory(
-    memoryType,
-    address,
-    byteLength,
-    group.name || '内存读取',
-    'parameter-group-memory-read',
-    {
-      maxFrameBytes: maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-  if (!bytes) return null
-
-  const wordCache = {}
-  if (isByteAddressedGroup(group)) {
-    fillByteCacheFromBytes(wordCache, address, bytes)
-  } else {
-    fillWordCacheFromBytes(wordCache, address, bytes)
-  }
-
-  return wordCache
-}
-
-async function writeMemoryGroup(group, options = {}) {
-  const memoryType = getMemoryType(group)
-  const address = getMemoryAddress(group)
-  const byteLength = getMemoryByteLength(group)
-  const maxPacketLength = storageAccessMemory.resolveMaxPacketLength(options.maxPacketLength)
-  let bytes
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-  if (memoryType === STORAGE_ACCESS_CODE_AREA) {
-    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
-    return null
-  }
-
-  try {
-    let baseBytes = null
-    if (groupHasBitFields(group)) {
-      baseBytes = await storageAccessMemory.readMemory(
-        memoryType,
-        address,
-        byteLength,
-        group.name ? `${group.name} 读改写` : '内存读改写',
-        'parameter-group-memory-rmw-read',
-        {
-          maxFrameBytes: maxPacketLength
-        }
-      )
-      if (!baseBytes) return null
-    }
-
-    bytes = getGroupEncodedBytes(group, baseBytes)
-  } catch (error) {
-    transport.showCommandAlert('内存写入', error.message || '寄存器组没有有效写入值')
-    return null
-  }
-
-  bytes = bytes.slice(0, byteLength)
-  const ok = await storageAccessMemory.writeMemory(
-    memoryType,
-    address,
-    bytes,
-    group.name || '内存写入',
-    'parameter-group-memory-write',
-    {
-      maxFrameBytes: maxPacketLength
-    }
-  )
-  if (!ok) return null
-
-  const wordCache = {}
-  if (isByteAddressedGroup(group)) {
-    fillByteCacheFromBytes(wordCache, address, bytes)
-  } else {
-    fillWordCacheFromBytes(wordCache, address, bytes)
-  }
-
-  return createWrittenRegisterSnapshots(group, wordCache)
-}
-
-module.exports = {
-  getMemoryAddress,
-  getMemoryByteLength,
-  getMemoryType,
-  isByteAddressedGroup,
-  isMemoryGroup,
-  readMemoryGroup,
-  writeMemoryGroup,
-  writeMemoryRegister
-}

+ 23 - 15
features/parameter-groups/store.js

@@ -1,23 +1,23 @@
 const {
-  migrateLegacyGroupsToProtocol,
   persistGroups,
   readStoredGroups
 } = require('./persistence.js')
 const {
   getWxApi
-} = require('../../utils/platform-utils.js')
+} = require('../../utils/base-utils.js')
 const {
   normalizeStorageCodeInfoCard,
   DATA_TYPE_OPTIONS,
   REGISTER_TYPE_OPTIONS,
   normalizeGroup
 } = require('../../domain/parameter-groups/model.js')
+const storageAccessService = require('../storage-access/service.js')
+const {
+  PROTOCOL_MODE,
+  normalizeProtocolMode
+} = require('../../domain/protocol-mode.js')
 
 const STORAGE_CODE_INFO_KEY = 'parameter-groups-code-info'
-const PROTOCOL_MODE = {
-  MODBUS_RTU: 'modbus-rtu',
-  STORAGE_ACCESS: 'storage-access'
-}
 let initialized = false
 const subscribers = []
 const DEFAULT_STORAGE_CODE_INFO_CARD = normalizeStorageCodeInfoCard(null)
@@ -32,22 +32,21 @@ let state = {
   storageCodeInfoCard: DEFAULT_STORAGE_CODE_INFO_CARD
 }
 
-function normalizeProtocolMode(protocolMode) {
-  return protocolMode === PROTOCOL_MODE.MODBUS_RTU
-    ? PROTOCOL_MODE.MODBUS_RTU
-    : PROTOCOL_MODE.STORAGE_ACCESS
-}
-
 function getActiveProtocolMode() {
   return normalizeProtocolMode(state.activeProtocolMode)
 }
 
 function getActiveGroups() {
+  if (getActiveProtocolMode() === PROTOCOL_MODE.NONE) return []
+
   return state.parameterGroupsByProtocol[getActiveProtocolMode()] || []
 }
 
 function getGroupsForProtocol(protocolMode = getActiveProtocolMode()) {
-  return state.parameterGroupsByProtocol[normalizeProtocolMode(protocolMode)] || []
+  const normalizedProtocolMode = normalizeProtocolMode(protocolMode)
+  if (normalizedProtocolMode === PROTOCOL_MODE.NONE) return []
+
+  return state.parameterGroupsByProtocol[normalizedProtocolMode] || []
 }
 
 function getCodeInfoContext(card = state.storageCodeInfoCard) {
@@ -95,6 +94,10 @@ function setState(changedData, options = {}) {
 
 function setGroups(parameterGroups, options = {}) {
   const protocolMode = normalizeProtocolMode(options.protocolMode || getActiveProtocolMode())
+  if (protocolMode === PROTOCOL_MODE.NONE) {
+    notify()
+    return
+  }
   const normalizedGroups = normalizeGroupsForProtocol(parameterGroups, protocolMode)
   const parameterGroupsByProtocol = {
     ...state.parameterGroupsByProtocol,
@@ -133,6 +136,7 @@ function readStorageCodeInfoCard() {
 
 function setStorageCodeInfo(codeInfo, options = {}) {
   const card = normalizeStorageCodeInfoCard(codeInfo)
+  storageAccessService.updateSyncedDeviceCaps(card.codeInfoContext || {})
   const storageGroups = state.parameterGroupsByProtocol[PROTOCOL_MODE.STORAGE_ACCESS] || []
   const normalizedStorageGroups = normalizeGroupsForProtocol(storageGroups, PROTOCOL_MODE.STORAGE_ACCESS, {
     storageCodeInfoCard: card
@@ -156,6 +160,7 @@ function updateGroups(mapper, options = {}) {
   if (typeof mapper !== 'function') return
 
   const protocolMode = normalizeProtocolMode(options.protocolMode || getActiveProtocolMode())
+  if (protocolMode === PROTOCOL_MODE.NONE) return
   setGroups(getGroupsForProtocol(protocolMode).map((group, index) => mapper(group, index)), {
     ...options,
     protocolMode
@@ -184,7 +189,7 @@ function init(protocolMode = state.activeProtocolMode) {
   if (initialized) return
 
   const storageCodeInfoCard = readStorageCodeInfoCard()
-  migrateLegacyGroupsToProtocol(normalizedProtocolMode)
+  storageAccessService.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]
@@ -196,7 +201,10 @@ function init(protocolMode = state.activeProtocolMode) {
   protocolOrder.forEach((targetProtocolMode) => {
     parameterGroupsByProtocol[targetProtocolMode] = normalizeGroupsForProtocol(
       readStoredGroups(targetProtocolMode),
-      targetProtocolMode
+      targetProtocolMode,
+      {
+        storageCodeInfoCard
+      }
     )
   })
 

+ 0 - 151
features/parameter-groups/struct-completion.js

@@ -1,151 +0,0 @@
-const {
-  getDataType,
-  normalizeGroup
-} = require('../../domain/parameter-groups/model.js')
-const {
-  parseStructCatalog,
-  parseStructDefinition: parseStructDefinitionSource
-} = require('../../domain/parameter-groups/struct-parser.js')
-
-function getRegisterByteLengthFromConfig(register) {
-  const dataType = getDataType(register.dataType).key
-  if (dataType === 'ascii' || dataType === 'utf8') return Math.max(1, Number(register.textByteLength) || 1)
-  if (dataType === 'float' || dataType === 'int32_t' || dataType === 'uint32_t') return 4
-  if (dataType === 'int8_t' || dataType === 'uint8_t') return 1
-
-  return 2
-}
-
-function getRegistersByteLength(registers = []) {
-  const explicitByteEnds = registers.map((register) => {
-    const byteStart = Number(register && register.byteStart)
-    if (!Number.isFinite(byteStart)) return null
-
-    if (register.isBitField) {
-      const bitOffset = Math.min(Math.max(Math.floor(Number(register.bitOffset) || 0), 0), 7)
-      const bitWidth = Math.max(1, Math.round(Number(register.bitWidth) || 1))
-
-      return Math.max(0, Math.floor(byteStart)) + Math.max(1, Math.ceil((bitOffset + bitWidth) / 8))
-    }
-
-    return Math.max(0, Math.floor(byteStart)) + getRegisterByteLengthFromConfig(register)
-  }).filter((value) => Number.isFinite(value))
-
-  if (explicitByteEnds.length) return Math.max.apply(null, explicitByteEnds)
-
-  return registers.reduce((total, register) => total + getRegisterByteLengthFromConfig(register), 0)
-}
-
-function normalizeSymbolText(value) {
-  return String(value || '')
-    .replace(/^(?:IDATA|XDATA|DATA|CODE)[\s:_-]+/i, '')
-    .replace(/^_+/, '')
-    .replace(/[^A-Za-z0-9]/g, '')
-    .toLowerCase()
-}
-
-function findStructCompletion(group, catalog) {
-  const symbolName = group.sourceSymbolName || group.name
-  const direct = catalog.variablesByName[normalizeSymbolText(symbolName)]
-    || catalog.variablesByName[symbolName]
-
-  if (direct) return direct
-
-  const normalizedSymbol = normalizeSymbolText(symbolName)
-  const normalizedType = normalizeSymbolText(group.sourceSymbolType)
-  const expectedBytes = Number(group.sourceByteLength || group.byteLength || 0)
-
-  const matchedStruct = catalog.structs.find((structInfo) => {
-    const normalizedStructName = normalizeSymbolText(structInfo.name)
-    if (normalizedType && normalizedType === normalizedStructName) {
-      return true
-    }
-    if (normalizedSymbol && (
-      normalizedSymbol === normalizedStructName
-      || normalizedSymbol.indexOf(normalizedStructName) >= 0
-      || normalizedStructName.indexOf(normalizedSymbol) >= 0
-    )) {
-      return true
-    }
-
-    return expectedBytes > 0 && getRegistersByteLength(structInfo.registers) === expectedBytes
-  })
-
-  return matchedStruct
-    ? {
-      name: symbolName,
-      registers: matchedStruct.registers,
-      structName: matchedStruct.name
-    }
-    : null
-}
-
-function createCompletedRegisters(group, completion) {
-  const existingRemarksByByteStart = (Array.isArray(group.registers) ? group.registers : []).reduce((remarks, register) => {
-    const byteStart = Number(register && register.byteStart)
-    const remark = String(register && register.remark ? register.remark : '').trim()
-    if (Number.isFinite(byteStart) && remark) remarks[Math.floor(byteStart)] = remark
-
-    return remarks
-  }, {})
-
-  return completion.registers.map((register) => ({
-    ...register,
-    isStructField: true,
-    remark: register.remark || existingRemarksByByteStart[Math.floor(Number(register.byteStart) || 0)] || '',
-    sourceMemoryArea: group.sourceMemoryArea,
-    sourceMemoryClass: group.sourceMemoryClass,
-    sourceSymbolName: group.sourceSymbolName,
-    sourceSymbolType: completion.structName || register.sourceSymbolType
-  }))
-}
-
-function completeStructInstanceGroups(groups, sourceText, options = {}) {
-  const catalog = parseStructCatalog(sourceText)
-  let completedCount = 0
-  let skippedCount = 0
-
-  const nextGroups = groups.map((group) => {
-    if (!group.sourceSymbolName || !group.sourceMemoryArea) return group
-
-    const completion = findStructCompletion(group, catalog)
-    if (!completion || !completion.registers || !completion.registers.length) {
-      skippedCount += 1
-      return group
-    }
-
-    const expectedBytes = Number(group.sourceByteLength || group.byteLength || 0)
-    const actualBytes = getRegistersByteLength(completion.registers)
-    if (expectedBytes > 0 && actualBytes !== expectedBytes && options.strictLength !== false) {
-      skippedCount += 1
-      return group
-    }
-
-    completedCount += 1
-
-    return normalizeGroup({
-      ...group,
-      layout: 'struct',
-      quantity: completion.registers.length,
-      registers: createCompletedRegisters(group, completion)
-    })
-  })
-
-  return {
-    completedCount,
-    groups: nextGroups,
-    skippedCount,
-    structCount: catalog.structs.length,
-    variableCount: Object.keys(catalog.variablesByName).length
-  }
-}
-
-function parseStructDefinition(sourceText) {
-  return parseStructDefinitionSource(sourceText)
-}
-
-module.exports = {
-  completeStructInstanceGroups,
-  getRegistersByteLength,
-  parseStructDefinition
-}

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

@@ -13,6 +13,7 @@ function getOption(options, index) {
 function getPageState() {
   const settingsState = settingsService.getState()
   const transportState = transport.getState()
+  const isNoProtocol = settingsService.isNoProtocol(settingsState.protocolMode)
   const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
   const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
 
@@ -21,6 +22,7 @@ function getPageState() {
     ...themeService.getState(),
     ...settingsState,
     connectedDevice: transportState.connectedDevice,
+    isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol
   }
@@ -31,12 +33,14 @@ function resolveActiveParamView(currentView) {
 }
 
 function getSettingsPageState(currentData, settingsState) {
+  const isNoProtocol = settingsService.isNoProtocol(settingsState.protocolMode)
   const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
   const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
 
   return {
     ...settingsState,
     activeParamView: resolveActiveParamView(currentData.activeParamView),
+    isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol
   }
@@ -45,6 +49,7 @@ function getSettingsPageState(currentData, settingsState) {
 function getVisiblePageState(currentData) {
   const settingsState = settingsService.getState()
   const transportState = transport.getState()
+  const isNoProtocol = settingsService.isNoProtocol(settingsState.protocolMode)
   const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
   const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
   const pageState = {
@@ -52,6 +57,7 @@ function getVisiblePageState(currentData) {
     ...themeService.getState(),
     ...settingsState,
     connectedDevice: transportState.connectedDevice,
+    isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol
   }
@@ -104,6 +110,8 @@ function createParameterDialogState(overrides = {}) {
     showUnit: false,
     readOnly: false,
     parsedStructRegisters: [],
+    pollEnabled: true,
+    showPollEnabled: false,
     structDefinition: '',
     structParsedSummary: '',
     ...overrides
@@ -112,20 +120,24 @@ function createParameterDialogState(overrides = {}) {
 
 function createParameterGroupDialogState(group) {
   const isEdit = !!group
+  const isStorageGroup = !!(group && group.sourceMemoryArea)
   const registerTypeIndex = isEdit ? (group.registerTypeIndex || 0) : 0
   const registerType = getOption(parameterGroupService.REGISTER_TYPE_OPTIONS, registerTypeIndex)
-
   return createParameterDialogState({
     confirmText: isEdit ? '保存' : '确认',
     groupId: isEdit ? group.id : '',
-    groupName: isEdit ? group.name : '寄存器组',
+    groupName: isEdit ? group.name : (isStorageGroup ? '结构体组' : '寄存器组'),
     layout: isEdit ? (group.layout || 'register') : 'register',
     mode: isEdit ? 'editGroup' : 'createGroup',
     quantity: isEdit ? String(group.quantity || 1) : '1',
+    pollEnabled: isEdit ? group.pollEnabled !== false : true,
     registerTypeIndex,
     registerTypeText: registerType.label || '',
+    showPollEnabled: true,
     startAddress: isEdit && group.startAddressText ? group.startAddressText.replace(/^0x/i, '') : '0000',
-    title: isEdit ? '编辑寄存器组' : '添加寄存器组',
+    title: isStorageGroup
+      ? (isEdit ? '编辑结构体组' : '添加结构体组')
+      : (isEdit ? '编辑寄存器组' : '添加寄存器组'),
     visible: true
   })
 }
@@ -194,6 +206,7 @@ function createParameterGroupConfig(dialog) {
   return {
     groupName: dialog.groupName,
     layout: registers.length ? 'struct' : (dialog.layout || 'register'),
+    pollEnabled: dialog.pollEnabled !== false,
     quantity: registers.length ? String(registers.length) : dialog.quantity,
     registerTypeIndex: dialog.registerTypeIndex,
     startAddress: dialog.startAddress,

+ 131 - 0
features/settings/protocol-implementation.js

@@ -0,0 +1,131 @@
+const VIEW_ID = 'storageProtocolImplementation'
+const TITLE = '协议实现'
+const SOURCES = {
+  STORAGE_ACCESS_C: '',
+  STORAGE_ACCESS_H: '',
+  STRUCT_H: ''
+}
+
+const GUIDE = {
+  kicker: '存储访问协议',
+  title: '协议说明与实现规划',
+  text: '存储访问协议普通读写使用 CMD + ADDR + LEN + DATA + CRC16-CCITT-FALSE 帧格式,不带从机地址。当前先固定上位机协议模型,从机源码参考暂不提供,后续再单独规划实现细节。',
+  points: [
+    { id: 'frame', text: 'CMD bit7 为故障位,bit3 为读写位,bit4/bit5 暂时保留,bit0~bit2 表示地址模式或区域。' },
+    { id: 'addr', text: 'bit0~bit2 为 0x7 时下发 32 位地址;0x1~0x4 对应 DATA、IDATA、XDATA、CODE 的 16 位地址;0x0、0x5、0x6 保留。' },
+    { id: 'info', text: 'bit0、bit1、bit2、bit3、bit6 全为 1 时为特殊指令,即 CMD=0x4F;OP=0x05 返回 CodeInfo 地址、长度、CodeInfo 读取地址宽度、目标内存字节序与最大包长。' },
+    { id: 'struct', text: 'CodeInfo 使用 TYPE + LEN8 + VALUE 的纯 TLV 信息块;0x20/0x21 表示 16 位地址入口,0x28/0x29 表示 32 位地址入口。' },
+    { id: 'source', text: '协议实现源码已暂时移除,设置页仅保留文件占位与说明,避免给出过期从机实现。' }
+  ]
+}
+
+const FILES = [
+  {
+    badge: '从机',
+    details: [
+      '源码暂未提供。',
+      '后续需要按新 CMD 位定义重新规划从机头文件。',
+      '当前页面仅保留文件占位。'
+    ],
+    location: 'User/function/storage_access.h',
+    name: 'storage_access.h',
+    role: '从机协议头文件',
+    sourceKey: 'STORAGE_ACCESS_H',
+    summary: '源码暂时移除,等待协议实现方案确认。'
+  },
+  {
+    badge: '从机',
+    details: [
+      '源码暂未提供。',
+      '后续需要重新设计普通读写、特殊指令、异常响应和 CodeInfo 描述符读取。',
+      '当前页面仅保留文件占位。'
+    ],
+    location: 'User/function/storage_access.c',
+    name: 'storage_access.c',
+    role: '从机协议实现',
+    sourceKey: 'STORAGE_ACCESS_C',
+    summary: '源码暂时移除,等待协议实现方案确认。'
+  },
+  {
+    badge: '定义',
+    details: [
+      '源码暂未提供。',
+      '后续再决定是否在此提供 struct.h 示例。',
+      '当前页面仅保留文件占位。'
+    ],
+    location: 'struct.h',
+    name: 'struct.h',
+    role: '结构体定义参考',
+    sourceKey: 'STRUCT_H',
+    summary: '结构体定义参考暂时移除,等待后续实现规划。'
+  }
+]
+
+function getSourceText(file) {
+  return String(SOURCES[file && file.sourceKey] || '')
+}
+
+function getLineCount(text) {
+  if (!text) return 0
+
+  return text.split(/\r?\n/).length
+}
+
+function formatSourceMeta(text) {
+  const chars = text.length
+  const lines = getLineCount(text)
+
+  return `${lines} 行 / ${chars} 字符`
+}
+
+function getSourceMetaText(text) {
+  return text ? formatSourceMeta(text) : '暂未提供'
+}
+
+function normalizeIndex(index) {
+  const numberValue = Number(index)
+  if (!Number.isFinite(numberValue)) return 0
+
+  return Math.max(0, Math.min(FILES.length - 1, Math.round(numberValue)))
+}
+
+function getFiles(activeIndex = 0) {
+  const normalizedIndex = normalizeIndex(activeIndex)
+
+  return FILES.map((file, index) => ({
+    ...file,
+    active: index === normalizedIndex,
+    id: file.name,
+    sourceAvailable: !!getSourceText(file),
+    sourceMeta: getSourceMetaText(getSourceText(file))
+  }))
+}
+
+function getState(activeIndex = 0) {
+  const normalizedIndex = normalizeIndex(activeIndex)
+  const activeFile = FILES[normalizedIndex]
+  const sourceText = getSourceText(activeFile)
+
+  return {
+    storageProtocolImplementationActiveFile: {
+      ...activeFile,
+      sourceAvailable: !!sourceText,
+      sourceMeta: getSourceMetaText(sourceText)
+    },
+    storageProtocolImplementationActiveIndex: normalizedIndex,
+    storageProtocolImplementationFiles: getFiles(normalizedIndex),
+    storageProtocolImplementationGuide: {
+      ...GUIDE,
+      points: GUIDE.points.map((point) => ({ ...point }))
+    }
+  }
+}
+
+module.exports = {
+  TITLE,
+  VIEW_ID,
+  getSourceText(index = 0) {
+    return getSourceText(FILES[normalizeIndex(index)])
+  },
+  getState
+}

+ 9 - 4
features/settings/view-model.js

@@ -2,7 +2,10 @@ const settingsService = require('../../store/settings-store.js')
 const themeService = require('../../store/theme-store.js')
 const transport = require('../../transport/ble-core.js')
 const bootloaderService = require('../bootloader/service.js')
-const toolNavigation = require('../tools/navigation.js')
+const {
+  toolNavigation
+} = require('../tools/index.js')
+const protocolImplementation = require('./protocol-implementation.js')
 
 function getSettingsPageState(
   settingsState = settingsService.getState(),
@@ -13,14 +16,13 @@ function getSettingsPageState(
   const nightModeEnabledSwitch = settingsState.nightModeFollowSystem
     ? themeState.themeMode === 'dark'
     : settingsState.nightModeEnabled
-  const protocolMode = settingsService.isModbusProtocol(settingsState.protocolMode)
-    ? settingsService.PROTOCOL_MODE.MODBUS_RTU
-    : settingsService.PROTOCOL_MODE.STORAGE_ACCESS
+  const protocolMode = settingsState.protocolMode || settingsService.PROTOCOL_MODE.STORAGE_ACCESS
   const protocolOptions = settingsService.PROTOCOL_OPTIONS
   const protocolIndex = Math.max(0, protocolOptions.findIndex((option) => (
     option.key === protocolMode
   )))
   const protocol = protocolOptions[protocolIndex] || protocolOptions[0]
+  const isNoProtocol = settingsService.isNoProtocol(protocol.key)
   const isModbusProtocol = settingsService.isModbusProtocol(protocol.key)
   const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(protocol.key)
 
@@ -29,6 +31,7 @@ function getSettingsPageState(
     ...themeState,
     ...bootloaderState,
     connectedDevice: transportState.connectedDevice,
+    isNoProtocol,
     isModbusProtocol,
     isStorageAccessProtocol,
     nightModeEnabledSwitch,
@@ -36,6 +39,8 @@ function getSettingsPageState(
     statusPollMinInterval: settingsService.STATUS_POLL_MIN_INTERVAL,
     parameterMinPacketLength: settingsService.PARAMETER_MIN_PACKET_LENGTH,
     protocolIndex,
+    storageProtocolImplementationEntryMeta: '从机实现与结构体定义参考',
+    storageProtocolImplementationView: protocolImplementation.VIEW_ID,
     protocolOptions,
     protocolText: protocol.label,
     toolEntries: toolNavigation.getToolEntries()

+ 0 - 9
features/storage-access/index.js

@@ -1,9 +0,0 @@
-const memoryService = require('./memory-service.js')
-const service = require('./service.js')
-
-module.exports = {
-  memoryService,
-  service,
-  ...memoryService,
-  ...service
-}

+ 0 - 59
features/storage-access/memory-service.js

@@ -1,59 +0,0 @@
-const storageAccessProtocol = require('../../protocols/storage-access/index.js')
-const settingsService = require('../../store/settings-store.js')
-
-function resolveMaxPacketLength(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 {
-    maxFrameBytes: resolveMaxPacketLength(maxPacketLength),
-    onChunk: options.onChunk,
-    showModal: options.showModal !== false
-  }
-}
-
-async function readMemory(area, startAddress, byteLength, label, kind, options = {}) {
-  return storageAccessProtocol.client.readMemory(
-    area,
-    startAddress,
-    byteLength,
-    label,
-    kind,
-    normalizeTransferOptions(options)
-  )
-}
-
-async function writeMemory(area, startAddress, bytes, label, kind, options = {}) {
-  return storageAccessProtocol.client.writeMemory(
-    area,
-    startAddress,
-    bytes,
-    label,
-    kind,
-    normalizeTransferOptions(options)
-  )
-}
-
-async function readCodeInfoBlock(label, kind, options = {}) {
-  return storageAccessProtocol.client.readCodeInfoBlock(
-    label,
-    kind,
-    normalizeTransferOptions(options)
-  )
-}
-
-module.exports = {
-  AREA: storageAccessProtocol.AREA,
-  readCodeInfoBlock,
-  readMemory,
-  resolveMaxPacketLength,
-  writeMemory
-}

+ 658 - 13
features/storage-access/service.js

@@ -1,3 +1,6 @@
+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
@@ -6,16 +9,580 @@ const {
   cloneImportedGroup,
   normalizeGroup
 } = require('../../domain/parameter-groups/model.js')
-const memoryService = require('./memory-service.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.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' }
+]
+const MEMORY_READ_INDEX = 0
+const MEMORY_WRITE_INDEX = 1
+
+const CONTROL_COMMANDS = [
+  { key: 'reset', label: '复位', op: storageAccessProtocol.CONTROL_OP.RESET },
+  { key: 'start', label: '启动', op: storageAccessProtocol.CONTROL_OP.START },
+  { key: 'stop', label: '停止', op: storageAccessProtocol.CONTROL_OP.STOP },
+  { key: 'controlRef', label: '控制参考值', op: storageAccessProtocol.CONTROL_OP.SET_CONTROL_REF, hidden: true }
+]
+
+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 descriptorResponse = await executeControl(
+    storageAccessProtocol.CONTROL_OP.READ_CODE_INFO_DESCRIPTOR,
+    [],
+    label,
+    `${kind}-descriptor`,
+    {
+      ...transferOptions,
+      expectedByteLength: storageAccessProtocol.CODE_INFO_DESCRIPTOR_BYTE_LENGTH,
+      useDeviceCaps: false,
+      showModal: false
+    }
+  )
+  if (!descriptorResponse) {
+    if (transferOptions.showModal !== false) {
+      transport.showCommandAlert(label, '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 || '').trim()
+  if (!codeInfoMemoryEndian) {
+    if (transferOptions.showModal !== false) {
+      transport.showCommandAlert(label, 'CodeInfo 描述符内存字节序标记无效')
+    }
+    return null
+  }
+
+  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: Array.isArray(descriptorResponse.codeInfoDescriptorBytes)
+      ? descriptorResponse.codeInfoDescriptorBytes
+      : [],
+    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].key === storageAccessProtocol.AREA.CODE) {
+    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 addressWidth = getAreaAddressWidth(area)
+  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()
+
+  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.key === storageAccessProtocol.AREA.CODE) {
+      errorText = 'code 区暂不支持写入'
+    } 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
+      ? `${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 normalizeReferenceText(value, fallback = '0') {
+  const source = String(value === undefined || value === null ? '' : value).trim()
+  if (!source) return fallback
+
+  const hexText = source.replace(/^0x/i, '')
+  if (/^[0-9A-F]{1,4}$/i.test(hexText) && /[A-F]/i.test(hexText)) {
+    const rawValue = parseInt(hexText, 16) || 0
+    return String(rawValue & 0x8000 ? rawValue - 0x10000 : rawValue)
+  }
+
+  if (/^-?\d+$/.test(source)) {
+    return String(Number(source))
+  }
+
+  return source
+}
+
+function normalizeControlReference(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) {
+    return {
+      bytes: [],
+      errorText: '控制参考值请输入十进制',
+      text,
+      value: 0
+    }
+  }
+  if (!/^-?\d+$/.test(text)) {
+    return {
+      bytes: [],
+      errorText: '控制参考值只支持十进制',
+      text,
+      value: 0
+    }
+  }
 
-function formatWordHex(value) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+  const valueNumber = Number(text)
+  if (valueNumber < -0x8000 || valueNumber > 0x7FFF) {
+    return {
+      bytes: [],
+      errorText: '控制参考值范围为 -32768 - 32767',
+      text,
+      value: 0
+    }
+  }
+
+  const wordValue = valueNumber & 0xFFFF
+
+  return {
+    bytes: [
+      (wordValue >>> 8) & 0xFF,
+      wordValue & 0xFF
+    ],
+    errorText: '',
+    text,
+    value: valueNumber
+  }
+}
+
+function getControlCommand(commandKey) {
+  return CONTROL_COMMANDS.find((command) => command.key === commandKey) || null
+}
+
+function normalizeControlState(current = {}, changed = {}) {
+  const next = {
+    storageAccessControlRefText: current.storageAccessControlRefText || '0',
+    ...changed
+  }
+  const controlRefText = normalizeReferenceText(next.storageAccessControlRefText, '0')
+  const controlRef = normalizeControlReference(controlRefText)
+
+  return {
+    storageAccessControlRefErrorText: controlRef.errorText,
+    storageAccessControlRefPreviewHexText: controlRef.errorText
+      ? ''
+      : bytesToHex(storageAccessProtocol.buildControlReferenceFrame(controlRef.value), ' '),
+    storageAccessControlRefText: controlRefText
+  }
+}
+
+function getControlCommands() {
+  return CONTROL_COMMANDS.map((command) => ({
+    ...command,
+    previewHexText: command.key === 'controlRef'
+      ? ''
+      : bytesToHex(storageAccessProtocol.buildControlFrame(command.op), ' ')
+  }))
 }
 
 async function syncCodeInfo(options = {}) {
-  const result = await memoryService.readCodeInfoBlock(
-    options.label || '同步info',
-    options.kind || 'storage-info-read',
+  const result = await readCodeInfoBlock(
+    options.label || '同步CodeInfo',
+    options.kind || 'storage-code-info-read',
     {
       maxPacketLength: options.maxPacketLength,
       showModal: options.showModal !== false
@@ -27,19 +594,31 @@ async function syncCodeInfo(options = {}) {
     }
   }
 
-  const codeInfo = parseCodeInfo(result.codeInfoBytes)
+  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,
-    codeInfoAddressText: formatWordHex(result.codeInfoAddress),
+    codeInfoAddressWidth: result.codeInfoAddressWidth,
+    codeInfoAddressText: formatDwordHex(result.codeInfoAddress),
     codeInfoByteLength: result.codeInfoByteLength,
-    codeInfoByteLengthText: formatWordHex(result.codeInfoByteLength),
+    codeInfoByteLengthText: formatDwordHex(result.codeInfoByteLength),
     codeInfoBytes: result.codeInfoBytes,
     codeInfo,
-    infoBytes: result.infoBytes,
+    codeInfoDescriptorBytes: result.codeInfoDescriptorBytes,
+    codeInfoMaxPacketLength: result.codeInfoMaxPacketLength,
+    codeInfoMemoryEndian: result.codeInfoMemoryEndian,
+    codeInfoMemoryEndianMark: result.codeInfoMemoryEndianMark,
     groupCount: importedGroups.length,
     importedGroups,
     codeInfoMemoryType: result.codeInfoMemoryType,
@@ -48,9 +627,75 @@ async function syncCodeInfo(options = {}) {
   }
 }
 
+async function executeControlCommand(commandKey, data = {}, options = {}) {
+  const command = getControlCommand(commandKey)
+  if (!command) {
+    return {
+      errorText: '特殊指令无效',
+      ok: false
+    }
+  }
+
+  const controlState = normalizeControlState(data)
+  if (command.key === 'controlRef' && controlState.storageAccessControlRefErrorText) {
+    return {
+      errorText: controlState.storageAccessControlRefErrorText,
+      ok: false
+    }
+  }
+
+  const response = await executeControl(
+    command.op,
+    command.key === 'controlRef'
+      ? normalizeControlReference(controlState.storageAccessControlRefText).bytes
+      : [],
+    command.label || '特殊指令',
+    `storage-control-${command.key}`,
+    {
+      maxPacketLength: options.maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+
+  if (!response) {
+    return {
+      errorText: '指令执行失败或超时',
+      ok: false
+    }
+  }
+
+  if (response.controlStatus !== 0) {
+    return {
+      errorText: response.controlStatusText || '设备拒绝执行',
+      ok: false,
+      response
+    }
+  }
+
+  return {
+    command,
+    ok: true,
+    response
+  }
+}
+
 module.exports = {
-  AREA: memoryService.AREA,
-  readMemory: memoryService.readMemory,
+  AREA: storageAccessProtocol.AREA,
+  CONTROL_OP: storageAccessProtocol.CONTROL_OP,
+  executeMemoryCommand,
+  executeControl,
+  executeControlCommand,
+  formatDwordHex,
+  getControlCommand,
+  getControlCommands,
+  getMemoryAreaOptions,
+  getSyncedDeviceCaps,
+  normalizeMemoryCommandState,
+  normalizeControlState,
+  readCodeInfoBlock,
+  readMemory,
+  resolveMaxPacketLength,
   syncCodeInfo,
-  writeMemory: memoryService.writeMemory
+  updateSyncedDeviceCaps,
+  writeMemory
 }

+ 0 - 34
features/tools/handlers/common.js

@@ -1,34 +0,0 @@
-const {
-  getWxApi
-} = require('../../../utils/platform-utils.js')
-
-const handlers = {
-  copyToolResult(event) {
-    const value = event && event.currentTarget && event.currentTarget.dataset
-      ? event.currentTarget.dataset.value
-      : ''
-    const text = String(value === undefined || value === null ? '' : value).trim()
-
-    if (!text || text === '--') return
-
-    const wxApi = getWxApi()
-    if (typeof wxApi.setClipboardData !== 'function') {
-      if (this.pageToast) this.pageToast.show('当前环境不支持复制', 'error')
-      return
-    }
-
-    wxApi.setClipboardData({
-      data: text,
-      fail: () => {
-        if (this.pageToast) this.pageToast.show('复制失败', 'error')
-      },
-      success: () => {
-        if (this.pageToast) this.pageToast.show('已复制')
-      }
-    })
-  }
-}
-
-module.exports = {
-  handlers
-}

+ 7 - 25
features/tools/handlers/crc.js

@@ -1,7 +1,6 @@
+const crcTool = require('../../../tools/crc-hash/crc-tool.js')
 const {
-  crcTool
-} = require('../../../tools/crc-hash/index.js')
-const {
+  isCancelError,
   loadSelectedFile
 } = require('../../../repositories/file.js')
 
@@ -11,13 +10,12 @@ const handlers = {
 
     this.setData({
       ...crcTool.createPresetState(presetIndex),
-      crcErrorText: ''
+      ...crcTool.createEmptyResultState()
     })
   },
 
   onCrcInputTypeChange(event) {
     this.setData({
-      crcErrorText: '',
       crcInputTypeIndex: Number(event.detail.value)
     })
   },
@@ -27,8 +25,7 @@ const handlers = {
     if (!field) return
     const isCrcConfigField = crcTool.CRC_CONFIG_FIELDS.includes(field)
     const nextData = {
-      [field]: event.detail.value,
-      crcErrorText: ''
+      [field]: event.detail.value
     }
 
     if (isCrcConfigField) {
@@ -46,7 +43,6 @@ const handlers = {
     this.setData({
       [field]: !!event.detail.value,
       crcAlgorithmCollapsed: false,
-      crcErrorText: '',
       crcPresetIndex: crcTool.getCustomPresetIndex()
     })
   },
@@ -61,7 +57,6 @@ const handlers = {
     this.crcFileBytes = null
     this.setData({
       crcDataText: event.detail.value,
-      crcErrorText: '',
       crcFileName: '',
       crcFileSizeText: ''
     })
@@ -72,21 +67,17 @@ const handlers = {
       this.setData(crcTool.calculateFromState(this.data, this.crcFileBytes))
     } catch (error) {
       const message = error && error.message ? error.message : '计算失败'
-      this.setData({
-        crcErrorText: message
-      })
       if (this.pageToast) this.pageToast.show(message, 'error')
     }
   },
 
   async loadCrcFileFromMessage() {
     try {
-      const file = await loadSelectedFile('message')
+      const file = await loadSelectedFile('auto')
       this.crcFileBytes = file.bytes
       this.setData({
         crcDataLengthText: file.sizeText,
         crcDataText: '',
-        crcErrorText: '',
         crcFileName: file.name,
         crcFileSizeText: file.sizeText
       })
@@ -96,7 +87,7 @@ const handlers = {
         ? (error.errMsg || error.message)
         : '读取文件失败'
 
-      if (!/cancel/i.test(message) && this.pageToast) {
+      if (!isCancelError(error) && this.pageToast) {
         this.pageToast.show(message, 'error')
       }
     }
@@ -107,18 +98,9 @@ const handlers = {
     this.setData({
       crcDataLengthText: '0 bytes',
       crcDataText: '',
-      crcErrorText: '',
       crcFileName: '',
       crcFileSizeText: '',
-      crcResultBase64: '--',
-      crcResultBin: '--',
-      crcResultBinLines: [
-        {
-          id: 'bin-line-0',
-          text: '--'
-        }
-      ],
-      crcResultHex: '--'
+      ...crcTool.createEmptyResultState()
     })
   }
 }

+ 97 - 6
features/tools/index.js

@@ -1,9 +1,100 @@
-const navigation = require('./navigation.js')
-const page = require('./page.js')
+const crcTool = require('../../tools/crc-hash/crc-tool.js')
+const filterCalculator = require('../../tools/filter/index.js')
+const smdCodeCalculator = require('../../tools/smd-code/index.js')
+const refrigerationCalculator = require('../../tools/refrigeration/index.js')
+const reactanceCalculator = require('../../tools/reactance/index.js')
+const threePhasePowerCalculator = require('../../tools/three-phase-power/index.js')
+const {
+  getWxApi
+} = require('../../utils/base-utils.js')
+
+const crcHandlers = require('./handlers/crc.js')
+const filterHandlers = require('./handlers/filter.js')
+const reactanceHandlers = require('./handlers/reactance.js')
+const refrigerationHandlers = require('./handlers/refrigeration.js')
+const smdCodeHandlers = require('./handlers/smd-code.js')
+const threePhasePowerHandlers = require('./handlers/three-phase-power.js')
+
+const TOOL_ENTRIES = [
+  { view: 'bootloader', label: 'BootLoader升级', icon: 'icon-chip', iconSrc: '/assets/icons/chip-white.png' },
+  { view: 'crc', label: 'CRC与哈希计算', icon: 'icon-crc', iconSrc: '/assets/icons/hash-white.png' },
+  { view: 'filter', label: '滤波器计算', icon: 'icon-filter', iconSrc: '/assets/icons/funnel-white.png' },
+  { view: 'reactance', label: '电抗计算', icon: 'icon-reactance', iconSrc: '/assets/icons/audio-waveform-white.png' },
+  { view: 'smdCode', label: '贴片电阻/容代码', icon: 'icon-smd', iconSrc: '/assets/icons/microchip-white.png' },
+  { view: 'refrigeration', label: '制冷计算', icon: 'icon-snow', iconSrc: '/assets/icons/snowflake-white.png' },
+  { view: 'threePhasePower', label: '三相功率计算', icon: 'icon-three-phase', iconSrc: '/assets/icons/zap-white.png' }
+]
+
+function getToolEntries() {
+  return TOOL_ENTRIES.map((item) => ({ ...item }))
+}
+
+function isToolView(view) {
+  return TOOL_ENTRIES.some((item) => item.view === view)
+}
+
+function getToolEntry(view) {
+  return TOOL_ENTRIES.find((item) => item.view === view) || null
+}
+
+function getToolTitle(view) {
+  const entry = getToolEntry(view)
+  return entry ? entry.label : ''
+}
+
+function createToolInitialState() {
+  return {
+    ...crcTool.createInitialState(),
+    ...filterCalculator.createInitialState(),
+    ...smdCodeCalculator.createInitialState(),
+    ...refrigerationCalculator.createInitialState(),
+    ...reactanceCalculator.createInitialState(),
+    ...threePhasePowerCalculator.createInitialState()
+  }
+}
+
+const toolNavigation = {
+  getToolEntry,
+  getToolEntries,
+  getToolTitle,
+  isToolView
+}
+
+const toolPageHandlers = {
+  copyToolResult(event) {
+    const value = event && event.currentTarget && event.currentTarget.dataset
+      ? event.currentTarget.dataset.value
+      : ''
+    const text = String(value === undefined || value === null ? '' : value).trim()
+
+    if (!text || text === '--') return
+
+    const wxApi = getWxApi()
+    if (typeof wxApi.setClipboardData !== 'function') {
+      if (this.pageToast) this.pageToast.show('当前环境不支持复制', 'error')
+      return
+    }
+
+    wxApi.setClipboardData({
+      data: text,
+      fail: () => {
+        if (this.pageToast) this.pageToast.show('复制失败', 'error')
+      },
+      success: () => {
+        if (this.pageToast) this.pageToast.show('已复制')
+      }
+    })
+  },
+  ...crcHandlers.handlers,
+  ...filterHandlers.handlers,
+  ...reactanceHandlers.handlers,
+  ...smdCodeHandlers.handlers,
+  ...refrigerationHandlers.handlers,
+  ...threePhasePowerHandlers.handlers
+}
 
 module.exports = {
-  navigation,
-  page,
-  toolNavigation: navigation,
-  ...page
+  createToolInitialState,
+  toolNavigation,
+  toolPageHandlers
 }

+ 0 - 33
features/tools/navigation.js

@@ -1,33 +0,0 @@
-const TOOL_ENTRIES = [
-  { view: 'bootloader', label: 'BootLoader升级', icon: 'icon-chip', iconSrc: '/assets/icons/chip-white.png' },
-  { view: 'crc', label: 'CRC与哈希计算', icon: 'icon-crc', iconSrc: '/assets/icons/hash-white.png' },
-  { view: 'filter', label: '滤波器计算', icon: 'icon-filter', iconSrc: '/assets/icons/funnel-white.png' },
-  { view: 'reactance', label: '电抗计算', icon: 'icon-reactance', iconSrc: '/assets/icons/audio-waveform-white.png' },
-  { view: 'smdCode', label: '贴片电阻/容代码', icon: 'icon-smd', iconSrc: '/assets/icons/microchip-white.png' },
-  { view: 'refrigeration', label: '制冷计算', icon: 'icon-snow', iconSrc: '/assets/icons/snowflake-white.png' },
-  { view: 'threePhasePower', label: '三相功率计算', icon: 'icon-three-phase', iconSrc: '/assets/icons/zap-white.png' }
-]
-
-function getToolEntries() {
-  return TOOL_ENTRIES.map((item) => ({ ...item }))
-}
-
-function isToolView(view) {
-  return TOOL_ENTRIES.some((item) => item.view === view)
-}
-
-function getToolEntry(view) {
-  return TOOL_ENTRIES.find((item) => item.view === view) || null
-}
-
-function getToolTitle(view) {
-  const entry = getToolEntry(view)
-  return entry ? entry.label : ''
-}
-
-module.exports = {
-  getToolEntry,
-  getToolEntries,
-  getToolTitle,
-  isToolView
-}

+ 0 - 42
features/tools/page.js

@@ -1,42 +0,0 @@
-const {
-  crcTool
-} = require('../../tools/crc-hash/index.js')
-const filterCalculator = require('../../tools/filter/index.js')
-const smdCodeCalculator = require('../../tools/smd-code/index.js')
-const refrigerationCalculator = require('../../tools/refrigeration/index.js')
-const reactanceCalculator = require('../../tools/reactance/index.js')
-const threePhasePowerCalculator = require('../../tools/three-phase-power/index.js')
-
-const commonHandlers = require('./handlers/common.js')
-const crcHandlers = require('./handlers/crc.js')
-const filterHandlers = require('./handlers/filter.js')
-const reactanceHandlers = require('./handlers/reactance.js')
-const refrigerationHandlers = require('./handlers/refrigeration.js')
-const smdCodeHandlers = require('./handlers/smd-code.js')
-const threePhasePowerHandlers = require('./handlers/three-phase-power.js')
-
-function createToolInitialState() {
-  return {
-    ...crcTool.createInitialState(),
-    ...filterCalculator.createInitialState(),
-    ...smdCodeCalculator.createInitialState(),
-    ...refrigerationCalculator.createInitialState(),
-    ...reactanceCalculator.createInitialState(),
-    ...threePhasePowerCalculator.createInitialState()
-  }
-}
-
-const toolPageHandlers = {
-  ...commonHandlers.handlers,
-  ...crcHandlers.handlers,
-  ...filterHandlers.handlers,
-  ...reactanceHandlers.handlers,
-  ...smdCodeHandlers.handlers,
-  ...refrigerationHandlers.handlers,
-  ...threePhasePowerHandlers.handlers
-}
-
-module.exports = {
-  createToolInitialState,
-  toolPageHandlers
-}

+ 122 - 45
pages/communication/communication.js

@@ -1,24 +1,29 @@
 const settingsService = require('../../store/settings-store.js')
 const themeService = require('../../store/theme-store.js')
 const transport = require('../../transport/ble-core.js')
-const manualRtuService = require('../../features/manual-rtu/service.js')
 const {
   createPageToast
 } = require('../../utils/page-toast.js')
-const communicationService = require('../../features/communication/service.js')
+const {
+  parameterGroupService
+} = require('../../features/parameter-groups/index.js')
 const {
   LOG_MODE_OPTIONS,
-  STORAGE_ACCESS_AREA_OPTIONS,
-  STORAGE_ACCESS_COMMAND_OPTIONS,
   decorateLogs,
+  executeStorageAccessProtocol,
+  executeStorageAccessSpecialCommand,
   getLogModeToggleText,
   getManualStatePayload,
   getNextLogMode,
   getNextSerialMode,
   getProtocolModeState,
+  getStorageAccessAreaOptions,
+  manualRtuService,
+  sendSerialFrame,
+  normalizeStorageAccessSpecialState,
   normalizeStorageAccessState,
   normalizeSerialState
-} = require('../../features/communication/view-model.js')
+} = require('../../features/communication/index.js')
 
 const INITIAL_SERIAL_STATE = normalizeSerialState({
   serialInputText: '',
@@ -31,20 +36,21 @@ Page({
     ...settingsService.getState(),
     ...getProtocolModeState(settingsService.getState()),
     ...transport.getState(),
+    ...parameterGroupService.getState(),
     ...manualRtuService.getState(),
     logs: decorateLogs(transport.getState().logs, 'hex'),
     logMode: 'hex',
     logModeToggleText: getLogModeToggleText('hex'),
     logModeOptions: LOG_MODE_OPTIONS,
-    storageAccessAreaOptions: STORAGE_ACCESS_AREA_OPTIONS,
-    storageAccessCommandOptions: STORAGE_ACCESS_COMMAND_OPTIONS,
+    storageAccessAreaOptions: getStorageAccessAreaOptions(),
     ...normalizeStorageAccessState({
-      storageAccessAreaIndex: 3,
-      storageAccessAddress: '0000',
+      storageAccessAreaIndex: 0,
+      storageAccessAddress: '',
       storageAccessCommandIndex: 0,
       storageAccessDataText: '',
-      storageAccessLength: '0004'
+      storageAccessLength: ''
     }),
+    ...normalizeStorageAccessSpecialState(),
     serialTitleText: '串口发送',
     ...INITIAL_SERIAL_STATE,
     toastText: '',
@@ -55,6 +61,7 @@ Page({
     this.pageToast = createPageToast(this, this.data)
     this.lastSyncedSlaveAddress = ''
     transport.init()
+    parameterGroupService.init()
     settingsService.init()
     themeService.init()
 
@@ -89,6 +96,16 @@ Page({
       })
     })
 
+    this.unsubscribeParameterGroups = parameterGroupService.subscribe((parameterState) => {
+      this.setData({
+        ...parameterState,
+        ...normalizeStorageAccessState(this.data, {
+          storageAccessCommandIndex: this.data.storageAccessCommandIndex,
+          storageAccessAreaIndex: this.data.storageAccessAreaIndex
+        })
+      })
+    })
+
     this.unsubscribeManualRtu = manualRtuService.subscribe((manualState) => {
       this.setData(getManualStatePayload(manualState))
     })
@@ -127,6 +144,11 @@ Page({
       this.unsubscribeTransport = null
     }
 
+    if (this.unsubscribeParameterGroups) {
+      this.unsubscribeParameterGroups()
+      this.unsubscribeParameterGroups = null
+    }
+
     if (this.unsubscribeManualRtu) {
       this.unsubscribeManualRtu()
       this.unsubscribeManualRtu = null
@@ -171,25 +193,20 @@ Page({
 
   async sendSerialFrame() {
     try {
-      const result = await communicationService.sendSerialFrame(this.data)
+      const result = await sendSerialFrame(this.data)
       if (!result.ok) {
-        this.setData({
-          serialErrorText: result.errorText || '发送失败'
-        })
+        if (this.pageToast) this.pageToast.show(result.errorText || '发送失败', 'error')
         return
       }
 
       if (this.pageToast) this.pageToast.show('已发送')
       this.setData({
         ...(result.serialState || {}),
-        serialErrorText: '',
         serialPreviewHex: result.previewHex || '',
         serialPreviewLengthText: `${(result.bytes || []).length} bytes`
       })
     } catch (error) {
-      this.setData({
-        serialErrorText: error.message || '发送失败'
-      })
+      if (this.pageToast) this.pageToast.show(error.message || '发送失败', 'error')
     }
   },
 
@@ -267,10 +284,10 @@ Page({
     }
   },
 
-  switchStorageAccessCommandMode(event) {
-    const storageAccessCommandIndex = Number(event.currentTarget.dataset.index)
+  switchStorageAccessCommandMode() {
+    const currentIndex = Number(this.data.storageAccessCommandIndex) || 0
     const nextState = normalizeStorageAccessState(this.data, {
-      storageAccessCommandIndex
+      storageAccessCommandIndex: currentIndex === 0 ? 1 : 0
     })
 
     this.setData(nextState)
@@ -281,6 +298,7 @@ Page({
     if (!field) return
 
     const changed = {
+      storageAccessCommandIndex: this.data.storageAccessCommandIndex,
       [field]: event.detail.value
     }
     const nextState = normalizeStorageAccessState(this.data, changed)
@@ -288,9 +306,38 @@ Page({
     this.setData(nextState)
   },
 
+  onStorageAccessControlRefInput(event) {
+    this.setData(normalizeStorageAccessSpecialState(this.data, {
+      storageAccessControlRefText: event.detail.value
+    }))
+  },
+
+  async onStorageAccessControlRefConfirm() {
+    const controlRefText = String(this.data.storageAccessControlRefText || '').trim()
+    const now = Date.now()
+    if (this.lastStorageAccessControlRefSent
+      && this.lastStorageAccessControlRefSent.text === controlRefText
+      && now - this.lastStorageAccessControlRefSent.time < 600) {
+      return
+    }
+    this.lastStorageAccessControlRefSent = {
+      text: controlRefText,
+      time: now
+    }
+
+    await this.sendStorageAccessSpecialCommand({
+      currentTarget: {
+        dataset: {
+          command: 'controlRef'
+        }
+      }
+    })
+  },
+
   onStorageAccessAreaChange(event) {
     const storageAccessAreaIndex = Number(event.detail.value)
     const nextState = normalizeStorageAccessState(this.data, {
+      storageAccessCommandIndex: this.data.storageAccessCommandIndex,
       storageAccessAreaIndex
     })
 
@@ -299,45 +346,75 @@ Page({
 
   async sendStorageAccessProtocolFrame() {
     try {
-      const command = STORAGE_ACCESS_COMMAND_OPTIONS[Number(this.data.storageAccessCommandIndex) || 0] || STORAGE_ACCESS_COMMAND_OPTIONS[0]
-      const result = await communicationService.executeStorageAccessProtocol(this.data)
-      if (!result.ok) {
-        this.setData({
-          storageAccessErrorText: result.errorText || '操作失败'
-        })
+      if (!this.data.connectedDevice) {
+        if (this.pageToast) this.pageToast.show('请先连接蓝牙设备', 'error')
         return
       }
 
-      if (command.key === 'sync') {
-        this.setData({
-          storageAccessErrorText: '',
-          storageAccessSyncInfoText: result.syncInfoText || this.data.storageAccessSyncInfoText
-        })
-        if (this.pageToast) {
-          const codeInfoAddressText = result.codeInfoAddressText || '0000'
-          const codeInfoByteLengthText = result.codeInfoByteLengthText || '0000'
-          this.pageToast.show(`同步完成 codeInfo 0x${codeInfoAddressText} / 0x${codeInfoByteLengthText}`)
-        }
+      const result = await executeStorageAccessProtocol(this.data)
+      if (!result.ok) {
+        if (this.pageToast) this.pageToast.show(result.errorText || '操作失败', 'error')
         return
       }
 
-      if (command.key === 'read') {
+      if (!this.data.storageAccessShowWriteData) {
         this.setData({
-          storageAccessErrorText: '',
           storageAccessPreviewHexText: result.previewHex || ''
         })
         if (this.pageToast) this.pageToast.show(`读取完成 ${(result.bytes || []).length} bytes`)
         return
       }
 
-      this.setData({
-        storageAccessErrorText: ''
-      })
       if (this.pageToast) this.pageToast.show('写入完成')
     } catch (error) {
-      this.setData({
-        storageAccessErrorText: error.message || '操作失败'
-      })
+      if (this.pageToast) this.pageToast.show(error.message || '操作失败', 'error')
+    }
+  },
+
+  async syncStorageAccessCodeInfo() {
+    if (!this.data.isStorageAccessProtocol) return
+    if (!this.data.connectedDevice) {
+      if (this.pageToast) this.pageToast.show('请先连接蓝牙设备', 'error')
+      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(',')
+
+      this.pageToast.show(`同步完成 codeInfo 0x${codeInfoAddressText} / 0x${codeInfoByteLengthText},${result.structCount} 项${changedText ? `,${changedText}` : ''}`)
+    }
+  },
+
+  async sendStorageAccessSpecialCommand(event) {
+    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)
+      if (!result.ok) {
+        if (this.pageToast) this.pageToast.show(result.errorText || '指令执行失败', 'error')
+        return
+      }
+
+      if (this.pageToast) this.pageToast.show(`${command.label || '特殊指令'}已执行`)
+    } catch (error) {
+      if (this.pageToast) this.pageToast.show(error.message || '指令执行失败', 'error')
     }
   }
 })

+ 58 - 22
pages/communication/communication.wxml

@@ -4,7 +4,7 @@
 </view>
 <scroll-view class="scrollarea {{themeClass}}" scroll-y type="list">
   <view class="page-shell">
-    <view class="panel communication-panel">
+    <view wx:if="{{isNoProtocol}}" class="panel communication-panel">
       <view class="panel-header panel-header--with-actions">
         <view class="panel-icon icon-send">
           <image class="panel-icon-image" src="/assets/icons/send-white.png" mode="aspectFit" />
@@ -29,7 +29,6 @@
           <view class="comm-send-state">{{serialModeLabel}} · {{connectedDevice ? '已连接' : '未连接'}}</view>
           <view class="comm-send-length">{{serialPreviewLengthText}}</view>
         </view>
-        <view wx:if="{{serialErrorText}}" class="comm-error">{{serialErrorText}}</view>
         <view wx:if="{{serialPreviewHex}}" class="comm-preview">
           <view class="comm-preview-label">预览</view>
           <view class="comm-preview-value">{{serialPreviewHex}}</view>
@@ -55,7 +54,7 @@
         <view class="protocol-row">
           <view class="protocol-label">功能码</view>
           <picker mode="selector" range="{{protocolCommands}}" range-key="label" value="{{commandIndex}}" bindchange="onProtocolCommandChange">
-            <view class="picker-value protocol-value-picker">{{protocolCommands[commandIndex].label}}</view>
+            <view class="picker-value protocol-value-picker">{{protocolCommandLabel}}</view>
           </picker>
         </view>
         <view class="protocol-row">
@@ -81,8 +80,6 @@
           <view class="protocol-label">寄存器数</view>
           <input class="protocol-input protocol-row-input" value="{{commandRegisterQuantity}}" bindinput="onProtocolInput" data-field="commandRegisterQuantity" />
         </view>
-        <view wx:if="{{protocolErrorText}}" class="protocol-error">{{protocolErrorText}}</view>
-        <view wx:if="{{protocolStatusText}}" class="protocol-error">{{protocolStatusText}}</view>
         <view class="generated-frame">
           <view class="generated-title">帧</view>
           <view class="generated-value">{{generatedHex || '--'}}</view>
@@ -94,48 +91,87 @@
       </view>
     </view>
 
-    <view wx:if="{{!isModbusProtocol}}" class="panel communication-panel">
+    <view wx:if="{{isStorageAccessProtocol}}" class="panel communication-panel">
       <view class="panel-header panel-header--with-actions">
         <view class="panel-icon icon-chip">
           <image class="panel-icon-image" src="/assets/icons/chip-white.png" mode="aspectFit" />
         </view>
-        <view class="panel-title">私有协议</view>
+        <view class="panel-title">存储访问协议</view>
         <view class="panel-actions communication-actions">
-          <view class="panel-action-button {{storageAccessCommandIndex === 0 ? 'is-active' : ''}}" data-index="0" bindtap="switchStorageAccessCommandMode">同步</view>
-          <view class="panel-action-button {{storageAccessCommandIndex === 1 ? 'is-active' : ''}}" data-index="1" bindtap="switchStorageAccessCommandMode">读取</view>
-          <view class="panel-action-button {{storageAccessCommandIndex === 2 ? 'is-active' : ''}}" data-index="2" bindtap="switchStorageAccessCommandMode">写入</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>
         </view>
       </view>
       <view class="protocol-form">
-        <view wx:if="{{storageAccessCommandIndex !== 0}}" class="protocol-row">
+        <view class="storage-code-info-inline">
+          <view class="storage-code-info-top">
+            <text class="storage-code-info-model">{{storageCodeInfoCard.model}}</text>
+          </view>
+          <view class="storage-code-info-bottom">
+            <text class="storage-code-info-chip">{{storageCodeInfoCard.chipModel}}</text>
+            <view class="storage-code-info-metrics">
+              <view wx:for="{{storageCodeInfoCard.metricItems}}" wx:for-item="item" wx:key="text" class="storage-code-info-metric">{{item.text}}</view>
+            </view>
+          </view>
+        </view>
+        <view class="protocol-row">
           <view class="protocol-label">区域</view>
-          <picker mode="selector" range="{{storageAccessAreaOptions}}" range-key="label" value="{{storageAccessAreaIndex}}" bindchange="onStorageAccessAreaChange">
+          <view wx:if="{{storageAccessAreaLocked}}" class="picker-value protocol-value-picker">{{storageAccessAreaLabel}}</view>
+          <picker wx:else mode="selector" range="{{storageAccessAreaOptions}}" range-key="label" value="{{storageAccessAreaIndex}}" bindchange="onStorageAccessAreaChange">
             <view class="picker-value protocol-value-picker">{{storageAccessAreaLabel}}</view>
           </picker>
         </view>
-        <view wx:if="{{storageAccessCommandIndex !== 0}}" class="protocol-row">
+        <view class="protocol-row">
           <view class="protocol-label">地址</view>
-          <input class="protocol-input protocol-row-input" maxlength="4" value="{{storageAccessAddress}}" bindinput="onStorageAccessInput" data-field="storageAccessAddress" />
+          <input class="protocol-input protocol-row-input" maxlength="{{storageAccessAddressMaxLength || 8}}" value="{{storageAccessAddress}}" bindinput="onStorageAccessInput" data-field="storageAccessAddress" />
         </view>
-        <view wx:if="{{storageAccessCommandIndex !== 0}}" class="protocol-row">
+        <view class="protocol-row">
           <view class="protocol-label">长度</view>
           <input class="protocol-input protocol-row-input" maxlength="4" value="{{storageAccessLength}}" bindinput="onStorageAccessInput" data-field="storageAccessLength" />
         </view>
-        <view wx:if="{{storageAccessCommandIndex === 2}}" class="protocol-row">
+        <view wx:if="{{storageAccessShowWriteData}}" class="protocol-row">
           <view class="protocol-label">数据</view>
           <input class="protocol-input protocol-row-input" value="{{storageAccessDataText}}" bindinput="onStorageAccessInput" data-field="storageAccessDataText" placeholder="01 02 03 04" />
         </view>
-        <view wx:if="{{storageAccessCommandIndex === 0}}" class="protocol-row">
-          <view class="protocol-label">同步</view>
-          <view class="protocol-label protocol-storage-sync">{{storageAccessSyncInfoText}}</view>
-        </view>
-        <view wx:if="{{storageAccessErrorText}}" class="protocol-error">{{storageAccessErrorText}}</view>
         <view class="generated-frame">
-          <view class="generated-title"></view>
+          <view class="generated-title">{{storageAccessTitleText || '读取命令'}}</view>
           <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 class="generated-meta">特殊指令不带地址和长度,仅携带 OP 与必要数据。</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 class="protocol-row">
+          <view class="protocol-label">控制参考值</view>
+          <input
+            class="protocol-input protocol-row-input"
+            maxlength="6"
+            value="{{storageAccessControlRefText}}"
+            bindinput="onStorageAccessControlRefInput"
+            bindblur="onStorageAccessControlRefConfirm"
+            bindconfirm="onStorageAccessControlRefConfirm"
+            confirm-type="send"
+            placeholder="1500"
+          />
+        </view>
+        <view class="generated-frame">
+          <view class="generated-title">控制参考值</view>
+          <view class="generated-value">{{storageAccessControlRefPreviewHexText || '--'}}</view>
+        </view>
       </view>
     </view>
 

+ 30 - 6
pages/communication/communication.wxss

@@ -126,10 +126,6 @@
   font-weight: 800;
 }
 
-.protocol-storage-sync {
-  text-align: right;
-}
-
 .protocol-value-picker,
 .protocol-row-input {
   width: 300rpx;
@@ -228,6 +224,35 @@
   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;
 }
@@ -415,8 +440,7 @@
   color: #5eead4;
 }
 
-.theme-dark .log-row--TX .log-direction,
-.theme-dark .protocol-storage-sync {
+.theme-dark .log-row--TX .log-direction {
   color: #5eead4;
 }
 

+ 57 - 33
pages/params/params.js

@@ -33,6 +33,13 @@ function getParameterGroupsFromState(state = {}) {
   return state.parameterGroups || []
 }
 
+function isReadableParameterGroup(group = {}, data = {}) {
+  if (!group || group.addressOverflow) return false
+  if (data.isStorageAccessProtocol) return !!group.sourceMemoryArea
+
+  return !!group.functionCode
+}
+
 Page({
   data: {
     ...getPageState(),
@@ -217,49 +224,34 @@ Page({
     if (count && this.pageToast) this.pageToast.show(`已导入 ${count} 个寄存器组`)
   },
 
-  async syncParameterGroups() {
-    if (this.data.isModbusProtocol) return
-    if (!this.data.connectedDevice) return
-
-    const result = await parameterGroupService.syncFromStorageAccessCodeInfo({
-      maxPacketLength: this.data.parameterMaxPacketLength
-    })
-    if (result && result.ok && this.pageToast) {
-      const codeInfoAddressText = result.codeInfoAddressText || Number(result.codeInfoAddress || 0).toString(16).toUpperCase().padStart(4, '0')
-      const codeInfoByteLengthText = result.codeInfoByteLengthText || Number(result.codeInfoByteLength || 0).toString(16).toUpperCase().padStart(4, '0')
-      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(',')
-
-      this.pageToast.show(`同步完成 codeInfo 0x${codeInfoAddressText} / 0x${codeInfoByteLengthText},${result.structCount} 项${changedText ? `,${changedText}` : ''}`)
-    }
-  },
-
   async completeParameterStructs() {
     const result = await parameterGroupService.completeStructInstanceGroupsWithStructFile()
     if (result && result.completedCount && this.pageToast) {
-      this.pageToast.show(`已补全 ${result.completedCount} 个寄存器组`)
+      this.pageToast.show(`已补全 ${result.completedCount} 个结构体/枚举项`)
     }
   },
 
-  toggleParameterPolling() {
-    if (this.data.isStorageAccessProtocol && !this.data.connectedDevice) return
+  clearStorageAccessGroups() {
+    if (!this.data.isStorageAccessProtocol) return
 
-    const enabled = !this.data.parameterAutoPollEnabled
-    settingsService.setParameterAutoPollEnabled(enabled)
-    if (enabled) {
-      this.scheduleParameterAutoPoll(0)
-    } else {
-      this.clearParameterAutoTimers()
-    }
+    this.clearParameterAutoTimers()
+    parameterGroupService.clearStorageAccessGroups()
+    this.setData({
+      activeParameterGroup: null,
+      activeParameterGroupId: '',
+      activeParameterRegisterRows: [],
+      activeParamView: 'parameterGroups'
+    })
+    if (this.pageToast) this.pageToast.show('已清除结构体组')
   },
 
   async saveParameterGroupsJson() {
-    const count = await parameterGroupService.saveJsonToChat()
-    if (count && this.pageToast) this.pageToast.show(`已保存 ${count} 个寄存器组`)
+    const result = await parameterGroupService.saveJsonToChat()
+    const count = Number(result && result.count) || 0
+    if (count && this.pageToast) {
+      const actionText = result.method === 'disk' ? '已保存' : '已发送'
+      this.pageToast.show(`${actionText} ${count} 个寄存器组`)
+    }
   },
 
   toggleParameterGroup(event) {
@@ -308,6 +300,38 @@ Page({
     }
   },
 
+  async readAllParameterGroups() {
+    if (!this.data.connectedDevice) return
+
+    const protocolMode = this.data.protocolMode
+    const groups = getParameterGroupsFromState(this.data)
+      .filter((group) => isReadableParameterGroup(group, this.data))
+    if (!groups.length) {
+      if (this.pageToast) this.pageToast.show('没有可读取的参数组')
+      return
+    }
+
+    this.clearParameterAutoTimers()
+
+    let successCount = 0
+    for (const group of groups) {
+      if (!this.data.connectedDevice || this.data.protocolMode !== protocolMode) break
+
+      const ok = await parameterGroupService.readGroup(group.id, {
+        maxPacketLength: this.data.parameterMaxPacketLength,
+        protocolMode
+      })
+      if (ok) successCount += 1
+    }
+
+    if (this.data.parameterAutoPollEnabled) {
+      this.scheduleParameterAutoPoll(this.data.parameterPollInterval || 100)
+    }
+    if (this.pageToast) {
+      this.pageToast.show(`读取完成 ${successCount}/${groups.length} 个`)
+    }
+  },
+
   async readParameterGroup(event) {
     if (!this.data.connectedDevice) return
 

+ 22 - 23
pages/params/params.wxml

@@ -5,8 +5,7 @@
 <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 class="panel-action-button {{isStorageAccessProtocol && !connectedDevice ? 'is-disabled' : ''}}" bindtap="syncParameterGroups">同步</view>
-      <view class="panel-action-button {{isStorageAccessProtocol && !connectedDevice ? 'is-disabled' : ''}} {{parameterAutoPollEnabled ? 'is-active' : ''}}" bindtap="toggleParameterPolling">轮询</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>
@@ -31,17 +30,6 @@
 <scroll-view class="scrollarea {{themeClass}} scrollarea--subpage scrollarea--generic" scroll-y type="list">
   <view class="page-shell">
     <block wx:if="{{activeParamView == 'parameterGroups'}}">
-      <view wx:if="{{isStorageAccessProtocol}}" class="panel storage-code-info-card">
-        <view class="storage-code-info-top">
-          <text class="storage-code-info-model">{{storageCodeInfoCard.model}}</text>
-        </view>
-        <view class="storage-code-info-bottom">
-          <text class="storage-code-info-chip">{{storageCodeInfoCard.chipModel}}</text>
-          <view class="storage-code-info-metrics">
-            <view wx:for="{{storageCodeInfoCard.metricItems}}" wx:for-item="item" wx:key="text" class="storage-code-info-metric">{{item.text}}</view>
-          </view>
-        </view>
-      </view>
       <view
         wx:for="{{parameterGroups}}"
         wx:for-item="group"
@@ -153,7 +141,6 @@
               <block wx:else>
                 <input
                   class="value-input generic-register-value {{register.isDirty ? 'value-input--dirty' : ''}}"
-                  placeholder="--"
                   data-group-id="{{activeParameterGroup.id}}"
                   data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
                   value="{{register.inputValue}}"
@@ -183,8 +170,8 @@
       <view class="generic-dialog-body">
         <view class="generic-config-row">
           <view class="param-main">
-            <view class="param-name">寄存器组名</view>
-            <view class="param-meta">每组寄存器地址连续</view>
+            <view class="param-name">{{isStorageAccessProtocol ? '结构体组名' : '寄存器组名'}}</view>
+            <view class="param-meta">{{isStorageAccessProtocol ? '内存地址连续' : '每组寄存器地址连续'}}</view>
           </view>
           <input
             class="value-input generic-value-input"
@@ -193,7 +180,7 @@
             bindinput="onParameterDraftInput"
           />
         </view>
-        <view class="generic-config-row">
+        <view wx:if="{{isModbusProtocol}}" class="generic-config-row">
           <view class="param-main">
             <view class="param-name">寄存器类型</view>
             <view class="param-meta">决定读取功能码与是否可写</view>
@@ -210,8 +197,8 @@
         </view>
         <view class="generic-config-row">
           <view class="param-main">
-            <view class="param-name">寄存器起始地址</view>
-            <view class="param-meta">16进制,例如 00A0</view>
+            <view class="param-name">{{isStorageAccessProtocol ? '内存起始地址' : '寄存器起始地址'}}</view>
+            <view class="param-meta">{{isStorageAccessProtocol ? '16进制,最多 8 位,例如 000000A0' : '16进制,例如 00A0'}}</view>
           </view>
           <input
             class="value-input generic-value-input"
@@ -222,7 +209,7 @@
         </view>
         <view class="generic-config-row">
           <view class="param-main">
-            <view class="param-name">寄存器数量</view>
+            <view class="param-name">{{isStorageAccessProtocol ? '字节长度' : '寄存器数量'}}</view>
             <view class="param-meta">{{parameterDialog.structParsedSummary || '1 - 256'}}</view>
           </view>
           <input
@@ -233,18 +220,30 @@
             bindinput="onParameterDraftInput"
           />
         </view>
+        <view wx:if="{{parameterDialog.showPollEnabled}}" class="generic-config-row">
+          <view class="param-main">
+            <view class="param-name">参与轮询</view>
+            <view class="param-meta">自动轮询</view>
+          </view>
+          <switch
+            checked="{{parameterDialog.pollEnabled}}"
+            color="#0f766e"
+            data-field="pollEnabled"
+            bindchange="onParameterDraftSwitchChange"
+          />
+        </view>
         <view wx:if="{{parameterDialog.mode == 'createGroup'}}" class="generic-struct-section">
           <view class="generic-struct-header">
             <view class="param-main">
-              <view class="param-name">结构体定义</view>
-              <view class="param-meta">支持 typedef struct、typedef 别名与数组</view>
+              <view class="param-name">结构体/枚举定义</view>
+              <view class="param-meta">支持 typedef struct、typedef enum、typedef 别名与数组</view>
             </view>
             <view class="panel-action-button" bindtap="parseParameterStructDefinition">解析</view>
           </view>
           <textarea
             class="generic-struct-input"
             maxlength="-1"
-            placeholder="粘贴 C 结构体定义"
+            placeholder="粘贴 C 结构体/枚举定义"
             data-field="structDefinition"
             value="{{parameterDialog.structDefinition}}"
             bindinput="onParameterDraftInput"

+ 60 - 1
pages/settings/settings.js

@@ -13,10 +13,15 @@ const {
 const {
   getSettingsPageState
 } = require('../../features/settings/view-model.js')
+const protocolImplementation = require('../../features/settings/protocol-implementation.js')
+const {
+  getWxApi
+} = require('../../utils/base-utils.js')
 
 Page({
   data: {
     ...getSettingsPageState(),
+    ...protocolImplementation.getState(),
     ...createToolInitialState(),
     activeSettingsTitle: '',
     activeSettingsView: ''
@@ -106,6 +111,17 @@ Page({
     settingsService.setNightModeFollowSystem(!!event.detail.value)
   },
 
+  onSettingsDraftInput(event) {
+    const field = event && event.currentTarget && event.currentTarget.dataset
+      ? event.currentTarget.dataset.field
+      : ''
+    if (!field) return
+
+    this.setData({
+      [field]: event.detail.value
+    })
+  },
+
   onModbusSlaveAddressBlur(event) {
     settingsService.setModbusSlaveAddress(event.detail.value)
   },
@@ -140,6 +156,49 @@ Page({
     })
   },
 
+  openProtocolImplementation() {
+    if (this.pageToast) this.pageToast.clear()
+    this.setData({
+      activeSettingsTitle: protocolImplementation.TITLE,
+      activeSettingsView: protocolImplementation.VIEW_ID,
+      ...protocolImplementation.getState(this.data.storageProtocolImplementationActiveIndex)
+    })
+  },
+
+  selectProtocolImplementationFile(event) {
+    const index = Number(event.currentTarget.dataset.index)
+
+    this.setData(protocolImplementation.getState(index))
+  },
+
+  copyProtocolImplementationFile(event) {
+    const index = event && event.currentTarget && event.currentTarget.dataset
+      ? Number(event.currentTarget.dataset.index)
+      : this.data.storageProtocolImplementationActiveIndex
+    const text = protocolImplementation.getSourceText(index)
+    if (!text) {
+      if (this.pageToast) this.pageToast.show('源码暂未提供')
+      return
+    }
+
+    const wxApi = getWxApi()
+    if (typeof wxApi.setClipboardData !== 'function') {
+      if (this.pageToast) this.pageToast.show('当前环境不支持复制', 'error')
+      return
+    }
+
+    wxApi.setClipboardData({
+      data: text,
+      fail: () => {
+        if (this.pageToast) this.pageToast.show('复制失败', 'error')
+      },
+      success: () => {
+        const file = protocolImplementation.getState(index).storageProtocolImplementationActiveFile
+        if (this.pageToast) this.pageToast.show(`已复制 ${file.name}`)
+      }
+    })
+  },
+
   backToSettingsHome() {
     if (this.pageToast) this.pageToast.clear()
     this.setData({
@@ -151,7 +210,7 @@ Page({
   chooseFirmwareFile() {
     if (this.data.isBootloaderBusy) return
 
-    bootloaderService.chooseFirmwareFile('message')
+    bootloaderService.chooseFirmwareFile('auto')
   },
 
   startFirmwareUpgrade() {

+ 96 - 12
pages/settings/settings.wxml

@@ -83,6 +83,84 @@
       </view>
     </block>
 
+    <block wx:elif="{{activeSettingsView == storageProtocolImplementationView}}">
+      <view class="panel settings-section-panel storage-protocol-guide-panel">
+        <view class="storage-protocol-guide-kicker">{{storageProtocolImplementationGuide.kicker}}</view>
+        <view class="storage-protocol-guide-title">{{storageProtocolImplementationGuide.title}}</view>
+        <view class="storage-protocol-guide-text">{{storageProtocolImplementationGuide.text}}</view>
+        <view class="storage-protocol-guide-points">
+          <view
+            wx:for="{{storageProtocolImplementationGuide.points}}"
+            wx:key="id"
+            class="storage-protocol-guide-point"
+          >
+            {{item.text}}
+          </view>
+        </view>
+      </view>
+
+      <view class="panel settings-section-panel">
+        <view class="params-section-title">参考文件</view>
+        <view
+          wx:for="{{storageProtocolImplementationFiles}}"
+          wx:key="id"
+          class="settings-row storage-protocol-file-row {{item.active ? 'is-active' : ''}}"
+          data-index="{{index}}"
+          bindtap="selectProtocolImplementationFile"
+        >
+          <view class="settings-row-main">
+            <view class="storage-protocol-file-head">
+              <view class="param-name">{{item.name}}</view>
+              <view class="storage-protocol-file-badge">{{item.badge}}</view>
+            </view>
+            <view class="param-meta">{{item.role}} · {{item.sourceMeta}}</view>
+          </view>
+          <view
+            wx:if="{{item.sourceAvailable}}"
+            class="storage-protocol-copy-button"
+            data-index="{{index}}"
+            catchtap="copyProtocolImplementationFile"
+          >
+            复制
+          </view>
+          <view wx:else class="storage-protocol-copy-button is-disabled">暂未提供</view>
+          <view class="entry-chevron"></view>
+        </view>
+      </view>
+
+      <view class="panel settings-section-panel storage-protocol-detail-panel">
+        <view class="params-section-title storage-protocol-detail-title">
+          <view class="storage-protocol-detail-name">{{storageProtocolImplementationActiveFile.name}}</view>
+          <view class="storage-protocol-detail-role">{{storageProtocolImplementationActiveFile.role}}</view>
+        </view>
+        <view class="storage-protocol-detail-body">
+          <view class="storage-protocol-file-location">{{storageProtocolImplementationActiveFile.location}}</view>
+          <view class="storage-protocol-copy-card">
+            <view class="storage-protocol-copy-meta">{{storageProtocolImplementationActiveFile.sourceMeta}}</view>
+            <view
+              wx:if="{{storageProtocolImplementationActiveFile.sourceAvailable}}"
+              class="panel-action-button storage-protocol-copy-action"
+              data-index="{{storageProtocolImplementationActiveIndex}}"
+              bindtap="copyProtocolImplementationFile"
+            >
+              复制当前文件
+            </view>
+            <view wx:else class="panel-action-button storage-protocol-copy-action is-disabled">源码暂未提供</view>
+          </view>
+          <view class="storage-protocol-file-summary">{{storageProtocolImplementationActiveFile.summary}}</view>
+          <view class="storage-protocol-detail-list">
+            <view
+              wx:for="{{storageProtocolImplementationActiveFile.details}}"
+              wx:key="*this"
+              class="storage-protocol-detail-item"
+            >
+              {{item}}
+            </view>
+          </view>
+        </view>
+      </view>
+    </block>
+
     <block wx:elif="{{activeSettingsView == 'crc'}}">
       <view class="panel params-section-panel crc-algorithm-panel {{crcAlgorithmCollapsed ? 'panel--collapsed' : ''}}">
         <view class="param-row input-row">
@@ -259,12 +337,6 @@
             <view class="crc-calc-result-value" data-value="{{crcResultBase64}}" bindtap="copyToolResult">{{crcResultBase64}}</view>
           </view>
         </view>
-        <view wx:if="{{crcErrorText}}" class="param-row">
-          <view class="param-main">
-            <view class="param-name">错误</view>
-          </view>
-          <view class="crc-error-value">{{crcErrorText}}</view>
-        </view>
       </view>
     </block>
 
@@ -287,7 +359,6 @@
             <input
               class="value-input filter-value-input {{filterComputedKey == 'resistance' ? 'filter-value-input--computed' : ''}}"
               type="digit"
-              placeholder="--"
               data-field="resistance"
               value="{{filterResistanceDisplayValue}}"
               bindinput="onFilterResistanceInput"
@@ -314,7 +385,6 @@
             <input
               class="value-input filter-value-input {{filterComputedKey == 'reactive' ? 'filter-value-input--computed' : ''}}"
               type="digit"
-              placeholder="--"
               data-field="reactive"
               value="{{filterReactiveDisplayValue}}"
               bindinput="onFilterReactiveInput"
@@ -341,7 +411,6 @@
             <input
               class="value-input filter-value-input {{filterComputedKey == 'frequency' ? 'filter-value-input--computed' : ''}}"
               type="digit"
-              placeholder="--"
               data-field="frequency"
               value="{{filterFrequencyDisplayValue}}"
               bindinput="onFilterFrequencyInput"
@@ -418,7 +487,6 @@
             <input
               class="value-input filter-value-input"
               type="digit"
-              placeholder="--"
               data-field="frequency"
               value="{{reactanceFrequencyDisplayValue}}"
               bindinput="onReactanceFrequencyInput"
@@ -444,7 +512,6 @@
             <input
               class="value-input filter-value-input"
               type="digit"
-              placeholder="--"
               data-field="reactive"
               value="{{reactanceReactiveDisplayValue}}"
               bindinput="onReactanceReactiveInput"
@@ -699,7 +766,7 @@
       <view class="settings-row">
         <view class="settings-row-main">
           <view class="param-name">协议模式</view>
-          <view class="param-meta">{{isStorageAccessProtocol ? '单主单从存储访问' : '标准功能码寄存器'}}</view>
+          <view class="param-meta">{{isNoProtocol ? '仅显示串口收发' : (isStorageAccessProtocol ? '单主单从存储访问' : '标准功能码寄存器')}}</view>
         </view>
         <picker
           mode="selector"
@@ -711,6 +778,17 @@
           <view class="settings-picker-value">{{protocolText}}</view>
         </picker>
       </view>
+      <view
+        wx:if="{{isStorageAccessProtocol}}"
+        class="settings-row settings-tool-row"
+        bindtap="openProtocolImplementation"
+      >
+        <view class="settings-row-main">
+          <view class="param-name">协议实现</view>
+          <view class="param-meta">{{storageProtocolImplementationEntryMeta}}</view>
+        </view>
+        <view class="entry-chevron"></view>
+      </view>
       <view wx:if="{{isModbusProtocol}}" class="settings-row settings-row--input">
         <view class="settings-row-main">
           <view class="param-name">从机地址</view>
@@ -721,6 +799,8 @@
             class="value-input settings-value-input settings-value-input--hex"
             maxlength="2"
             value="{{modbusSlaveAddress}}"
+            data-field="modbusSlaveAddress"
+            bindinput="onSettingsDraftInput"
             bindblur="onModbusSlaveAddressBlur"
             bindconfirm="onModbusSlaveAddressBlur"
           />
@@ -747,6 +827,8 @@
             class="value-input settings-value-input settings-value-input--unit"
             type="number"
             value="{{parameterPollInterval}}"
+            data-field="parameterPollInterval"
+            bindinput="onSettingsDraftInput"
             bindblur="onParameterPollIntervalBlur"
             bindconfirm="onParameterPollIntervalBlur"
           />
@@ -763,6 +845,8 @@
             class="value-input settings-value-input settings-value-input--unit"
             type="number"
             value="{{parameterMaxPacketLength}}"
+            data-field="parameterMaxPacketLength"
+            bindinput="onSettingsDraftInput"
             bindblur="onParameterMaxPacketLengthBlur"
             bindconfirm="onParameterMaxPacketLengthBlur"
           />

+ 235 - 0
pages/settings/settings.wxss

@@ -30,6 +30,197 @@
   opacity: 0.72;
 }
 
+.storage-protocol-guide-panel {
+  padding: 24rpx;
+}
+
+.storage-protocol-guide-kicker {
+  color: var(--accent-dark);
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 900;
+}
+
+.storage-protocol-guide-title {
+  margin-top: 8rpx;
+  color: #111827;
+  font-size: 34rpx;
+  line-height: 1.28;
+  font-weight: 900;
+}
+
+.storage-protocol-guide-text {
+  margin-top: 14rpx;
+  color: #475569;
+  font-size: 25rpx;
+  line-height: 1.58;
+  word-break: break-all;
+}
+
+.storage-protocol-guide-points {
+  margin-top: 18rpx;
+}
+
+.storage-protocol-guide-point {
+  position: relative;
+  padding: 14rpx 0 14rpx 30rpx;
+  border-top: 1rpx solid #edf2f7;
+  color: #334155;
+  font-size: 24rpx;
+  line-height: 1.5;
+  word-break: break-all;
+}
+
+.storage-protocol-guide-point::before {
+  content: "";
+  position: absolute;
+  left: 4rpx;
+  top: 28rpx;
+  width: 10rpx;
+  height: 10rpx;
+  border-radius: 50%;
+  background: var(--accent);
+}
+
+.storage-protocol-file-row.is-active {
+  background: #effaf8;
+}
+
+.storage-protocol-file-row:active {
+  opacity: 0.72;
+}
+
+.storage-protocol-file-head {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.storage-protocol-file-badge {
+  flex: none;
+  height: 34rpx;
+  padding: 0 12rpx;
+  border-radius: 999rpx;
+  background: #e8f6f5;
+  color: var(--accent-dark);
+  font-size: 20rpx;
+  line-height: 34rpx;
+  font-weight: 900;
+}
+
+.storage-protocol-copy-button {
+  flex: none;
+  height: 50rpx;
+  padding: 0 18rpx;
+  border: 1rpx solid #d9edeb;
+  border-radius: 999rpx;
+  background: #f4fbfa;
+  color: var(--accent-dark);
+  font-size: 22rpx;
+  line-height: 50rpx;
+  font-weight: 900;
+}
+
+.storage-protocol-copy-button:active {
+  opacity: 0.72;
+}
+
+.storage-protocol-detail-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.storage-protocol-detail-name {
+  min-width: 0;
+  flex: 1;
+  color: #111827;
+  font-size: 26rpx;
+  line-height: 1.35;
+  font-weight: 900;
+  word-break: break-all;
+}
+
+.storage-protocol-detail-role {
+  flex: none;
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.storage-protocol-detail-body {
+  padding: 22rpx 24rpx 24rpx;
+}
+
+.storage-protocol-file-location {
+  padding: 14rpx 16rpx;
+  border-radius: 12rpx;
+  background: #f8fafc;
+  color: #0f766e;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 22rpx;
+  line-height: 1.45;
+  word-break: break-all;
+}
+
+.storage-protocol-copy-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+  margin-top: 16rpx;
+}
+
+.storage-protocol-copy-meta {
+  min-width: 0;
+  flex: 1;
+  color: #64748b;
+  font-size: 23rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.storage-protocol-copy-action {
+  flex: none;
+  width: auto;
+  min-width: 170rpx;
+  padding: 0 18rpx;
+}
+
+.storage-protocol-file-summary {
+  margin-top: 18rpx;
+  color: #334155;
+  font-size: 25rpx;
+  line-height: 1.55;
+  word-break: break-all;
+}
+
+.storage-protocol-detail-list {
+  margin-top: 16rpx;
+}
+
+.storage-protocol-detail-item {
+  position: relative;
+  padding: 10rpx 0 10rpx 28rpx;
+  color: #475569;
+  font-size: 24rpx;
+  line-height: 1.5;
+  word-break: break-all;
+}
+
+.storage-protocol-detail-item::before {
+  content: "";
+  position: absolute;
+  left: 4rpx;
+  top: 25rpx;
+  width: 8rpx;
+  height: 8rpx;
+  border-radius: 50%;
+  background: #94a3b8;
+}
+
 .settings-tool-main {
   min-width: 0;
   flex: 1;
@@ -277,6 +468,50 @@
   border-color: #263241;
 }
 
+.theme-dark .storage-protocol-guide-title,
+.theme-dark .storage-protocol-detail-name {
+  color: #e5e7eb;
+}
+
+.theme-dark .storage-protocol-guide-text,
+.theme-dark .storage-protocol-guide-point,
+.theme-dark .storage-protocol-file-summary,
+.theme-dark .storage-protocol-detail-item {
+  color: #cbd5e1;
+}
+
+.theme-dark .storage-protocol-guide-point {
+  border-color: #263241;
+}
+
+.theme-dark .storage-protocol-file-row.is-active {
+  background: #102f2e;
+}
+
+.theme-dark .storage-protocol-file-badge {
+  background: #123d3b;
+  color: #5eead4;
+}
+
+.theme-dark .storage-protocol-copy-button {
+  border-color: #174e49;
+  background: #123d3b;
+  color: #99f6e4;
+}
+
+.theme-dark .storage-protocol-detail-role {
+  color: #94a3b8;
+}
+
+.theme-dark .storage-protocol-file-location {
+  background: #111827;
+  color: #5eead4;
+}
+
+.theme-dark .storage-protocol-copy-meta {
+  color: #94a3b8;
+}
+
 .theme-dark .settings-unit {
   color: #94a3b8;
 }

+ 3 - 222
protocols/modbus-rtu/index.js

@@ -9,12 +9,6 @@ const {
   appendCrc16Modbus,
   hasValidCrc16Modbus
 } = require('../../utils/crc.js')
-const settingsService = require('../../store/settings-store.js')
-const transport = require('../../transport/ble-core.js')
-const {
-  addCoilReadValues,
-  addWordReadValues
-} = require('../../utils/register-value-utils.js')
 
 const PROTOCOL_NAME = 'modbus-rtu'
 const MODBUS_CRC_OPTIONS = {
@@ -430,33 +424,6 @@ function readResponseFromBuffer(buffer, expected, options = {}) {
   }
 }
 
-function getSharedSlaveAddress(title = '从机地址错误') {
-  try {
-    return settingsService.getModbusSlaveAddress()
-  } catch (error) {
-    transport.showCommandAlert(title, error.message)
-    return null
-  }
-}
-
-function formatAddress(value) {
-  return Number(value || 0).toString(16).toUpperCase()
-}
-
-function getChunkLabel(label, chunks, chunk) {
-  if (!label || chunks.length <= 1) return label
-
-  return `${label} ${formatAddress(chunk.address)}-${formatAddress(chunk.address + chunk.quantity - 1)}`
-}
-
-function isBroadcastAddress(slaveAddress) {
-  return Number(slaveAddress) === 0
-}
-
-function showBroadcastReadAlert(label) {
-  transport.showCommandAlert(label || '读取命令错误', '广播地址 0x00 不支持读取')
-}
-
 function splitQuantity(startAddress, quantity, maxQuantity) {
   const chunks = []
   let address = Number(startAddress) || 0
@@ -482,178 +449,6 @@ function getReadChunks(functionCode, startAddress, quantity, options = {}) {
   return splitQuantity(startAddress, quantity, maxQuantity || quantity)
 }
 
-async function sendReadChunk(slaveAddress, functionCode, chunk, label, kind, options = {}) {
-  if (isBroadcastAddress(slaveAddress)) {
-    showBroadcastReadAlert(label)
-    return false
-  }
-
-  return transport.sendManagedFrame(
-    buildReadFrame(slaveAddress, functionCode, chunk.address, chunk.quantity, {
-      maxFrameBytes: options.maxFrameBytes
-    }),
-    label,
-    {
-      address: chunk.address,
-      functionCode,
-      kind,
-      protocol: PROTOCOL_NAME,
-      quantity: chunk.quantity,
-      slaveAddress
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-async function readSpans(slaveAddress, functionCode, spans, label, kind, options = {}) {
-  const readValues = {
-    coils: {},
-    words: {}
-  }
-  const normalizedSpans = (spans || []).filter((span) => span && span.quantity > 0)
-
-  for (const span of normalizedSpans) {
-    const chunks = getReadChunks(functionCode, span.address, span.quantity, options)
-
-    for (const chunk of chunks) {
-      const responseValue = await sendReadChunk(
-        slaveAddress,
-        functionCode,
-        chunk,
-        getChunkLabel(label, chunks, chunk),
-        kind,
-        options
-      )
-      if (!responseValue) return null
-
-      if (functionCode === 0x01 || functionCode === 0x02) {
-        addCoilReadValues(readValues, chunk.address, chunk.quantity, responseValue)
-      } else {
-        addWordReadValues(readValues, chunk.address, responseValue)
-      }
-
-      if (typeof options.onChunk === 'function') {
-        options.onChunk(responseValue, chunk)
-      }
-    }
-  }
-
-  return readValues
-}
-
-async function readRegisterWords(slaveAddress, functionCode, startAddress, quantity, label, kind, options = {}) {
-  const words = []
-  const chunks = getReadChunks(functionCode, startAddress, quantity, options)
-
-  for (const chunk of chunks) {
-    const responseValue = await sendReadChunk(
-      slaveAddress,
-      functionCode,
-      chunk,
-      getChunkLabel(label, chunks, chunk),
-      kind,
-      options
-    )
-    if (!responseValue) return null
-
-    const chunkWords = responseValue.words || []
-    chunkWords.forEach((word, index) => {
-      words[chunk.address - startAddress + index] = Number(word) & 0xFFFF
-    })
-
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(responseValue, chunk)
-    }
-  }
-
-  return words
-}
-
-async function readBitValues(slaveAddress, functionCode, startAddress, quantity, label, kind, options = {}) {
-  const result = await readSpans(
-    slaveAddress,
-    functionCode,
-    [{ address: startAddress, quantity }],
-    label,
-    kind,
-    options
-  )
-
-  return result ? result.coils : null
-}
-
-async function readSingleHoldingWord(slaveAddress, address, label = '读取配对寄存器', kind = 'holding-word-read') {
-  const words = await readRegisterWords(slaveAddress, 0x03, address, 1, label, kind)
-
-  return words && Number.isInteger(words[0]) ? words[0] & 0xFFFF : null
-}
-
-function writeSingleCoil(slaveAddress, address, checked, label, kind = 'coil-write', options = {}) {
-  const coilValue = checked ? 0xFF00 : 0x0000
-
-  return transport.sendManagedFrame(
-    buildWriteSingleCoilFrame(slaveAddress, address, checked),
-    label,
-    isBroadcastAddress(slaveAddress) ? null : {
-      address,
-      functionCode: 0x05,
-      kind,
-      protocol: PROTOCOL_NAME,
-      quantity: 1,
-      slaveAddress,
-      value: coilValue
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-function writeSingleRegister(slaveAddress, address, value, label, kind = 'register-write', options = {}) {
-  return transport.sendManagedFrame(
-    buildWriteSingleRegisterFrame(slaveAddress, address, value),
-    label,
-    isBroadcastAddress(slaveAddress) ? null : {
-      address,
-      functionCode: 0x06,
-      kind,
-      protocol: PROTOCOL_NAME,
-      quantity: 1,
-      slaveAddress,
-      value
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-function writeMultipleRegisters(slaveAddress, address, values, label, kind = 'registers-write', options = {}) {
-  return transport.sendManagedFrame(
-    buildWriteMultipleRegistersFrame(slaveAddress, address, values, {
-      maxFrameBytes: options.maxFrameBytes
-    }),
-    label,
-    isBroadcastAddress(slaveAddress) ? null : {
-      address,
-      functionCode: 0x10,
-      kind,
-      protocol: PROTOCOL_NAME,
-      quantity: values.length,
-      slaveAddress
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
 const response = {
   MODBUS_EXCEPTION_MESSAGES,
   formatExceptionMessage,
@@ -666,21 +461,6 @@ const response = {
   readResponseFromBuffer
 }
 
-const client = {
-  getReadChunks,
-  getSharedSlaveAddress,
-  getMaxReadQuantity,
-  readBitValues,
-  readRegisterWords,
-  readSingleHoldingWord,
-  readSpans,
-  splitQuantity,
-  getMaxWriteMultipleRegisterQuantity,
-  writeMultipleRegisters,
-  writeSingleCoil,
-  writeSingleRegister
-}
-
 module.exports = {
   MAX_MODBUS_DMA_BYTES,
   MAX_READ_COIL_QUANTITY,
@@ -694,11 +474,12 @@ module.exports = {
   buildWriteMultipleRegistersFrame,
   buildWriteSingleCoilFrame,
   buildWriteSingleRegisterFrame,
-  client,
   formatHex,
   getMaxReadQuantity,
   getMaxWriteMultipleRegisterQuantity,
+  getReadChunks,
   getReadResponseByteLength,
   hasValidCrc16Modbus,
-  response
+  response,
+  splitQuantity
 }

+ 397 - 220
protocols/storage-access/index.js

@@ -11,39 +11,58 @@ const {
   crc16Ccitt,
   hasValidCrc16Ccitt
 } = require('../../utils/crc.js')
-const transport = require('../../transport/ble-core.js')
 
 const PROTOCOL_NAME = 'storage-access'
 
 const CMD_ERR_MASK = 0x80
-const CMD_WRITE_MASK = 0x40
-const CMD_AREA_MASK = 0x3F
-const CMD_INFO = 0x0F
-const INFO_DATA_BYTE_LENGTH = 4
+const CMD_CONTROL_FLAG = 0x40
+const CMD_WRITE_MASK = 0x08
+const CMD_ADDRESS_MODE_MASK = 0x07
+const CMD_RESERVED_MASK = 0x30
+const CMD_CONTROL = 0x4F
+const CONTROL_RESPONSE_HEADER_LENGTH = 3
+const ADDRESS16_BYTE_LENGTH = 2
+const ADDRESS32_BYTE_LENGTH = 4
 
 const AREA = {
+  ADDR32: 0x07,
   DATA: 0x01,
   IDATA: 0x02,
   XDATA: 0x03,
-  CODE: 0x04,
-  INFO: 0x0F
+  CODE: 0x04
+}
+
+const CONTROL_OP = {
+  RESET: 0x01,
+  START: 0x02,
+  STOP: 0x03,
+  SET_CONTROL_REF: 0x04,
+  READ_CODE_INFO_DESCRIPTOR: 0x05
+}
+
+const CONTROL_STATUS_MESSAGES = {
+  0x00: '成功',
+  0x01: '不支持的指令',
+  0x02: '参数无效',
+  0x03: '设备忙',
+  0x04: '执行失败'
 }
 
 const AREA_NAMES = {
+  [AREA.ADDR32]: 'ADDR32',
   [AREA.DATA]: 'DATA',
   [AREA.IDATA]: 'IDATA',
   [AREA.XDATA]: 'XDATA',
-  [AREA.CODE]: 'CODE',
-  [AREA.INFO]: 'INFO'
+  [AREA.CODE]: 'CODE'
 }
 
 const AREA_BY_NAME = {
+  ADDR32: AREA.ADDR32,
+  ADDRESS32: AREA.ADDR32,
   DATA: AREA.DATA,
   IDATA: AREA.IDATA,
   XDATA: AREA.XDATA,
-  CODE: AREA.CODE,
-  INFO: AREA.INFO,
-  SYNC: AREA.INFO
+  CODE: AREA.CODE
 }
 
 const EXCEPTION_MESSAGES = {
@@ -62,22 +81,34 @@ const EXCEPTION_MESSAGES = {
 }
 
 const DEFAULT_MAX_FRAME_BYTES = 64
-const MAX_PAYLOAD_BYTES = 256
 const UNLIMITED_FRAME_BYTES = 0
-
-const READ_REQUEST_LENGTH = 7
-const WRITE_REQUEST_OVERHEAD = 7
-const READ_RESPONSE_OVERHEAD = 7
-const WRITE_RESPONSE_LENGTH = 7
+const MAX_UINT16 = 0xFFFF
+const MAX_UINT32 = 0xFFFFFFFF
+const MAX_PAYLOAD_BYTES = MAX_UINT16
+
+const READ_REQUEST_LENGTH_16 = 1 + ADDRESS16_BYTE_LENGTH + 2 + 2
+const READ_REQUEST_LENGTH_32 = 1 + ADDRESS32_BYTE_LENGTH + 2 + 2
+const WRITE_REQUEST_OVERHEAD_16 = 1 + ADDRESS16_BYTE_LENGTH + 2 + 2
+const WRITE_REQUEST_OVERHEAD_32 = 1 + ADDRESS32_BYTE_LENGTH + 2 + 2
+const READ_RESPONSE_OVERHEAD_16 = 1 + ADDRESS16_BYTE_LENGTH + 2 + 2
+const READ_RESPONSE_OVERHEAD_32 = 1 + ADDRESS32_BYTE_LENGTH + 2 + 2
+const WRITE_RESPONSE_LENGTH_16 = 1 + ADDRESS16_BYTE_LENGTH + 2 + 2
+const WRITE_RESPONSE_LENGTH_32 = 1 + ADDRESS32_BYTE_LENGTH + 2 + 2
 const EXCEPTION_RESPONSE_LENGTH = 4
-const INFO_REQUEST_LENGTH = READ_REQUEST_LENGTH
-const INFO_RESPONSE_LENGTH = READ_RESPONSE_OVERHEAD + INFO_DATA_BYTE_LENGTH
+const CODE_INFO_DESCRIPTOR_BYTE_LENGTH = 11
+const MEMORY_ENDIAN_MARK_BIG = 0x55AA
+const MEMORY_ENDIAN_MARK_LITTLE = 0xAA55
+const MEMORY_ENDIAN = {
+  BIG: 'big',
+  LITTLE: 'little'
+}
 
 const STORAGE_CRC_OPTIONS = {
   byteOrder: BYTE_ORDER_HIGH
 }
-const VALID_AREAS = [AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE, AREA.INFO]
-const MEMORY_AREAS = [AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE]
+const VALID_AREAS = [AREA.ADDR32, AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE]
+const MEMORY_AREAS = [AREA.ADDR32, AREA.DATA, AREA.IDATA, AREA.XDATA, AREA.CODE]
+const RESERVED_AREAS = [0x00, 0x05, 0x06]
 
 function toByte(value, label) {
   if (!Number.isInteger(value) || value < 0 || value > 0xFF) {
@@ -95,24 +126,68 @@ function toWord(value, label) {
   return value
 }
 
+function toInt16Word(value, label) {
+  if (!Number.isInteger(value) || value < -0x8000 || value > 0x7FFF) {
+    throw new Error(`${label}必须在 -32768 至 32767 之间`)
+  }
+
+  return value & 0xFFFF
+}
+
+function toUint32(value, label) {
+  if (!Number.isInteger(value) || value < 0 || value > MAX_UINT32) {
+    throw new Error(`${label}必须在 0x00000000 至 0xFFFFFFFF 之间`)
+  }
+
+  return value
+}
+
 function normalizeArea(value) {
   if (typeof value === 'string') {
     const area = AREA_BY_NAME[value.trim().toUpperCase()]
-    if (area) return area
+    if (area !== undefined) return area
   }
 
   const area = toByte(Number(value), '存储区域')
   if (VALID_AREAS.indexOf(area) < 0) {
-    throw new Error('存储区域必须为 data/idata/xdata/code/info')
+    throw new Error('存储区域必须为 addr32/data/idata/xdata/code')
   }
 
   return area
 }
 
+function isAddress32Area(area) {
+  return Number(area) === AREA.ADDR32
+}
+
+function getAddressFieldByteLength(area) {
+  return isAddress32Area(area) ? ADDRESS32_BYTE_LENGTH : ADDRESS16_BYTE_LENGTH
+}
+
+function getMemoryHeaderLength(area) {
+  return 1 + getAddressFieldByteLength(area) + 2
+}
+
+function getReadRequestLength(area) {
+  return getMemoryHeaderLength(area) + 2
+}
+
+function getWriteRequestOverhead(area) {
+  return getMemoryHeaderLength(area) + 2
+}
+
+function getReadResponseOverhead(area) {
+  return getMemoryHeaderLength(area) + 2
+}
+
+function getWriteResponseLength(area) {
+  return getMemoryHeaderLength(area) + 2
+}
+
 function normalizeMemoryArea(value) {
   const area = normalizeArea(value)
   if (MEMORY_AREAS.indexOf(area) < 0) {
-    throw new Error('存储读写区域必须为 data/idata/xdata/code')
+    throw new Error('存储读写区域必须为 addr32/data/idata/xdata/code')
   }
 
   return area
@@ -134,10 +209,58 @@ function splitWord(value) {
   return [(value >> 8) & 0xFF, value & 0xFF]
 }
 
+function splitDword(value) {
+  const normalizedValue = Number(value) >>> 0
+
+  return [
+    (normalizedValue >>> 24) & 0xFF,
+    (normalizedValue >>> 16) & 0xFF,
+    (normalizedValue >>> 8) & 0xFF,
+    normalizedValue & 0xFF
+  ]
+}
+
 function readWord(bytes, offset) {
   return (((bytes[offset] || 0) << 8) | (bytes[offset + 1] || 0)) & 0xFFFF
 }
 
+function readDword(bytes, offset) {
+  return (
+    ((bytes[offset] || 0) * 0x1000000)
+    + (((bytes[offset + 1] || 0) << 16) >>> 0)
+    + (((bytes[offset + 2] || 0) << 8) >>> 0)
+    + (bytes[offset + 3] || 0)
+  ) >>> 0
+}
+
+function normalizeDescriptorAddressWidth(value) {
+  const numberValue = Number(value)
+  if (numberValue === 16 || numberValue === 32) return numberValue
+
+  throw new Error('CodeInfo 描述符地址长度必须为 16 或 32')
+}
+
+function parseMemoryEndianMark(bytes, offset) {
+  const marker = readWord(bytes, offset)
+  if (marker === MEMORY_ENDIAN_MARK_BIG) {
+    return {
+      marker,
+      memoryEndian: MEMORY_ENDIAN.BIG
+    }
+  }
+  if (marker === MEMORY_ENDIAN_MARK_LITTLE) {
+    return {
+      marker,
+      memoryEndian: MEMORY_ENDIAN.LITTLE
+    }
+  }
+
+  return {
+    marker,
+    memoryEndian: ''
+  }
+}
+
 function normalizeMaxFrameBytes(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) {
   const numberValue = Number(maxFrameBytes)
   if (Number.isFinite(numberValue) && Math.round(numberValue) === UNLIMITED_FRAME_BYTES) return UNLIMITED_FRAME_BYTES
@@ -146,6 +269,15 @@ function normalizeMaxFrameBytes(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) {
   return DEFAULT_MAX_FRAME_BYTES
 }
 
+function resolveDescriptorMaxFrameBytes(configuredMaxFrameBytes, descriptorMaxFrameBytes) {
+  const configured = normalizeMaxFrameBytes(configuredMaxFrameBytes)
+  const descriptor = normalizeMaxFrameBytes(descriptorMaxFrameBytes)
+  if (descriptor === UNLIMITED_FRAME_BYTES) return configured
+  if (configured === UNLIMITED_FRAME_BYTES) return descriptor
+
+  return Math.min(configured, descriptor)
+}
+
 function getPayloadLimitFromFrame(maxFrameBytes, overhead) {
   const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
   if (frameBytes === UNLIMITED_FRAME_BYTES) return MAX_PAYLOAD_BYTES
@@ -153,28 +285,41 @@ function getPayloadLimitFromFrame(maxFrameBytes, overhead) {
   return Math.max(0, Math.min(MAX_PAYLOAD_BYTES, frameBytes - overhead))
 }
 
-function getMaxReadByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) {
-  return getPayloadLimitFromFrame(maxFrameBytes, READ_RESPONSE_OVERHEAD)
+function getMaxReadByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES, area = AREA.ADDR32) {
+  return getPayloadLimitFromFrame(maxFrameBytes, getReadResponseOverhead(area))
 }
 
-function getMaxWriteByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) {
-  return getPayloadLimitFromFrame(maxFrameBytes, WRITE_REQUEST_OVERHEAD)
+function getMaxWriteByteLength(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES, area = AREA.ADDR32) {
+  return getPayloadLimitFromFrame(maxFrameBytes, getWriteRequestOverhead(area))
 }
 
 function buildCommand(area, isWrite = false) {
   const normalizedArea = isWrite ? normalizeMemoryArea(area) : normalizeArea(area)
 
+  if (RESERVED_AREAS.indexOf(normalizedArea) >= 0) {
+    throw new Error('存储访问区域号 0x00/0x05/0x06 暂时保留')
+  }
+
   return (isWrite ? CMD_WRITE_MASK : 0x00) | normalizedArea
 }
 
 function decodeCommand(command) {
   const cmd = toByte(Number(command), '命令字')
+  const sourceCommand = cmd & ~CMD_ERR_MASK
+  const isControl = sourceCommand === CMD_CONTROL
+  const area = isControl ? CMD_CONTROL : (sourceCommand & CMD_ADDRESS_MODE_MASK)
+  const reservedBits = sourceCommand & CMD_RESERVED_MASK
 
   return {
-    area: cmd & CMD_AREA_MASK,
+    addressBytes: isControl ? 0 : getAddressFieldByteLength(area),
+    area,
     command: cmd,
     hasError: !!(cmd & CMD_ERR_MASK),
-    isWrite: !!(cmd & CMD_WRITE_MASK)
+    hasReservedBits: !isControl && reservedBits !== 0,
+    isAddress32: !isControl && isAddress32Area(area),
+    isControl,
+    isWrite: !isControl && !!(sourceCommand & CMD_WRITE_MASK),
+    sourceCommand
   }
 }
 
@@ -193,16 +338,18 @@ function appendStorageCrc(bytes) {
 }
 
 function buildReadFrame(area, address, byteLength, options = {}) {
-  const command = buildCommand(area, false)
-  const startAddress = toWord(Number(address), '内存地址')
-  const maxByteLength = getMaxReadByteLength(options.maxFrameBytes)
+  const normalizedArea = normalizeMemoryArea(area)
+  const addressBytes = getAddressFieldByteLength(normalizedArea)
+  const startAddress = addressBytes === ADDRESS32_BYTE_LENGTH
+    ? toUint32(Number(address), '内存地址')
+    : toWord(Number(address), '内存地址')
+  const maxByteLength = getMaxReadByteLength(options.maxFrameBytes, normalizedArea)
   const length = toByteLength(Number(byteLength), '读取字节长度', maxByteLength || MAX_PAYLOAD_BYTES)
+  const command = buildCommand(normalizedArea, false)
+  const addressParts = addressBytes === ADDRESS32_BYTE_LENGTH ? splitDword(startAddress) : splitWord(startAddress)
+  const lengthParts = splitWord(toWord(length, '读取字节长度'))
 
-  return appendStorageCrc([command].concat(splitWord(startAddress), splitWord(length)))
-}
-
-function buildInfoFrame(address = 0, byteLength = INFO_DATA_BYTE_LENGTH) {
-  return buildReadFrame(AREA.INFO, address, byteLength)
+  return appendStorageCrc([command].concat(addressParts, lengthParts))
 }
 
 function buildWriteFrame(area, address, bytes, options = {}) {
@@ -211,13 +358,33 @@ function buildWriteFrame(area, address, bytes, options = {}) {
     throw new Error('code 区暂不支持写入')
   }
 
-  const command = buildCommand(normalizedArea, true)
-  const startAddress = toWord(Number(address), '内存地址')
+  const addressBytes = getAddressFieldByteLength(normalizedArea)
+  const startAddress = addressBytes === ADDRESS32_BYTE_LENGTH
+    ? toUint32(Number(address), '内存地址')
+    : toWord(Number(address), '内存地址')
   const dataBytes = toByteArray(bytes).map((byte) => toByte(Number(byte), '写入字节'))
-  const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes)
+  const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes, normalizedArea)
   const length = toByteLength(dataBytes.length, '写入字节长度', maxByteLength || MAX_PAYLOAD_BYTES)
+  const command = buildCommand(normalizedArea, true)
+  const addressParts = addressBytes === ADDRESS32_BYTE_LENGTH ? splitDword(startAddress) : splitWord(startAddress)
+  const lengthParts = splitWord(toWord(length, '写入字节长度'))
 
-  return appendStorageCrc([command].concat(splitWord(startAddress), splitWord(length), dataBytes))
+  return appendStorageCrc([command].concat(addressParts, lengthParts, dataBytes))
+}
+
+function buildControlFrame(operation, dataBytes = []) {
+  const op = toByte(Number(operation), '特殊指令')
+  const payload = toByteArray(dataBytes).map((byte) => toByte(Number(byte), '指令数据'))
+
+  return appendStorageCrc([CMD_CONTROL, op].concat(payload))
+}
+
+function buildControlReferenceFrame(referenceValue) {
+  return buildControlFrame(CONTROL_OP.SET_CONTROL_REF, splitWord(toInt16Word(Number(referenceValue), '控制参考值')))
+}
+
+function buildCodeInfoDescriptorFrame() {
+  return buildControlFrame(CONTROL_OP.READ_CODE_INFO_DESCRIPTOR)
 }
 
 function formatHex(bytes) {
@@ -230,6 +397,7 @@ function parseStorageAccessResponse(bytes) {
 
   const command = frame[0] & 0xFF
   const decoded = decodeCommand(command)
+  if (decoded.isControl) return parseStorageControlResponse(frame, decoded)
 
   if (decoded.hasError) {
     if (frame.length !== EXCEPTION_RESPONSE_LENGTH) return null
@@ -242,88 +410,151 @@ function parseStorageAccessResponse(bytes) {
       isException: true,
       isWrite: decoded.isWrite,
       protocol: PROTOCOL_NAME,
-      sourceCommand: command & ~CMD_ERR_MASK
+      sourceCommand: decoded.sourceCommand
     }
   }
 
+  if (decoded.hasReservedBits) return null
   if (!AREA_NAMES[decoded.area]) return null
+  const addressBytes = decoded.addressBytes
+  const headerLength = getMemoryHeaderLength(decoded.area)
 
   if (decoded.isWrite) {
-    if (decoded.area === AREA.INFO) return null
-    if (frame.length !== WRITE_RESPONSE_LENGTH) return null
+    if (frame.length !== getWriteResponseLength(decoded.area)) return null
 
     return {
-      address: readWord(frame, 1),
+      address: decoded.isAddress32 ? readDword(frame, 1) : readWord(frame, 1),
+      addressWidth: decoded.isAddress32 ? 32 : 16,
       area: decoded.area,
       areaName: AREA_NAMES[decoded.area] || 'UNKNOWN',
-      byteLength: readWord(frame, 3),
+      byteLength: readWord(frame, 1 + addressBytes),
       command,
       dataBytes: [],
       isException: false,
+      isAddress32: decoded.isAddress32,
       isWrite: true,
       protocol: PROTOCOL_NAME
     }
   }
 
-  if (frame.length < READ_RESPONSE_OVERHEAD) return null
+  if (frame.length < getReadResponseOverhead(decoded.area)) return null
 
-  const byteLength = readWord(frame, 3)
-  const dataStart = 5
+  const address = decoded.isAddress32 ? readDword(frame, 1) : readWord(frame, 1)
+  const byteLength = readWord(frame, 1 + addressBytes)
+  const dataStart = headerLength
   const dataEnd = dataStart + byteLength
   if (frame.length !== dataEnd + 2) return null
 
   const dataBytes = frame.slice(dataStart, dataEnd)
 
   return {
-    address: readWord(frame, 1),
+    address,
+    addressWidth: decoded.isAddress32 ? 32 : 16,
     area: decoded.area,
     areaName: AREA_NAMES[decoded.area] || 'UNKNOWN',
     byteLength,
     command,
     dataBytes,
     isException: false,
-    isInfo: decoded.area === AREA.INFO,
+    isAddress32: decoded.isAddress32,
     isWrite: false,
     protocol: PROTOCOL_NAME,
-    words: bytesToWords(dataBytes.length % 2 === 0 ? dataBytes : dataBytes.concat(0)),
-    ...(decoded.area === AREA.INFO && dataBytes.length >= INFO_DATA_BYTE_LENGTH
-      ? {
-        codeInfoAddress: readWord(frame, 5),
-        codeInfoByteLength: readWord(frame, 7),
-        infoBytes: dataBytes.slice(0, INFO_DATA_BYTE_LENGTH)
-      }
+    words: bytesToWords(dataBytes.length % 2 === 0 ? dataBytes : dataBytes.concat(0))
+  }
+}
+
+function parseStorageControlResponse(frame, decoded) {
+  if (decoded.hasError) {
+    if (frame.length !== EXCEPTION_RESPONSE_LENGTH) return null
+
+    return {
+      area: CMD_CONTROL,
+      areaName: 'CONTROL',
+      command: frame[0] & 0xFF,
+      exceptionCode: frame[1] & 0xFF,
+      isControl: true,
+      isException: true,
+      isWrite: false,
+      protocol: PROTOCOL_NAME,
+      sourceCommand: decoded.sourceCommand
+    }
+  }
+
+  if (frame.length < CONTROL_RESPONSE_HEADER_LENGTH + 2) return null
+
+  const operation = frame[1] & 0xFF
+  const status = frame[2] & 0xFF
+  const dataStart = CONTROL_RESPONSE_HEADER_LENGTH
+  const dataEnd = frame.length - 2
+  const byteLength = Math.max(0, dataEnd - dataStart)
+
+  const dataBytes = frame.slice(dataStart, dataEnd)
+
+  return {
+    area: CMD_CONTROL,
+    areaName: 'CONTROL',
+    byteLength,
+    command: frame[0] & 0xFF,
+    controlStatus: status,
+    controlStatusText: CONTROL_STATUS_MESSAGES[status] || '未知状态',
+    dataBytes,
+    isControl: true,
+    isException: false,
+    isWrite: false,
+    operation,
+    protocol: PROTOCOL_NAME,
+    ...(operation === CONTROL_OP.READ_CODE_INFO_DESCRIPTOR && dataBytes.length >= CODE_INFO_DESCRIPTOR_BYTE_LENGTH
+      ? (() => {
+        const endian = parseMemoryEndianMark(dataBytes, 7)
+
+        return {
+          codeInfoAddress: readDword(dataBytes, 0),
+          codeInfoByteLength: readWord(dataBytes, 4),
+          codeInfoAddressWidth: dataBytes[6] & 0xFF,
+          codeInfoDescriptorBytes: dataBytes.slice(0, CODE_INFO_DESCRIPTOR_BYTE_LENGTH),
+          codeInfoMaxPacketLength: readWord(dataBytes, 9),
+          codeInfoMemoryEndian: endian.memoryEndian,
+          codeInfoMemoryEndianMark: endian.marker
+        }
+      })()
       : {})
   }
 }
 
 function parseStorageAccessRequest(bytes) {
   const frame = toByteArray(bytes)
-  if (frame.length < INFO_REQUEST_LENGTH || !hasValidStorageCrc(frame)) return null
+  if (frame.length < 4 || !hasValidStorageCrc(frame)) return null
 
   const command = frame[0] & 0xFF
-  if (frame.length < READ_REQUEST_LENGTH) return null
 
   const decoded = decodeCommand(command)
+  if (decoded.isControl) return parseStorageControlRequest(frame)
+  if (frame.length < READ_REQUEST_LENGTH_16) return null
   if (decoded.hasError) return null
+  if (decoded.hasReservedBits) return null
   if (!AREA_NAMES[decoded.area]) return null
-  if (decoded.area === AREA.INFO && decoded.isWrite) return null
 
-  const address = readWord(frame, 1)
-  const byteLength = readWord(frame, 3)
+  const addressBytes = decoded.addressBytes
+  const headerLength = getMemoryHeaderLength(decoded.area)
+  if (frame.length < headerLength + 2) return null
+
+  const address = decoded.isAddress32 ? readDword(frame, 1) : readWord(frame, 1)
+  const byteLength = readWord(frame, 1 + addressBytes)
   const expectedLength = decoded.isWrite
-    ? WRITE_REQUEST_OVERHEAD + byteLength
-    : READ_REQUEST_LENGTH
+    ? headerLength + byteLength + 2
+    : headerLength + 2
 
   if (byteLength <= 0 || frame.length !== expectedLength) return null
 
   return {
     address,
+    addressWidth: decoded.isAddress32 ? 32 : 16,
     area: decoded.area,
     areaName: AREA_NAMES[decoded.area] || 'UNKNOWN',
     byteLength,
     command,
-    dataBytes: decoded.isWrite ? frame.slice(5, 5 + byteLength) : [],
-    isInfo: decoded.area === AREA.INFO,
+    dataBytes: decoded.isWrite ? frame.slice(headerLength, headerLength + byteLength) : [],
+    isAddress32: decoded.isAddress32,
     isWrite: decoded.isWrite,
     kind: 'raw-hex',
     operation: decoded.isWrite ? 'write' : 'read',
@@ -332,25 +563,63 @@ function parseStorageAccessRequest(bytes) {
   }
 }
 
+function parseStorageControlRequest(frame) {
+  if (frame.length < 4) return null
+
+  const operation = frame[1] & 0xFF
+  const dataStart = 2
+  const dataEnd = frame.length - 2
+  const byteLength = Math.max(0, dataEnd - dataStart)
+
+  return {
+    area: CMD_CONTROL,
+    areaName: 'CONTROL',
+    byteLength,
+    command: CMD_CONTROL,
+    dataBytes: frame.slice(dataStart, dataEnd),
+    isControl: true,
+    isWrite: false,
+    kind: 'storage-control',
+    operation,
+    protocol: PROTOCOL_NAME
+  }
+}
+
 function getExpectedResponseLength(expected, responseCommand, responseBytes = []) {
   if (!expected) return 0
   const command = Number(responseCommand) & 0xFF
   if (command === (expected.command | CMD_ERR_MASK)) return EXCEPTION_RESPONSE_LENGTH
   if (command !== expected.command) return 0
 
+  if (expected.isControl) {
+    if (responseBytes.length < CONTROL_RESPONSE_HEADER_LENGTH) return 0
+    const status = responseBytes[2] & 0xFF
+    if (status !== 0) return CONTROL_RESPONSE_HEADER_LENGTH + 2
+
+    return CONTROL_RESPONSE_HEADER_LENGTH + Number(expected.expectedByteLength || 0) + 2
+  }
+
   if (expected.operation === 'write' || expected.isWrite) {
-    return WRITE_RESPONSE_LENGTH
+    return getWriteResponseLength(expected.area)
   }
 
-  if (responseBytes.length < 5) return 0
+  const headerLength = getMemoryHeaderLength(expected.area)
+  if (responseBytes.length < headerLength) return 0
 
-  return READ_RESPONSE_OVERHEAD + readWord(responseBytes, 3)
+  return headerLength + readWord(responseBytes, 1 + getAddressFieldByteLength(expected.area)) + 2
 }
 
 function isExpectedResponse(response, expected) {
   if (!response || !expected) return false
   const sourceCommand = response.isException ? response.sourceCommand : response.command
   if (sourceCommand !== expected.command) return false
+  if (expected.isControl) {
+    if (!response.isControl) return false
+    if (response.isException) return true
+    if (response.operation !== expected.operation) return false
+
+    return true
+  }
   if (!response.isException && response.area !== expected.area) return false
   if (response.isException) return true
   if (response.address !== expected.address) return false
@@ -374,9 +643,12 @@ function formatExceptionMessage(response) {
 
 function getReadBufferHint(expected) {
   if (!expected) return 0
-  if (expected.operation === 'write' || expected.isWrite) return WRITE_RESPONSE_LENGTH
+  if (expected.isControl) return expected.responseBufferHint || CONTROL_RESPONSE_HEADER_LENGTH + Number(expected.expectedByteLength || 0) + 2
+  if (expected.operation === 'write' || expected.isWrite) {
+    return getWriteResponseLength(expected.area)
+  }
 
-  return READ_RESPONSE_OVERHEAD + Number(expected.byteLength || expected.quantity || 0)
+  return getReadResponseOverhead(expected.area) + Number(expected.byteLength || expected.quantity || 0)
 }
 
 function alignResponseBuffer(buffer, expected) {
@@ -470,15 +742,17 @@ function readResponseFromBuffer(buffer, expected, options = {}) {
   }
 }
 
-function createExpected(area, address, byteLength, isWrite, kind) {
+function createExpected(area, address, byteLength, isWrite, kind, options = {}) {
   const normalizedArea = normalizeMemoryArea(area)
   const command = buildCommand(normalizedArea, isWrite)
 
   return {
     address,
+    addressWidth: isAddress32Area(normalizedArea) ? 32 : 16,
     area: normalizedArea,
     byteLength,
     command,
+    isAddress32: isAddress32Area(normalizedArea),
     isWrite,
     kind,
     operation: isWrite ? 'write' : 'read',
@@ -487,18 +761,20 @@ function createExpected(area, address, byteLength, isWrite, kind) {
   }
 }
 
-function createInfoExpected(kind = 'storage-info-read') {
+function createControlExpected(operation, kind = 'storage-control', options = {}) {
+  const op = toByte(Number(operation), '特殊指令')
+
   return {
-    address: 0,
-    area: AREA.INFO,
-    byteLength: INFO_DATA_BYTE_LENGTH,
-    command: buildCommand(AREA.INFO, false),
-    frame: buildInfoFrame(0, INFO_DATA_BYTE_LENGTH),
-    isInfo: true,
+    area: CMD_CONTROL,
+    byteLength: 0,
+    command: CMD_CONTROL,
+    expectedByteLength: Number(options.expectedByteLength) || 0,
+    isControl: true,
     isWrite: false,
     kind,
-    operation: 'read',
-    protocol: PROTOCOL_NAME
+    operation: op,
+    protocol: PROTOCOL_NAME,
+    responseBufferHint: CONTROL_RESPONSE_HEADER_LENGTH + (Number(options.expectedByteLength) || 0) + 2
   }
 }
 
@@ -532,14 +808,14 @@ function splitQuantity(startAddress, quantity, maxQuantity) {
 }
 
 function getReadChunks(startAddress, byteLength, options = {}) {
-  const maxByteLength = getMaxReadByteLength(options.maxFrameBytes)
+  const maxByteLength = getMaxReadByteLength(options.maxFrameBytes, options.area)
 
   return splitQuantity(startAddress, byteLength, maxByteLength || byteLength)
 }
 
 function getWriteChunks(startAddress, bytes, options = {}) {
   const sourceBytes = Array.prototype.slice.call(bytes || []).map((byte) => Number(byte) & 0xFF)
-  const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes)
+  const maxByteLength = getMaxWriteByteLength(options.maxFrameBytes, options.area)
   const chunks = splitQuantity(startAddress, sourceBytes.length, maxByteLength || sourceBytes.length)
   let offset = 0
 
@@ -554,119 +830,9 @@ function getWriteChunks(startAddress, bytes, options = {}) {
   })
 }
 
-function sendReadChunk(area, chunk, label, kind, options = {}) {
-  const normalizedArea = normalizeMemoryArea(area)
-
-  return transport.sendManagedFrame(
-    buildReadFrame(normalizedArea, chunk.address, chunk.quantity, {
-      maxFrameBytes: options.maxFrameBytes
-    }),
-    label,
-    createExpected(normalizedArea, chunk.address, chunk.quantity, false, kind),
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-async function readMemory(area, startAddress, byteLength, label, kind = 'storage-memory-read', options = {}) {
-  const normalizedArea = normalizeMemoryArea(area)
-  const bytes = []
-  const chunks = getReadChunks(startAddress, byteLength, options)
-
-  for (const chunk of chunks) {
-    const response = await sendReadChunk(
-      normalizedArea,
-      chunk,
-      getChunkLabel(label, chunks, chunk),
-      kind,
-      options
-    )
-    if (!response) return null
-
-    const dataBytes = Array.isArray(response.dataBytes) ? response.dataBytes : []
-    dataBytes.forEach((byte, index) => {
-      bytes[chunk.address - startAddress + index] = Number(byte) & 0xFF
-    })
-
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(response, chunk)
-    }
-  }
-
-  return bytes
-}
-
-async function writeMemory(area, startAddress, bytes, label, kind = 'storage-memory-write', options = {}) {
-  const normalizedArea = normalizeMemoryArea(area)
-  const chunks = getWriteChunks(startAddress, bytes, options)
-
-  for (const chunk of chunks) {
-    const response = await transport.sendManagedFrame(
-      buildWriteFrame(normalizedArea, chunk.address, chunk.dataBytes, {
-        maxFrameBytes: options.maxFrameBytes
-      }),
-      getChunkLabel(label, chunks, chunk),
-      createExpected(normalizedArea, chunk.address, chunk.quantity, true, kind),
-      {
-        maxFrameBytes: options.maxFrameBytes,
-        showModal: options.showModal
-      }
-    )
-    if (!response) return false
-
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(response, chunk)
-    }
-  }
-
-  return true
-}
-
-async function readCodeInfoBlock(label = '同步info', kind = 'storage-info-read', options = {}) {
-  const infoResponse = await transport.sendManagedFrame(
-    buildInfoFrame(0, INFO_DATA_BYTE_LENGTH),
-    label,
-    createInfoExpected(`${kind}-info`),
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-  if (!infoResponse) return null
-
-  const codeInfoAddress = Number(infoResponse.codeInfoAddress || 0)
-  const codeInfoByteLength = Number(infoResponse.codeInfoByteLength || 0)
-  if (!codeInfoByteLength || codeInfoByteLength > 0xFFFF) {
-    transport.showCommandAlert(label, 'info 信息块长度无效')
-    return null
-  }
-
-  const bytes = await readMemory(
-    AREA.CODE,
-    codeInfoAddress,
-    codeInfoByteLength,
-    label,
-    kind,
-    options
-  )
-  if (!bytes) return null
-
-  return {
-    codeInfoAddress,
-    codeInfoByteLength,
-    codeInfoBytes: bytes,
-    infoBytes: Array.isArray(infoResponse.infoBytes)
-      ? infoResponse.infoBytes
-      : [],
-    codeInfoMemoryType: AREA.CODE
-  }
-}
-
 const response = {
+  createControlExpected,
   createExpected,
-  createInfoExpected,
   formatExceptionMessage,
   getExceptionText,
   getExpectedResponseLength,
@@ -677,57 +843,68 @@ const response = {
   readResponseFromBuffer
 }
 
-const client = {
-  AREA,
-  getMaxReadByteLength,
-  getMaxWriteByteLength,
-  getReadChunks,
-  getWriteChunks,
-  readCodeInfoBlock,
-  readMemory,
-  splitQuantity,
-  writeMemory
-}
-
 module.exports = {
   AREA,
   AREA_BY_NAME,
   AREA_NAMES,
-  CMD_AREA_MASK,
+  ADDRESS16_BYTE_LENGTH,
+  ADDRESS32_BYTE_LENGTH,
+  CMD_CONTROL,
+  CMD_CONTROL_FLAG,
+  CMD_ADDRESS_MODE_MASK,
   CMD_ERR_MASK,
-  CMD_INFO,
+  CMD_RESERVED_MASK,
   CMD_WRITE_MASK,
+  CONTROL_OP,
+  CONTROL_RESPONSE_HEADER_LENGTH,
+  CONTROL_STATUS_MESSAGES,
   DEFAULT_MAX_FRAME_BYTES,
   EXCEPTION_MESSAGES,
   EXCEPTION_RESPONSE_LENGTH,
-  INFO_REQUEST_LENGTH,
-  INFO_RESPONSE_LENGTH,
-  INFO_DATA_BYTE_LENGTH,
+  CODE_INFO_DESCRIPTOR_BYTE_LENGTH,
   MAX_PAYLOAD_BYTES,
+  MEMORY_ENDIAN,
+  MEMORY_ENDIAN_MARK_BIG,
+  MEMORY_ENDIAN_MARK_LITTLE,
   PROTOCOL_NAME,
-  READ_REQUEST_LENGTH,
-  READ_RESPONSE_OVERHEAD,
+  READ_REQUEST_LENGTH_16,
+  READ_REQUEST_LENGTH_32,
+  READ_RESPONSE_OVERHEAD_16,
+  READ_RESPONSE_OVERHEAD_32,
   STORAGE_CRC_OPTIONS,
   UNLIMITED_FRAME_BYTES,
-  WRITE_REQUEST_OVERHEAD,
-  WRITE_RESPONSE_LENGTH,
+  WRITE_REQUEST_OVERHEAD_16,
+  WRITE_REQUEST_OVERHEAD_32,
+  WRITE_RESPONSE_LENGTH_16,
+  WRITE_RESPONSE_LENGTH_32,
   appendStorageCrc,
   buildCommand,
-  buildInfoFrame,
+  buildCodeInfoDescriptorFrame,
+  buildControlReferenceFrame,
+  buildControlFrame,
   buildReadFrame,
   buildWriteFrame,
-  client,
+  createControlExpected,
+  createExpected,
   decodeCommand,
   formatHex,
+  getChunkLabel,
+  getReadChunks,
+  getWriteChunks,
   getMaxReadByteLength,
   getMaxWriteByteLength,
   hasValidStorageCrc,
+  normalizeDescriptorAddressWidth,
   normalizeArea,
   normalizeMaxFrameBytes,
   normalizeMemoryArea,
+  resolveDescriptorMaxFrameBytes,
   response,
+  splitDword,
+  splitQuantity,
   splitWord,
   toByte,
   toByteLength,
+  toUint32,
   toWord
 }

+ 210 - 35
repositories/file.js

@@ -1,7 +1,7 @@
 const {
   getWxApi,
   isCancelError
-} = require('../utils/platform-utils.js')
+} = require('../utils/base-utils.js')
 const {
   formatBytes
 } = require('../utils/binary-utils.js')
@@ -26,26 +26,95 @@ function normalizeExtensions(extensions = []) {
     .filter(Boolean)
 }
 
+function escapeRegExp(text) {
+  return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function getRuntimeInfo() {
+  const wxApi = getWxApi()
+
+  try {
+    if (typeof wxApi.getDeviceInfo === 'function') return wxApi.getDeviceInfo() || {}
+  } catch (error) {}
+
+  try {
+    if (typeof wxApi.getSystemInfoSync === 'function') return wxApi.getSystemInfoSync() || {}
+  } catch (error) {}
+
+  return {}
+}
+
+function getRuntimePlatform() {
+  const info = getRuntimeInfo()
+  return String(info.platform || info.system || '').toLowerCase()
+}
+
+function isPcRuntime() {
+  const platform = getRuntimePlatform()
+
+  return /windows|mac|devtools/.test(platform)
+}
+
+function getPathFileName(filePath) {
+  const text = String(filePath || '').split(/[\\/]/).pop() || ''
+
+  try {
+    return decodeURIComponent(text)
+  } catch (error) {
+    return text
+  }
+}
+
 function getFileName(file, fallback = '未命名文件') {
-  return String(file && file.name ? file.name : fallback)
+  const name = file && (file.name || file.fileName)
+  if (name) return String(name)
+
+  return getPathFileName(getFilePath(file)) || fallback
 }
 
 function getFilePath(file) {
-  return file && (file.path || file.tempFilePath) ? (file.path || file.tempFilePath) : ''
+  return file && (file.path || file.tempFilePath || file.filePath)
+    ? (file.path || file.tempFilePath || file.filePath)
+    : ''
 }
 
-function getFirstSelectedFile(result, fallbackName = '未命名文件') {
-  const file = result && Array.isArray(result.tempFiles) ? result.tempFiles[0] : null
-  if (!file) throw new Error('没有选择文件')
+function getFileSize(file) {
+  const size = Number(file && file.size)
 
-  const filePath = getFilePath(file)
-  if (!filePath) throw new Error('无法读取所选文件路径')
+  return Number.isFinite(size) && size >= 0 ? size : 0
+}
 
-  return {
-    file,
-    name: getFileName(file, fallbackName),
-    path: filePath
-  }
+function normalizeSelectedFiles(result, fallbackName = '未命名文件') {
+  const sourceFiles = Array.isArray(result && result.tempFiles)
+    ? result.tempFiles
+    : []
+  const pathFiles = Array.isArray(result && result.tempFilePaths)
+    ? result.tempFilePaths.map((filePath) => ({ path: filePath }))
+    : []
+  const directFile = result && (result.path || result.tempFilePath || result.filePath)
+    ? [result]
+    : []
+
+  return sourceFiles.concat(pathFiles, directFile)
+    .map((file, index) => {
+      const path = getFilePath(file)
+
+      return {
+        file,
+        name: getFileName(file, index === 0 ? fallbackName : `${fallbackName}-${index + 1}`),
+        path,
+        size: getFileSize(file)
+      }
+    })
+    .filter((file) => !!file.path)
+}
+
+function getFirstSelectedFile(result, fallbackName = '未命名文件') {
+  const files = normalizeSelectedFiles(result, fallbackName)
+  const fileInfo = files[0]
+  if (!fileInfo) throw new Error('没有选择文件')
+
+  return fileInfo
 }
 
 function assertFileExtension(fileInfo, extensions = [], message = '文件格式不符') {
@@ -54,7 +123,7 @@ function assertFileExtension(fileInfo, extensions = [], message = '文件格式
 
   const nameText = `${fileInfo && fileInfo.name ? fileInfo.name : ''} ${fileInfo && fileInfo.path ? fileInfo.path : ''}`
   const matched = normalizedExtensions.some((extension) => (
-    new RegExp(`\\.${extension}$`, 'i').test(nameText)
+    new RegExp(`\\.${escapeRegExp(extension)}$`, 'i').test(nameText)
   ))
 
   if (!matched) throw new Error(message)
@@ -63,16 +132,17 @@ function assertFileExtension(fileInfo, extensions = [], message = '文件格式
 function chooseMessageFile(options = {}) {
   const wxApi = getWxApi()
   const extensions = normalizeExtensions(options.extensions || options.extension || [])
+  const type = extensions.length ? 'file' : (options.type || 'all')
 
   return new Promise((resolve, reject) => {
     if (typeof wxApi.chooseMessageFile !== 'function') {
-      reject(new Error('当前微信版本不支持从聊天记录选择文件'))
+      reject(new Error('当前微信版本不支持从聊天记录选择文件,请升级微信或将文件转发到文件传输助手后重试'))
       return
     }
 
     const chooseOptions = {
       count: options.count || 1,
-      type: options.type || 'file',
+      type,
       success: resolve,
       fail: reject
     }
@@ -85,16 +155,17 @@ function chooseMessageFile(options = {}) {
 function chooseLocalFile(options = {}) {
   const wxApi = getWxApi()
   const extensions = normalizeExtensions(options.extensions || options.extension || [])
+  const type = extensions.length ? 'file' : (options.type || 'all')
 
   return new Promise((resolve, reject) => {
     if (typeof wxApi.chooseFile !== 'function') {
-      reject(new Error(options.unsupportedMessage || '当前微信版本不支持打开本地文件,请从聊天记录选择'))
+      reject(new Error(options.unsupportedMessage || '当前微信环境不支持本地文件选择,请将文件发送到文件传输助手后从聊天记录选择'))
       return
     }
 
     const chooseOptions = {
       count: options.count || 1,
-      type: options.type || 'file',
+      type,
       success: resolve,
       fail: reject
     }
@@ -104,10 +175,32 @@ function chooseLocalFile(options = {}) {
   })
 }
 
-function chooseFile(source = 'message', options = {}) {
-  return source === 'local'
-    ? chooseLocalFile(options)
-    : chooseMessageFile(options)
+async function chooseFile(source = 'message', options = {}) {
+  const normalizedSource = source === 'local' || source === 'auto' ? source : 'message'
+  // PC 微信更接近本地文件选择,移动端更稳定的是从聊天记录取文件。
+  const preferLocal = normalizedSource === 'local' || (normalizedSource === 'auto' && isPcRuntime())
+  const firstChooser = preferLocal ? chooseLocalFile : chooseMessageFile
+  const fallbackChooser = preferLocal ? chooseMessageFile : chooseLocalFile
+
+  try {
+    return await firstChooser(options)
+  } catch (error) {
+    if (isCancelError(error) || options.fallback === false) throw error
+    if (normalizedSource !== 'auto' && options.fallbackToOtherSource === false) throw error
+
+    try {
+      return await fallbackChooser(options)
+    } catch (fallbackError) {
+      if (isCancelError(fallbackError)) throw fallbackError
+
+      const firstMessage = error && (error.errMsg || error.message || error)
+      const fallbackMessage = fallbackError && (fallbackError.errMsg || fallbackError.message || fallbackError)
+      throw new Error([
+        firstMessage || '文件选择失败',
+        fallbackMessage || ''
+      ].filter(Boolean).join(';'))
+    }
+  }
 }
 
 function readFile(filePath, options = {}) {
@@ -156,8 +249,8 @@ async function loadSelectedFile(source = 'message', options = {}) {
     ...fileInfo,
     bytes,
     data,
-    size: bytes ? bytes.length : String(data || '').length,
-    sizeText: formatBytes(bytes ? bytes.length : String(data || '').length),
+    size: bytes ? bytes.length : (fileInfo.size || String(data || '').length),
+    sizeText: formatBytes(bytes ? bytes.length : (fileInfo.size || String(data || '').length)),
     text: options.encoding ? String(data || '') : ''
   }
 }
@@ -173,16 +266,54 @@ function getUserDataFilePath(fileName) {
 
 function writeTextFile(filePath, data, encoding = 'utf8') {
   const wxApi = getWxApi()
-  if (typeof wxApi.getFileSystemManager !== 'function') {
-    throw new Error('当前微信版本不支持生成文件')
-  }
 
-  const fs = wxApi.getFileSystemManager()
-  if (typeof fs.writeFileSync !== 'function') {
-    throw new Error('当前微信版本不支持同步生成文件')
-  }
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.getFileSystemManager !== 'function') {
+      reject(new Error('当前微信版本不支持生成文件'))
+      return
+    }
+
+    const fs = wxApi.getFileSystemManager()
+    if (typeof fs.writeFileSync === 'function') {
+      try {
+        fs.writeFileSync(filePath, data, encoding)
+        resolve(filePath)
+      } catch (error) {
+        reject(error)
+      }
+      return
+    }
+
+    if (typeof fs.writeFile !== 'function') {
+      reject(new Error('当前微信版本不支持生成文件'))
+      return
+    }
+
+    fs.writeFile({
+      data,
+      encoding,
+      filePath,
+      fail: reject,
+      success: () => resolve(filePath)
+    })
+  })
+}
 
-  fs.writeFileSync(filePath, data, encoding)
+function saveFileToDisk(filePath) {
+  const wxApi = getWxApi()
+
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.saveFileToDisk !== 'function') {
+      reject(new Error('当前微信环境不支持保存到电脑磁盘'))
+      return
+    }
+
+    wxApi.saveFileToDisk({
+      filePath,
+      fail: reject,
+      success: resolve
+    })
+  })
 }
 
 function shareFileToChat(filePath, fileName) {
@@ -203,13 +334,52 @@ function shareFileToChat(filePath, fileName) {
   })
 }
 
+async function exportFile(filePath, fileName, options = {}) {
+  const wxApi = getWxApi()
+  const preferDisk = options.target === 'disk'
+    || (options.target !== 'chat' && isPcRuntime() && typeof wxApi.saveFileToDisk === 'function')
+
+  if (preferDisk && typeof wxApi.saveFileToDisk === 'function') {
+    try {
+      await saveFileToDisk(filePath)
+
+      return {
+        method: 'disk'
+      }
+    } catch (error) {
+      if (isCancelError(error) || typeof wxApi.shareFileMessage !== 'function') throw error
+    }
+  }
+
+  if (typeof wxApi.shareFileMessage === 'function') {
+    await shareFileToChat(filePath, fileName)
+
+    return {
+      method: 'chat'
+    }
+  }
+
+  if (typeof wxApi.saveFileToDisk === 'function') {
+    await saveFileToDisk(filePath)
+
+    return {
+      method: 'disk'
+    }
+  }
+
+  throw new Error('当前微信环境不支持导出文件,请升级微信后重试')
+}
+
 async function saveTextFileToChat(fileName, data) {
   const filePath = getUserDataFilePath(fileName)
 
-  writeTextFile(filePath, data, 'utf8')
-  await shareFileToChat(filePath, fileName)
+  await writeTextFile(filePath, data, 'utf8')
+  const result = await exportFile(filePath, fileName)
 
-  return filePath
+  return {
+    filePath,
+    ...result
+  }
 }
 
 module.exports = {
@@ -217,13 +387,18 @@ module.exports = {
   chooseFile,
   chooseLocalFile,
   chooseMessageFile,
+  exportFile,
   formatBytes,
   formatExportStamp,
   getFirstSelectedFile,
+  getRuntimePlatform,
   getUserDataFilePath,
   isCancelError,
+  isPcRuntime,
   loadSelectedFile,
+  normalizeSelectedFiles,
   readFile,
+  saveFileToDisk,
   saveTextFileToChat,
   shareFileToChat,
   toUint8Array,

+ 28 - 79
store/settings-store.js

@@ -1,26 +1,23 @@
 const {
+  clampInteger,
+  getWxApi,
   toFiniteNumber
-} = require('../utils/number-format.js')
-const {
-  clampInteger
 } = require('../utils/base-utils.js')
 const {
-  getWxApi
-} = require('../utils/platform-utils.js')
+  normalizeHexByte,
+  parseHexByte
+} = require('../utils/validation.js')
+const {
+  DEFAULT_PROTOCOL_MODE,
+  PROTOCOL_MODE,
+  PROTOCOL_OPTIONS,
+  isModbusProtocolMode,
+  isNoProtocolMode,
+  isStorageAccessProtocolMode,
+  normalizeProtocolMode
+} = require('../domain/protocol-mode.js')
 
 const STORAGE_KEY = 'app-settings'
-const PROTOCOL_MODE = {
-  MODBUS_RTU: 'modbus-rtu',
-  STORAGE_ACCESS: 'storage-access'
-}
-const LEGACY_PROTOCOL_MODE_ALIASES = {
-  generic: PROTOCOL_MODE.MODBUS_RTU,
-  private: PROTOCOL_MODE.STORAGE_ACCESS
-}
-const PROTOCOL_OPTIONS = [
-  { key: PROTOCOL_MODE.STORAGE_ACCESS, label: '存储访问' },
-  { key: PROTOCOL_MODE.MODBUS_RTU, label: '标准Modbus' }
-]
 const DEFAULT_SETTINGS = {
   modbusSlaveAddress: 'F0',
   nightModeEnabled: false,
@@ -28,7 +25,7 @@ const DEFAULT_SETTINGS = {
   parameterAutoPollEnabled: false,
   parameterMaxPacketLength: 64,
   parameterPollInterval: 100,
-  protocolMode: PROTOCOL_MODE.STORAGE_ACCESS
+  protocolMode: DEFAULT_PROTOCOL_MODE
 }
 const STATUS_POLL_MIN_INTERVAL = 100
 const STATUS_POLL_MAX_INTERVAL = 3000
@@ -41,16 +38,6 @@ const state = {
 let initialized = false
 const subscribers = []
 
-function normalizeHexByte(value, fallback = DEFAULT_SETTINGS.modbusSlaveAddress) {
-  const fallbackText = String(fallback || DEFAULT_SETTINGS.modbusSlaveAddress).toUpperCase()
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
-
-  if (!/^[0-9A-F]{1,2}$/i.test(hexText)) return fallbackText
-
-  return parseInt(hexText, 16).toString(16).toUpperCase().padStart(2, '0')
-}
-
 function normalizeParameterPacketLength(value, fallback = DEFAULT_SETTINGS.parameterMaxPacketLength) {
   const numberValue = toFiniteNumber(value, NaN)
   if (!Number.isFinite(numberValue)) return fallback
@@ -61,76 +48,36 @@ function normalizeParameterPacketLength(value, fallback = DEFAULT_SETTINGS.param
   return Math.max(rounded, PARAMETER_MIN_PACKET_LENGTH)
 }
 
-function normalizeProtocolMode(value) {
-  const key = String(value || '').trim()
-  const normalizedKey = LEGACY_PROTOCOL_MODE_ALIASES[key] || key
-  const matched = PROTOCOL_OPTIONS.find((option) => option.key === key)
-
-  if (matched) return matched.key
-
-  const normalizedMatched = PROTOCOL_OPTIONS.find((option) => option.key === normalizedKey)
-  return normalizedMatched ? normalizedMatched.key : DEFAULT_SETTINGS.protocolMode
-}
-
 function isModbusProtocol(value = state.protocolMode) {
-  return normalizeProtocolMode(value) === PROTOCOL_MODE.MODBUS_RTU
+  return isModbusProtocolMode(value)
 }
 
 function isStorageAccessProtocol(value = state.protocolMode) {
-  return normalizeProtocolMode(value) === PROTOCOL_MODE.STORAGE_ACCESS
+  return isStorageAccessProtocolMode(value)
 }
 
-function parseHexByte(value, label = '从机地址') {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
-
-  if (!/^[0-9A-F]{1,2}$/i.test(hexText)) {
-    throw new Error(`${label}需为 00 - FF`)
-  }
-
-  return parseInt(hexText, 16)
-}
-
-function migrateLegacySettings(settings = {}) {
-  const hasProtocolMode = settings.protocolMode !== undefined && settings.protocolMode !== null && settings.protocolMode !== ''
-  const hasParameterAutoPollEnabled = settings.parameterAutoPollEnabled !== undefined
-  const hasParameterMaxPacketLength = settings.parameterMaxPacketLength !== undefined
-  const hasParameterPollInterval = settings.parameterPollInterval !== undefined
-
-  return {
-    ...settings,
-    protocolMode: hasProtocolMode ? settings.protocolMode : settings.modbusProtocolMode,
-    parameterAutoPollEnabled: hasParameterAutoPollEnabled
-      ? settings.parameterAutoPollEnabled
-      : settings.genericModbusAutoPollEnabled,
-    parameterMaxPacketLength: hasParameterMaxPacketLength
-      ? settings.parameterMaxPacketLength
-      : settings.genericModbusMaxPacketLength,
-    parameterPollInterval: hasParameterPollInterval
-      ? settings.parameterPollInterval
-      : settings.genericModbusPollInterval
-  }
+function isNoProtocol(value = state.protocolMode) {
+  return isNoProtocolMode(value)
 }
 
 function normalizeSettings(settings = {}) {
-  const migratedSettings = migrateLegacySettings(settings)
-  const protocolMode = normalizeProtocolMode(migratedSettings.protocolMode)
-  const parameterAutoPollEnabled = !!migratedSettings.parameterAutoPollEnabled
+  const protocolMode = normalizeProtocolMode(settings.protocolMode)
+  const parameterAutoPollEnabled = !!settings.parameterAutoPollEnabled
   const parameterMaxPacketLength = normalizeParameterPacketLength(
-    migratedSettings.parameterMaxPacketLength,
+    settings.parameterMaxPacketLength,
     DEFAULT_SETTINGS.parameterMaxPacketLength
   )
   const parameterPollInterval = clampInteger(
-    migratedSettings.parameterPollInterval,
+    settings.parameterPollInterval,
     STATUS_POLL_MIN_INTERVAL,
     STATUS_POLL_MAX_INTERVAL,
     DEFAULT_SETTINGS.parameterPollInterval
   )
 
   return {
-    modbusSlaveAddress: normalizeHexByte(migratedSettings.modbusSlaveAddress),
-    nightModeEnabled: !!migratedSettings.nightModeEnabled,
-    nightModeFollowSystem: migratedSettings.nightModeFollowSystem !== false,
+    modbusSlaveAddress: normalizeHexByte(settings.modbusSlaveAddress),
+    nightModeEnabled: !!settings.nightModeEnabled,
+    nightModeFollowSystem: settings.nightModeFollowSystem !== false,
     parameterAutoPollEnabled,
     parameterMaxPacketLength,
     parameterPollInterval,
@@ -268,6 +215,7 @@ module.exports = {
   getModbusSlaveAddress,
   getState,
   init,
+  isNoProtocol,
   isModbusProtocol,
   isStorageAccessProtocol,
   setModbusSlaveAddress,
@@ -277,5 +225,6 @@ module.exports = {
   setParameterMaxPacketLength,
   setParameterPollInterval,
   setProtocolMode,
+  normalizeProtocolMode,
   subscribe
 }

+ 1 - 1
store/theme-store.js

@@ -1,7 +1,7 @@
 const settingsService = require('./settings-store.js')
 const {
   getWxApi
-} = require('../utils/platform-utils.js')
+} = require('../utils/base-utils.js')
 
 const TAB_ITEMS = [
   {

+ 14 - 9
tools/crc-hash/crc-tool.js

@@ -68,9 +68,9 @@ function createPresetState(presetIndex = 0) {
 function createInitialState() {
   return {
     ...createPresetState(0),
+    ...createEmptyResultState(),
     crcDataLengthText: '0 bytes',
     crcDataText: '',
-    crcErrorText: '',
     crcFileName: '',
     crcFileSizeText: '',
     crcHmacKey: '',
@@ -79,11 +79,16 @@ function createInitialState() {
     crcPbkdf2Iterations: '1000',
     crcPbkdf2Length: '32',
     crcPbkdf2Salt: '',
-    crcPresetOptions: ALGORITHM_PRESETS.map(clonePreset),
-    crcResultBase64: '--',
-    crcResultBin: '--',
-    crcResultBinLines: splitBinaryResult('--'),
-    crcResultHex: '--'
+    crcPresetOptions: ALGORITHM_PRESETS.map(clonePreset)
+  }
+}
+
+function createEmptyResultState() {
+  return {
+    crcResultBase64: '',
+    crcResultBin: '',
+    crcResultBinLines: splitBinaryResult(''),
+    crcResultHex: ''
   }
 }
 
@@ -143,8 +148,8 @@ function parseInputBytes(dataText, inputTypeIndex) {
 }
 
 function splitBinaryResult(value) {
-  const text = String(value || '--')
-  if (text === '--' || text.length <= 32) return [
+  const text = String(value === undefined || value === null ? '' : value)
+  if (!text || text.length <= 32) return [
     {
       id: 'bin-line-0',
       text
@@ -203,7 +208,6 @@ function calculateFromState(state, fileBytes) {
 
   return {
     crcDataLengthText: formatBytes(bytes.length),
-    crcErrorText: '',
     crcResultBase64: result.base64,
     crcResultBin: result.bin,
     crcResultBinLines: splitBinaryResult(result.bin),
@@ -213,6 +217,7 @@ function calculateFromState(state, fileBytes) {
 
 module.exports = {
   calculateFromState,
+  createEmptyResultState,
   createInitialState,
   createPresetState,
   CRC_CONFIG_FIELDS,

+ 0 - 4
tools/crc-hash/index.js

@@ -1,4 +0,0 @@
-module.exports = {
-  crcTool: require('./crc-tool.js'),
-  hash: require('./hash.js')
-}

+ 3 - 0
transport/ble-core.js

@@ -58,6 +58,7 @@ const state = {
   sendHex: '',
   sendQueueLength: 0,
   systemTip: '',
+  signalText: '',
   txCount: 0,
   writeCharacteristicId: '',
   writeServiceId: '',
@@ -319,6 +320,7 @@ function clearConnectedState(changedData = {}) {
     connectedServiceCount: 0,
     connectingDeviceId: '',
     isConnecting: false,
+    signalText: '',
     writeCharacteristicId: '',
     writeServiceId: '',
     writeType: '',
@@ -344,6 +346,7 @@ function applyRssiUpdate(deviceId, rssi) {
 
   setState({
     connectedDevice: result.updatedDevice,
+    signalText: result.updatedDevice.signalText || '',
     devices: result.deviceList
   })
 }

+ 51 - 1
utils/base-utils.js

@@ -19,6 +19,41 @@ function delay(ms) {
   })
 }
 
+function getWxApi() {
+  return typeof wx === 'undefined' ? {} : wx
+}
+
+function isCancelError(error) {
+  const message = String(error && (error.errMsg || error.message || error) || '')
+
+  return /cancel|取消/i.test(message)
+}
+
+function toFiniteNumber(value, fallback = 0) {
+  if (typeof value === 'string') {
+    const text = value.trim()
+    const directValue = Number(text)
+    if (Number.isFinite(directValue)) return directValue
+
+    const match = text.match(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i)
+    const textValue = match ? Number(match[0]) : NaN
+
+    return Number.isFinite(textValue) ? textValue : fallback
+  }
+
+  const numberValue = Number(value)
+
+  return Number.isFinite(numberValue) ? numberValue : fallback
+}
+
+function formatFixedValue(value, precision = 2) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  const text = numberValue.toFixed(precision)
+  return Number(text) === 0 ? (0).toFixed(precision) : text
+}
+
 function parseHexInteger(value, fallback = 0) {
   const text = String(value === undefined || value === null ? '' : value).trim()
   if (!text) return fallback
@@ -31,6 +66,16 @@ function normalizeTextValue(value) {
   return String(value === undefined || value === null ? '' : value)
 }
 
+function pickFields(source, fields = []) {
+  return fields.reduce((result, field) => {
+    if (source && source[field] !== undefined && source[field] !== null && source[field] !== '') {
+      result[field] = source[field]
+    }
+
+    return result
+  }, {})
+}
+
 function padHex(value, length = 4) {
   return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
 }
@@ -39,7 +84,12 @@ module.exports = {
   clampInteger,
   createId,
   delay,
+  formatFixedValue,
+  getWxApi,
+  isCancelError,
   normalizeTextValue,
   padHex,
-  parseHexInteger
+  pickFields,
+  parseHexInteger,
+  toFiniteNumber
 }

+ 0 - 29
utils/number-format.js

@@ -1,29 +0,0 @@
-function toFiniteNumber(value, fallback = 0) {
-  if (typeof value === 'string') {
-    const text = value.trim()
-    const directValue = Number(text)
-    if (Number.isFinite(directValue)) return directValue
-
-    const match = text.match(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i)
-    const textValue = match ? Number(match[0]) : NaN
-
-    return Number.isFinite(textValue) ? textValue : fallback
-  }
-
-  const numberValue = Number(value)
-
-  return Number.isFinite(numberValue) ? numberValue : fallback
-}
-
-function formatFixedValue(value, precision = 2) {
-  const numberValue = toFiniteNumber(value, NaN)
-  if (!Number.isFinite(numberValue)) return '--'
-
-  const text = numberValue.toFixed(precision)
-  return Number(text) === 0 ? (0).toFixed(precision) : text
-}
-
-module.exports = {
-  formatFixedValue,
-  toFiniteNumber
-}

+ 0 - 14
utils/platform-utils.js

@@ -1,14 +0,0 @@
-function getWxApi() {
-  return typeof wx === 'undefined' ? {} : wx
-}
-
-function isCancelError(error) {
-  const message = String(error && (error.errMsg || error.message || error) || '')
-
-  return /cancel/i.test(message)
-}
-
-module.exports = {
-  getWxApi,
-  isCancelError
-}

+ 1 - 1
utils/register-value-utils.js

@@ -1,6 +1,6 @@
 const {
   toFiniteNumber
-} = require('./number-format.js')
+} = require('./base-utils.js')
 
 function toAddressKey(address) {
   if (typeof address === 'number' && Number.isFinite(address)) {

+ 136 - 0
utils/validation.js

@@ -0,0 +1,136 @@
+function toText(value) {
+  return String(value === undefined || value === null ? '' : value).trim()
+}
+
+function stripHexPrefix(value) {
+  return toText(value).replace(/^0x/i, '')
+}
+
+function normalizeHexText(value) {
+  return toText(value)
+    .replace(/0x/gi, '')
+    .replace(/[\s,;:_-]/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+    .toUpperCase()
+}
+
+function compactHexText(value) {
+  return toText(value)
+    .replace(/0x/gi, '')
+    .replace(/[\s,;:_-]/g, '')
+    .toUpperCase()
+}
+
+function validateHexText(value, options = {}) {
+  const withoutPrefix = toText(value).replace(/0x/gi, '')
+  const compact = withoutPrefix.replace(/[\s,;:_-]/g, '')
+  const emptyMessage = options.emptyMessage || '请输入十六进制数据'
+  const invalidMessage = options.invalidMessage || '只支持十六进制字符'
+  const oddLengthMessage = options.oddLengthMessage || '十六进制长度必须为偶数'
+
+  if (!compact) return emptyMessage
+  if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return invalidMessage
+  if (compact.length % 2 !== 0) return oddLengthMessage
+
+  return ''
+}
+
+function parseHexBytes(value) {
+  const compact = compactHexText(value)
+  const bytes = []
+
+  for (let index = 0; index < compact.length; index += 2) {
+    bytes.push(parseInt(compact.slice(index, index + 2), 16) & 0xFF)
+  }
+
+  return bytes
+}
+
+function normalizeHexUnitText(value, fallback = '') {
+  const text = stripHexPrefix(value).toUpperCase()
+
+  return text || fallback
+}
+
+function normalizeHexWordText(value, fallback = '') {
+  return normalizeHexUnitText(value, fallback)
+}
+
+function normalizeHexDwordText(value, fallback = '') {
+  return normalizeHexUnitText(value, fallback)
+}
+
+function validateHexWordText(value, label) {
+  const text = normalizeHexWordText(value)
+  if (!text) return `${label}请输入十六进制`
+  if (!/^[0-9A-F]+$/i.test(text)) return `${label}只支持十六进制`
+  if (text.length > 4) return `${label}最多 4 位十六进制`
+
+  return ''
+}
+
+function validateHexDwordText(value, label) {
+  const text = normalizeHexDwordText(value)
+  if (!text) return `${label}请输入十六进制`
+  if (!/^[0-9A-F]+$/i.test(text)) return `${label}只支持十六进制`
+  if (text.length > 8) return `${label}最多 8 位十六进制`
+
+  return ''
+}
+
+function parseHexNumber(value, label = '数值', maxValue = 0xFFFF) {
+  const text = normalizeHexWordText(value)
+
+  if (!text || !/^[0-9A-F]+$/i.test(text)) {
+    throw new Error(`${label}请输入十六进制数值`)
+  }
+
+  const parsedValue = parseInt(text, 16)
+  if (parsedValue > maxValue) {
+    throw new Error(`${label}超出范围`)
+  }
+
+  return parsedValue
+}
+
+function parseHexDword(value, label = '地址') {
+  return parseHexNumber(value, label, 0xFFFFFFFF)
+}
+
+function parseHexByte(value, label = '从机地址') {
+  const text = normalizeHexWordText(value)
+
+  if (!/^[0-9A-F]{1,2}$/i.test(text)) {
+    throw new Error(`${label}需为 00 - FF`)
+  }
+
+  return parseInt(text, 16)
+}
+
+function normalizeHexByte(value, fallback = '00') {
+  const fallbackText = normalizeHexWordText(fallback, '00')
+  const text = normalizeHexWordText(value)
+
+  if (!/^[0-9A-F]{1,2}$/i.test(text)) {
+    return parseInt(fallbackText || '0', 16).toString(16).toUpperCase().padStart(2, '0')
+  }
+
+  return parseInt(text, 16).toString(16).toUpperCase().padStart(2, '0')
+}
+
+module.exports = {
+  compactHexText,
+  normalizeHexDwordText,
+  normalizeHexByte,
+  normalizeHexText,
+  normalizeHexWordText,
+  parseHexByte,
+  parseHexBytes,
+  parseHexDword,
+  parseHexNumber,
+  stripHexPrefix,
+  validateHexDwordText,
+  validateHexText,
+  validateHexWordText
+}

+ 178 - 233
协议架构说明.md

@@ -1,30 +1,36 @@
 # 协议架构说明
 
-## 1. 分层原则
+## 1. 总体边界
 
-小程序当前把链路、协议、功能服务和页面视图拆开处理:
+小程序按“链路、协议、领域模型、功能服务、页面”分层。页面只做展示和事件转发;协议层只处理帧格式和响应解析;功能服务负责把协议动作组合成业务流程;领域模型负责参数组、结构体、数值编解码等纯数据逻辑。
 
 ```text
 BLE 透传链路
   transport/ble-core.js
   transport/ble-utils.js
+  transport/ble-logs.js
+  transport/ble-device-registry.js
+  transport/protocol-helper-registry.js
 
 协议层
   protocols/modbus-rtu/index.js
   protocols/storage-access/index.js
   protocols/bootloader/index.js
+  protocols/transport-helpers.js
 
 领域模型
   domain/parameter-groups/
-  domain/storage-access/
+  domain/storage-access/code-info-parser.js
 
 功能服务
-  features/manual-rtu/
-  features/storage-access/
-  features/parameter-groups/
   features/communication/
+  features/modbus-rtu/service.js
+  features/storage-access/service.js
+  features/parameter-groups/
   features/bootloader/
   features/tools/
+  features/settings/
+  features/home/service.js
 
 页面
   pages/home/
@@ -33,46 +39,43 @@ BLE 透传链路
   pages/settings/
 ```
 
-协议层只负责帧格式、CRC、请求/响应解析和分片能力,不直接操作页面状态。
-
-功能服务负责把协议层组合成业务动作,例如手动生成 Modbus 帧、同步 code 信息块、读取参数组、串口原始发送。
-
-页面只负责展示、输入、按钮状态和用户反馈。
-
-功能服务和传输层只依赖每套协议的入口文件:
+维护原则:
 
-- `protocols/modbus-rtu/index.js`
-- `protocols/storage-access/index.js`
-- `protocols/bootloader/index.js`
+1. 一个协议只保留一个协议入口文件,不再拆 `frame.js`、`request.js`、`response.js` 这类细粒度文件。
+2. 功能服务按页面或业务域聚合,不为每个按钮、每个弹窗再单独建模块。
+3. 领域模型可以按“值类型、编解码、结构体解析、参数组规范化”拆分,因为这些逻辑可测试且复用度高。
+4. 页面不直接拼协议帧,不直接读写本地存储格式。
+5. 设置页的协议模式只保留当前字段 `protocolMode`,不维护历史字段迁移逻辑。
 
-协议层保持一个协议一个文件。`index.js` 内部可以继续暴露 `response`、`client` 这类逻辑分组对象,但不要重新拆出协议内部的 `frame.js`、`response.js`、`client.js` 给功能层引用。
+## 2. 协议模式
 
-协议模式配置由 `store/settings-store.js` 统一维护,内部 key 使用协议目录名
+协议模式常量由 `domain/protocol-mode.js` 统一定义,当前设置值由 `store/settings-store.js` 持久化维护
 
-- `storage-access`:存储访问协议。
-- `modbus-rtu`:标准 Modbus RTU。
+| key | 页面名称 | 通讯页显示 | 参数页数据 |
+|---|---|---|---|
+| `none` | 无协议 | 串口发送卡片 + 日志 | 不显示协议参数组 |
+| `storage-access` | 存储访问 | 同步、CodeInfo、特殊指令、读写卡片 + 日志 | 存储访问结构体组/单变量组 |
+| `modbus-rtu` | 标准 Modbus | 标准 Modbus 指令卡片 + 日志 | Modbus 寄存器组 |
 
-页面层使用 `isStorageAccessProtocol`、`isModbusProtocol` 这类展示布尔字段,业务判断由 settings store 的协议判断函数生成。
+参数组按协议分别存储,切换协议时 `features/parameter-groups/store.js` 会切换当前参数组集合。自动读取/轮询运行时如果检测到协议切换,会停止当前协议的循环,避免用错误协议继续读写
 
-历史配置字段只允许在 `store/settings-store.js` 的归一化入口迁移,例如旧的 `modbusProtocolMode`、`genericModbusMaxPacketLength` 会映射到新的 `protocolMode`、`parameterMaxPacketLength`。页面和功能服务不直接读取旧字段。
-
-## 2. 传输层
+## 3. 传输层
 
 目录:`transport/`
 
 职责:
 
-- `ble-core.js`:BLE 扫描、连接、通知订阅、发送队列、响应等待、收发日志和页面订阅状态
-- `ble-utils.js`:BLE 设备识别、目标 UUID 判断、包长推断、HEX/ArrayBuffer 转换、错误文案和特征值展示文本等纯工具
-- `ble-logs.js`:收发日志构造、日志数量裁剪和清空日志状态
-- `ble-device-registry.js`:扫描设备合并、排序、清空、连接标记和 RSSI 展示更新
-- `protocol-helper-registry.js`:传输层协议 helper 的配置、懒加载和函数归一化。
+- `ble-core.js`:蓝牙扫描、连接、通知订阅、发送队列、响应等待、收发日志、页面订阅
+- `ble-utils.js`:BLE 设备识别、UUID 判断、包长推断、HEX/ArrayBuffer 转换、错误文案。
+- `ble-logs.js`:收发日志构造、裁剪和清空。
+- `ble-device-registry.js`:扫描设备合并、排序、连接标记和 RSSI 文案
+- `protocol-helper-registry.js`:协议 helper 的懒加载和响应读取函数归一化。
 
-传输层只处理蓝牙透传链路,不直接理解标准 Modbus 或存储访问协议的业务语义。响应解析通过 `configureProtocolHelpers` 注入协议 helper,由协议层决定如何识别完整回复帧
+传输层不知道业务含义,只通过 `protocols/transport-helpers.js` 调用当前协议的响应解析能力
 
-## 3. 协议层
+## 4. 协议层
 
-### 3.1 标准 Modbus RTU
+### 4.1 标准 Modbus RTU
 
 入口:`protocols/modbus-rtu/index.js`
 
@@ -80,290 +83,232 @@ BLE 透传链路
 
 - 生成标准 Modbus RTU 请求帧。
 - 校验 Modbus CRC。
-- 解析标准 Modbus 响应。
-- 支持标准功能码 `0x01`、`0x02`、`0x03`、`0x04`、`0x05`、`0x06`、`0x10`。
-- 地址和数量按标准 Modbus 寄存器或线圈语义处理。
+- 解析标准 Modbus 响应和异常响应。
+- 计算单帧包长限制下的读取分片。
+- 支持功能码 `0x01`、`0x02`、`0x03`、`0x04`、`0x05`、`0x06`、`0x10`。
+- 地址和数量按标准 Modbus 寄存器/线圈语义处理。
+- 不直接读取设置页从机地址,不直接调用 BLE 发送。
 
-标准 Modbus 不读取 `info` 区,也不解析 `Modbus_Code_Info_t`。
+标准 Modbus 不解析 CodeInfo TLV 信息块,也不使用存储访问协议的 `CMD/AREA`。
 
-### 3.2 存储访问协议
+### 4.2 存储访问协议
 
 入口:`protocols/storage-access/index.js`
 
 职责:
 
-- 普通读写生成 `CMD ADDR LEN DATA CRC` 格式的存储访问帧。
-- `0x0F info` 也使用 `CMD ADDR LEN DATA CRC` 格式,按普通只读响应解析。
-- 使用 `CRC16-CCITT-FALSE`。
-- 地址单位始终是字节。
-- 长度单位始终是字节。
-- 普通读写支持区域:
-  - `0x01 data`
-  - `0x02 idata`
-  - `0x03 xdata`
-  - `0x04 code`
-- `0x0F info` 是只读同步区域,不作为普通变量区域写入。
-- `code` 和 `info` 均为只读区域。
-- `0x0F info` 当前读取 `ADDR=0x0000`、`LEN=0x0004`,返回数据为 code 区关键信息块的地址和长度。
-- 读取完整 code 信息块时,通过 `AREA=0x04 code` 按最大包长分片读取。
-
-完整帧格式见 `存储访问协议.md`。
+- 普通内存读写使用 `CMD + ADDR + LEN + DATA + CRC16-CCITT-FALSE`。
+- `LEN` 固定 16 位,单位始终为字节。
+- `CMD bit0~bit2` 区分地址模式或区域:`0x07` 为 32 位地址,`0x01..0x04` 为 DATA/IDATA/XDATA/CODE 的 16 位地址,`0x00/0x05/0x06` 保留。
+- `CMD bit3` 为读写位,`bit4/bit5` 暂时保留,`bit7` 为异常标志。
+- 普通区域包括 `DATA`、`IDATA`、`XDATA`、`CODE`,其中 `CODE` 只读。
+- 特殊指令使用 `CMD=0x4F`,用于复位、启动、停止、控制参考值和 CodeInfo 描述符读取。
+- CodeInfo 同步只使用特殊指令 `OP=0x05` 获取 `CODE_ADDR32 + CODE_LEN16 + ADDR_WIDTH8 + MEMORY_ENDIAN16 + MAX_PACKET16`,再按 bootstrap 声明的地址宽度、目标内存变量字节序和包长读取完整信息块。
+- 协议控制字段始终固定大端;结构体字段和单独变量等目标内存值按 bootstrap `MEMORY_ENDIAN` 编解码。
+- 不直接调用 BLE 发送,不负责弹窗;发包编排由 `features/storage-access/service.js` 处理。
 
-### 3.3 Bootloader
+完整帧格式和从机实现参考见 `存储访问协议.md`。
 
-入口:`protocols/bootloader/index.js`、`features/bootloader/`
+### 4.3 Bootloader
 
-职责:
+协议入口:`protocols/bootloader/index.js`
 
-- 保留独立升级协议。
-- 不依赖 Modbus RTU。
-- 不依赖存储访问协议。
-- 升级工具仍在设置页的工具区入口中维护。
+功能服务:`features/bootloader/`
 
-当前模块边界
+职责:
 
-- `protocols/bootloader/index.js`:Bootloader 帧构建、CRC、响应解析、ACK/NAK 校验。
-- `features/bootloader/service.js`:设置页升级状态、握手流程、固件加载、擦除/编程/校验流程编排。
-- `features/bootloader/firmware.js`:芯片型号识别、Flash 容量推断、固件大小校验和升级地址布局。
-- `features/bootloader/transport.js`:Bootloader 原始帧发送、响应等待、断连中止和超时处理。
+- 保持独立升级协议,不依赖标准 Modbus 或存储访问协议。
+- `protocols/bootloader/index.js` 负责 Bootloader 帧构建、CRC、ACK/NAK 和响应解析。
+- `features/bootloader/service.js` 负责设置页升级状态、固件加载、握手、擦除、编程、校验流程。
+- `features/bootloader/firmware.js` 负责芯片型号识别、Flash 容量推断、固件大小校验和升级地址布局。
+- `features/bootloader/transport.js` 负责 Bootloader 原始帧发送、响应等待、断连中止和超时处理。
 
-## 4. 领域模型
+## 5. 领域模型
 
-### 4.1 参数组
+### 5.1 参数组模型
 
 目录:`domain/parameter-groups/`
 
 职责:
 
-- 定义参数组和寄存器/变量的标准数据结构。
-- 支持标准 Modbus 寄存器组
-- 支持存储访问同步出来的结构体组。
-- 支持字节地址变量、结构体字段、数组、bit field。
-- 负责输入值、显示值、原始字节、原始字、数据类型转换
+- 定义参数组、寄存器、结构体字段、单变量的标准数据结构。
+- 规范化地址、数量、读写状态、显示值和来源元数据
+- 支持标准 Modbus 寄存器/线圈组。
+- 支持存储访问字节地址组、结构体字段、单变量、数组、bit field。
+- 支持原始值到实际值的转换公式
 
-参数组不是某一种协议的私有模型。标准 Modbus 和存储访问协议都会把可读写数据落到参数组模型上。
+当前模块:
 
-当前模块拆分:
+| 文件 | 职责 |
+|---|---|
+| `constants.js` | 寄存器类型、数据类型、布局类型、地址上限和导入导出字段白名单 |
+| `model.js` | 参数组/寄存器规范化、地址布局、显示文案、导入克隆和领域 API |
+| `register-io.js` | 读缓存解码、写入编码、组级字节/字编码和分片辅助 |
+| `value-types.js` | 数据类型元数据、字节/字长度、bit field 长度、类型分类 |
+| `value-number.js` | 整数、HEX、float 的解析、范围校验和格式化 |
+| `value-text.js` | ASCII/UTF-8 文本字段的编码和解码 |
+| `value-codec.js` | 寄存器值编解码、显示值和输入校验 |
+| `value-formula.js` | 读回标幺值到实际值的转换公式求值 |
+| `struct-parser.js` | C 结构体与 enum 定义解析入口 |
+| `struct-c-syntax.js` | C 注释剥离、typedef、struct、enum 和 declarator 解析 |
+| `struct-layout.js` | 结构体字段布局、数组展开、ASCII 字段、enum 映射和 bit field 展开 |
 
-- `constants.js`:寄存器类型、数据类型、布局类型、地址上限和导入导出字段白名单。
-- `model.js`:参数组/寄存器规范化、地址布局、导入克隆和对外领域 API。
-- `register-io.js`:寄存器读缓存解码、写入值编码、组级字节/字编码和读写分片计算。
-- `value-types.js`:数据类型元数据、字节/字长度、bit field 长度、类型分类和显示能力判断。
-- `value-text.js`:ASCII/UTF-8 文本字段的字节编码、尾部 0 裁剪和解码。
-- `value-number.js`:整数/HEX/float 的解析、范围校验、显示格式化和大端字节转换。
-- `value-codec.js`:寄存器值编码解码、文本/数值/float/bit field 转换、显示值和输入校验。
-- `struct-parser.js`:C 结构体定义和结构体实例目录解析的对外入口。
-- `struct-c-syntax.js`:注释剥离、类型别名、结构体查找、声明和 declarator 解析。
-- `struct-layout.js`:结构体字段到参数寄存器的字节布局、数组展开、ASCII 字段和 bit field 展开。
+### 5.2 CodeInfo 解析
 
-### 4.2 存储访问信息解析
-
-目录:`domain/storage-access/`
+入口:`domain/storage-access/code-info-parser.js`
 
 职责:
 
-- 解析 code 区中的 `Modbus_Code_Info_t`。
-- 从 `struct_table` 生成参数组初始结构。
-- 根据 `mem_type` 映射到真实存储区域:
-  - `0x01 DATA`
-  - `0x02 IDATA`
-  - `0x03 XDATA`
-  - `0x04 CODE`
-- 不把 `0x0F INFO` 当成普通变量区域写入;同步时按普通只读响应解析 `0x0F` 回复。
-
-## 5. 功能服务
+- 解析 CodeInfo 纯 TLV 信息块,未知 TLV 类型跳过。
+- 使用同步 bootstrap 返回的 `CODE_LEN`、`ADDR_WIDTH`、`MEMORY_ENDIAN` 和 `MAX_PACKET`;`CODE_LEN` 决定 CodeInfo 总长度,`ADDR_WIDTH` 只决定读取 CodeInfo 信息块本体时的地址宽度,内存入口地址宽度由 TLV `TYPE` 自描述,`MEMORY_ENDIAN` 进入参数组上下文用于变量值编解码。
+- 解析 UTF-8/ASCII 电机型号、芯片型号和转换相关可选 TLV 参数。
+- 按 `mem_type` 映射真实存储区域。
+- 按 TLV `TYPE=0x20/0x21/0x28/0x29` 区分 16/32 位地址结构体实例和单独变量。
+- 生成参数组初始结构,结构体未导入定义时按字节占位,单独变量按 1/2/4 字节推断为 `uint8_t`、`uint16_t`、`uint32_t`。
 
-### 5.1 手动 Modbus 指令
+## 6. 功能服务
 
-目录:`features/manual-rtu/`
+### 6.1 通讯页服务
 
-职责:
+目录:`features/communication/`
 
-- 维护通讯页标准 Modbus 指令表单状态。
-- 根据输入生成 Modbus RTU 帧。
-- 支持写多个寄存器的多值输入。
-- 发送后等待并格式化响应。
-- 未连接蓝牙时仍允许生成帧;点击发送时再提示连接状态。
+当前模块:
 
-当前模块拆分:
+| 文件 | 职责 |
+|---|---|
+| `index.js` | 通讯页功能聚合入口,显式导出页面需要的 API |
+| `manual-rtu.js` | 标准 Modbus 指令表单、写多个寄存器输入、帧生成、发送和响应文案 |
+| `service.js` | 串口原始发送、存储访问普通读写、特殊指令下发 |
+| `view-model.js` | 日志展示、串口/存储访问表单状态、协议模式展示状态 |
 
-- `service.js`:通讯页标准 Modbus 表单状态、弹窗开关和发送动作。
-- `frame-builder.js`:功能码列表、HEX 参数解析、标准 Modbus 帧生成、期望响应描述和回复文本格式化。
-- `multiple-registers.js`:写多个寄存器时的数量规范化、字段生成、数据类型切换、值编码和输入校验。
+无协议模式只显示串口发送和日志;标准 Modbus 模式只显示 Modbus 指令;存储访问模式只显示存储访问卡片。
 
-### 5.2 存储访问同步
+### 6.2 存储访问服务
 
-目录:`features/storage-access/`
+入口:`features/storage-access/service.js`
 
 职责:
 
-- 读取 `0x0F info` 的 `ADDR=0x0000`、`LEN=0x0004` 并解析返回数据。
-- 根据返回的地址和长度分片读取 `0x04 code` 中的完整信息块。
-- 调用 `domain/storage-access` 解析结构体表。
-- 将解析出的结构体组导入 `features/parameter-groups`。
+- 包装 `protocols/storage-access/index.js` 的读写、特殊指令和 CodeInfo 同步能力。
+- 根据设置的最大包长决定分片长度。
+- 执行 `OP=0x05` 描述符读取,再用 `MODE=0x07` 或 `MODE=0x04 CODE` 完整读取 CodeInfo。
+- 将解析结果转换为参数组。
+- 提供复位、启动、停止、控制参考值等特殊指令。
 
-### 5.3 参数组服务
+### 6.3 标准 Modbus 服务
 
-目录:`features/parameter-groups/`
+入口:`features/modbus-rtu/service.js`
 
 职责:
 
-- 参数组持久化、导入、导出。
-- 标准 Modbus 参数组读写。
-- 存储访问结构体组读写。
-- 自动轮询。
-- 修改后焦点切换自动写入。
-- 结构体定义导入后的字段补全。
-
-存储访问协议下,参数组按字节地址读写;标准 Modbus 下,参数组按 Modbus 寄存器/线圈地址读写。
-
-当前模块拆分:
-
-- `service.js`:参数组页面入口 API、导入导出、结构体补全和协议读写调度。
-- `store.js`:参数组状态容器、初始化、订阅通知和本地持久化触发。
-- `persistence.js`:参数组 JSON 存储、导入、导出。
-- `import-merge.js`:导入去重、重复变量更新、聚合组变量合并。
-- `struct-completion.js`:结构体定义解析后的实例匹配、字段补全、数组和 bit field 展开。
-- `storage-access-io.js`:存储访问协议参数组读写、data/idata/xdata/code 区域映射、字节地址缓存和 bit field 读改写。
-- `modbus-io.js`:标准 Modbus 参数组读写、线圈写入、多寄存器编码和按最大包长拆分写入。
-- `state-mappers.js`:读取缓存和写入快照映射回参数组寄存器展示态。
-- `poller.js`:按设置间隔轮询参数组。
-- `view-model.js`:参数页展示态转换。
-- `drag-view-model.js`:参数页寄存器拖拽排序的索引、位移和行样式计算。
-- `dialog-handlers.js`:参数页新建/编辑参数组、寄存器信息弹窗和结构体解析交互处理器。
+- 包装 `protocols/modbus-rtu/index.js` 的帧生成、响应期望和分片能力。
+- 读取设置页从机地址,并把地址错误统一转成浮层警告。
+- 通过 BLE 发送标准 Modbus 读写命令。
+- 聚合分片读取结果,供参数组读取、写入和自动读取复用。
 
-### 5.4 通讯页服务
+### 6.4 参数组服务
 
-目录:`features/communication/`
+目录:`features/parameter-groups/`
 
-职责
+当前模块:
 
-- 串口原始发送。
-- 通讯页存储访问指令卡片。
-- 日志显示模式转换。
-- 根据设置中的协议模式显示对应的指令控制卡片。
+| 文件 | 职责 |
+|---|---|
+| `index.js` | 参数页功能聚合入口,显式导出页面需要的 API |
+| `service.js` | 参数组页面主 API、导入导出、结构体补全、读写调度 |
+| `store.js` | 参数组状态容器、协议切换、本地持久化、CodeInfo 卡片状态 |
+| `persistence.js` | 参数组 JSON 导入、导出和按协议本地存储 |
+| `imports.js` | 导入合并、重复项识别、结构体/enum 定义补全和已有结构保留 |
+| `io.js` | 标准 Modbus 和存储访问协议的参数组读写实现 |
+| `poller.js` | 按设置自动读取当前协议的可读参数组 |
+| `view-model.js` | 参数页展示态和弹窗状态 |
+| `drag-view-model.js` | 参数页寄存器拖拽排序状态计算 |
+| `dialog-handlers.js` | 新建/编辑参数组、寄存器信息弹窗、结构体解析交互 |
 
-### 5.5 工具页服务
+### 6.5 设置与工具服务
 
-目录:`features/tools/`
+目录:`features/settings/`、`features/tools/`
 
 职责:
 
-- 设置页工具入口导航。
-- 汇总 CRC/哈希、滤波器、阻抗、贴片码、制冷、三相功率等工具的初始状态。
-- 按工具域拆分设置页事件处理器。
-
-当前模块拆分:
+- 设置页协议模式、主题、蓝牙状态、Bootloader 状态和工具页面状态聚合。
+- `protocol-implementation.js` 提供存储访问协议实现说明卡片和文件占位;从机源码暂未提供。
+- `features/tools/index.js` 聚合工具入口、工具导航、CRC/哈希、滤波、阻抗、贴片码、制冷、三相功率等工具状态和事件处理器。
+- 工具内部按工具域拆分,避免设置页脚本膨胀。
 
-- `navigation.js`:设置页工具入口、标题和视图识别。
-- `page.js`:工具初始状态与页面事件处理器聚合入口。
-- `handlers/common.js`:工具结果复制等通用事件。
-- `handlers/crc.js`:CRC/哈希配置、输入、文件加载、计算和清空。
-- `handlers/filter.js`:滤波器输入、单位切换、网络/响应切换和归一化。
-- `handlers/reactance.js`:阻抗输入、单位切换、模式切换和归一化。
-- `handlers/smd-code.js`:贴片电阻/电容代码类型、格式和输入。
-- `handlers/refrigeration.js`:制冷单位换算模式和输入。
-- `handlers/three-phase-power.js`:三相接法、功率/电气量输入和驱动字段维护。
+## 7. 页面职责
 
-## 6. 页面职责
-
-### 6.1 首页
+### 7.1 首页
 
 目录:`pages/home/`
 
-职责:
-
-- 蓝牙扫描。
-- 蓝牙连接。
-- 显示连接状态。
-
-首页不再承载 Modbus 指令生成。
+职责:蓝牙扫描、连接、断开、设备列表展示。
 
-### 6.2 通讯页
+### 7.2 通讯页
 
 目录:`pages/communication/`
 
-布局顺序:
+职责:根据协议模式显示对应通讯卡片和收发日志。
 
-1. 串口发送卡片。
-2. 当前协议对应的指令卡片。
-3. 收发日志。
-
-标准 Modbus 模式显示 Modbus 指令卡片。
-
-存储访问模式显示同步、读取、写入指令卡片。
-
-### 6.3 参数页
+### 7.3 参数页
 
 目录:`pages/params/`
 
-职责:
-
-- 展示参数组列表。
-- 存储访问模式下通过同步按钮生成结构体组。
-- 存储访问模式下通过轮询读取结构体组。
-- 修改字段后在焦点切换时自动下发。
-- 标准 Modbus 模式下保留新建参数组、读组、写组能力。
-
-参数页不再包含手动 Modbus 指令窗口。
+职责:展示参数组、读取、写入、导入导出、结构体补全、自动读取和拖拽排序。
 
-### 6.4 设置页
+### 7.4 设置页
 
 目录:`pages/settings/`
 
-职责:
-
-- 协议模式选择。
-- 从机地址配置。
-- 参数轮询间隔和最大包长配置。
-- Bootloader 升级。
-- CRC、滤波、SMD、制冷、三相功率等工具入口。
+职责:协议模式、从机地址、自动读取间隔、最大包长、协议实现说明、Bootloader 和工具入口。
 
-## 7. 数据流
+## 8. 数据流
 
-### 7.1 存储访问同步数据流
+### 8.1 存储访问同步
 
 ```text
 用户点击同步
-  pages/params 或 pages/communication
-    -> features/storage-access/service.syncCodeInfo
-      -> protocols/storage-access/index.js readCodeInfoBlock
-        -> 读取 AREA=0x0F ADDR=0x0000 LEN=0x0004
-        -> 按普通读响应解析 DATA 中的 code 地址和长度
-        -> 读 AREA=0x04 ADDR=CODE_ADDR LEN=CODE_LEN
-      -> domain/storage-access/code-info-parser.parseCodeInfo
-      -> domain/storage-access/code-info-parser.createGroupsFromCodeInfo
-      -> features/parameter-groups/service.mergeImportedGroups
+  -> pages/communication/communication.js
+  -> features/parameter-groups/service.syncFromStorageAccessCodeInfo
+  -> features/storage-access/service.syncCodeInfo
+  -> features/storage-access/service.readCodeInfoBlock
+  -> protocols/storage-access/index.js 构建特殊指令和普通读帧
+  -> 特殊指令 OP=0x05 读取 CODE_ADDR32 + CODE_LEN16 + ADDR_WIDTH8 + MEMORY_ENDIAN16 + MAX_PACKET16
+  -> 按 ADDR_WIDTH/MAX_PACKET 分片读取 CodeInfo TLV 信息块,保存 MEMORY_ENDIAN 到参数组上下文
+  -> domain/storage-access/code-info-parser.parseCodeInfo
+  -> domain/storage-access/code-info-parser.createGroupsFromCodeInfo
+  -> features/parameter-groups/imports.mergeImportedGroups
+  -> features/parameter-groups/store.setGroups
 ```
 
-### 7.2 存储访问参数读取数据流
+### 8.2 参数组读取
 
 ```text
-参数组轮询或手动读取
+手动读取或自动读取
+  -> pages/params/params.js
   -> features/parameter-groups/service.readGroup
-    -> features/storage-access/memory-service.readMemory
-      -> protocols/storage-access/index.js readMemory
-        -> AREA 使用结构体表中的 mem_type
-        -> ADDR 使用结构体实例字节地址
-        -> LEN 使用结构体实例字节长度
+  -> features/parameter-groups/io.readGroup
+  -> 标准 Modbus: protocols/modbus-rtu/index.js
+  -> 存储访问: features/storage-access/service.readMemory
+  -> 读缓存映射回参数组显示态
 ```
 
-### 7.3 标准 Modbus 指令数据流
+### 8.3 参数组写入
 
 ```text
-通讯页填写指令
-  -> features/manual-rtu/service 生成标准 Modbus RTU 帧
-  -> transport/ble-core.js 下发
-  -> protocols/modbus-rtu/index.js 解析回复
-  -> 通讯页显示回复与日志
+输入完成或点击写入
+  -> pages/params/params.js
+  -> features/parameter-groups/service.writeRegister/writeGroup
+  -> features/parameter-groups/io.writeRegister/writeGroup
+  -> 标准 Modbus: 单寄存器/多寄存器写入
+  -> 存储访问: 字节地址写入,bit field 使用读改写
+  -> 写入快照映射回参数组显示态
 ```
 
-## 8. 维护约束
+## 9. 维护约束
 
-1. 不要把存储访问协议挂到 Modbus RTU 协议目录下。
-2. `0x0F info` 只作为只读同步区域,必须按普通读响应校验 CMD、ADDR、LEN、DATA 和 CRC。
-3. 存储访问协议的地址和长度始终按字节处理。
-4. 标准 Modbus 的地址和数量始终按标准寄存器/线圈语义处理。
-5. 参数组模型保持协议中立。
-6. Bootloader 升级和工具页保持独立,不随参数协议重构删除。
-7. 新功能优先放到对应协议或 feature 模块,不直接写进页面。
+1. 不再按单个按钮、单个协议动作、单个导入步骤拆零碎模块,优先收敛到当前 feature service 和领域模型文件。
+2. 新增协议能力优先放入对应 `protocols/*/index.js` 和对应 feature service,不直接写进页面。
+3. 新增参数值类型时,同时更新 `constants.js`、`value-types.js`、`value-codec.js` 和必要的导入导出字段。
+4. 新增 CodeInfo 字段时,同时更新 `domain/storage-access/code-info-parser.js` 和 `存储访问协议.md`。
+5. 页面只通过 feature 入口或明确 service/view-model 文件调用业务逻辑。

+ 277 - 158
存储访问协议.md

@@ -1,129 +1,166 @@
 # 存储访问协议
 
-## 1. 协议目标
+## 1. 协议定位
 
-协议用于上位机通过蓝牙透传链路按字节访问从机 MCU 的存储区域。协议为单主单从模型,不包含从机地址字段,不属于 Modbus RTU。
+存储访问协议用于上位机通过蓝牙透传链路按字节访问从机 MCU 的存储区域。协议为单主单从模型,不带从机地址字段,不属于标准 Modbus RTU。
 
-小程序中该协议与标准 Modbus、Bootloader 升级协议平级。标准 Modbus 继续使用原有从机地址、功能码和 Modbus CRC;存储访问协议统一使用本文定义的 `CMD + ADDR + LEN + DATA + CRC` 格式,`0x0F info` 也按只读区域处理。
+小程序中该协议与标准 Modbus RTU、Bootloader 升级协议平级:
 
-## 2. 基本字节序
+- 标准 Modbus RTU 使用从机地址、功能码和 Modbus CRC。
+- 存储访问协议使用 `CMD + ADDR + LEN + DATA + CRC16-CCITT-FALSE`。
+- 复位、启动、停止、控制参考值、CodeInfo 描述符读取使用特殊指令帧。
+- CodeInfo 同步只使用特殊指令 `OP=0x05`,不保留旧同步区域入口。
 
-除 CRC 算法内部计算外,协议字段均为大端序。
+## 2. 字节序与 CRC
+
+除 CRC 算法内部计算外,所有多字节协议控制字段均为大端序。目标内存变量的多字节值不跟随协议控制字段字节序,而是按第一次同步 bootstrap 描述符中的 `MEMORY_ENDIAN` 解释和编辑。
 
 ```text
-16 位地址: ADDR_H ADDR_L
-16 位长度: LEN_H  LEN_L
-CRC 输出 : CRC_H  CRC_L
+32 位地址 : ADDR_3 ADDR_2 ADDR_1 ADDR_0
+16 位长度 : LEN_H LEN_L
+CRC 输出  : CRC_H CRC_L
 ```
 
 CRC 使用 `CRC16-CCITT-FALSE`:
 
-```text
-多项式: 0x1021
-初值  : 0xFFFF
-反转  : 输入不反转,输出不反转
-异或  : 0x0000
-顺序  : 高字节在前
-```
+| 参数 | 值 |
+|---|---|
+| 多项式 | `0x1021` |
+| 初值 | `0xFFFF` |
+| 输入反转 | 否 |
+| 输出反转 | 否 |
+| 结果异或 | `0x0000` |
+| 输出顺序 | 高字节在前 |
+
+CRC 覆盖除最后两个 CRC 字节外的整帧内容。
 
 ## 3. CMD 定义
 
 每帧第 1 字节为 `CMD`。
 
 ```text
-bit7      ERR    异常标志
-bit6      RW     读写标志
-bit5~bit0 AREA   存储区域编号
+bit7      ERR       异常标志
+bit6      CTL       特殊指令标志位
+bit5~bit4 RSV       保留,普通读写保持 0
+bit3      RW        读写标志,0=读,1=写
+bit2~bit0 MODE      地址模式或存储区域
 ```
 
-含义如下:
-
 | 位 | 值 | 含义 |
 |---|---:|---|
 | ERR | 0 | 正常请求或正常响应 |
 | ERR | 1 | 异常响应 |
+| CTL | 0 | 普通内存读写 |
+| CTL | 1 | 与 `bit3=1` 且 `MODE=0x7` 组合为特殊指令,即 `CMD=0x4F` |
+| RSV | 0 | 当前版本固定为 0 |
 | RW | 0 | 读操作 |
 | RW | 1 | 写操作 |
 
-命令生成规则:
+普通内存命令生成规则:
 
 ```text
-读命令 CMD = AREA
-写命令 CMD = 0x40 | AREA
-异常 CMD_ERR = CMD | 0x80
+读请求   : CMD = MODE
+写请求   : CMD = 0x08 | MODE
+异常响应 : CMD_ERR = CMD | 0x80
 ```
 
+`CMD=0x4F` 固定保留为特殊指令帧,不作为普通存储区域。
+
 ## 4. AREA 定义
 
 | AREA | 名称 | 读 | 写 | 用途 |
 |---:|---|---|---|---|
-| `0x01` | data | 支持 | 支持 | 内部直接寻址 RAM 区 |
-| `0x02` | idata | 支持 | 支持 | 内部间接寻址 RAM 区 |
-| `0x03` | xdata | 支持 | 支持 | 外部数据空间或扩展 RAM |
-| `0x04` | code | 支持 | 禁止 | 程序存储区 |
-| `0x0F` | info | 支持 | 禁止 | 连接后同步 code 区关键数据地址与长度 |
-
-`0x0F info` 是一个只读信息区域,不直接承载完整 `Modbus_Code_Info_t`。小程序连接设备后先通过 `0x0F` 按地址和长度读取 4 字节关键数据,再使用 `AREA=0x04 code` 读取真正的 `Modbus_Code_Info_t`。
+| `0x00` | 保留 | 禁止 | 禁止 | 保留 |
+| `0x01` | DATA | 支持 | 支持 | 内部直接寻址 RAM 区 |
+| `0x02` | IDATA | 支持 | 支持 | 内部间接寻址 RAM 区 |
+| `0x03` | XDATA | 支持 | 支持 | 外部数据空间或扩展 RAM |
+| `0x04` | CODE | 支持 | 禁止 | 程序存储区 |
+| `0x05..0x06` | 保留 | 禁止 | 禁止 | 保留 |
+| `0x07` | ADDR32 | 支持 | 支持 | 32 位统一地址模式 |
 
-## 5. 帧格式
+写 `CODE` 区必须返回写保护异常。
 
-### 5.0 info 同步请求/响应
+## 5. 普通内存访问帧
 
-`info` 同步请求与普通读请求一致,地址和长度单位均为字节。当前定义固定读取 `ADDR=0x0000`、`LEN=0x0004`:
+### 5.1 读请求
 
 ```text
-0F 00 00 00 04 CRC_H CRC_L
+MODE=0x07: CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L CRC_H CRC_L
+MODE=0x01..0x04: CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
 ```
 
-回复与普通读响应一致,仍包含本次读取的 `ADDR` 与 `LEN`,随后 4 字节数据为 code 区内信息块的地址和长度:
+长度分别为 9 字节或 7 字节。
+
+### 5.2 写请求
 
 ```text
-0F 00 00 00 04 CODE_ADDR_H CODE_ADDR_L CODE_LEN_H CODE_LEN_L CRC_H CRC_L
+MODE=0x07: CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L DATA... CRC_H CRC_L
+MODE=0x01..0x04: CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
 ```
 
-响应中的前两个字节地址 `00 00` 和长度 `00 04` 是本次 `info` 区读取的地址与长度。`CODE_ADDR` 和 `CODE_LEN` 是返回数据,表示 code 区内 `Modbus_Code_Info_t` 的字节地址和字节长度
+长度分别为 `9 + LEN` 或 `7 + LEN` 字节
 
-### 5.1 读请求
+### 5.3 正常读响应
 
 ```text
-CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
+MODE=0x07: CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L DATA... CRC_H CRC_L
+MODE=0x01..0x04: CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
 ```
 
-长度固定 7 字节。
+长度分别为 `9 + LEN` 或 `7 + LEN` 字节。
 
-### 5.2 写请求
+响应必须回显请求中的 `CMD`、`ADDR`、`LEN`。
+
+### 5.4 正常写响应
 
 ```text
-CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
+MODE=0x07: CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L CRC_H CRC_L
+MODE=0x01..0x04: CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
 ```
 
-长度为 `7 + LEN` 字节。
+长度分别为 9 字节或 7 字节。
 
-### 5.3 正常读响应
+响应必须回显请求中的 `CMD`、`ADDR`、`LEN`。
+
+### 5.5 异常响应
 
 ```text
-CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
+CMD_ERR EXCEPTION_CODE CRC_H CRC_L
 ```
 
-长度为 `7 + LEN` 字节
+长度固定 4 字节。`CMD_ERR` 为原请求 `CMD | 0x80`。特殊指令异常响应使用 `0xCF`
 
-### 5.4 正常写响应
+## 6. 特殊指令帧
+
+特殊指令使用固定 `CMD=0x4F`。
 
 ```text
-CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
+请求: 4F OP DATA... CRC_H CRC_L
+响应: 4F OP STATUS DATA... CRC_H CRC_L
+异常: CF EXCEPTION_CODE CRC_H CRC_L
 ```
 
-长度固定 7 字节
+`STATUS=0x00` 表示指令执行成功;非 0 表示从机识别了指令,但拒绝执行或执行失败
 
-### 5.5 异常响应
+| OP | 名称 | 请求 DATA | 成功响应 DATA | 说明 |
+|---:|---|---|---|---|
+| `0x01` | RESET | 无 | 无 | 复位 |
+| `0x02` | START | 无 | 无 | 启动 |
+| `0x03` | STOP | 无 | 无 | 停止 |
+| `0x04` | SET_CONTROL_REF | `REF_H REF_L` | 无 | 控制参考值,`int16_t`,大端序 |
+| `0x05` | READ_CODE_INFO_DESCRIPTOR | 无 | `CODE_ADDR32 CODE_LEN16 ADDR_WIDTH8 MEMORY_ENDIAN16 MAX_PACKET16` | 读取 CodeInfo bootstrap 描述符 |
 
-```text
-CMD_ERR EXCEPTION_CODE CRC_H CRC_L
-```
+特殊指令状态码:
 
-长度固定 4 字节。
+| STATUS | 名称 | 说明 |
+|---:|---|---|
+| `0x00` | OK | 成功 |
+| `0x01` | UNSUPPORTED | 不支持的指令 |
+| `0x02` | INVALID_PARAM | 参数无效 |
+| `0x03` | BUSY | 设备忙 |
+| `0x04` | FAILED | 执行失败 |
 
-## 6. 异常码
+## 7. 异常码
 
 | 异常码 | 名称 | 说明 |
 |---:|---|---|
@@ -140,152 +177,234 @@ CMD_ERR EXCEPTION_CODE CRC_H CRC_L
 | `0x0B` | RANGE_OVERFLOW | 地址范围溢出 |
 | `0x0C` | UNSUPPORTED_OPERATION | 不支持的操作 |
 
-## 7. info 同步流程
+## 8. CodeInfo 同步流程
+
+CodeInfo 同步分两步:
 
-连接后同步 code 区关键数据时,上位机先读取 `AREA=0x0F info` 的只读 4 字节数据,再访问 `AREA=0x04 code` 读取真实数据。
+1. 使用特殊指令 `OP=0x05` 读取 bootstrap 描述符。
+2. 按 bootstrap 返回的地址长度、目标内存字节序和最大包长读取完整 CodeInfo TLV 信息块。
 
-### 7.1 读取 info 区关键数据
+### 8.1 读取 bootstrap 描述符
 
-请求:
+请求:
 
 ```text
-0F 00 00 00 04 CRC_H CRC_L
+4F 05 CRC_H CRC_L
 ```
 
-从机返回
+从机成功响应
 
 ```text
-0F 00 00 00 04 CODE_ADDR_H CODE_ADDR_L CODE_LEN_H CODE_LEN_L CRC_H CRC_L
+4F 05 00 CODE_ADDR_3 CODE_ADDR_2 CODE_ADDR_1 CODE_ADDR_0 CODE_LEN_H CODE_LEN_L ADDR_WIDTH ENDIAN_MARK_H ENDIAN_MARK_L MAX_PACKET_H MAX_PACKET_L CRC_H CRC_L
 ```
 
-返回数据字段含义
+响应 DATA 字段
 
 | 字段 | 长度 | 说明 |
 |---|---:|---|
-| CODE_ADDR | 2 字节 | `Modbus_Code_Info_t` 在 code 区内的字节地址 |
-| CODE_LEN | 2 字节 | `Modbus_Code_Info_t` 的字节长度 |
+| `CODE_ADDR` | 4 | CodeInfo TLV 信息块在 CODE 区内的字节地址 |
+| `CODE_LEN` | 2 | CodeInfo TLV 信息块的字节长度 |
+| `ADDR_WIDTH` | 1 | 读取 CodeInfo 信息块本体时使用的地址长度,只允许 `16` 或 `32` |
+| `ENDIAN_MARK` | 2 | 目标内存变量字节序标记;原始字节 `55 AA` 表示大端,原始字节 `AA 55` 表示小端 |
+| `MAX_PACKET` | 2 | 从机允许的最大完整协议帧长度,包含 CMD/ADDR/LEN/DATA/CRC;为 0 表示未声明 |
 
-### 7.2 使用 code 区读取完整信息块
+`ENDIAN_MARK` 只影响目标内存变量的多字节值显示和编辑,例如结构体字段、单独变量、enum 底层整数。`CMD`、`ADDR`、普通读写 `LEN`、`CODE_LEN`、`MAX_PACKET` 等多字节协议控制字段始终按大端序解析;TLV `LEN` 为单字节字段,不涉及大小端。
 
-上位机根据 `info` 返回数据继续读取:
+### 8.2 读取完整 CodeInfo
+
+上位机继续发送普通读请求:
 
 ```text
-AREA = 0x04
+ADDR_WIDTH = 32: MODE = 0x07 ADDR32
+ADDR_WIDTH = 16: MODE = 0x04 CODE
 ADDR = CODE_ADDR
 LEN  = CODE_LEN
 ```
 
-如果 `CODE_LEN` 超过当前链路最大包长,小程序按字节地址自动分片。例如最大帧长为 64 字节时,单帧读响应可承载的数据长度为:
+当 `ADDR_WIDTH=16` 时,下发普通读帧只携带 `CODE_ADDR` 的低 16 位。
+
+如果 `CODE_LEN` 超过当前 BLE 包长可承载的数据长度,小程序按协议自动分片读取。后续分片使用 bootstrap 返回的 `ADDR_WIDTH` 与 `MAX_PACKET`;当 `MAX_PACKET` 非 0 时,设置页最大包长和设备声明包长取较小值。
+
+最大数据长度计算:
 
 ```text
-64 - 7 = 57 字节
+ADDR_WIDTH=32: 读响应单帧数据上限 = max_frame_bytes - 9
+ADDR_WIDTH=16: 读响应单帧数据上限 = max_frame_bytes - 7
 ```
 
-小程序会依次读取:
+## 9. CodeInfo TLV 信息块
+
+CodeInfo TLV 信息块位于 bootstrap 描述符给出的 CODE 区地址。该信息块不再定义固定 C 结构体头,也不再包含 `format_version`、`board_info_format`、`struct_entry_len` 或 `struct_table`。当前测试阶段只维护一种 TLV 字节格式,字段是否存在由 TLV `TYPE` 决定,信息块总长度由 `OP=0x05` bootstrap 返回的 `CODE_LEN` 决定。
+
+TLV 项连续排列,直到累计长度等于 `CODE_LEN`:
 
 ```text
-CODE_ADDR + 0x0000 ~ CODE_ADDR + 0x0038
-CODE_ADDR + 0x0039 ~ CODE_ADDR + 0x0071
-...
+TYPE LEN VALUE...
 ```
 
-每个分片均使用 `AREA=0x04 code`,地址为 code 区内真实字节地址。
-
-## 8. Modbus_Code_Info_t 布局
-
-`Modbus_Code_Info_t` 位于 `info` 返回数据给出的 code 区地址,布局如下:
-
-```c
-typedef struct
-{
-    uint16_t byte_len;
-    uint8_t cave_freq;
-    uint8_t ref_volt;
-    uint16_t amp_gain;
-    uint16_t rs_shunt;
-    float bus_div;
-    float along_div;
-    uint8_t chip_model[8];
-    uint8_t model[16];
-
-    uint16_t struct_count;
-    uint16_t struct_entry_len;
-    struct
-    {
-        uint16_t byte_addr;
-        uint16_t byte_len;
-        uint8_t mem_type;
-        uint8_t type_name[MODBUS_CODE_INFO_STRUCT_NAME_BYTE_LEN];
-    } struct_table[MODBUS_CODE_INFO_STRUCT_COUNT];
-} Modbus_Code_Info_t;
-```
+| 字段 | 长度 | 说明 |
+|---|---:|---|
+| `TYPE` | 1 | TLV 类型 |
+| `LEN` | 1 | `VALUE` 字节长度,单项最大 255 字节 |
+| `VALUE` | `LEN` | 类型对应的数据 |
+
+上位机解析到未知 `TYPE` 时跳过该项。任何 TLV 项声明长度超过 `CODE_LEN` 剩余字节时,整段 CodeInfo 视为格式错误。`CODE_LEN` 仍由 bootstrap 使用 16 位字段给出,因此整段 CodeInfo 可以超过 255 字节,只是单个 TLV 项不能超过 255 字节。
+
+### 9.1 板卡信息 TLV
+
+板卡信息全部为可选项。某块板不存在对应硬件信息时,不需要放该 TLV;小程序卡片和转换公式只使用实际同步到的字段。
 
-固定头长度为 44 字节:
-
-| 偏移 | 字段 | 长度 |
-|---:|---|---:|
-| 0 | byte_len | 2 |
-| 2 | cave_freq | 1 |
-| 3 | ref_volt | 1 |
-| 4 | amp_gain | 2 |
-| 6 | rs_shunt | 2 |
-| 8 | bus_div | 4 |
-| 12 | along_div | 4 |
-| 16 | chip_model | 8 |
-| 24 | model | 16 |
-| 40 | struct_count | 2 |
-| 42 | struct_entry_len | 2 |
-| 44 | struct_table | `struct_count * struct_entry_len` |
-
-`struct_table` 中 `mem_type` 使用同一套 AREA 编号:
-
-| mem_type | 结构体实例所在区域 |
+| TYPE | 名称 | LEN | VALUE 格式 | 显示/用途 |
+|---:|---|---:|---|---|
+| `0x01` | `cave_freq` | 1 | `uint8_t` | 载波频率,单位 KHz |
+| `0x02` | `ref_volt` | 1 | `uint8_t` | 基准电压实际值乘 10,显示时除以 10,单位 V |
+| `0x03` | `amp_gain` | 1 | `uint8_t` | 运放倍数,无单位 |
+| `0x04` | `rs_shunt` | 2 | `uint16_t` | 采样电阻,单位 mΩ |
+| `0x05` | `bus_div` | 4 | `float32` | 母线电压分压比,大端序 |
+| `0x06` | `along_div` | 4 | `float32` | 模拟输入电压分压比,大端序 |
+| `0x07` | `chip_model` | 可变 | UTF-8 或 ASCII 字符串 | 芯片型号,建议 0 结尾或 0 填充 |
+| `0x08` | `model` | 可变 | UTF-8 或 ASCII 字符串 | 电机型号,建议最多 30 字节,可容纳至少 7 个常见 UTF-8 汉字 |
+
+### 9.2 内存入口 TLV
+
+结构体实例和单独变量也是 TLV 项,不再额外保存 `entry_kind` 字段:
+
+| TYPE | 类型 | 地址宽度 | 小程序处理 |
+|---:|---|---:|---|
+| `0x20` | 结构体实例 | 16 位 | 创建结构体组;未导入定义时按 `00`、`01`、`02`... 字节占位 |
+| `0x21` | 单独变量 | 16 位 | 创建单变量组;1/2/4 字节默认按 `uint8_t`、`uint16_t`、`uint32_t` 显示,导入 enum 后可按枚举项显示 |
+| `0x28` | 结构体实例 | 32 位 | 创建结构体组,统一 32 位字节地址 |
+| `0x29` | 单独变量 | 32 位 | 创建单变量组,统一 32 位字节地址 |
+
+内存入口 `VALUE` 的地址宽度由 TLV `TYPE` 决定,不再由 bootstrap 的 `ADDR_WIDTH` 决定。bootstrap `ADDR_WIDTH` 只表示“读取 CodeInfo 信息块本体”时使用 16 位 CODE 地址还是 32 位统一地址。名称字段为 UTF-8 或 ASCII,建议 0 结尾或 0 填充。
+
+`TYPE=0x20/0x21` 的 16 位地址入口:
+
+| 字段 | 长度 | 说明 |
+|---|---:|---|
+| `mem_type` | 1 | 所在存储区域,使用 AREA 编号 |
+| `byte_addr` | 2 | 结构体实例或单独变量所在区域的字节地址 |
+| `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
+| `type_name` | `LEN - 5` | 结构体定义名、变量名或 enum 类型名 |
+
+`TYPE=0x28/0x29` 的 32 位地址入口:
+
+| 字段 | 长度 | 说明 |
+|---|---:|---|
+| `byte_addr` | 4 | 结构体实例或单独变量所在统一地址空间内的字节地址 |
+| `byte_len` | 2 | 结构体实例或单独变量的字节长度 |
+| `type_name` | `LEN - 6` | 结构体定义名、变量名或 enum 类型名 |
+
+32 位地址入口不携带 `mem_type`,小程序同步出的参数组统一使用 `MODE=0x07 ADDR32`。16 位地址入口按 `mem_type=0x01..0x04` 使用 DATA/IDATA/XDATA/CODE。手动存储访问卡片始终允许选择 `ADDR32/DATA/IDATA/XDATA/CODE`,不再因为 bootstrap `ADDR_WIDTH` 被锁定。
+
+`mem_type`:
+
+| 值 | 区域 |
 |---:|---|
-| `0x01` | data |
-| `0x02` | idata |
-| `0x03` | xdata |
-| `0x04` | code |
+| `0x01` | DATA |
+| `0x02` | IDATA |
+| `0x03` | XDATA |
+| `0x04` | CODE |
+
+## 10. 小程序同步后的处理
+
+小程序读取完整 CodeInfo TLV 信息块后:
+
+1. 按 `TYPE LEN VALUE` 遍历 TLV 项,未知类型跳过。
+2. 解析可选板卡信息,更新通讯页 CodeInfo 卡片;不存在的字段不显示、不进入转换公式上下文。
+3. 遇到 `TYPE=0x20/0x28` 创建结构体组;结构体定义未导入时按字节占位。
+4. 遇到 `TYPE=0x21/0x29` 创建单独变量组。
+5. 按 TLV `TYPE` 解析内存入口地址;`0x20/0x21` 为 16 位地址,`0x28/0x29` 为 32 位地址。
+6. 按 bootstrap `MEMORY_ENDIAN` 解释结构体字段、单独变量和 enum 底层整数的多字节值;单字节值不受影响。
+7. 根据 `mem_type`、`byte_addr`、`byte_len`、`TYPE`、`type_name` 创建参数组。
+8. 如果导入了 C 结构体和 enum 定义,只有结构体入口 `TYPE=0x20/0x28` 且 `type_name` 与结构体定义名一致、长度一致时才补全结构体字段;结构体字段类型为 enum 时,读回值按枚举项显示。
+9. 单独变量入口 `TYPE=0x21/0x29` 如果 `type_name` 匹配 enum 类型名,或导入文件中存在 `EnumType variable_name;` 且变量名匹配 `type_name`,读回值按枚举项显示。
+10. 如果当前已有同地址、同区域、同名称、同长度的结构体组,并且已经导入过结构体定义,则保留当前导入结构,只更新来源地址、区域、轮询开关等状态。
+11. 如果存在相同 `type_name` 但 `mem_type` 或 `byte_addr` 不同的入口,应视为不同参数组。
+
+## 11. 示例
 
-`struct_table` 不应把结构体实例标为 `0x0F`,因为 `0x0F info` 只用于同步 code 区信息块地址和长度,不作为普通变量读写区域。
+### 11.1 读取 XDATA 中 64 字节结构体
 
-## 9. 小程序同步后的处理
+```text
+AREA = 0x03 XDATA
+ADDR = 0x2000
+LEN = 0x0040
+读请求 = 03 20 00 00 40 CRC_H CRC_L
+正常响应 = 03 20 00 00 40 DATA(64B) CRC_H CRC_L
+```
+
+### 11.2 读取 CODE 中 CodeInfo
 
-小程序读取完整 `Modbus_Code_Info_t` 后执行以下步骤:
+假设描述符返回
 
-1. 解析硬件固定信息。
-2. 解析 `struct_count` 和 `struct_entry_len`。
-3. 遍历 `struct_table`。
-4. 根据每一项的 `mem_type`、`byte_addr`、`byte_len`、`type_name` 创建结构体组。
-5. 后续读取结构体实例时,改用对应的真实区域:
-   - `0x01 data`
-   - `0x02 idata`
-   - `0x03 xdata`
-   - `0x04 code`
-6. 如果导入了 C 结构体定义,小程序会根据 `type_name` 自动补全字段、位域、数组和数据类型。
+```text
+CODE_ADDR = 0x00123456
+CODE_LEN  = 0x0120
+ADDR_WIDTH = 32
+MEMORY_ENDIAN = big,bootstrap 原始标记 55 AA
+```
 
-示例:
+读取请求:
 
 ```text
-struct_table[0]:
-  byte_addr = 0x2000
-  byte_len  = 64
-  mem_type  = 0x03
-  type_name = motor_runtime_t
+07 00 12 34 56 01 20 CRC_H CRC_L
 ```
 
-则后续读取该结构体实例时发送:
+`0x07` 表示 32 位统一地址模式。
+
+### 11.3 CodeInfo TLV 示例
+
+结构体实例:
 
 ```text
-03 20 00 00 40 CRC_H CRC_L
+TYPE = 0x20
+LEN = 0x14
+VALUE = 03 20 00 00 40 4D 6F 74 6F 72 5F 52 75 6E 74 69 6D 65 5F 74
+含义 = XDATA 0x2000 / 64 bytes / Motor_Runtime_t
 ```
 
-含义为读取 `xdata` 区 `0x2000` 开始的 64 字节。
+单独变量:
+
+```text
+TYPE = 0x29
+LEN = 0x0F
+VALUE = 00 00 21 00 00 02 73 70 65 65 64 5F 72 65 66
+含义 = ADDR32 0x00002100 / 2 bytes / speed_ref
+```
+
+## 12. 实现约束
+
+1. 普通内存读写帧的 `LEN` 单位始终为字节,字段固定 16 位,有效范围为 `1..0xFFFF`;CodeInfo TLV 项内的 `LEN` 为 1 字节,只表示单项 `VALUE` 长度。
+2. `ADDR` 单位始终为字节;`MODE=0x07` 时地址字段为 32 位,`MODE=0x01..0x04` 时地址字段为 16 位;`MODE=0x00/0x05/0x06` 保留。
+3. 普通读写响应必须回显请求的 `CMD`、`ADDR`、`LEN`。
+4. 上位机必须校验响应 `CMD`、`ADDR`、`LEN`、CRC,并确认数据长度等于 `LEN`。
+5. `CODE` 区只读,写入必须返回 `WRITE_PROTECT`。
+6. 特殊指令正常响应必须包含 `STATUS`。
+7. CodeInfo 同步必须走 `OP=0x05` bootstrap 描述符,再按 bootstrap `ADDR_WIDTH` 读取信息块;内存入口地址宽度由 TLV `TYPE` 决定,目标变量值按 bootstrap `MEMORY_ENDIAN` 解释。
+8. 固件更新 CodeInfo TLV 类型或内存入口格式时,必须同步更新本文档和小程序解析器。
+
+## 13. 读写位宽说明
+
+普通内存读写协议不额外携带变量位宽字段,只传输“字节地址 + 字节长度 + 字节数据”。变量显示和编辑位宽由以下信息决定:
+
+- `TYPE=0x20/0x28` 的结构体实例由导入的 C 结构体定义决定字段类型。
+- `TYPE=0x21/0x29` 的单独变量由 `byte_len` 默认推断:1 字节为 `uint8_t`,2 字节为 `uint16_t`,4 字节为 `uint32_t`。
+- `enum` 不改变普通内存读写帧,只是在导入定义后给整数值增加枚举映射;显示格式为 `枚举项 (数值)`,写入时可输入枚举项名称或数字。
+- 参数页可在寄存器/变量配置中进一步设置公式、单位、范围和显示类型。
+
+如果后续从机需要原子读写、硬件寄存器位宽或对齐语义,可以新增 typed 特殊指令,不改变普通内存访问帧:
+
+```text
+读: 4F 06 AREA ADDR_3 ADDR_2 ADDR_1 ADDR_0 VALUE_WIDTH COUNT_H COUNT_L CRC_H CRC_L
+写: 4F 07 AREA ADDR_3 ADDR_2 ADDR_1 ADDR_0 VALUE_WIDTH COUNT_H COUNT_L DATA... CRC_H CRC_L
+```
 
-## 10. 实现约束
+建议字段:
 
-1. `code` 和 `info` 区默认只读。
-2. `LEN` 单位始终为字节,不是 Modbus 寄存器。
-3. 上位机必须校验响应 CMD、ADDR、LEN 和 CRC。
-4. 异常响应固定 4 字节。
-5. `0x0F info` 只用于连接同步信息块地址和长度,请求/回复均按普通只读帧处理,不作为普通变量读写区域。
-6. 如果固件更新了 `Modbus_Code_Info_t` 布局,应同步更新本文档和小程序解析器。
+| 字段 | 建议值 | 说明 |
+|---|---|---|
+| `ADDR` | 4 字节 | typed 扩展建议使用 32 位字节地址 |
+| `VALUE_WIDTH` | `0x01`、`0x02`、`0x04` | 单个值 8/16/32 位 |
+| `COUNT` | 16 位 | 值数量,不是字节数 |
+| `DATA` | `VALUE_WIDTH * COUNT` 字节 | 按协议大端序 |

+ 255 - 0
完整协议说明.md

@@ -0,0 +1,255 @@
+# 完整协议说明
+
+## 1. 协议模式总览
+
+本项目在同一条 BLE 透传链路上支持四种工作模式。模式由设置页 `protocolMode` 控制,通讯页和参数页会按当前模式切换 UI、参数分组和自动读取协议。
+
+| 模式 | key | 通讯页 | 参数页 | 说明 |
+|---|---|---|---|---|
+| 无协议 | `none` | 串口发送卡片、日志卡片 | 不显示协议参数组 | 只做原始字节透传和日志观察 |
+| 标准 Modbus | `modbus-rtu` | 标准 Modbus 指令卡片、日志卡片 | Modbus 寄存器组 | 使用从机地址、功能码和 Modbus CRC |
+| 存储访问 | `storage-access` | 同步、CodeInfo、特殊指令、读写卡片、日志卡片 | 存储访问结构体组/单变量组 | 按字节访问 DATA、IDATA、XDATA、CODE 或 32 位统一地址空间 |
+| Bootloader | 设置页升级工具 | Bootloader 工具卡片 | 不参与参数页 | 独立升级协议,不和 Modbus/存储访问混用 |
+
+切换协议时,参数页会同步切换对应协议的分组集合。自动读取运行中如果检测到协议变化,会停止当前读取循环,避免用旧协议继续访问新模式下的参数组。
+
+## 2. 公共链路规则
+
+所有协议均通过 BLE 透传发送原始字节。发送队列、响应等待、日志和超时由 `transport/ble-core.js` 统一处理。
+
+| 项目 | 规则 |
+|---|---|
+| 发送单位 | 原始字节数组 |
+| 日志显示 | HEX 或文本两种显示方式 |
+| 响应识别 | 由 `protocols/transport-helpers.js` 根据待响应协议调用对应解析器 |
+| 包长限制 | 设置页最大包长用于 Modbus 和存储访问分片,`0` 表示不限制 |
+| 参数自动读取 | 由设置页“自动轮询”开关控制,参数页“读取”只执行一次全量读取 |
+
+## 3. 无协议模式
+
+无协议模式不解析帧格式,也不绑定参数页分组。
+
+| 功能 | 行为 |
+|---|---|
+| 发送 | 通讯页原始串口发送卡片支持 HEX 或文本 |
+| 日志 | 显示发送和接收日志 |
+| 响应 | 不要求特定响应帧 |
+| 参数页 | 不显示标准 Modbus 或存储访问参数组 |
+
+适用场景:临时调试私有串口命令、观察设备透传输出、验证蓝牙链路连通性。
+
+## 4. 标准 Modbus RTU
+
+标准 Modbus 使用常规 RTU 帧格式:
+
+```text
+SLAVE FUNC DATA... CRC_L CRC_H
+```
+
+CRC 使用 `CRC16-Modbus`,低字节在前。
+
+| 参数 | 值 |
+|---|---|
+| 多项式 | `0x8005` 反射形式 |
+| 初值 | `0xFFFF` |
+| 输入反转 | 是 |
+| 输出反转 | 是 |
+| 结果异或 | `0x0000` |
+| 输出顺序 | 低字节在前 |
+
+支持功能码:
+
+| 功能码 | 名称 | 方向 | 参数页用途 |
+|---:|---|---|---|
+| `0x01` | 读线圈 | 读 | bit/coil 类型参数组 |
+| `0x02` | 读离散输入 | 读 | 只读 bit 类型参数组 |
+| `0x03` | 读保持寄存器 | 读 | 可读写寄存器组 |
+| `0x04` | 读输入寄存器 | 读 | 只读寄存器组 |
+| `0x05` | 写单线圈 | 写 | 单个 bit 写入 |
+| `0x06` | 写单寄存器 | 写 | 单个 16 位寄存器写入 |
+| `0x10` | 写多个寄存器 | 写 | 连续寄存器批量写入 |
+
+数量限制按当前最大包长计算。默认 64 字节包长下,`0x03/0x04` 单帧最多读取 29 个寄存器,`0x10` 单帧最多写入 27 个寄存器。超过限制时参数页会按连续片段分片读取或写入。
+
+异常响应格式:
+
+```text
+SLAVE (FUNC | 0x80) EXCEPTION_CODE CRC_L CRC_H
+```
+
+标准 Modbus 与存储访问完全独立,不解析 CodeInfo TLV 信息块,也不使用存储访问的 `CMD/AREA`。
+
+## 5. 存储访问协议
+
+存储访问协议不带从机地址,不属于标准 Modbus。普通内存访问帧格式为:
+
+```text
+CMD ADDR LEN DATA... CRC_H CRC_L
+```
+
+CRC 使用 `CRC16-CCITT-FALSE`,高字节在前。所有多字节协议字段均为大端序。
+
+`CMD` 位定义:
+
+```text
+bit7      ERR       异常标志
+bit6      CTL       特殊指令标志位
+bit5~bit4 RSV       保留,普通读写保持 0
+bit3      RW        读写标志,0=读,1=写
+bit2~bit0 MODE      地址模式或存储区域
+```
+
+地址模式与存储区域:
+
+| MODE | 名称 | 地址宽度 | 读 | 写 | 说明 |
+|---:|---|---|---|---|---|
+| `0x00` | 保留 | - | 禁止 | 禁止 | 保留 |
+| `0x01` | DATA | 16 位 | 支持 | 支持 | 内部直接寻址 RAM |
+| `0x02` | IDATA | 16 位 | 支持 | 支持 | 内部间接寻址 RAM |
+| `0x03` | XDATA | 16 位 | 支持 | 支持 | 外部数据空间或扩展 RAM |
+| `0x04` | CODE | 16 位 | 支持 | 禁止 | 程序存储区 |
+| `0x05..0x06` | 保留 | - | 禁止 | 禁止 | 保留 |
+| `0x07` | ADDR32 | 32 位 | 支持 | 支持 | 统一 32 位字节地址 |
+
+普通读请求:
+
+```text
+CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L CRC_H CRC_L
+或
+CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
+```
+
+普通读响应回显 `CMD`、`ADDR`、`LEN`,然后携带 `LEN` 字节数据。
+
+普通写请求:
+
+```text
+CMD ADDR_3 ADDR_2 ADDR_1 ADDR_0 LEN_H LEN_L DATA... CRC_H CRC_L
+或
+CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
+```
+
+普通写响应只回显 `CMD`、`ADDR`、`LEN`。写 CODE 区必须返回写保护异常。
+
+特殊指令使用固定 `CMD=0x4F`:
+
+```text
+请求: 4F OP DATA... CRC_H CRC_L
+响应: 4F OP STATUS DATA... CRC_H CRC_L
+异常: CF EXCEPTION_CODE CRC_H CRC_L
+```
+
+特殊指令表:
+
+| OP | 名称 | 请求 DATA | 成功响应 DATA | 用途 |
+|---:|---|---|---|---|
+| `0x01` | RESET | 无 | 无 | 复位 |
+| `0x02` | START | 无 | 无 | 启动 |
+| `0x03` | STOP | 无 | 无 | 停止 |
+| `0x04` | SET_CONTROL_REF | `REF_H REF_L` | 无 | 控制参考值,`int16_t` 大端 |
+| `0x05` | READ_CODE_INFO_DESCRIPTOR | 无 | `CODE_ADDR32 + CODE_LEN16 + ADDR_WIDTH8 + MEMORY_ENDIAN16 + MAX_PACKET16` | 读取 CodeInfo bootstrap 描述符 |
+
+完整帧细节、异常码、示例和从机参考见 `存储访问协议.md`。
+
+## 6. CodeInfo 与参数组同步
+
+存储访问同步固定分两步:
+
+1. 发送特殊指令 `4F 05 CRC_H CRC_L`,读取 CodeInfo bootstrap 描述符。
+2. 根据 bootstrap 返回的 `CODE_ADDR32 + CODE_LEN16 + ADDR_WIDTH8 + MEMORY_ENDIAN16 + MAX_PACKET16` 读取完整 CodeInfo TLV 信息块。`ADDR_WIDTH=32` 使用 `MODE=0x07 ADDR32`,`ADDR_WIDTH=16` 使用 `MODE=0x04 CODE`;`MAX_PACKET` 非 0 时与设置页最大包长取较小值。
+
+bootstrap 中 `MEMORY_ENDIAN16` 使用原始字节标记目标内存变量字节序:`55 AA` 表示变量值大端,`AA 55` 表示变量值小端。多字节协议控制字段始终固定大端,包括 `CMD`、`ADDR`、普通读写 `LEN`、`CODE_LEN` 和 `MAX_PACKET`;TLV `LEN` 为单字节字段,不涉及大小端。
+
+CodeInfo 信息块使用纯 TLV 格式,不再包含固定头、`format_version`、`board_info_format`、`struct_entry_len` 或 `struct_table`:
+
+```text
+TYPE LEN VALUE...
+```
+
+常用 TLV:
+
+| TYPE | 名称 | VALUE |
+|---:|---|---|
+| `0x01` | `cave_freq` | `uint8_t`,KHz |
+| `0x02` | `ref_volt` | `uint8_t`,实际参考电压乘 10 |
+| `0x03` | `amp_gain` | `uint8_t` |
+| `0x04` | `rs_shunt` | `uint16_t`,mΩ |
+| `0x05` | `bus_div` | `float32`,大端 |
+| `0x06` | `along_div` | `float32`,大端 |
+| `0x07` | `chip_model` | UTF-8 或 ASCII 字符串 |
+| `0x08` | `model` | UTF-8 或 ASCII 字符串 |
+| `0x20` | 16 位地址结构体实例 | `mem_type + byte_addr16 + byte_len16 + type_name` |
+| `0x21` | 16 位地址单独变量 | `mem_type + byte_addr16 + byte_len16 + type_name` |
+| `0x28` | 32 位地址结构体实例 | `byte_addr32 + byte_len16 + type_name` |
+| `0x29` | 32 位地址单独变量 | `byte_addr32 + byte_len16 + type_name` |
+
+TLV `LEN` 为 1 字节,表示单项 `VALUE` 字节数;整段 CodeInfo 总长度仍由 bootstrap `CODE_LEN16` 决定。内存入口 TLV 的地址宽度由 `TYPE` 决定,bootstrap `ADDR_WIDTH` 只决定读取 CodeInfo 信息块本体时使用 16 位 CODE 地址还是 32 位统一地址。`0x20/0x21` 按 `mem_type=0x01..0x04` 使用 DATA/IDATA/XDATA/CODE,`0x28/0x29` 统一使用 `MODE=0x07 ADDR32` 且不携带 `mem_type`。
+
+同步到参数页后的规则:
+
+| 条件 | 处理 |
+|---|---|
+| `TYPE=0x20/0x28` 且未导入结构体定义 | 创建结构体组,每个字节按 `00`、`01`、`02` 命名 |
+| `TYPE=0x20/0x28` 且导入定义名、长度匹配 | 按 C 结构体字段展示,保留多字节字段整体值;字段类型为 enum 时按枚举项显示 |
+| `TYPE=0x21/0x29` | 创建单变量组,1/2/4 字节默认推断为 `uint8_t`、`uint16_t`、`uint32_t`;匹配 enum 后按枚举项显示 |
+| 相同名称但地址或区域不同 | 视为不同参数组 |
+| 当前已有同区域、同地址、同名称、同长度且已导入定义 | 保留当前导入结构,只更新同步来源信息 |
+| 未知 TLV `TYPE` | 跳过 |
+
+结构体和 enum 可在同一个 `.h/.c/.txt` 定义文件中导入。单独变量的 enum 匹配支持两种方式:`type_name` 直接等于 enum 类型名,或导入文件中存在 `EnumType variable_name;` 且变量名等于 `type_name`。enum 只影响显示与输入映射,不改变底层字节读写协议。
+
+CodeInfo 卡片始终显示在通讯页存储访问协议卡片内。板卡信息 TLV 不存在时对应字段不显示;电机型号优先按 UTF-8 解析,也兼容纯 ASCII。
+
+## 7. 参数值与转换公式
+
+参数页读回的数据先按字段类型解码为原始值,再根据可选转换公式显示实际值。公式由参数配置自定义,适合标幺值转实际值、比例缩放和偏置换算。
+
+存储访问参数组的多字节变量值按 bootstrap `MEMORY_ENDIAN` 解码和写入。标准 Modbus 寄存器仍保持 Modbus 字/字节规则,不受该字段影响。
+
+公式支持变量:
+
+| 变量 | 含义 |
+|---|---|
+| `x`、`value`、`rawValue` | 当前字段原始值 |
+| `caveFreq` | CodeInfo 载波频率 |
+| `refVolt` | CodeInfo 参考电压实际值 |
+| `ampGain` | CodeInfo 运放倍数 |
+| `rsShunt` | CodeInfo 采样电阻 |
+| `busDiv` | CodeInfo 母线电压分压比 |
+| `alongDiv` | CodeInfo 模拟输入电压分压比 |
+| `maxPacketLength` | 同步描述符声明的最大完整协议帧长度 |
+| `addressWidth` | 同步描述符声明的地址长度 |
+| `memoryEndian` | 同步 bootstrap 声明的目标内存变量字节序,`big` 或 `little` |
+
+公式只支持数字、括号和 `+ - * /`,不执行任意脚本。
+
+## 8. Bootloader 协议
+
+Bootloader 协议用于固件升级,独立于标准 Modbus 和存储访问。帧头固定为:
+
+```text
+46 54 PAYLOAD... CRC_H CRC_L
+```
+
+CRC 使用 `CRC16-CCITT-FALSE`,高字节在前。
+
+主要命令:
+
+| 命令 | 载荷 | 响应长度 | 用途 |
+|---|---|---:|---|
+| 握手 | `39 42 4C` | 15 | 读取 Bootloader 版本和芯片 ID |
+| 解锁 | `08 4E 00` | 8 | 进入可编程状态 |
+| 编程 | `44 ADDR_L ADDR_H DATA(128B)` | 8 | 写入 128 字节程序块 |
+| 全 Flash 校验 | `19 43 43` | 9 | 读取 Flash 校验值 |
+| 页擦除开关 | `08 50 45/44` | 8 | 开启或关闭页擦除 |
+| 退出 | `08 42 42` | 8 | 退出 Bootloader |
+
+ACK 为 `0x06`,NAK 为 `0x15`。固件文件加载、芯片型号识别、升级地址和 Flash 容量由 `features/bootloader/` 处理。
+
+## 9. 维护约束
+
+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`。
+4. 可复用且有明确领域边界的逻辑保留在 `domain/parameter-groups/` 和 `domain/storage-access/`。
+5. 不再为单个按钮、单个字段、单个导入步骤新增小模块;新增能力优先并入现有协议模块、功能服务或领域模型。