Bladeren bron

完善协议与UI

avery 4 uur geleden
bovenliggende
commit
d66ccfc0a2
87 gewijzigde bestanden met toevoegingen van 11664 en 7421 verwijderingen
  1. 8 1
      app.json
  2. 117 20
      app.wxss
  3. 0 5
      domain/generic-modbus/index.js
  4. 0 1398
      domain/generic-modbus/model.js
  5. 0 529
      domain/generic-modbus/struct-parser.js
  6. 164 0
      domain/parameter-groups/constants.js
  7. 10 0
      domain/parameter-groups/index.js
  8. 667 0
      domain/parameter-groups/model.js
  9. 211 0
      domain/parameter-groups/register-io.js
  10. 261 0
      domain/parameter-groups/struct-c-syntax.js
  11. 177 0
      domain/parameter-groups/struct-layout.js
  12. 131 0
      domain/parameter-groups/struct-parser.js
  13. 350 0
      domain/parameter-groups/value-codec.js
  14. 281 0
      domain/parameter-groups/value-formula.js
  15. 196 0
      domain/parameter-groups/value-number.js
  16. 101 0
      domain/parameter-groups/value-text.js
  17. 156 0
      domain/parameter-groups/value-types.js
  18. 20 13
      domain/storage-access/code-info-parser.js
  19. 3 0
      domain/storage-access/index.js
  20. 149 0
      features/bootloader/firmware.js
  21. 3 1
      features/bootloader/index.js
  22. 22 231
      features/bootloader/service.js
  23. 102 0
      features/bootloader/transport.js
  24. 9 0
      features/communication/index.js
  25. 196 0
      features/communication/service.js
  26. 313 0
      features/communication/view-model.js
  27. 0 12
      features/generic-modbus/index.js
  28. 0 1550
      features/generic-modbus/service.js
  29. 3 19
      features/home/service.js
  30. 1 4
      features/home/view-model.js
  31. 165 0
      features/manual-rtu/frame-builder.js
  32. 167 0
      features/manual-rtu/multiple-registers.js
  33. 82 263
      features/manual-rtu/service.js
  34. 213 0
      features/parameter-groups/dialog-handlers.js
  35. 127 0
      features/parameter-groups/drag-view-model.js
  36. 420 0
      features/parameter-groups/import-merge.js
  37. 22 0
      features/parameter-groups/index.js
  38. 197 0
      features/parameter-groups/modbus-io.js
  39. 241 0
      features/parameter-groups/persistence.js
  40. 20 13
      features/parameter-groups/poller.js
  41. 466 0
      features/parameter-groups/service.js
  42. 136 0
      features/parameter-groups/state-mappers.js
  43. 288 0
      features/parameter-groups/storage-access-io.js
  44. 256 0
      features/parameter-groups/store.js
  45. 151 0
      features/parameter-groups/struct-completion.js
  46. 57 43
      features/parameter-groups/view-model.js
  47. 15 10
      features/settings/view-model.js
  48. 9 0
      features/storage-access/index.js
  49. 59 0
      features/storage-access/memory-service.js
  50. 56 0
      features/storage-access/service.js
  51. 34 0
      features/tools/handlers/common.js
  52. 128 0
      features/tools/handlers/crc.js
  53. 101 0
      features/tools/handlers/filter.js
  54. 59 0
      features/tools/handlers/reactance.js
  55. 40 0
      features/tools/handlers/refrigeration.js
  56. 46 0
      features/tools/handlers/smd-code.js
  57. 42 0
      features/tools/handlers/three-phase-power.js
  58. 15 400
      features/tools/page.js
  59. 343 0
      pages/communication/communication.js
  60. 5 0
      pages/communication/communication.json
  61. 223 0
      pages/communication/communication.wxml
  62. 450 0
      pages/communication/communication.wxss
  63. 0 96
      pages/home/home.js
  64. 0 202
      pages/home/home.wxml
  65. 0 283
      pages/home/home.wxss
  66. 191 468
      pages/params/params.js
  67. 125 96
      pages/params/params.wxml
  68. 9 9
      pages/settings/settings.js
  69. 17 17
      pages/settings/settings.wxml
  70. 0 218
      protocols/bootloader/frame.js
  71. 216 1
      protocols/bootloader/index.js
  72. 0 424
      protocols/modbus-rtu/client.js
  73. 0 264
      protocols/modbus-rtu/frame.js
  74. 702 3
      protocols/modbus-rtu/index.js
  75. 0 413
      protocols/modbus-rtu/response.js
  76. 733 0
      protocols/storage-access/index.js
  77. 43 15
      protocols/transport-helpers.js
  78. 94 47
      store/settings-store.js
  79. 9 1
      store/theme-store.js
  80. 74 352
      transport/ble-core.js
  81. 137 0
      transport/ble-device-registry.js
  82. 39 0
      transport/ble-logs.js
  83. 235 0
      transport/ble-utils.js
  84. 73 0
      transport/protocol-helper-registry.js
  85. 23 0
      utils/binary-utils.js
  86. 369 0
      协议架构说明.md
  87. 291 0
      存储访问协议.md

+ 8 - 1
app.json

@@ -2,6 +2,7 @@
   "darkmode": true,
   "pages": [
     "pages/home/home",
+    "pages/communication/communication",
     "pages/params/params",
     "pages/settings/settings"
   ],
@@ -17,10 +18,16 @@
     "list": [
       {
         "pagePath": "pages/home/home",
-        "text": "首页",
+        "text": "连接",
         "iconPath": "assets/tab/home.png",
         "selectedIconPath": "assets/tab/home-active.png"
       },
+      {
+        "pagePath": "pages/communication/communication",
+        "text": "通讯",
+        "iconPath": "assets/tab/control.png",
+        "selectedIconPath": "assets/tab/control-active.png"
+      },
       {
         "pagePath": "pages/params/params",
         "text": "参数",

+ 117 - 20
app.wxss

@@ -270,7 +270,8 @@ page {
 
 .theme-dark .generic-register-row,
 .theme-dark .generic-config-row,
-.theme-dark .generic-struct-section {
+.theme-dark .generic-struct-section,
+.theme-dark .storage-code-info-card {
   border-color: #263241;
 }
 
@@ -302,10 +303,17 @@ page {
 }
 
 .theme-dark .generic-readonly-value,
-.theme-dark .generic-register-unit {
+.theme-dark .generic-register-unit,
+.theme-dark .storage-code-info-model {
   color: #5eead4;
 }
 
+.theme-dark .storage-code-info-chip,
+.theme-dark .storage-code-info-metric,
+.theme-dark .generic-register-display-meta {
+  color: #94a3b8;
+}
+
 .theme-dark .value-input--dirty {
   border-color: #f59e0b;
   background: #2b1d0e;
@@ -1296,15 +1304,84 @@ page {
   transform: rotate(-45deg);
 }
 
+.storage-code-info-card {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 8rpx;
+  margin-bottom: 12rpx;
+  padding: 16rpx 18rpx;
+  border: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.storage-code-info-top,
+.storage-code-info-bottom {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+
+.storage-code-info-top {
+  justify-content: flex-start;
+}
+
+.storage-code-info-bottom {
+  justify-content: space-between;
+  gap: 14rpx;
+}
+
+.storage-code-info-model {
+  width: 100%;
+  min-width: 0;
+  color: #0f8f87;
+  font-size: 28rpx;
+  line-height: 1.25;
+  font-weight: 900;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.storage-code-info-chip {
+  flex: 1;
+  min-width: 120rpx;
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.25;
+  font-weight: 800;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.storage-code-info-metrics {
+  flex: 2;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+
+.storage-code-info-metric {
+  color: #64748b;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 20rpx;
+  line-height: 1.25;
+  font-weight: 900;
+}
+
 .generic-register-row {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 10rpx;
-  min-height: 112rpx;
-  padding: 12rpx 18rpx;
+  gap: 8rpx;
+  min-height: 92rpx;
+  padding: 8rpx 16rpx;
   border-top: 1rpx solid #edf2f7;
-  border-radius: 18rpx;
+  border-radius: 16rpx;
   box-sizing: border-box;
   transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, opacity 0.18s ease;
   transform-origin: center center;
@@ -1333,18 +1410,18 @@ page {
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  gap: 5rpx;
-  width: 34rpx;
-  min-height: 72rpx;
+  gap: 4rpx;
+  width: 30rpx;
+  min-height: 58rpx;
   margin-left: -4rpx;
-  border-radius: 14rpx;
+  border-radius: 12rpx;
   transition: background-color 0.18s ease, transform 0.18s ease;
 }
 
 .generic-register-layout-spacer {
   flex: none;
   width: 12rpx;
-  min-height: 72rpx;
+  min-height: 58rpx;
 }
 
 .generic-register-drag-handle.is-drag-armed {
@@ -1357,7 +1434,7 @@ page {
 }
 
 .generic-register-drag-bar {
-  width: 20rpx;
+  width: 18rpx;
   height: 3rpx;
   border-radius: 999rpx;
   background: #94a3b8;
@@ -1382,13 +1459,13 @@ page {
   flex: 1;
   display: flex;
   flex-direction: column;
-  gap: 6rpx;
+  gap: 3rpx;
 }
 
 .generic-register-name {
   color: #111827;
-  font-size: 26rpx;
-  line-height: 1.35;
+  font-size: 25rpx;
+  line-height: 1.25;
   font-weight: 800;
   word-break: break-all;
 }
@@ -1397,12 +1474,12 @@ page {
   display: flex;
   align-items: center;
   flex-wrap: wrap;
-  gap: 14rpx;
-  margin-top: 6rpx;
+  gap: 10rpx;
+  margin-top: 2rpx;
   color: #64748b;
   font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 21rpx;
-  line-height: 1.35;
+  font-size: 20rpx;
+  line-height: 1.25;
   font-weight: 700;
 }
 
@@ -1415,6 +1492,16 @@ page {
   box-sizing: border-box;
 }
 
+.generic-register-display-meta {
+  margin-top: 6rpx;
+  color: #64748b;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 19rpx;
+  line-height: 1.25;
+  font-weight: 700;
+  text-align: right;
+}
+
 .generic-register-value {
   width: 100%;
   min-width: 0;
@@ -1448,8 +1535,18 @@ page {
   overflow: hidden;
 }
 
+.generic-group-detail-header {
+  padding: 18rpx 18rpx 8rpx;
+  box-sizing: border-box;
+}
+
+.generic-group-detail-header .panel-title {
+  font-size: 30rpx;
+  line-height: 1.25;
+}
+
 .generic-group-detail-meta {
-  padding: 14rpx 18rpx;
+  padding: 0 18rpx 14rpx;
   border-bottom: 1rpx solid #edf2f7;
   color: #64748b;
   font-family: Menlo, Monaco, Consolas, monospace;

+ 0 - 5
domain/generic-modbus/index.js

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

+ 0 - 1398
domain/generic-modbus/model.js

@@ -1,1398 +0,0 @@
-const {
-  formatFixedValue
-} = require('../../utils/number-format.js')
-const {
-  clampInteger,
-  createId,
-  normalizeTextValue,
-  padHex
-} = require('../../utils/base-utils.js')
-const {
-  bytesToWords,
-  getByteFromWord,
-  trimTrailingNullBytes,
-  wordsToBytes
-} = require('../../utils/binary-utils.js')
-
-const MAX_MODBUS_ADDRESS = 0xFFFF
-const MAX_GENERIC_MODBUS_ITEMS = 256
-const DEFAULT_TEXT_BYTE_LENGTH = 32
-const MAX_TEXT_BYTE_LENGTH = 32
-const REGISTER_TYPE_OPTIONS = [
-  {
-    functionCode: 0x03,
-    key: 'holding',
-    label: '保持寄存器',
-    writable: true
-  },
-  {
-    functionCode: 0x01,
-    key: 'coil',
-    label: '线圈',
-    writable: true
-  },
-  {
-    functionCode: 0x02,
-    key: 'discrete',
-    label: '离散输入状态',
-    writable: false
-  },
-  {
-    functionCode: 0x04,
-    key: 'input',
-    label: '输入寄存器',
-    writable: false
-  }
-]
-const DATA_TYPE_OPTIONS = [
-  {
-    byteLength: 1,
-    key: 'int8_t',
-    label: 'int8_t',
-    kind: 'number',
-    wordCount: 1
-  },
-  {
-    byteLength: 1,
-    key: 'uint8_t',
-    label: 'uint8_t',
-    kind: 'number',
-    wordCount: 1
-  },
-  {
-    byteLength: 2,
-    key: 'int16_t',
-    label: 'int16_t',
-    kind: 'number',
-    wordCount: 1
-  },
-  {
-    byteLength: 2,
-    key: 'uint16_t',
-    label: 'uint16_t',
-    kind: 'number',
-    wordCount: 1
-  },
-  {
-    byteLength: 4,
-    key: 'int32_t',
-    label: 'int32_t',
-    kind: 'number',
-    wordCount: 2
-  },
-  {
-    byteLength: 4,
-    key: 'uint32_t',
-    label: 'uint32_t',
-    kind: 'number',
-    wordCount: 2
-  },
-  {
-    byteLength: 4,
-    key: 'float',
-    label: 'float',
-    kind: 'number',
-    wordCount: 2
-  },
-  {
-    byteLength: 32,
-    key: 'utf8',
-    label: 'UTF-8',
-    kind: 'text',
-    maxByteLength: MAX_TEXT_BYTE_LENGTH,
-    wordCount: 16
-  },
-  {
-    byteLength: 32,
-    key: 'ascii',
-    label: 'ASCII',
-    kind: 'text',
-    maxByteLength: MAX_TEXT_BYTE_LENGTH,
-    wordCount: 16
-  },
-  {
-    byteLength: 2,
-    key: 'hex',
-    label: 'HEX',
-    kind: 'hex',
-    wordCount: 1
-  }
-]
-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 SOURCE_REGISTER_FIELDS = [
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceBitOffset',
-  'sourceBitWidth',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSymbolName',
-  'sourceSymbolType'
-]
-const STRUCT_REGISTER_FIELDS = [
-  'bitOffset',
-  'bitWidth',
-  'byteStart',
-  'isBitField',
-  'structByteLength'
-]
-const SOURCE_GROUP_FIELDS = [
-  'addressUnit',
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSegment',
-  'sourceSegmentModule',
-  'sourceSymbolName'
-]
-
-function normalizeAddress(value, fallback = 0) {
-  if (typeof value === 'number') {
-    return Number.isFinite(value) ? clampInteger(value, 0, MAX_MODBUS_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_MODBUS_ADDRESS, fallback) : fallback
-  }
-
-  const numberValue = Number(text)
-  return Number.isFinite(numberValue) ? clampInteger(numberValue, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
-}
-
-function parseConfigAddress(value) {
-  if (typeof value === 'number') {
-    return clampInteger(value, 0, MAX_MODBUS_ADDRESS, 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('寄存器起始地址无效')
-  }
-
-  return parseInt(hexText, 16)
-}
-
-function parseConfigQuantity(value, maxQuantity) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  const quantity = Number(text)
-
-  if (!Number.isInteger(quantity) || quantity < 1 || quantity > maxQuantity) {
-    throw new Error(`寄存器数量需为 1 - ${maxQuantity}`)
-  }
-
-  return quantity
-}
-
-function getRegisterType(typeKey) {
-  return REGISTER_TYPE_OPTIONS.find((item) => item.key === typeKey) || REGISTER_TYPE_OPTIONS[0]
-}
-
-function getRegisterTypeIndex(typeKey) {
-  return Math.max(0, REGISTER_TYPE_OPTIONS.findIndex((item) => item.key === getRegisterType(typeKey).key))
-}
-
-function getDataType(dataType) {
-  return DATA_TYPE_OPTIONS.find((item) => item.key === dataType)
-    || DATA_TYPE_OPTIONS.find((item) => item.key === DEFAULT_DATA_TYPE)
-    || DATA_TYPE_OPTIONS[0]
-}
-
-function getDataTypeIndex(dataType) {
-  return Math.max(0, DATA_TYPE_OPTIONS.findIndex((item) => item.key === getDataType(dataType).key))
-}
-
-function normalizeTextByteLength(value, fallback = DEFAULT_TEXT_BYTE_LENGTH) {
-  const numberValue = Number(value)
-  const rounded = Number.isFinite(numberValue) ? Math.round(numberValue) : fallback
-
-  return Math.min(Math.max(rounded, 1), MAX_TEXT_BYTE_LENGTH)
-}
-
-function alignEvenByteLength(byteLength) {
-  const length = Math.max(1, Math.round(Number(byteLength) || 1))
-
-  return length % 2 === 0 ? length : length + 1
-}
-
-function getRegisterTextByteLength(register = {}) {
-  return normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
-}
-
-function isStructLayout(layout) {
-  return layout === GROUP_LAYOUT_STRUCT
-}
-
-function isBitFieldRegister(register = {}) {
-  return !!register.isBitField
-}
-
-function normalizeBitOffset(value) {
-  const numberValue = Math.floor(Number(value) || 0)
-
-  return Math.min(Math.max(numberValue, 0), 7)
-}
-
-function normalizeBitWidth(value) {
-  const numberValue = Math.round(Number(value) || 1)
-
-  return Math.min(Math.max(numberValue, 1), 32)
-}
-
-function getBitFieldByteLength(register = {}) {
-  const bitOffset = normalizeBitOffset(register.bitOffset)
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-
-  return Math.max(1, Math.ceil((bitOffset + bitWidth) / 8))
-}
-
-function getBitFieldMaxValue(register = {}) {
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-
-  return bitWidth >= 32 ? 0xFFFFFFFF : Math.pow(2, bitWidth) - 1
-}
-
-function getRegisterByteLength(dataType, register = {}) {
-  if (isBitFieldRegister(register)) return getBitFieldByteLength(register)
-
-  const type = getDataType(dataType)
-  if (type.kind === 'text') {
-    const byteLength = getRegisterTextByteLength(register)
-
-    return isStructLayout(register.layout) ? byteLength : alignEvenByteLength(byteLength)
-  }
-
-  return type.byteLength || ((type.wordCount || 1) * 2)
-}
-
-function getRegisterWordCount(dataType, register = {}) {
-  return Math.max(1, Math.ceil(getRegisterByteLength(dataType, register) / 2))
-}
-
-function getByteSpanWordCount(byteOffset, byteLength) {
-  return Math.max(1, Math.ceil((Math.max(0, Number(byteOffset) || 0) + Math.max(1, Number(byteLength) || 1)) / 2))
-}
-
-function getRegisterWordCountAtOffset(dataType, byteOffset, register = {}) {
-  const byteLength = getRegisterByteLength(dataType, register)
-  return getByteSpanWordCount(byteOffset, byteLength)
-}
-
-function getEncodeByteLimit(register) {
-  return isTextRegister(register.dataType) ? getRegisterTextByteLength(register) : getRegisterByteLength(register.dataType, register)
-}
-
-function isTextRegister(dataType) {
-  return getDataType(dataType).kind === 'text'
-}
-
-function isByteRegister(dataType) {
-  const key = getDataType(dataType).key
-
-  return key === 'int8_t' || key === 'uint8_t'
-}
-
-function isBitRegisterType(registerType) {
-  return registerType === 'coil' || registerType === 'discrete'
-}
-
-function isHexRegister(dataType) {
-  return getDataType(dataType).key === 'hex'
-}
-
-function isNumericRegister(dataType) {
-  return getDataType(dataType).kind === 'number'
-}
-
-function supportsRange(dataType) {
-  return isNumericRegister(dataType) || isHexRegister(dataType)
-}
-
-function supportsUnit(dataType) {
-  return isNumericRegister(dataType)
-}
-
-function padWordHex(value) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
-}
-
-function formatRawWordText(words = []) {
-  if (!Array.isArray(words) || !words.length) return '--'
-
-  return words.map((word) => `0x${padWordHex(word)}`).join(' ')
-}
-
-function formatRawByteText(bytes = []) {
-  if (!Array.isArray(bytes) || !bytes.length) return '--'
-
-  return bytes.map((byte) => `0x${(Number(byte) & 0xFF).toString(16).toUpperCase().padStart(2, '0')}`).join(' ')
-}
-
-function formatAddressRange(startAddress, wordCount) {
-  const address = normalizeAddress(startAddress, 0)
-  const count = Math.max(1, Number(wordCount) || 1)
-  const endAddress = address + count - 1
-  const safeEndAddress = Math.min(endAddress, MAX_MODBUS_ADDRESS)
-  const overflowText = endAddress > MAX_MODBUS_ADDRESS ? '+' : ''
-
-  if (count <= 1) return `0x${padWordHex(address)}`
-
-  return `0x${padWordHex(address)}-0x${padWordHex(safeEndAddress)}${overflowText}`
-}
-
-function isByteAddressedGroup(group = {}) {
-  const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
-
-  return group.addressUnit === 'byte'
-    || group.addressUnit === 'bytes'
-    || BYTE_ADDRESS_MEMORY_AREAS.indexOf(memoryArea) >= 0
-}
-
-function formatRegisterAddressText(address, byteOffset, byteLength, registerType) {
-  if (isBitRegisterType(registerType)) return `0x${padHex(address)}`
-  if (byteLength === 1) return `0x${padHex(address)}${byteOffset === 0 ? 'H' : 'L'}`
-
-  return `0x${padHex(address)}`
-}
-
-function formatBitFieldAddressText(address, byteOffset, bitOffset, bitWidth) {
-  const byteText = formatRegisterAddressText(address, byteOffset, 1, DEFAULT_REGISTER_TYPE)
-  const startBit = normalizeBitOffset(bitOffset)
-  const endBit = startBit + normalizeBitWidth(bitWidth) - 1
-
-  return endBit === startBit
-    ? `${byteText}.b${startBit}`
-    : `${byteText}.b${startBit}..${endBit}`
-}
-
-function isAddressRangeOverflow(startAddress, wordCount) {
-  const address = normalizeAddress(startAddress, 0)
-  const count = Math.max(1, Number(wordCount) || 1)
-
-  return address + count - 1 > MAX_MODBUS_ADDRESS
-}
-
-function encodeAsciiBytes(text, byteLimit = 32) {
-  const bytes = []
-  const stringValue = normalizeTextValue(text)
-
-  for (let index = 0; index < stringValue.length; index += 1) {
-    const code = stringValue.charCodeAt(index)
-    if (code > 0x7F) {
-      throw new Error('ASCII 文本只能包含 0x00 - 0x7F 字符')
-    }
-    bytes.push(code)
-    if (bytes.length > byteLimit) break
-  }
-
-  if (bytes.length > byteLimit) {
-    throw new Error(`长文本最长 ${byteLimit} 字节`)
-  }
-
-  return bytes
-}
-
-function encodeUtf8Bytes(text, byteLimit = 32) {
-  const bytes = []
-  const encoded = encodeURIComponent(normalizeTextValue(text))
-
-  for (let index = 0; index < encoded.length; index += 1) {
-    const char = encoded[index]
-    if (char === '%') {
-      const byte = parseInt(encoded.slice(index + 1, index + 3), 16)
-      if (!Number.isFinite(byte)) break
-      bytes.push(byte & 0xFF)
-      index += 2
-    } else {
-      bytes.push(char.charCodeAt(0) & 0xFF)
-    }
-    if (bytes.length > byteLimit) break
-  }
-
-  if (bytes.length > byteLimit) {
-    throw new Error(`长文本最长 ${byteLimit} 字节`)
-  }
-
-  return bytes
-}
-
-function decodeAsciiBytes(bytes = []) {
-  return String.fromCharCode.apply(null, trimTrailingNullBytes(bytes).map((byte) => byte & 0xFF))
-}
-
-function decodeUtf8Bytes(bytes = []) {
-  const trimmed = trimTrailingNullBytes(bytes)
-  if (!trimmed.length) return ''
-
-  let encoded = ''
-
-  trimmed.forEach((byte) => {
-    encoded += `%${(byte & 0xFF).toString(16).padStart(2, '0').toUpperCase()}`
-  })
-
-  try {
-    return decodeURIComponent(encoded)
-  } catch (error) {
-    return decodeAsciiBytes(trimmed)
-  }
-}
-
-function encodeTextBytes(text, dataType, byteLimit = MAX_TEXT_BYTE_LENGTH) {
-  const normalizedType = getDataType(dataType).key
-
-  if (normalizedType === 'ascii') return encodeAsciiBytes(text, byteLimit)
-  return encodeUtf8Bytes(text, byteLimit)
-}
-
-function decodeTextBytes(bytes, dataType) {
-  const normalizedType = getDataType(dataType).key
-
-  return normalizedType === 'ascii'
-    ? decodeAsciiBytes(bytes)
-    : decodeUtf8Bytes(bytes)
-}
-
-function formatIntegerValue(value, dataType) {
-  const type = getDataType(dataType).key
-  const numberValue = Number(value)
-
-  if (!Number.isFinite(numberValue)) return '--'
-  if (type === 'int8_t') return String(((Math.round(numberValue) << 24) >> 24))
-  if (type === 'uint8_t') return String(Math.round(numberValue) & 0xFF)
-  if (type === 'int16_t') return String(((Math.round(numberValue) << 16) >> 16))
-  if (type === 'uint16_t') return String(Math.round(numberValue) & 0xFFFF)
-  if (type === 'int32_t') return String((Math.round(numberValue) | 0))
-  if (type === 'uint32_t') return String(Math.round(numberValue) >>> 0)
-
-  return String(Math.round(numberValue))
-}
-
-function formatHexValue(value) {
-  const numberValue = Number(value)
-  if (!Number.isFinite(numberValue)) return '--'
-
-  return `0x${padWordHex(Math.round(numberValue) & 0xFFFF)}`
-}
-
-function formatFloatValue(value) {
-  return formatFixedValue(value, 6).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
-}
-
-function parseIntegerText(value) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text) return null
-
-  const isHex = /^[-+]?0x[0-9a-f]+$/i.test(text) || /^0x[0-9a-f]+$/i.test(text)
-  const parsed = isHex ? parseInt(text, 16) : Number(text)
-
-  return Number.isFinite(parsed) ? parsed : null
-}
-
-function parseHexText(value) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text) return null
-
-  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
-  if (/^[0-9A-F]{1,4}$/i.test(hexText)) {
-    const parsedHex = parseInt(hexText, 16)
-    return Number.isFinite(parsedHex) ? parsedHex : null
-  }
-
-  return null
-}
-
-function getRegisterValueTypeLabel(dataType) {
-  return getDataType(dataType).label
-}
-
-function getMaxQuantity() {
-  return MAX_GENERIC_MODBUS_ITEMS
-}
-
-function parseCoilValue(value) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text || text === '--') return null
-  if (['1', 'true', 'TRUE', 'on', 'ON', '开'].includes(text)) return 1
-  if (['0', 'false', 'FALSE', 'off', 'OFF', '关'].includes(text)) return 0
-
-  const coilValue = Number(text)
-  return Number.isFinite(coilValue) ? (coilValue ? 1 : 0) : null
-}
-
-function getNumericRange(dataType) {
-  const type = getDataType(dataType).key
-
-  if (type === 'int8_t') return { max: 127, min: -128 }
-  if (type === 'uint8_t') return { max: 0xFF, min: 0 }
-  if (type === 'int16_t') return { max: 32767, min: -32768 }
-  if (type === 'uint16_t') return { max: 0xFFFF, min: 0 }
-  if (type === 'int32_t') return { max: 2147483647, min: -2147483648 }
-  if (type === 'uint32_t') return { max: 0xFFFFFFFF, min: 0 }
-  if (type === 'hex') return { max: 0xFFFF, min: 0 }
-
-  return { max: Number.POSITIVE_INFINITY, min: Number.NEGATIVE_INFINITY }
-}
-
-function parseNumberText(value, dataType) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text || text === '--') return null
-
-  if (getDataType(dataType).key === 'float') {
-    const parsed = Number(text)
-    return Number.isFinite(parsed) ? parsed : null
-  }
-  if (isHexRegister(dataType)) return parseHexText(text)
-
-  return parseIntegerText(text)
-}
-
-function parseRangeBoundary(value, dataType, label) {
-  const text = String(value === undefined || value === null ? '' : value).trim()
-  if (!text) return null
-
-  const parsed = parseNumberText(text, dataType)
-  if (parsed === null) {
-    throw new Error(`${label}无效`)
-  }
-
-  return parsed
-}
-
-function validateNumericValue(register, value) {
-  const dataType = getDataType(register.dataType).key
-  const range = getNumericRange(dataType)
-  const numberValue = Number(value)
-  if (!Number.isFinite(numberValue)) return false
-
-  if (dataType !== 'float' && Math.round(numberValue) !== numberValue) {
-    throw new Error(`${register.name || '寄存器'} 需要整数`)
-  }
-  if (numberValue < range.min || numberValue > range.max) {
-    throw new Error(`${register.name || '寄存器'} 超出 ${dataType} 范围`)
-  }
-
-  const minValue = parseRangeBoundary(register.minValue, dataType, `${register.name || '寄存器'} 最小值`)
-  const maxValue = parseRangeBoundary(register.maxValue, dataType, `${register.name || '寄存器'} 最大值`)
-  if (minValue !== null && numberValue < minValue) {
-    throw new Error(`${register.name || '寄存器'} 小于限制最小值`)
-  }
-  if (maxValue !== null && numberValue > maxValue) {
-    throw new Error(`${register.name || '寄存器'} 大于限制最大值`)
-  }
-
-  return true
-}
-
-function floatToWords(value) {
-  const buffer = new ArrayBuffer(4)
-  const view = new DataView(buffer)
-
-  view.setFloat32(0, Number(value), false)
-
-  return [view.getUint16(0, false), view.getUint16(2, false)]
-}
-
-function wordsToFloat(words) {
-  if (!Array.isArray(words) || words.length < 2) return null
-
-  const buffer = new ArrayBuffer(4)
-  const view = new DataView(buffer)
-
-  view.setUint16(0, Number(words[0]) & 0xFFFF, false)
-  view.setUint16(2, Number(words[1]) & 0xFFFF, false)
-
-  return view.getFloat32(0, false)
-}
-
-function floatToBytes(value) {
-  const buffer = new ArrayBuffer(4)
-  const view = new DataView(buffer)
-
-  view.setFloat32(0, Number(value), false)
-
-  return [
-    view.getUint8(0),
-    view.getUint8(1),
-    view.getUint8(2),
-    view.getUint8(3)
-  ]
-}
-
-function bytesToFloatValue(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 4) return null
-
-  const buffer = new ArrayBuffer(4)
-  const view = new DataView(buffer)
-
-  for (let index = 0; index < 4; index += 1) {
-    view.setUint8(index, Number(bytes[index]) & 0xFF)
-  }
-
-  return view.getFloat32(0, false)
-}
-
-function unsignedIntegerToBytes(value, byteLength) {
-  let numberValue = Math.round(Number(value) || 0)
-  const bytes = []
-
-  if (numberValue < 0) {
-    numberValue += Math.pow(2, byteLength * 8)
-  }
-
-  for (let index = byteLength - 1; index >= 0; index -= 1) {
-    bytes[index] = numberValue & 0xFF
-    numberValue = Math.floor(numberValue / 0x100)
-  }
-
-  return bytes
-}
-
-function bytesToUnsignedInteger(bytes) {
-  return bytes.reduce((value, byte) => ((value * 0x100) + (Number(byte) & 0xFF)), 0)
-}
-
-function bytesToSignedInteger(bytes) {
-  const unsignedValue = bytesToUnsignedInteger(bytes)
-  const signLimit = Math.pow(2, bytes.length * 8 - 1)
-  const fullRange = Math.pow(2, bytes.length * 8)
-
-  return unsignedValue >= signLimit ? unsignedValue - fullRange : unsignedValue
-}
-
-function parseBitFieldValue(register, valueText) {
-  const text = normalizeTextValue(valueText).trim()
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-  let parsed = parseNumberText(text, 'uint32_t')
-  const maxValue = getBitFieldMaxValue(register)
-
-  if (parsed === null && bitWidth === 1) parsed = parseCoilValue(text)
-  if (parsed === null) return null
-  if (Math.round(parsed) !== parsed || parsed < 0 || parsed > maxValue) {
-    throw new Error(`${register.name || '位域'} 超出 0 - ${maxValue} 范围`)
-  }
-
-  return Math.round(parsed)
-}
-
-function decodeBitFieldBytes(register, bytes = []) {
-  const bitOffset = normalizeBitOffset(register.bitOffset)
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-  let byteIndex = 0
-  let currentBitOffset = bitOffset
-  let multiplier = 1
-  let remaining = bitWidth
-  let value = 0
-
-  while (remaining > 0 && byteIndex < bytes.length) {
-    const take = Math.min(8 - currentBitOffset, remaining)
-    const mask = (1 << take) - 1
-    const part = ((Number(bytes[byteIndex]) & 0xFF) >> currentBitOffset) & mask
-
-    value += part * multiplier
-    multiplier *= Math.pow(2, take)
-    remaining -= take
-    byteIndex += 1
-    currentBitOffset = 0
-  }
-
-  return remaining > 0 ? null : value
-}
-
-function encodeBitFieldIntoBytes(register, bytes, byteStart = 0) {
-  const valueText = normalizeTextValue(register.inputValue)
-  let value = parseBitFieldValue(register, valueText)
-  const bitOffset = normalizeBitOffset(register.bitOffset)
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-  let byteIndex = Math.max(0, Math.floor(Number(byteStart) || 0))
-  let currentBitOffset = bitOffset
-  let remaining = bitWidth
-
-  if (value === null) return null
-
-  while (remaining > 0) {
-    const take = Math.min(8 - currentBitOffset, remaining)
-    const mask = (1 << take) - 1
-    const shiftedMask = (mask << currentBitOffset) & 0xFF
-    const part = value & mask
-
-    if (byteIndex >= bytes.length) return null
-    bytes[byteIndex] = ((Number(bytes[byteIndex]) & 0xFF) & (~shiftedMask & 0xFF))
-      | ((part << currentBitOffset) & shiftedMask)
-
-    value = Math.floor(value / Math.pow(2, take))
-    remaining -= take
-    byteIndex += 1
-    currentBitOffset = 0
-  }
-
-  return bytes
-}
-
-function encodeBitFieldBytes(register) {
-  const bytes = Array.from({ length: getBitFieldByteLength(register) }, () => 0)
-
-  return encodeBitFieldIntoBytes(register, bytes, 0)
-}
-
-function getRegisterDataBytes(register, words) {
-  const dataType = getDataType(register.dataType).key
-  const byteLength = getRegisterByteLength(dataType, register)
-  const byteOffset = Math.max(0, Math.floor(Number(register.byteOffset) || 0))
-  const sourceBytes = wordsToBytes(words, Math.max(0, (Array.isArray(words) ? words.length : 0) * 2))
-
-  return sourceBytes.slice(byteOffset, byteOffset + byteLength)
-}
-
-function encodeRegisterBytes(register) {
-  const dataType = getDataType(register.dataType).key
-  const valueText = normalizeTextValue(register.inputValue)
-  const byteLength = getRegisterByteLength(dataType, register)
-
-  if (isBitFieldRegister(register)) {
-    return encodeBitFieldBytes(register)
-  }
-
-  if (isTextRegister(dataType)) {
-    const byteLimit = getEncodeByteLimit(register)
-    const bytes = encodeTextBytes(valueText, dataType, byteLimit)
-    const paddedBytes = bytes.slice()
-
-    while (paddedBytes.length < byteLength) {
-      paddedBytes.push(0)
-    }
-
-    return paddedBytes.slice(0, byteLength)
-  }
-
-  const numberValue = parseNumberText(valueText, dataType)
-  if (numberValue === null) return null
-  validateNumericValue(register, numberValue)
-
-  if (dataType === 'float') return floatToBytes(numberValue)
-
-  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)
-  }
-  if (dataType === 'int32_t' || dataType === 'uint32_t') {
-    return unsignedIntegerToBytes(rounded, 4)
-  }
-
-  return unsignedIntegerToBytes(rounded, byteLength)
-}
-
-function encodeRegisterWords(register) {
-  const dataType = getDataType(register.dataType).key
-  const bytes = encodeRegisterBytes(register)
-
-  if (!Array.isArray(bytes)) return null
-  if (isByteRegister(dataType)) return [bytes[0] & 0xFF]
-
-  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
-}
-
-function decodeRegisterValue(register, words) {
-  const dataType = getDataType(register.dataType).key
-
-  if (!Array.isArray(words) || words.length < getRegisterWordCountAtOffset(dataType, register.byteOffset || 0, register)) return null
-
-  const bytes = getRegisterDataBytes(register, words)
-  const byteLength = getRegisterByteLength(dataType, register)
-  if (bytes.length < byteLength) return null
-
-  if (isBitFieldRegister(register)) {
-    return decodeBitFieldBytes(register, bytes)
-  }
-
-  if (isTextRegister(dataType)) {
-    return decodeTextBytes(bytes.slice(0, getEncodeByteLimit(register)), dataType)
-  }
-  if (dataType === 'float') {
-    return bytesToFloatValue(bytes)
-  }
-  if (dataType === 'int8_t') {
-    const byteValue = bytes[0] & 0xFF
-    return byteValue & 0x80 ? byteValue - 0x100 : byteValue
-  }
-  if (dataType === 'uint8_t') {
-    return bytes[0] & 0xFF
-  }
-  if (dataType === 'int16_t') {
-    return bytesToSignedInteger(bytes.slice(0, 2))
-  }
-  if (dataType === 'uint16_t') {
-    return bytesToUnsignedInteger(bytes.slice(0, 2))
-  }
-  if (dataType === 'hex') {
-    return bytesToUnsignedInteger(bytes.slice(0, 2))
-  }
-
-  if (dataType === 'int32_t') {
-    return bytesToSignedInteger(bytes.slice(0, 4))
-  }
-
-  return bytesToUnsignedInteger(bytes.slice(0, 4))
-}
-
-function formatRegisterValue(register, rawValue) {
-  if (rawValue === null || rawValue === undefined) return '--'
-
-  const dataType = getDataType(register.dataType).key
-  if (isTextRegister(dataType)) return normalizeTextValue(rawValue)
-  if (dataType === 'hex') return formatHexValue(rawValue)
-  if (dataType === 'float') return formatFloatValue(rawValue)
-
-  return formatIntegerValue(rawValue, dataType)
-}
-
-function formatCoilDisplayValue(value) {
-  return Number(value) ? '1' : '0'
-}
-
-function getRegisterSavedValue(register) {
-  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
-  if (register.value !== undefined && register.value !== null) return normalizeTextValue(register.value)
-
-  return null
-}
-
-function normalizeRegisterDataType(register, registerType) {
-  if (isBitRegisterType(registerType)) return DEFAULT_DATA_TYPE
-
-  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)}`
-    : ''
-  const parts = [
-    register.sourceMemoryArea,
-    register.sourceAddressText,
-    bitText,
-    register.sourceSymbolType && register.sourceSymbolType !== '---' ? register.sourceSymbolType : ''
-  ].filter(Boolean)
-
-  return parts.join(' · ')
-}
-
-function createGroupSourceMetaText(group) {
-  const parts = [
-    group.sourceMemoryArea,
-    group.sourceAddressText,
-    group.sourceSymbolName,
-    group.sourceSegmentModule
-  ].filter(Boolean)
-
-  return parts.join(' · ')
-}
-
-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 byteAddressed = isByteAddressedGroup(group)
-  const dataType = normalizeRegisterDataType(register, registerType)
-  const bitOffset = normalizeBitOffset(register.bitOffset)
-  const bitWidth = normalizeBitWidth(register.bitWidth)
-  const isBitField = !isBitRegisterType(registerType) && isBitFieldRegister(register)
-  const textByteLength = isTextRegister(dataType)
-    ? normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
-    : ''
-  const defaultValue = normalizeTextValue(register.defaultValue)
-  const savedValue = getRegisterSavedValue(register)
-  const inputValue = savedValue === null ? defaultValue : savedValue
-  const rawValue = register.rawValue === undefined ? null : register.rawValue
-  const byteLength = isBitRegisterType(registerType)
-    ? 1
-    : getRegisterByteLength(dataType, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
-  const registerCount = isBitRegisterType(registerType)
-    ? 1
-    : getRegisterWordCountAtOffset(dataType, byteOffset, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
-  const canShowUnit = !isBitRegisterType(registerType) && !isBitField && supportsUnit(dataType)
-  const rawWords = Array.isArray(register.rawWords)
-    ? register.rawWords.slice(0, registerCount).map((word) => Number(word) & 0xFFFF)
-    : []
-  const rawBytes = Array.isArray(register.rawBytes)
-    ? register.rawBytes.slice(0, byteLength).map((byte) => Number(byte) & 0xFF)
-    : []
-  const rawValueText = rawValue === null
-    ? '--'
-    : (isBitRegisterType(registerType)
-      ? formatCoilDisplayValue(rawValue)
-      : (byteAddressed ? formatRawByteText(rawBytes) : formatRawWordText(rawWords)))
-  const displayValue = rawValue === null
-    ? (inputValue.trim() ? inputValue : '--')
-    : formatRegisterValue({ ...register, dataType, byteOffset }, rawValue)
-
-  return {
-    address,
-    addressRangeText: isBitRegisterType(registerType)
-      ? `0x${padHex(address)}`
-      : (byteAddressed
-        ? formatAddressRange(address, Math.max(1, byteLength))
-        : formatAddressRange(address, registerCount)),
-    addressText: isBitField
-      ? (byteAddressed
-        ? `${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))
-        : formatRegisterAddressText(address, byteOffset, byteLength, registerType)),
-    bitOffset: isBitField ? bitOffset : '',
-    bitWidth: isBitField ? bitWidth : '',
-    byteLength,
-    byteLengthText: isBitRegisterType(registerType)
-      ? '1bit'
-      : (isBitField
-        ? (bitWidth === 1 ? `1bit/占${byteLength}B` : `${bitWidth}bit/占${byteLength}B`)
-        : (textByteLength && textByteLength !== byteLength ? `${textByteLength}B/占${byteLength}B` : `${byteLength}B`)),
-    byteStart: Math.max(0, Math.floor(Number(register.byteStart) || 0)),
-    dataType,
-    dataTypeIndex: getDataTypeIndex(dataType),
-    dataTypeText: getRegisterValueTypeLabel(dataType),
-    defaultValue,
-    displayValue,
-    id: register.id || createId('gm-reg'),
-    inputType: isTextRegister(dataType) ? 'text' : 'text',
-    inputValue,
-    isStructField: layout === GROUP_LAYOUT_STRUCT || !!register.isStructField,
-    layout,
-    isBitField,
-    isDirty: !!register.isDirty,
-    maxValue: normalizeTextValue(register.maxValue),
-    minValue: normalizeTextValue(register.minValue),
-    name: register.name || `寄存器 ${index + 1}`,
-    rawValue,
-    rawValueText,
-    rawBytes,
-    rawWords,
-    registerCount,
-    byteOffset,
-    registerType,
-    showDataType: !isBitRegisterType(registerType),
-    showRange: !isBitRegisterType(registerType) && !isBitField && supportsRange(dataType),
-    showTextLength: !isBitRegisterType(registerType) && isTextRegister(dataType),
-    showUnit: canShowUnit,
-    textByteLength,
-    unit: canShowUnit ? normalizeTextValue(register.unit).trim() : '',
-    structByteLength: register.structByteLength,
-    remark: register.remark || '',
-    ...pickFields(register, SOURCE_REGISTER_FIELDS),
-    sourceMetaText: createRegisterSourceMetaText(register)
-  }
-}
-
-function normalizeGroup(group) {
-  const registerType = getRegisterType(group.registerType || group.type || DEFAULT_REGISTER_TYPE)
-  const startAddress = normalizeAddress(group.startAddress, 0)
-  const maxQuantity = getMaxQuantity(registerType.key)
-  const sourceRegisters = Array.isArray(group.registers) ? group.registers : []
-  const layout = isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER
-  const byteAddressed = isByteAddressedGroup(group)
-  const hasExplicitQuantity = group.quantity !== undefined && group.quantity !== null && group.quantity !== ''
-  const quantity = hasExplicitQuantity
-    ? clampInteger(group.quantity, 1, maxQuantity, 1)
-    : clampInteger(sourceRegisters.length || 1, 1, maxQuantity, 1)
-  const baseGroup = {
-    deleteVisible: !!group.deleteVisible,
-    expanded: group.expanded === true,
-    id: group.id || createId('gm-group'),
-    isStructLayout: layout === GROUP_LAYOUT_STRUCT,
-    layout,
-    name: String(group.name || group.groupName || '寄存器组').trim() || '寄存器组',
-    quantity,
-    registerType: registerType.key,
-    startAddress,
-    touchStartX: 0,
-    ...pickFields(group, SOURCE_GROUP_FIELDS)
-  }
-  const registers = []
-  let nextAddress = startAddress
-  let nextByteOffset = 0
-
-  for (let index = 0; index < quantity; index += 1) {
-    const sourceRegister = sourceRegisters[index] || {}
-    let normalizedSourceRegister = sourceRegister
-    const dataType = normalizeRegisterDataType(sourceRegister, baseGroup.registerType)
-    const textByteLength = isTextRegister(dataType)
-      ? normalizeTextByteLength(sourceRegister.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
-      : ''
-    const isBitRegister = isBitRegisterType(baseGroup.registerType)
-    let address = startAddress + index
-    let byteOffset = 0
-
-    if (!isBitRegister) {
-      const explicitByteStart = Number(sourceRegister.byteStart)
-      const hasExplicitByteStart = layout === GROUP_LAYOUT_STRUCT && Number.isFinite(explicitByteStart)
-      const byteLength = getRegisterByteLength(dataType, { ...sourceRegister, layout, textByteLength })
-      if (layout !== GROUP_LAYOUT_STRUCT && !isByteRegister(dataType) && nextByteOffset % 2 !== 0) {
-        nextByteOffset += 1
-      }
-
-      const currentByteStart = hasExplicitByteStart
-        ? Math.max(0, Math.floor(explicitByteStart))
-        : nextByteOffset
-
-      address = byteAddressed ? startAddress + currentByteStart : startAddress + Math.floor(currentByteStart / 2)
-      byteOffset = byteAddressed ? 0 : currentByteStart % 2
-      normalizedSourceRegister = {
-        ...sourceRegister,
-        byteStart: currentByteStart
-      }
-      nextByteOffset = Math.max(nextByteOffset, currentByteStart + byteLength)
-    }
-
-    const register = normalizeRegister(normalizedSourceRegister, baseGroup, index, address, byteOffset)
-    registers.push(register)
-    if (isBitRegister) nextAddress += register.registerCount
-  }
-
-  const byteLength = isBitRegisterType(baseGroup.registerType)
-    ? Math.max(1, nextAddress - startAddress)
-    : Math.max(1, nextByteOffset)
-  const paddedByteLength = isBitRegisterType(baseGroup.registerType)
-    ? byteLength
-    : (byteAddressed ? byteLength : alignEvenByteLength(byteLength))
-  const wordQuantity = isBitRegisterType(baseGroup.registerType)
-    ? 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 endAddress = startAddress + addressSpan - 1
-
-  return {
-    ...baseGroup,
-    addressRangeText: formatAddressRange(startAddress, addressSpan),
-    addressOverflow,
-    addressWarningText: addressOverflow ? '地址超出 0xFFFF' : '',
-    endAddressText: addressOverflow ? `0x${padHex(MAX_MODBUS_ADDRESS)}+` : `0x${padHex(endAddress)}`,
-    functionCode: registerType.functionCode,
-    isReadOnly: !registerType.writable,
-    byteLength,
-    maxQuantity,
-    registerTypeIndex: getRegisterTypeIndex(registerType.key),
-    registerTypeText: registerType.label,
-    registers,
-    paddedByteLength,
-    sourceMetaText: createGroupSourceMetaText(baseGroup),
-    startAddressText: `0x${padHex(startAddress)}`,
-    wordQuantity,
-    writable: registerType.writable
-  }
-}
-
-function normalizeGroupConfig(config = {}) {
-  const registerType = config.registerTypeIndex !== undefined && config.registerTypeIndex !== null
-    ? (REGISTER_TYPE_OPTIONS[Number(config.registerTypeIndex)] || REGISTER_TYPE_OPTIONS[0])
-    : getRegisterType(config.registerType || config.type || DEFAULT_REGISTER_TYPE)
-  const maxQuantity = getMaxQuantity(registerType.key)
-
-  return {
-    layout: isStructLayout(config.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
-    name: String(config.name || config.groupName || '寄存器组').trim() || '寄存器组',
-    quantity: parseConfigQuantity(config.quantity, maxQuantity),
-    registerType: registerType.key,
-    startAddress: parseConfigAddress(config.startAddress)
-  }
-}
-
-function getRegisterJsonValue(register) {
-  if (register.inputValue !== undefined && register.inputValue !== null) {
-    return normalizeTextValue(register.inputValue)
-  }
-
-  if (register.defaultValue !== undefined && register.defaultValue !== null) {
-    return normalizeTextValue(register.defaultValue)
-  }
-
-  if (register.displayValue !== undefined && register.displayValue !== null && register.displayValue !== '--') {
-    return normalizeTextValue(register.displayValue)
-  }
-
-  return ''
-}
-
-function normalizeImportedRegisterDataType(register) {
-  const dataType = register.dataType || register.type || DEFAULT_DATA_TYPE
-
-  return getDataType(dataType).key
-}
-
-function cloneImportedGroup(group) {
-  return {
-    layout: isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
-    name: group.name,
-    quantity: group.quantity,
-    registerType: group.registerType || group.type || DEFAULT_REGISTER_TYPE,
-    registers: (Array.isArray(group.registers) ? group.registers : []).map((register) => ({
-      dataType: normalizeImportedRegisterDataType(register),
-      defaultValue: register.defaultValue,
-      inputValue: register.inputValue,
-      maxValue: register.maxValue,
-      minValue: register.minValue,
-      name: register.name,
-      isStructField: !!register.isStructField,
-      textByteLength: register.textByteLength,
-      remark: register.remark,
-      unit: register.unit,
-      value: register.value,
-      ...pickFields(register, STRUCT_REGISTER_FIELDS),
-      ...pickFields(register, SOURCE_REGISTER_FIELDS)
-    })),
-    startAddress: group.startAddress,
-    ...pickFields(group, SOURCE_GROUP_FIELDS)
-  }
-}
-
-function splitWordSpans(startAddress, quantity, maxQuantity) {
-  const spans = []
-  let address = normalizeAddress(startAddress, 0)
-  let remaining = Math.max(0, Math.floor(Number(quantity) || 0))
-
-  while (remaining > 0) {
-    const spanQuantity = Math.min(remaining, maxQuantity)
-
-    spans.push({
-      address,
-      quantity: spanQuantity
-    })
-
-    address += spanQuantity
-    remaining -= spanQuantity
-  }
-
-  return spans
-}
-
-function getRegisterWriteValueText(register) {
-  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
-  if (register.defaultValue !== undefined && register.defaultValue !== null) return normalizeTextValue(register.defaultValue)
-
-  return ''
-}
-
-function getRegisterWordsFromWordCache(register, wordCache) {
-  const words = []
-  for (let offset = 0; offset < register.registerCount; offset += 1) {
-    const word = wordCache[register.address + offset]
-    if (word === undefined) return null
-    words.push(word)
-  }
-
-  return words
-}
-
-function getRegisterBytesFromByteCache(register, byteCache) {
-  const bytes = []
-  for (let offset = 0; offset < register.byteLength; offset += 1) {
-    const byte = byteCache[register.address + offset]
-    if (byte === undefined) return null
-    bytes.push(Number(byte) & 0xFF)
-  }
-
-  return bytes
-}
-
-function getRegisterWordsFromByteCache(register, byteCache) {
-  const bytes = getRegisterBytesFromByteCache(register, byteCache)
-  if (!bytes) return null
-
-  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
-}
-
-function decodeRegisterFromBytes(register, bytes) {
-  if (!Array.isArray(bytes) || bytes.length < register.byteLength) return null
-
-  return decodeRegisterValue(
-    {
-      ...register,
-      byteOffset: 0
-    },
-    bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
-  )
-}
-
-function decodeRegisterFromWordCache(register, wordCache) {
-  const words = getRegisterWordsFromWordCache(register, wordCache)
-  if (!words) return null
-
-  return decodeRegisterValue(register, words)
-}
-
-function decodeRegisterFromByteCache(register, byteCache) {
-  const bytes = getRegisterBytesFromByteCache(register, byteCache)
-  if (!bytes) return null
-
-  return decodeRegisterFromBytes(register, bytes)
-}
-
-function getRegisterEncodedWords(register) {
-  return encodeRegisterWords({
-    ...register,
-    inputValue: getRegisterWriteValueText(register)
-  })
-}
-
-function getRegisterEncodedBytes(register) {
-  return encodeRegisterBytes({
-    ...register,
-    inputValue: getRegisterWriteValueText(register)
-  })
-}
-
-function getRegisterByteStart(register, groupStartAddress = 0) {
-  if (Number.isFinite(Number(register.byteStart))) {
-    return Math.max(0, Math.floor(Number(register.byteStart)))
-  }
-
-  return Math.max(0, ((Number(register.address) || 0) - (Number(groupStartAddress) || 0)) * 2 + (Number(register.byteOffset) || 0))
-}
-
-function getGroupEncodedBytes(group, baseBytes = null) {
-  const byteLength = Math.max(2, Number(group && group.paddedByteLength) || ((Number(group && group.wordQuantity) || 1) * 2))
-  const bytes = Array.isArray(baseBytes)
-    ? baseBytes.slice(0, byteLength).map((byte) => Number(byte) & 0xFF)
-    : []
-  const registers = Array.isArray(group && group.registers) ? group.registers : []
-
-  while (bytes.length < byteLength) {
-    bytes.push(0)
-  }
-
-  registers.forEach((register) => {
-    const byteStart = getRegisterByteStart(register, group.startAddress)
-
-    if (isBitFieldRegister(register)) {
-      const encodedBytes = encodeBitFieldIntoBytes(
-        {
-          ...register,
-          inputValue: getRegisterWriteValueText(register)
-        },
-        bytes,
-        byteStart
-      )
-
-      if (!Array.isArray(encodedBytes)) {
-        throw new Error(`${register.name || '位域'} 没有有效写入值`)
-      }
-      return
-    }
-
-    const registerBytes = encodeRegisterBytes(register)
-    if (!Array.isArray(registerBytes) || !registerBytes.length) {
-      throw new Error(`${register.name || '寄存器'} 没有有效写入值`)
-    }
-
-    for (let offset = 0; offset < register.byteLength; offset += 1) {
-      if (byteStart + offset < bytes.length) {
-        bytes[byteStart + offset] = Number(registerBytes[offset] || 0) & 0xFF
-      }
-    }
-  })
-
-  return bytes
-}
-
-function getGroupEncodedWords(group, baseBytes = null) {
-  return bytesToWords(getGroupEncodedBytes(group, baseBytes))
-}
-
-function validateRegisterValue(register, value) {
-  const valueText = normalizeTextValue(
-    value === undefined || value === null ? getRegisterWriteValueText(register) : value
-  ).trim()
-  if (!valueText || valueText === '--') return true
-
-  if (registerTypeIsBit(register)) {
-    if (parseCoilValue(valueText) === null) {
-      throw new Error(`${register.name || '线圈'} 只能填写 0 或 1`)
-    }
-    return true
-  }
-
-  if (isBitFieldRegister(register)) {
-    if (parseBitFieldValue(register, valueText) === null) {
-      throw new Error(`${register.name || '位域'} 输入值无效`)
-    }
-    return true
-  }
-
-  const dataType = getDataType(register.dataType).key
-  if (isTextRegister(dataType)) {
-    encodeTextBytes(valueText, dataType, getEncodeByteLimit(register))
-    return true
-  }
-
-  const numberValue = parseNumberText(valueText, dataType)
-  if (numberValue === null) {
-    throw new Error(`${register.name || '寄存器'} 输入值无效`)
-  }
-
-  return validateNumericValue(register, numberValue)
-}
-
-function registerTypeIsBit(register) {
-  return !!register && isBitRegisterType(register.registerType)
-}
-
-module.exports = {
-  DATA_TYPE_OPTIONS,
-  DEFAULT_DATA_TYPE,
-  DEFAULT_REGISTER_TYPE,
-  GROUP_LAYOUT_REGISTER,
-  GROUP_LAYOUT_STRUCT,
-  MAX_MODBUS_ADDRESS,
-  REGISTER_TYPE_OPTIONS,
-  cloneImportedGroup,
-  decodeRegisterFromByteCache,
-  decodeRegisterFromWordCache,
-  decodeRegisterValue,
-  formatCoilDisplayValue,
-  formatRegisterValue,
-  getDataType,
-  getRegisterEncodedBytes,
-  getRegisterEncodedWords,
-  getGroupEncodedBytes,
-  getGroupEncodedWords,
-  getRegisterWordCount,
-  getRegisterJsonValue,
-  getRegisterBytesFromByteCache,
-  getRegisterWordsFromByteCache,
-  getRegisterWordsFromWordCache,
-  getRegisterWriteValueText,
-  isAddressRangeOverflow,
-  isBitRegisterType,
-  isByteRegister,
-  isTextRegister,
-  normalizeGroup,
-  normalizeGroupConfig,
-  normalizeRegister,
-  parseCoilValue,
-  registerTypeIsBit,
-  splitWordSpans,
-  validateRegisterValue
-}

+ 0 - 529
domain/generic-modbus/struct-parser.js

@@ -1,529 +0,0 @@
-const TYPE_ALIASES = {
-  bit: 'uint8_t',
-  bool: 'uint8_t',
-  char: 'int8_t',
-  double: 'float',
-  float: 'float',
-  int: 'int16_t',
-  int8: 'int8_t',
-  int8_t: 'int8_t',
-  int16: 'int16_t',
-  int16_t: 'int16_t',
-  int32: 'int32_t',
-  int32_t: 'int32_t',
-  long: 'int32_t',
-  short: 'int16_t',
-  'signed char': 'int8_t',
-  'signed int': 'int16_t',
-  'signed long': 'int32_t',
-  'signed short': 'int16_t',
-  uint8: 'uint8_t',
-  uint8_t: 'uint8_t',
-  uint16: 'uint16_t',
-  uint16_t: 'uint16_t',
-  uint32: 'uint32_t',
-  uint32_t: 'uint32_t',
-  'unsigned char': 'uint8_t',
-  'unsigned int': 'uint16_t',
-  'unsigned long': 'uint32_t',
-  'unsigned short': 'uint16_t'
-}
-
-const TYPE_QUALIFIERS = {
-  _I: true,
-  _IO: true,
-  _O: true,
-  const: true,
-  extern: true,
-  register: true,
-  static: true,
-  volatile: true
-}
-
-const STRUCT_PATTERNS = [
-  /typedef\s+struct(?:\s+[A-Za-z_]\w*)?\s*\{([\s\S]*?)\}\s*([A-Za-z_]\w*)\s*;/g,
-  /struct\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}\s*;/g
-]
-const STRUCT_VARIABLE_QUALIFIERS = {
-  code: true,
-  const: true,
-  data: true,
-  extern: true,
-  idata: true,
-  pdata: true,
-  static: true,
-  volatile: true,
-  xdata: true
-}
-
-function normalizeLookupName(value) {
-  return String(value || '')
-    .replace(/^_+/, '')
-    .replace(/[^A-Za-z0-9]/g, '')
-    .toLowerCase()
-}
-
-function stripComments(source) {
-  return String(source || '')
-    .replace(/\/\*[\s\S]*?\*\//g, '')
-    .replace(/\/\/.*$/gm, '')
-}
-
-function normalizeTypeText(typeText) {
-  return String(typeText || '')
-    .replace(/\*/g, ' ')
-    .replace(/\s+/g, ' ')
-    .trim()
-}
-
-function createAliasMap(source) {
-  const aliases = {
-    ...TYPE_ALIASES
-  }
-  const definePattern = /^\s*#\s*define\s+([A-Za-z_]\w*)\s+([A-Za-z_]\w*)\s*$/gm
-  let defineMatch
-
-  while ((defineMatch = definePattern.exec(source))) {
-    const name = defineMatch[1]
-    const value = defineMatch[2]
-    if (aliases[value]) aliases[name] = aliases[value]
-  }
-
-  const typedefPattern = /typedef\s+(?!struct\b)([^;{}]+?)\s+([A-Za-z_]\w*)\s*;/g
-  let typedefMatch
-
-  while ((typedefMatch = typedefPattern.exec(source))) {
-    const resolvedType = resolveType(typedefMatch[1], aliases)
-    if (resolvedType) aliases[typedefMatch[2]] = resolvedType
-  }
-
-  const typedefStructPattern = /typedef\s+struct(?:\s+([A-Za-z_]\w*))?\s*\{[\s\S]*?\}\s*([A-Za-z_]\w*)\s*;/g
-  let structTypedefMatch
-
-  while ((structTypedefMatch = typedefStructPattern.exec(source))) {
-    const tagName = structTypedefMatch[1]
-    const typedefName = structTypedefMatch[2]
-    if (tagName && typedefName) aliases[`struct ${tagName}`] = typedefName
-  }
-
-  return aliases
-}
-
-function resolveType(typeText, aliases) {
-  const normalized = normalizeTypeText(typeText)
-  if (!normalized) return ''
-
-  const compact = normalized
-    .split(/\s+/)
-    .filter((token) => !TYPE_QUALIFIERS[token])
-    .join(' ')
-    .trim()
-
-  if (!compact || /^struct\b/.test(compact) || /^enum\b/.test(compact) || compact.indexOf('*') >= 0) {
-    return ''
-  }
-
-  if (aliases[compact]) return aliases[compact]
-
-  const tokens = compact.split(/\s+/).filter(Boolean)
-  for (const token of tokens) {
-    if (aliases[token]) return aliases[token]
-  }
-
-  return ''
-}
-
-function findStruct(source) {
-  for (const pattern of STRUCT_PATTERNS) {
-    pattern.lastIndex = 0
-    const match = pattern.exec(source)
-    if (!match) continue
-
-    if (pattern === STRUCT_PATTERNS[0]) {
-      return {
-        body: match[1],
-        name: match[2]
-      }
-    }
-
-    return {
-      body: match[2],
-      name: match[1]
-    }
-  }
-
-  return null
-}
-
-function findStructs(source) {
-  const structs = []
-
-  STRUCT_PATTERNS.forEach((pattern) => {
-    pattern.lastIndex = 0
-    let match
-
-    while ((match = pattern.exec(source))) {
-      if (pattern === STRUCT_PATTERNS[0]) {
-        structs.push({
-          body: match[1],
-          name: match[2]
-        })
-      } else {
-        structs.push({
-          body: match[2],
-          name: match[1],
-          tagName: match[1]
-        })
-      }
-    }
-  })
-
-  const seen = {}
-
-  return structs.filter((item) => {
-    const key = item.name
-    if (!key || seen[key]) return false
-    seen[key] = true
-
-    return true
-  })
-}
-
-function parseArrayDimensions(suffix) {
-  const dimensions = []
-  const pattern = /\[([^\]]*)\]/g
-  let match
-
-  while ((match = pattern.exec(suffix || ''))) {
-    const text = String(match[1] || '').trim()
-    const value = Number(text)
-    if (!Number.isInteger(value) || value < 1) {
-      throw new Error('数组长度需为正整数')
-    }
-    dimensions.push(value)
-  }
-
-  return dimensions
-}
-
-function splitDeclarations(body) {
-  return String(body || '')
-    .split(';')
-    .map((item) => item.trim())
-    .filter(Boolean)
-}
-
-function splitDeclarators(statement) {
-  return String(statement || '')
-    .split(',')
-    .map((item) => item.trim())
-    .filter(Boolean)
-}
-
-function parseFirstDeclarator(text) {
-  const match = String(text || '').match(/^(.+?)\s+(\**\s*(?:[A-Za-z_]\w*)?(?:\s*\[[^\]]*\])*(?:\s*:\s*\d+)?)$/)
-  if (!match) return null
-
-  return {
-    declarator: match[2],
-    typeText: match[1]
-  }
-}
-
-function parseDeclarator(text) {
-  const rawText = String(text || '')
-  const bitWidthMatch = rawText.match(/:\s*(\d+)\s*$/)
-  const cleaned = rawText
-    .replace(/=.*/, '')
-    .replace(/:\s*\d+\s*$/, '')
-    .replace(/\*/g, '')
-    .trim()
-
-  if (!cleaned && bitWidthMatch) {
-    return {
-      arrayDimensions: [],
-      bitWidth: Number(bitWidthMatch[1]),
-      name: ''
-    }
-  }
-
-  const match = cleaned.match(/^([A-Za-z_]\w*)\s*((?:\[[^\]]*\])*)$/)
-  if (!match) return null
-
-  return {
-    arrayDimensions: parseArrayDimensions(match[2]),
-    bitWidth: bitWidthMatch ? Number(bitWidthMatch[1]) : null,
-    name: match[1]
-  }
-}
-
-function isAsciiArray(typeText, dataType, name, arrayLength) {
-  if (!arrayLength || arrayLength < 2 || arrayLength > 32) return false
-
-  const normalizedType = normalizeTypeText(typeText).toLowerCase()
-  if (normalizedType === 'char' || normalizedType === 'signed char') return true
-
-  return dataType === 'uint8_t' && /(^|_)(model|name|text|str|string|chip|version|ver|serial|sn)($|_)/i.test(name)
-}
-
-function getDataTypeByteLength(dataType) {
-  if (dataType === 'float' || dataType === 'int32_t' || dataType === 'uint32_t') return 4
-  if (dataType === 'int8_t' || dataType === 'uint8_t') return 1
-
-  return 2
-}
-
-function getBitFieldDataType(bitWidth) {
-  const width = Math.max(1, Math.round(Number(bitWidth) || 1))
-  if (width <= 8) return 'uint8_t'
-  if (width <= 16) return 'uint16_t'
-
-  return 'uint32_t'
-}
-
-function isBitType(typeText) {
-  return normalizeTypeText(typeText).toLowerCase() === 'bit'
-}
-
-function alignLayoutToByte(layoutState) {
-  if (layoutState.bitOffset % 8 !== 0) {
-    layoutState.bitOffset += 8 - (layoutState.bitOffset % 8)
-  }
-}
-
-function getLayoutByteStart(layoutState) {
-  return Math.floor(layoutState.bitOffset / 8)
-}
-
-function advanceLayoutBytes(layoutState, byteLength) {
-  layoutState.bitOffset += Math.max(1, Number(byteLength) || 1) * 8
-}
-
-function createBitFieldRegister(field, bitWidth, layoutState, name) {
-  const width = Math.max(0, Math.round(Number(bitWidth) || 0))
-
-  if (width === 0) {
-    alignLayoutToByte(layoutState)
-    return []
-  }
-
-  const byteStart = getLayoutByteStart(layoutState)
-  const bitOffset = layoutState.bitOffset % 8
-  layoutState.bitOffset += width
-
-  if (!name) return []
-
-  return [{
-    bitOffset,
-    bitWidth: width,
-    byteStart,
-    dataType: getBitFieldDataType(width),
-    isBitField: true,
-    name,
-    unit: 'bit'
-  }]
-}
-
-function createRegisterFromField(field, dataType, originalTypeText, layoutState) {
-  const arrayLength = field.arrayDimensions.reduce((total, value) => total * value, 1)
-  const hasArray = field.arrayDimensions.length > 0
-  const bitFieldWidth = field.bitWidth !== null && field.bitWidth !== undefined
-    ? field.bitWidth
-    : (isBitType(originalTypeText) ? 1 : null)
-
-  if (bitFieldWidth !== null && bitFieldWidth !== undefined) {
-    if (hasArray) {
-      const registers = []
-      for (let index = 0; index < arrayLength; index += 1) {
-        registers.push(...createBitFieldRegister(
-          field,
-          bitFieldWidth,
-          layoutState,
-          field.name ? `${field.name}[${index}]` : ''
-        ))
-      }
-
-      return registers
-    }
-
-    return createBitFieldRegister(field, bitFieldWidth, layoutState, field.name)
-  }
-
-  alignLayoutToByte(layoutState)
-
-  if (hasArray && isAsciiArray(originalTypeText, dataType, field.name, arrayLength)) {
-    const byteStart = getLayoutByteStart(layoutState)
-    advanceLayoutBytes(layoutState, arrayLength)
-
-    return [{
-      byteStart,
-      dataType: 'ascii',
-      name: field.name,
-      textByteLength: String(arrayLength)
-    }]
-  }
-
-  if (!hasArray) {
-    const byteStart = getLayoutByteStart(layoutState)
-    advanceLayoutBytes(layoutState, getDataTypeByteLength(dataType))
-
-    return [{
-      byteStart,
-      dataType,
-      name: field.name
-    }]
-  }
-
-  const registers = []
-  for (let index = 0; index < arrayLength; index += 1) {
-    const byteStart = getLayoutByteStart(layoutState)
-    advanceLayoutBytes(layoutState, getDataTypeByteLength(dataType))
-    registers.push({
-      byteStart,
-      dataType,
-      name: `${field.name}[${index}]`
-    })
-  }
-
-  return registers
-}
-
-function parseStructFields(body, aliases) {
-  const registers = []
-  const layoutState = {
-    bitOffset: 0
-  }
-  const declarations = splitDeclarations(body)
-
-  declarations.forEach((statement) => {
-    if (!statement || statement.indexOf('(') >= 0) return
-
-    const parts = splitDeclarators(statement)
-    if (!parts.length) return
-
-    const first = parseFirstDeclarator(parts[0])
-    if (!first) return
-
-    const dataType = resolveType(first.typeText, aliases)
-    if (!dataType) return
-
-    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))
-    })
-  })
-
-  return registers.map((register) => ({
-    ...register,
-    structByteLength: Math.ceil(layoutState.bitOffset / 8)
-  }))
-}
-
-function parseStructDefinition(sourceText) {
-  const source = stripComments(sourceText)
-  const aliases = createAliasMap(source)
-  const structInfo = findStruct(source)
-  if (!structInfo) {
-    throw new Error('未找到结构体定义')
-  }
-
-  const registers = parseStructFields(structInfo.body, aliases)
-  if (!registers.length) {
-    throw new Error('结构体中没有可识别的变量定义')
-  }
-
-  return {
-    name: structInfo.name || 'Struct',
-    registers,
-    structName: structInfo.name || 'Struct'
-  }
-}
-
-function getStructNameAliases(structInfo, aliases) {
-  const names = [structInfo.name]
-
-  if (structInfo.tagName) names.push(`struct ${structInfo.tagName}`)
-
-  Object.keys(aliases || {}).forEach((aliasName) => {
-    if (aliases[aliasName] === structInfo.name) names.push(aliasName)
-  })
-
-  return names.filter(Boolean)
-}
-
-function normalizeVariableTypeText(typeText, aliases) {
-  const normalized = normalizeTypeText(typeText)
-  if (!normalized) return ''
-
-  const tokens = normalized
-    .split(/\s+/)
-    .filter((token) => !STRUCT_VARIABLE_QUALIFIERS[token])
-
-  const compact = tokens.join(' ')
-  if (aliases && aliases[compact]) return aliases[compact]
-
-  return compact
-}
-
-function parseStructVariables(source, structs, aliases) {
-  const variablesByName = {}
-
-  structs.forEach((structInfo) => {
-    const structNames = getStructNameAliases(structInfo, aliases)
-
-    structNames.forEach((structName) => {
-      const escaped = structName.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
-
-          const variableInfo = {
-            arrayDimensions: field.arrayDimensions,
-            name: field.name,
-            registers: structInfo.registers,
-            structName: structInfo.name
-          }
-          variablesByName[field.name] = variableInfo
-          variablesByName[field.name.replace(/^_+/, '').toLowerCase()] = variableInfo
-          variablesByName[normalizeLookupName(field.name)] = variableInfo
-        })
-      }
-    })
-  })
-
-  return variablesByName
-}
-
-function parseStructCatalog(sourceText) {
-  const source = stripComments(sourceText)
-  const aliases = createAliasMap(source)
-  const structs = findStructs(source).map((structInfo) => ({
-    ...structInfo,
-    registers: parseStructFields(structInfo.body, aliases)
-  })).filter((structInfo) => structInfo.registers.length)
-
-  if (!structs.length) {
-    throw new Error('未找到可识别的结构体定义')
-  }
-
-  return {
-    structs,
-    variablesByName: parseStructVariables(source, structs, aliases)
-  }
-}
-
-module.exports = {
-  parseStructCatalog,
-  parseStructDefinition,
-  stripComments
-}

+ 164 - 0
domain/parameter-groups/constants.js

@@ -0,0 +1,164 @@
+const MAX_MODBUS_ADDRESS = 0xFFFF
+const MAX_PARAMETER_GROUP_ITEMS = 256
+const DEFAULT_TEXT_BYTE_LENGTH = 32
+const MAX_TEXT_BYTE_LENGTH = 32
+
+const REGISTER_TYPE_OPTIONS = [
+  {
+    functionCode: 0x03,
+    key: 'holding',
+    label: '保持寄存器',
+    writable: true
+  },
+  {
+    functionCode: 0x01,
+    key: 'coil',
+    label: '线圈',
+    writable: true
+  },
+  {
+    functionCode: 0x02,
+    key: 'discrete',
+    label: '离散输入状态',
+    writable: false
+  },
+  {
+    functionCode: 0x04,
+    key: 'input',
+    label: '输入寄存器',
+    writable: false
+  }
+]
+
+const DATA_TYPE_OPTIONS = [
+  {
+    byteLength: 1,
+    key: 'int8_t',
+    label: 'int8_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 1,
+    key: 'uint8_t',
+    label: 'uint8_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 2,
+    key: 'int16_t',
+    label: 'int16_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 2,
+    key: 'uint16_t',
+    label: 'uint16_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 4,
+    key: 'int32_t',
+    label: 'int32_t',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 4,
+    key: 'uint32_t',
+    label: 'uint32_t',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 4,
+    key: 'float',
+    label: 'float',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 32,
+    key: 'utf8',
+    label: 'UTF-8',
+    kind: 'text',
+    maxByteLength: MAX_TEXT_BYTE_LENGTH,
+    wordCount: 16
+  },
+  {
+    byteLength: 32,
+    key: 'ascii',
+    label: 'ASCII',
+    kind: 'text',
+    maxByteLength: MAX_TEXT_BYTE_LENGTH,
+    wordCount: 16
+  },
+  {
+    byteLength: 2,
+    key: 'hex',
+    label: 'HEX',
+    kind: 'hex',
+    wordCount: 1
+  }
+]
+
+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 SOURCE_REGISTER_FIELDS = [
+  'conversionFormula',
+  'sourceAddress',
+  'sourceAddressText',
+  'sourceByteLength',
+  'sourceBitOffset',
+  'sourceBitWidth',
+  'sourceMemoryArea',
+  'sourceMemoryClass',
+  'sourceSymbolName',
+  'sourceSymbolType'
+]
+
+const STRUCT_REGISTER_FIELDS = [
+  'bitOffset',
+  'bitWidth',
+  'byteStart',
+  'isPlaceholderByteField',
+  'isBitField',
+  'structByteLength'
+]
+
+const SOURCE_GROUP_FIELDS = [
+  'addressUnit',
+  'sourceAddress',
+  'sourceAddressText',
+  'sourceByteLength',
+  'sourceMemoryArea',
+  'sourceMemoryClass',
+  'sourceSegment',
+  'sourceSegmentModule',
+  'sourceSymbolName',
+  'sourceSymbolType'
+]
+
+module.exports = {
+  BYTE_ADDRESS_MEMORY_AREAS,
+  DATA_TYPE_OPTIONS,
+  DEFAULT_DATA_TYPE,
+  DEFAULT_REGISTER_TYPE,
+  DEFAULT_TEXT_BYTE_LENGTH,
+  GROUP_LAYOUT_REGISTER,
+  GROUP_LAYOUT_STRUCT,
+  MAX_MODBUS_ADDRESS,
+  MAX_PARAMETER_GROUP_ITEMS,
+  MAX_TEXT_BYTE_LENGTH,
+  REGISTER_TYPE_OPTIONS,
+  SOURCE_GROUP_FIELDS,
+  SOURCE_REGISTER_FIELDS,
+  STRUCT_REGISTER_FIELDS
+}

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

@@ -0,0 +1,10 @@
+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')
+}

+ 667 - 0
domain/parameter-groups/model.js

@@ -0,0 +1,667 @@
+const {
+  clampInteger,
+  createId,
+  normalizeTextValue,
+  padHex
+} = require('../../utils/base-utils.js')
+const {
+  BYTE_ADDRESS_MEMORY_AREAS,
+  DATA_TYPE_OPTIONS,
+  DEFAULT_DATA_TYPE,
+  DEFAULT_REGISTER_TYPE,
+  DEFAULT_TEXT_BYTE_LENGTH,
+  GROUP_LAYOUT_REGISTER,
+  GROUP_LAYOUT_STRUCT,
+  MAX_MODBUS_ADDRESS,
+  MAX_PARAMETER_GROUP_ITEMS,
+  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')
+const {
+  alignEvenByteLength,
+  decodeRegisterValue,
+  formatCoilDisplayValue,
+  formatRawByteText,
+  formatRawByteTextWithDefault,
+  formatRawWordText,
+  formatRegisterValue,
+  getDataType,
+  getDataTypeIndex,
+  getRegisterByteLength,
+  getRegisterValueTypeLabel,
+  getRegisterWordCount,
+  getRegisterWordCountAtOffset,
+  isBitFieldRegister,
+  isBitRegisterType,
+  isByteRegister,
+  isTextRegister,
+  normalizeBitOffset,
+  normalizeBitWidth,
+  normalizeTextByteLength,
+  parseCoilValue,
+  registerTypeIsBit,
+  supportsRange,
+  supportsUnit
+} = require('./value-codec.js')
+const {
+  decodeRegisterFromByteCache,
+  decodeRegisterFromWordCache,
+  getGroupEncodedBytes,
+  getGroupEncodedWords,
+  getRegisterBytesFromByteCache,
+  getRegisterEncodedBytes,
+  getRegisterEncodedWords,
+  getRegisterWordsFromByteCache,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  splitWordSpans,
+  validateRegisterValue
+} = require('./register-io.js')
+
+function normalizeAddress(value, fallback = 0) {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? clampInteger(value, 0, MAX_MODBUS_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_MODBUS_ADDRESS, fallback) : fallback
+  }
+
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? clampInteger(numberValue, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
+}
+
+function parseConfigAddress(value) {
+  if (typeof value === 'number') {
+    return clampInteger(value, 0, MAX_MODBUS_ADDRESS, 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('寄存器起始地址无效')
+  }
+
+  return parseInt(hexText, 16)
+}
+
+function parseConfigQuantity(value, maxQuantity) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  const quantity = Number(text)
+
+  if (!Number.isInteger(quantity) || quantity < 1 || quantity > maxQuantity) {
+    throw new Error(`寄存器数量需为 1 - ${maxQuantity}`)
+  }
+
+  return quantity
+}
+
+function getRegisterType(typeKey) {
+  return REGISTER_TYPE_OPTIONS.find((item) => item.key === typeKey) || REGISTER_TYPE_OPTIONS[0]
+}
+
+function getRegisterTypeIndex(typeKey) {
+  return Math.max(0, REGISTER_TYPE_OPTIONS.findIndex((item) => item.key === getRegisterType(typeKey).key))
+}
+
+function isStructLayout(layout) {
+  return layout === GROUP_LAYOUT_STRUCT
+}
+
+function padWordHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+function formatAddressRange(startAddress, wordCount) {
+  const address = normalizeAddress(startAddress, 0)
+  const count = Math.max(1, Number(wordCount) || 1)
+  const endAddress = address + count - 1
+  const safeEndAddress = Math.min(endAddress, MAX_MODBUS_ADDRESS)
+  const overflowText = endAddress > MAX_MODBUS_ADDRESS ? '+' : ''
+
+  if (count <= 1) return `0x${padWordHex(address)}`
+
+  return `0x${padWordHex(address)}-0x${padWordHex(safeEndAddress)}${overflowText}`
+}
+
+function isByteAddressedGroup(group = {}) {
+  const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
+
+  return group.addressUnit === 'byte'
+    || group.addressUnit === 'bytes'
+    || BYTE_ADDRESS_MEMORY_AREAS.indexOf(memoryArea) >= 0
+}
+
+function formatRegisterAddressText(address, byteOffset, byteLength, registerType) {
+  if (isBitRegisterType(registerType)) return `0x${padHex(address)}`
+  if (byteLength === 1) return `0x${padHex(address)}${byteOffset === 0 ? 'H' : 'L'}`
+
+  return `0x${padHex(address)}`
+}
+
+function formatBitFieldAddressText(address, byteOffset, bitOffset, bitWidth) {
+  const byteText = formatRegisterAddressText(address, byteOffset, 1, DEFAULT_REGISTER_TYPE)
+  const startBit = normalizeBitOffset(bitOffset)
+  const endBit = startBit + normalizeBitWidth(bitWidth) - 1
+
+  return endBit === startBit
+    ? `${byteText}.b${startBit}`
+    : `${byteText}.b${startBit}..${endBit}`
+}
+
+function isAddressRangeOverflow(startAddress, wordCount) {
+  const address = normalizeAddress(startAddress, 0)
+  const count = Math.max(1, Number(wordCount) || 1)
+
+  return address + count - 1 > MAX_MODBUS_ADDRESS
+}
+
+function getMaxQuantity() {
+  return MAX_PARAMETER_GROUP_ITEMS
+}
+
+function getRegisterSavedValue(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
+  if (register.value !== undefined && register.value !== null) return normalizeTextValue(register.value)
+
+  return null
+}
+
+function normalizeRegisterDataType(register, registerType) {
+  if (isBitRegisterType(registerType)) return DEFAULT_DATA_TYPE
+
+  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)}`
+    : ''
+  const parts = [
+    register.sourceMemoryArea,
+    register.sourceAddressText,
+    bitText,
+    register.sourceSymbolType && register.sourceSymbolType !== '---' ? register.sourceSymbolType : ''
+  ].filter(Boolean)
+
+  return parts.join(' · ')
+}
+
+function createGroupSourceMetaText(group) {
+  const parts = [
+    group.sourceMemoryArea,
+    group.sourceAddressText,
+    group.sourceSymbolName,
+    group.sourceSegmentModule
+  ].filter(Boolean)
+
+  return parts.join(' · ')
+}
+
+function formatCompactNumber(value, precision = 4) {
+  const text = formatFixedValue(value, precision)
+
+  return text === '--' ? '--' : text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
+}
+
+function formatCardMetricValue(value) {
+  if (value === '--') return '--'
+
+  return formatCompactNumber(value)
+}
+
+function normalizeStorageCodeInfoCard(codeInfo = null) {
+  const hasCodeInfo = !!codeInfo && codeInfo.hasCodeInfo !== false
+  const refVolt = hasCodeInfo ? Number(codeInfo.refVolt) : NaN
+  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() || '--') : '--',
+    refVolt: hasCodeInfo
+      ? (Number.isFinite(refVolt) ? refVolt : ((Number(codeInfo.refVoltRaw) || 0) / 10))
+      : '--',
+    refVoltRaw: hasCodeInfo ? (Number(codeInfo.refVoltRaw) || 0) : 0,
+    rsShunt: hasCodeInfo ? (Number(codeInfo.rsShunt) || 0) : '--'
+  }
+  const codeInfoContext = hasCodeInfo
+    ? {
+      alongDiv: card.alongDiv,
+      ampGain: card.ampGain,
+      busDiv: card.busDiv,
+      caveFreq: card.caveFreq,
+      refVolt: card.refVolt,
+      rsShunt: card.rsShunt
+    }
+    : {}
+
+  return {
+    ...card,
+    codeInfoContext,
+    hasCodeInfo,
+    metricItems: [
+      {
+        text: `${formatCardMetricValue(card.caveFreq)}KHz`
+      },
+      {
+        text: `${formatCardMetricValue(card.refVolt)}V`
+      },
+      {
+        text: formatCardMetricValue(card.ampGain)
+      },
+      {
+        text: `${formatCardMetricValue(card.rsShunt)}mΩ`
+      }
+    ]
+  }
+}
+
+function isStorageStructGroup(group = {}) {
+  return isStructLayout(group.layout)
+    && isByteAddressedGroup(group)
+    && !!String(group.sourceMemoryArea || '').trim()
+}
+
+function getStorageAreaText(group = {}, lowercase = false) {
+  const area = String(group.sourceMemoryArea || '').trim()
+  return lowercase ? area.toLowerCase() : area.toUpperCase()
+}
+
+function stripStorageAreaPrefix(text) {
+  const source = String(text || '').trim()
+  const cleaned = source.replace(/^(?:IDATA|XDATA|DATA|CODE)[\s:_-]+/i, '').trim()
+
+  return cleaned || source
+}
+
+function formatRegisterStartAddressText(address) {
+  return `0x${padHex(address)}`
+}
+
+function formatStorageHexBytes(bytes = [], byteLength = 1) {
+  const safeLength = Math.max(1, Math.floor(Number(byteLength) || 1))
+  const source = Array.isArray(bytes) ? bytes : []
+
+  return `0x${Array.from({ length: safeLength }, (_, index) => (
+    (Number(source[index] || 0) & 0xFF).toString(16).toUpperCase().padStart(2, '0')
+  )).join('')}`
+}
+
+function formatStorageRegisterHexValue(register, rawBytes = []) {
+  if (isTextRegister(register.dataType)) {
+    return formatRawByteTextWithDefault(rawBytes, register.byteLength)
+  }
+
+  return formatStorageHexBytes(rawBytes, register.byteLength)
+}
+
+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 byteAddressed = isByteAddressedGroup(group)
+  const dataType = normalizeRegisterDataType(register, registerType)
+  const bitOffset = normalizeBitOffset(register.bitOffset)
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+  const isBitField = !isBitRegisterType(registerType) && isBitFieldRegister(register)
+  const isPlaceholderByteField = !!register.isPlaceholderByteField
+  const textByteLength = isTextRegister(dataType)
+    ? normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+    : ''
+  const defaultValue = normalizeTextValue(register.defaultValue)
+  const savedValue = getRegisterSavedValue(register)
+  const inputValue = savedValue === null ? defaultValue : savedValue
+  const rawValue = register.rawValue === undefined ? null : register.rawValue
+  const byteLength = isBitRegisterType(registerType)
+    ? 1
+    : getRegisterByteLength(dataType, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
+  const registerCount = isBitRegisterType(registerType)
+    ? 1
+    : getRegisterWordCountAtOffset(dataType, byteOffset, { ...register, bitOffset, bitWidth, isBitField, layout, textByteLength })
+  const canShowUnit = !isBitRegisterType(registerType) && !isBitField && supportsUnit(dataType)
+  const rawWords = Array.isArray(register.rawWords)
+    ? register.rawWords.slice(0, registerCount).map((word) => Number(word) & 0xFFFF)
+    : []
+  const rawBytes = Array.isArray(register.rawBytes)
+    ? register.rawBytes.slice(0, byteLength).map((byte) => Number(byte) & 0xFF)
+    : []
+  const defaultRawValueText = rawValue === null
+    ? '--'
+    : (isBitRegisterType(registerType)
+      ? formatCoilDisplayValue(rawValue)
+      : (byteAddressed ? formatRawByteText(rawBytes) : formatRawWordText(rawWords)))
+  const rawValueText = isStorageStruct
+    ? formatStorageRegisterHexValue({
+      ...register,
+      byteLength,
+      dataType
+    }, rawBytes)
+    : defaultRawValueText
+  const displayValue = rawValue === null
+    ? (inputValue.trim() ? inputValue : '--')
+    : formatRegisterValue({ ...register, dataType, byteOffset }, rawValue)
+  const conversionFormula = normalizeTextValue(register.conversionFormula).trim()
+  const conversionResult = conversionFormula && rawValue !== null
+    ? evaluateValueFormula(conversionFormula, rawValue, group.codeInfoContext || {})
+    : null
+  const convertedDisplayValue = conversionResult && conversionResult.ok
+    ? conversionResult.text
+    : displayValue
+  const displayMetaText = conversionResult && conversionResult.ok
+    ? `raw ${displayValue}`
+    : ''
+  const addressRangeText = isBitRegisterType(registerType)
+    ? `0x${padHex(address)}`
+    : (byteAddressed
+      ? 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}` : ''}`
+      : formatBitFieldAddressText(address, byteOffset, bitOffset, bitWidth))
+    : (byteAddressed
+      ? formatAddressRange(address, Math.max(1, byteLength))
+      : formatRegisterAddressText(address, byteOffset, byteLength, registerType))
+  const registerStartAddressText = formatRegisterStartAddressText(address)
+  const metaText = isStorageStruct
+    ? `${registerStartAddressText} ${rawValueText}`
+    : `${addressText} ${rawValueText}`.trim()
+
+  return {
+    address,
+    addressRangeText,
+    addressText,
+    bitOffset: isBitField ? bitOffset : '',
+    bitWidth: isBitField ? bitWidth : '',
+    byteLength,
+    byteLengthText: isBitRegisterType(registerType)
+      ? '1bit'
+      : (isBitField
+        ? (bitWidth === 1 ? `1bit/占${byteLength}B` : `${bitWidth}bit/占${byteLength}B`)
+        : (textByteLength && textByteLength !== byteLength ? `${textByteLength}B/占${byteLength}B` : `${byteLength}B`)),
+    byteStart: Math.max(0, Math.floor(Number(register.byteStart) || 0)),
+    dataType,
+    dataTypeIndex: getDataTypeIndex(dataType),
+    dataTypeText: getRegisterValueTypeLabel(dataType),
+    defaultValue,
+    displayMetaText,
+    displayValue: convertedDisplayValue,
+    id: register.id || createId('gm-reg'),
+    inputType: isTextRegister(dataType) ? 'text' : 'text',
+    inputValue,
+    isStructField: layout === GROUP_LAYOUT_STRUCT || !!register.isStructField,
+    layout,
+    isBitField,
+    isPlaceholderByteField,
+    isDirty: !!register.isDirty,
+    maxValue: normalizeTextValue(register.maxValue),
+    metaText,
+    minValue: normalizeTextValue(register.minValue),
+    name: register.name || `寄存器 ${index + 1}`,
+    conversionFormula,
+    conversionFormulaErrorText: conversionResult && conversionResult.errorText ? conversionResult.errorText : '',
+    rawValue,
+    rawValueText,
+    rawBytes,
+    rawWords,
+    registerCount,
+    byteOffset,
+    registerType,
+    showDataType: !isBitRegisterType(registerType),
+    showRange: !isBitRegisterType(registerType) && !isBitField && supportsRange(dataType),
+    showTextLength: !isBitRegisterType(registerType) && isTextRegister(dataType),
+    showUnit: canShowUnit,
+    textByteLength,
+    unit: canShowUnit ? normalizeTextValue(register.unit).trim() : '',
+    structByteLength: register.structByteLength,
+    remark: register.remark || '',
+    ...pickFields(register, SOURCE_REGISTER_FIELDS),
+    sourceMetaText: createRegisterSourceMetaText(register)
+  }
+}
+
+function normalizeGroup(group) {
+  const registerType = getRegisterType(group.registerType || group.type || DEFAULT_REGISTER_TYPE)
+  const startAddress = normalizeAddress(group.startAddress, 0)
+  const maxQuantity = getMaxQuantity(registerType.key)
+  const sourceRegisters = Array.isArray(group.registers) ? group.registers : []
+  const codeInfoContext = group.codeInfoContext || {}
+  const layout = isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER
+  const byteAddressed = isByteAddressedGroup(group)
+  const hasExplicitQuantity = group.quantity !== undefined && group.quantity !== null && group.quantity !== ''
+  const quantity = hasExplicitQuantity
+    ? clampInteger(group.quantity, 1, maxQuantity, 1)
+    : clampInteger(sourceRegisters.length || 1, 1, maxQuantity, 1)
+  const baseGroup = {
+    deleteVisible: !!group.deleteVisible,
+    expanded: group.expanded === true,
+    id: group.id || createId('gm-group'),
+    isStructLayout: layout === GROUP_LAYOUT_STRUCT,
+    layout,
+    name: String(group.name || group.groupName || '寄存器组').trim() || '寄存器组',
+    quantity,
+    registerType: registerType.key,
+    startAddress,
+    touchStartX: 0,
+    codeInfoContext,
+    ...pickFields(group, SOURCE_GROUP_FIELDS)
+  }
+  const registers = []
+  let nextAddress = startAddress
+  let nextByteOffset = 0
+
+  for (let index = 0; index < quantity; index += 1) {
+    const sourceRegister = sourceRegisters[index] || {}
+    let normalizedSourceRegister = sourceRegister
+    const dataType = normalizeRegisterDataType(sourceRegister, baseGroup.registerType)
+    const textByteLength = isTextRegister(dataType)
+      ? normalizeTextByteLength(sourceRegister.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+      : ''
+    const isBitRegister = isBitRegisterType(baseGroup.registerType)
+    let address = startAddress + index
+    let byteOffset = 0
+
+    if (!isBitRegister) {
+      const explicitByteStart = Number(sourceRegister.byteStart)
+      const hasExplicitByteStart = layout === GROUP_LAYOUT_STRUCT && Number.isFinite(explicitByteStart)
+      const byteLength = getRegisterByteLength(dataType, { ...sourceRegister, layout, textByteLength })
+      if (layout !== GROUP_LAYOUT_STRUCT && !isByteRegister(dataType) && nextByteOffset % 2 !== 0) {
+        nextByteOffset += 1
+      }
+
+      const currentByteStart = hasExplicitByteStart
+        ? Math.max(0, Math.floor(explicitByteStart))
+        : nextByteOffset
+
+      address = byteAddressed ? startAddress + currentByteStart : startAddress + Math.floor(currentByteStart / 2)
+      byteOffset = byteAddressed ? 0 : currentByteStart % 2
+      normalizedSourceRegister = {
+        ...sourceRegister,
+        byteStart: currentByteStart
+      }
+      nextByteOffset = Math.max(nextByteOffset, currentByteStart + byteLength)
+    }
+
+    const register = normalizeRegister(normalizedSourceRegister, baseGroup, index, address, byteOffset)
+    registers.push(register)
+    if (isBitRegister) nextAddress += register.registerCount
+  }
+
+  const byteLength = isBitRegisterType(baseGroup.registerType)
+    ? Math.max(1, nextAddress - startAddress)
+    : Math.max(1, nextByteOffset)
+  const paddedByteLength = isBitRegisterType(baseGroup.registerType)
+    ? byteLength
+    : (byteAddressed ? byteLength : alignEvenByteLength(byteLength))
+  const wordQuantity = isBitRegisterType(baseGroup.registerType)
+    ? 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 endAddress = startAddress + addressSpan - 1
+  const startAddressText = `0x${padHex(startAddress)}`
+  const endAddressText = addressOverflow ? `0x${padHex(MAX_MODBUS_ADDRESS)}+` : `0x${padHex(endAddress)}`
+  const displayName = isStorageStructGroup(baseGroup)
+    ? stripStorageAreaPrefix(baseGroup.name)
+    : baseGroup.name
+  const listMetaText = isStorageStructGroup(baseGroup)
+    ? `${getStorageAreaText(baseGroup)} ${startAddressText}-${endAddressText} ${byteLength}B`
+    : `${formatAddressRange(startAddress, addressSpan)} · ${quantity}/${wordQuantity}${createGroupSourceMetaText(baseGroup) ? ` · ${createGroupSourceMetaText(baseGroup)}` : ''}${addressOverflow ? ' · 地址超出 0xFFFF' : ''}`
+  const detailMetaText = isStorageStructGroup(baseGroup)
+    ? `${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),
+    addressOverflow,
+    addressWarningText: addressOverflow ? '地址超出 0xFFFF' : '',
+    detailMetaText,
+    detailTitleText: displayName,
+    displayName,
+    endAddressText,
+    functionCode: registerType.functionCode,
+    isReadOnly: !registerType.writable,
+    byteLength,
+    listMetaText,
+    maxQuantity,
+    registerTypeIndex: getRegisterTypeIndex(registerType.key),
+    registerTypeText: registerType.label,
+    registers,
+    paddedByteLength,
+    sourceMetaText,
+    startAddressText,
+    wordQuantity,
+    writable: registerType.writable
+  }
+}
+
+function normalizeGroupConfig(config = {}) {
+  const registerType = config.registerTypeIndex !== undefined && config.registerTypeIndex !== null
+    ? (REGISTER_TYPE_OPTIONS[Number(config.registerTypeIndex)] || REGISTER_TYPE_OPTIONS[0])
+    : getRegisterType(config.registerType || config.type || DEFAULT_REGISTER_TYPE)
+  const maxQuantity = getMaxQuantity(registerType.key)
+
+  return {
+    layout: isStructLayout(config.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
+    name: String(config.name || config.groupName || '寄存器组').trim() || '寄存器组',
+    quantity: parseConfigQuantity(config.quantity, maxQuantity),
+    registerType: registerType.key,
+    startAddress: parseConfigAddress(config.startAddress)
+  }
+}
+
+function getRegisterJsonValue(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) {
+    return normalizeTextValue(register.inputValue)
+  }
+
+  if (register.defaultValue !== undefined && register.defaultValue !== null) {
+    return normalizeTextValue(register.defaultValue)
+  }
+
+  if (register.displayValue !== undefined && register.displayValue !== null && register.displayValue !== '--') {
+    return normalizeTextValue(register.displayValue)
+  }
+
+  return ''
+}
+
+function normalizeImportedRegisterDataType(register) {
+  const dataType = register.dataType || register.type || DEFAULT_DATA_TYPE
+
+  return getDataType(dataType).key
+}
+
+function cloneImportedGroup(group) {
+  return {
+    layout: isStructLayout(group.layout) ? GROUP_LAYOUT_STRUCT : GROUP_LAYOUT_REGISTER,
+    name: group.name,
+    quantity: group.quantity,
+    registerType: group.registerType || group.type || DEFAULT_REGISTER_TYPE,
+    registers: (Array.isArray(group.registers) ? group.registers : []).map((register) => ({
+      conversionFormula: register.conversionFormula,
+      dataType: normalizeImportedRegisterDataType(register),
+      defaultValue: register.defaultValue,
+      inputValue: register.inputValue,
+      maxValue: register.maxValue,
+      minValue: register.minValue,
+      name: register.name,
+      isStructField: !!register.isStructField,
+      textByteLength: register.textByteLength,
+      remark: register.remark,
+      unit: register.unit,
+      value: register.value,
+      ...pickFields(register, STRUCT_REGISTER_FIELDS),
+      ...pickFields(register, SOURCE_REGISTER_FIELDS)
+    })),
+    startAddress: group.startAddress,
+    ...pickFields(group, SOURCE_GROUP_FIELDS)
+  }
+}
+
+module.exports = {
+  DATA_TYPE_OPTIONS,
+  DEFAULT_DATA_TYPE,
+  DEFAULT_REGISTER_TYPE,
+  GROUP_LAYOUT_REGISTER,
+  GROUP_LAYOUT_STRUCT,
+  MAX_MODBUS_ADDRESS,
+  REGISTER_TYPE_OPTIONS,
+  cloneImportedGroup,
+  decodeRegisterFromByteCache,
+  decodeRegisterFromWordCache,
+  decodeRegisterValue,
+  formatCoilDisplayValue,
+  formatRegisterValue,
+  getDataType,
+  getRegisterEncodedBytes,
+  getRegisterEncodedWords,
+  getGroupEncodedBytes,
+  getGroupEncodedWords,
+  getRegisterWordCount,
+  getRegisterJsonValue,
+  getRegisterBytesFromByteCache,
+  getRegisterWordsFromByteCache,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  isAddressRangeOverflow,
+  isBitRegisterType,
+  isByteRegister,
+  isTextRegister,
+  normalizeGroup,
+  normalizeGroupConfig,
+  normalizeStorageCodeInfoCard,
+  normalizeRegister,
+  parseCoilValue,
+  registerTypeIsBit,
+  splitWordSpans,
+  validateRegisterValue
+}

+ 211 - 0
domain/parameter-groups/register-io.js

@@ -0,0 +1,211 @@
+const {
+  normalizeTextValue
+} = require('../../utils/base-utils.js')
+const {
+  bytesToWords
+} = require('../../utils/binary-utils.js')
+const {
+  MAX_MODBUS_ADDRESS
+} = require('./constants.js')
+const {
+  decodeRegisterValue,
+  encodeBitFieldIntoBytes,
+  encodeRegisterBytes,
+  encodeRegisterWords,
+  isBitFieldRegister,
+  validateRegisterValue: validateCodecRegisterValue
+} = require('./value-codec.js')
+
+function normalizeAddress(value, fallback = 0) {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? Math.max(0, Math.min(Math.round(value), MAX_MODBUS_ADDRESS)) : 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) ? Math.max(0, Math.min(parsedHex, MAX_MODBUS_ADDRESS)) : fallback
+  }
+
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? Math.max(0, Math.min(Math.round(numberValue), MAX_MODBUS_ADDRESS)) : fallback
+}
+
+function splitWordSpans(startAddress, quantity, maxQuantity) {
+  const spans = []
+  let address = normalizeAddress(startAddress, 0)
+  let remaining = Math.max(0, Math.floor(Number(quantity) || 0))
+
+  while (remaining > 0) {
+    const spanQuantity = Math.min(remaining, maxQuantity)
+
+    spans.push({
+      address,
+      quantity: spanQuantity
+    })
+
+    address += spanQuantity
+    remaining -= spanQuantity
+  }
+
+  return spans
+}
+
+function getRegisterWriteValueText(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
+  if (register.defaultValue !== undefined && register.defaultValue !== null) return normalizeTextValue(register.defaultValue)
+
+  return ''
+}
+
+function getRegisterWordsFromWordCache(register, wordCache) {
+  const words = []
+  for (let offset = 0; offset < register.registerCount; offset += 1) {
+    const word = wordCache[register.address + offset]
+    if (word === undefined) return null
+    words.push(word)
+  }
+
+  return words
+}
+
+function getRegisterBytesFromByteCache(register, byteCache) {
+  const bytes = []
+  for (let offset = 0; offset < register.byteLength; offset += 1) {
+    const byte = byteCache[register.address + offset]
+    if (byte === undefined) return null
+    bytes.push(Number(byte) & 0xFF)
+  }
+
+  return bytes
+}
+
+function getRegisterWordsFromByteCache(register, byteCache) {
+  const bytes = getRegisterBytesFromByteCache(register, byteCache)
+  if (!bytes) return null
+
+  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
+}
+
+function decodeRegisterFromBytes(register, bytes) {
+  if (!Array.isArray(bytes) || bytes.length < register.byteLength) return null
+
+  return decodeRegisterValue(
+    {
+      ...register,
+      byteOffset: 0
+    },
+    bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
+  )
+}
+
+function decodeRegisterFromWordCache(register, wordCache) {
+  const words = getRegisterWordsFromWordCache(register, wordCache)
+  if (!words) return null
+
+  return decodeRegisterValue(register, words)
+}
+
+function decodeRegisterFromByteCache(register, byteCache) {
+  const bytes = getRegisterBytesFromByteCache(register, byteCache)
+  if (!bytes) return null
+
+  return decodeRegisterFromBytes(register, bytes)
+}
+
+function getRegisterEncodedWords(register) {
+  return encodeRegisterWords({
+    ...register,
+    inputValue: getRegisterWriteValueText(register)
+  })
+}
+
+function getRegisterEncodedBytes(register) {
+  return encodeRegisterBytes({
+    ...register,
+    inputValue: getRegisterWriteValueText(register)
+  })
+}
+
+function getRegisterByteStart(register, groupStartAddress = 0) {
+  if (Number.isFinite(Number(register.byteStart))) {
+    return Math.max(0, Math.floor(Number(register.byteStart)))
+  }
+
+  return Math.max(0, ((Number(register.address) || 0) - (Number(groupStartAddress) || 0)) * 2 + (Number(register.byteOffset) || 0))
+}
+
+function getGroupEncodedBytes(group, baseBytes = null) {
+  const byteLength = Math.max(2, Number(group && group.paddedByteLength) || ((Number(group && group.wordQuantity) || 1) * 2))
+  const bytes = Array.isArray(baseBytes)
+    ? baseBytes.slice(0, byteLength).map((byte) => Number(byte) & 0xFF)
+    : []
+  const registers = Array.isArray(group && group.registers) ? group.registers : []
+
+  while (bytes.length < byteLength) {
+    bytes.push(0)
+  }
+
+  registers.forEach((register) => {
+    const byteStart = getRegisterByteStart(register, group.startAddress)
+
+    if (isBitFieldRegister(register)) {
+      const encodedBytes = encodeBitFieldIntoBytes(
+        {
+          ...register,
+          inputValue: getRegisterWriteValueText(register)
+        },
+        bytes,
+        byteStart
+      )
+
+      if (!Array.isArray(encodedBytes)) {
+        throw new Error(`${register.name || '位域'} 没有有效写入值`)
+      }
+      return
+    }
+
+    const registerBytes = encodeRegisterBytes(register)
+    if (!Array.isArray(registerBytes) || !registerBytes.length) {
+      throw new Error(`${register.name || '寄存器'} 没有有效写入值`)
+    }
+
+    for (let offset = 0; offset < register.byteLength; offset += 1) {
+      if (byteStart + offset < bytes.length) {
+        bytes[byteStart + offset] = Number(registerBytes[offset] || 0) & 0xFF
+      }
+    }
+  })
+
+  return bytes
+}
+
+function getGroupEncodedWords(group, baseBytes = null) {
+  return bytesToWords(getGroupEncodedBytes(group, baseBytes))
+}
+
+function validateRegisterValue(register, value) {
+  return validateCodecRegisterValue(
+    register,
+    value === undefined || value === null ? getRegisterWriteValueText(register) : value
+  )
+}
+
+module.exports = {
+  decodeRegisterFromByteCache,
+  decodeRegisterFromBytes,
+  decodeRegisterFromWordCache,
+  getGroupEncodedBytes,
+  getGroupEncodedWords,
+  getRegisterBytesFromByteCache,
+  getRegisterEncodedBytes,
+  getRegisterEncodedWords,
+  getRegisterWordsFromByteCache,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  splitWordSpans,
+  validateRegisterValue
+}

+ 261 - 0
domain/parameter-groups/struct-c-syntax.js

@@ -0,0 +1,261 @@
+const TYPE_ALIASES = {
+  bit: 'uint8_t',
+  bool: 'uint8_t',
+  char: 'int8_t',
+  double: 'float',
+  float: 'float',
+  int: 'int16_t',
+  int8: 'int8_t',
+  int8_t: 'int8_t',
+  int16: 'int16_t',
+  int16_t: 'int16_t',
+  int32: 'int32_t',
+  int32_t: 'int32_t',
+  long: 'int32_t',
+  short: 'int16_t',
+  'signed char': 'int8_t',
+  'signed int': 'int16_t',
+  'signed long': 'int32_t',
+  'signed short': 'int16_t',
+  uint8: 'uint8_t',
+  uint8_t: 'uint8_t',
+  uint16: 'uint16_t',
+  uint16_t: 'uint16_t',
+  uint32: 'uint32_t',
+  uint32_t: 'uint32_t',
+  'unsigned char': 'uint8_t',
+  'unsigned int': 'uint16_t',
+  'unsigned long': 'uint32_t',
+  'unsigned short': 'uint16_t'
+}
+
+const TYPE_QUALIFIERS = {
+  _I: true,
+  _IO: true,
+  _O: true,
+  const: true,
+  extern: true,
+  register: true,
+  static: true,
+  volatile: true
+}
+
+const STRUCT_PATTERNS = [
+  /typedef\s+struct(?:\s+[A-Za-z_]\w*)?\s*\{([\s\S]*?)\}\s*([A-Za-z_]\w*)\s*;/g,
+  /struct\s+([A-Za-z_]\w*)\s*\{([\s\S]*?)\}\s*;/g
+]
+
+function normalizeLookupName(value) {
+  return String(value || '')
+    .replace(/^_+/, '')
+    .replace(/[^A-Za-z0-9]/g, '')
+    .toLowerCase()
+}
+
+function stripComments(source) {
+  return String(source || '')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/\/\/.*$/gm, '')
+}
+
+function normalizeTypeText(typeText) {
+  return String(typeText || '')
+    .replace(/\*/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+
+function resolveType(typeText, aliases) {
+  const normalized = normalizeTypeText(typeText)
+  if (!normalized) return ''
+
+  const compact = normalized
+    .split(/\s+/)
+    .filter((token) => !TYPE_QUALIFIERS[token])
+    .join(' ')
+    .trim()
+
+  if (!compact || /^struct\b/.test(compact) || /^enum\b/.test(compact) || compact.indexOf('*') >= 0) {
+    return ''
+  }
+
+  if (aliases[compact]) return aliases[compact]
+
+  const tokens = compact.split(/\s+/).filter(Boolean)
+  for (const token of tokens) {
+    if (aliases[token]) return aliases[token]
+  }
+
+  return ''
+}
+
+function createAliasMap(source) {
+  const aliases = {
+    ...TYPE_ALIASES
+  }
+  const definePattern = /^\s*#\s*define\s+([A-Za-z_]\w*)\s+([A-Za-z_]\w*)\s*$/gm
+  let defineMatch
+
+  while ((defineMatch = definePattern.exec(source))) {
+    const name = defineMatch[1]
+    const value = defineMatch[2]
+    if (aliases[value]) aliases[name] = aliases[value]
+  }
+
+  const typedefPattern = /typedef\s+(?!struct\b)([^;{}]+?)\s+([A-Za-z_]\w*)\s*;/g
+  let typedefMatch
+
+  while ((typedefMatch = typedefPattern.exec(source))) {
+    const resolvedType = resolveType(typedefMatch[1], aliases)
+    if (resolvedType) aliases[typedefMatch[2]] = resolvedType
+  }
+
+  const typedefStructPattern = /typedef\s+struct(?:\s+([A-Za-z_]\w*))?\s*\{[\s\S]*?\}\s*([A-Za-z_]\w*)\s*;/g
+  let structTypedefMatch
+
+  while ((structTypedefMatch = typedefStructPattern.exec(source))) {
+    const tagName = structTypedefMatch[1]
+    const typedefName = structTypedefMatch[2]
+    if (tagName && typedefName) aliases[`struct ${tagName}`] = typedefName
+  }
+
+  return aliases
+}
+
+function findStruct(source) {
+  for (const pattern of STRUCT_PATTERNS) {
+    pattern.lastIndex = 0
+    const match = pattern.exec(source)
+    if (!match) continue
+
+    if (pattern === STRUCT_PATTERNS[0]) {
+      return {
+        body: match[1],
+        name: match[2]
+      }
+    }
+
+    return {
+      body: match[2],
+      name: match[1]
+    }
+  }
+
+  return null
+}
+
+function findStructs(source) {
+  const structs = []
+
+  STRUCT_PATTERNS.forEach((pattern) => {
+    pattern.lastIndex = 0
+    let match
+
+    while ((match = pattern.exec(source))) {
+      if (pattern === STRUCT_PATTERNS[0]) {
+        structs.push({
+          body: match[1],
+          name: match[2]
+        })
+      } else {
+        structs.push({
+          body: match[2],
+          name: match[1],
+          tagName: match[1]
+        })
+      }
+    }
+  })
+
+  const seen = {}
+
+  return structs.filter((item) => {
+    const key = item.name
+    if (!key || seen[key]) return false
+    seen[key] = true
+
+    return true
+  })
+}
+
+function parseArrayDimensions(suffix) {
+  const dimensions = []
+  const pattern = /\[([^\]]*)\]/g
+  let match
+
+  while ((match = pattern.exec(suffix || ''))) {
+    const text = String(match[1] || '').trim()
+    const value = Number(text)
+    if (!Number.isInteger(value) || value < 1) {
+      throw new Error('数组长度需为正整数')
+    }
+    dimensions.push(value)
+  }
+
+  return dimensions
+}
+
+function splitDeclarations(body) {
+  return String(body || '')
+    .split(';')
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+function splitDeclarators(statement) {
+  return String(statement || '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+function parseFirstDeclarator(text) {
+  const match = String(text || '').match(/^(.+?)\s+(\**\s*(?:[A-Za-z_]\w*)?(?:\s*\[[^\]]*\])*(?:\s*:\s*\d+)?)$/)
+  if (!match) return null
+
+  return {
+    declarator: match[2],
+    typeText: match[1]
+  }
+}
+
+function parseDeclarator(text) {
+  const rawText = String(text || '')
+  const bitWidthMatch = rawText.match(/:\s*(\d+)\s*$/)
+  const cleaned = rawText
+    .replace(/=.*/, '')
+    .replace(/:\s*\d+\s*$/, '')
+    .replace(/\*/g, '')
+    .trim()
+
+  if (!cleaned && bitWidthMatch) {
+    return {
+      arrayDimensions: [],
+      bitWidth: Number(bitWidthMatch[1]),
+      name: ''
+    }
+  }
+
+  const match = cleaned.match(/^([A-Za-z_]\w*)\s*((?:\[[^\]]*\])*)$/)
+  if (!match) return null
+
+  return {
+    arrayDimensions: parseArrayDimensions(match[2]),
+    bitWidth: bitWidthMatch ? Number(bitWidthMatch[1]) : null,
+    name: match[1]
+  }
+}
+
+module.exports = {
+  createAliasMap,
+  findStruct,
+  findStructs,
+  normalizeLookupName,
+  normalizeTypeText,
+  parseDeclarator,
+  parseFirstDeclarator,
+  resolveType,
+  splitDeclarations,
+  splitDeclarators,
+  stripComments
+}

+ 177 - 0
domain/parameter-groups/struct-layout.js

@@ -0,0 +1,177 @@
+const {
+  normalizeTypeText,
+  parseDeclarator,
+  parseFirstDeclarator,
+  resolveType,
+  splitDeclarations,
+  splitDeclarators
+} = require('./struct-c-syntax.js')
+
+function isAsciiArray(typeText, dataType, name, arrayLength) {
+  if (!arrayLength || arrayLength < 2 || arrayLength > 32) return false
+
+  const normalizedType = normalizeTypeText(typeText).toLowerCase()
+  if (normalizedType === 'char' || normalizedType === 'signed char') return true
+
+  return dataType === 'uint8_t' && /(^|_)(model|name|text|str|string|chip|version|ver|serial|sn)($|_)/i.test(name)
+}
+
+function getDataTypeByteLength(dataType) {
+  if (dataType === 'float' || dataType === 'int32_t' || dataType === 'uint32_t') return 4
+  if (dataType === 'int8_t' || dataType === 'uint8_t') return 1
+
+  return 2
+}
+
+function getBitFieldDataType(bitWidth) {
+  const width = Math.max(1, Math.round(Number(bitWidth) || 1))
+  if (width <= 8) return 'uint8_t'
+  if (width <= 16) return 'uint16_t'
+
+  return 'uint32_t'
+}
+
+function isBitType(typeText) {
+  return normalizeTypeText(typeText).toLowerCase() === 'bit'
+}
+
+function alignLayoutToByte(layoutState) {
+  if (layoutState.bitOffset % 8 !== 0) {
+    layoutState.bitOffset += 8 - (layoutState.bitOffset % 8)
+  }
+}
+
+function getLayoutByteStart(layoutState) {
+  return Math.floor(layoutState.bitOffset / 8)
+}
+
+function advanceLayoutBytes(layoutState, byteLength) {
+  layoutState.bitOffset += Math.max(1, Number(byteLength) || 1) * 8
+}
+
+function createBitFieldRegister(field, bitWidth, layoutState, name) {
+  const width = Math.max(0, Math.round(Number(bitWidth) || 0))
+
+  if (width === 0) {
+    alignLayoutToByte(layoutState)
+    return []
+  }
+
+  const byteStart = getLayoutByteStart(layoutState)
+  const bitOffset = layoutState.bitOffset % 8
+  layoutState.bitOffset += width
+
+  if (!name) return []
+
+  return [{
+    bitOffset,
+    bitWidth: width,
+    byteStart,
+    dataType: getBitFieldDataType(width),
+    isBitField: true,
+    name,
+    unit: 'bit'
+  }]
+}
+
+function createRegisterFromField(field, dataType, originalTypeText, layoutState) {
+  const arrayLength = field.arrayDimensions.reduce((total, value) => total * value, 1)
+  const hasArray = field.arrayDimensions.length > 0
+  const bitFieldWidth = field.bitWidth !== null && field.bitWidth !== undefined
+    ? field.bitWidth
+    : (isBitType(originalTypeText) ? 1 : null)
+
+  if (bitFieldWidth !== null && bitFieldWidth !== undefined) {
+    if (hasArray) {
+      const registers = []
+      for (let index = 0; index < arrayLength; index += 1) {
+        registers.push(...createBitFieldRegister(
+          field,
+          bitFieldWidth,
+          layoutState,
+          field.name ? `${field.name}[${index}]` : ''
+        ))
+      }
+
+      return registers
+    }
+
+    return createBitFieldRegister(field, bitFieldWidth, layoutState, field.name)
+  }
+
+  alignLayoutToByte(layoutState)
+
+  if (hasArray && isAsciiArray(originalTypeText, dataType, field.name, arrayLength)) {
+    const byteStart = getLayoutByteStart(layoutState)
+    advanceLayoutBytes(layoutState, arrayLength)
+
+    return [{
+      byteStart,
+      dataType: 'ascii',
+      name: field.name,
+      textByteLength: String(arrayLength)
+    }]
+  }
+
+  if (!hasArray) {
+    const byteStart = getLayoutByteStart(layoutState)
+    advanceLayoutBytes(layoutState, getDataTypeByteLength(dataType))
+
+    return [{
+      byteStart,
+      dataType,
+      name: field.name
+    }]
+  }
+
+  const registers = []
+  for (let index = 0; index < arrayLength; index += 1) {
+    const byteStart = getLayoutByteStart(layoutState)
+    advanceLayoutBytes(layoutState, getDataTypeByteLength(dataType))
+    registers.push({
+      byteStart,
+      dataType,
+      name: `${field.name}[${index}]`
+    })
+  }
+
+  return registers
+}
+
+function parseStructFields(body, aliases) {
+  const registers = []
+  const layoutState = {
+    bitOffset: 0
+  }
+  const declarations = splitDeclarations(body)
+
+  declarations.forEach((statement) => {
+    if (!statement || statement.indexOf('(') >= 0) return
+
+    const parts = splitDeclarators(statement)
+    if (!parts.length) return
+
+    const first = parseFirstDeclarator(parts[0])
+    if (!first) return
+
+    const dataType = resolveType(first.typeText, aliases)
+    if (!dataType) return
+
+    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))
+    })
+  })
+
+  return registers.map((register) => ({
+    ...register,
+    structByteLength: Math.ceil(layoutState.bitOffset / 8)
+  }))
+}
+
+module.exports = {
+  parseStructFields
+}

+ 131 - 0
domain/parameter-groups/struct-parser.js

@@ -0,0 +1,131 @@
+const {
+  createAliasMap,
+  findStruct,
+  findStructs,
+  normalizeLookupName,
+  normalizeTypeText,
+  parseDeclarator,
+  splitDeclarators,
+  stripComments
+} = require('./struct-c-syntax.js')
+const {
+  parseStructFields
+} = require('./struct-layout.js')
+
+const STRUCT_VARIABLE_QUALIFIERS = {
+  code: true,
+  const: true,
+  data: true,
+  extern: true,
+  idata: true,
+  pdata: true,
+  static: true,
+  volatile: true,
+  xdata: true
+}
+
+function parseStructDefinition(sourceText) {
+  const source = stripComments(sourceText)
+  const aliases = createAliasMap(source)
+  const structInfo = findStruct(source)
+  if (!structInfo) {
+    throw new Error('未找到结构体定义')
+  }
+
+  const registers = parseStructFields(structInfo.body, aliases)
+  if (!registers.length) {
+    throw new Error('结构体中没有可识别的变量定义')
+  }
+
+  return {
+    name: structInfo.name || 'Struct',
+    registers,
+    structName: structInfo.name || 'Struct'
+  }
+}
+
+function getStructNameAliases(structInfo, aliases) {
+  const names = [structInfo.name]
+
+  if (structInfo.tagName) names.push(`struct ${structInfo.tagName}`)
+
+  Object.keys(aliases || {}).forEach((aliasName) => {
+    if (aliases[aliasName] === structInfo.name) names.push(aliasName)
+  })
+
+  return names.filter(Boolean)
+}
+
+function normalizeVariableTypeText(typeText, aliases) {
+  const normalized = normalizeTypeText(typeText)
+  if (!normalized) return ''
+
+  const tokens = normalized
+    .split(/\s+/)
+    .filter((token) => !STRUCT_VARIABLE_QUALIFIERS[token])
+
+  const compact = tokens.join(' ')
+  if (aliases && aliases[compact]) return aliases[compact]
+
+  return compact
+}
+
+function parseStructVariables(source, structs, aliases) {
+  const variablesByName = {}
+
+  structs.forEach((structInfo) => {
+    const structNames = getStructNameAliases(structInfo, aliases)
+
+    structNames.forEach((structName) => {
+      const escaped = structName.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
+
+          const variableInfo = {
+            arrayDimensions: field.arrayDimensions,
+            name: field.name,
+            registers: structInfo.registers,
+            structName: structInfo.name
+          }
+          variablesByName[field.name] = variableInfo
+          variablesByName[field.name.replace(/^_+/, '').toLowerCase()] = variableInfo
+          variablesByName[normalizeLookupName(field.name)] = variableInfo
+        })
+      }
+    })
+  })
+
+  return variablesByName
+}
+
+function parseStructCatalog(sourceText) {
+  const source = stripComments(sourceText)
+  const aliases = createAliasMap(source)
+  const structs = findStructs(source).map((structInfo) => ({
+    ...structInfo,
+    registers: parseStructFields(structInfo.body, aliases)
+  })).filter((structInfo) => structInfo.registers.length)
+
+  if (!structs.length) {
+    throw new Error('未找到可识别的结构体定义')
+  }
+
+  return {
+    structs,
+    variablesByName: parseStructVariables(source, structs, aliases)
+  }
+}
+
+module.exports = {
+  parseStructCatalog,
+  parseStructDefinition,
+  stripComments
+}

+ 350 - 0
domain/parameter-groups/value-codec.js

@@ -0,0 +1,350 @@
+const {
+  normalizeTextValue
+} = require('../../utils/base-utils.js')
+const {
+  bytesToWords,
+  wordsToBytes
+} = require('../../utils/binary-utils.js')
+const {
+  alignEvenByteLength,
+  getBitFieldByteLength,
+  getBitFieldMaxValue,
+  getDataType,
+  getDataTypeIndex,
+  getEncodeByteLimit,
+  getRegisterByteLength,
+  getRegisterTextByteLength,
+  getRegisterValueTypeLabel,
+  getRegisterWordCount,
+  getRegisterWordCountAtOffset,
+  isBitFieldRegister,
+  isBitRegisterType,
+  isByteRegister,
+  isHexRegister,
+  isNumericRegister,
+  isTextRegister,
+  normalizeBitOffset,
+  normalizeBitWidth,
+  normalizeTextByteLength,
+  supportsRange,
+  supportsUnit
+} = require('./value-types.js')
+const {
+  decodeTextBytes,
+  encodeTextBytes
+} = require('./value-text.js')
+const {
+  bytesToFloatValue,
+  bytesToSignedInteger,
+  bytesToUnsignedInteger,
+  floatToBytes,
+  formatFloatValue,
+  formatHexValue,
+  formatIntegerValue,
+  parseNumberText,
+  unsignedIntegerToBytes,
+  validateNumericValue
+} = require('./value-number.js')
+
+function padWordHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+function formatRawWordText(words = []) {
+  if (!Array.isArray(words) || !words.length) return '--'
+
+  return words.map((word) => `0x${padWordHex(word)}`).join(' ')
+}
+
+function formatRawByteText(bytes = []) {
+  if (!Array.isArray(bytes) || !bytes.length) return '--'
+
+  return bytes.map((byte) => `0x${(Number(byte) & 0xFF).toString(16).toUpperCase().padStart(2, '0')}`).join(' ')
+}
+
+function formatRawByteTextWithDefault(bytes = [], byteLength = 1) {
+  const safeLength = Math.max(1, Math.floor(Number(byteLength) || 1))
+  const source = Array.isArray(bytes) ? bytes : []
+
+  return Array.from({ length: safeLength }, (_, index) => (
+    `0x${(Number(source[index] || 0) & 0xFF).toString(16).toUpperCase().padStart(2, '0')}`
+  )).join(' ')
+}
+
+function parseCoilValue(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text || text === '--') return null
+  if (['1', 'true', 'TRUE', 'on', 'ON', '开'].includes(text)) return 1
+  if (['0', 'false', 'FALSE', 'off', 'OFF', '关'].includes(text)) return 0
+
+  const coilValue = Number(text)
+  return Number.isFinite(coilValue) ? (coilValue ? 1 : 0) : null
+}
+
+function parseBitFieldValue(register, valueText) {
+  const text = normalizeTextValue(valueText).trim()
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+  let parsed = parseNumberText(text, 'uint32_t')
+  const maxValue = getBitFieldMaxValue(register)
+
+  if (parsed === null && bitWidth === 1) parsed = parseCoilValue(text)
+  if (parsed === null) return null
+  if (Math.round(parsed) !== parsed || parsed < 0 || parsed > maxValue) {
+    throw new Error(`${register.name || '位域'} 超出 0 - ${maxValue} 范围`)
+  }
+
+  return Math.round(parsed)
+}
+
+function decodeBitFieldBytes(register, bytes = []) {
+  const bitOffset = normalizeBitOffset(register.bitOffset)
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+  let byteIndex = 0
+  let currentBitOffset = bitOffset
+  let multiplier = 1
+  let remaining = bitWidth
+  let value = 0
+
+  while (remaining > 0 && byteIndex < bytes.length) {
+    const take = Math.min(8 - currentBitOffset, remaining)
+    const mask = (1 << take) - 1
+    const part = ((Number(bytes[byteIndex]) & 0xFF) >> currentBitOffset) & mask
+
+    value += part * multiplier
+    multiplier *= Math.pow(2, take)
+    remaining -= take
+    byteIndex += 1
+    currentBitOffset = 0
+  }
+
+  return remaining > 0 ? null : value
+}
+
+function encodeBitFieldIntoBytes(register, bytes, byteStart = 0) {
+  const valueText = normalizeTextValue(register.inputValue)
+  let value = parseBitFieldValue(register, valueText)
+  const bitOffset = normalizeBitOffset(register.bitOffset)
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+  let byteIndex = Math.max(0, Math.floor(Number(byteStart) || 0))
+  let currentBitOffset = bitOffset
+  let remaining = bitWidth
+
+  if (value === null) return null
+
+  while (remaining > 0) {
+    const take = Math.min(8 - currentBitOffset, remaining)
+    const mask = (1 << take) - 1
+    const shiftedMask = (mask << currentBitOffset) & 0xFF
+    const part = value & mask
+
+    if (byteIndex >= bytes.length) return null
+    bytes[byteIndex] = ((Number(bytes[byteIndex]) & 0xFF) & (~shiftedMask & 0xFF))
+      | ((part << currentBitOffset) & shiftedMask)
+
+    value = Math.floor(value / Math.pow(2, take))
+    remaining -= take
+    byteIndex += 1
+    currentBitOffset = 0
+  }
+
+  return bytes
+}
+
+function encodeBitFieldBytes(register) {
+  const bytes = Array.from({ length: getBitFieldByteLength(register) }, () => 0)
+
+  return encodeBitFieldIntoBytes(register, bytes, 0)
+}
+
+function getRegisterDataBytes(register, words) {
+  const dataType = getDataType(register.dataType).key
+  const byteLength = getRegisterByteLength(dataType, register)
+  const byteOffset = Math.max(0, Math.floor(Number(register.byteOffset) || 0))
+  const sourceBytes = wordsToBytes(words, Math.max(0, (Array.isArray(words) ? words.length : 0) * 2))
+
+  return sourceBytes.slice(byteOffset, byteOffset + byteLength)
+}
+
+function encodeRegisterBytes(register) {
+  const dataType = getDataType(register.dataType).key
+  const valueText = normalizeTextValue(register.inputValue)
+  const byteLength = getRegisterByteLength(dataType, register)
+
+  if (isBitFieldRegister(register)) {
+    return encodeBitFieldBytes(register)
+  }
+
+  if (isTextRegister(dataType)) {
+    const byteLimit = getEncodeByteLimit(register)
+    const bytes = encodeTextBytes(valueText, dataType, byteLimit)
+    const paddedBytes = bytes.slice()
+
+    while (paddedBytes.length < byteLength) {
+      paddedBytes.push(0)
+    }
+
+    return paddedBytes.slice(0, byteLength)
+  }
+
+  const numberValue = parseNumberText(valueText, dataType)
+  if (numberValue === null) return null
+  validateNumericValue(register, numberValue)
+
+  if (dataType === 'float') return floatToBytes(numberValue)
+
+  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)
+  }
+  if (dataType === 'int32_t' || dataType === 'uint32_t') {
+    return unsignedIntegerToBytes(rounded, 4)
+  }
+
+  return unsignedIntegerToBytes(rounded, byteLength)
+}
+
+function encodeRegisterWords(register) {
+  const dataType = getDataType(register.dataType).key
+  const bytes = encodeRegisterBytes(register)
+
+  if (!Array.isArray(bytes)) return null
+  if (isByteRegister(dataType)) return [bytes[0] & 0xFF]
+
+  return bytesToWords(bytes.length % 2 === 0 ? bytes : bytes.concat(0))
+}
+
+function decodeRegisterValue(register, words) {
+  const dataType = getDataType(register.dataType).key
+
+  if (!Array.isArray(words) || words.length < getRegisterWordCountAtOffset(dataType, register.byteOffset || 0, register)) return null
+
+  const bytes = getRegisterDataBytes(register, words)
+  const byteLength = getRegisterByteLength(dataType, register)
+  if (bytes.length < byteLength) return null
+
+  if (isBitFieldRegister(register)) {
+    return decodeBitFieldBytes(register, bytes)
+  }
+
+  if (isTextRegister(dataType)) {
+    return decodeTextBytes(bytes.slice(0, getEncodeByteLimit(register)), dataType)
+  }
+  if (dataType === 'float') {
+    return bytesToFloatValue(bytes)
+  }
+  if (dataType === 'int8_t') {
+    const byteValue = bytes[0] & 0xFF
+    return byteValue & 0x80 ? byteValue - 0x100 : byteValue
+  }
+  if (dataType === 'uint8_t') {
+    return bytes[0] & 0xFF
+  }
+  if (dataType === 'int16_t') {
+    return bytesToSignedInteger(bytes.slice(0, 2))
+  }
+  if (dataType === 'uint16_t') {
+    return bytesToUnsignedInteger(bytes.slice(0, 2))
+  }
+  if (dataType === 'hex') {
+    return bytesToUnsignedInteger(bytes.slice(0, 2))
+  }
+
+  if (dataType === 'int32_t') {
+    return bytesToSignedInteger(bytes.slice(0, 4))
+  }
+
+  return bytesToUnsignedInteger(bytes.slice(0, 4))
+}
+
+function formatRegisterValue(register, rawValue) {
+  if (rawValue === null || rawValue === undefined) return '--'
+
+  const dataType = getDataType(register.dataType).key
+  if (isTextRegister(dataType)) return normalizeTextValue(rawValue)
+  if (dataType === 'hex') return formatHexValue(rawValue)
+  if (dataType === 'float') return formatFloatValue(rawValue)
+
+  return formatIntegerValue(rawValue, dataType)
+}
+
+function formatCoilDisplayValue(value) {
+  return Number(value) ? '1' : '0'
+}
+
+function registerTypeIsBit(register) {
+  return !!register && isBitRegisterType(register.registerType)
+}
+
+function validateRegisterValue(register, value) {
+  const valueText = normalizeTextValue(
+    value === undefined || value === null ? '' : value
+  ).trim()
+  if (!valueText || valueText === '--') return true
+
+  if (registerTypeIsBit(register)) {
+    if (parseCoilValue(valueText) === null) {
+      throw new Error(`${register.name || '线圈'} 只能填写 0 或 1`)
+    }
+    return true
+  }
+
+  if (isBitFieldRegister(register)) {
+    if (parseBitFieldValue(register, valueText) === null) {
+      throw new Error(`${register.name || '位域'} 输入值无效`)
+    }
+    return true
+  }
+
+  const dataType = getDataType(register.dataType).key
+  if (isTextRegister(dataType)) {
+    encodeTextBytes(valueText, dataType, getEncodeByteLimit(register))
+    return true
+  }
+
+  const numberValue = parseNumberText(valueText, dataType)
+  if (numberValue === null) {
+    throw new Error(`${register.name || '寄存器'} 输入值无效`)
+  }
+
+  return validateNumericValue(register, numberValue)
+}
+
+module.exports = {
+  alignEvenByteLength,
+  decodeRegisterValue,
+  encodeBitFieldIntoBytes,
+  encodeRegisterBytes,
+  encodeRegisterWords,
+  encodeTextBytes,
+  formatCoilDisplayValue,
+  formatRawByteText,
+  formatRawByteTextWithDefault,
+  formatRawWordText,
+  formatRegisterValue,
+  getBitFieldByteLength,
+  getBitFieldMaxValue,
+  getDataType,
+  getDataTypeIndex,
+  getEncodeByteLimit,
+  getRegisterByteLength,
+  getRegisterTextByteLength,
+  getRegisterValueTypeLabel,
+  getRegisterWordCount,
+  getRegisterWordCountAtOffset,
+  isBitFieldRegister,
+  isBitRegisterType,
+  isByteRegister,
+  isHexRegister,
+  isNumericRegister,
+  isTextRegister,
+  normalizeBitOffset,
+  normalizeBitWidth,
+  normalizeTextByteLength,
+  parseCoilValue,
+  parseNumberText,
+  registerTypeIsBit,
+  supportsRange,
+  supportsUnit,
+  validateRegisterValue
+}

+ 281 - 0
domain/parameter-groups/value-formula.js

@@ -0,0 +1,281 @@
+const {
+  formatFixedValue
+} = require('../../utils/number-format.js')
+
+const TOKEN_NUMBER = 'number'
+const TOKEN_IDENTIFIER = 'identifier'
+const TOKEN_OPERATOR = 'operator'
+const TOKEN_LEFT_PAREN = 'leftParen'
+const TOKEN_RIGHT_PAREN = 'rightParen'
+const ALLOWED_IDENTIFIERS = [
+  'x',
+  'value',
+  'rawValue',
+  'caveFreq',
+  'refVolt',
+  'ampGain',
+  'rsShunt',
+  'busDiv',
+  'alongDiv'
+]
+
+function tokenizeFormula(formulaText) {
+  const source = String(formulaText || '').trim()
+  const tokens = []
+  let index = 0
+
+  while (index < source.length) {
+    const char = source[index]
+
+    if (/\s/.test(char)) {
+      index += 1
+      continue
+    }
+
+    if (/[0-9.]/.test(char)) {
+      const rest = source.slice(index)
+      const match = rest.match(/^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i)
+      if (!match) throw new Error('公式数字无效')
+
+      tokens.push({
+        type: TOKEN_NUMBER,
+        value: Number(match[0])
+      })
+      index += match[0].length
+      continue
+    }
+
+    if (/[A-Za-z_]/.test(char)) {
+      const rest = source.slice(index)
+      const match = rest.match(/^[A-Za-z_][A-Za-z0-9_]*/)
+      const name = match ? match[0] : ''
+      if (ALLOWED_IDENTIFIERS.indexOf(name) < 0) {
+        throw new Error(`公式变量 ${name || char} 不支持`)
+      }
+
+      tokens.push({
+        type: TOKEN_IDENTIFIER,
+        value: name
+      })
+      index += name.length
+      continue
+    }
+
+    if ('+-*/'.indexOf(char) >= 0) {
+      tokens.push({
+        type: TOKEN_OPERATOR,
+        value: char
+      })
+      index += 1
+      continue
+    }
+
+    if (char === '(') {
+      tokens.push({
+        type: TOKEN_LEFT_PAREN,
+        value: char
+      })
+      index += 1
+      continue
+    }
+
+    if (char === ')') {
+      tokens.push({
+        type: TOKEN_RIGHT_PAREN,
+        value: char
+      })
+      index += 1
+      continue
+    }
+
+    throw new Error(`公式包含不支持字符 ${char}`)
+  }
+
+  return tokens
+}
+
+function getOperatorPrecedence(operator) {
+  if (operator === 'u+' || operator === 'u-') return 3
+  if (operator === '*' || operator === '/') return 2
+  if (operator === '+' || operator === '-') return 1
+
+  return 0
+}
+
+function operatorIsRightAssociative(operator) {
+  return operator === 'u+' || operator === 'u-'
+}
+
+function toRpn(tokens) {
+  const output = []
+  const operators = []
+  let previousToken = null
+
+  tokens.forEach((token) => {
+    if (token.type === TOKEN_NUMBER || token.type === TOKEN_IDENTIFIER) {
+      output.push(token)
+      previousToken = token
+      return
+    }
+
+    if (token.type === TOKEN_OPERATOR) {
+      const unary = !previousToken
+        || previousToken.type === TOKEN_OPERATOR
+        || previousToken.type === TOKEN_LEFT_PAREN
+      const operator = unary && (token.value === '+' || token.value === '-')
+        ? `u${token.value}`
+        : token.value
+      const precedence = getOperatorPrecedence(operator)
+
+      while (operators.length) {
+        const top = operators[operators.length - 1]
+        if (top.type !== TOKEN_OPERATOR) break
+
+        const topPrecedence = getOperatorPrecedence(top.value)
+        if (
+          topPrecedence > precedence
+          || (topPrecedence === precedence && !operatorIsRightAssociative(operator))
+        ) {
+          output.push(operators.pop())
+          continue
+        }
+        break
+      }
+
+      operators.push({
+        type: TOKEN_OPERATOR,
+        value: operator
+      })
+      previousToken = token
+      return
+    }
+
+    if (token.type === TOKEN_LEFT_PAREN) {
+      operators.push(token)
+      previousToken = token
+      return
+    }
+
+    if (token.type === TOKEN_RIGHT_PAREN) {
+      let matched = false
+      while (operators.length) {
+        const top = operators.pop()
+        if (top.type === TOKEN_LEFT_PAREN) {
+          matched = true
+          break
+        }
+        output.push(top)
+      }
+
+      if (!matched) throw new Error('公式括号不匹配')
+      previousToken = token
+    }
+  })
+
+  while (operators.length) {
+    const operator = operators.pop()
+    if (operator.type === TOKEN_LEFT_PAREN) throw new Error('公式括号不匹配')
+    output.push(operator)
+  }
+
+  return output
+}
+
+function getContextValue(name, context) {
+  if (name === 'x' || name === 'value' || name === 'rawValue') return context.x
+
+  return context[name]
+}
+
+function evaluateRpn(rpn, context) {
+  const stack = []
+
+  rpn.forEach((token) => {
+    if (token.type === TOKEN_NUMBER) {
+      stack.push(token.value)
+      return
+    }
+
+    if (token.type === TOKEN_IDENTIFIER) {
+      const value = Number(getContextValue(token.value, context))
+      if (!Number.isFinite(value)) throw new Error(`公式变量 ${token.value} 无效`)
+      stack.push(value)
+      return
+    }
+
+    if (token.type !== TOKEN_OPERATOR) return
+
+    if (token.value === 'u+' || token.value === 'u-') {
+      if (!stack.length) throw new Error('公式运算无效')
+      const value = stack.pop()
+      stack.push(token.value === 'u-' ? -value : value)
+      return
+    }
+
+    if (stack.length < 2) throw new Error('公式运算无效')
+    const right = stack.pop()
+    const left = stack.pop()
+    if (token.value === '+') stack.push(left + right)
+    if (token.value === '-') stack.push(left - right)
+    if (token.value === '*') stack.push(left * right)
+    if (token.value === '/') stack.push(left / right)
+  })
+
+  if (stack.length !== 1 || !Number.isFinite(stack[0])) {
+    throw new Error('公式计算结果无效')
+  }
+
+  return stack[0]
+}
+
+function formatFormulaNumber(value) {
+  return formatFixedValue(value, 6).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
+}
+
+function evaluateValueFormula(formulaText, rawValue, codeInfoContext = {}) {
+  const formula = String(formulaText || '').trim()
+  const numberValue = Number(rawValue)
+  if (!formula || !Number.isFinite(numberValue)) {
+    return {
+      ok: false,
+      value: numberValue,
+      text: Number.isFinite(numberValue) ? formatFormulaNumber(numberValue) : '--'
+    }
+  }
+
+  try {
+    const context = {
+      ...codeInfoContext,
+      x: numberValue
+    }
+    const value = evaluateRpn(toRpn(tokenizeFormula(formula)), context)
+
+    return {
+      ok: true,
+      value,
+      text: formatFormulaNumber(value)
+    }
+  } catch (error) {
+    return {
+      errorText: error && error.message ? error.message : '公式无效',
+      ok: false,
+      value: numberValue,
+      text: formatFormulaNumber(numberValue)
+    }
+  }
+}
+
+function validateValueFormula(formulaText) {
+  const formula = String(formulaText || '').trim()
+  if (!formula) return true
+
+  toRpn(tokenizeFormula(formula))
+
+  return true
+}
+
+module.exports = {
+  ALLOWED_IDENTIFIERS,
+  evaluateValueFormula,
+  validateValueFormula
+}

+ 196 - 0
domain/parameter-groups/value-number.js

@@ -0,0 +1,196 @@
+const {
+  formatFixedValue
+} = require('../../utils/number-format.js')
+const {
+  getDataType,
+  isHexRegister
+} = require('./value-types.js')
+
+function padWordHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+function formatIntegerValue(value, dataType) {
+  const type = getDataType(dataType).key
+  const numberValue = Number(value)
+
+  if (!Number.isFinite(numberValue)) return '--'
+  if (type === 'int8_t') return String(((Math.round(numberValue) << 24) >> 24))
+  if (type === 'uint8_t') return String(Math.round(numberValue) & 0xFF)
+  if (type === 'int16_t') return String(((Math.round(numberValue) << 16) >> 16))
+  if (type === 'uint16_t') return String(Math.round(numberValue) & 0xFFFF)
+  if (type === 'int32_t') return String((Math.round(numberValue) | 0))
+  if (type === 'uint32_t') return String(Math.round(numberValue) >>> 0)
+
+  return String(Math.round(numberValue))
+}
+
+function formatHexValue(value) {
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  return `0x${padWordHex(Math.round(numberValue) & 0xFFFF)}`
+}
+
+function formatFloatValue(value) {
+  return formatFixedValue(value, 6).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
+}
+
+function parseIntegerText(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const isHex = /^[-+]?0x[0-9a-f]+$/i.test(text) || /^0x[0-9a-f]+$/i.test(text)
+  const parsed = isHex ? parseInt(text, 16) : Number(text)
+
+  return Number.isFinite(parsed) ? parsed : null
+}
+
+function parseHexText(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+  if (/^[0-9A-F]{1,4}$/i.test(hexText)) {
+    const parsedHex = parseInt(hexText, 16)
+    return Number.isFinite(parsedHex) ? parsedHex : null
+  }
+
+  return null
+}
+
+function getNumericRange(dataType) {
+  const type = getDataType(dataType).key
+
+  if (type === 'int8_t') return { max: 127, min: -128 }
+  if (type === 'uint8_t') return { max: 0xFF, min: 0 }
+  if (type === 'int16_t') return { max: 32767, min: -32768 }
+  if (type === 'uint16_t') return { max: 0xFFFF, min: 0 }
+  if (type === 'int32_t') return { max: 2147483647, min: -2147483648 }
+  if (type === 'uint32_t') return { max: 0xFFFFFFFF, min: 0 }
+  if (type === 'hex') return { max: 0xFFFF, min: 0 }
+
+  return { max: Number.POSITIVE_INFINITY, min: Number.NEGATIVE_INFINITY }
+}
+
+function parseNumberText(value, dataType) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text || text === '--') return null
+
+  if (getDataType(dataType).key === 'float') {
+    const parsed = Number(text)
+    return Number.isFinite(parsed) ? parsed : null
+  }
+  if (isHexRegister(dataType)) return parseHexText(text)
+
+  return parseIntegerText(text)
+}
+
+function parseRangeBoundary(value, dataType, label) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const parsed = parseNumberText(text, dataType)
+  if (parsed === null) {
+    throw new Error(`${label}无效`)
+  }
+
+  return parsed
+}
+
+function validateNumericValue(register, value) {
+  const dataType = getDataType(register.dataType).key
+  const range = getNumericRange(dataType)
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return false
+
+  if (dataType !== 'float' && Math.round(numberValue) !== numberValue) {
+    throw new Error(`${register.name || '寄存器'} 需要整数`)
+  }
+  if (numberValue < range.min || numberValue > range.max) {
+    throw new Error(`${register.name || '寄存器'} 超出 ${dataType} 范围`)
+  }
+
+  const minValue = parseRangeBoundary(register.minValue, dataType, `${register.name || '寄存器'} 最小值`)
+  const maxValue = parseRangeBoundary(register.maxValue, dataType, `${register.name || '寄存器'} 最大值`)
+  if (minValue !== null && numberValue < minValue) {
+    throw new Error(`${register.name || '寄存器'} 小于限制最小值`)
+  }
+  if (maxValue !== null && numberValue > maxValue) {
+    throw new Error(`${register.name || '寄存器'} 大于限制最大值`)
+  }
+
+  return true
+}
+
+function floatToBytes(value) {
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  view.setFloat32(0, Number(value), false)
+
+  return [
+    view.getUint8(0),
+    view.getUint8(1),
+    view.getUint8(2),
+    view.getUint8(3)
+  ]
+}
+
+function bytesToFloatValue(bytes) {
+  if (!Array.isArray(bytes) || bytes.length < 4) return null
+
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  for (let index = 0; index < 4; index += 1) {
+    view.setUint8(index, Number(bytes[index]) & 0xFF)
+  }
+
+  return view.getFloat32(0, false)
+}
+
+function unsignedIntegerToBytes(value, byteLength) {
+  let numberValue = Math.round(Number(value) || 0)
+  const bytes = []
+
+  if (numberValue < 0) {
+    numberValue += Math.pow(2, byteLength * 8)
+  }
+
+  for (let index = byteLength - 1; index >= 0; index -= 1) {
+    bytes[index] = numberValue & 0xFF
+    numberValue = Math.floor(numberValue / 0x100)
+  }
+
+  return bytes
+}
+
+function bytesToUnsignedInteger(bytes) {
+  return bytes.reduce((value, byte) => ((value * 0x100) + (Number(byte) & 0xFF)), 0)
+}
+
+function bytesToSignedInteger(bytes) {
+  const unsignedValue = bytesToUnsignedInteger(bytes)
+  const signLimit = Math.pow(2, bytes.length * 8 - 1)
+  const fullRange = Math.pow(2, bytes.length * 8)
+
+  return unsignedValue >= signLimit ? unsignedValue - fullRange : unsignedValue
+}
+
+module.exports = {
+  bytesToFloatValue,
+  bytesToSignedInteger,
+  bytesToUnsignedInteger,
+  floatToBytes,
+  formatFloatValue,
+  formatHexValue,
+  formatIntegerValue,
+  getNumericRange,
+  parseHexText,
+  parseIntegerText,
+  parseNumberText,
+  parseRangeBoundary,
+  unsignedIntegerToBytes,
+  validateNumericValue
+}

+ 101 - 0
domain/parameter-groups/value-text.js

@@ -0,0 +1,101 @@
+const {
+  normalizeTextValue
+} = require('../../utils/base-utils.js')
+const {
+  trimTrailingNullBytes
+} = require('../../utils/binary-utils.js')
+const {
+  MAX_TEXT_BYTE_LENGTH
+} = require('./constants.js')
+const {
+  getDataType
+} = require('./value-types.js')
+
+function encodeAsciiBytes(text, byteLimit = 32) {
+  const bytes = []
+  const stringValue = normalizeTextValue(text)
+
+  for (let index = 0; index < stringValue.length; index += 1) {
+    const code = stringValue.charCodeAt(index)
+    if (code > 0x7F) {
+      throw new Error('ASCII 文本只能包含 0x00 - 0x7F 字符')
+    }
+    bytes.push(code)
+    if (bytes.length > byteLimit) break
+  }
+
+  if (bytes.length > byteLimit) {
+    throw new Error(`长文本最长 ${byteLimit} 字节`)
+  }
+
+  return bytes
+}
+
+function encodeUtf8Bytes(text, byteLimit = 32) {
+  const bytes = []
+  const encoded = encodeURIComponent(normalizeTextValue(text))
+
+  for (let index = 0; index < encoded.length; index += 1) {
+    const char = encoded[index]
+    if (char === '%') {
+      const byte = parseInt(encoded.slice(index + 1, index + 3), 16)
+      if (!Number.isFinite(byte)) break
+      bytes.push(byte & 0xFF)
+      index += 2
+    } else {
+      bytes.push(char.charCodeAt(0) & 0xFF)
+    }
+    if (bytes.length > byteLimit) break
+  }
+
+  if (bytes.length > byteLimit) {
+    throw new Error(`长文本最长 ${byteLimit} 字节`)
+  }
+
+  return bytes
+}
+
+function decodeAsciiBytes(bytes = []) {
+  return String.fromCharCode.apply(null, trimTrailingNullBytes(bytes).map((byte) => byte & 0xFF))
+}
+
+function decodeUtf8Bytes(bytes = []) {
+  const trimmed = trimTrailingNullBytes(bytes)
+  if (!trimmed.length) return ''
+
+  let encoded = ''
+
+  trimmed.forEach((byte) => {
+    encoded += `%${(byte & 0xFF).toString(16).padStart(2, '0').toUpperCase()}`
+  })
+
+  try {
+    return decodeURIComponent(encoded)
+  } catch (error) {
+    return decodeAsciiBytes(trimmed)
+  }
+}
+
+function encodeTextBytes(text, dataType, byteLimit = MAX_TEXT_BYTE_LENGTH) {
+  const normalizedType = getDataType(dataType).key
+
+  if (normalizedType === 'ascii') return encodeAsciiBytes(text, byteLimit)
+  return encodeUtf8Bytes(text, byteLimit)
+}
+
+function decodeTextBytes(bytes, dataType) {
+  const normalizedType = getDataType(dataType).key
+
+  return normalizedType === 'ascii'
+    ? decodeAsciiBytes(bytes)
+    : decodeUtf8Bytes(bytes)
+}
+
+module.exports = {
+  decodeAsciiBytes,
+  decodeTextBytes,
+  decodeUtf8Bytes,
+  encodeAsciiBytes,
+  encodeTextBytes,
+  encodeUtf8Bytes
+}

+ 156 - 0
domain/parameter-groups/value-types.js

@@ -0,0 +1,156 @@
+const {
+  DATA_TYPE_OPTIONS,
+  DEFAULT_DATA_TYPE,
+  DEFAULT_TEXT_BYTE_LENGTH,
+  GROUP_LAYOUT_STRUCT,
+  MAX_TEXT_BYTE_LENGTH
+} = require('./constants.js')
+
+function getDataType(dataType) {
+  return DATA_TYPE_OPTIONS.find((item) => item.key === dataType)
+    || DATA_TYPE_OPTIONS.find((item) => item.key === DEFAULT_DATA_TYPE)
+    || DATA_TYPE_OPTIONS[0]
+}
+
+function getDataTypeIndex(dataType) {
+  return Math.max(0, DATA_TYPE_OPTIONS.findIndex((item) => item.key === getDataType(dataType).key))
+}
+
+function normalizeTextByteLength(value, fallback = DEFAULT_TEXT_BYTE_LENGTH) {
+  const numberValue = Number(value)
+  const rounded = Number.isFinite(numberValue) ? Math.round(numberValue) : fallback
+
+  return Math.min(Math.max(rounded, 1), MAX_TEXT_BYTE_LENGTH)
+}
+
+function alignEvenByteLength(byteLength) {
+  const length = Math.max(1, Math.round(Number(byteLength) || 1))
+
+  return length % 2 === 0 ? length : length + 1
+}
+
+function getRegisterTextByteLength(register = {}) {
+  return normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+}
+
+function isStructLayout(layout) {
+  return layout === GROUP_LAYOUT_STRUCT
+}
+
+function isBitFieldRegister(register = {}) {
+  return !!register.isBitField
+}
+
+function normalizeBitOffset(value) {
+  const numberValue = Math.floor(Number(value) || 0)
+
+  return Math.min(Math.max(numberValue, 0), 7)
+}
+
+function normalizeBitWidth(value) {
+  const numberValue = Math.round(Number(value) || 1)
+
+  return Math.min(Math.max(numberValue, 1), 32)
+}
+
+function getBitFieldByteLength(register = {}) {
+  const bitOffset = normalizeBitOffset(register.bitOffset)
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+
+  return Math.max(1, Math.ceil((bitOffset + bitWidth) / 8))
+}
+
+function getBitFieldMaxValue(register = {}) {
+  const bitWidth = normalizeBitWidth(register.bitWidth)
+
+  return bitWidth >= 32 ? 0xFFFFFFFF : Math.pow(2, bitWidth) - 1
+}
+
+function getRegisterByteLength(dataType, register = {}) {
+  if (isBitFieldRegister(register)) return getBitFieldByteLength(register)
+
+  const type = getDataType(dataType)
+  if (type.kind === 'text') {
+    const byteLength = getRegisterTextByteLength(register)
+
+    return isStructLayout(register.layout) ? byteLength : alignEvenByteLength(byteLength)
+  }
+
+  return type.byteLength || ((type.wordCount || 1) * 2)
+}
+
+function getRegisterWordCount(dataType, register = {}) {
+  return Math.max(1, Math.ceil(getRegisterByteLength(dataType, register) / 2))
+}
+
+function getByteSpanWordCount(byteOffset, byteLength) {
+  return Math.max(1, Math.ceil((Math.max(0, Number(byteOffset) || 0) + Math.max(1, Number(byteLength) || 1)) / 2))
+}
+
+function getRegisterWordCountAtOffset(dataType, byteOffset, register = {}) {
+  const byteLength = getRegisterByteLength(dataType, register)
+  return getByteSpanWordCount(byteOffset, byteLength)
+}
+
+function isTextRegister(dataType) {
+  return getDataType(dataType).kind === 'text'
+}
+
+function getEncodeByteLimit(register) {
+  return isTextRegister(register.dataType) ? getRegisterTextByteLength(register) : getRegisterByteLength(register.dataType, register)
+}
+
+function isByteRegister(dataType) {
+  const key = getDataType(dataType).key
+
+  return key === 'int8_t' || key === 'uint8_t'
+}
+
+function isBitRegisterType(registerType) {
+  return registerType === 'coil' || registerType === 'discrete'
+}
+
+function isHexRegister(dataType) {
+  return getDataType(dataType).key === 'hex'
+}
+
+function isNumericRegister(dataType) {
+  return getDataType(dataType).kind === 'number'
+}
+
+function supportsRange(dataType) {
+  return isNumericRegister(dataType) || isHexRegister(dataType)
+}
+
+function supportsUnit(dataType) {
+  return isNumericRegister(dataType)
+}
+
+function getRegisterValueTypeLabel(dataType) {
+  return getDataType(dataType).label
+}
+
+module.exports = {
+  alignEvenByteLength,
+  getBitFieldByteLength,
+  getBitFieldMaxValue,
+  getDataType,
+  getDataTypeIndex,
+  getEncodeByteLimit,
+  getRegisterByteLength,
+  getRegisterTextByteLength,
+  getRegisterValueTypeLabel,
+  getRegisterWordCount,
+  getRegisterWordCountAtOffset,
+  isBitFieldRegister,
+  isBitRegisterType,
+  isByteRegister,
+  isHexRegister,
+  isNumericRegister,
+  isTextRegister,
+  normalizeBitOffset,
+  normalizeBitWidth,
+  normalizeTextByteLength,
+  supportsRange,
+  supportsUnit
+}

+ 20 - 13
domain/generic-modbus/code-info-parser.js → domain/storage-access/code-info-parser.js

@@ -1,15 +1,16 @@
 const {
   bytesToHex,
+  bytesToUtf8Text,
   trimTrailingNullBytes
 } = require('../../utils/binary-utils.js')
 
 const FIXED_HEADER_BYTE_LENGTH = 44
 const STRUCT_ENTRY_MIN_BYTE_LENGTH = 5
 const MEMORY_TYPE_AREAS = {
-  0x00: 'DATA',
-  0x01: 'IDATA',
-  0x02: 'XDATA',
-  0x03: 'CODE'
+  0x01: 'DATA',
+  0x02: 'IDATA',
+  0x03: 'XDATA',
+  0x04: 'CODE'
 }
 
 function toBytes(bytes) {
@@ -43,6 +44,10 @@ function readAscii(bytes, offset, byteLength) {
     .trim()
 }
 
+function readUtf8OrAscii(bytes, offset, byteLength) {
+  return bytesToUtf8Text(bytes.slice(offset, offset + byteLength)).trim()
+}
+
 function formatAddress(address) {
   return `0x${Number(address || 0).toString(16).toUpperCase().padStart(4, '0')}`
 }
@@ -75,17 +80,17 @@ function parseStructEntry(bytes, offset, entryLength, index) {
   }
 }
 
-function parseModbusCodeInfo(bytes) {
+function parseCodeInfo(bytes) {
   const source = toBytes(bytes)
   if (source.length < FIXED_HEADER_BYTE_LENGTH) {
-    throw new Error('Code信息块长度不足,无法解析 Modbus_Code_Info_t 固定头')
+    throw new Error('info 信息块长度不足,无法解析 Modbus_Code_Info_t 固定头')
   }
 
   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('Code信息块 struct_entry_len 无效')
+    throw new Error('info 信息块 struct_entry_len 无效')
   }
 
   const availableTableBytes = Math.max(0, source.length - FIXED_HEADER_BYTE_LENGTH)
@@ -109,9 +114,10 @@ function parseModbusCodeInfo(bytes) {
     byteLength,
     caveFreq: source[2] & 0xFF,
     chipModel: readAscii(source, 16, 8),
-    model: readAscii(source, 24, 16),
+    model: readUtf8OrAscii(source, 24, 16),
     rawHex: bytesToHex(source, ' '),
-    refVolt: source[3] & 0xFF,
+    refVolt: (source[3] & 0xFF) / 10,
+    refVoltRaw: source[3] & 0xFF,
     rsShunt: readUint16(source, 6),
     structCount,
     structEntryLength,
@@ -129,8 +135,8 @@ function createRegistersForByteSpan(entry) {
     registers.push({
       byteStart: offset,
       dataType: 'uint8_t',
-      name: `${entry.typeName}[${offset.toString(16).toUpperCase().padStart(2, '0')}]`,
-      remark: `${entry.memoryArea} ${formatAddress(entry.byteAddr + offset)} · ${entry.typeName}`,
+      isPlaceholderByteField: true,
+      name: offset.toString(16).toUpperCase().padStart(2, '0'),
       sourceAddress: (entry.byteAddr + offset) & 0xFFFF,
       sourceAddressText: formatAddress((entry.byteAddr + offset) & 0xFFFF),
       sourceByteLength: 1,
@@ -163,7 +169,7 @@ function createGroupsFromCodeInfo(codeInfo, options = {}) {
       groups.push({
         addressUnit: 'byte',
         layout: 'struct',
-        name: `${entry.memoryArea} ${entry.typeName}${suffix}`,
+        name: `${entry.typeName}${suffix}`,
         quantity: chunkLength,
         registerType: entry.memoryArea === 'CODE' ? 'input' : 'holding',
         registers: createRegistersForByteSpan(chunkEntry),
@@ -175,6 +181,7 @@ function createGroupsFromCodeInfo(codeInfo, options = {}) {
         sourceSegment: 'Modbus_Code_Info_t',
         sourceSegmentModule: '',
         sourceSymbolName: entry.typeName,
+        sourceSymbolType: entry.typeName,
         startAddress: formatAddress(chunkEntry.byteAddr)
       })
     }
@@ -186,5 +193,5 @@ function createGroupsFromCodeInfo(codeInfo, options = {}) {
 module.exports = {
   FIXED_HEADER_BYTE_LENGTH,
   createGroupsFromCodeInfo,
-  parseModbusCodeInfo
+  parseCodeInfo
 }

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

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

+ 149 - 0
features/bootloader/firmware.js

@@ -0,0 +1,149 @@
+const {
+  formatBytes
+} = require('../../utils/binary-utils.js')
+
+const FILE_SIZES = {
+  16: 16 * 1024,
+  32: 32 * 1024
+}
+const FLASH_LAYOUTS = {
+  16: {
+    capacity: FILE_SIZES[16],
+    startAddress: 0x0400,
+    endAddress: 0x4000
+  },
+  32: {
+    capacity: FILE_SIZES[32],
+    startAddress: 0x0800,
+    endAddress: 0x8000
+  }
+}
+const FLASH_SIZE_TEXT = Object.keys(FILE_SIZES)
+  .map((sizeKb) => formatBytes(FILE_SIZES[sizeKb]))
+  .join(' 或 ')
+const CHIP_FLASH_SIZE_KB = {
+  FU6572L: 32,
+  FU6572N: 32,
+  FU6572T: 32,
+  FU6565N: 32,
+  FU6565T: 32,
+  FU6563N: 32,
+  FU6562L: 32,
+  FU6562LA: 32,
+  FU6562Q: 32,
+  FU6562S: 32,
+  FU6562T: 32,
+  FU6532N: 32,
+  FU6532T: 32,
+  FU6522L: 32,
+  FU6522N: 32,
+  FU6522T: 32,
+  FU6812L2: 16,
+  FU6812N2: 16,
+  FU6812S2: 16,
+  FU6812V: 16,
+  FU6861Q2: 16,
+  FU6861N2: 16,
+  FU6861NF2: 16,
+  FU6861L2: 16,
+  FU6862L: 16,
+  FU6862Q: 16,
+  FU6872P: 16
+}
+const CHIP_FAMILY_FLASH_SIZE_KB = {
+  65: 32,
+  68: 16
+}
+
+function normalizeModel(value) {
+  const text = String(value || '').trim()
+  return text && text !== '--' ? text : ''
+}
+
+function extractChipModels(chipModel) {
+  const model = normalizeModel(chipModel).toUpperCase()
+  return model.match(/FU\d{4}[A-Z0-9]*/g) || []
+}
+
+function inferFlashSizeKb(chipModel) {
+  const models = extractChipModels(chipModel)
+
+  for (const model of models) {
+    if (CHIP_FLASH_SIZE_KB[model]) return CHIP_FLASH_SIZE_KB[model]
+
+    const family = model.slice(2, 4)
+    if (CHIP_FAMILY_FLASH_SIZE_KB[family]) return CHIP_FAMILY_FLASH_SIZE_KB[family]
+  }
+
+  const text = normalizeModel(chipModel).toUpperCase()
+  if (/(^|[^0-9])32\s*K(B)?([^0-9]|$)/.test(text)) return 32
+  if (/(^|[^0-9])16\s*K(B)?([^0-9]|$)/.test(text)) return 16
+
+  return null
+}
+
+function inferFlashSizeKbFromBytes(byteLength) {
+  return Number(Object.keys(FILE_SIZES).find((sizeKb) => FILE_SIZES[sizeKb] === byteLength)) || null
+}
+
+function inferUpgradeFlashSizeKb(chipModel, byteLength) {
+  return inferFlashSizeKb(chipModel) || inferFlashSizeKbFromBytes(byteLength)
+}
+
+function getFlashLayout(chipModel, byteLength) {
+  const sizeKb = inferUpgradeFlashSizeKb(chipModel, byteLength)
+  return sizeKb ? FLASH_LAYOUTS[sizeKb] : null
+}
+
+function getFirmwareValidation(byteLength, chipModel) {
+  const chipSizeKb = inferFlashSizeKb(chipModel)
+  const firmwareSizeKb = inferFlashSizeKbFromBytes(byteLength)
+  const sizeKb = chipSizeKb || firmwareSizeKb
+  const layout = sizeKb ? FLASH_LAYOUTS[sizeKb] : null
+  const normalizedChipModel = normalizeModel(chipModel)
+
+  if (!byteLength) {
+    return {
+      isReady: false,
+      text: chipSizeKb
+        ? `需要 ${formatBytes(FLASH_LAYOUTS[chipSizeKb].capacity)} .bin`
+        : `请选择 ${FLASH_SIZE_TEXT} .bin`
+    }
+  }
+
+  if (!layout) {
+    return {
+      isReady: false,
+      text: `文件 ${formatBytes(byteLength)},应为 ${FLASH_SIZE_TEXT}`
+    }
+  }
+
+  if (byteLength !== layout.capacity) {
+    return {
+      isReady: false,
+      text: `文件 ${formatBytes(byteLength)},应为 ${formatBytes(layout.capacity)}`
+    }
+  }
+
+  return {
+    isReady: true,
+    text: chipSizeKb
+      ? `匹配 ${formatBytes(layout.capacity)}`
+      : `${normalizedChipModel ? `${normalizedChipModel} 未识别,` : '未读到芯片型号,'}按 ${formatBytes(layout.capacity)} 尝试`
+  }
+}
+
+module.exports = {
+  CHIP_FAMILY_FLASH_SIZE_KB,
+  CHIP_FLASH_SIZE_KB,
+  FILE_SIZES,
+  FLASH_LAYOUTS,
+  FLASH_SIZE_TEXT,
+  extractChipModels,
+  getFirmwareValidation,
+  getFlashLayout,
+  inferFlashSizeKb,
+  inferFlashSizeKbFromBytes,
+  inferUpgradeFlashSizeKb,
+  normalizeModel
+}

+ 3 - 1
features/bootloader/index.js

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

+ 22 - 231
features/bootloader/service.js

@@ -1,7 +1,6 @@
 const transport = require('../../transport/ble-core.js')
 const {
   PROGRAM_CHUNK_SIZE,
-  alignBootloaderBuffer,
   assertBootloaderAck,
   buildExitFrame,
   buildFlashCheckFrame,
@@ -11,10 +10,8 @@ const {
   buildUnlockFrame,
   calculateBootloaderCrc,
   formatBootloaderCrc,
-  getBootloaderExpectedLength,
-  parseBootloaderResponse,
   toHex
-} = require('../../protocols/bootloader/frame.js')
+} = require('../../protocols/bootloader/index.js')
 const {
   delay
 } = require('../../utils/base-utils.js')
@@ -25,64 +22,13 @@ const {
 const {
   formatBytes
 } = require('../../utils/binary-utils.js')
+const firmware = require('./firmware.js')
+const bootloaderTransport = require('./transport.js')
 
 const HANDSHAKE_INTERVAL_MS = 200
 const HANDSHAKE_ATTEMPTS = 10
 const HANDSHAKE_TIMEOUT_MS = HANDSHAKE_INTERVAL_MS * HANDSHAKE_ATTEMPTS
-const RESPONSE_TIMEOUT_MS = 3000
 const PROGRAM_RESPONSE_TIMEOUT_MS = 6000
-const FILE_SIZES = {
-  16: 16 * 1024,
-  32: 32 * 1024
-}
-const FLASH_LAYOUTS = {
-  16: {
-    capacity: FILE_SIZES[16],
-    startAddress: 0x0400,
-    endAddress: 0x4000
-  },
-  32: {
-    capacity: FILE_SIZES[32],
-    startAddress: 0x0800,
-    endAddress: 0x8000
-  }
-}
-const FLASH_SIZE_TEXT = Object.keys(FILE_SIZES)
-  .map((sizeKb) => formatBytes(FILE_SIZES[sizeKb]))
-  .join(' 或 ')
-const CHIP_FLASH_SIZE_KB = {
-  FU6572L: 32,
-  FU6572N: 32,
-  FU6572T: 32,
-  FU6565N: 32,
-  FU6565T: 32,
-  FU6563N: 32,
-  FU6562L: 32,
-  FU6562LA: 32,
-  FU6562Q: 32,
-  FU6562S: 32,
-  FU6562T: 32,
-  FU6532N: 32,
-  FU6532T: 32,
-  FU6522L: 32,
-  FU6522N: 32,
-  FU6522T: 32,
-  FU6812L2: 16,
-  FU6812N2: 16,
-  FU6812S2: 16,
-  FU6812V: 16,
-  FU6861Q2: 16,
-  FU6861N2: 16,
-  FU6861NF2: 16,
-  FU6861L2: 16,
-  FU6862L: 16,
-  FU6862Q: 16,
-  FU6872P: 16
-}
-const CHIP_FAMILY_FLASH_SIZE_KB = {
-  65: 32,
-  68: 16
-}
 
 const state = {
   bootloaderChipId: '--',
@@ -104,7 +50,6 @@ const state = {
 let firmwareBytes = null
 let initialized = false
 let unsubscribeTransport = null
-let activeResponseWaiter = null
 const subscribers = []
 
 function getState() {
@@ -120,15 +65,6 @@ function setState(changedData) {
   })
 }
 
-function abortActiveResponseWaiter(message) {
-  if (!activeResponseWaiter) return false
-
-  const waiter = activeResponseWaiter
-  activeResponseWaiter = null
-  waiter.abort(new Error(message || '蓝牙已断开'))
-  return true
-}
-
 function subscribe(subscriber) {
   if (typeof subscriber !== 'function') return () => {}
 
@@ -147,7 +83,7 @@ function init() {
 
   unsubscribeTransport = transport.subscribe((transportState) => {
     if (!transportState.connectedDevice) {
-      abortActiveResponseWaiter('蓝牙已断开')
+      bootloaderTransport.abortActiveResponseWaiter('蓝牙已断开')
     }
 
     if (!transportState.connectedDevice && state.isBootloaderBusy) {
@@ -163,87 +99,13 @@ function init() {
   initialized = true
 }
 
-function normalizeModel(value) {
-  const text = String(value || '').trim()
-  return text && text !== '--' ? text : ''
-}
-
-function extractChipModels(chipModel) {
-  const model = normalizeModel(chipModel).toUpperCase()
-  return model.match(/FU\d{4}[A-Z0-9]*/g) || []
-}
-
-function inferFlashSizeKb(chipModel) {
-  const models = extractChipModels(chipModel)
-
-  for (const model of models) {
-    if (CHIP_FLASH_SIZE_KB[model]) return CHIP_FLASH_SIZE_KB[model]
-
-    const family = model.slice(2, 4)
-    if (CHIP_FAMILY_FLASH_SIZE_KB[family]) return CHIP_FAMILY_FLASH_SIZE_KB[family]
-  }
-
-  const text = normalizeModel(chipModel).toUpperCase()
-  if (/(^|[^0-9])32\s*K(B)?([^0-9]|$)/.test(text)) return 32
-  if (/(^|[^0-9])16\s*K(B)?([^0-9]|$)/.test(text)) return 16
-
-  return null
-}
-
-function inferFlashSizeKbFromBytes(byteLength) {
-  return Number(Object.keys(FILE_SIZES).find((sizeKb) => FILE_SIZES[sizeKb] === byteLength)) || null
-}
-
-function inferUpgradeFlashSizeKb(chipModel, byteLength) {
-  return inferFlashSizeKb(chipModel) || inferFlashSizeKbFromBytes(byteLength)
-}
-
 function getFlashLayout() {
-  const sizeKb = inferUpgradeFlashSizeKb(state.chipModel, state.firmwareSize)
-  return sizeKb ? FLASH_LAYOUTS[sizeKb] : null
-}
-
-function getFirmwareValidation(byteLength = state.firmwareSize, chipModel = state.chipModel) {
-  const chipSizeKb = inferFlashSizeKb(chipModel)
-  const firmwareSizeKb = inferFlashSizeKbFromBytes(byteLength)
-  const sizeKb = chipSizeKb || firmwareSizeKb
-  const layout = sizeKb ? FLASH_LAYOUTS[sizeKb] : null
-  const normalizedChipModel = normalizeModel(chipModel)
-
-  if (!byteLength) {
-    return {
-      isReady: false,
-      text: chipSizeKb
-        ? `需要 ${formatBytes(FLASH_LAYOUTS[chipSizeKb].capacity)} .bin`
-        : `请选择 ${FLASH_SIZE_TEXT} .bin`
-    }
-  }
-
-  if (!layout) {
-    return {
-      isReady: false,
-      text: `文件 ${formatBytes(byteLength)},应为 ${FLASH_SIZE_TEXT}`
-    }
-  }
-
-  if (byteLength !== layout.capacity) {
-    return {
-      isReady: false,
-      text: `文件 ${formatBytes(byteLength)},应为 ${formatBytes(layout.capacity)}`
-    }
-  }
-
-  return {
-    isReady: true,
-    text: chipSizeKb
-      ? `匹配 ${formatBytes(layout.capacity)}`
-      : `${normalizedChipModel ? `${normalizedChipModel} 未识别,` : '未读到芯片型号,'}按 ${formatBytes(layout.capacity)} 尝试`
-  }
+  return firmware.getFlashLayout(state.chipModel, state.firmwareSize)
 }
 
 function setChipModel(chipModel) {
-  const nextChipModel = normalizeModel(chipModel) || '--'
-  const validation = getFirmwareValidation(state.firmwareSize, nextChipModel)
+  const nextChipModel = firmware.normalizeModel(chipModel) || '--'
+  const validation = firmware.getFirmwareValidation(state.firmwareSize, nextChipModel)
 
   setState({
     chipModel: nextChipModel,
@@ -259,77 +121,6 @@ function getHandshakeDetail(response) {
   return `${response.versionText || '--'} / ${response.chipIdText || '--'}`
 }
 
-function waitForResponse(kind, timeout, options = {}) {
-  const expectedLength = getBootloaderExpectedLength(kind)
-  const buffer = []
-
-  return new Promise((resolve, reject) => {
-    let settled = false
-    let timer = null
-    let unsubscribe = () => {}
-    const waiter = {
-      abort: (error) => {
-        cleanup()
-        reject(error)
-      }
-    }
-
-    abortActiveResponseWaiter('新的 BootLoader 响应等待已开始')
-    activeResponseWaiter = waiter
-
-    unsubscribe = transport.subscribeRawResponse((bytes) => {
-      buffer.push.apply(buffer, bytes)
-      alignBootloaderBuffer(buffer)
-      if (buffer.length < expectedLength) return
-
-      const frame = buffer.slice(0, expectedLength)
-
-      try {
-        const response = parseBootloaderResponse(frame, kind)
-        cleanup()
-        resolve(response)
-      } catch (error) {
-        if (options.ignoreInvalid) {
-          buffer.shift()
-          return
-        }
-
-        cleanup()
-        reject(error)
-      }
-    })
-    timer = setTimeout(() => {
-      cleanup()
-      reject(new Error(`${kind} 响应超时`))
-    }, timeout || RESPONSE_TIMEOUT_MS)
-
-    function cleanup() {
-      if (settled) return
-      settled = true
-      clearTimeout(timer)
-      if (activeResponseWaiter === waiter) {
-        activeResponseWaiter = null
-      }
-      unsubscribe()
-    }
-  })
-}
-
-async function sendBootloaderFrame(frame, label, kind, timeout) {
-  const responsePromise = kind ? waitForResponse(kind, timeout) : null
-  const sent = await transport.sendRawFrameExact(frame, label)
-
-  if (!sent) {
-    if (responsePromise) {
-      responsePromise.catch(() => {})
-      abortActiveResponseWaiter(`${label}发送失败`)
-    }
-    throw new Error(`${label}发送失败`)
-  }
-
-  return responsePromise ? responsePromise : true
-}
-
 async function sendHandshakeKeepAlive() {
   if (state.isBootloaderBusy) return false
 
@@ -343,7 +134,7 @@ async function sendHandshakeKeepAlive() {
     isBootloaderBusy: true
   })
 
-  const responsePromise = waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
+  const responsePromise = bootloaderTransport.waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
     ignoreInvalid: true
   }).then((response) => {
     finished = true
@@ -356,7 +147,7 @@ async function sendHandshakeKeepAlive() {
 
   try {
     for (let attempt = 0; attempt < HANDSHAKE_ATTEMPTS && !finished; attempt += 1) {
-      const sent = await transport.sendRawFrameExact(frame, 'Bootloader握手')
+      const sent = await bootloaderTransport.sendRawFrame(frame, 'Bootloader握手')
       if (!sent) throw new Error('握手帧发送失败')
 
       setState({
@@ -381,7 +172,7 @@ async function sendHandshakeKeepAlive() {
     })
     return true
   } catch (error) {
-    abortActiveResponseWaiter('握手已停止')
+    bootloaderTransport.abortActiveResponseWaiter('握手已停止')
     const message = error && error.message ? error.message : '握手失败'
     transport.showCommandAlert('BootLoader握手', message)
     setState({
@@ -404,7 +195,7 @@ async function handshakeUntilReady() {
 
   let lastError = null
   let finished = false
-  const responsePromise = waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
+  const responsePromise = bootloaderTransport.waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
     ignoreInvalid: true
   }).then((response) => {
     finished = true
@@ -416,7 +207,7 @@ async function handshakeUntilReady() {
 
   for (let attempt = 0; attempt < HANDSHAKE_ATTEMPTS && !finished; attempt += 1) {
     try {
-      await transport.sendRawFrameExact(frame, 'Bootloader握手')
+      await bootloaderTransport.sendRawFrame(frame, 'Bootloader握手')
     } catch (error) {
       lastError = error
     }
@@ -454,7 +245,7 @@ async function chooseFirmwareFile(source = 'message') {
     firmwareBytes = file.bytes
     const firmwareCrcText = formatBootloaderCrc(calculateBootloaderCrc(firmwareBytes))
     const firmwareSizeText = formatBytes(firmwareBytes.length)
-    const validation = getFirmwareValidation(firmwareBytes.length)
+    const validation = firmware.getFirmwareValidation(firmwareBytes.length, state.chipModel)
 
     setState({
       bootloaderDetailText: validation.text,
@@ -486,13 +277,13 @@ async function chooseFirmwareFile(source = 'message') {
 async function startUpgrade() {
   if (state.isBootloaderBusy) return false
   if (!firmwareBytes || !state.isFirmwareReady) {
-    transport.showCommandAlert('固件不匹配', state.firmwareValidText || `请先选择 ${FLASH_SIZE_TEXT} .bin 文件`)
+    transport.showCommandAlert('固件不匹配', state.firmwareValidText || `请先选择 ${firmware.FLASH_SIZE_TEXT} .bin 文件`)
     return false
   }
 
   const layout = getFlashLayout()
   if (!layout) {
-    transport.showCommandAlert('固件大小', `请选择 ${FLASH_SIZE_TEXT} .bin 文件`)
+    transport.showCommandAlert('固件大小', `请选择 ${firmware.FLASH_SIZE_TEXT} .bin 文件`)
     return false
   }
 
@@ -510,20 +301,20 @@ async function startUpgrade() {
       bootloaderDetailText: '编程解锁',
       bootloaderStatusText: '升级中'
     })
-    assertBootloaderAck(await sendBootloaderFrame(buildUnlockFrame(), 'Bootloader解锁', 'unlock'), '编程解锁')
+    assertBootloaderAck(await bootloaderTransport.sendFrame(buildUnlockFrame(), 'Bootloader解锁', 'unlock'), '编程解锁')
 
     setState({
       bootloaderDetailText: '开启页擦除',
       bootloaderStatusText: '升级中'
     })
-    assertBootloaderAck(await sendBootloaderFrame(buildPageEraseFrame(true), '页擦除使能', 'pageErase'), '页擦除使能')
+    assertBootloaderAck(await bootloaderTransport.sendFrame(buildPageEraseFrame(true), '页擦除使能', 'pageErase'), '页擦除使能')
 
     const totalBytes = layout.endAddress - layout.startAddress
     let programmedBytes = 0
 
     for (let address = layout.startAddress; address < layout.endAddress; address += PROGRAM_CHUNK_SIZE) {
       const chunk = firmwareBytes.slice(address, address + PROGRAM_CHUNK_SIZE)
-      const response = await sendBootloaderFrame(
+      const response = await bootloaderTransport.sendFrame(
         buildProgramFrame(address, chunk),
         `编程 0x${toHex(address, 4)}`,
         'program',
@@ -544,8 +335,8 @@ async function startUpgrade() {
       })
     }
 
-    const checkResponse = await sendBootloaderFrame(buildFlashCheckFrame(), '全Flash校验', 'flashCheck')
-    await sendBootloaderFrame(buildExitFrame(), '退出Bootloader')
+    const checkResponse = await bootloaderTransport.sendFrame(buildFlashCheckFrame(), '全Flash校验', 'flashCheck')
+    await bootloaderTransport.sendFrame(buildExitFrame(), '退出Bootloader')
 
     setState({
       bootloaderDetailText: '校验通过',
@@ -576,7 +367,7 @@ async function readProgramChecksum() {
   })
 
   try {
-    const response = await sendBootloaderFrame(buildFlashCheckFrame(), '读取程序校验码', 'flashCheck')
+    const response = await bootloaderTransport.sendFrame(buildFlashCheckFrame(), '读取程序校验码', 'flashCheck')
 
     setState({
       bootloaderDetailText: '程序校验码已读取',
@@ -599,7 +390,7 @@ async function exitBootloader() {
   if (state.isBootloaderBusy) return false
 
   try {
-    const sent = await sendBootloaderFrame(buildExitFrame(), '退出BootLoader')
+    const sent = await bootloaderTransport.sendFrame(buildExitFrame(), '退出BootLoader')
     if (!sent) throw new Error('退出命令发送失败')
 
     setState({

+ 102 - 0
features/bootloader/transport.js

@@ -0,0 +1,102 @@
+const transport = require('../../transport/ble-core.js')
+const {
+  alignBootloaderBuffer,
+  getBootloaderExpectedLength,
+  parseBootloaderResponse
+} = require('../../protocols/bootloader/index.js')
+
+const RESPONSE_TIMEOUT_MS = 3000
+
+let activeResponseWaiter = null
+
+function abortActiveResponseWaiter(message) {
+  if (!activeResponseWaiter) return false
+
+  const waiter = activeResponseWaiter
+  activeResponseWaiter = null
+  waiter.abort(new Error(message || '蓝牙已断开'))
+  return true
+}
+
+function waitForResponse(kind, timeout, options = {}) {
+  const expectedLength = getBootloaderExpectedLength(kind)
+  const buffer = []
+
+  return new Promise((resolve, reject) => {
+    let settled = false
+    let timer = null
+    let unsubscribe = () => {}
+    const waiter = {
+      abort: (error) => {
+        cleanup()
+        reject(error)
+      }
+    }
+
+    abortActiveResponseWaiter('新的 BootLoader 响应等待已开始')
+    activeResponseWaiter = waiter
+
+    unsubscribe = transport.subscribeRawResponse((bytes) => {
+      buffer.push.apply(buffer, bytes)
+      alignBootloaderBuffer(buffer)
+      if (buffer.length < expectedLength) return
+
+      const frame = buffer.slice(0, expectedLength)
+
+      try {
+        const response = parseBootloaderResponse(frame, kind)
+        cleanup()
+        resolve(response)
+      } catch (error) {
+        if (options.ignoreInvalid) {
+          buffer.shift()
+          return
+        }
+
+        cleanup()
+        reject(error)
+      }
+    })
+    timer = setTimeout(() => {
+      cleanup()
+      reject(new Error(`${kind} 响应超时`))
+    }, timeout || RESPONSE_TIMEOUT_MS)
+
+    function cleanup() {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      if (activeResponseWaiter === waiter) {
+        activeResponseWaiter = null
+      }
+      unsubscribe()
+    }
+  })
+}
+
+async function sendRawFrame(frame, label) {
+  return transport.sendRawFrameExact(frame, label)
+}
+
+async function sendFrame(frame, label, kind, timeout) {
+  const responsePromise = kind ? waitForResponse(kind, timeout) : null
+  const sent = await sendRawFrame(frame, label)
+
+  if (!sent) {
+    if (responsePromise) {
+      responsePromise.catch(() => {})
+      abortActiveResponseWaiter(`${label}发送失败`)
+    }
+    throw new Error(`${label}发送失败`)
+  }
+
+  return responsePromise ? responsePromise : true
+}
+
+module.exports = {
+  RESPONSE_TIMEOUT_MS,
+  abortActiveResponseWaiter,
+  sendFrame,
+  sendRawFrame,
+  waitForResponse
+}

+ 9 - 0
features/communication/index.js

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

+ 196 - 0
features/communication/service.js

@@ -0,0 +1,196 @@
+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('./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)
+  const mode = serialState.serialMode
+
+  if (!data.connectedDevice) {
+    return {
+      errorText: '请先连接蓝牙设备',
+      ok: false
+    }
+  }
+
+  if (serialState.serialErrorText) {
+    return {
+      errorText: serialState.serialErrorText,
+      ok: false
+    }
+  }
+
+  if (mode === 'hex') {
+    const errorText = validateHexText(serialInputText)
+    if (errorText) {
+      return {
+        errorText,
+        ok: false
+      }
+    }
+
+    const bytes = parseHexBytes(serialInputText)
+    const previewHex = bytesToHex(bytes, ' ')
+    const ok = await transport.sendRawFrameExact(new Uint8Array(bytes), 'SERIAL')
+
+    return {
+      bytes,
+      ok,
+      previewHex,
+      serialState
+    }
+  }
+
+  const bytes = stringToUtf8Bytes(serialInputText)
+  if (!bytes.length) {
+    return {
+      errorText: '请输入要发送的文本',
+      ok: false
+    }
+  }
+
+  const ok = await transport.sendRawFrameExact(new Uint8Array(bytes), 'SERIAL')
+
+  return {
+    bytes,
+    ok,
+    previewHex: bytesToHex(bytes, ' '),
+    serialState
+  }
+}
+
+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: '请先连接蓝牙设备',
+      ok: false
+    }
+  }
+
+  if (data.storageAccessErrorText) {
+    return {
+      errorText: data.storageAccessErrorText,
+      ok: false
+    }
+  }
+
+  if (command.key === 'sync') {
+    return syncStorageAccessCodeInfo(data)
+  }
+
+  if (command.key === 'write' && (area.key === AREA.CODE || area.key === AREA.INFO)) {
+    return {
+      errorText: 'code/info 区暂不支持写入',
+      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) {
+    return {
+      errorText,
+      ok: false
+    }
+  }
+
+  if (dataBytes.length !== length) {
+    return {
+      errorText: `写入长度为 ${length} 字节,当前数据为 ${dataBytes.length} 字节`,
+      ok: false
+    }
+  }
+
+  const ok = await storageAccessService.writeMemory(
+    area.key,
+    address,
+    dataBytes,
+    '私有协议写入',
+    'communication-storage-write',
+    {
+      maxPacketLength: data.parameterMaxPacketLength,
+      showModal: true
+    }
+  )
+
+  return {
+    ok
+  }
+}
+
+module.exports = {
+  executeStorageAccessProtocol,
+  sendSerialFrame
+}

+ 313 - 0
features/communication/view-model.js

@@ -0,0 +1,313 @@
+const storageAccessProtocol = require('../../protocols/storage-access/index.js')
+const settingsService = require('../../store/settings-store.js')
+const {
+  AREA
+} = storageAccessProtocol
+const {
+  bytesToHex,
+  bytesToUtf8Text,
+  stringToUtf8Bytes
+} = require('../../utils/binary-utils.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 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 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) {
+  return mode === 'text' ? '文本' : 'HEX'
+}
+
+function getNextSerialMode(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 normalizeSerialState(current = {}, changed = {}) {
+  const next = {
+    serialInputText: current.serialInputText || '',
+    serialMode: current.serialMode || 'hex',
+    ...changed
+  }
+  const mode = next.serialMode === 'text' ? 'text' : 'hex'
+  const inputText = String(next.serialInputText || '')
+  let errorText = ''
+  let bytes = []
+  let previewHex = ''
+
+  if (inputText.trim()) {
+    if (mode === 'hex') {
+      errorText = validateHexText(inputText)
+      if (!errorText) {
+        bytes = parseHexBytes(inputText)
+        previewHex = bytesToHex(bytes, ' ')
+      }
+    } else {
+      bytes = stringToUtf8Bytes(inputText)
+      previewHex = bytesToHex(bytes, ' ')
+    }
+  }
+
+  return {
+    serialErrorText: errorText,
+    serialInputText: inputText,
+    serialMode: mode,
+    serialModeLabel: getSerialModeLabel(mode),
+    serialModeToggleText: getSerialModeToggleText(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]'
+    }
+  }
+
+  const previewHex = command.key === 'write'
+    ? buildStorageAccessPreview('write', previewArea, previewAddress, previewLength, dataBytes)
+    : buildStorageAccessPreview('read', previewArea, previewAddress, previewLength, dataBytes)
+
+  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: ''
+  }
+}
+
+function getLogPayloadText(log, mode) {
+  if (!log) return '--'
+
+  if (mode === 'text') {
+    const payloadText = String(log.payloadText || '').trim()
+    if (payloadText) return payloadText
+
+    const bytes = Array.isArray(log.payloadBytes) ? log.payloadBytes : []
+    return bytesToUtf8Text(bytes) || String(log.payload || '').trim() || '--'
+  }
+
+  if (typeof log.payload === 'string' && log.payload) {
+    return log.payload
+  }
+
+  const bytes = Array.isArray(log.payloadBytes) ? log.payloadBytes : []
+  return bytes.length ? bytesToHex(bytes, ' ') : '--'
+}
+
+function decorateLogs(logs, mode) {
+  return (logs || []).map((item) => ({
+    ...item,
+    displayText: getLogPayloadText(item, mode)
+  }))
+}
+
+function getProtocolModeState(settingsState = {}) {
+  const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
+  const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
+
+  return {
+    isModbusProtocol,
+    isStorageAccessProtocol
+  }
+}
+
+function getManualStatePayload(manualState = {}) {
+  const commands = Array.isArray(manualState.protocolCommands) ? manualState.protocolCommands : []
+  const command = commands[Number(manualState.commandIndex) || 0] || commands[0] || {}
+
+  return {
+    ...manualState,
+    protocolCommandLabel: command.label || '指令',
+    protocolResponseText: manualState.protocolResponseText || '',
+    protocolSendLabel: '发送',
+    showProtocolMultipleButton: command.inputMode === 'multiple',
+    protocolTitleText: '标准 Modbus'
+  }
+}
+
+module.exports = {
+  LOG_MODE_OPTIONS,
+  STORAGE_ACCESS_AREA_OPTIONS,
+  STORAGE_ACCESS_COMMAND_OPTIONS,
+  decorateLogs,
+  getLogModeToggleText,
+  getManualStatePayload,
+  getNextLogMode,
+  getNextSerialMode,
+  getProtocolModeState,
+  normalizeSerialState,
+  normalizeHexText,
+  normalizeStorageAccessState,
+  parseHexBytes,
+  validateHexText
+}

+ 0 - 12
features/generic-modbus/index.js

@@ -1,12 +0,0 @@
-const domain = require('../../domain/generic-modbus/index.js')
-const poller = require('./poller.js')
-const service = require('./service.js')
-
-module.exports = {
-  domain,
-  model: domain.model,
-  poller,
-  service,
-  genericModbusService: service,
-  ...poller
-}

+ 0 - 1550
features/generic-modbus/service.js

@@ -1,1550 +0,0 @@
-const {
-  formatExportStamp,
-  isCancelError,
-  loadSelectedFile,
-  saveTextFileToChat
-} = require('../../repositories/file.js')
-const {
-  getWxApi
-} = require('../../utils/platform-utils.js')
-const {
-  bytesToWords
-} = require('../../utils/binary-utils.js')
-const {
-  parseHexInteger
-} = require('../../utils/base-utils.js')
-const transport = require('../../transport/ble-core.js')
-const settingsService = require('../../store/settings-store.js')
-const modbusClient = require('../../protocols/modbus-rtu/client.js')
-const {
-  DATA_TYPE_OPTIONS,
-  REGISTER_TYPE_OPTIONS,
-  cloneImportedGroup,
-  decodeRegisterFromByteCache,
-  decodeRegisterFromWordCache,
-  decodeRegisterValue,
-  formatCoilDisplayValue,
-  formatRegisterValue,
-  getDataType,
-  getGroupEncodedBytes,
-  getGroupEncodedWords,
-  getRegisterEncodedBytes,
-  getRegisterEncodedWords,
-  getRegisterBytesFromByteCache,
-  getRegisterJsonValue,
-  getRegisterWordsFromByteCache,
-  getRegisterWordsFromWordCache,
-  getRegisterWriteValueText,
-  isAddressRangeOverflow,
-  isBitRegisterType,
-  isByteRegister,
-  normalizeGroup,
-  normalizeGroupConfig,
-  parseCoilValue,
-  registerTypeIsBit,
-  splitWordSpans,
-  validateRegisterValue
-} = require('../../domain/generic-modbus/model.js')
-const {
-  parseStructCatalog,
-  parseStructDefinition: parseStructDefinitionSource
-} = require('../../domain/generic-modbus/struct-parser.js')
-const {
-  createGroupsFromCodeInfo,
-  parseModbusCodeInfo
-} = require('../../domain/generic-modbus/code-info-parser.js')
-
-const STORAGE_KEY = 'generic-modbus-groups-json'
-const JSON_DOCUMENT_TYPE = 'generic-modbus-rtu'
-const JSON_SCHEMA_VERSION = 2
-const DEBUG_MEMORY_TYPES = {
-  DATA: 0x00,
-  BIT: 0x00,
-  IDATA: 0x01,
-  XDATA: 0x02,
-  CODE: 0x03
-}
-const GROUP_SOURCE_FIELDS = [
-  'addressUnit',
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSegment',
-  'sourceSegmentModule',
-  'sourceSymbolName'
-]
-const REGISTER_SOURCE_FIELDS = [
-  'sourceAddress',
-  'sourceAddressText',
-  'sourceByteLength',
-  'sourceBitOffset',
-  'sourceBitWidth',
-  'sourceMemoryArea',
-  'sourceMemoryClass',
-  'sourceSymbolName',
-  'sourceSymbolType'
-]
-const REGISTER_STRUCT_FIELDS = [
-  'bitOffset',
-  'bitWidth',
-  'byteStart',
-  'isBitField',
-  'structByteLength'
-]
-
-let initialized = false
-const subscribers = []
-let state = {
-  genericModbusDataTypeOptions: DATA_TYPE_OPTIONS,
-  genericModbusGroups: [],
-  genericModbusRegisterTypeOptions: REGISTER_TYPE_OPTIONS
-}
-
-function notify() {
-  const nextState = getState()
-
-  subscribers.slice().forEach((subscriber) => {
-    subscriber(nextState)
-  })
-}
-
-function setState(changedData, options = {}) {
-  state = {
-    ...state,
-    ...changedData
-  }
-
-  if (options.persist !== false) persistGroups()
-  notify()
-}
-
-function resolveMaxPacketLength(value) {
-  const settings = settingsService.getState()
-  const numberValue = Number(value === undefined ? settings.genericModbusMaxPacketLength : value)
-  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
-  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
-
-  return 64
-}
-
-function getWriteSpanMaxQuantity(totalQuantity, maxPacketLength) {
-  if (maxPacketLength === 0) return Math.max(1, totalQuantity)
-
-  return Math.max(1, modbusClient.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
-}
-
-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,
-    registerType: group.registerType,
-    startAddress: group.startAddress,
-    quantity: group.quantity,
-    registers: group.registers.map((register) => ({
-      dataType: register.dataType,
-      defaultValue: register.defaultValue,
-      isStructField: register.isStructField,
-      name: register.name,
-      maxValue: register.maxValue,
-      minValue: register.minValue,
-      textByteLength: register.textByteLength,
-      remark: register.remark,
-      unit: register.unit,
-      value: getRegisterJsonValue(register),
-      ...pickFields(register, REGISTER_STRUCT_FIELDS),
-      ...pickFields(register, REGISTER_SOURCE_FIELDS)
-    })),
-    ...pickFields(group, GROUP_SOURCE_FIELDS)
-  }))
-}
-
-function toJsonData(groups = state.genericModbusGroups, options = {}) {
-  const jsonData = {
-    groups: toPersistedGroups(groups),
-    type: JSON_DOCUMENT_TYPE,
-    version: JSON_SCHEMA_VERSION
-  }
-
-  if (options.includeExportedAt) {
-    jsonData.exportedAt = new Date().toISOString()
-  }
-
-  return jsonData
-}
-
-function toJsonText(groups = state.genericModbusGroups, options = {}) {
-  return JSON.stringify(toJsonData(groups, options), null, 2)
-}
-
-function parseJsonGroups(jsonText) {
-  const parsed = typeof jsonText === 'string' ? JSON.parse(jsonText) : jsonText
-  const groups = Array.isArray(parsed)
-    ? parsed
-    : (Array.isArray(parsed && parsed.groups) ? parsed.groups : parsed && parsed.genericModbusGroups)
-
-  if (parsed && parsed.type && parsed.type !== JSON_DOCUMENT_TYPE) {
-    throw new Error('JSON 文件不是通用Modbus配置')
-  }
-
-  if (parsed && parsed.version && parsed.version !== JSON_SCHEMA_VERSION) {
-    throw new Error('JSON 版本不兼容')
-  }
-
-  if (!Array.isArray(groups)) {
-    throw new Error('JSON 中没有找到寄存器组数组')
-  }
-
-  return groups
-}
-
-function normalizeDuplicateText(value) {
-  return String(value === undefined || value === null ? '' : value)
-    .trim()
-    .toLowerCase()
-}
-
-function normalizeAddressKey(value, textValue) {
-  const numberValue = Number(value)
-  if (Number.isFinite(numberValue)) return String(Math.floor(numberValue))
-
-  return String(textValue === undefined || textValue === null ? '' : textValue)
-    .trim()
-    .toUpperCase()
-}
-
-function normalizeBitKey(source = {}) {
-  const value = source.sourceBitOffset !== undefined && source.sourceBitOffset !== null && source.sourceBitOffset !== ''
-    ? source.sourceBitOffset
-    : source.bitOffset
-  const numberValue = Number(value)
-
-  return Number.isFinite(numberValue) ? String(Math.floor(numberValue)) : ''
-}
-
-function getRegisterDuplicateKey(register = {}, group = {}) {
-  const area = normalizeDuplicateText(register.sourceMemoryArea || group.sourceMemoryArea || register.memoryArea || '')
-  const symbolName = normalizeDuplicateText(register.sourceSymbolName || register.name || '')
-  if (area && symbolName) return ['register', area, symbolName].join('|')
-
-  const addressKey = normalizeAddressKey(
-    register.sourceAddress !== undefined ? register.sourceAddress : register.address,
-    register.sourceAddressText || register.addressText
-  )
-  const bitKey = normalizeBitKey(register)
-
-  if (!area && !symbolName && !addressKey) return ''
-
-  return ['register', area, symbolName, addressKey, bitKey].join('|')
-}
-
-function isSingleRegisterAggregateGroup(group = {}) {
-  const groupSymbolName = normalizeDuplicateText(group.sourceSymbolName || group.name || '')
-  const registers = Array.isArray(group.registers) ? group.registers : []
-
-  return registers.some((register) => {
-    const registerSymbolName = normalizeDuplicateText(register.sourceSymbolName || register.name || '')
-
-    return registerSymbolName && groupSymbolName && registerSymbolName !== groupSymbolName
-  })
-}
-
-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 ''
-
-  return ['group', area, symbolName, addressKey].join('|')
-}
-
-function getAggregateGroupDuplicateKey(source = {}) {
-  const area = normalizeDuplicateText(source.sourceMemoryArea || source.memoryArea || '')
-  const registerType = normalizeDuplicateText(source.registerType || '')
-  const segment = normalizeDuplicateText(source.sourceSegment || '')
-
-  return ['aggregate', area, registerType, segment].join('|')
-}
-
-function collectImportedVariableIndexes(groups = []) {
-  return groups.reduce((indexes, group, groupIndex) => {
-    if (!isSingleRegisterAggregateGroup(group)) {
-      const groupKey = getGroupDuplicateKey(group)
-      if (groupKey) indexes.groupIndexes[groupKey] = groupIndex
-    } else {
-      const aggregateKey = getAggregateGroupDuplicateKey(group)
-      if (aggregateKey) indexes.aggregateGroupIndexes[aggregateKey] = groupIndex
-    }
-
-    ;(Array.isArray(group.registers) ? group.registers : []).forEach((register) => {
-      const registerKey = getRegisterDuplicateKey(register, group)
-      if (registerKey) {
-        indexes.registerIndexes[registerKey] = {
-          groupIndex,
-          registerIndex: group.registers.indexOf(register)
-        }
-      }
-    })
-
-    return indexes
-  }, {
-    aggregateGroupIndexes: {},
-    groupIndexes: {},
-    registerIndexes: {}
-  })
-}
-
-function mergeImportedGroupState(existingGroup, incomingGroup) {
-  if (!existingGroup) return incomingGroup
-
-  return {
-    ...incomingGroup,
-    deleteVisible: false,
-    expanded: existingGroup.expanded === true,
-    id: existingGroup.id
-  }
-}
-
-function mergeImportedRegisterState(existingRegister, incomingRegister) {
-  if (!existingRegister) return incomingRegister
-
-  return {
-    ...incomingRegister,
-    id: existingRegister.id,
-    inputValue: incomingRegister.inputValue !== undefined && incomingRegister.inputValue !== null
-      ? incomingRegister.inputValue
-      : existingRegister.inputValue,
-    rawBytes: [],
-    rawValue: null,
-    rawWords: []
-  }
-}
-
-function mergeAggregateImportedGroup(nextGroups, incomingGroup, indexes) {
-  const aggregateKey = getAggregateGroupDuplicateKey(incomingGroup)
-  const aggregateGroupIndex = indexes.aggregateGroupIndexes[aggregateKey]
-  let targetGroup = aggregateGroupIndex === undefined ? null : nextGroups[aggregateGroupIndex]
-  let targetGroupIndex = aggregateGroupIndex
-  let targetRegisters = targetGroup && Array.isArray(targetGroup.registers)
-    ? targetGroup.registers.slice()
-    : []
-  let addedRegisterCount = 0
-  let updatedRegisterCount = 0
-
-  ;(Array.isArray(incomingGroup.registers) ? incomingGroup.registers : []).forEach((incomingRegister) => {
-    const registerKey = getRegisterDuplicateKey(incomingRegister, incomingGroup)
-    const existingRef = registerKey ? indexes.registerIndexes[registerKey] : null
-
-    if (existingRef) {
-      const existingGroup = nextGroups[existingRef.groupIndex]
-      const existingRegister = existingGroup && existingGroup.registers
-        ? existingGroup.registers[existingRef.registerIndex]
-        : null
-      const mergedRegister = mergeImportedRegisterState(existingRegister, incomingRegister)
-
-      if (targetGroupIndex !== undefined && existingRef.groupIndex === targetGroupIndex) {
-        targetRegisters[existingRef.registerIndex] = mergedRegister
-      } else if (existingGroup) {
-        const registers = existingGroup.registers.slice()
-        registers[existingRef.registerIndex] = mergedRegister
-        nextGroups[existingRef.groupIndex] = normalizeGroup({
-          ...existingGroup,
-          registers
-        })
-      }
-
-      updatedRegisterCount += 1
-      return
-    }
-
-    targetRegisters.push(incomingRegister)
-    addedRegisterCount += 1
-  })
-
-  if (targetGroupIndex !== undefined && targetGroup) {
-    nextGroups[targetGroupIndex] = normalizeGroup(mergeImportedGroupState(targetGroup, {
-      ...incomingGroup,
-      quantity: targetRegisters.length,
-      registers: targetRegisters
-    }))
-  } else if (targetRegisters.length) {
-    targetGroup = normalizeGroup({
-      ...incomingGroup,
-      quantity: targetRegisters.length,
-      registers: targetRegisters
-    })
-    nextGroups.push(targetGroup)
-    targetGroupIndex = nextGroups.length - 1
-  }
-
-  return {
-    addedGroupCount: targetGroupIndex === aggregateGroupIndex ? 0 : (targetRegisters.length ? 1 : 0),
-    addedRegisterCount,
-    updatedGroupCount: targetGroupIndex === aggregateGroupIndex && (addedRegisterCount || updatedRegisterCount) ? 1 : 0,
-    updatedRegisterCount
-  }
-}
-
-function mergeImportedGroups(existingGroups = [], incomingGroups = []) {
-  const nextGroups = existingGroups.slice()
-  let indexes = collectImportedVariableIndexes(nextGroups)
-  const result = {
-    addedGroupCount: 0,
-    addedRegisterCount: 0,
-    groups: nextGroups,
-    updatedGroupCount: 0,
-    updatedRegisterCount: 0
-  }
-
-  incomingGroups.forEach((incomingGroup) => {
-    if (isSingleRegisterAggregateGroup(incomingGroup)) {
-      const aggregateResult = mergeAggregateImportedGroup(nextGroups, incomingGroup, indexes)
-      result.addedGroupCount += aggregateResult.addedGroupCount
-      result.addedRegisterCount += aggregateResult.addedRegisterCount
-      result.updatedGroupCount += aggregateResult.updatedGroupCount
-      result.updatedRegisterCount += aggregateResult.updatedRegisterCount
-      indexes = collectImportedVariableIndexes(nextGroups)
-      return
-    }
-
-    const groupKey = getGroupDuplicateKey(incomingGroup)
-    const existingGroupIndex = groupKey ? indexes.groupIndexes[groupKey] : undefined
-
-    if (existingGroupIndex !== undefined) {
-      const existingGroup = nextGroups[existingGroupIndex]
-      nextGroups[existingGroupIndex] = normalizeGroup(mergeImportedGroupState(existingGroup, incomingGroup))
-      result.updatedGroupCount += 1
-    } else {
-      nextGroups.push(incomingGroup)
-      result.addedGroupCount += 1
-    }
-
-    indexes = collectImportedVariableIndexes(nextGroups)
-  })
-
-  result.changedCount = result.addedGroupCount
-    + result.updatedGroupCount
-    + result.addedRegisterCount
-    + result.updatedRegisterCount
-
-  return result
-}
-
-function readStoredGroups() {
-  const wxApi = getWxApi()
-  if (typeof wxApi.getStorageSync !== 'function') return []
-
-  try {
-    const jsonText = wxApi.getStorageSync(STORAGE_KEY)
-    if (jsonText) return parseJsonGroups(jsonText).map(cloneImportedGroup)
-  } catch (error) {
-    return []
-  }
-
-  return []
-}
-
-function persistGroups() {
-  const wxApi = getWxApi()
-  if (typeof wxApi.setStorageSync !== 'function') return
-
-  try {
-    wxApi.setStorageSync(STORAGE_KEY, toJsonText())
-  } catch (error) {}
-}
-
-function init() {
-  if (initialized) return
-
-  state = {
-    ...state,
-    genericModbusGroups: readStoredGroups().map(normalizeGroup)
-  }
-  initialized = true
-}
-
-function getState() {
-  return {
-    ...state,
-    genericModbusDataTypeOptions: DATA_TYPE_OPTIONS,
-    genericModbusRegisterTypeOptions: REGISTER_TYPE_OPTIONS
-  }
-}
-
-function subscribe(subscriber) {
-  if (typeof subscriber !== 'function') return () => {}
-
-  init()
-  subscribers.push(subscriber)
-  subscriber(getState())
-
-  return () => {
-    const index = subscribers.indexOf(subscriber)
-    if (index >= 0) subscribers.splice(index, 1)
-  }
-}
-
-function getShareFileName() {
-  return `generic-modbus-rtu-${formatExportStamp()}.json`
-}
-
-async function importJsonFromMessageFile() {
-  try {
-    const file = await loadSelectedFile('message', {
-      encoding: 'utf8',
-      extensionMessage: '请选择 .json 寄存器配置文件',
-      extensions: ['json'],
-      fallbackName: 'generic-modbus.json'
-    })
-    const jsonText = file.text
-    const importedGroups = parseJsonGroups(jsonText).map(cloneImportedGroup).map(normalizeGroup)
-    if (!importedGroups.length) throw new Error('JSON 中没有可导入的寄存器组')
-    const merged = mergeImportedGroups(state.genericModbusGroups, importedGroups)
-
-    setState({
-      genericModbusGroups: merged.groups
-    })
-
-    return merged.changedCount || 0
-  } catch (error) {
-    const message = error && error.message ? error.message : '导入通用Modbus配置失败'
-    transport.showCommandAlert('通用Modbus导入', message)
-    return 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(/^_+/, '')
-    .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) {
-  return completion.registers.map((register) => ({
-    ...register,
-    isStructField: true,
-    remark: [
-      register.remark,
-      `${group.sourceMemoryArea || ''} ${group.sourceAddressText || group.startAddressText || ''}`.trim(),
-      completion.structName ? `struct ${completion.structName}` : '',
-      register.isBitField ? `bit${register.bitOffset}:${register.bitWidth}` : ''
-    ].filter(Boolean).join(' · '),
-    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 completeStructInstanceGroupsWithStructSource(sourceText, options = {}) {
-  const completed = completeStructInstanceGroups(state.genericModbusGroups, sourceText, options)
-
-  if (!completed.completedCount) {
-    throw new Error('没有找到可匹配的结构体实例')
-  }
-
-  setState({
-    genericModbusGroups: completed.groups
-  })
-
-  return completed
-}
-
-async function completeStructInstanceGroupsWithStructFile(options = {}) {
-  try {
-    const file = await loadSelectedFile('message', {
-      encoding: 'utf8',
-      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)
-
-    return {
-      completedCount: 0,
-      skippedCount: 0,
-      structCount: 0,
-      variableCount: 0
-    }
-  }
-}
-
-async function queryCodeInfoBlock(options = {}) {
-  const slaveAddress = modbusClient.getSharedSlaveAddress()
-  if (slaveAddress === null) {
-    return {
-      ok: false
-    }
-  }
-
-  const maxPacketLength = resolveMaxPacketLength(options.maxPacketLength)
-  const result = await modbusClient.readCodeInfoBlock(
-    slaveAddress,
-    '查询Code信息块',
-    'generic-modbus-code-info-read',
-    {
-      maxFrameBytes: maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-  if (!result) {
-    return {
-      ok: false
-    }
-  }
-
-  const codeInfo = parseModbusCodeInfo(result.bytes)
-  const importedGroups = createGroupsFromCodeInfo(codeInfo, options).map(cloneImportedGroup).map(normalizeGroup)
-  const merged = mergeImportedGroups(state.genericModbusGroups, importedGroups)
-
-  if (importedGroups.length) {
-    setState({
-      genericModbusGroups: merged.groups
-    })
-  }
-
-  return {
-    address: result.address,
-    addedGroups: merged.addedGroupCount,
-    addedRegisters: merged.addedRegisterCount,
-    byteLength: result.byteLength,
-    bytes: result.bytes,
-    codeInfo,
-    groupCount: importedGroups.length,
-    memoryType: result.memoryType,
-    ok: true,
-    structCount: codeInfo.structCount,
-    updatedGroups: merged.updatedGroupCount,
-    updatedRegisters: merged.updatedRegisterCount
-  }
-}
-
-async function saveJsonToChat() {
-  try {
-    if (!state.genericModbusGroups.length) {
-      throw new Error('没有可保存的寄存器组')
-    }
-
-    const jsonText = toJsonText(state.genericModbusGroups, {
-      includeExportedAt: true
-    })
-
-    await saveTextFileToChat(getShareFileName(), jsonText)
-
-    return state.genericModbusGroups.length
-  } catch (error) {
-    const message = error && error.message ? error.message : '保存通用Modbus配置失败'
-
-    if (!isCancelError(error)) {
-      transport.showCommandAlert('通用Modbus保存', message)
-    }
-
-    return 0
-  }
-}
-
-function addGroupFromConfig(config = {}) {
-  let groupConfig
-
-  try {
-    groupConfig = normalizeGroupConfig(config)
-  } catch (error) {
-    transport.showCommandAlert('通用Modbus添加', error.message || '寄存器组配置无效')
-    return null
-  }
-
-  if (isAddressRangeOverflow(groupConfig.startAddress, groupConfig.quantity)) {
-    transport.showCommandAlert('通用Modbus添加', '地址范围超出 0xFFFF')
-    return null
-  }
-
-  const registers = Array.isArray(config.registers) ? config.registers : []
-  const group = normalizeGroup({
-    ...groupConfig,
-    layout: config.layout,
-    ...(registers.length ? { registers } : {}),
-    expanded: false
-  })
-
-  if (group.addressOverflow) {
-    transport.showCommandAlert('通用Modbus添加', '地址范围超出 0xFFFF')
-    return null
-  }
-
-  setState({
-    genericModbusGroups: state.genericModbusGroups.concat(group)
-  })
-
-  return group
-}
-
-function updateGroupConfig(groupId, config = {}) {
-  const group = findGroup(groupId)
-  if (!group) return null
-
-  let nextConfig
-  try {
-    nextConfig = normalizeGroupConfig({
-      ...group,
-      ...config
-    })
-  } catch (error) {
-    transport.showCommandAlert('通用Modbus更新', error.message || '寄存器组配置无效')
-    return null
-  }
-
-  if (isAddressRangeOverflow(nextConfig.startAddress, nextConfig.quantity)) {
-    transport.showCommandAlert('通用Modbus更新', '地址范围超出 0xFFFF')
-    return null
-  }
-
-  const registers = Array.isArray(config.registers) ? config.registers : group.registers
-  const updatedGroup = normalizeGroup({
-    ...group,
-    ...nextConfig,
-    registers
-  })
-
-  if (updatedGroup.addressOverflow) {
-    transport.showCommandAlert('通用Modbus更新', '地址范围超出 0xFFFF')
-    return null
-  }
-
-  setState({
-    genericModbusGroups: state.genericModbusGroups.map((item) => (
-      item.id === groupId ? updatedGroup : item
-    ))
-  })
-
-  return updatedGroup
-}
-
-function updateGroups(mapper) {
-  setState({
-    genericModbusGroups: state.genericModbusGroups.map((group, index) => normalizeGroup(mapper(group, index)))
-  })
-}
-
-function findGroup(groupId) {
-  return state.genericModbusGroups.find((group) => group.id === groupId)
-}
-
-function setGroupExpanded(groupId, expanded) {
-  updateGroups((group) => group.id === groupId
-    ? {
-      ...group,
-      deleteVisible: false,
-      expanded
-    }
-    : group)
-}
-
-function setGroupDeleteVisible(groupId, deleteVisible) {
-  updateGroups((group) => group.id === groupId
-    ? {
-      ...group,
-      deleteVisible
-    }
-    : group)
-}
-
-function removeGroup(groupId) {
-  setState({
-    genericModbusGroups: state.genericModbusGroups.filter((group) => group.id !== groupId)
-  })
-}
-
-function reorderRegister(groupId, fromIndex, toIndex) {
-  const group = findGroup(groupId)
-  if (!group) return null
-  if (group.isStructLayout) return group
-
-  const registers = group.registers.slice()
-  const sourceIndex = Number(fromIndex)
-  const targetIndex = Number(toIndex)
-  if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex)) return null
-  if (sourceIndex < 0 || sourceIndex >= registers.length) return null
-
-  const safeTargetIndex = Math.min(Math.max(targetIndex, 0), registers.length - 1)
-  if (safeTargetIndex === sourceIndex) return group
-
-  const moved = registers.splice(sourceIndex, 1)[0]
-  registers.splice(safeTargetIndex, 0, moved)
-
-  const updatedGroup = normalizeGroup({
-    ...group,
-    quantity: registers.length,
-    registers
-  })
-
-  setState({
-    genericModbusGroups: state.genericModbusGroups.map((item) => (
-      item.id === groupId ? updatedGroup : item
-    ))
-  })
-
-  return updatedGroup
-}
-
-function updateRegister(groupId, registerIndex, changedData) {
-  updateGroups((group) => {
-    if (group.id !== groupId) return group
-    const shouldResetReadState = Object.prototype.hasOwnProperty.call(changedData, 'dataType')
-      || Object.prototype.hasOwnProperty.call(changedData, 'textByteLength')
-
-    return {
-      ...group,
-      registers: group.registers.map((register, currentIndex) => (
-        currentIndex === registerIndex
-          ? {
-            ...register,
-            ...(shouldResetReadState ? { rawValue: null, rawWords: [] } : {}),
-            ...changedData
-          }
-          : register
-      ))
-    }
-  })
-}
-
-function updateRegisterValue(groupId, registerIndex, value) {
-  updateRegister(groupId, registerIndex, {
-    inputValue: value,
-    isDirty: true
-  })
-}
-
-function validateRegisterInputValue(groupId, registerIndex, value) {
-  const group = findGroup(groupId)
-  if (!group) return false
-
-  const register = group.registers[registerIndex]
-  if (!register) return false
-
-  return validateRegisterValue(register, value)
-}
-
-function parseStructDefinition(sourceText) {
-  return parseStructDefinitionSource(sourceText)
-}
-
-function getDebugMemoryType(group = {}) {
-  const memoryArea = String(group.sourceMemoryArea || '').trim().toUpperCase()
-
-  return Object.prototype.hasOwnProperty.call(DEBUG_MEMORY_TYPES, memoryArea)
-    ? DEBUG_MEMORY_TYPES[memoryArea]
-    : null
-}
-
-function isDebugMemoryGroup(group = {}) {
-  return getDebugMemoryType(group) !== null
-}
-
-function isByteAddressedGroup(group = {}) {
-  return group.addressUnit === 'byte' || group.addressUnit === 'bytes' || isDebugMemoryGroup(group)
-}
-
-function getDebugMemoryAddress(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 getDebugMemoryByteLength(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 writeDebugMemoryRegister(group, register, slaveAddress) {
-  const memoryType = getDebugMemoryType(group)
-  const maxPacketLength = resolveMaxPacketLength()
-  const byteLength = Math.max(1, Number(register.byteLength) || 1)
-  const address = Math.max(0, Math.floor(Number(register.address) || getDebugMemoryAddress(group))) & 0xFFFF
-  let bytes
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-  if (memoryType === 0x03) {
-    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
-    return null
-  }
-  if (slaveAddress === 0x00 && register.isBitField) {
-    transport.showCommandAlert('内存写入', '位域变量需要先读后写,不能使用广播地址')
-    return null
-  }
-
-  try {
-    if (register.isBitField) {
-      const baseBytes = await modbusClient.readDebugMemory(
-        slaveAddress,
-        memoryType,
-        address,
-        byteLength,
-        register.name ? `${register.name} 读改写` : '变量读改写',
-        'generic-modbus-debug-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 modbusClient.writeDebugMemory(
-    slaveAddress,
-    memoryType,
-    address,
-    bytes,
-    register.name || group.name || '变量写入',
-    'generic-modbus-debug-register-write',
-    {
-      maxFrameBytes: maxPacketLength
-    }
-  )
-  if (!ok) return null
-
-  const byteCache = {}
-  fillByteCacheFromBytes(byteCache, address, bytes)
-
-  return createWrittenRegisterSnapshot(group, register, byteCache)
-}
-
-async function readDebugMemoryGroup(group, slaveAddress, options = {}) {
-  const memoryType = getDebugMemoryType(group)
-  const address = getDebugMemoryAddress(group)
-  const byteLength = getDebugMemoryByteLength(group)
-  const maxPacketLength = resolveMaxPacketLength(options.maxPacketLength)
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存读取', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-
-  const bytes = await modbusClient.readDebugMemory(
-    slaveAddress,
-    memoryType,
-    address,
-    byteLength,
-    group.name || '内存读取',
-    'generic-modbus-debug-read',
-    {
-      maxFrameBytes: maxPacketLength,
-      showModal: options.showModal !== false
-    }
-  )
-  if (!bytes) return null
-
-  const wordCache = {}
-  if (isByteAddressedGroup(group)) {
-    fillByteCacheFromBytes(wordCache, group.startAddress, bytes)
-  } else {
-    fillWordCacheFromBytes(wordCache, group.startAddress, bytes)
-  }
-
-  return wordCache
-}
-
-async function writeDebugMemoryGroup(group, slaveAddress) {
-  const memoryType = getDebugMemoryType(group)
-  const address = getDebugMemoryAddress(group)
-  const byteLength = getDebugMemoryByteLength(group)
-  const maxPacketLength = resolveMaxPacketLength()
-  let bytes
-
-  if (memoryType === null) {
-    transport.showCommandAlert('内存写入', `暂不支持 ${group.sourceMemoryArea || '未知'} 内存区域`)
-    return null
-  }
-  if (memoryType === 0x03) {
-    transport.showCommandAlert('内存写入', 'code 区暂不支持写入')
-    return null
-  }
-  if (slaveAddress === 0x00 && groupHasBitFields(group)) {
-    transport.showCommandAlert('内存写入', '位域变量需要先读后写,不能使用广播地址')
-    return null
-  }
-
-  try {
-    let baseBytes = null
-    if (groupHasBitFields(group)) {
-      baseBytes = await modbusClient.readDebugMemory(
-        slaveAddress,
-        memoryType,
-        address,
-        byteLength,
-        group.name ? `${group.name} 读改写` : '内存读改写',
-        'generic-modbus-debug-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 modbusClient.writeDebugMemory(
-    slaveAddress,
-    memoryType,
-    address,
-    bytes,
-    group.name || '内存写入',
-    'generic-modbus-debug-write',
-    {
-      maxFrameBytes: maxPacketLength
-    }
-  )
-  if (!ok) return null
-
-  const wordCache = {}
-  if (isByteAddressedGroup(group)) {
-    fillByteCacheFromBytes(wordCache, group.startAddress, bytes)
-  } else {
-    fillWordCacheFromBytes(wordCache, group.startAddress, bytes)
-  }
-
-  return createWrittenRegisterSnapshots(group, wordCache)
-}
-
-async function readGroup(groupId, options = {}) {
-  const group = findGroup(groupId)
-  const slaveAddress = modbusClient.getSharedSlaveAddress()
-  if (!group || slaveAddress === null) return false
-  if (group.addressOverflow) {
-    transport.showCommandAlert('通用Modbus读取', '寄存器地址范围超出 0xFFFF')
-    return false
-  }
-
-  const totalQuantity = Math.max(1, group.wordQuantity || group.quantity || 0)
-  const maxPacketLength = resolveMaxPacketLength(options.maxPacketLength)
-  const wordCache = {}
-
-  if (isDebugMemoryGroup(group)) {
-    const debugWordCache = await readDebugMemoryGroup(group, slaveAddress, options)
-    if (!debugWordCache) return false
-
-    Object.keys(debugWordCache).forEach((addressText) => {
-      const numericAddress = Number(addressText)
-      if (Number.isFinite(numericAddress)) {
-        wordCache[numericAddress] = Number(debugWordCache[addressText]) & 0xFFFF
-      }
-    })
-  } else {
-    const values = await modbusClient.readSpans(
-      slaveAddress,
-      group.functionCode,
-      [{
-        address: group.startAddress,
-        quantity: totalQuantity
-      }],
-      group.name || '通用Modbus读取',
-      'generic-modbus-read',
-      {
-        maxFrameBytes: maxPacketLength,
-        showModal: options.showModal !== false
-      }
-    )
-    if (!values) return false
-
-    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
-      })
-    }
-  }
-
-  updateGroups((item) => {
-    if (item.id !== groupId) return item
-
-    const nextRegisters = item.registers.map((register) => {
-      const rawBytes = registerTypeIsBit(register)
-        ? []
-        : (isByteAddressedGroup(item) ? getRegisterBytesFromByteCache(register, wordCache) : [])
-      const rawWords = registerTypeIsBit(register)
-        ? []
-        : (isByteAddressedGroup(item)
-          ? getRegisterWordsFromByteCache(register, wordCache)
-          : getRegisterWordsFromWordCache(register, wordCache))
-      const rawValue = registerTypeIsBit(register)
-        ? decodeRegisterFromWordCache(register, wordCache)
-        : (isByteAddressedGroup(item)
-          ? decodeRegisterFromByteCache(register, wordCache)
-          : (rawWords ? decodeRegisterValue(register, rawWords) : null))
-      const displayValue = rawValue === null || rawValue === undefined
-        ? '--'
-        : (registerTypeIsBit(register)
-          ? formatCoilDisplayValue(rawValue)
-          : formatRegisterValue(register, rawValue))
-
-      return {
-        ...register,
-        displayValue,
-        inputValue: item.writable ? displayValue : register.inputValue,
-        isDirty: false,
-        rawBytes: rawBytes || [],
-        rawValue,
-        rawWords: rawWords || []
-      }
-    })
-
-    return {
-      ...item,
-      registers: nextRegisters
-    }
-  })
-
-  return true
-}
-
-async function writeRegister(groupId, registerIndex) {
-  const group = findGroup(groupId)
-  const register = group && group.registers ? group.registers[registerIndex] : null
-  const slaveAddress = modbusClient.getSharedSlaveAddress()
-  if (!group || !register || slaveAddress === null) return false
-  if (!group.writable) {
-    transport.showCommandAlert('通用Modbus写入', '当前变量为只读')
-    return false
-  }
-  if (group.addressOverflow) {
-    transport.showCommandAlert('通用Modbus写入', '地址范围超出 0xFFFF')
-    return false
-  }
-
-  const written = isDebugMemoryGroup(group)
-    ? await writeDebugMemoryRegister(group, register, slaveAddress)
-    : null
-  if (!written) {
-    if (!isDebugMemoryGroup(group)) return writeGroup(groupId)
-    return false
-  }
-
-  updateGroups((item) => {
-    if (item.id !== groupId) return item
-
-    return {
-      ...item,
-      registers: item.registers.map((currentRegister, currentIndex) => (
-        currentIndex === registerIndex
-          ? {
-            ...currentRegister,
-            displayValue: written.displayValue,
-            inputValue: written.displayValue,
-            isDirty: false,
-            rawBytes: written.rawBytes,
-            rawValue: written.rawValue,
-            rawWords: written.rawWords
-          }
-          : currentRegister
-      ))
-    }
-  })
-
-  return true
-}
-
-async function writeGroup(groupId) {
-  const group = findGroup(groupId)
-  const slaveAddress = modbusClient.getSharedSlaveAddress()
-  const maxPacketLength = resolveMaxPacketLength()
-  if (!group || slaveAddress === null) return false
-  if (!group.writable) {
-    transport.showCommandAlert('通用Modbus写入', '当前寄存器组为只读')
-    return false
-  }
-  if (group.addressOverflow) {
-    transport.showCommandAlert('通用Modbus写入', '寄存器地址范围超出 0xFFFF')
-    return false
-  }
-
-  const writtenRegisters = []
-
-  if (isDebugMemoryGroup(group)) {
-    const snapshots = await writeDebugMemoryGroup(group, slaveAddress)
-    if (!snapshots) return false
-
-    snapshots.forEach((snapshot) => {
-      writtenRegisters.push(snapshot)
-    })
-  } else {
-    if (group.registerType === 'coil') {
-      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('通用Modbus写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
-          return false
-        }
-
-        const response = await modbusClient.writeSingleCoil(
-          slaveAddress,
-          group.startAddress + index,
-          !!coilValue,
-          register.name || group.name || '通用Modbus写入',
-          'generic-modbus-coil-write',
-          {
-            maxFrameBytes: maxPacketLength
-          }
-        )
-        if (!response) return false
-
-        writtenRegisters.push({
-          rawBytes: [],
-          rawValue: coilValue,
-          rawWords: [],
-          displayValue: formatCoilDisplayValue(coilValue)
-        })
-      }
-    } else {
-      let words
-
-      try {
-        words = group.isStructLayout
-          ? getGroupEncodedWords(group)
-          : Array.from({ length: Math.max(1, group.wordQuantity || 1) }, () => 0)
-
-        if (!group.isStructLayout) {
-          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
-              }
-            }
-          }
-        }
-      } catch (error) {
-        transport.showCommandAlert('通用Modbus写入', error.message || '寄存器组没有有效写入值')
-        return false
-      }
-
-      const writtenWordCache = words.reduce((cache, word, offset) => {
-        cache[group.startAddress + offset] = word
-        return cache
-      }, {})
-
-      group.registers.forEach((register) => {
-        const rawWords = getRegisterWordsFromWordCache(register, writtenWordCache) || []
-        const rawValue = decodeRegisterValue(register, rawWords)
-        const displayValue = formatRegisterValue(register, rawValue)
-
-        writtenRegisters.push({
-          rawWords,
-          rawValue,
-          displayValue
-        })
-      })
-
-      const maxWriteQuantity = getWriteSpanMaxQuantity(words.length, 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 || '通用Modbus写入',
-          'generic-modbus-write',
-          {
-            maxFrameBytes: maxPacketLength
-          }
-        )
-        if (!response) return false
-      }
-    }
-  }
-
-  updateGroups((item) => {
-    if (item.id !== groupId) return item
-
-    let writtenIndex = 0
-
-    return {
-      ...item,
-      registers: item.registers.map((register) => {
-        const written = writtenRegisters[writtenIndex] || {}
-        writtenIndex += 1
-        const hasDisplayValue = Object.prototype.hasOwnProperty.call(written, 'displayValue')
-        const hasRawBytes = Object.prototype.hasOwnProperty.call(written, 'rawBytes')
-        const hasRawValue = Object.prototype.hasOwnProperty.call(written, 'rawValue')
-        const hasRawWords = Object.prototype.hasOwnProperty.call(written, 'rawWords')
-
-        return {
-          ...register,
-          displayValue: hasDisplayValue ? written.displayValue : register.displayValue,
-          inputValue: hasDisplayValue ? written.displayValue : register.inputValue,
-          isDirty: false,
-          rawBytes: hasRawBytes ? written.rawBytes : register.rawBytes,
-          rawValue: hasRawValue ? written.rawValue : register.rawValue,
-          rawWords: hasRawWords ? written.rawWords : register.rawWords
-        }
-      })
-    }
-  })
-
-  return true
-}
-
-module.exports = {
-  DATA_TYPE_OPTIONS,
-  REGISTER_TYPE_OPTIONS,
-  addGroupFromConfig,
-  completeStructInstanceGroups,
-  completeStructInstanceGroupsWithStructFile,
-  completeStructInstanceGroupsWithStructSource,
-  getState,
-  importJsonFromMessageFile,
-  init,
-  parseStructDefinition,
-  queryCodeInfoBlock,
-  readGroup,
-  removeGroup,
-  reorderRegister,
-  saveJsonToChat,
-  setGroupDeleteVisible,
-  setGroupExpanded,
-  subscribe,
-  updateGroupConfig,
-  updateRegister,
-  updateRegisterValue,
-  validateRegisterInputValue,
-  writeRegister,
-  writeGroup
-}

+ 3 - 19
features/home/service.js

@@ -1,5 +1,4 @@
 const transport = require('../../transport/ble-core.js')
-const manualRtuService = require('../manual-rtu/service.js')
 const themeService = require('../../store/theme-store.js')
 const {
   DEFAULT_DEVICE_FILTER,
@@ -25,6 +24,7 @@ function init() {
   initScheduled = true
   deferStartupWork(() => {
     try {
+      transport.init()
       themeService.init()
     } catch (error) {}
   })
@@ -34,8 +34,7 @@ function getState(deviceFilterMode = DEFAULT_DEVICE_FILTER) {
   return getHomePageState(
     transport.getState(),
     deviceFilterMode,
-    themeService.getState(),
-    manualRtuService.getState()
+    themeService.getState()
   )
 }
 
@@ -49,7 +48,6 @@ function subscribeState(getDeviceFilterMode, subscriber) {
 
   const unsubscribers = [
     transport.subscribe(emit),
-    manualRtuService.subscribe(emit),
     themeService.subscribe(emit)
   ]
 
@@ -67,24 +65,10 @@ function toggleScan(isDiscovering) {
 module.exports = {
   DEFAULT_DEVICE_FILTER,
   clearDevices: transport.clearDevices,
-  clearInput: transport.clearInput,
-  clearLogs: transport.clearLogs,
-  closeProtocolMultipleDialog: manualRtuService.closeProtocolMultipleDialog,
   connectDeviceById: transport.connectDeviceById,
   disconnectDevice: transport.disconnectDevice,
   getState,
   init,
-  openProtocolMultipleDialog: manualRtuService.openProtocolMultipleDialog,
-  sendGeneratedFrame: manualRtuService.sendGeneratedFrame,
-  sendHexFrame: transport.sendHexFrame,
-  setCommandIndex: manualRtuService.setCommandIndex,
-  setProtocolInput: manualRtuService.setProtocolInput,
-  setProtocolMultipleQuantity: manualRtuService.setProtocolMultipleQuantity,
-  setProtocolMultipleTextLength: manualRtuService.setProtocolMultipleTextLength,
-  setProtocolMultipleType: manualRtuService.setProtocolMultipleType,
-  setProtocolMultipleValue: manualRtuService.setProtocolMultipleValue,
-  setSendHex: transport.setSendHex,
   subscribeState,
-  toggleScan,
-  validateProtocolMultipleValue: manualRtuService.validateProtocolMultipleValue
+  toggleScan
 }

+ 1 - 4
features/home/view-model.js

@@ -1,5 +1,4 @@
 const transport = require('../../transport/ble-core.js')
-const manualRtuService = require('../manual-rtu/service.js')
 const themeService = require('../../store/theme-store.js')
 
 const DEFAULT_DEVICE_FILTER = 'all'
@@ -21,8 +20,7 @@ function filterDevices(devices, filterMode) {
 function getHomePageState(
   transportState = transport.getState(),
   deviceFilterMode = DEFAULT_DEVICE_FILTER,
-  themeState = themeService.getState(),
-  manualRtuState = manualRtuService.getState()
+  themeState = themeService.getState()
 ) {
   const { connectedDevice } = transportState
   const filteredDevices = filterDevices(transportState.devices, deviceFilterMode)
@@ -34,7 +32,6 @@ function getHomePageState(
 
   return {
     ...transportState,
-    ...manualRtuState,
     ...themeState,
     allDeviceCount,
     canClearDevices: !!allDeviceCount && !transportState.isConnecting,

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

@@ -0,0 +1,165 @@
+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
+}

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

@@ -0,0 +1,167 @@
+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
+}

+ 82 - 263
features/manual-rtu/service.js

@@ -1,30 +1,24 @@
-const {
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  formatHex
-} = require('../../protocols/modbus-rtu/frame.js')
 const transport = require('../../transport/ble-core.js')
 const {
   DATA_TYPE_OPTIONS,
-  getDataType,
-  getRegisterEncodedWords,
-  isByteRegister,
-  isTextRegister,
-  normalizeRegister,
-  validateRegisterValue
-} = require('../../domain/generic-modbus/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' }
-]
+  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,
@@ -39,7 +33,10 @@ const state = {
   protocolMultipleDialog: {
     visible: false
   },
+  protocolMultipleExpanded: false,
   protocolMultipleValues: [],
+  protocolResponseText: '',
+  protocolStatusText: '',
   registerAddress: '0000',
   showCoilValue: false,
   showCommandValue: true,
@@ -64,9 +61,11 @@ function getState() {
     protocolMultipleDialog: {
       ...state.protocolMultipleDialog
     },
+    protocolMultipleExpanded: !!state.protocolMultipleExpanded,
     protocolMultipleValues: state.protocolMultipleValues.map((item) => ({
       ...item
-    }))
+    })),
+    protocolStatusText: state.protocolStatusText || ''
   }
 }
 
@@ -82,181 +81,6 @@ function subscribe(subscriber) {
   }
 }
 
-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 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 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 setProtocolInput(changedData) {
   const command = getCommand(changedData.commandIndex === undefined ? state.commandIndex : changedData.commandIndex)
   let nextMultipleValues = changedData.protocolMultipleValues
@@ -292,6 +116,8 @@ function setProtocolInput(changedData) {
     ...changedData,
     ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
     ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {}),
+    protocolResponseText: '',
+    protocolStatusText: '',
     ...createProtocolState(
       nextState.commandIndex,
       nextState.slaveAddress,
@@ -306,15 +132,28 @@ function setProtocolInput(changedData) {
 function setCommandIndex(index) {
   const commandIndex = Number(index)
   const command = getCommand(commandIndex)
-  const commandValue = command.inputMode === 'multiple'
-    ? getManualMultipleValueText(state.protocolMultipleValues)
-    : getDefaultCommandValue(command)
+  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
+    commandRegisterQuantity: state.commandRegisterQuantity,
+    ...(command.inputMode === 'multiple' ? { protocolMultipleValues } : {})
   })
 }
 
@@ -337,6 +176,7 @@ function openProtocolMultipleDialog() {
       title: '写多个寄存器',
       visible: true
     },
+    protocolMultipleExpanded: false,
     protocolMultipleValues: values
   })
 }
@@ -349,6 +189,12 @@ function closeProtocolMultipleDialog() {
   })
 }
 
+function toggleProtocolMultipleExpanded() {
+  setState({
+    protocolMultipleExpanded: !state.protocolMultipleExpanded
+  })
+}
+
 function setProtocolMultipleQuantity(value) {
   const quantity = normalizeManualMultipleQuantity(value)
   let startAddress = 0
@@ -372,15 +218,7 @@ function setProtocolMultipleQuantity(value) {
 }
 
 function setProtocolMultipleValue(index, value) {
-  const registerIndex = Number(index)
-  const values = state.protocolMultipleValues.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? {
-        ...register,
-        inputValue: value
-      }
-      : register
-  ))
+  const values = updateManualMultipleValue(state.protocolMultipleValues, index, value)
   let startAddress = 0
   try {
     startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
@@ -398,18 +236,7 @@ function setProtocolMultipleValue(index, value) {
 }
 
 function setProtocolMultipleType(index, dataTypeIndex) {
-  const registerIndex = Number(index)
-  const dataType = DATA_TYPE_OPTIONS[Number(dataTypeIndex)] || DATA_TYPE_OPTIONS[0]
-  const changedValues = state.protocolMultipleValues.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? createManualMultipleRegister(currentIndex, {
-        ...register,
-        dataType: dataType.key,
-        inputValue: '',
-        textByteLength: isTextRegister(dataType.key) ? (register.textByteLength || '32') : ''
-      })
-      : register
-  ))
+  const changedValues = updateManualMultipleType(state.protocolMultipleValues, index, dataTypeIndex)
   let startAddress = 0
   try {
     startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
@@ -426,15 +253,7 @@ function setProtocolMultipleType(index, dataTypeIndex) {
 }
 
 function setProtocolMultipleTextLength(index, value) {
-  const registerIndex = Number(index)
-  const changedValues = state.protocolMultipleValues.map((register, currentIndex) => (
-    currentIndex === registerIndex
-      ? createManualMultipleRegister(currentIndex, {
-        ...register,
-        textByteLength: value
-      })
-      : register
-  ))
+  const changedValues = updateManualMultipleTextLength(state.protocolMultipleValues, index, value)
   let startAddress = 0
   try {
     startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
@@ -451,45 +270,43 @@ function setProtocolMultipleTextLength(index, value) {
 }
 
 function validateProtocolMultipleValue(index, value) {
-  const register = state.protocolMultipleValues[Number(index)]
-  if (!register) return false
-
-  return validateRegisterValue(register, value)
+  return validateManualMultipleValue(state.protocolMultipleValues, index, value)
 }
 
 function buildGeneratedExpectedResponse() {
-  try {
-    const command = getCommand(state.commandIndex)
-    const address = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
-    const slaveAddress = parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
-    const quantity = command.inputMode === 'quantity'
-      ? parseHexNumber(state.commandValue, '读取数量', 0xFFFF)
-      : (command.inputMode === 'multiple' ? 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',
-      quantity,
-      value,
-      slaveAddress
-    }
-  } catch (error) {
-    return null
-  }
+  return buildExpectedResponse(state)
 }
 
-function sendGeneratedFrame() {
-  if (!state.generatedHex) return false
+async function sendGeneratedFrame() {
+  if (!state.generatedHex) {
+    setState({
+      protocolStatusText: state.protocolErrorText || '请先生成有效帧'
+    })
+    return false
+  }
 
   const expected = buildGeneratedExpectedResponse()
+  setState({
+    protocolResponseText: '',
+    protocolStatusText: ''
+  })
 
-  return transport.enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
+  const response = await transport.enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
     expected
   } : {})
+
+  if (response) {
+    setState({
+      protocolResponseText: formatGeneratedResponse(response),
+      protocolStatusText: ''
+    })
+  } else {
+    setState({
+      protocolStatusText: '未收到回复'
+    })
+  }
+
+  return response
 }
 
 setState(createProtocolState(
@@ -504,6 +321,7 @@ setState(createProtocolState(
 module.exports = {
   buildGeneratedExpectedResponse,
   closeProtocolMultipleDialog,
+  formatGeneratedResponse,
   getState,
   openProtocolMultipleDialog,
   sendGeneratedFrame,
@@ -514,5 +332,6 @@ module.exports = {
   setProtocolMultipleType,
   setProtocolMultipleValue,
   subscribe,
+  toggleProtocolMultipleExpanded,
   validateProtocolMultipleValue
 }

+ 213 - 0
features/parameter-groups/dialog-handlers.js

@@ -0,0 +1,213 @@
+const {
+  createParameterGroupConfig,
+  createParameterGroupDialogState,
+  createParameterDialogState,
+  createParameterRegisterChangedData,
+  createParameterRegisterDialogState,
+  findParameterGroup,
+  findParameterRegister,
+  getDialogDataTypeState,
+  getOption
+} = require('./view-model.js')
+const {
+  buildActiveParameterRegisterRows
+} = require('./drag-view-model.js')
+
+function getParameterGroupsFromState(state = {}) {
+  return state.parameterGroups || []
+}
+
+function createDialogHandlers(parameterGroupService) {
+  return {
+    updateParameterDialog(changedData) {
+      this.setData({
+        parameterDialog: {
+          ...this.data.parameterDialog,
+          ...changedData
+        }
+      })
+    },
+
+    openParameterDraft(event) {
+      const groupId = event && event.currentTarget && event.currentTarget.dataset
+        ? event.currentTarget.dataset.groupId
+        : ''
+      const group = groupId ? findParameterGroup(getParameterGroupsFromState(this.data), groupId) : null
+
+      this.updateParameterDialog(createParameterGroupDialogState(group))
+    },
+
+    closeParameterDraft() {
+      this.parameterGroupLongPressGuard = ''
+      this.parameterRegisterLongPressGuard = ''
+      this.updateParameterDialog(createParameterDialogState())
+    },
+
+    onParameterDraftInput(event) {
+      const field = event.currentTarget.dataset.field
+      if (!field) return
+
+      const value = event.detail.value
+      this.updateParameterDialog({
+        [field]: value,
+        ...(field === 'structDefinition' ? {
+          parsedStructRegisters: [],
+          structParsedSummary: ''
+        } : {})
+      })
+    },
+
+    parseParameterStructDefinition() {
+      const dialog = this.data.parameterDialog || createParameterDialogState()
+      const sourceText = dialog.structDefinition || ''
+      if (!sourceText.trim()) {
+        if (this.pageToast) this.pageToast.show('请先粘贴结构体定义', 'error')
+        return
+      }
+
+      const registerType = getOption(this.data.parameterRegisterTypeOptions, dialog.registerTypeIndex)
+      if (registerType.key === 'coil' || registerType.key === 'discrete') {
+        if (this.pageToast) this.pageToast.show('结构体解析仅支持寄存器类型', 'error')
+        return
+      }
+
+      try {
+        const parsed = parameterGroupService.parseStructDefinition(sourceText)
+        const inputRegisterIndex = Math.max(
+          0,
+          this.data.parameterRegisterTypeOptions.findIndex((item) => item.key === 'input')
+        )
+        const inputRegisterType = getOption(this.data.parameterRegisterTypeOptions, inputRegisterIndex)
+        this.updateParameterDialog({
+          groupName: parsed.name || dialog.groupName,
+          parsedStructRegisters: parsed.registers,
+          quantity: String(parsed.registers.length),
+          registerTypeIndex: inputRegisterIndex,
+          registerTypeText: inputRegisterType.label || '',
+          structParsedSummary: `${parsed.structName} · ${parsed.registers.length} 个字段`
+        })
+        if (this.pageToast) this.pageToast.show('结构体解析完成')
+      } catch (error) {
+        if (this.pageToast) this.pageToast.show(error.message || '结构体解析失败', 'error')
+      }
+    },
+
+    onParameterDraftTypeChange(event) {
+      const registerTypeIndex = Number(event.detail.value)
+      const registerType = getOption(this.data.parameterRegisterTypeOptions, registerTypeIndex)
+      const clearParsedStruct = registerType.key === 'coil' || registerType.key === 'discrete'
+
+      this.updateParameterDialog({
+        ...(clearParsedStruct ? {
+          parsedStructRegisters: [],
+          structParsedSummary: ''
+        } : {}),
+        registerTypeIndex,
+        registerTypeText: registerType.label || ''
+      })
+    },
+
+    onParameterDialogDataTypeChange(event) {
+      const dataTypeIndex = Number(event.detail.value)
+
+      this.updateParameterDialog(getDialogDataTypeState(
+        this.data.parameterDialog,
+        this.data.parameterDataTypeOptions,
+        dataTypeIndex
+      ))
+    },
+
+    openParameterGroupEdit(event) {
+      const groupId = event.currentTarget.dataset.groupId
+      const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
+      if (!group) return
+
+      this.parameterGroupLongPressGuard = groupId
+
+      this.updateParameterDialog(createParameterGroupDialogState(group))
+    },
+
+    openParameterRegisterInfo(event) {
+      const groupId = event.currentTarget.dataset.groupId
+      const registerIndex = Number(event.currentTarget.dataset.index)
+      const registerKey = `${groupId}:${registerIndex}`
+      if (this.parameterRegisterLongPressGuard === registerKey) {
+        this.parameterRegisterLongPressGuard = ''
+        return
+      }
+
+      const {
+        group,
+        register
+      } = findParameterRegister(getParameterGroupsFromState(this.data), groupId, registerIndex)
+      if (!register) return
+
+      this.updateParameterDialog(createParameterRegisterDialogState('viewRegister', group, register, registerIndex))
+    },
+
+    openParameterRegisterEdit(event) {
+      const groupId = event.currentTarget.dataset.groupId
+      const registerIndex = Number(event.currentTarget.dataset.index)
+      const {
+        group,
+        register
+      } = findParameterRegister(getParameterGroupsFromState(this.data), groupId, registerIndex)
+      if (!register) return
+
+      this.parameterRegisterLongPressGuard = `${groupId}:${registerIndex}`
+      this.updateParameterDialog(createParameterRegisterDialogState('editRegister', group, register, registerIndex))
+    },
+
+    async confirmParameterDialog() {
+      const dialog = this.data.parameterDialog || createParameterDialogState()
+      const mode = dialog.mode
+
+      if (mode === 'createGroup') {
+        const group = parameterGroupService.addGroupFromConfig(createParameterGroupConfig(dialog))
+        if (group) {
+          if (this.pageToast) this.pageToast.show(`${group.name}已添加`)
+          this.closeParameterDraft()
+          this.setData({
+            activeParameterGroup: group,
+            activeParameterGroupId: group.id,
+            activeParamView: 'parameterGroup',
+            activeParameterRegisterRows: buildActiveParameterRegisterRows(group, this.parameterRegisterDrag)
+          })
+        }
+        return
+      }
+
+      if (mode === 'editGroup') {
+        const group = parameterGroupService.updateGroupConfig(dialog.groupId, createParameterGroupConfig(dialog))
+        if (group) {
+          if (this.pageToast) this.pageToast.show(`${group.name}已更新`)
+          this.closeParameterDraft()
+          if (this.data.activeParameterGroupId === group.id) {
+            this.setData({
+              activeParameterGroup: group,
+              activeParameterRegisterRows: buildActiveParameterRegisterRows(group, this.parameterRegisterDrag)
+            })
+          }
+        }
+        return
+      }
+
+      if (mode === 'editRegister') {
+        let changedData
+        try {
+          changedData = createParameterRegisterChangedData(dialog, this.data.parameterDataTypeOptions)
+        } catch (error) {
+          if (this.pageToast) this.pageToast.show(error.message || '公式无效', 'error')
+          return
+        }
+        parameterGroupService.updateRegister(dialog.groupId, dialog.registerIndex, changedData)
+        if (this.pageToast) this.pageToast.show(`${dialog.name || '寄存器'}已更新`)
+        this.closeParameterDraft()
+      }
+    }
+  }
+}
+
+module.exports = {
+  createDialogHandlers
+}

+ 127 - 0
features/parameter-groups/drag-view-model.js

@@ -0,0 +1,127 @@
+const PARAMETER_REGISTER_DRAG_THRESHOLD_PX = 12
+const PARAMETER_REGISTER_ROW_HEIGHT_RPX = 92
+
+function clampIndex(value, min, max) {
+  return Math.min(Math.max(value, min), max)
+}
+
+function getNumberOr(value, fallback) {
+  const numberValue = Number(value)
+
+  return Number.isFinite(numberValue) ? numberValue : fallback
+}
+
+function getWindowWidth() {
+  try {
+    if (typeof wx !== 'undefined' && wx && typeof wx.getWindowInfo === 'function') {
+      const info = wx.getWindowInfo()
+      if (info && Number.isFinite(info.windowWidth)) return info.windowWidth
+    }
+  } catch (error) {}
+
+  try {
+    if (typeof wx !== 'undefined' && wx && typeof wx.getSystemInfoSync === 'function') {
+      const info = wx.getSystemInfoSync()
+      if (info && Number.isFinite(info.windowWidth)) return info.windowWidth
+    }
+  } catch (error) {}
+
+  return 375
+}
+
+function rpxToPx(rpx, windowWidth) {
+  return Math.round(Number(rpx || 0) * Number(windowWidth || 375) / 750)
+}
+
+function getFallbackDragRowOffsetPx(windowWidth) {
+  return Math.max(44, rpxToPx(PARAMETER_REGISTER_ROW_HEIGHT_RPX, windowWidth))
+}
+
+function resolveDragTargetIndex(drag, currentY, totalCount) {
+  if (!drag || !Number.isInteger(totalCount) || totalCount <= 0) return 0
+
+  const sourceIndex = clampIndex(getNumberOr(drag.index, 0), 0, totalCount - 1)
+  const rowCenters = Array.isArray(drag.rowCenters) ? drag.rowCenters : []
+  const sourceCenter = Number(rowCenters[sourceIndex])
+  const startY = getNumberOr(drag.startY, currentY)
+
+  if (rowCenters.length === totalCount && Number.isFinite(sourceCenter)) {
+    const currentCenter = sourceCenter + (Number(currentY) - startY)
+    let targetIndex = sourceIndex
+
+    if (currentCenter >= sourceCenter) {
+      for (let index = sourceIndex + 1; index < totalCount; index += 1) {
+        if (currentCenter > Number(rowCenters[index])) targetIndex = index
+      }
+    } else {
+      for (let index = sourceIndex - 1; index >= 0; index -= 1) {
+        if (currentCenter < Number(rowCenters[index])) targetIndex = index
+      }
+    }
+
+    return clampIndex(targetIndex, 0, totalCount - 1)
+  }
+
+  const rowOffset = Math.max(1, Number(drag.rowOffset) || 1)
+  const step = Math.round((Number(currentY) - startY) / rowOffset)
+
+  return clampIndex(sourceIndex + step, 0, totalCount - 1)
+}
+
+function buildActiveParameterRegisterRows(group, dragState) {
+  if (!group || !Array.isArray(group.registers)) return []
+
+  const drag = dragState && dragState.groupId === group.id ? dragState : null
+  const activeIndex = drag ? clampIndex(getNumberOr(drag.index, 0), 0, group.registers.length - 1) : -1
+  const targetIndex = drag ? clampIndex(getNumberOr(drag.targetIndex, activeIndex), 0, group.registers.length - 1) : -1
+  const rowOffset = drag ? Math.max(1, Math.round(Number(drag.rowOffset) || 0)) : 0
+  const translateY = drag ? Math.round(Number(drag.translateY) || 0) : 0
+
+  return group.registers.map((register, index) => {
+    const row = {
+      ...register,
+      sourceIndex: index,
+      dragClass: '',
+      dragHandleClass: '',
+      dragStyle: ''
+    }
+
+    if (!drag) return row
+
+    const isActive = index === activeIndex
+    let shiftY = 0
+
+    if (drag.moved && rowOffset) {
+      if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) {
+        shiftY = -rowOffset
+      } else if (activeIndex > targetIndex && index >= targetIndex && index < activeIndex) {
+        shiftY = rowOffset
+      }
+    }
+
+    if (isActive) {
+      row.dragClass = drag.moved ? 'is-dragging' : 'is-drag-armed'
+      row.dragHandleClass = drag.moved ? 'is-dragging' : 'is-drag-armed'
+      row.dragStyle = drag.moved
+        ? `transform: translate3d(0, ${translateY}px, 0) scale(1.02); z-index: 8;`
+        : 'z-index: 3;'
+      return row
+    }
+
+    if (shiftY) {
+      row.dragClass = shiftY > 0 ? 'is-shift-down' : 'is-shift-up'
+      row.dragStyle = `transform: translate3d(0, ${shiftY}px, 0);`
+    }
+
+    return row
+  })
+}
+
+module.exports = {
+  PARAMETER_REGISTER_DRAG_THRESHOLD_PX,
+  buildActiveParameterRegisterRows,
+  clampIndex,
+  getFallbackDragRowOffsetPx,
+  getWindowWidth,
+  resolveDragTargetIndex
+}

+ 420 - 0
features/parameter-groups/import-merge.js

@@ -0,0 +1,420 @@
+const {
+  normalizeGroup
+} = require('../../domain/parameter-groups/model.js')
+const {
+  getRegistersByteLength
+} = require('./struct-completion.js')
+
+function formatAddress(address) {
+  return `0x${Number(address || 0).toString(16).toUpperCase().padStart(4, '0')}`
+}
+
+function normalizeDuplicateText(value) {
+  return String(value === undefined || value === null ? '' : value)
+    .trim()
+    .toLowerCase()
+}
+
+function normalizeStructMatchText(value) {
+  return String(value === undefined || value === null ? '' : value)
+    .trim()
+    .replace(/^(?:IDATA|XDATA|DATA|CODE)[\s:_-]+/i, '')
+    .replace(/^struct\s+/i, '')
+    .replace(/\s+#\d+$/i, '')
+    .replace(/^_+/, '')
+    .replace(/[^A-Za-z0-9]/g, '')
+    .toLowerCase()
+}
+
+function normalizeAddressKey(value, textValue) {
+  const numberValue = Number(value)
+  if (Number.isFinite(numberValue)) return String(Math.floor(numberValue))
+
+  return String(textValue === undefined || textValue === null ? '' : textValue)
+    .trim()
+    .toUpperCase()
+}
+
+function normalizeBitKey(source = {}) {
+  const value = source.sourceBitOffset !== undefined && source.sourceBitOffset !== null && source.sourceBitOffset !== ''
+    ? source.sourceBitOffset
+    : source.bitOffset
+  const numberValue = Number(value)
+
+  return Number.isFinite(numberValue) ? String(Math.floor(numberValue)) : ''
+}
+
+function getRegisterDuplicateKey(register = {}, group = {}) {
+  const area = normalizeDuplicateText(register.sourceMemoryArea || group.sourceMemoryArea || register.memoryArea || '')
+  const symbolName = normalizeDuplicateText(register.sourceSymbolName || register.name || '')
+  if (area && symbolName) return ['register', area, symbolName].join('|')
+
+  const addressKey = normalizeAddressKey(
+    register.sourceAddress !== undefined ? register.sourceAddress : register.address,
+    register.sourceAddressText || register.addressText
+  )
+  const bitKey = normalizeBitKey(register)
+
+  if (!area && !symbolName && !addressKey) return ''
+
+  return ['register', area, symbolName, addressKey, bitKey].join('|')
+}
+
+function isSingleRegisterAggregateGroup(group = {}) {
+  const groupSymbolName = normalizeDuplicateText(group.sourceSymbolName || group.name || '')
+  const registers = Array.isArray(group.registers) ? group.registers : []
+
+  return registers.some((register) => {
+    const registerSymbolName = normalizeDuplicateText(register.sourceSymbolName || register.name || '')
+
+    return registerSymbolName && groupSymbolName && registerSymbolName !== groupSymbolName
+  })
+}
+
+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 ''
+
+  return ['group', area, symbolName, addressKey].join('|')
+}
+
+function getAggregateGroupDuplicateKey(source = {}) {
+  const area = normalizeDuplicateText(source.sourceMemoryArea || source.memoryArea || '')
+  const registerType = normalizeDuplicateText(source.registerType || '')
+  const segment = normalizeDuplicateText(source.sourceSegment || '')
+
+  return ['aggregate', area, registerType, segment].join('|')
+}
+
+function collectImportedVariableIndexes(groups = []) {
+  return groups.reduce((indexes, group, groupIndex) => {
+    if (!isSingleRegisterAggregateGroup(group)) {
+      const groupKey = getGroupDuplicateKey(group)
+      if (groupKey) indexes.groupIndexes[groupKey] = groupIndex
+    } else {
+      const aggregateKey = getAggregateGroupDuplicateKey(group)
+      if (aggregateKey) indexes.aggregateGroupIndexes[aggregateKey] = groupIndex
+    }
+
+    ;(Array.isArray(group.registers) ? group.registers : []).forEach((register) => {
+      const registerKey = getRegisterDuplicateKey(register, group)
+      if (registerKey) {
+        indexes.registerIndexes[registerKey] = {
+          groupIndex,
+          registerIndex: group.registers.indexOf(register)
+        }
+      }
+    })
+
+    return indexes
+  }, {
+    aggregateGroupIndexes: {},
+    groupIndexes: {},
+    registerIndexes: {}
+  })
+}
+
+function getStructMatchNames(group = {}) {
+  const registers = Array.isArray(group.registers) ? group.registers : []
+  const names = [
+    group.sourceSymbolName,
+    group.sourceSymbolType,
+    group.name,
+    group.displayName
+  ]
+
+  registers.forEach((register) => {
+    names.push(register.sourceSymbolType, register.sourceSymbolName)
+  })
+
+  return names
+    .map(normalizeStructMatchText)
+    .filter(Boolean)
+    .filter((name, index, list) => list.indexOf(name) === index)
+}
+
+function structsMatchByName(existingGroup = {}, incomingGroup = {}) {
+  const existingNames = getStructMatchNames(existingGroup)
+  const incomingNames = getStructMatchNames(incomingGroup)
+
+  return existingNames.some((name) => incomingNames.indexOf(name) >= 0)
+}
+
+function getGroupByteLengthCandidates(group = {}) {
+  const registers = Array.isArray(group.registers) ? group.registers : []
+  const candidates = [
+    group.sourceByteLength,
+    group.byteLength,
+    group.structByteLength,
+    getRegistersByteLength(registers)
+  ]
+
+  registers.forEach((register) => {
+    candidates.push(register.structByteLength)
+  })
+
+  return candidates
+    .map((value) => Number(value))
+    .filter((value) => Number.isFinite(value) && value > 0)
+    .map((value) => Math.floor(value))
+    .filter((value, index, list) => list.indexOf(value) === index)
+}
+
+function structsMatchByByteLength(existingGroup = {}, incomingGroup = {}) {
+  const existingLengths = getGroupByteLengthCandidates(existingGroup)
+  const incomingLengths = getGroupByteLengthCandidates(incomingGroup)
+
+  return existingLengths.some((length) => incomingLengths.indexOf(length) >= 0)
+}
+
+function isIncomingPlaceholderStructGroup(group = {}) {
+  const registers = Array.isArray(group.registers) ? group.registers : []
+
+  return group.layout === 'struct'
+    && registers.length > 0
+    && registers.every((register) => !!register.isPlaceholderByteField)
+}
+
+function hasImportedStructRegisters(group = {}) {
+  const registers = Array.isArray(group.registers) ? group.registers : []
+
+  return group.layout === 'struct'
+    && registers.length > 0
+    && registers.some((register) => !register.isPlaceholderByteField)
+}
+
+function canPreserveExistingStructLayout(existingGroup, incomingGroup, options = {}) {
+  return options.preserveExistingStructLayout
+    && hasImportedStructRegisters(existingGroup)
+    && isIncomingPlaceholderStructGroup(incomingGroup)
+    && structsMatchByName(existingGroup, incomingGroup)
+    && structsMatchByByteLength(existingGroup, incomingGroup)
+}
+
+function getRegisterByteStart(register = {}) {
+  const byteStart = Number(register.byteStart)
+
+  return Number.isFinite(byteStart) ? Math.max(0, Math.floor(byteStart)) : 0
+}
+
+function mergePreservedStructRegister(register = {}, incomingGroup = {}) {
+  const byteStart = getRegisterByteStart(register)
+  const sourceAddress = ((Number(incomingGroup.sourceAddress) || Number(incomingGroup.startAddress) || 0) + byteStart) & 0xFFFF
+  const sourceSymbolName = incomingGroup.sourceSymbolName || register.sourceSymbolName
+  const sourceSymbolType = incomingGroup.sourceSymbolType || register.sourceSymbolType || sourceSymbolName
+
+  return {
+    ...register,
+    rawBytes: [],
+    rawValue: null,
+    rawWords: [],
+    sourceAddress,
+    sourceAddressText: formatAddress(sourceAddress),
+    sourceMemoryArea: incomingGroup.sourceMemoryArea,
+    sourceMemoryClass: incomingGroup.sourceMemoryClass,
+    sourceSymbolName,
+    sourceSymbolType
+  }
+}
+
+function mergePreservedStructGroupState(existingGroup, incomingGroup) {
+  const preservedRegisters = (Array.isArray(existingGroup.registers) ? existingGroup.registers : [])
+    .map((register) => mergePreservedStructRegister(register, incomingGroup))
+
+  return {
+    ...incomingGroup,
+    deleteVisible: false,
+    expanded: existingGroup.expanded === true,
+    id: existingGroup.id,
+    quantity: preservedRegisters.length,
+    registers: preservedRegisters
+  }
+}
+
+function findPreservableStructGroupIndex(groups = [], incomingGroup = {}, preferredIndex, options = {}) {
+  if (preferredIndex !== undefined && canPreserveExistingStructLayout(groups[preferredIndex], incomingGroup, options)) {
+    return preferredIndex
+  }
+
+  if (!options.preserveExistingStructLayout || !isIncomingPlaceholderStructGroup(incomingGroup)) return undefined
+
+  return groups.findIndex((group, index) => (
+    index !== preferredIndex && canPreserveExistingStructLayout(group, incomingGroup, options)
+  ))
+}
+
+function mergeImportedRegisterState(existingRegister, incomingRegister, options = {}) {
+  if (!existingRegister) return incomingRegister
+
+  const incomingRemark = incomingRegister.remark
+  const shouldPreserveRemark = options.preserveExistingRemarks
+    && !String(incomingRemark === undefined || incomingRemark === null ? '' : incomingRemark).trim()
+
+  return {
+    ...incomingRegister,
+    id: existingRegister.id,
+    inputValue: incomingRegister.inputValue !== undefined && incomingRegister.inputValue !== null
+      ? incomingRegister.inputValue
+      : existingRegister.inputValue,
+    remark: shouldPreserveRemark
+      ? existingRegister.remark
+      : (incomingRemark !== undefined && incomingRemark !== null ? incomingRemark : existingRegister.remark),
+    rawBytes: [],
+    rawValue: null,
+    rawWords: []
+  }
+}
+
+function mergeImportedGroupState(existingGroup, incomingGroup, options = {}) {
+  if (!existingGroup) return incomingGroup
+
+  if (canPreserveExistingStructLayout(existingGroup, incomingGroup, options)) {
+    return mergePreservedStructGroupState(existingGroup, incomingGroup)
+  }
+
+  const existingRegisters = Array.isArray(existingGroup.registers) ? existingGroup.registers : []
+  const incomingRegisters = Array.isArray(incomingGroup.registers) ? incomingGroup.registers : []
+
+  return {
+    ...incomingGroup,
+    deleteVisible: false,
+    expanded: existingGroup.expanded === true,
+    id: existingGroup.id,
+    registers: incomingRegisters.map((incomingRegister, index) => mergeImportedRegisterState(
+      existingRegisters[index],
+      incomingRegister,
+      options
+    ))
+  }
+}
+
+function mergeAggregateImportedGroup(nextGroups, incomingGroup, indexes, options = {}) {
+  const aggregateKey = getAggregateGroupDuplicateKey(incomingGroup)
+  const aggregateGroupIndex = indexes.aggregateGroupIndexes[aggregateKey]
+  let targetGroup = aggregateGroupIndex === undefined ? null : nextGroups[aggregateGroupIndex]
+  let targetGroupIndex = aggregateGroupIndex
+  let targetRegisters = targetGroup && Array.isArray(targetGroup.registers)
+    ? targetGroup.registers.slice()
+    : []
+  let addedRegisterCount = 0
+  let updatedRegisterCount = 0
+
+  ;(Array.isArray(incomingGroup.registers) ? incomingGroup.registers : []).forEach((incomingRegister) => {
+    const registerKey = getRegisterDuplicateKey(incomingRegister, incomingGroup)
+    const existingRef = registerKey ? indexes.registerIndexes[registerKey] : null
+
+    if (existingRef) {
+      const existingGroup = nextGroups[existingRef.groupIndex]
+      const existingRegister = existingGroup && existingGroup.registers
+        ? existingGroup.registers[existingRef.registerIndex]
+        : null
+      const mergedRegister = mergeImportedRegisterState(existingRegister, incomingRegister, options)
+
+      if (targetGroupIndex !== undefined && existingRef.groupIndex === targetGroupIndex) {
+        targetRegisters[existingRef.registerIndex] = mergedRegister
+      } else if (existingGroup) {
+        const registers = existingGroup.registers.slice()
+        registers[existingRef.registerIndex] = mergedRegister
+        nextGroups[existingRef.groupIndex] = normalizeGroup({
+          ...existingGroup,
+          registers
+        })
+      }
+
+      updatedRegisterCount += 1
+      return
+    }
+
+    targetRegisters.push(incomingRegister)
+    addedRegisterCount += 1
+  })
+
+  if (targetGroupIndex !== undefined && targetGroup) {
+    nextGroups[targetGroupIndex] = normalizeGroup(mergeImportedGroupState(targetGroup, {
+      ...incomingGroup,
+      quantity: targetRegisters.length,
+      registers: targetRegisters
+    }))
+  } else if (targetRegisters.length) {
+    targetGroup = normalizeGroup({
+      ...incomingGroup,
+      quantity: targetRegisters.length,
+      registers: targetRegisters
+    })
+    nextGroups.push(targetGroup)
+    targetGroupIndex = nextGroups.length - 1
+  }
+
+  return {
+    addedGroupCount: targetGroupIndex === aggregateGroupIndex ? 0 : (targetRegisters.length ? 1 : 0),
+    addedRegisterCount,
+    updatedGroupCount: targetGroupIndex === aggregateGroupIndex && (addedRegisterCount || updatedRegisterCount) ? 1 : 0,
+    updatedRegisterCount
+  }
+}
+
+function mergeImportedGroups(existingGroups = [], incomingGroups = [], options = {}) {
+  const nextGroups = existingGroups.slice()
+  let indexes = collectImportedVariableIndexes(nextGroups)
+  const result = {
+    addedGroupCount: 0,
+    addedRegisterCount: 0,
+    groups: nextGroups,
+    updatedGroupCount: 0,
+    updatedRegisterCount: 0
+  }
+
+  incomingGroups.forEach((incomingGroup) => {
+    if (isSingleRegisterAggregateGroup(incomingGroup)) {
+      const aggregateResult = mergeAggregateImportedGroup(nextGroups, incomingGroup, indexes, options)
+      result.addedGroupCount += aggregateResult.addedGroupCount
+      result.addedRegisterCount += aggregateResult.addedRegisterCount
+      result.updatedGroupCount += aggregateResult.updatedGroupCount
+      result.updatedRegisterCount += aggregateResult.updatedRegisterCount
+      indexes = collectImportedVariableIndexes(nextGroups)
+      return
+    }
+
+    const groupKey = getGroupDuplicateKey(incomingGroup)
+    const existingGroupIndex = groupKey ? indexes.groupIndexes[groupKey] : undefined
+    const preservableStructGroupIndex = findPreservableStructGroupIndex(
+      nextGroups,
+      incomingGroup,
+      existingGroupIndex,
+      options
+    )
+    const targetGroupIndex = preservableStructGroupIndex >= 0
+      ? preservableStructGroupIndex
+      : existingGroupIndex
+
+    if (targetGroupIndex !== undefined) {
+      const existingGroup = nextGroups[targetGroupIndex]
+      nextGroups[targetGroupIndex] = normalizeGroup(mergeImportedGroupState(existingGroup, incomingGroup, options))
+      result.updatedGroupCount += 1
+    } else {
+      nextGroups.push(incomingGroup)
+      result.addedGroupCount += 1
+    }
+
+    indexes = collectImportedVariableIndexes(nextGroups)
+  })
+
+  result.changedCount = result.addedGroupCount
+    + result.updatedGroupCount
+    + result.addedRegisterCount
+    + result.updatedRegisterCount
+
+  return result
+}
+
+module.exports = {
+  mergeImportedGroups
+}

+ 22 - 0
features/parameter-groups/index.js

@@ -0,0 +1,22 @@
+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,
+  parameterGroupService: service,
+  ...poller,
+  ...viewModel
+}

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

@@ -0,0 +1,197 @@
+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
+}

+ 241 - 0
features/parameter-groups/persistence.js

@@ -0,0 +1,241 @@
+const {
+  formatExportStamp,
+  isCancelError,
+  loadSelectedFile,
+  saveTextFileToChat
+} = require('../../repositories/file.js')
+const {
+  getWxApi
+} = require('../../utils/platform-utils.js')
+const {
+  cloneImportedGroup,
+  getRegisterJsonValue
+} = require('../../domain/parameter-groups/model.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,
+    registerType: group.registerType,
+    startAddress: group.startAddress,
+    quantity: group.quantity,
+    registers: group.registers.map((register) => ({
+      dataType: register.dataType,
+      defaultValue: register.defaultValue,
+      conversionFormula: register.conversionFormula,
+      isStructField: register.isStructField,
+      name: register.name,
+      maxValue: register.maxValue,
+      minValue: register.minValue,
+      textByteLength: register.textByteLength,
+      remark: register.remark,
+      unit: register.unit,
+      value: getRegisterJsonValue(register),
+      ...pickFields(register, REGISTER_STRUCT_FIELDS),
+      ...pickFields(register, REGISTER_SOURCE_FIELDS)
+    })),
+    ...pickFields(group, GROUP_SOURCE_FIELDS)
+  }))
+}
+
+function toJsonData(groups = [], options = {}) {
+  const jsonData = {
+    groups: toPersistedGroups(groups),
+    type: JSON_DOCUMENT_TYPE,
+    version: JSON_SCHEMA_VERSION
+  }
+
+  if (options.includeExportedAt) {
+    jsonData.exportedAt = new Date().toISOString()
+  }
+
+  return jsonData
+}
+
+function toJsonText(groups = [], options = {}) {
+  return JSON.stringify(toJsonData(groups, options), null, 2)
+}
+
+function normalizeProtocolMode(protocolMode) {
+  return protocolMode === PROTOCOL_MODE.MODBUS_RTU
+    ? PROTOCOL_MODE.MODBUS_RTU
+    : PROTOCOL_MODE.STORAGE_ACCESS
+}
+
+function getProtocolStorageKey(protocolMode) {
+  return `${STORAGE_KEY}:${normalizeProtocolMode(protocolMode)}`
+}
+
+function parseJsonGroups(jsonText) {
+  const parsed = typeof jsonText === 'string' ? JSON.parse(jsonText) : jsonText
+  const groups = Array.isArray(parsed)
+    ? parsed
+    : (Array.isArray(parsed && parsed.groups) ? parsed.groups : parsed && parsed.parameterGroups)
+
+  if (parsed && parsed.type && parsed.type !== JSON_DOCUMENT_TYPE) {
+    throw new Error('JSON 文件不是参数组配置')
+  }
+
+  if (parsed && parsed.version && parsed.version !== JSON_SCHEMA_VERSION) {
+    throw new Error('JSON 版本不兼容')
+  }
+
+  if (!Array.isArray(groups)) {
+    throw new Error('JSON 中没有找到寄存器组数组')
+  }
+
+  return groups
+}
+
+function readStoredGroups(protocolMode = PROTOCOL_MODE.STORAGE_ACCESS) {
+  const wxApi = getWxApi()
+  if (typeof wxApi.getStorageSync !== 'function') return []
+
+  try {
+    const storageKey = getProtocolStorageKey(protocolMode)
+    const jsonText = wxApi.getStorageSync(storageKey)
+    if (jsonText) return parseJsonGroups(jsonText).map(cloneImportedGroup)
+  } catch (error) {
+    return []
+  }
+
+  return []
+}
+
+function persistGroups(groups = [], protocolMode = PROTOCOL_MODE.STORAGE_ACCESS) {
+  const wxApi = getWxApi()
+  if (typeof wxApi.setStorageSync !== 'function') return
+
+  try {
+    wxApi.setStorageSync(getProtocolStorageKey(protocolMode), toJsonText(groups))
+  } 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', {
+    encoding: 'utf8',
+    extensionMessage: '请选择 .json 寄存器配置文件',
+    extensions: ['json'],
+    fallbackName: 'parameter-groups.json'
+  })
+
+  return parseJsonGroups(file.text).map(cloneImportedGroup)
+}
+
+async function saveGroupsToChat(groups = []) {
+  if (!groups.length) {
+    throw new Error('没有可保存的寄存器组')
+  }
+
+  const jsonText = toJsonText(groups, {
+    includeExportedAt: true
+  })
+
+  await saveTextFileToChat(getShareFileName(), jsonText)
+
+  return groups.length
+}
+
+module.exports = {
+  JSON_DOCUMENT_TYPE,
+  JSON_SCHEMA_VERSION,
+  STORAGE_KEY,
+  isCancelError,
+  legacyGroupsNeedMigration,
+  loadGroupsFromMessageFile,
+  migrateLegacyGroupsToProtocol,
+  parseJsonGroups,
+  persistGroups,
+  readStoredGroups,
+  saveGroupsToChat,
+  toJsonData,
+  toJsonText
+}

+ 20 - 13
features/generic-modbus/poller.js → features/parameter-groups/poller.js

@@ -1,6 +1,6 @@
-const genericModbusService = require('./service.js')
+const parameterGroupService = require('./service.js')
 
-const POLL_TIMER_ID = '__genericPoll'
+const POLL_TIMER_ID = '__parameterPoll'
 const MEMORY_AREA_ORDER = {
   DATA: 0,
   IDATA: 1,
@@ -25,19 +25,23 @@ function getGroupAddress(group = {}) {
 
 function shouldPoll(data) {
   return !!data
-    && (data.activeParamView === 'genericModbus' || data.activeParamView === 'genericModbusGroup')
+    && (data.activeParamView === 'parameterGroups' || data.activeParamView === 'parameterGroup')
     && !!data.connectedDevice
-    && !!data.genericModbusAutoPollEnabled
+    && !!data.parameterAutoPollEnabled
+}
+
+function getParameterGroups(data = {}) {
+  return data.parameterGroups || []
 }
 
 function getPollableGroups(data) {
-  return (data.genericModbusGroups || [])
+  return getParameterGroups(data)
     .filter((group) => {
       if (!group || group.addressOverflow) return false
       if ((group.registers || []).some((register) => register && register.isDirty)) return false
-      if (data.isPrivateProtocol) return !!group.sourceMemoryArea
+      if (data.isStorageAccessProtocol) return !!group.sourceMemoryArea
 
-      return group.isReadOnly
+      return !!group.functionCode
     })
     .sort((left, right) => (
       getMemoryAreaOrder(left) - getMemoryAreaOrder(right)
@@ -46,7 +50,7 @@ function getPollableGroups(data) {
     ))
 }
 
-function createGenericModbusPoller(getData) {
+function createParameterGroupPoller(getData) {
   const timers = {}
 
   function clearTimer(timerId) {
@@ -68,20 +72,23 @@ function createGenericModbusPoller(getData) {
         clearTimer(POLL_TIMER_ID)
         return
       }
+      const protocolMode = data.protocolMode
 
       for (const group of getPollableGroups(data)) {
         const latestData = getData()
         if (!shouldPoll(latestData)) break
+        if (latestData.protocolMode !== protocolMode) break
 
-        await genericModbusService.readGroup(group.id, {
-          maxPacketLength: latestData.genericModbusMaxPacketLength,
+        await parameterGroupService.readGroup(group.id, {
+          maxPacketLength: latestData.parameterMaxPacketLength,
+          protocolMode,
           showModal: false
         })
       }
 
       const latestData = getData()
       if (shouldPoll(latestData)) {
-        schedule(latestData.genericModbusPollInterval || 100)
+        schedule(latestData.parameterPollInterval || 100)
       }
     }, delay)
   }
@@ -93,7 +100,7 @@ function createGenericModbusPoller(getData) {
       return
     }
 
-    schedule(data.genericModbusPollInterval || 100)
+    schedule(data.parameterPollInterval || 100)
   }
 
   return {
@@ -105,5 +112,5 @@ function createGenericModbusPoller(getData) {
 }
 
 module.exports = {
-  createGenericModbusPoller
+  createParameterGroupPoller
 }

+ 466 - 0
features/parameter-groups/service.js

@@ -0,0 +1,466 @@
+const {
+  isCancelError,
+  loadGroupsFromMessageFile,
+  saveGroupsToChat
+} = require('./persistence.js')
+const {
+  mergeImportedGroups
+} = require('./import-merge.js')
+const {
+  completeStructInstanceGroups,
+  parseStructDefinition
+} = require('./struct-completion.js')
+const storageAccessService = require('../storage-access/service.js')
+const storageAccessIo = require('./storage-access-io.js')
+const modbusIo = require('./modbus-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,
+  REGISTER_TYPE_OPTIONS,
+  cloneImportedGroup,
+  isAddressRangeOverflow,
+  normalizeGroup,
+  normalizeGroupConfig,
+  validateRegisterValue
+} = require('../../domain/parameter-groups/model.js')
+const {
+  findGroup,
+  getGroups,
+  getState,
+  init,
+  setGroups,
+  setStorageCodeInfo,
+  switchProtocolMode,
+  subscribe,
+  updateGroups
+} = store
+
+let initialized = false
+
+function getActiveProtocolMode() {
+  return settingsService.getState().protocolMode
+}
+
+function isStorageAccessProtocolMode(protocolMode) {
+  return settingsService.isStorageAccessProtocol(protocolMode)
+}
+
+function initParameterGroups() {
+  settingsService.init()
+  init(getActiveProtocolMode())
+  switchProtocolMode(getActiveProtocolMode(), {
+    notify: false
+  })
+
+  if (initialized) return
+
+  settingsService.subscribe((settingsState) => {
+    switchProtocolMode(settingsState.protocolMode)
+  })
+  initialized = true
+}
+
+async function importJsonFromMessageFile() {
+  try {
+    const importedGroups = (await loadGroupsFromMessageFile()).map(normalizeGroup)
+    if (!importedGroups.length) throw new Error('JSON 中没有可导入的寄存器组')
+    const merged = mergeImportedGroups(getGroups(), importedGroups)
+
+    setGroups(merged.groups)
+
+    return merged.changedCount || 0
+  } catch (error) {
+    const message = error && error.message ? error.message : '导入参数组配置失败'
+    transport.showCommandAlert('参数组导入', message)
+    return 0
+  }
+}
+
+function completeStructInstanceGroupsWithStructSource(sourceText, options = {}) {
+  const completed = completeStructInstanceGroups(getGroups(), sourceText, options)
+
+  if (!completed.completedCount) {
+    throw new Error('没有找到可匹配的结构体实例')
+  }
+
+  setGroups(completed.groups)
+
+  return completed
+}
+
+function mergeImportedGroupsIntoState(importedGroups = [], options = {}) {
+  const normalizedGroups = importedGroups.map(cloneImportedGroup).map(normalizeGroup)
+  const merged = mergeImportedGroups(getGroups(), normalizedGroups, options)
+
+  if (normalizedGroups.length) {
+    setGroups(merged.groups)
+  }
+
+  return merged
+}
+
+async function completeStructInstanceGroupsWithStructFile(options = {}) {
+  try {
+    const file = await loadSelectedFile('message', {
+      encoding: 'utf8',
+      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)
+
+    return {
+      completedCount: 0,
+      skippedCount: 0,
+      structCount: 0,
+      variableCount: 0
+    }
+  }
+}
+
+async function saveJsonToChat() {
+  try {
+    return saveGroupsToChat(getGroups())
+  } catch (error) {
+    const message = error && error.message ? error.message : '保存参数组配置失败'
+
+    if (!isCancelError(error)) {
+      transport.showCommandAlert('参数组保存', message)
+    }
+
+    return 0
+  }
+}
+
+async function syncFromStorageAccessCodeInfo(options = {}) {
+  const result = await storageAccessService.syncCodeInfo(options)
+  if (!result || !result.ok) return result
+
+  setStorageCodeInfo(result.codeInfo)
+  const merged = mergeImportedGroupsIntoState(result.importedGroups || [], {
+    preserveExistingRemarks: true,
+    preserveExistingStructLayout: true
+  })
+
+  return {
+    ...result,
+    addedGroups: merged.addedGroupCount,
+    addedRegisters: merged.addedRegisterCount,
+    updatedGroups: merged.updatedGroupCount,
+    updatedRegisters: merged.updatedRegisterCount
+  }
+}
+
+function addGroupFromConfig(config = {}) {
+  let groupConfig
+
+  try {
+    groupConfig = normalizeGroupConfig(config)
+  } catch (error) {
+    transport.showCommandAlert('参数组添加', error.message || '寄存器组配置无效')
+    return null
+  }
+
+  if (isAddressRangeOverflow(groupConfig.startAddress, groupConfig.quantity)) {
+    transport.showCommandAlert('参数组添加', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  const registers = Array.isArray(config.registers) ? config.registers : []
+  const group = normalizeGroup({
+    ...groupConfig,
+    layout: config.layout,
+    ...(registers.length ? { registers } : {}),
+    expanded: false
+  })
+
+  if (group.addressOverflow) {
+    transport.showCommandAlert('参数组添加', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  setGroups(getGroups().concat(group))
+
+  return group
+}
+
+function updateGroupConfig(groupId, config = {}) {
+  const group = findGroup(groupId)
+  if (!group) return null
+
+  let nextConfig
+  try {
+    nextConfig = normalizeGroupConfig({
+      ...group,
+      ...config
+    })
+  } catch (error) {
+    transport.showCommandAlert('参数组更新', error.message || '寄存器组配置无效')
+    return null
+  }
+
+  if (isAddressRangeOverflow(nextConfig.startAddress, nextConfig.quantity)) {
+    transport.showCommandAlert('参数组更新', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  const registers = Array.isArray(config.registers) ? config.registers : group.registers
+  const updatedGroup = normalizeGroup({
+    ...group,
+    ...nextConfig,
+    registers
+  })
+
+  if (updatedGroup.addressOverflow) {
+    transport.showCommandAlert('参数组更新', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  setGroups(getGroups().map((item) => (
+    item.id === groupId ? updatedGroup : item
+  )))
+
+  return updatedGroup
+}
+
+function setGroupExpanded(groupId, expanded) {
+  updateGroups((group) => group.id === groupId
+    ? {
+      ...group,
+      deleteVisible: false,
+      expanded
+    }
+    : group)
+}
+
+function setGroupDeleteVisible(groupId, deleteVisible) {
+  updateGroups((group) => group.id === groupId
+    ? {
+      ...group,
+      deleteVisible
+    }
+    : group)
+}
+
+function removeGroup(groupId) {
+  setGroups(getGroups().filter((group) => group.id !== groupId))
+}
+
+function reorderRegister(groupId, fromIndex, toIndex) {
+  const group = findGroup(groupId)
+  if (!group) return null
+  if (group.isStructLayout) return group
+
+  const registers = group.registers.slice()
+  const sourceIndex = Number(fromIndex)
+  const targetIndex = Number(toIndex)
+  if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex)) return null
+  if (sourceIndex < 0 || sourceIndex >= registers.length) return null
+
+  const safeTargetIndex = Math.min(Math.max(targetIndex, 0), registers.length - 1)
+  if (safeTargetIndex === sourceIndex) return group
+
+  const moved = registers.splice(sourceIndex, 1)[0]
+  registers.splice(safeTargetIndex, 0, moved)
+
+  const updatedGroup = normalizeGroup({
+    ...group,
+    quantity: registers.length,
+    registers
+  })
+
+  setGroups(getGroups().map((item) => (
+    item.id === groupId ? updatedGroup : item
+  )))
+
+  return updatedGroup
+}
+
+function updateRegister(groupId, registerIndex, changedData) {
+  updateGroups((group) => {
+    if (group.id !== groupId) return group
+    const shouldResetReadState = Object.prototype.hasOwnProperty.call(changedData, 'dataType')
+      || Object.prototype.hasOwnProperty.call(changedData, 'textByteLength')
+
+    return {
+      ...group,
+      registers: group.registers.map((register, currentIndex) => (
+        currentIndex === registerIndex
+          ? {
+            ...register,
+            ...(shouldResetReadState ? { rawValue: null, rawWords: [] } : {}),
+            ...changedData
+          }
+          : register
+      ))
+    }
+  })
+}
+
+function updateRegisterValue(groupId, registerIndex, value) {
+  updateRegister(groupId, registerIndex, {
+    inputValue: value,
+    isDirty: true
+  })
+}
+
+function validateRegisterInputValue(groupId, registerIndex, value) {
+  const group = findGroup(groupId)
+  if (!group) return false
+
+  const register = group.registers[registerIndex]
+  if (!register) return false
+
+  return validateRegisterValue(register, value)
+}
+
+async function readGroup(groupId, options = {}) {
+  const protocolMode = options.protocolMode || getActiveProtocolMode()
+  const group = findGroup(groupId, protocolMode)
+  if (!group) return false
+  if (group.addressOverflow) {
+    transport.showCommandAlert('参数组读取', '地址范围超出 0xFFFF')
+    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)
+  }
+
+  updateGroups((item) => {
+    if (item.id !== groupId) return item
+
+    return stateMappers.applyReadCacheToGroup(item, wordCache)
+  }, {
+    protocolMode
+  })
+
+  return true
+}
+
+async function writeRegister(groupId, registerIndex) {
+  const protocolMode = getActiveProtocolMode()
+  const group = findGroup(groupId, protocolMode)
+  const register = group && group.registers ? group.registers[registerIndex] : null
+  if (!group || !register) return false
+  if (!group.writable) {
+    transport.showCommandAlert('参数组写入', '当前变量为只读')
+    return false
+  }
+  if (group.addressOverflow) {
+    transport.showCommandAlert('参数组写入', '地址范围超出 0xFFFF')
+    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
+  }
+
+  updateGroups((item) => {
+    if (item.id !== groupId) return item
+
+    return stateMappers.applyWrittenSnapshotToGroupRegister(item, registerIndex, written)
+  }, {
+    protocolMode
+  })
+
+  return true
+}
+
+async function writeGroup(groupId, options = {}) {
+  const protocolMode = options.protocolMode || getActiveProtocolMode()
+  const group = findGroup(groupId, protocolMode)
+  if (!group) return false
+  if (!group.writable) {
+    transport.showCommandAlert('参数组写入', '当前寄存器组为只读')
+    return false
+  }
+  if (group.addressOverflow) {
+    transport.showCommandAlert('参数组写入', '地址范围超出 0xFFFF')
+    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)
+    })
+  }
+
+  updateGroups((item) => {
+    if (item.id !== groupId) return item
+
+    return stateMappers.applyWrittenSnapshotsToGroup(item, writtenRegisters)
+  }, {
+    protocolMode
+  })
+
+  return true
+}
+
+module.exports = {
+  DATA_TYPE_OPTIONS,
+  REGISTER_TYPE_OPTIONS,
+  addGroupFromConfig,
+  completeStructInstanceGroups,
+  completeStructInstanceGroupsWithStructFile,
+  completeStructInstanceGroupsWithStructSource,
+  getState,
+  importJsonFromMessageFile,
+  init: initParameterGroups,
+  mergeImportedGroups: mergeImportedGroupsIntoState,
+  parseStructDefinition,
+  readGroup,
+  removeGroup,
+  reorderRegister,
+  saveJsonToChat,
+  setGroupDeleteVisible,
+  setGroupExpanded,
+  subscribe,
+  syncFromStorageAccessCodeInfo,
+  updateGroupConfig,
+  updateRegister,
+  updateRegisterValue,
+  validateRegisterInputValue,
+  writeRegister,
+  writeGroup
+}

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

@@ -0,0 +1,136 @@
+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
+}

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

@@ -0,0 +1,288 @@
+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
+}

+ 256 - 0
features/parameter-groups/store.js

@@ -0,0 +1,256 @@
+const {
+  migrateLegacyGroupsToProtocol,
+  persistGroups,
+  readStoredGroups
+} = require('./persistence.js')
+const {
+  getWxApi
+} = require('../../utils/platform-utils.js')
+const {
+  normalizeStorageCodeInfoCard,
+  DATA_TYPE_OPTIONS,
+  REGISTER_TYPE_OPTIONS,
+  normalizeGroup
+} = require('../../domain/parameter-groups/model.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)
+let state = {
+  activeProtocolMode: PROTOCOL_MODE.STORAGE_ACCESS,
+  parameterDataTypeOptions: DATA_TYPE_OPTIONS,
+  parameterGroupsByProtocol: {
+    [PROTOCOL_MODE.MODBUS_RTU]: [],
+    [PROTOCOL_MODE.STORAGE_ACCESS]: []
+  },
+  parameterRegisterTypeOptions: REGISTER_TYPE_OPTIONS,
+  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() {
+  return state.parameterGroupsByProtocol[getActiveProtocolMode()] || []
+}
+
+function getGroupsForProtocol(protocolMode = getActiveProtocolMode()) {
+  return state.parameterGroupsByProtocol[normalizeProtocolMode(protocolMode)] || []
+}
+
+function getCodeInfoContext(card = state.storageCodeInfoCard) {
+  return card && card.codeInfoContext
+    ? card.codeInfoContext
+    : {}
+}
+
+function normalizeGroupsForProtocol(parameterGroups = [], protocolMode = getActiveProtocolMode(), options = {}) {
+  const codeInfoContext = protocolMode === PROTOCOL_MODE.STORAGE_ACCESS
+    ? getCodeInfoContext(options.storageCodeInfoCard)
+    : {}
+
+  return Array.isArray(parameterGroups)
+    ? parameterGroups.map((group) => normalizeGroup({
+      ...group,
+      codeInfoContext
+    }))
+    : []
+}
+
+function notify() {
+  const nextState = getState()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(nextState)
+  })
+}
+
+function persistActiveGroups(options = {}) {
+  if (options.persist === false) return
+
+  persistGroups(getActiveGroups(), getActiveProtocolMode())
+}
+
+function setState(changedData, options = {}) {
+  state = {
+    ...state,
+    ...changedData
+  }
+
+  persistActiveGroups(options)
+  notify()
+}
+
+function setGroups(parameterGroups, options = {}) {
+  const protocolMode = normalizeProtocolMode(options.protocolMode || getActiveProtocolMode())
+  const normalizedGroups = normalizeGroupsForProtocol(parameterGroups, protocolMode)
+  const parameterGroupsByProtocol = {
+    ...state.parameterGroupsByProtocol,
+    [protocolMode]: normalizedGroups
+  }
+
+  state = {
+    ...state,
+    parameterGroupsByProtocol
+  }
+
+  if (options.persist !== false) persistGroups(normalizedGroups, protocolMode)
+  notify()
+}
+
+function persistStorageCodeInfoCard(card) {
+  const wxApi = getWxApi()
+  if (typeof wxApi.setStorageSync !== 'function') return
+
+  try {
+    wxApi.setStorageSync(STORAGE_CODE_INFO_KEY, JSON.stringify(card || null))
+  } catch (error) {}
+}
+
+function readStorageCodeInfoCard() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.getStorageSync !== 'function') return DEFAULT_STORAGE_CODE_INFO_CARD
+
+  try {
+    const jsonText = wxApi.getStorageSync(STORAGE_CODE_INFO_KEY)
+    return jsonText ? normalizeStorageCodeInfoCard(JSON.parse(jsonText)) : DEFAULT_STORAGE_CODE_INFO_CARD
+  } catch (error) {
+    return DEFAULT_STORAGE_CODE_INFO_CARD
+  }
+}
+
+function setStorageCodeInfo(codeInfo, options = {}) {
+  const card = normalizeStorageCodeInfoCard(codeInfo)
+  const storageGroups = state.parameterGroupsByProtocol[PROTOCOL_MODE.STORAGE_ACCESS] || []
+  const normalizedStorageGroups = normalizeGroupsForProtocol(storageGroups, PROTOCOL_MODE.STORAGE_ACCESS, {
+    storageCodeInfoCard: card
+  })
+
+  state = {
+    ...state,
+    parameterGroupsByProtocol: {
+      ...state.parameterGroupsByProtocol,
+      [PROTOCOL_MODE.STORAGE_ACCESS]: normalizedStorageGroups
+    },
+    storageCodeInfoCard: card
+  }
+
+  if (options.persist !== false) persistGroups(normalizedStorageGroups, PROTOCOL_MODE.STORAGE_ACCESS)
+  if (options.persist !== false) persistStorageCodeInfoCard(card)
+  notify()
+}
+
+function updateGroups(mapper, options = {}) {
+  if (typeof mapper !== 'function') return
+
+  const protocolMode = normalizeProtocolMode(options.protocolMode || getActiveProtocolMode())
+  setGroups(getGroupsForProtocol(protocolMode).map((group, index) => mapper(group, index)), {
+    ...options,
+    protocolMode
+  })
+}
+
+function switchProtocolMode(protocolMode, options = {}) {
+  const normalizedProtocolMode = normalizeProtocolMode(protocolMode)
+  init(normalizedProtocolMode)
+
+  if (state.activeProtocolMode === normalizedProtocolMode) {
+    if (options.notify !== false) notify()
+    return
+  }
+
+  state = {
+    ...state,
+    activeProtocolMode: normalizedProtocolMode
+  }
+
+  if (options.notify !== false) notify()
+}
+
+function init(protocolMode = state.activeProtocolMode) {
+  const normalizedProtocolMode = normalizeProtocolMode(protocolMode)
+  if (initialized) return
+
+  const storageCodeInfoCard = readStorageCodeInfoCard()
+  migrateLegacyGroupsToProtocol(normalizedProtocolMode)
+  const protocolOrder = normalizedProtocolMode === PROTOCOL_MODE.MODBUS_RTU
+    ? [PROTOCOL_MODE.MODBUS_RTU, PROTOCOL_MODE.STORAGE_ACCESS]
+    : [PROTOCOL_MODE.STORAGE_ACCESS, PROTOCOL_MODE.MODBUS_RTU]
+  const parameterGroupsByProtocol = {
+    [PROTOCOL_MODE.MODBUS_RTU]: [],
+    [PROTOCOL_MODE.STORAGE_ACCESS]: []
+  }
+
+  protocolOrder.forEach((targetProtocolMode) => {
+    parameterGroupsByProtocol[targetProtocolMode] = normalizeGroupsForProtocol(
+      readStoredGroups(targetProtocolMode),
+      targetProtocolMode
+    )
+  })
+
+  state = {
+    ...state,
+    activeProtocolMode: normalizedProtocolMode,
+    storageCodeInfoCard,
+    parameterGroupsByProtocol
+  }
+  initialized = true
+}
+
+function getState() {
+  return {
+    activeProtocolMode: getActiveProtocolMode(),
+    parameterDataTypeOptions: DATA_TYPE_OPTIONS,
+    parameterGroups: getActiveGroups(),
+    parameterRegisterTypeOptions: REGISTER_TYPE_OPTIONS,
+    storageCodeInfoCard: state.storageCodeInfoCard
+  }
+}
+
+function getGroups() {
+  return getActiveGroups()
+}
+
+function findGroup(groupId, protocolMode = getActiveProtocolMode()) {
+  return getGroupsForProtocol(protocolMode).find((group) => group.id === groupId)
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  init()
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+module.exports = {
+  DATA_TYPE_OPTIONS,
+  REGISTER_TYPE_OPTIONS,
+  findGroup,
+  getGroups,
+  getState,
+  init,
+  setGroups,
+  setState,
+  setStorageCodeInfo,
+  switchProtocolMode,
+  subscribe,
+  updateGroups
+}

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

@@ -0,0 +1,151 @@
+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
+}

+ 57 - 43
features/private-protocol/params-view-model.js → features/parameter-groups/view-model.js

@@ -1,53 +1,59 @@
-const genericModbusService = require('../generic-modbus/service.js')
+const parameterGroupService = require('./service.js')
+const {
+  validateValueFormula
+} = require('../../domain/parameter-groups/value-formula.js')
 const settingsService = require('../../store/settings-store.js')
 const themeService = require('../../store/theme-store.js')
 const transport = require('../../transport/ble-core.js')
 
-function getGenericOption(options, index) {
+function getOption(options, index) {
   return options[Number(index)] || options[0] || {}
 }
 
 function getPageState() {
   const settingsState = settingsService.getState()
   const transportState = transport.getState()
-  const isPrivateProtocol = settingsState.modbusProtocolMode !== 'generic'
+  const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
+  const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
 
   return {
-    ...genericModbusService.getState(),
+    ...parameterGroupService.getState(),
     ...themeService.getState(),
     ...settingsState,
     connectedDevice: transportState.connectedDevice,
-    isGenericProtocol: !isPrivateProtocol,
-    isPrivateProtocol
+    isModbusProtocol,
+    isStorageAccessProtocol
   }
 }
 
 function resolveActiveParamView(currentView) {
-  return currentView === 'genericModbusGroup' ? currentView : 'genericModbus'
+  return currentView === 'parameterGroup' ? currentView : 'parameterGroups'
 }
 
 function getSettingsPageState(currentData, settingsState) {
-  const isPrivateProtocol = settingsState.modbusProtocolMode !== 'generic'
+  const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
+  const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
 
   return {
     ...settingsState,
     activeParamView: resolveActiveParamView(currentData.activeParamView),
-    isGenericProtocol: !isPrivateProtocol,
-    isPrivateProtocol
+    isModbusProtocol,
+    isStorageAccessProtocol
   }
 }
 
 function getVisiblePageState(currentData) {
   const settingsState = settingsService.getState()
   const transportState = transport.getState()
-  const isPrivateProtocol = settingsState.modbusProtocolMode !== 'generic'
+  const isModbusProtocol = settingsService.isModbusProtocol(settingsState.protocolMode)
+  const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(settingsState.protocolMode)
   const pageState = {
-    ...genericModbusService.getState(),
+    ...parameterGroupService.getState(),
     ...themeService.getState(),
     ...settingsState,
     connectedDevice: transportState.connectedDevice,
-    isGenericProtocol: !isPrivateProtocol,
-    isPrivateProtocol
+    isModbusProtocol,
+    isStorageAccessProtocol
   }
 
   return {
@@ -62,9 +68,9 @@ function getTransportPageState(transportState = transport.getState()) {
   }
 }
 
-function createGenericModbusDialogState(overrides = {}) {
-  const registerType = getGenericOption(genericModbusService.REGISTER_TYPE_OPTIONS, 0)
-  const dataType = getGenericOption(genericModbusService.DATA_TYPE_OPTIONS, 0)
+function createParameterDialogState(overrides = {}) {
+  const registerType = getOption(parameterGroupService.REGISTER_TYPE_OPTIONS, 0)
+  const dataType = getOption(parameterGroupService.DATA_TYPE_OPTIONS, 0)
 
   return {
     cancelText: '取消',
@@ -88,6 +94,8 @@ function createGenericModbusDialogState(overrides = {}) {
     visible: false,
     maxValue: '',
     minValue: '',
+    conversionFormula: '',
+    conversionFormulaErrorText: '',
     addressText: '',
     displayValue: '',
     rawValueText: '',
@@ -102,12 +110,12 @@ function createGenericModbusDialogState(overrides = {}) {
   }
 }
 
-function createGenericGroupDialogState(group) {
+function createParameterGroupDialogState(group) {
   const isEdit = !!group
   const registerTypeIndex = isEdit ? (group.registerTypeIndex || 0) : 0
-  const registerType = getGenericOption(genericModbusService.REGISTER_TYPE_OPTIONS, registerTypeIndex)
+  const registerType = getOption(parameterGroupService.REGISTER_TYPE_OPTIONS, registerTypeIndex)
 
-  return createGenericModbusDialogState({
+  return createParameterDialogState({
     confirmText: isEdit ? '保存' : '确认',
     groupId: isEdit ? group.id : '',
     groupName: isEdit ? group.name : '寄存器组',
@@ -122,12 +130,12 @@ function createGenericGroupDialogState(group) {
   })
 }
 
-function createGenericRegisterDialogState(mode, group, register, registerIndex) {
+function createParameterRegisterDialogState(mode, group, register, registerIndex) {
   const isView = mode === 'viewRegister'
   const dataTypeIndex = register.dataTypeIndex || 0
-  const dataType = getGenericOption(genericModbusService.DATA_TYPE_OPTIONS, dataTypeIndex)
+  const dataType = getOption(parameterGroupService.DATA_TYPE_OPTIONS, dataTypeIndex)
 
-  return createGenericModbusDialogState({
+  return createParameterDialogState({
     cancelText: isView ? '关闭' : '取消',
     confirmText: isView ? '' : '保存',
     dataTypeIndex,
@@ -145,6 +153,8 @@ function createGenericRegisterDialogState(mode, group, register, registerIndex)
     showTextLength: !!register.showTextLength,
     unit: register.unit || '',
     visible: true,
+    conversionFormula: register.conversionFormula || '',
+    conversionFormulaErrorText: register.conversionFormulaErrorText || '',
     maxValue: register.maxValue || '',
     minValue: register.minValue || '',
     addressText: register.addressRangeText || register.addressText || '',
@@ -158,8 +168,8 @@ function createGenericRegisterDialogState(mode, group, register, registerIndex)
   })
 }
 
-function getGenericDialogDataTypeState(dialog, dataTypeOptions, dataTypeIndex) {
-  const dataType = getGenericOption(dataTypeOptions, dataTypeIndex)
+function getDialogDataTypeState(dialog, dataTypeOptions, dataTypeIndex) {
+  const dataType = getOption(dataTypeOptions, dataTypeIndex)
   const isTextType = dataType.kind === 'text'
   const showUnit = dataType.kind === 'number' && dataType.key !== 'hex'
 
@@ -176,7 +186,7 @@ function getGenericDialogDataTypeState(dialog, dataTypeOptions, dataTypeIndex) {
   }
 }
 
-function createGenericGroupConfig(dialog) {
+function createParameterGroupConfig(dialog) {
   const registers = Array.isArray(dialog.parsedStructRegisters)
     ? dialog.parsedStructRegisters
     : []
@@ -203,13 +213,17 @@ function createGenericGroupConfig(dialog) {
   }
 }
 
-function createGenericRegisterChangedData(dialog, dataTypeOptions) {
-  const dataType = getGenericOption(dataTypeOptions, dialog.dataTypeIndex)
+function createParameterRegisterChangedData(dialog, dataTypeOptions) {
+  const dataType = getOption(dataTypeOptions, dialog.dataTypeIndex)
   const isTextType = dataType.kind === 'text'
   const showUnit = dataType.kind === 'number' && dataType.key !== 'hex'
+  const conversionFormula = String(dialog.conversionFormula || '').trim()
+
+  validateValueFormula(conversionFormula)
 
   return {
     name: dialog.name,
+    conversionFormula,
     dataType: dataType.key,
     maxValue: isTextType ? '' : dialog.maxValue,
     minValue: isTextType ? '' : dialog.minValue,
@@ -219,12 +233,12 @@ function createGenericRegisterChangedData(dialog, dataTypeOptions) {
   }
 }
 
-function findGenericGroup(groups, groupId) {
+function findParameterGroup(groups, groupId) {
   return (groups || []).find((item) => item.id === groupId) || null
 }
 
-function findGenericRegister(groups, groupId, registerIndex) {
-  const group = findGenericGroup(groups, groupId)
+function findParameterRegister(groups, groupId, registerIndex) {
+  const group = findParameterGroup(groups, groupId)
   const register = group && group.registers ? group.registers[registerIndex] : null
 
   return {
@@ -233,21 +247,21 @@ function findGenericRegister(groups, groupId, registerIndex) {
   }
 }
 
-function getActiveGenericGroup(groups, groupId) {
-  return findGenericGroup(groups, groupId) || null
+function getActiveParameterGroup(groups, groupId) {
+  return findParameterGroup(groups, groupId) || null
 }
 
 module.exports = {
-  createGenericGroupConfig,
-  createGenericGroupDialogState,
-  createGenericModbusDialogState,
-  createGenericRegisterChangedData,
-  createGenericRegisterDialogState,
-  findGenericGroup,
-  findGenericRegister,
-  getActiveGenericGroup,
-  getGenericDialogDataTypeState,
-  getGenericOption,
+  createParameterGroupConfig,
+  createParameterGroupDialogState,
+  createParameterDialogState,
+  createParameterRegisterChangedData,
+  createParameterRegisterDialogState,
+  findParameterGroup,
+  findParameterRegister,
+  getActiveParameterGroup,
+  getDialogDataTypeState,
+  getOption,
   getPageState,
   getSettingsPageState,
   getTransportPageState,

+ 15 - 10
features/settings/view-model.js

@@ -13,26 +13,31 @@ function getSettingsPageState(
   const nightModeEnabledSwitch = settingsState.nightModeFollowSystem
     ? themeState.themeMode === 'dark'
     : settingsState.nightModeEnabled
-  const modbusProtocolOptions = settingsService.MODBUS_PROTOCOL_OPTIONS
-  const modbusProtocolIndex = Math.max(0, modbusProtocolOptions.findIndex((option) => (
-    option.key === settingsState.modbusProtocolMode
+  const protocolMode = settingsService.isModbusProtocol(settingsState.protocolMode)
+    ? settingsService.PROTOCOL_MODE.MODBUS_RTU
+    : settingsService.PROTOCOL_MODE.STORAGE_ACCESS
+  const protocolOptions = settingsService.PROTOCOL_OPTIONS
+  const protocolIndex = Math.max(0, protocolOptions.findIndex((option) => (
+    option.key === protocolMode
   )))
-  const modbusProtocol = modbusProtocolOptions[modbusProtocolIndex] || modbusProtocolOptions[0]
+  const protocol = protocolOptions[protocolIndex] || protocolOptions[0]
+  const isModbusProtocol = settingsService.isModbusProtocol(protocol.key)
+  const isStorageAccessProtocol = settingsService.isStorageAccessProtocol(protocol.key)
 
   return {
     ...settingsState,
     ...themeState,
     ...bootloaderState,
     connectedDevice: transportState.connectedDevice,
-    genericModbusMinPacketLength: settingsService.GENERIC_MODBUS_MIN_PACKET_LENGTH,
-    isGenericProtocol: modbusProtocol.key === 'generic',
-    isPrivateProtocol: modbusProtocol.key === 'private',
-    modbusProtocolIndex,
-    modbusProtocolOptions,
-    modbusProtocolText: modbusProtocol.label,
+    isModbusProtocol,
+    isStorageAccessProtocol,
     nightModeEnabledSwitch,
     statusPollMaxInterval: settingsService.STATUS_POLL_MAX_INTERVAL,
     statusPollMinInterval: settingsService.STATUS_POLL_MIN_INTERVAL,
+    parameterMinPacketLength: settingsService.PARAMETER_MIN_PACKET_LENGTH,
+    protocolIndex,
+    protocolOptions,
+    protocolText: protocol.label,
     toolEntries: toolNavigation.getToolEntries()
   }
 }

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

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

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

@@ -0,0 +1,59 @@
+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
+}

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

@@ -0,0 +1,56 @@
+const {
+  createGroupsFromCodeInfo,
+  parseCodeInfo
+} = require('../../domain/storage-access/code-info-parser.js')
+const {
+  cloneImportedGroup,
+  normalizeGroup
+} = require('../../domain/parameter-groups/model.js')
+const memoryService = require('./memory-service.js')
+
+function formatWordHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+async function syncCodeInfo(options = {}) {
+  const result = await memoryService.readCodeInfoBlock(
+    options.label || '同步info',
+    options.kind || 'storage-info-read',
+    {
+      maxPacketLength: options.maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+  if (!result) {
+    return {
+      ok: false
+    }
+  }
+
+  const codeInfo = parseCodeInfo(result.codeInfoBytes)
+  const importedGroups = createGroupsFromCodeInfo(codeInfo, options)
+    .map(cloneImportedGroup)
+    .map(normalizeGroup)
+
+  return {
+    codeInfoAddress: result.codeInfoAddress,
+    codeInfoAddressText: formatWordHex(result.codeInfoAddress),
+    codeInfoByteLength: result.codeInfoByteLength,
+    codeInfoByteLengthText: formatWordHex(result.codeInfoByteLength),
+    codeInfoBytes: result.codeInfoBytes,
+    codeInfo,
+    infoBytes: result.infoBytes,
+    groupCount: importedGroups.length,
+    importedGroups,
+    codeInfoMemoryType: result.codeInfoMemoryType,
+    ok: true,
+    structCount: codeInfo.structCount
+  }
+}
+
+module.exports = {
+  AREA: memoryService.AREA,
+  readMemory: memoryService.readMemory,
+  syncCodeInfo,
+  writeMemory: memoryService.writeMemory
+}

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

@@ -0,0 +1,34 @@
+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
+}

+ 128 - 0
features/tools/handlers/crc.js

@@ -0,0 +1,128 @@
+const {
+  crcTool
+} = require('../../../tools/crc-hash/index.js')
+const {
+  loadSelectedFile
+} = require('../../../repositories/file.js')
+
+const handlers = {
+  onCrcPresetChange(event) {
+    const presetIndex = Number(event.detail.value)
+
+    this.setData({
+      ...crcTool.createPresetState(presetIndex),
+      crcErrorText: ''
+    })
+  },
+
+  onCrcInputTypeChange(event) {
+    this.setData({
+      crcErrorText: '',
+      crcInputTypeIndex: Number(event.detail.value)
+    })
+  },
+
+  onCrcConfigInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+    const isCrcConfigField = crcTool.CRC_CONFIG_FIELDS.includes(field)
+    const nextData = {
+      [field]: event.detail.value,
+      crcErrorText: ''
+    }
+
+    if (isCrcConfigField) {
+      nextData.crcAlgorithmCollapsed = false
+      nextData.crcPresetIndex = crcTool.getCustomPresetIndex()
+    }
+
+    this.setData(nextData)
+  },
+
+  onCrcReflectChange(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    this.setData({
+      [field]: !!event.detail.value,
+      crcAlgorithmCollapsed: false,
+      crcErrorText: '',
+      crcPresetIndex: crcTool.getCustomPresetIndex()
+    })
+  },
+
+  toggleCrcAlgorithmPanel() {
+    this.setData({
+      crcAlgorithmCollapsed: !this.data.crcAlgorithmCollapsed
+    })
+  },
+
+  onCrcDataInput(event) {
+    this.crcFileBytes = null
+    this.setData({
+      crcDataText: event.detail.value,
+      crcErrorText: '',
+      crcFileName: '',
+      crcFileSizeText: ''
+    })
+  },
+
+  calculateCrc() {
+    try {
+      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')
+      this.crcFileBytes = file.bytes
+      this.setData({
+        crcDataLengthText: file.sizeText,
+        crcDataText: '',
+        crcErrorText: '',
+        crcFileName: file.name,
+        crcFileSizeText: file.sizeText
+      })
+      this.calculateCrc()
+    } catch (error) {
+      const message = error && (error.errMsg || error.message)
+        ? (error.errMsg || error.message)
+        : '读取文件失败'
+
+      if (!/cancel/i.test(message) && this.pageToast) {
+        this.pageToast.show(message, 'error')
+      }
+    }
+  },
+
+  clearCrcInput() {
+    this.crcFileBytes = null
+    this.setData({
+      crcDataLengthText: '0 bytes',
+      crcDataText: '',
+      crcErrorText: '',
+      crcFileName: '',
+      crcFileSizeText: '',
+      crcResultBase64: '--',
+      crcResultBin: '--',
+      crcResultBinLines: [
+        {
+          id: 'bin-line-0',
+          text: '--'
+        }
+      ],
+      crcResultHex: '--'
+    })
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 101 - 0
features/tools/handlers/filter.js

@@ -0,0 +1,101 @@
+const filterCalculator = require('../../../tools/filter/index.js')
+
+const handlers = {
+  setFilterState(changedData) {
+    this.setData(filterCalculator.updateState(this.data, changedData))
+  },
+
+  toggleFilterNetwork() {
+    this.setFilterState({
+      filterNetworkIndex: this.data.filterNetworkKey === 'rl' ? 0 : 1
+    })
+  },
+
+  toggleFilterResponse() {
+    this.setFilterState({
+      filterResponseIndex: this.data.filterResponseKey === 'highpass' ? 0 : 1
+    })
+  },
+
+  onFilterNetworkChange(event) {
+    this.setFilterState({
+      filterNetworkIndex: Number(event.detail.value)
+    })
+  },
+
+  onFilterResponseChange(event) {
+    this.setFilterState({
+      filterResponseIndex: Number(event.detail.value)
+    })
+  },
+
+  onFilterResistanceInput(event) {
+    this.setFilterState({
+      filterResistanceValue: event.detail.value
+    })
+  },
+
+  onFilterReactiveInput(event) {
+    this.setFilterState({
+      filterReactiveValue: event.detail.value
+    })
+  },
+
+  onFilterFrequencyInput(event) {
+    this.setFilterState({
+      filterFrequencyValue: event.detail.value
+    })
+  },
+
+  clearFilterInputs() {
+    this.ignoreFilterBlurUntil = Date.now() + 300
+    this.setFilterState({
+      filterFrequencyValue: '',
+      filterReactiveValue: '',
+      filterResistanceValue: ''
+    })
+  },
+
+  onFilterResistanceUnitChange(event) {
+    this.setFilterState({
+      filterResistanceUnitIndex: Number(event.detail.value)
+    })
+  },
+
+  onFilterReactiveUnitChange(event) {
+    const unitIndex = Number(event.detail.value)
+    const field = this.data.filterNetworkKey === 'rl'
+      ? 'filterInductanceUnitIndex'
+      : 'filterCapacitanceUnitIndex'
+
+    this.setFilterState({
+      [field]: unitIndex
+    })
+  },
+
+  onFilterFrequencyUnitChange(event) {
+    this.setFilterState({
+      filterFrequencyUnitIndex: Number(event.detail.value)
+    })
+  },
+
+  onFilterValueBlur(event) {
+    if (this.ignoreFilterBlurUntil && Date.now() < this.ignoreFilterBlurUntil) return
+
+    const field = event.currentTarget.dataset.field
+    const valueKeyMap = {
+      frequency: 'filterFrequencyValue',
+      reactive: 'filterReactiveValue',
+      resistance: 'filterResistanceValue'
+    }
+    const valueKey = valueKeyMap[field]
+
+    if (this.data.filterComputedKey === field && valueKey && !this.data[valueKey]) return
+
+    this.setData(filterCalculator.normalizeValue(this.data, field, event.detail.value))
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 59 - 0
features/tools/handlers/reactance.js

@@ -0,0 +1,59 @@
+const reactanceCalculator = require('../../../tools/reactance/index.js')
+
+const handlers = {
+  setReactanceState(changedData) {
+    this.setData(reactanceCalculator.updateState(this.data, changedData))
+  },
+
+  toggleReactanceMode() {
+    this.setReactanceState({
+      reactanceModeIndex: this.data.reactanceModeKey === 'inductive' ? 0 : 1
+    })
+  },
+
+  onReactanceFrequencyInput(event) {
+    this.setReactanceState({
+      reactanceFrequencyValue: event.detail.value
+    })
+  },
+
+  onReactanceReactiveInput(event) {
+    this.setReactanceState({
+      reactanceReactiveValue: event.detail.value
+    })
+  },
+
+  clearReactanceInputs() {
+    this.ignoreReactanceBlurUntil = Date.now() + 300
+    this.setData(reactanceCalculator.clearInputs(this.data))
+  },
+
+  onReactanceFrequencyUnitChange(event) {
+    this.setReactanceState({
+      reactanceFrequencyUnitIndex: Number(event.detail.value)
+    })
+  },
+
+  onReactanceReactiveUnitChange(event) {
+    const unitIndex = Number(event.detail.value)
+    const field = this.data.reactanceModeKey === 'inductive'
+      ? 'reactanceInductanceUnitIndex'
+      : 'reactanceCapacitanceUnitIndex'
+
+    this.setReactanceState({
+      [field]: unitIndex
+    })
+  },
+
+  onReactanceValueBlur(event) {
+    if (this.ignoreReactanceBlurUntil && Date.now() < this.ignoreReactanceBlurUntil) return
+
+    const field = event.currentTarget.dataset.field
+
+    this.setData(reactanceCalculator.normalizeValue(this.data, field, event.detail.value))
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 40 - 0
features/tools/handlers/refrigeration.js

@@ -0,0 +1,40 @@
+const refrigerationCalculator = require('../../../tools/refrigeration/index.js')
+
+const handlers = {
+  setCoolingState(changedData) {
+    this.setData(refrigerationCalculator.updateState(this.data, changedData))
+  },
+
+  onCoolingModeTap(event) {
+    const mode = event.currentTarget.dataset.mode
+    const modeIndex = (this.data.coolingModeOptions || []).findIndex((item) => item.key === mode)
+    if (modeIndex < 0) return
+
+    this.setCoolingState({
+      coolingModeIndex: modeIndex
+    })
+  },
+
+  onCoolingModeChange(event) {
+    this.setCoolingState({
+      coolingModeIndex: Number(event.detail.value)
+    })
+  },
+
+  onCoolingInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    this.setCoolingState({
+      [field]: event.detail.value
+    })
+  },
+
+  clearCoolingInputs() {
+    this.setData(refrigerationCalculator.clearInputs(this.data))
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 46 - 0
features/tools/handlers/smd-code.js

@@ -0,0 +1,46 @@
+const smdCodeCalculator = require('../../../tools/smd-code/index.js')
+
+const handlers = {
+  setSmdCodeState(changedData) {
+    this.setData(smdCodeCalculator.updateState(this.data, changedData))
+  },
+
+  onSmdKindTap(event) {
+    const kind = event.currentTarget.dataset.kind
+    const kindIndex = (this.data.smdKindOptions || []).findIndex((item) => item.key === kind)
+    if (kindIndex < 0) return
+
+    this.setSmdCodeState({
+      smdFormatIndex: 0,
+      smdFormatKey: '',
+      smdKindIndex: kindIndex
+    })
+  },
+
+  onSmdFormatTap(event) {
+    const format = event.currentTarget.dataset.format
+    const formatIndex = (this.data.smdFormatOptions || []).findIndex((item) => item.key === format)
+    if (formatIndex < 0) return
+
+    this.setSmdCodeState({
+      smdFormatIndex: formatIndex,
+      smdFormatKey: format
+    })
+  },
+
+  onSmdCodeInput(event) {
+    this.setSmdCodeState({
+      smdCodeText: event.detail.value
+    })
+  },
+
+  clearSmdCodeInput() {
+    this.setSmdCodeState({
+      smdCodeText: ''
+    })
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 42 - 0
features/tools/handlers/three-phase-power.js

@@ -0,0 +1,42 @@
+const threePhasePowerCalculator = require('../../../tools/three-phase-power/index.js')
+
+const handlers = {
+  setThreePhasePowerState(changedData) {
+    this.setData(threePhasePowerCalculator.updateState(this.data, changedData))
+  },
+
+  onThreePhaseConnectionTap(event) {
+    const connection = event.currentTarget.dataset.connection
+    const connectionIndex = (this.data.threePhaseConnectionOptions || []).findIndex((item) => item.key === connection)
+    if (connectionIndex < 0) return
+
+    this.setThreePhasePowerState({
+      threePhaseConnectionIndex: connectionIndex
+    })
+  },
+
+  onThreePhaseInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    const changedData = {
+      [field]: event.detail.value
+    }
+    if (threePhasePowerCalculator.ELECTRICAL_INPUT_KEYS.includes(field)) {
+      changedData.threePhaseElectricalDriver = field
+    }
+    if (threePhasePowerCalculator.POWER_DRIVER_KEYS.includes(field)) {
+      changedData.threePhasePowerDriver = field
+    }
+
+    this.setThreePhasePowerState(changedData)
+  },
+
+  clearThreePhaseInputs() {
+    this.setData(threePhasePowerCalculator.clearInputs(this.data))
+  }
+}
+
+module.exports = {
+  handlers
+}

+ 15 - 400
features/tools/page.js

@@ -6,12 +6,14 @@ 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/platform-utils.js')
-const {
-  loadSelectedFile
-} = require('../../repositories/file.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 {
@@ -25,400 +27,13 @@ function createToolInitialState() {
 }
 
 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('已复制')
-      }
-    })
-  },
-
-  onCrcPresetChange(event) {
-    const presetIndex = Number(event.detail.value)
-
-    this.setData({
-      ...crcTool.createPresetState(presetIndex),
-      crcErrorText: ''
-    })
-  },
-
-  onCrcInputTypeChange(event) {
-    this.setData({
-      crcErrorText: '',
-      crcInputTypeIndex: Number(event.detail.value)
-    })
-  },
-
-  onCrcConfigInput(event) {
-    const field = event.currentTarget.dataset.field
-    if (!field) return
-    const isCrcConfigField = crcTool.CRC_CONFIG_FIELDS.includes(field)
-    const nextData = {
-      [field]: event.detail.value,
-      crcErrorText: ''
-    }
-
-    if (isCrcConfigField) {
-      nextData.crcAlgorithmCollapsed = false
-      nextData.crcPresetIndex = crcTool.getCustomPresetIndex()
-    }
-
-    this.setData(nextData)
-  },
-
-  onCrcReflectChange(event) {
-    const field = event.currentTarget.dataset.field
-    if (!field) return
-
-    this.setData({
-      [field]: !!event.detail.value,
-      crcAlgorithmCollapsed: false,
-      crcErrorText: '',
-      crcPresetIndex: crcTool.getCustomPresetIndex()
-    })
-  },
-
-  toggleCrcAlgorithmPanel() {
-    this.setData({
-      crcAlgorithmCollapsed: !this.data.crcAlgorithmCollapsed
-    })
-  },
-
-  onCrcDataInput(event) {
-    this.crcFileBytes = null
-    this.setData({
-      crcDataText: event.detail.value,
-      crcErrorText: '',
-      crcFileName: '',
-      crcFileSizeText: ''
-    })
-  },
-
-  calculateCrc() {
-    try {
-      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')
-      this.crcFileBytes = file.bytes
-      this.setData({
-        crcDataLengthText: file.sizeText,
-        crcDataText: '',
-        crcErrorText: '',
-        crcFileName: file.name,
-        crcFileSizeText: file.sizeText
-      })
-      this.calculateCrc()
-    } catch (error) {
-      const message = error && (error.errMsg || error.message)
-        ? (error.errMsg || error.message)
-        : '读取文件失败'
-
-      if (!/cancel/i.test(message) && this.pageToast) {
-        this.pageToast.show(message, 'error')
-      }
-    }
-  },
-
-  clearCrcInput() {
-    this.crcFileBytes = null
-    this.setData({
-      crcDataLengthText: '0 bytes',
-      crcDataText: '',
-      crcErrorText: '',
-      crcFileName: '',
-      crcFileSizeText: '',
-      crcResultBase64: '--',
-      crcResultBin: '--',
-      crcResultBinLines: [
-        {
-          id: 'bin-line-0',
-          text: '--'
-        }
-      ],
-      crcResultHex: '--'
-    })
-  },
-
-  setFilterState(changedData) {
-    this.setData(filterCalculator.updateState(this.data, changedData))
-  },
-
-  toggleFilterNetwork() {
-    this.setFilterState({
-      filterNetworkIndex: this.data.filterNetworkKey === 'rl' ? 0 : 1
-    })
-  },
-
-  toggleFilterResponse() {
-    this.setFilterState({
-      filterResponseIndex: this.data.filterResponseKey === 'highpass' ? 0 : 1
-    })
-  },
-
-  onFilterNetworkChange(event) {
-    this.setFilterState({
-      filterNetworkIndex: Number(event.detail.value)
-    })
-  },
-
-  onFilterResponseChange(event) {
-    this.setFilterState({
-      filterResponseIndex: Number(event.detail.value)
-    })
-  },
-
-  onFilterResistanceInput(event) {
-    this.setFilterState({
-      filterResistanceValue: event.detail.value
-    })
-  },
-
-  onFilterReactiveInput(event) {
-    this.setFilterState({
-      filterReactiveValue: event.detail.value
-    })
-  },
-
-  onFilterFrequencyInput(event) {
-    this.setFilterState({
-      filterFrequencyValue: event.detail.value
-    })
-  },
-
-  clearFilterInputs() {
-    this.ignoreFilterBlurUntil = Date.now() + 300
-    this.setFilterState({
-      filterFrequencyValue: '',
-      filterReactiveValue: '',
-      filterResistanceValue: ''
-    })
-  },
-
-  onFilterResistanceUnitChange(event) {
-    this.setFilterState({
-      filterResistanceUnitIndex: Number(event.detail.value)
-    })
-  },
-
-  onFilterReactiveUnitChange(event) {
-    const unitIndex = Number(event.detail.value)
-    const field = this.data.filterNetworkKey === 'rl'
-      ? 'filterInductanceUnitIndex'
-      : 'filterCapacitanceUnitIndex'
-
-    this.setFilterState({
-      [field]: unitIndex
-    })
-  },
-
-  onFilterFrequencyUnitChange(event) {
-    this.setFilterState({
-      filterFrequencyUnitIndex: Number(event.detail.value)
-    })
-  },
-
-  onFilterValueBlur(event) {
-    if (this.ignoreFilterBlurUntil && Date.now() < this.ignoreFilterBlurUntil) return
-
-    const field = event.currentTarget.dataset.field
-    const valueKeyMap = {
-      frequency: 'filterFrequencyValue',
-      reactive: 'filterReactiveValue',
-      resistance: 'filterResistanceValue'
-    }
-    const valueKey = valueKeyMap[field]
-
-    if (this.data.filterComputedKey === field && valueKey && !this.data[valueKey]) return
-
-    this.setData(filterCalculator.normalizeValue(this.data, field, event.detail.value))
-  },
-
-  setSmdCodeState(changedData) {
-    this.setData(smdCodeCalculator.updateState(this.data, changedData))
-  },
-
-  onSmdKindTap(event) {
-    const kind = event.currentTarget.dataset.kind
-    const kindIndex = (this.data.smdKindOptions || []).findIndex((item) => item.key === kind)
-    if (kindIndex < 0) return
-
-    this.setSmdCodeState({
-      smdFormatIndex: 0,
-      smdFormatKey: '',
-      smdKindIndex: kindIndex
-    })
-  },
-
-  onSmdFormatTap(event) {
-    const format = event.currentTarget.dataset.format
-    const formatIndex = (this.data.smdFormatOptions || []).findIndex((item) => item.key === format)
-    if (formatIndex < 0) return
-
-    this.setSmdCodeState({
-      smdFormatIndex: formatIndex,
-      smdFormatKey: format
-    })
-  },
-
-  onSmdCodeInput(event) {
-    this.setSmdCodeState({
-      smdCodeText: event.detail.value
-    })
-  },
-
-  clearSmdCodeInput() {
-    this.setSmdCodeState({
-      smdCodeText: ''
-    })
-  },
-
-  setCoolingState(changedData) {
-    this.setData(refrigerationCalculator.updateState(this.data, changedData))
-  },
-
-  onCoolingModeTap(event) {
-    const mode = event.currentTarget.dataset.mode
-    const modeIndex = (this.data.coolingModeOptions || []).findIndex((item) => item.key === mode)
-    if (modeIndex < 0) return
-
-    this.setCoolingState({
-      coolingModeIndex: modeIndex
-    })
-  },
-
-  onCoolingModeChange(event) {
-    this.setCoolingState({
-      coolingModeIndex: Number(event.detail.value)
-    })
-  },
-
-  onCoolingInput(event) {
-    const field = event.currentTarget.dataset.field
-    if (!field) return
-
-    this.setCoolingState({
-      [field]: event.detail.value
-    })
-  },
-
-  clearCoolingInputs() {
-    this.setData(refrigerationCalculator.clearInputs(this.data))
-  },
-
-  setThreePhasePowerState(changedData) {
-    this.setData(threePhasePowerCalculator.updateState(this.data, changedData))
-  },
-
-  onThreePhaseConnectionTap(event) {
-    const connection = event.currentTarget.dataset.connection
-    const connectionIndex = (this.data.threePhaseConnectionOptions || []).findIndex((item) => item.key === connection)
-    if (connectionIndex < 0) return
-
-    this.setThreePhasePowerState({
-      threePhaseConnectionIndex: connectionIndex
-    })
-  },
-
-  onThreePhaseInput(event) {
-    const field = event.currentTarget.dataset.field
-    if (!field) return
-
-    const changedData = {
-      [field]: event.detail.value
-    }
-    if (threePhasePowerCalculator.ELECTRICAL_INPUT_KEYS.includes(field)) {
-      changedData.threePhaseElectricalDriver = field
-    }
-    if (threePhasePowerCalculator.POWER_DRIVER_KEYS.includes(field)) {
-      changedData.threePhasePowerDriver = field
-    }
-
-    this.setThreePhasePowerState(changedData)
-  },
-
-  clearThreePhaseInputs() {
-    this.setData(threePhasePowerCalculator.clearInputs(this.data))
-  },
-
-  setReactanceState(changedData) {
-    this.setData(reactanceCalculator.updateState(this.data, changedData))
-  },
-
-  toggleReactanceMode() {
-    this.setReactanceState({
-      reactanceModeIndex: this.data.reactanceModeKey === 'inductive' ? 0 : 1
-    })
-  },
-
-  onReactanceFrequencyInput(event) {
-    this.setReactanceState({
-      reactanceFrequencyValue: event.detail.value
-    })
-  },
-
-  onReactanceReactiveInput(event) {
-    this.setReactanceState({
-      reactanceReactiveValue: event.detail.value
-    })
-  },
-
-  clearReactanceInputs() {
-    this.ignoreReactanceBlurUntil = Date.now() + 300
-    this.setData(reactanceCalculator.clearInputs(this.data))
-  },
-
-  onReactanceFrequencyUnitChange(event) {
-    this.setReactanceState({
-      reactanceFrequencyUnitIndex: Number(event.detail.value)
-    })
-  },
-
-  onReactanceReactiveUnitChange(event) {
-    const unitIndex = Number(event.detail.value)
-    const field = this.data.reactanceModeKey === 'inductive'
-      ? 'reactanceInductanceUnitIndex'
-      : 'reactanceCapacitanceUnitIndex'
-
-    this.setReactanceState({
-      [field]: unitIndex
-    })
-  },
-
-  onReactanceValueBlur(event) {
-    if (this.ignoreReactanceBlurUntil && Date.now() < this.ignoreReactanceBlurUntil) return
-
-    const field = event.currentTarget.dataset.field
-
-    this.setData(reactanceCalculator.normalizeValue(this.data, field, event.detail.value))
-  }
-
+  ...commonHandlers.handlers,
+  ...crcHandlers.handlers,
+  ...filterHandlers.handlers,
+  ...reactanceHandlers.handlers,
+  ...smdCodeHandlers.handlers,
+  ...refrigerationHandlers.handlers,
+  ...threePhasePowerHandlers.handlers
 }
 
 module.exports = {

+ 343 - 0
pages/communication/communication.js

@@ -0,0 +1,343 @@
+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 {
+  LOG_MODE_OPTIONS,
+  STORAGE_ACCESS_AREA_OPTIONS,
+  STORAGE_ACCESS_COMMAND_OPTIONS,
+  decorateLogs,
+  getLogModeToggleText,
+  getManualStatePayload,
+  getNextLogMode,
+  getNextSerialMode,
+  getProtocolModeState,
+  normalizeStorageAccessState,
+  normalizeSerialState
+} = require('../../features/communication/view-model.js')
+
+const INITIAL_SERIAL_STATE = normalizeSerialState({
+  serialInputText: '',
+  serialMode: 'hex'
+})
+
+Page({
+  data: {
+    ...themeService.getState(),
+    ...settingsService.getState(),
+    ...getProtocolModeState(settingsService.getState()),
+    ...transport.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,
+    ...normalizeStorageAccessState({
+      storageAccessAreaIndex: 3,
+      storageAccessAddress: '0000',
+      storageAccessCommandIndex: 0,
+      storageAccessDataText: '',
+      storageAccessLength: '0004'
+    }),
+    serialTitleText: '串口发送',
+    ...INITIAL_SERIAL_STATE,
+    toastText: '',
+    toastType: ''
+  },
+
+  onLoad() {
+    this.pageToast = createPageToast(this, this.data)
+    this.lastSyncedSlaveAddress = ''
+    transport.init()
+    settingsService.init()
+    themeService.init()
+
+    const settingsState = settingsService.getState()
+    manualRtuService.setProtocolInput({
+      slaveAddress: settingsState.modbusSlaveAddress
+    })
+    this.lastSyncedSlaveAddress = settingsState.modbusSlaveAddress
+
+    this.unsubscribeTheme = themeService.subscribe((themeState) => {
+      this.setData(themeState)
+    })
+
+    this.unsubscribeSettings = settingsService.subscribe((settingsState) => {
+      this.setData({
+        ...settingsState,
+        ...getProtocolModeState(settingsState)
+      })
+
+      if (settingsState.modbusSlaveAddress !== this.lastSyncedSlaveAddress) {
+        this.lastSyncedSlaveAddress = settingsState.modbusSlaveAddress
+        manualRtuService.setProtocolInput({
+          slaveAddress: settingsState.modbusSlaveAddress
+        })
+      }
+    })
+
+    this.unsubscribeTransport = transport.subscribe((transportState) => {
+      this.setData({
+        ...transportState,
+        logs: decorateLogs(transportState.logs, this.data.logMode)
+      })
+    })
+
+    this.unsubscribeManualRtu = manualRtuService.subscribe((manualState) => {
+      this.setData(getManualStatePayload(manualState))
+    })
+  },
+
+  onShow() {
+    if (this.pageToast) {
+      this.pageToast.setActive(true)
+    }
+  },
+
+  onHide() {
+    if (this.pageToast) {
+      this.pageToast.setActive(false)
+    }
+  },
+
+  onUnload() {
+    if (this.pageToast) {
+      this.pageToast.destroy()
+      this.pageToast = null
+    }
+
+    if (this.unsubscribeTheme) {
+      this.unsubscribeTheme()
+      this.unsubscribeTheme = null
+    }
+
+    if (this.unsubscribeSettings) {
+      this.unsubscribeSettings()
+      this.unsubscribeSettings = null
+    }
+
+    if (this.unsubscribeTransport) {
+      this.unsubscribeTransport()
+      this.unsubscribeTransport = null
+    }
+
+    if (this.unsubscribeManualRtu) {
+      this.unsubscribeManualRtu()
+      this.unsubscribeManualRtu = null
+    }
+  },
+
+  noop() {},
+
+  switchSerialMode() {
+    this.setData(normalizeSerialState(this.data, {
+      serialMode: getNextSerialMode(this.data.serialMode)
+    }))
+  },
+
+  switchLogMode(event) {
+    const requestedMode = event && event.currentTarget && event.currentTarget.dataset
+      ? event.currentTarget.dataset.mode
+      : ''
+    const mode = requestedMode || getNextLogMode(this.data.logMode)
+    this.setData({
+      logMode: mode,
+      logModeToggleText: getLogModeToggleText(mode),
+      logs: decorateLogs(transport.getState().logs, mode)
+    })
+  },
+
+  clearCommunicationLogs() {
+    transport.clearLogs()
+  },
+
+  onSerialInput(event) {
+    this.setData(normalizeSerialState(this.data, {
+      serialInputText: event.detail.value
+    }))
+  },
+
+  clearSerialInput() {
+    this.setData(normalizeSerialState(this.data, {
+      serialInputText: ''
+    }))
+  },
+
+  async sendSerialFrame() {
+    try {
+      const result = await communicationService.sendSerialFrame(this.data)
+      if (!result.ok) {
+        this.setData({
+          serialErrorText: result.errorText || '发送失败'
+        })
+        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 || '发送失败'
+      })
+    }
+  },
+
+  onProtocolCommandChange(event) {
+    manualRtuService.setCommandIndex(Number(event.detail.value))
+  },
+
+  onProtocolInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    manualRtuService.setProtocolInput({
+      [field]: event.detail.value
+    })
+  },
+
+  onProtocolCoilChange(event) {
+    manualRtuService.setProtocolInput({
+      coilEnabled: !!event.detail.value
+    })
+  },
+
+  openProtocolMultipleDialog() {
+    manualRtuService.openProtocolMultipleDialog()
+  },
+
+  closeProtocolMultipleDialog() {
+    manualRtuService.closeProtocolMultipleDialog()
+  },
+
+  toggleProtocolMultipleExpanded() {
+    manualRtuService.toggleProtocolMultipleExpanded()
+  },
+
+  onProtocolMultipleQuantityChange(event) {
+    manualRtuService.setProtocolMultipleQuantity(event.detail.value)
+  },
+
+  onProtocolMultipleValueInput(event) {
+    const index = Number(event.currentTarget.dataset.index)
+    manualRtuService.setProtocolMultipleValue(index, event.detail.value)
+  },
+
+  onProtocolMultipleTypeChange(event) {
+    const index = Number(event.currentTarget.dataset.index)
+    manualRtuService.setProtocolMultipleType(index, Number(event.detail.value))
+  },
+
+  onProtocolMultipleTextLengthInput(event) {
+    const index = Number(event.currentTarget.dataset.index)
+    manualRtuService.setProtocolMultipleTextLength(index, event.detail.value)
+  },
+
+  async sendProtocolFrame(event) {
+    const source = event && event.currentTarget && event.currentTarget.dataset
+      ? event.currentTarget.dataset.source
+      : ''
+
+    if (this.data.protocolMultipleDialog && this.data.protocolMultipleDialog.visible && source !== 'dialog') {
+      if (this.pageToast) this.pageToast.show('请在多值窗口内发送', 'error')
+      return
+    }
+
+    if (!this.data.connectedDevice) {
+      if (this.pageToast) this.pageToast.show('请先连接蓝牙设备', 'error')
+      return
+    }
+
+    const response = await manualRtuService.sendGeneratedFrame()
+    if (response && this.pageToast) {
+      this.pageToast.show(response === true ? '已发送' : '已收到回复')
+    } else if (this.pageToast) {
+      const manualState = manualRtuService.getState()
+      this.pageToast.show(manualState.protocolStatusText || '发送失败或超时未回复', 'error')
+    }
+  },
+
+  switchStorageAccessCommandMode(event) {
+    const storageAccessCommandIndex = Number(event.currentTarget.dataset.index)
+    const nextState = normalizeStorageAccessState(this.data, {
+      storageAccessCommandIndex
+    })
+
+    this.setData(nextState)
+  },
+
+  onStorageAccessInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    const changed = {
+      [field]: event.detail.value
+    }
+    const nextState = normalizeStorageAccessState(this.data, changed)
+
+    this.setData(nextState)
+  },
+
+  onStorageAccessAreaChange(event) {
+    const storageAccessAreaIndex = Number(event.detail.value)
+    const nextState = normalizeStorageAccessState(this.data, {
+      storageAccessAreaIndex
+    })
+
+    this.setData(nextState)
+  },
+
+  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 || '操作失败'
+        })
+        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}`)
+        }
+        return
+      }
+
+      if (command.key === 'read') {
+        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 || '操作失败'
+      })
+    }
+  }
+})

+ 5 - 0
pages/communication/communication.json

@@ -0,0 +1,5 @@
+{
+  "usingComponents": {
+    "navigation-bar": "/components/navigation-bar/navigation-bar"
+  }
+}

+ 223 - 0
pages/communication/communication.wxml

@@ -0,0 +1,223 @@
+<navigation-bar background="{{themeMode === 'dark' ? '#111827' : '#FFF'}}"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
+  {{toastText}}
+</view>
+<scroll-view class="scrollarea {{themeClass}}" scroll-y type="list">
+  <view class="page-shell">
+    <view 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" />
+        </view>
+        <view class="panel-title">串口通讯发送</view>
+        <view class="panel-actions communication-actions">
+          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="sendSerialFrame">发送</view>
+          <view class="panel-action-button" bindtap="switchSerialMode">{{serialModeToggleText}}</view>
+          <view class="panel-action-button" bindtap="clearSerialInput">清空</view>
+        </view>
+      </view>
+      <view class="comm-send-body">
+        <textarea
+          class="comm-send-input comm-send-input--{{serialMode}}"
+          maxlength="-1"
+          auto-height
+          placeholder="{{serialMode === 'hex' ? '01 03 00 00 00 02' : '文本'}}"
+          value="{{serialInputText}}"
+          bindinput="onSerialInput"
+        />
+        <view class="comm-send-meta">
+          <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>
+        </view>
+      </view>
+    </view>
+
+    <view wx:if="{{isModbusProtocol}}" class="panel communication-panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-control">
+          <image class="panel-icon-image" src="/assets/icons/control-white.png" mode="aspectFit" />
+        </view>
+        <view class="panel-title">标准 Modbus</view>
+        <view class="panel-actions communication-actions">
+          <view
+            class="panel-action-button {{protocolMultipleDialog.visible ? 'is-disabled' : ''}}"
+            bindtap="sendProtocolFrame"
+          >发送</view>
+          <view wx:if="{{showProtocolMultipleButton}}" class="panel-action-button" bindtap="openProtocolMultipleDialog">多值</view>
+        </view>
+      </view>
+      <view class="protocol-form">
+        <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>
+          </picker>
+        </view>
+        <view class="protocol-row">
+          <view class="protocol-label">从机地址</view>
+          <input class="protocol-input protocol-row-input" maxlength="2" value="{{slaveAddress}}" bindinput="onProtocolInput" data-field="slaveAddress" />
+        </view>
+        <view class="protocol-row">
+          <view class="protocol-label">起始地址</view>
+          <input class="protocol-input protocol-row-input" maxlength="4" value="{{registerAddress}}" bindinput="onProtocolInput" data-field="registerAddress" />
+        </view>
+        <view wx:if="{{showCommandValue}}" class="protocol-row">
+          <view class="protocol-label">{{commandValueLabel}}</view>
+          <input class="protocol-input protocol-row-input" value="{{commandValue}}" bindinput="onProtocolInput" data-field="commandValue" />
+        </view>
+        <view wx:if="{{showCoilValue}}" class="protocol-row coil-row">
+          <view class="protocol-label">线圈</view>
+          <view class="coil-control">
+            <switch checked="{{coilEnabled}}" color="#0f766e" bindchange="onProtocolCoilChange" />
+            <text>{{coilEnabled ? 'ON' : 'OFF'}}</text>
+          </view>
+        </view>
+        <view wx:if="{{showRegisterQuantity}}" class="protocol-row">
+          <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>
+        </view>
+        <view wx:if="{{protocolResponseText}}" class="generated-frame generated-frame--response">
+          <view class="generated-title">回复</view>
+          <view class="generated-value">{{protocolResponseText}}</view>
+        </view>
+      </view>
+    </view>
+
+    <view wx:if="{{!isModbusProtocol}}" 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-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="sendStorageAccessProtocolFrame">执行</view>
+        </view>
+      </view>
+      <view class="protocol-form">
+        <view wx:if="{{storageAccessCommandIndex !== 0}}" class="protocol-row">
+          <view class="protocol-label">区域</view>
+          <picker 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-label">地址</view>
+          <input class="protocol-input protocol-row-input" maxlength="4" value="{{storageAccessAddress}}" bindinput="onStorageAccessInput" data-field="storageAccessAddress" />
+        </view>
+        <view wx:if="{{storageAccessCommandIndex !== 0}}" 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 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-value">{{storageAccessPreviewHexText || '--'}}</view>
+          <view wx:if="{{storageAccessPreviewText}}" class="generated-meta">{{storageAccessPreviewText}}</view>
+        </view>
+      </view>
+    </view>
+
+    <view class="panel communication-panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-history">
+          <image class="panel-icon-image" src="/assets/icons/history-white.png" mode="aspectFit" />
+        </view>
+        <view class="panel-title">收发日志</view>
+        <view class="panel-actions communication-actions">
+          <view class="panel-action-button" bindtap="switchLogMode">{{logModeToggleText}}</view>
+          <view class="panel-action-button" bindtap="clearCommunicationLogs">清空</view>
+        </view>
+      </view>
+      <view wx:if="{{!logs.length}}" class="empty-log">暂无日志</view>
+      <scroll-view wx:else class="log-scroll" scroll-y type="list">
+        <view
+          wx:for="{{logs}}"
+          wx:for-item="item"
+          wx:key="id"
+          class="log-row log-row--{{item.direction}}"
+        >
+          <view class="log-meta">
+            <view class="log-tags">
+              <view class="log-direction">{{item.direction}}</view>
+              <view wx:if="{{item.note}}" class="log-note">{{item.note}}</view>
+            </view>
+            <view class="log-time">{{item.time}}</view>
+          </view>
+          <view class="log-payload log-payload--{{logMode}}">{{item.displayText || '--'}}</view>
+        </view>
+      </scroll-view>
+    </view>
+  </view>
+</scroll-view>
+
+<view wx:if="{{protocolMultipleDialog.visible}}" class="generic-dialog-mask {{themeClass}}" bindtap="closeProtocolMultipleDialog">
+  <view class="generic-dialog protocol-multiple-dialog" catchtap="noop">
+    <view class="generic-dialog-header">
+      <view class="generic-dialog-title">{{protocolMultipleDialog.title || '多寄存器'}}</view>
+      <view class="generic-dialog-close" bindtap="closeProtocolMultipleDialog">×</view>
+    </view>
+    <view class="generic-dialog-body">
+      <view class="protocol-multiple-row">
+        <view class="protocol-multiple-head">
+          <view class="protocol-multiple-title">数量</view>
+          <input class="value-input protocol-multiple-length-input" value="{{commandRegisterQuantity}}" bindinput="onProtocolMultipleQuantityChange" />
+        </view>
+      </view>
+      <view
+        wx:for="{{protocolMultipleValues}}"
+        wx:for-item="register"
+        wx:for-index="index"
+        wx:key="name"
+        wx:if="{{protocolMultipleExpanded || index < 3}}"
+        class="protocol-multiple-row"
+      >
+        <view class="protocol-multiple-head">
+          <view class="protocol-multiple-title">{{register.name}}</view>
+          <picker mode="selector" range="{{protocolDataTypeOptions}}" range-key="label" value="{{register.dataTypeIndex}}" data-index="{{index}}" bindchange="onProtocolMultipleTypeChange">
+            <view class="picker-value protocol-multiple-type">{{protocolDataTypeOptions[register.dataTypeIndex].label}}</view>
+          </picker>
+        </view>
+        <view wx:if="{{register.showTextLength}}" class="protocol-multiple-text-length">
+          <view class="protocol-label">长度</view>
+          <input class="value-input protocol-multiple-length-input" data-index="{{index}}" value="{{register.textByteLength}}" bindinput="onProtocolMultipleTextLengthInput" />
+        </view>
+        <input
+          class="value-input protocol-multiple-input {{register.dataType === 'text' ? 'protocol-multiple-input--text' : ''}}"
+          placeholder="写入值"
+          data-index="{{index}}"
+          value="{{register.inputValue}}"
+          bindinput="onProtocolMultipleValueInput"
+        />
+      </view>
+      <view wx:if="{{protocolMultipleValues.length > 3}}" class="generic-draft-actions">
+        <view class="panel-action-button" bindtap="toggleProtocolMultipleExpanded">{{protocolMultipleExpanded ? '收起' : '展开'}}</view>
+      </view>
+    </view>
+    <view class="generic-draft-actions">
+      <view class="panel-action-button" bindtap="closeProtocolMultipleDialog">关闭</view>
+      <view class="panel-action-button is-active" data-source="dialog" bindtap="sendProtocolFrame">发送</view>
+    </view>
+  </view>
+</view>

+ 450 - 0
pages/communication/communication.wxss

@@ -0,0 +1,450 @@
+.communication-panel {
+  overflow: hidden;
+}
+
+.communication-panel .panel-header {
+  padding-bottom: 14rpx;
+}
+
+.panel-action-button.is-disabled {
+  background: #eef1f5;
+  color: #94a3b8;
+}
+
+.panel-action-button.is-active {
+  background: var(--accent);
+  color: #ffffff;
+}
+
+.communication-actions {
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 8rpx;
+}
+
+.comm-send-body {
+  padding: 0 24rpx 22rpx;
+  box-sizing: border-box;
+}
+
+.comm-send-input {
+  width: 100%;
+  min-height: 136rpx;
+  padding: 16rpx 18rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-size: 26rpx;
+  line-height: 1.5;
+  box-sizing: border-box;
+}
+
+.comm-send-input--hex {
+  font-family: Menlo, Monaco, Consolas, monospace;
+}
+
+.comm-send-input--text {
+  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
+}
+
+.comm-send-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+  margin-top: 12rpx;
+}
+
+.comm-send-state,
+.comm-send-length {
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.comm-error {
+  margin-top: 10rpx;
+  color: var(--danger);
+  font-size: 23rpx;
+  line-height: 1.4;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.comm-preview {
+  margin-top: 12rpx;
+  padding: 12rpx 16rpx;
+  border: 1rpx solid #d9edeb;
+  border-radius: 10rpx;
+  background: #f4fbfa;
+  box-sizing: border-box;
+}
+
+.comm-preview-label {
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.comm-preview-value {
+  margin-top: 8rpx;
+  color: #0f766e;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 24rpx;
+  line-height: 1.45;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.protocol-form {
+  padding: 0 24rpx 22rpx;
+  box-sizing: border-box;
+}
+
+.protocol-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+  min-height: 82rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.protocol-row:first-child {
+  border-top: 0;
+}
+
+.protocol-label {
+  flex: none;
+  color: #64748b;
+  font-size: 24rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.protocol-storage-sync {
+  text-align: right;
+}
+
+.protocol-value-picker,
+.protocol-row-input {
+  width: 300rpx;
+}
+
+.picker-value {
+  color: #111827;
+  font-size: 28rpx;
+  line-height: 70rpx;
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.protocol-input,
+.protocol-row-input {
+  height: 70rpx;
+  padding: 0 18rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 28rpx;
+  line-height: 70rpx;
+  text-align: right;
+  box-sizing: border-box;
+}
+
+.protocol-input {
+  width: 100%;
+}
+
+.coil-row {
+  min-height: 88rpx;
+}
+
+.coil-control {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  color: #0f766e;
+  font-size: 24rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.generated-frame {
+  margin-top: 14rpx;
+  padding: 14rpx 18rpx;
+  border: 1rpx solid #d9edeb;
+  border-radius: 10rpx;
+  background: #f4fbfa;
+  box-sizing: border-box;
+}
+
+.generated-frame--response {
+  margin-top: 10rpx;
+  border-color: #dbeafe;
+  background: #eff6ff;
+}
+
+.generated-title {
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.generated-value {
+  margin-top: 8rpx;
+  color: #0f766e;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 24rpx;
+  line-height: 1.5;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.generated-meta {
+  margin-top: 6rpx;
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.protocol-error {
+  padding-top: 10rpx;
+  color: var(--danger);
+  font-size: 23rpx;
+  line-height: 1.4;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.protocol-multiple-dialog {
+  max-height: 86vh;
+}
+
+.protocol-multiple-row {
+  padding: 18rpx 24rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.protocol-multiple-row:first-child {
+  border-top: 0;
+}
+
+.protocol-multiple-head,
+.protocol-multiple-text-length {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.protocol-multiple-title {
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 26rpx;
+  line-height: 1.35;
+  font-weight: 900;
+}
+
+.protocol-multiple-type {
+  width: 220rpx;
+  min-width: 220rpx;
+  max-width: 220rpx;
+}
+
+.protocol-multiple-length-input {
+  width: 180rpx;
+}
+
+.protocol-multiple-input {
+  width: 100%;
+  margin-top: 12rpx;
+}
+
+.protocol-multiple-input--text {
+  text-align: left;
+}
+
+.empty-log {
+  padding: 42rpx 24rpx;
+  color: #64748b;
+  font-size: 25rpx;
+  line-height: 1.4;
+  text-align: center;
+}
+
+.log-scroll {
+  height: 540rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.log-row {
+  padding: 18rpx 24rpx;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.log-row:first-child {
+  border-top: 0;
+}
+
+.log-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.log-tags {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.log-direction {
+  color: #0f766e;
+  font-size: 23rpx;
+  line-height: 1.35;
+  font-weight: 900;
+}
+
+.log-note {
+  padding: 3rpx 10rpx;
+  border-radius: 999rpx;
+  background: #eff6ff;
+  color: #2563eb;
+  font-size: 20rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.log-row--RX .log-note {
+  background: #ecfdf5;
+  color: #047857;
+}
+
+.log-row--TX .log-direction {
+  color: #2563eb;
+}
+
+.log-row--SYS .log-direction {
+  color: #64748b;
+}
+
+.log-time {
+  color: #94a3b8;
+  font-size: 22rpx;
+  line-height: 1.35;
+}
+
+.log-payload {
+  margin-top: 8rpx;
+  color: #111827;
+  font-size: 24rpx;
+  line-height: 1.55;
+  word-break: break-all;
+}
+
+.log-payload--hex {
+  font-family: Menlo, Monaco, Consolas, monospace;
+}
+
+.log-payload--text {
+  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
+}
+
+.theme-dark .comm-send-input,
+.theme-dark .protocol-row-input {
+  border-color: #334155;
+  background: #111827;
+  color: #e5e7eb;
+}
+
+.theme-dark .comm-send-state,
+.theme-dark .comm-send-length,
+.theme-dark .protocol-label,
+.theme-dark .comm-preview-label,
+.theme-dark .generated-meta,
+.theme-dark .log-time {
+  color: #94a3b8;
+}
+
+.theme-dark .comm-preview,
+.theme-dark .generated-frame {
+  border-color: #174e49;
+  background: #111827;
+}
+
+.theme-dark .generated-frame--response {
+  border-color: #1e3a8a;
+  background: #101827;
+}
+
+.theme-dark .comm-preview-value,
+.theme-dark .generated-value,
+.theme-dark .log-payload,
+.theme-dark .protocol-multiple-title {
+  color: #e5e7eb;
+}
+
+.theme-dark .log-scroll,
+.theme-dark .log-row,
+.theme-dark .protocol-row {
+  border-color: #263241;
+}
+
+.theme-dark .log-note {
+  background: #123d3b;
+  color: #5eead4;
+}
+
+.theme-dark .log-row--RX .log-note {
+  background: #0f2f2d;
+  color: #5eead4;
+}
+
+.theme-dark .log-row--TX .log-direction,
+.theme-dark .protocol-storage-sync {
+  color: #5eead4;
+}
+
+.theme-dark .log-row--SYS .log-direction {
+  color: #94a3b8;
+}
+
+.theme-dark .protocol-error {
+  color: #fed7aa;
+}
+
+.theme-dark .empty-log {
+  color: #94a3b8;
+}
+
+@media (max-width: 360px) {
+  .protocol-value-picker,
+  .protocol-row-input {
+    width: 258rpx;
+  }
+
+  .protocol-multiple-type {
+    width: 180rpx;
+    min-width: 180rpx;
+    max-width: 180rpx;
+  }
+
+  .comm-send-input {
+    min-height: 128rpx;
+  }
+}

+ 0 - 96
pages/home/home.js

@@ -47,98 +47,6 @@ Page({
     }
   },
 
-  onCommandChange(event) {
-    homeService.setCommandIndex(event.detail.value)
-  },
-
-  onSlaveAddressInput(event) {
-    homeService.setProtocolInput({
-      slaveAddress: event.detail.value
-    })
-  },
-
-  onRegisterAddressInput(event) {
-    homeService.setProtocolInput({
-      registerAddress: event.detail.value
-    })
-  },
-
-  onCommandValueInput(event) {
-    homeService.setProtocolInput({
-      commandValue: event.detail.value
-    })
-  },
-
-  onCommandRegisterQuantityInput(event) {
-    homeService.setProtocolMultipleQuantity(event.detail.value)
-  },
-
-  openProtocolMultipleDialog() {
-    homeService.openProtocolMultipleDialog()
-  },
-
-  closeProtocolMultipleDialog() {
-    homeService.closeProtocolMultipleDialog()
-  },
-
-  onProtocolMultipleTypeChange(event) {
-    homeService.setProtocolMultipleType(
-      event.currentTarget.dataset.index,
-      event.detail.value
-    )
-  },
-
-  onProtocolMultipleTextLengthInput(event) {
-    homeService.setProtocolMultipleTextLength(
-      event.currentTarget.dataset.index,
-      event.detail.value
-    )
-  },
-
-  onProtocolMultipleValueInput(event) {
-    homeService.setProtocolMultipleValue(
-      event.currentTarget.dataset.index,
-      event.detail.value
-    )
-  },
-
-  onProtocolMultipleValueBlur(event) {
-    try {
-      homeService.validateProtocolMultipleValue(
-        event.currentTarget.dataset.index,
-        event.detail.value
-      )
-    } catch (error) {
-      if (this.pageToast) this.pageToast.show(error.message || '输入值无效', 'error')
-    }
-  },
-
-  onCoilValueChange(event) {
-    homeService.setProtocolInput({
-      coilEnabled: !!event.detail.value
-    })
-  },
-
-  sendGeneratedFrame() {
-    if (!this.data.connectedDevice || !this.data.generatedHex) return
-
-    homeService.sendGeneratedFrame()
-  },
-
-  onHexInput(event) {
-    homeService.setSendHex(event.detail.value)
-  },
-
-  clearInput() {
-    homeService.clearInput()
-  },
-
-  sendHexFrame() {
-    if (!this.data.connectedDevice) return
-
-    homeService.sendHexFrame()
-  },
-
   startScan() {
     if (!this.data.canStartScan) return
 
@@ -165,9 +73,5 @@ Page({
     if (!this.data.canDisconnectDevice) return
 
     homeService.disconnectDevice()
-  },
-
-  clearLogs() {
-    homeService.clearLogs()
   }
 })

+ 0 - 202
pages/home/home.wxml

@@ -108,207 +108,5 @@
         </view>
       </scroll-view>
     </view>
-
-    <view class="panel">
-      <view class="panel-header panel-header--with-actions">
-        <view class="panel-icon icon-terminal">
-          <image class="panel-icon-image" src="/assets/icons/terminal-white.png" mode="aspectFit" />
-        </view>
-        <view class="panel-title">Modbus RTU 指令</view>
-        <view class="panel-actions">
-          <view
-            class="panel-action-button {{!connectedDevice || !generatedHex ? 'is-disabled' : ''}}"
-            bindtap="sendGeneratedFrame"
-          >
-            下发
-          </view>
-        </view>
-      </view>
-      <view class="protocol-form">
-        <view class="protocol-row">
-          <text class="protocol-label">功能码</text>
-          <picker
-            class="protocol-picker"
-            mode="selector"
-            range="{{protocolCommands}}"
-            range-key="label"
-            value="{{commandIndex}}"
-            bindchange="onCommandChange"
-          >
-            <view class="picker-value">{{protocolCommands[commandIndex].label}}</view>
-          </picker>
-        </view>
-        <view class="protocol-row protocol-field-row">
-          <text class="protocol-label">从站地址</text>
-          <input
-            class="protocol-input protocol-row-input"
-            type="text"
-            maxlength="2"
-            value="{{slaveAddress}}"
-            bindinput="onSlaveAddressInput"
-          />
-        </view>
-        <view class="protocol-row protocol-field-row">
-          <text class="protocol-label">起始地址</text>
-          <input
-            class="protocol-input protocol-row-input"
-            type="text"
-            maxlength="4"
-            value="{{registerAddress}}"
-            bindinput="onRegisterAddressInput"
-          />
-        </view>
-        <view wx:if="{{showRegisterQuantity}}" class="protocol-row protocol-field-row">
-          <text class="protocol-label">寄存器个数</text>
-          <input
-            class="protocol-input protocol-row-input"
-            type="text"
-            maxlength="4"
-            value="{{commandRegisterQuantity}}"
-            bindinput="onCommandRegisterQuantityInput"
-          />
-        </view>
-        <view wx:if="{{showCommandValue}}" class="protocol-row protocol-field-row">
-          <text class="protocol-label">{{commandValueLabel}}</text>
-          <view wx:if="{{showRegisterQuantity}}" class="protocol-input protocol-row-input protocol-value-picker" bindtap="openProtocolMultipleDialog">
-            {{commandValue || '点击编辑'}}
-          </view>
-          <input
-            wx:else
-            class="protocol-input protocol-row-input"
-            type="text"
-            value="{{commandValue}}"
-            bindinput="onCommandValueInput"
-          />
-        </view>
-        <view wx:if="{{showCoilValue}}" class="protocol-row coil-row">
-          <text class="protocol-label">线圈值</text>
-          <view class="coil-control">
-            <text>{{coilEnabled ? 'ON' : 'OFF'}}</text>
-            <switch checked="{{coilEnabled}}" color="#0f766e" bindchange="onCoilValueChange" />
-          </view>
-        </view>
-        <view class="generated-frame">
-          <view class="generated-title">生成帧</view>
-          <view class="generated-value">{{generatedHex || '--'}}</view>
-          <view wx:if="{{protocolErrorText}}" class="protocol-error">{{protocolErrorText}}</view>
-        </view>
-      </view>
-    </view>
-
-    <view class="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" />
-        </view>
-        <view class="panel-title">发送</view>
-        <view class="panel-actions">
-          <view class="panel-action-button" bindtap="clearInput">清空</view>
-          <view
-            class="panel-action-button {{!connectedDevice ? 'is-disabled' : ''}}"
-            bindtap="sendHexFrame"
-          >
-            发送
-          </view>
-        </view>
-      </view>
-      <textarea
-        class="hex-input"
-        maxlength="-1"
-        auto-height
-        placeholder="例如:01 03 00 00 00 01 84 0A"
-        value="{{sendHex}}"
-        bindinput="onHexInput"
-      />
-    </view>
-
-    <view class="panel">
-      <view class="panel-header panel-header--with-actions log-header">
-        <view class="panel-icon icon-history">
-          <image class="panel-icon-image" src="/assets/icons/history-white.png" mode="aspectFit" />
-        </view>
-        <view class="panel-title">收发日志 {{logs.length ? '(' + logs.length + ')' : ''}}</view>
-        <view class="panel-actions">
-          <view class="panel-action-button" bindtap="clearLogs">清空</view>
-        </view>
-      </view>
-      <scroll-view
-        class="log-scroll"
-        scroll-y
-        scroll-with-animation
-        scroll-into-view="{{logScrollTarget}}"
-        type="list"
-      >
-        <view wx:if="{{!logs.length}}" class="empty-log">暂无收发数据</view>
-        <view
-          wx:for="{{logs}}"
-          wx:key="id"
-          id="{{item.id}}"
-          class="log-row log-row--{{item.direction}}"
-        >
-          <view class="log-meta">
-            <view class="log-tags">
-              <text class="log-direction">{{item.direction}}</text>
-              <text wx:if="{{item.note}}" class="log-note">{{item.note}}</text>
-            </view>
-            <text class="log-time">{{item.time}}</text>
-          </view>
-          <view class="log-payload">{{item.payload}}</view>
-        </view>
-      </scroll-view>
-    </view>
   </view>
 </scroll-view>
-
-<view wx:if="{{protocolMultipleDialog.visible}}" class="generic-dialog-mask {{themeClass}}" bindtap="closeProtocolMultipleDialog">
-  <view class="generic-dialog protocol-multiple-dialog" catchtap="noop">
-    <view class="generic-dialog-header">
-      <view class="generic-dialog-title">{{protocolMultipleDialog.title}}</view>
-      <view class="generic-dialog-close" bindtap="closeProtocolMultipleDialog">×</view>
-    </view>
-    <view class="generic-dialog-body">
-      <view
-        wx:for="{{protocolMultipleValues}}"
-        wx:for-item="register"
-        wx:for-index="registerIndex"
-        wx:key="id"
-        class="protocol-multiple-row"
-      >
-        <view class="protocol-multiple-head">
-          <view class="protocol-multiple-title">{{register.addressText}}</view>
-          <picker
-            mode="selector"
-            range="{{protocolDataTypeOptions}}"
-            range-key="label"
-            value="{{register.dataTypeIndex}}"
-            data-index="{{registerIndex}}"
-            bindchange="onProtocolMultipleTypeChange"
-          >
-            <view class="generic-picker-value protocol-multiple-type">{{register.dataTypeText}}</view>
-          </picker>
-        </view>
-        <view wx:if="{{register.showTextLength}}" class="protocol-multiple-text-length">
-          <text class="param-meta">长度</text>
-          <input
-            class="value-input protocol-multiple-length-input"
-            type="number"
-            data-index="{{registerIndex}}"
-            value="{{register.textByteLength}}"
-            bindinput="onProtocolMultipleTextLengthInput"
-          />
-        </view>
-        <input
-          class="value-input protocol-multiple-input {{register.showTextLength ? 'protocol-multiple-input--text' : ''}}"
-          placeholder="{{register.dataTypeText}}"
-          data-index="{{registerIndex}}"
-          value="{{register.inputValue}}"
-          bindinput="onProtocolMultipleValueInput"
-          bindblur="onProtocolMultipleValueBlur"
-        />
-      </view>
-    </view>
-    <view class="generic-draft-actions">
-      <view class="panel-action-button is-active" bindtap="closeProtocolMultipleDialog">确认</view>
-    </view>
-  </view>
-</view>

+ 0 - 283
pages/home/home.wxss

@@ -247,274 +247,6 @@
   color: #166534;
 }
 
-.protocol-form {
-  padding: 8rpx 24rpx 0;
-}
-
-.protocol-row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 18rpx;
-  min-height: 76rpx;
-  border-top: 1rpx solid #edf2f7;
-}
-
-.protocol-field-row {
-  justify-content: space-between;
-}
-
-.protocol-row:first-child {
-  border-top: 0;
-}
-
-.protocol-label {
-  color: #475569;
-  font-size: 24rpx;
-  line-height: 1.35;
-  font-weight: 700;
-}
-
-.protocol-picker {
-  width: 350rpx;
-  height: 70rpx;
-  padding: 0;
-  border: 0;
-  background: transparent;
-  box-sizing: border-box;
-}
-
-.picker-value {
-  color: #111827;
-  font-size: 28rpx;
-  line-height: 70rpx;
-  text-align: right;
-}
-
-.protocol-input {
-  width: 100%;
-  height: 70rpx;
-  padding: 0 18rpx;
-  border: 1rpx solid #e7edf3;
-  border-radius: 10rpx;
-  background: #fafbfd;
-  color: #111827;
-  font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 28rpx;
-  line-height: 70rpx;
-  text-align: right;
-  box-sizing: border-box;
-}
-
-.protocol-row-input {
-  flex: none;
-  width: 350rpx;
-}
-
-.protocol-value-picker {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.coil-row {
-  margin-top: 16rpx;
-}
-
-.coil-control {
-  display: flex;
-  align-items: center;
-  gap: 14rpx;
-  color: var(--accent-dark);
-  font-size: 25rpx;
-  font-weight: 800;
-}
-
-.generated-frame {
-  margin-top: 18rpx;
-  margin-bottom: 24rpx;
-  padding: 16rpx 18rpx;
-  border: 1rpx solid #d9edeb;
-  border-radius: 12rpx;
-  background: #f4fbfa;
-}
-
-.generated-title {
-  color: #64748b;
-  font-size: 22rpx;
-  line-height: 1.35;
-}
-
-.generated-value {
-  margin-top: 8rpx;
-  color: #0f766e;
-  font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 25rpx;
-  line-height: 1.55;
-  font-weight: 800;
-  word-break: break-all;
-}
-
-.protocol-error {
-  margin-top: 8rpx;
-  color: var(--danger);
-  font-size: 23rpx;
-  line-height: 1.4;
-}
-
-.protocol-multiple-dialog {
-  max-height: 86vh;
-}
-
-.protocol-multiple-row {
-  padding: 18rpx 24rpx;
-  border-top: 1rpx solid #edf2f7;
-  box-sizing: border-box;
-}
-
-.protocol-multiple-row:first-child {
-  border-top: 0;
-}
-
-.protocol-multiple-head,
-.protocol-multiple-text-length {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 16rpx;
-}
-
-.protocol-multiple-title {
-  color: #111827;
-  font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 26rpx;
-  line-height: 1.35;
-  font-weight: 900;
-}
-
-.protocol-multiple-type {
-  width: 240rpx;
-  min-width: 240rpx;
-  max-width: 240rpx;
-}
-
-.protocol-multiple-text-length {
-  margin-top: 12rpx;
-}
-
-.protocol-multiple-length-input {
-  width: 180rpx;
-}
-
-.protocol-multiple-input {
-  width: 100%;
-  margin-top: 12rpx;
-}
-
-.protocol-multiple-input--text {
-  text-align: left;
-}
-
-.hex-input {
-  width: auto;
-  min-height: 190rpx;
-  margin: 12rpx 24rpx 24rpx;
-  padding: 20rpx;
-  border: 1rpx solid #e7edf3;
-  border-radius: 14rpx;
-  background: #fafbfd;
-  color: #111827;
-  font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 27rpx;
-  line-height: 1.55;
-  box-sizing: border-box;
-}
-
-.log-header {
-  padding-right: 16rpx;
-}
-
-.empty-log {
-  padding: 42rpx 24rpx;
-  color: #64748b;
-  font-size: 25rpx;
-  line-height: 1.4;
-  text-align: center;
-}
-
-.log-scroll {
-  height: 500rpx;
-  border-top: 1rpx solid #edf2f7;
-  box-sizing: border-box;
-}
-
-.log-row {
-  padding: 18rpx 24rpx;
-  border-top: 1rpx solid #edf2f7;
-}
-
-.log-row:first-child {
-  border-top: 0;
-}
-
-.log-meta {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 18rpx;
-}
-
-.log-tags {
-  display: flex;
-  align-items: center;
-  gap: 10rpx;
-}
-
-.log-direction {
-  color: #0f766e;
-  font-size: 23rpx;
-  line-height: 1.35;
-  font-weight: 900;
-}
-
-.log-note {
-  padding: 3rpx 9rpx;
-  border-radius: 999rpx;
-  background: #eff6ff;
-  color: #2563eb;
-  font-size: 20rpx;
-  line-height: 1.35;
-  font-weight: 800;
-}
-
-.log-row--RX .log-note {
-  background: #ecfdf5;
-  color: #047857;
-}
-
-.log-row--TX .log-direction {
-  color: #2563eb;
-}
-
-.log-row--SYS .log-direction {
-  color: #64748b;
-}
-
-.log-time {
-  color: #94a3b8;
-  font-size: 22rpx;
-  line-height: 1.35;
-}
-
-.log-payload {
-  margin-top: 8rpx;
-  color: #111827;
-  font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 25rpx;
-  line-height: 1.55;
-  word-break: break-all;
-}
-
 @media (max-width: 360px) {
   .device-card {
     padding: 16rpx;
@@ -523,19 +255,4 @@
   .device-main-row {
     align-items: flex-start;
   }
-
-  .protocol-picker {
-    width: 280rpx;
-  }
-
-  .protocol-row-input {
-    width: 280rpx;
-  }
-
-  .protocol-multiple-type {
-    width: 200rpx;
-    min-width: 200rpx;
-    max-width: 200rpx;
-  }
-
 }

+ 191 - 468
pages/params/params.js

@@ -1,150 +1,45 @@
 const {
-  createGenericGroupConfig,
-  createGenericGroupDialogState,
-  createGenericModbusDialogState,
-  createGenericRegisterChangedData,
-  createGenericRegisterDialogState,
-  findGenericGroup,
-  findGenericRegister,
-  getActiveGenericGroup,
-  getGenericDialogDataTypeState,
-  getGenericOption,
+  createParameterDialogState,
+  findParameterGroup,
+  findParameterRegister,
+  getActiveParameterGroup,
   getPageState,
   getSettingsPageState,
   getTransportPageState,
   getVisiblePageState,
   resolveActiveParamView
-} = require('../../features/private-protocol/params-view-model.js')
+} = require('../../features/parameter-groups/view-model.js')
 const {
-  createGenericModbusPoller,
-  genericModbusService
-} = require('../../features/generic-modbus/index.js')
+  createDialogHandlers,
+  createParameterGroupPoller,
+  parameterGroupService
+} = require('../../features/parameter-groups/index.js')
 const settingsService = require('../../store/settings-store.js')
 const themeService = require('../../store/theme-store.js')
 const transport = require('../../transport/ble-core.js')
 const {
   createPageToast
 } = require('../../utils/page-toast.js')
-
-const GENERIC_REGISTER_DRAG_THRESHOLD_PX = 12
-const GENERIC_REGISTER_ROW_HEIGHT_RPX = 112
-
-function clampIndex(value, min, max) {
-  return Math.min(Math.max(value, min), max)
-}
-
-function getWindowWidth() {
-  try {
-    if (typeof wx !== 'undefined' && wx && typeof wx.getWindowInfo === 'function') {
-      const info = wx.getWindowInfo()
-      if (info && Number.isFinite(info.windowWidth)) return info.windowWidth
-    }
-  } catch (error) {}
-
-  try {
-    if (typeof wx !== 'undefined' && wx && typeof wx.getSystemInfoSync === 'function') {
-      const info = wx.getSystemInfoSync()
-      if (info && Number.isFinite(info.windowWidth)) return info.windowWidth
-    }
-  } catch (error) {}
-
-  return 375
-}
-
-function rpxToPx(rpx, windowWidth) {
-  return Math.round(Number(rpx || 0) * Number(windowWidth || 375) / 750)
-}
-
-function getFallbackDragRowOffsetPx(windowWidth) {
-  return Math.max(44, rpxToPx(GENERIC_REGISTER_ROW_HEIGHT_RPX, windowWidth))
-}
-
-function resolveDragTargetIndex(drag, currentY, totalCount) {
-  if (!drag || !Number.isInteger(totalCount) || totalCount <= 0) return 0
-
-  const sourceIndex = clampIndex(Number(drag.index) || 0, 0, totalCount - 1)
-  const rowCenters = Array.isArray(drag.rowCenters) ? drag.rowCenters : []
-  const sourceCenter = Number(rowCenters[sourceIndex])
-
-  if (rowCenters.length === totalCount && Number.isFinite(sourceCenter)) {
-    const currentCenter = sourceCenter + (Number(currentY) - Number(drag.startY || currentY))
-    let targetIndex = sourceIndex
-
-    if (currentCenter >= sourceCenter) {
-      for (let index = sourceIndex + 1; index < totalCount; index += 1) {
-        if (currentCenter > Number(rowCenters[index])) targetIndex = index
-      }
-    } else {
-      for (let index = sourceIndex - 1; index >= 0; index -= 1) {
-        if (currentCenter < Number(rowCenters[index])) targetIndex = index
-      }
-    }
-
-    return clampIndex(targetIndex, 0, totalCount - 1)
-  }
-
-  const rowOffset = Math.max(1, Number(drag.rowOffset) || 1)
-  const step = Math.round((Number(currentY) - Number(drag.startY || currentY)) / rowOffset)
-
-  return clampIndex(sourceIndex + step, 0, totalCount - 1)
-}
-
-function buildActiveGenericRegisterRows(group, dragState) {
-  if (!group || !Array.isArray(group.registers)) return []
-
-  const drag = dragState && dragState.groupId === group.id ? dragState : null
-  const activeIndex = drag ? clampIndex(Number(drag.index) || 0, 0, group.registers.length - 1) : -1
-  const targetIndex = drag ? clampIndex(Number(drag.targetIndex) || activeIndex, 0, group.registers.length - 1) : -1
-  const rowOffset = drag ? Math.max(1, Math.round(Number(drag.rowOffset) || 0)) : 0
-  const translateY = drag ? Math.round(Number(drag.translateY) || 0) : 0
-
-  return group.registers.map((register, index) => {
-    const row = {
-      ...register,
-      sourceIndex: index,
-      dragClass: '',
-      dragHandleClass: '',
-      dragStyle: ''
-    }
-
-    if (!drag) return row
-
-    const isActive = index === activeIndex
-    let shiftY = 0
-
-    if (drag.moved && rowOffset) {
-      if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) {
-        shiftY = -rowOffset
-      } else if (activeIndex > targetIndex && index >= targetIndex && index < activeIndex) {
-        shiftY = rowOffset
-      }
-    }
-
-    if (isActive) {
-      row.dragClass = drag.moved ? 'is-dragging' : 'is-drag-armed'
-      row.dragHandleClass = drag.moved ? 'is-dragging' : 'is-drag-armed'
-      row.dragStyle = drag.moved
-        ? `transform: translate3d(0, ${translateY}px, 0) scale(1.02); z-index: 8;`
-        : 'z-index: 3;'
-      return row
-    }
-
-    if (shiftY) {
-      row.dragClass = shiftY > 0 ? 'is-shift-down' : 'is-shift-up'
-      row.dragStyle = `transform: translate3d(0, ${shiftY}px, 0);`
-    }
-
-    return row
-  })
+const {
+  PARAMETER_REGISTER_DRAG_THRESHOLD_PX,
+  buildActiveParameterRegisterRows,
+  clampIndex,
+  getFallbackDragRowOffsetPx,
+  getWindowWidth,
+  resolveDragTargetIndex
+} = require('../../features/parameter-groups/drag-view-model.js')
+
+function getParameterGroupsFromState(state = {}) {
+  return state.parameterGroups || []
 }
 
 Page({
   data: {
     ...getPageState(),
-    activeParamView: 'genericModbus',
-    activeGenericGroupId: '',
-    activeGenericRegisterRows: [],
-    genericModbusDialog: createGenericModbusDialogState()
+    activeParamView: 'parameterGroups',
+    activeParameterGroupId: '',
+    activeParameterRegisterRows: [],
+    parameterDialog: createParameterDialogState()
   },
 
   onTabItemTap() {
@@ -153,10 +48,10 @@ Page({
 
   onLoad() {
     this.pageToast = createPageToast(this, this.data)
-    this.genericModbusPoller = createGenericModbusPoller(() => this.data)
-    this.genericModbusTouchStarts = {}
-    this.genericWindowWidth = getWindowWidth()
-    genericModbusService.init()
+    this.parameterGroupPoller = createParameterGroupPoller(() => this.data)
+    this.parameterGroupTouchStarts = {}
+    this.parameterWindowWidth = getWindowWidth()
+    parameterGroupService.init()
     themeService.init()
     settingsService.init()
     this.unsubscribeTheme = themeService.subscribe((themeState) => {
@@ -165,40 +60,46 @@ Page({
     this.unsubscribeTransport = transport.subscribe((transportState) => {
       this.setData(getTransportPageState(transportState))
       if (transportState.connectedDevice) {
-        setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+        setTimeout(() => this.scheduleVisibleParameterAutoReads(), 0)
       } else {
-        this.clearGenericAutoTimers()
+        this.clearParameterAutoTimers()
       }
     })
-    this.unsubscribeGenericModbus = genericModbusService.subscribe((genericState) => {
-      const activeGenericGroup = getActiveGenericGroup(genericState.genericModbusGroups, this.data.activeGenericGroupId)
+    this.unsubscribeParameterGroups = parameterGroupService.subscribe((parameterState) => {
+      const activeParameterGroup = getActiveParameterGroup(getParameterGroupsFromState(parameterState), this.data.activeParameterGroupId)
       this.setData({
-        ...genericState,
-        activeGenericGroup,
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag),
-        activeParamView: this.data.activeParamView === 'genericModbusGroup' && !activeGenericGroup
-          ? 'genericModbus'
+        ...parameterState,
+        activeParameterGroup,
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(activeParameterGroup, this.parameterRegisterDrag),
+        activeParamView: this.data.activeParamView === 'parameterGroup' && !activeParameterGroup
+          ? 'parameterGroups'
           : this.data.activeParamView
       })
     })
     this.unsubscribeSettings = settingsService.subscribe((settingsState) => {
+      const protocolChanged = this.data.protocolMode && this.data.protocolMode !== settingsState.protocolMode
       const nextState = getSettingsPageState(this.data, settingsState)
-      const activeParamView = nextState.activeParamView
+      const activeParamView = protocolChanged ? 'parameterGroups' : nextState.activeParamView
+      const parameterGroups = getParameterGroupsFromState(parameterGroupService.getState())
 
-      const activeGenericGroup = getActiveGenericGroup(this.data.genericModbusGroups, this.data.activeGenericGroupId)
-      const safeActiveView = activeParamView === 'genericModbusGroup' && !activeGenericGroup
-        ? 'genericModbus'
+      const activeParameterGroupId = protocolChanged ? '' : this.data.activeParameterGroupId
+      const activeParameterGroup = getActiveParameterGroup(parameterGroups, activeParameterGroupId)
+      const safeActiveView = activeParamView === 'parameterGroup' && !activeParameterGroup
+        ? 'parameterGroups'
         : activeParamView
+      this.clearParameterAutoTimers()
       this.setData({
         ...nextState,
+        parameterGroups,
+        activeParameterGroupId,
         activeParamView: safeActiveView,
-        activeGenericGroup,
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag)
+        activeParameterGroup,
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(activeParameterGroup, this.parameterRegisterDrag)
       })
-      if (safeActiveView === 'genericModbus' || safeActiveView === 'genericModbusGroup') {
-        setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+      if (safeActiveView === 'parameterGroups' || safeActiveView === 'parameterGroup') {
+        setTimeout(() => this.scheduleVisibleParameterAutoReads(), 0)
       } else {
-        this.clearGenericAutoTimers()
+        this.clearParameterAutoTimers()
       }
     })
   },
@@ -211,22 +112,22 @@ Page({
     const pageState = getVisiblePageState(this.data)
     this.setData({
       ...pageState,
-      activeGenericGroup: getActiveGenericGroup(pageState.genericModbusGroups, this.data.activeGenericGroupId),
-      activeGenericRegisterRows: buildActiveGenericRegisterRows(
-        getActiveGenericGroup(pageState.genericModbusGroups, this.data.activeGenericGroupId),
-        this.genericModbusRegisterDrag
+      activeParameterGroup: getActiveParameterGroup(getParameterGroupsFromState(pageState), this.data.activeParameterGroupId),
+      activeParameterRegisterRows: buildActiveParameterRegisterRows(
+        getActiveParameterGroup(getParameterGroupsFromState(pageState), this.data.activeParameterGroupId),
+        this.parameterRegisterDrag
       )
     })
     this.pageToast.showFromState(pageState)
-    this.scheduleVisibleGenericAutoReads()
+    this.scheduleVisibleParameterAutoReads()
   },
 
   onHide() {
     if (this.pageToast) {
       this.pageToast.setActive(false)
     }
-    this.clearGenericRegisterDrag()
-    this.clearGenericAutoTimers()
+    this.clearParameterRegisterDrag()
+    this.clearParameterAutoTimers()
   },
 
   onUnload() {
@@ -245,9 +146,9 @@ Page({
       this.unsubscribeTransport = null
     }
 
-    if (this.unsubscribeGenericModbus) {
-      this.unsubscribeGenericModbus()
-      this.unsubscribeGenericModbus = null
+    if (this.unsubscribeParameterGroups) {
+      this.unsubscribeParameterGroups()
+      this.unsubscribeParameterGroups = null
     }
 
     if (this.unsubscribeSettings) {
@@ -255,13 +156,13 @@ Page({
       this.unsubscribeSettings = null
     }
 
-    this.clearGenericAutoTimers()
+    this.clearParameterAutoTimers()
   },
 
   openParamView(event) {
     if (this.pageToast) this.pageToast.clear()
-    this.closeGenericModbusDraft()
-    this.clearGenericRegisterDrag()
+    this.closeParameterDraft()
+    this.clearParameterRegisterDrag()
 
     const activeParamView = event.currentTarget.dataset.view
     if (!activeParamView) return
@@ -269,241 +170,63 @@ Page({
     this.setData({
       activeParamView
     })
-    if (activeParamView === 'genericModbus') {
-      setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+    if (activeParamView === 'parameterGroups') {
+      setTimeout(() => this.scheduleVisibleParameterAutoReads(), 0)
     }
   },
 
-  openGenericModbusGroup(event) {
+  openParameterGroup(event) {
     const groupId = event.currentTarget.dataset.groupId
-    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
     if (!group) return
 
     if (this.pageToast) this.pageToast.clear()
-    this.closeGenericModbusDraft()
+    this.closeParameterDraft()
     this.setData({
-      activeGenericGroup: group,
-      activeGenericGroupId: groupId,
-      activeParamView: 'genericModbusGroup',
-      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, this.genericModbusRegisterDrag)
+      activeParameterGroup: group,
+      activeParameterGroupId: groupId,
+      activeParamView: 'parameterGroup',
+      activeParameterRegisterRows: buildActiveParameterRegisterRows(group, this.parameterRegisterDrag)
     })
   },
 
   backToParamsHome() {
     if (this.pageToast) this.pageToast.clear()
-    this.closeGenericModbusDraft()
-    this.clearGenericRegisterDrag()
-    this.clearGenericAutoTimers()
+    this.closeParameterDraft()
+    this.clearParameterRegisterDrag()
+    this.clearParameterAutoTimers()
 
     const activeParamView = resolveActiveParamView('', this.data)
     this.setData({
-      activeGenericGroup: null,
-      activeGenericGroupId: '',
+      activeParameterGroup: null,
+      activeParameterGroupId: '',
       activeParamView,
-      activeGenericRegisterRows: []
+      activeParameterRegisterRows: []
     })
-    if (activeParamView === 'genericModbus') {
-      setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+    if (activeParamView === 'parameterGroups') {
+      setTimeout(() => this.scheduleVisibleParameterAutoReads(), 0)
     }
   },
 
   noop() {},
 
-  updateGenericModbusDialog(changedData) {
-    this.setData({
-      genericModbusDialog: {
-        ...this.data.genericModbusDialog,
-        ...changedData
-      }
-    })
-  },
-
-  openGenericModbusDraft(event) {
-    const groupId = event && event.currentTarget && event.currentTarget.dataset
-      ? event.currentTarget.dataset.groupId
-      : ''
-    const group = groupId ? findGenericGroup(this.data.genericModbusGroups, groupId) : null
-
-    this.updateGenericModbusDialog(createGenericGroupDialogState(group))
-  },
-
-  closeGenericModbusDraft() {
-    this.genericModbusGroupLongPressGuard = ''
-    this.genericModbusRegisterLongPressGuard = ''
-    this.updateGenericModbusDialog(createGenericModbusDialogState())
-  },
-
-  onGenericDraftInput(event) {
-    const field = event.currentTarget.dataset.field
-    if (!field) return
-
-    const value = event.detail.value
-    this.updateGenericModbusDialog({
-      [field]: value,
-      ...(field === 'structDefinition' ? {
-        parsedStructRegisters: [],
-        structParsedSummary: ''
-      } : {})
-    })
-  },
-
-  parseGenericStructDefinition() {
-    const dialog = this.data.genericModbusDialog || createGenericModbusDialogState()
-    const sourceText = dialog.structDefinition || ''
-    if (!sourceText.trim()) {
-      if (this.pageToast) this.pageToast.show('请先粘贴结构体定义', 'error')
-      return
-    }
-
-    const registerType = getGenericOption(this.data.genericModbusRegisterTypeOptions, dialog.registerTypeIndex)
-    if (registerType.key === 'coil' || registerType.key === 'discrete') {
-      if (this.pageToast) this.pageToast.show('结构体解析仅支持寄存器类型', 'error')
-      return
-    }
-
-    try {
-      const parsed = genericModbusService.parseStructDefinition(sourceText)
-      const inputRegisterIndex = Math.max(
-        0,
-        this.data.genericModbusRegisterTypeOptions.findIndex((item) => item.key === 'input')
-      )
-      const inputRegisterType = getGenericOption(this.data.genericModbusRegisterTypeOptions, inputRegisterIndex)
-      this.updateGenericModbusDialog({
-        groupName: parsed.name || dialog.groupName,
-        parsedStructRegisters: parsed.registers,
-        quantity: String(parsed.registers.length),
-        registerTypeIndex: inputRegisterIndex,
-        registerTypeText: inputRegisterType.label || '',
-        structParsedSummary: `${parsed.structName} · ${parsed.registers.length} 个字段`
-      })
-      if (this.pageToast) this.pageToast.show('结构体解析完成')
-    } catch (error) {
-      if (this.pageToast) this.pageToast.show(error.message || '结构体解析失败', 'error')
-    }
-  },
-
-  onGenericDraftTypeChange(event) {
-    const registerTypeIndex = Number(event.detail.value)
-    const registerType = getGenericOption(this.data.genericModbusRegisterTypeOptions, registerTypeIndex)
-    const clearParsedStruct = registerType.key === 'coil' || registerType.key === 'discrete'
-
-    this.updateGenericModbusDialog({
-      ...(clearParsedStruct ? {
-        parsedStructRegisters: [],
-        structParsedSummary: ''
-      } : {}),
-      registerTypeIndex,
-      registerTypeText: registerType.label || ''
-    })
-  },
-
-  onGenericDialogDataTypeChange(event) {
-    const dataTypeIndex = Number(event.detail.value)
-
-    this.updateGenericModbusDialog(getGenericDialogDataTypeState(
-      this.data.genericModbusDialog,
-      this.data.genericModbusDataTypeOptions,
-      dataTypeIndex
-    ))
-  },
-
-  openGenericGroupEdit(event) {
-    const groupId = event.currentTarget.dataset.groupId
-    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
-    if (!group) return
-
-    this.genericModbusGroupLongPressGuard = groupId
-
-    this.updateGenericModbusDialog(createGenericGroupDialogState(group))
-  },
-
-  openGenericRegisterInfo(event) {
-    const groupId = event.currentTarget.dataset.groupId
-    const registerIndex = Number(event.currentTarget.dataset.index)
-    const registerKey = `${groupId}:${registerIndex}`
-    if (this.genericModbusRegisterLongPressGuard === registerKey) {
-      this.genericModbusRegisterLongPressGuard = ''
-      return
-    }
-
-    const {
-      group,
-      register
-    } = findGenericRegister(this.data.genericModbusGroups, groupId, registerIndex)
-    if (!register) return
-
-    this.updateGenericModbusDialog(createGenericRegisterDialogState('viewRegister', group, register, registerIndex))
-  },
-
-  openGenericRegisterEdit(event) {
-    const groupId = event.currentTarget.dataset.groupId
-    const registerIndex = Number(event.currentTarget.dataset.index)
-    const {
-      group,
-      register
-    } = findGenericRegister(this.data.genericModbusGroups, groupId, registerIndex)
-    if (!register) return
-
-    this.genericModbusRegisterLongPressGuard = `${groupId}:${registerIndex}`
-    this.updateGenericModbusDialog(createGenericRegisterDialogState('editRegister', group, register, registerIndex))
-  },
-
-  async confirmGenericModbusDialog() {
-    const dialog = this.data.genericModbusDialog || createGenericModbusDialogState()
-    const mode = dialog.mode
-
-    if (mode === 'createGroup') {
-      const group = genericModbusService.addGroupFromConfig(createGenericGroupConfig(dialog))
-      if (group) {
-        if (this.pageToast) this.pageToast.show(`${group.name}已添加`)
-        this.closeGenericModbusDraft()
-        this.setData({
-          activeGenericGroup: group,
-          activeGenericGroupId: group.id,
-          activeParamView: 'genericModbusGroup',
-          activeGenericRegisterRows: buildActiveGenericRegisterRows(group, this.genericModbusRegisterDrag)
-        })
-      }
-      return
-    }
-
-    if (mode === 'editGroup') {
-      const group = genericModbusService.updateGroupConfig(dialog.groupId, createGenericGroupConfig(dialog))
-      if (group) {
-        if (this.pageToast) this.pageToast.show(`${group.name}已更新`)
-        this.closeGenericModbusDraft()
-        if (this.data.activeGenericGroupId === group.id) {
-          this.setData({
-            activeGenericGroup: group,
-            activeGenericRegisterRows: buildActiveGenericRegisterRows(group, this.genericModbusRegisterDrag)
-          })
-        }
-      }
-      return
-    }
-
-    if (mode === 'editRegister') {
-      const changedData = createGenericRegisterChangedData(dialog, this.data.genericModbusDataTypeOptions)
-      genericModbusService.updateRegister(dialog.groupId, dialog.registerIndex, changedData)
-      if (this.pageToast) this.pageToast.show(`${dialog.name || '寄存器'}已更新`)
-      this.closeGenericModbusDraft()
-    }
-  },
+  ...createDialogHandlers(parameterGroupService),
 
-  async importGenericModbusJson() {
-    const count = await genericModbusService.importJsonFromMessageFile()
+  async importParameterGroupsJson() {
+    const count = await parameterGroupService.importJsonFromMessageFile()
     if (count && this.pageToast) this.pageToast.show(`已导入 ${count} 个寄存器组`)
   },
 
-  async syncGenericModbusGroups() {
-    if (this.data.isGenericProtocol) return
+  async syncParameterGroups() {
+    if (this.data.isModbusProtocol) return
     if (!this.data.connectedDevice) return
 
-    const result = await genericModbusService.queryCodeInfoBlock({
-      maxPacketLength: this.data.genericModbusMaxPacketLength
+    const result = await parameterGroupService.syncFromStorageAccessCodeInfo({
+      maxPacketLength: this.data.parameterMaxPacketLength
     })
     if (result && result.ok && this.pageToast) {
-      const addressText = Number(result.address || 0).toString(16).toUpperCase().padStart(4, '0')
+      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 = [
@@ -511,160 +234,160 @@ Page({
         updatedCount ? `更新 ${updatedCount}` : ''
       ].filter(Boolean).join(',')
 
-      this.pageToast.show(`同步完成 0x${addressText},${result.structCount} 项${changedText ? `,${changedText}` : ''}`)
+      this.pageToast.show(`同步完成 codeInfo 0x${codeInfoAddressText} / 0x${codeInfoByteLengthText},${result.structCount} 项${changedText ? `,${changedText}` : ''}`)
     }
   },
 
-  async completeGenericModbusStructs() {
-    const result = await genericModbusService.completeStructInstanceGroupsWithStructFile()
+  async completeParameterStructs() {
+    const result = await parameterGroupService.completeStructInstanceGroupsWithStructFile()
     if (result && result.completedCount && this.pageToast) {
       this.pageToast.show(`已补全 ${result.completedCount} 个寄存器组`)
     }
   },
 
-  toggleGenericModbusPolling() {
-    if (this.data.isPrivateProtocol && !this.data.connectedDevice) return
+  toggleParameterPolling() {
+    if (this.data.isStorageAccessProtocol && !this.data.connectedDevice) return
 
-    const enabled = !this.data.genericModbusAutoPollEnabled
-    settingsService.setGenericModbusAutoPollEnabled(enabled)
+    const enabled = !this.data.parameterAutoPollEnabled
+    settingsService.setParameterAutoPollEnabled(enabled)
     if (enabled) {
-      this.scheduleGenericAutoPoll(0)
+      this.scheduleParameterAutoPoll(0)
     } else {
-      this.clearGenericAutoTimers()
+      this.clearParameterAutoTimers()
     }
   },
 
-  async saveGenericModbusJson() {
-    const count = await genericModbusService.saveJsonToChat()
+  async saveParameterGroupsJson() {
+    const count = await parameterGroupService.saveJsonToChat()
     if (count && this.pageToast) this.pageToast.show(`已保存 ${count} 个寄存器组`)
   },
 
-  toggleGenericModbusGroup(event) {
+  toggleParameterGroup(event) {
     const groupId = event.currentTarget.dataset.groupId
-    if (this.genericModbusGroupLongPressGuard === groupId) {
-      this.genericModbusGroupLongPressGuard = ''
+    if (this.parameterGroupLongPressGuard === groupId) {
+      this.parameterGroupLongPressGuard = ''
       return
     }
-    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
     if (!group) return
 
-    genericModbusService.setGroupExpanded(groupId, !group.expanded)
+    parameterGroupService.setGroupExpanded(groupId, !group.expanded)
   },
 
-  onGenericRegisterValueInput(event) {
-    genericModbusService.updateRegisterValue(
+  onParameterRegisterValueInput(event) {
+    parameterGroupService.updateRegisterValue(
       event.currentTarget.dataset.groupId,
       Number(event.currentTarget.dataset.index),
       event.detail.value
     )
   },
 
-  async onGenericRegisterValueBlur(event) {
+  async onParameterRegisterValueBlur(event) {
     const groupId = event.currentTarget.dataset.groupId
     const registerIndex = Number(event.currentTarget.dataset.index)
     try {
-      genericModbusService.validateRegisterInputValue(groupId, registerIndex, event.detail.value)
+      parameterGroupService.validateRegisterInputValue(groupId, registerIndex, event.detail.value)
     } catch (error) {
       if (this.pageToast) this.pageToast.show(error.message || '输入值无效', 'error')
       return
     }
 
-    if (!this.data.isPrivateProtocol || !this.data.connectedDevice) return
+    if (!this.data.isStorageAccessProtocol || !this.data.connectedDevice) return
 
-    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
     const register = group && group.registers ? group.registers[registerIndex] : null
     if (!group || !register || !register.isDirty || !group.writable || group.addressOverflow) return
 
-    this.clearGenericAutoTimers()
-    const ok = await genericModbusService.writeRegister(groupId, registerIndex)
-    if (this.data.genericModbusAutoPollEnabled) {
-      this.scheduleGenericAutoPoll(this.data.genericModbusPollInterval || 100)
+    this.clearParameterAutoTimers()
+    const ok = await parameterGroupService.writeRegister(groupId, registerIndex)
+    if (this.data.parameterAutoPollEnabled) {
+      this.scheduleParameterAutoPoll(this.data.parameterPollInterval || 100)
     }
     if (ok && this.pageToast) {
       this.pageToast.show(`${register.name || '变量'}已写入`)
     }
   },
 
-  async readGenericModbusGroup(event) {
+  async readParameterGroup(event) {
     if (!this.data.connectedDevice) return
 
     const groupId = event.currentTarget.dataset.groupId
-    const ok = await genericModbusService.readGroup(groupId, {
-      maxPacketLength: this.data.genericModbusMaxPacketLength
+    const ok = await parameterGroupService.readGroup(groupId, {
+      maxPacketLength: this.data.parameterMaxPacketLength
     })
-    if (ok && this.pageToast) this.pageToast.show('通用Modbus读取完成')
+    if (ok && this.pageToast) this.pageToast.show('参数组读取完成')
   },
 
-  async writeGenericModbusGroup(event) {
+  async writeParameterGroup(event) {
     if (!this.data.connectedDevice) return
 
     const groupId = event.currentTarget.dataset.groupId
-    const ok = await genericModbusService.writeGroup(groupId)
-    if (ok && this.pageToast) this.pageToast.show('通用Modbus写入完成')
+    const ok = await parameterGroupService.writeGroup(groupId)
+    if (ok && this.pageToast) this.pageToast.show('参数组写入完成')
   },
 
-  onGenericGroupTouchStart(event) {
+  onParameterGroupTouchStart(event) {
     const groupId = event.currentTarget.dataset.groupId
     const touch = (event.changedTouches || [])[0]
     if (!groupId || !touch) return
 
-    this.genericModbusTouchStarts[groupId] = touch.clientX
+    this.parameterGroupTouchStarts[groupId] = touch.clientX
   },
 
-  onGenericGroupTouchEnd(event) {
+  onParameterGroupTouchEnd(event) {
     const groupId = event.currentTarget.dataset.groupId
-    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
     const touch = (event.changedTouches || [])[0]
-    const startX = this.genericModbusTouchStarts[groupId]
+    const startX = this.parameterGroupTouchStarts[groupId]
     if (!groupId || !group || group.expanded || !touch || !Number.isFinite(startX)) return
 
     const deltaX = touch.clientX - startX
     if (deltaX > 42) {
-      genericModbusService.setGroupDeleteVisible(groupId, true)
+      parameterGroupService.setGroupDeleteVisible(groupId, true)
     } else if (deltaX < -24) {
-      genericModbusService.setGroupDeleteVisible(groupId, false)
+      parameterGroupService.setGroupDeleteVisible(groupId, false)
     }
   },
 
-  onGenericRegisterDragStart(event) {
+  onParameterRegisterDragStart(event) {
     const touch = (event.changedTouches || [])[0]
     if (!touch) return
 
     const groupId = event.currentTarget.dataset.groupId
     const index = Number(event.currentTarget.dataset.index)
-    const activeGenericGroup = findGenericGroup(this.data.genericModbusGroups, groupId)
-    if (!groupId || !activeGenericGroup || !Number.isInteger(index)) return
+    const activeParameterGroup = findParameterGroup(getParameterGroupsFromState(this.data), groupId)
+    if (!groupId || !activeParameterGroup || !Number.isInteger(index)) return
 
-    this.genericModbusRegisterDrag = {
+    this.parameterRegisterDrag = {
       groupId,
       index,
       moved: false,
       rowCenters: [],
-      rowOffset: getFallbackDragRowOffsetPx(this.genericWindowWidth),
+      rowOffset: getFallbackDragRowOffsetPx(this.parameterWindowWidth),
       startY: touch.clientY,
       targetIndex: index,
       translateY: 0
     }
 
-    if (this.data.activeGenericGroupId === groupId) {
+    if (this.data.activeParameterGroupId === groupId) {
       this.setData({
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag)
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(activeParameterGroup, this.parameterRegisterDrag)
       })
     }
 
-    this.measureGenericRegisterRows(this.genericModbusRegisterDrag)
+    this.measureParameterRegisterRows(this.parameterRegisterDrag)
   },
 
-  onGenericRegisterDragMove(event) {
+  onParameterRegisterDragMove(event) {
     const touch = (event.changedTouches || [])[0]
-    if (!touch || !this.genericModbusRegisterDrag) return
+    if (!touch || !this.parameterRegisterDrag) return
 
-    const drag = this.genericModbusRegisterDrag
-    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    const drag = this.parameterRegisterDrag
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), drag.groupId)
     if (!group) return
 
     const translateY = Math.round(touch.clientY - drag.startY)
-    const moved = Math.abs(translateY) > GENERIC_REGISTER_DRAG_THRESHOLD_PX
+    const moved = Math.abs(translateY) > PARAMETER_REGISTER_DRAG_THRESHOLD_PX
     const targetIndex = moved
       ? resolveDragTargetIndex(drag, touch.clientY, group.registers.length)
       : drag.index
@@ -681,24 +404,24 @@ Page({
     drag.moved = moved
     drag.targetIndex = targetIndex
 
-    if (this.data.activeGenericGroupId === group.id) {
+    if (this.data.activeParameterGroupId === group.id) {
       this.setData({
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, drag)
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(group, drag)
       })
     }
   },
 
-  onGenericRegisterDragEnd(event) {
-    const drag = this.genericModbusRegisterDrag
-    this.genericModbusRegisterDrag = null
+  onParameterRegisterDragEnd(event) {
+    const drag = this.parameterRegisterDrag
+    this.parameterRegisterDrag = null
     if (!drag || !drag.groupId) return
 
-    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), drag.groupId)
     if (!group) return
 
-    if (this.data.activeGenericGroupId === group.id) {
+    if (this.data.activeParameterGroupId === group.id) {
       this.setData({
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(group, null)
       })
     }
 
@@ -711,83 +434,83 @@ Page({
     )
     if (targetIndex === drag.index) return
 
-    const updatedGroup = genericModbusService.reorderRegister(drag.groupId, drag.index, targetIndex)
+    const updatedGroup = parameterGroupService.reorderRegister(drag.groupId, drag.index, targetIndex)
     if (!updatedGroup) return
 
-    this.genericModbusRegisterLongPressGuard = `${drag.groupId}:${targetIndex}`
+    this.parameterRegisterLongPressGuard = `${drag.groupId}:${targetIndex}`
     setTimeout(() => {
-      if (this.genericModbusRegisterLongPressGuard === `${drag.groupId}:${targetIndex}`) {
-        this.genericModbusRegisterLongPressGuard = ''
+      if (this.parameterRegisterLongPressGuard === `${drag.groupId}:${targetIndex}`) {
+        this.parameterRegisterLongPressGuard = ''
       }
     }, 260)
 
-    if (this.data.activeGenericGroupId === updatedGroup.id) {
+    if (this.data.activeParameterGroupId === updatedGroup.id) {
       this.setData({
-        activeGenericGroup: updatedGroup,
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(updatedGroup, null)
+        activeParameterGroup: updatedGroup,
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(updatedGroup, null)
       })
     }
   },
 
-  onGenericRegisterDragCancel() {
-    const drag = this.genericModbusRegisterDrag
-    this.genericModbusRegisterDrag = null
+  onParameterRegisterDragCancel() {
+    const drag = this.parameterRegisterDrag
+    this.parameterRegisterDrag = null
     if (!drag || !drag.groupId) return
 
-    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
-    if (!group || this.data.activeGenericGroupId !== group.id) return
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), drag.groupId)
+    if (!group || this.data.activeParameterGroupId !== group.id) return
 
     this.setData({
-      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+      activeParameterRegisterRows: buildActiveParameterRegisterRows(group, null)
     })
   },
 
-  deleteGenericModbusGroup(event) {
+  deleteParameterGroup(event) {
     const groupId = event.currentTarget.dataset.groupId
-    this.clearGenericAutoTimer(groupId)
-    genericModbusService.removeGroup(groupId)
-    if (this.data.activeGenericGroupId === groupId) {
+    this.clearParameterAutoTimer(groupId)
+    parameterGroupService.removeGroup(groupId)
+    if (this.data.activeParameterGroupId === groupId) {
       this.setData({
-        activeGenericGroup: null,
-        activeGenericGroupId: '',
-        activeParamView: 'genericModbus'
+        activeParameterGroup: null,
+        activeParameterGroupId: '',
+        activeParamView: 'parameterGroups'
       })
     }
     if (this.pageToast) this.pageToast.show('寄存器组已删除')
   },
 
-  clearGenericAutoTimer(groupId) {
-    if (this.genericModbusPoller) this.genericModbusPoller.clearTimer(groupId)
+  clearParameterAutoTimer(groupId) {
+    if (this.parameterGroupPoller) this.parameterGroupPoller.clearTimer(groupId)
   },
 
-  clearGenericAutoTimers() {
-    if (this.genericModbusPoller) this.genericModbusPoller.clearAll()
+  clearParameterAutoTimers() {
+    if (this.parameterGroupPoller) this.parameterGroupPoller.clearAll()
   },
 
-  scheduleVisibleGenericAutoReads() {
-    if (this.genericModbusPoller) this.genericModbusPoller.scheduleVisible()
+  scheduleVisibleParameterAutoReads() {
+    if (this.parameterGroupPoller) this.parameterGroupPoller.scheduleVisible()
   },
 
-  scheduleGenericAutoPoll(delay) {
-    if (this.genericModbusPoller) this.genericModbusPoller.schedule(delay)
+  scheduleParameterAutoPoll(delay) {
+    if (this.parameterGroupPoller) this.parameterGroupPoller.schedule(delay)
   },
 
-  clearGenericRegisterDrag() {
-    if (!this.genericModbusRegisterDrag) return
+  clearParameterRegisterDrag() {
+    if (!this.parameterRegisterDrag) return
 
-    const drag = this.genericModbusRegisterDrag
-    this.genericModbusRegisterDrag = null
-    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    const drag = this.parameterRegisterDrag
+    this.parameterRegisterDrag = null
+    const group = findParameterGroup(getParameterGroupsFromState(this.data), drag.groupId)
 
     this.setData({
-      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+      activeParameterRegisterRows: buildActiveParameterRegisterRows(group, null)
     })
   },
 
-  measureGenericRegisterRows(dragReference) {
+  measureParameterRegisterRows(dragReference) {
     const query = this.createSelectorQuery()
     query.selectAll('.generic-register-row').boundingClientRect((rects) => {
-      if (!this.genericModbusRegisterDrag || this.genericModbusRegisterDrag !== dragReference) return
+      if (!this.parameterRegisterDrag || this.parameterRegisterDrag !== dragReference) return
       if (!Array.isArray(rects) || !rects.length) return
 
       dragReference.rowCenters = rects.map((rect) => Number(rect.top || 0) + Number(rect.height || 0) / 2)
@@ -796,11 +519,11 @@ Page({
         Math.round(Number((rects[dragReference.index] || {}).height) || dragReference.rowOffset || 0)
       )
 
-      const group = findGenericGroup(this.data.genericModbusGroups, dragReference.groupId)
-      if (!group || this.data.activeGenericGroupId !== group.id) return
+      const group = findParameterGroup(getParameterGroupsFromState(this.data), dragReference.groupId)
+      if (!group || this.data.activeParameterGroupId !== group.id) return
 
       this.setData({
-        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, dragReference)
+        activeParameterRegisterRows: buildActiveParameterRegisterRows(group, dragReference)
       })
     }).exec()
   }

+ 125 - 96
pages/params/params.wxml

@@ -4,25 +4,25 @@
 </view>
 <view class="subpage-fixed-header subpage-fixed-header--generic {{themeClass}}">
   <view class="subpage-page-header">
-    <view wx:if="{{activeParamView == 'genericModbus'}}" class="panel-actions subpage-actions generic-protocol-actions">
-      <view class="panel-action-button {{isPrivateProtocol && !connectedDevice ? 'is-disabled' : ''}}" bindtap="syncGenericModbusGroups">同步</view>
-      <view wx:if="{{isPrivateProtocol}}" class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}} {{genericModbusAutoPollEnabled ? 'is-active' : ''}}" bindtap="toggleGenericModbusPolling">轮询</view>
-      <view class="panel-action-button" bindtap="saveGenericModbusJson">保存</view>
-      <view class="panel-action-button" bindtap="importGenericModbusJson">加载</view>
-      <view wx:if="{{isPrivateProtocol}}" class="panel-action-button" bindtap="completeGenericModbusStructs">结构</view>
-      <view wx:if="{{isGenericProtocol}}" class="panel-action-button panel-action-button--icon" bindtap="openGenericModbusDraft">+</view>
+    <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" bindtap="saveParameterGroupsJson">保存</view>
+      <view class="panel-action-button" bindtap="importParameterGroupsJson">加载</view>
+      <view wx:if="{{isStorageAccessProtocol}}" class="panel-action-button" bindtap="completeParameterStructs">结构</view>
+      <view wx:if="{{isModbusProtocol}}" class="panel-action-button panel-action-button--icon" bindtap="openParameterDraft">+</view>
     </view>
-    <view wx:elif="{{activeParamView == 'genericModbusGroup' && isGenericProtocol}}" class="panel-actions subpage-actions">
+    <view wx:elif="{{activeParamView == 'parameterGroup' && isModbusProtocol}}" class="panel-actions subpage-actions">
       <view
-        class="panel-action-button {{connectedDevice && !activeGenericGroup.addressOverflow ? '' : 'is-disabled'}}"
-        data-group-id="{{activeGenericGroup.id}}"
-        bindtap="readGenericModbusGroup"
+        class="panel-action-button {{connectedDevice && !activeParameterGroup.addressOverflow ? '' : 'is-disabled'}}"
+        data-group-id="{{activeParameterGroup.id}}"
+        bindtap="readParameterGroup"
       >读取</view>
       <view
-        wx:if="{{activeGenericGroup.writable}}"
-        class="panel-action-button {{connectedDevice && !activeGenericGroup.addressOverflow ? '' : 'is-disabled'}}"
-        data-group-id="{{activeGenericGroup.id}}"
-        bindtap="writeGenericModbusGroup"
+        wx:if="{{activeParameterGroup.writable}}"
+        class="panel-action-button {{connectedDevice && !activeParameterGroup.addressOverflow ? '' : 'is-disabled'}}"
+        data-group-id="{{activeParameterGroup.id}}"
+        bindtap="writeParameterGroup"
       >写入</view>
       <view class="panel-action-button" bindtap="backToParamsHome">返回</view>
     </view>
@@ -30,9 +30,20 @@
 </view>
 <scroll-view class="scrollarea {{themeClass}} scrollarea--subpage scrollarea--generic" scroll-y type="list">
   <view class="page-shell">
-    <block wx:if="{{activeParamView == 'genericModbus'}}">
+    <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="{{genericModbusGroups}}"
+        wx:for="{{parameterGroups}}"
         wx:for-item="group"
         wx:key="id"
         class="generic-group-shell {{group.deleteVisible ? 'is-delete-visible' : ''}}"
@@ -41,35 +52,35 @@
           wx:if="{{!group.expanded && group.deleteVisible}}"
           class="generic-delete-action"
           data-group-id="{{group.id}}"
-          bindtap="deleteGenericModbusGroup"
+          bindtap="deleteParameterGroup"
         >
           -
         </view>
         <view
           class="panel generic-group-panel {{group.expanded ? '' : 'panel--collapsed'}}"
           data-group-id="{{group.id}}"
-          bindtouchstart="onGenericGroupTouchStart"
-          bindtouchend="onGenericGroupTouchEnd"
+          bindtouchstart="onParameterGroupTouchStart"
+          bindtouchend="onParameterGroupTouchEnd"
         >
           <view class="panel-header panel-header--with-actions">
             <view
               class="panel-heading-toggle"
               data-group-id="{{group.id}}"
-              bindtap="openGenericModbusGroup"
+              bindtap="openParameterGroup"
             >
               <view class="panel-icon icon-terminal">
                 <image class="panel-icon-image" src="/assets/icons/terminal-white.png" mode="aspectFit" />
               </view>
               <view class="generic-group-title-wrap">
-                <view class="panel-title" data-group-id="{{group.id}}" catchlongpress="openGenericGroupEdit">{{group.name}}</view>
-                <view class="param-meta generic-group-meta">{{group.addressRangeText}} · {{group.quantity}}/{{group.wordQuantity}}{{group.sourceMetaText ? ' · ' + group.sourceMetaText : ''}}{{group.addressWarningText ? ' · ' + group.addressWarningText : ''}}</view>
+                <view class="panel-title" data-group-id="{{group.id}}" catchlongpress="openParameterGroupEdit">{{group.displayName || group.name}}</view>
+                <view class="param-meta generic-group-meta">{{group.listMetaText || group.addressRangeText}}</view>
               </view>
             </view>
-            <view wx:if="{{isGenericProtocol}}" class="panel-actions generic-group-actions">
+            <view wx:if="{{isModbusProtocol}}" class="panel-actions generic-group-actions">
               <view
                 class="panel-action-button {{connectedDevice && !group.addressOverflow ? '' : 'is-disabled'}}"
                 data-group-id="{{group.id}}"
-                bindtap="readGenericModbusGroup"
+                bindtap="readParameterGroup"
               >
                 读取
               </view>
@@ -77,7 +88,7 @@
                 wx:if="{{group.writable}}"
                 class="panel-action-button {{connectedDevice && !group.addressOverflow ? '' : 'is-disabled'}}"
                 data-group-id="{{group.id}}"
-                bindtap="writeGenericModbusGroup"
+                bindtap="writeParameterGroup"
               >
                 写入
               </view>
@@ -89,13 +100,16 @@
       </view>
     </block>
 
-    <block wx:elif="{{activeParamView == 'genericModbusGroup'}}">
-      <view wx:if="{{activeGenericGroup}}" class="panel generic-group-detail-panel">
+    <block wx:elif="{{activeParamView == 'parameterGroup'}}">
+      <view wx:if="{{activeParameterGroup}}" class="panel generic-group-detail-panel">
+        <view class="generic-group-detail-header">
+          <view class="panel-title">{{activeParameterGroup.detailTitleText || activeParameterGroup.displayName || activeParameterGroup.name}}</view>
+        </view>
         <view class="generic-group-detail-meta">
-          {{activeGenericGroup.addressRangeText}} · {{activeGenericGroup.quantity}}/{{activeGenericGroup.wordQuantity}}{{activeGenericGroup.sourceMetaText ? ' · ' + activeGenericGroup.sourceMetaText : ''}}{{activeGenericGroup.addressWarningText ? ' · ' + activeGenericGroup.addressWarningText : ''}}
+          {{activeParameterGroup.detailMetaText || activeParameterGroup.addressRangeText}}
         </view>
         <view
-          wx:for="{{activeGenericRegisterRows.length ? activeGenericRegisterRows : activeGenericGroup.registers}}"
+          wx:for="{{activeParameterRegisterRows.length ? activeParameterRegisterRows : activeParameterGroup.registers}}"
           wx:for-item="register"
           wx:for-index="registerIndex"
           wx:key="id"
@@ -103,14 +117,14 @@
           style="{{register.dragStyle}}"
         >
           <view
-            wx:if="{{!activeGenericGroup.isStructLayout}}"
+            wx:if="{{!activeParameterGroup.isStructLayout}}"
             class="generic-register-drag-handle {{register.dragHandleClass}}"
-            data-group-id="{{activeGenericGroup.id}}"
+            data-group-id="{{activeParameterGroup.id}}"
             data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
-            catchtouchstart="onGenericRegisterDragStart"
-            catchtouchmove="onGenericRegisterDragMove"
-            catchtouchend="onGenericRegisterDragEnd"
-            catchtouchcancel="onGenericRegisterDragCancel"
+            catchtouchstart="onParameterRegisterDragStart"
+            catchtouchmove="onParameterRegisterDragMove"
+            catchtouchend="onParameterRegisterDragEnd"
+            catchtouchcancel="onParameterRegisterDragCancel"
           >
             <view class="generic-register-drag-bar"></view>
             <view class="generic-register-drag-bar"></view>
@@ -120,31 +134,37 @@
           <view class="generic-register-main">
             <view
               class="generic-register-name"
-              data-group-id="{{activeGenericGroup.id}}"
+              data-group-id="{{activeParameterGroup.id}}"
               data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
-              bindtap="openGenericRegisterInfo"
-              catchlongpress="openGenericRegisterEdit"
+              bindtap="openParameterRegisterInfo"
+              catchlongpress="openParameterRegisterEdit"
             >
               {{register.name}}
             </view>
             <view class="generic-register-meta">
-              <text>{{register.addressText}} {{register.rawValueText}}</text>
+              <text>{{register.metaText || (register.addressText + ' ' + register.rawValueText)}}</text>
             </view>
           </view>
           <view class="generic-register-input-wrap {{register.showUnit && register.unit ? 'generic-register-input-wrap--unit' : ''}}">
-            <block wx:if="{{activeGenericGroup.writable}}">
-              <input
-                class="value-input generic-register-value {{register.isDirty ? 'value-input--dirty' : ''}}"
-                placeholder="--"
-                data-group-id="{{activeGenericGroup.id}}"
-                data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
-                value="{{register.inputValue}}"
-                bindinput="onGenericRegisterValueInput"
-                bindblur="onGenericRegisterValueBlur"
-              />
-              <view wx:if="{{register.showUnit && register.unit}}" class="generic-register-unit">{{register.unit}}</view>
+            <block wx:if="{{activeParameterGroup.writable}}">
+              <block wx:if="{{register.conversionFormula}}">
+                <view class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
+              </block>
+              <block wx:else>
+                <input
+                  class="value-input generic-register-value {{register.isDirty ? 'value-input--dirty' : ''}}"
+                  placeholder="--"
+                  data-group-id="{{activeParameterGroup.id}}"
+                  data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
+                  value="{{register.inputValue}}"
+                  bindinput="onParameterRegisterValueInput"
+                  bindblur="onParameterRegisterValueBlur"
+                />
+                <view wx:if="{{register.showUnit && register.unit}}" class="generic-register-unit">{{register.unit}}</view>
+              </block>
             </block>
             <view wx:else class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
+            <view wx:if="{{register.displayMetaText}}" class="generic-register-display-meta">{{register.displayMetaText}}</view>
           </view>
         </view>
       </view>
@@ -152,14 +172,14 @@
 
   </view>
 </scroll-view>
-<view wx:if="{{genericModbusDialog.visible}}" class="generic-dialog-mask {{themeClass}}" bindtap="closeGenericModbusDraft">
+<view wx:if="{{parameterDialog.visible}}" class="generic-dialog-mask {{themeClass}}" bindtap="closeParameterDraft">
   <view class="generic-dialog" catchtap="noop">
     <view class="generic-dialog-header">
-      <view class="generic-dialog-title">{{genericModbusDialog.title}}</view>
-      <view class="generic-dialog-close" bindtap="closeGenericModbusDraft">×</view>
+      <view class="generic-dialog-title">{{parameterDialog.title}}</view>
+      <view class="generic-dialog-close" bindtap="closeParameterDraft">×</view>
     </view>
 
-    <block wx:if="{{genericModbusDialog.mode == 'createGroup' || genericModbusDialog.mode == 'editGroup'}}">
+    <block wx:if="{{parameterDialog.mode == 'createGroup' || parameterDialog.mode == 'editGroup'}}">
       <view class="generic-dialog-body">
         <view class="generic-config-row">
           <view class="param-main">
@@ -169,8 +189,8 @@
           <input
             class="value-input generic-value-input"
             data-field="groupName"
-            value="{{genericModbusDialog.groupName}}"
-            bindinput="onGenericDraftInput"
+            value="{{parameterDialog.groupName}}"
+            bindinput="onParameterDraftInput"
           />
         </view>
         <view class="generic-config-row">
@@ -180,12 +200,12 @@
           </view>
           <picker
             mode="selector"
-            range="{{genericModbusRegisterTypeOptions}}"
+            range="{{parameterRegisterTypeOptions}}"
             range-key="label"
-            value="{{genericModbusDialog.registerTypeIndex}}"
-            bindchange="onGenericDraftTypeChange"
+            value="{{parameterDialog.registerTypeIndex}}"
+            bindchange="onParameterDraftTypeChange"
           >
-            <view class="generic-picker-value">{{genericModbusDialog.registerTypeText}}</view>
+            <view class="generic-picker-value">{{parameterDialog.registerTypeText}}</view>
           </picker>
         </view>
         <view class="generic-config-row">
@@ -196,98 +216,107 @@
           <input
             class="value-input generic-value-input"
             data-field="startAddress"
-            value="{{genericModbusDialog.startAddress}}"
-            bindinput="onGenericDraftInput"
+            value="{{parameterDialog.startAddress}}"
+            bindinput="onParameterDraftInput"
           />
         </view>
         <view class="generic-config-row">
           <view class="param-main">
             <view class="param-name">寄存器数量</view>
-            <view class="param-meta">{{genericModbusDialog.structParsedSummary || '1 - 256'}}</view>
+            <view class="param-meta">{{parameterDialog.structParsedSummary || '1 - 256'}}</view>
           </view>
           <input
             class="value-input generic-value-input"
             type="number"
             data-field="quantity"
-            value="{{genericModbusDialog.quantity}}"
-            bindinput="onGenericDraftInput"
+            value="{{parameterDialog.quantity}}"
+            bindinput="onParameterDraftInput"
           />
         </view>
-        <view wx:if="{{genericModbusDialog.mode == 'createGroup'}}" class="generic-struct-section">
+        <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>
-            <view class="panel-action-button" bindtap="parseGenericStructDefinition">解析</view>
+            <view class="panel-action-button" bindtap="parseParameterStructDefinition">解析</view>
           </view>
           <textarea
             class="generic-struct-input"
             maxlength="-1"
             placeholder="粘贴 C 结构体定义"
             data-field="structDefinition"
-            value="{{genericModbusDialog.structDefinition}}"
-            bindinput="onGenericDraftInput"
+            value="{{parameterDialog.structDefinition}}"
+            bindinput="onParameterDraftInput"
           />
         </view>
       </view>
     </block>
 
-    <block wx:elif="{{genericModbusDialog.mode == 'editRegister' || genericModbusDialog.mode == 'viewRegister'}}">
+    <block wx:elif="{{parameterDialog.mode == 'editRegister' || parameterDialog.mode == 'viewRegister'}}">
       <view class="generic-dialog-body">
         <view class="generic-info-stack">
           <view class="generic-info-row">
             <view class="generic-info-label">名称</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.name}}</view>
-            <input wx:else class="value-input generic-value-input" data-field="name" value="{{genericModbusDialog.name}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.name}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="name" value="{{parameterDialog.name}}" bindinput="onParameterDraftInput" />
           </view>
           <view class="generic-info-row">
             <view class="generic-info-label">地址</view>
-            <view class="generic-info-value">{{genericModbusDialog.addressText}}</view>
+            <view class="generic-info-value">{{parameterDialog.addressText}}</view>
           </view>
-          <view wx:if="{{genericModbusDialog.sourceMetaText}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.sourceMetaText}}" class="generic-info-row">
             <view class="generic-info-label">来源</view>
-            <view class="generic-info-value">{{genericModbusDialog.sourceMetaText}}</view>
+            <view class="generic-info-value">{{parameterDialog.sourceMetaText}}</view>
           </view>
-          <view wx:if="{{genericModbusDialog.showDataType}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.showDataType}}" class="generic-info-row">
             <view class="generic-info-label">类型</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.dataTypeText}}</view>
-            <picker wx:else mode="selector" range="{{genericModbusDataTypeOptions}}" range-key="label" value="{{genericModbusDialog.dataTypeIndex}}" bindchange="onGenericDialogDataTypeChange">
-              <view class="generic-picker-value">{{genericModbusDialog.dataTypeText}}</view>
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.dataTypeText}}</view>
+            <picker wx:else mode="selector" range="{{parameterDataTypeOptions}}" range-key="label" value="{{parameterDialog.dataTypeIndex}}" bindchange="onParameterDialogDataTypeChange">
+              <view class="generic-picker-value">{{parameterDialog.dataTypeText}}</view>
             </picker>
           </view>
-          <view wx:if="{{genericModbusDialog.showTextLength}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.showTextLength}}" class="generic-info-row">
             <view class="generic-info-label">长度</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.textByteLength || '--'}}B</view>
-            <input wx:else class="value-input generic-value-input" type="number" data-field="textByteLength" value="{{genericModbusDialog.textByteLength}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.textByteLength || '--'}}B</view>
+            <input wx:else class="value-input generic-value-input" type="number" data-field="textByteLength" value="{{parameterDialog.textByteLength}}" bindinput="onParameterDraftInput" />
           </view>
           <view class="generic-info-row">
             <view class="generic-info-label">备注</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.remark || '--'}}</view>
-            <input wx:else class="value-input generic-value-input" data-field="remark" value="{{genericModbusDialog.remark}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.remark || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="remark" value="{{parameterDialog.remark}}" bindinput="onParameterDraftInput" />
           </view>
-          <view wx:if="{{genericModbusDialog.showUnit}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.showUnit}}" class="generic-info-row">
             <view class="generic-info-label">单位</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.unit || '--'}}</view>
-            <input wx:else class="value-input generic-value-input" data-field="unit" value="{{genericModbusDialog.unit}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.unit || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="unit" value="{{parameterDialog.unit}}" bindinput="onParameterDraftInput" />
+          </view>
+          <view wx:if="{{parameterDialog.showDataType}}" class="generic-info-row">
+            <view class="generic-info-label">公式</view>
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.conversionFormula || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="conversionFormula" value="{{parameterDialog.conversionFormula}}" placeholder="x" bindinput="onParameterDraftInput" />
+          </view>
+          <view wx:if="{{parameterDialog.conversionFormulaErrorText}}" class="generic-info-row">
+            <view class="generic-info-label">公式</view>
+            <view class="generic-info-value">{{parameterDialog.conversionFormulaErrorText}}</view>
           </view>
-          <view wx:if="{{genericModbusDialog.mode == 'viewRegister' || genericModbusDialog.showRange}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.mode == 'viewRegister' || parameterDialog.showRange}}" class="generic-info-row">
             <view class="generic-info-label">最小值</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.minValue || '--'}}</view>
-            <input wx:else class="value-input generic-value-input" data-field="minValue" value="{{genericModbusDialog.minValue}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.minValue || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="minValue" value="{{parameterDialog.minValue}}" bindinput="onParameterDraftInput" />
           </view>
-          <view wx:if="{{genericModbusDialog.mode == 'viewRegister' || genericModbusDialog.showRange}}" class="generic-info-row">
+          <view wx:if="{{parameterDialog.mode == 'viewRegister' || parameterDialog.showRange}}" class="generic-info-row">
             <view class="generic-info-label">最大值</view>
-            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.maxValue || '--'}}</view>
-            <input wx:else class="value-input generic-value-input" data-field="maxValue" value="{{genericModbusDialog.maxValue}}" bindinput="onGenericDraftInput" />
+            <view wx:if="{{parameterDialog.mode == 'viewRegister'}}" class="generic-info-value">{{parameterDialog.maxValue || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="maxValue" value="{{parameterDialog.maxValue}}" bindinput="onParameterDraftInput" />
           </view>
         </view>
       </view>
     </block>
 
     <view class="generic-draft-actions">
-      <view class="panel-action-button" bindtap="closeGenericModbusDraft">{{genericModbusDialog.cancelText}}</view>
-      <view wx:if="{{genericModbusDialog.confirmText}}" class="panel-action-button is-active" bindtap="confirmGenericModbusDialog">{{genericModbusDialog.confirmText}}</view>
+      <view class="panel-action-button" bindtap="closeParameterDraft">{{parameterDialog.cancelText}}</view>
+      <view wx:if="{{parameterDialog.confirmText}}" class="panel-action-button is-active" bindtap="confirmParameterDialog">{{parameterDialog.confirmText}}</view>
     </view>
   </view>
 </view>

+ 9 - 9
pages/settings/settings.js

@@ -110,23 +110,23 @@ Page({
     settingsService.setModbusSlaveAddress(event.detail.value)
   },
 
-  onModbusProtocolChange(event) {
-    const option = this.data.modbusProtocolOptions[Number(event.detail.value)]
+  onProtocolModeChange(event) {
+    const option = this.data.protocolOptions[Number(event.detail.value)]
     if (!option) return
 
-    settingsService.setModbusProtocolMode(option.key)
+    settingsService.setProtocolMode(option.key)
   },
 
-  onGenericModbusAutoPollChange(event) {
-    settingsService.setGenericModbusAutoPollEnabled(!!event.detail.value)
+  onParameterAutoPollChange(event) {
+    settingsService.setParameterAutoPollEnabled(!!event.detail.value)
   },
 
-  onGenericModbusPollIntervalBlur(event) {
-    settingsService.setGenericModbusPollInterval(event.detail.value)
+  onParameterPollIntervalBlur(event) {
+    settingsService.setParameterPollInterval(event.detail.value)
   },
 
-  onGenericModbusMaxPacketLengthBlur(event) {
-    settingsService.setGenericModbusMaxPacketLength(event.detail.value)
+  onParameterMaxPacketLengthBlur(event) {
+    settingsService.setParameterMaxPacketLength(event.detail.value)
   },
 
   openToolEntry(event) {

+ 17 - 17
pages/settings/settings.wxml

@@ -695,23 +695,23 @@
     </view>
 
     <view class="panel settings-section-panel">
-      <view class="params-section-title">Modbus</view>
+      <view class="params-section-title">协议</view>
       <view class="settings-row">
         <view class="settings-row-main">
           <view class="param-name">协议模式</view>
-          <view class="param-meta">{{isPrivateProtocol ? '0x40 / 0x41 / 0x42' : '标准功能码寄存器'}}</view>
+          <view class="param-meta">{{isStorageAccessProtocol ? '单主单从存储访问' : '标准功能码寄存器'}}</view>
         </view>
         <picker
           mode="selector"
-          range="{{modbusProtocolOptions}}"
+          range="{{protocolOptions}}"
           range-key="label"
-          value="{{modbusProtocolIndex}}"
-          bindchange="onModbusProtocolChange"
+          value="{{protocolIndex}}"
+          bindchange="onProtocolModeChange"
         >
-          <view class="settings-picker-value">{{modbusProtocolText}}</view>
+          <view class="settings-picker-value">{{protocolText}}</view>
         </picker>
       </view>
-      <view class="settings-row settings-row--input">
+      <view wx:if="{{isModbusProtocol}}" class="settings-row settings-row--input">
         <view class="settings-row-main">
           <view class="param-name">从机地址</view>
           <view class="param-meta">00 - FF</view>
@@ -729,12 +729,12 @@
       <view class="settings-row">
         <view class="settings-row-main">
           <view class="param-name">自动轮询</view>
-          <view class="param-meta">{{genericModbusAutoPollEnabled ? '已启用' : '已停止'}}</view>
+          <view class="param-meta">{{parameterAutoPollEnabled ? '已启用' : '已停止'}}</view>
         </view>
         <switch
-          checked="{{genericModbusAutoPollEnabled}}"
+          checked="{{parameterAutoPollEnabled}}"
           color="#0f766e"
-          bindchange="onGenericModbusAutoPollChange"
+          bindchange="onParameterAutoPollChange"
         />
       </view>
       <view class="settings-row settings-row--input">
@@ -746,9 +746,9 @@
           <input
             class="value-input settings-value-input settings-value-input--unit"
             type="number"
-            value="{{genericModbusPollInterval}}"
-            bindblur="onGenericModbusPollIntervalBlur"
-            bindconfirm="onGenericModbusPollIntervalBlur"
+            value="{{parameterPollInterval}}"
+            bindblur="onParameterPollIntervalBlur"
+            bindconfirm="onParameterPollIntervalBlur"
           />
           <text class="settings-unit settings-unit--inside">ms</text>
         </view>
@@ -756,15 +756,15 @@
       <view class="settings-row settings-row--input">
         <view class="settings-row-main">
           <view class="param-name">最大包长</view>
-          <view class="param-meta">0 为无限制,最小 {{genericModbusMinPacketLength}} 字节</view>
+          <view class="param-meta">0 为无限制,最小 {{parameterMinPacketLength}} 字节</view>
         </view>
         <view class="settings-input-wrap settings-input-wrap--unit">
           <input
             class="value-input settings-value-input settings-value-input--unit"
             type="number"
-            value="{{genericModbusMaxPacketLength}}"
-            bindblur="onGenericModbusMaxPacketLengthBlur"
-            bindconfirm="onGenericModbusMaxPacketLengthBlur"
+            value="{{parameterMaxPacketLength}}"
+            bindblur="onParameterMaxPacketLengthBlur"
+            bindconfirm="onParameterMaxPacketLengthBlur"
           />
           <text class="settings-unit settings-unit--inside">B</text>
         </view>

+ 0 - 218
protocols/bootloader/frame.js

@@ -1,218 +0,0 @@
-const BOOTLOADER_HEAD = [0x46, 0x54]
-const {
-  BYTE_ORDER_HIGH,
-  appendCrc16Ccitt,
-  crc16Ccitt,
-  hasValidCrc16Ccitt
-} = require('../../utils/crc.js')
-
-const ACK = 0x06
-const NAK = 0x15
-const PROGRAM_CHUNK_SIZE = 128
-const BOOTLOADER_CRC_OPTIONS = {
-  byteOrder: BYTE_ORDER_HIGH
-}
-
-function isBootloaderFrame(bytes) {
-  return Array.isArray(bytes)
-    && bytes.length >= 2
-    && bytes[0] === BOOTLOADER_HEAD[0]
-    && bytes[1] === BOOTLOADER_HEAD[1]
-}
-
-function getBootloaderResponseLength(bytes) {
-  if (!isBootloaderFrame(bytes) || bytes.length < 3) return 0
-  if (bytes[2] === 0x39) return 15
-  if (bytes[2] === 0x19) return 9
-
-  return 8
-}
-
-function getBootloaderExpectedLength(kind) {
-  if (kind === 'handshake') return 15
-  if (kind === 'flashCheck') return 9
-
-  return 8
-}
-
-function toHex(value, length = 2) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
-}
-
-function formatBootloaderCrc(value) {
-  return `0x${toHex(value, 4)}`
-}
-
-function calculateBootloaderCrc(bytes) {
-  return crc16Ccitt(Array.prototype.slice.call(bytes || []), BOOTLOADER_CRC_OPTIONS)
-}
-
-function buildBootloaderFrame(payload) {
-  return new Uint8Array(appendCrc16Ccitt(BOOTLOADER_HEAD.concat(payload), BOOTLOADER_CRC_OPTIONS))
-}
-
-function buildHandshakeFrame() {
-  return buildBootloaderFrame([0x39, 0x42, 0x4C])
-}
-
-function buildUnlockFrame() {
-  return buildBootloaderFrame([0x08, 0x4E, 0x00])
-}
-
-function buildProgramFrame(address, dataBytes, chunkSize = PROGRAM_CHUNK_SIZE) {
-  const payload = [
-    0x44,
-    address & 0xFF,
-    (address >> 8) & 0xFF
-  ]
-  const data = Array.prototype.slice.call(dataBytes || []).slice(0, chunkSize)
-
-  while (data.length < chunkSize) {
-    data.push(0x00)
-  }
-
-  return buildBootloaderFrame(payload.concat(data))
-}
-
-function buildFlashCheckFrame() {
-  return buildBootloaderFrame([0x19, 0x43, 0x43])
-}
-
-function buildPageEraseFrame(enabled) {
-  return buildBootloaderFrame([0x08, 0x50, enabled ? 0x45 : 0x44])
-}
-
-function buildExitFrame() {
-  return buildBootloaderFrame([0x08, 0x42, 0x42])
-}
-
-function parseAsciiField(bytes, offset, length) {
-  const chars = []
-
-  for (let index = 0; index < length; index += 1) {
-    const byte = bytes[offset + index] & 0xFF
-    if (byte === 0x00 || byte === 0xFF) continue
-    if (byte >= 0x20 && byte <= 0x7E) {
-      chars.push(String.fromCharCode(byte))
-    }
-  }
-
-  return chars.join('').trim() || '--'
-}
-
-function alignBootloaderBuffer(buffer) {
-  let headIndex = -1
-
-  for (let index = 0; index < buffer.length - 1; index += 1) {
-    if (buffer[index] === BOOTLOADER_HEAD[0] && buffer[index + 1] === BOOTLOADER_HEAD[1]) {
-      headIndex = index
-      break
-    }
-  }
-
-  if (headIndex > 0) {
-    buffer.splice(0, headIndex)
-  } else if (headIndex < 0 && buffer.length > 1) {
-    buffer.splice(0, buffer.length - 1)
-  }
-}
-
-function parseBootloaderResponse(bytes, kind) {
-  if (!hasValidCrc16Ccitt(bytes, BOOTLOADER_CRC_OPTIONS)) {
-    throw new Error('Bootloader 返回帧 CRC 校验失败')
-  }
-
-  if (kind === 'handshake') {
-    if (bytes.length !== 15 || bytes[2] !== 0x39 || bytes[3] !== 0x42 || bytes[4] !== 0x4C) {
-      throw new Error('握手反馈帧不匹配')
-    }
-
-    const versionText = parseAsciiField(bytes, 5, 4)
-    const chipIdText = parseAsciiField(bytes, 9, 4)
-
-    return {
-      chipId: chipIdText,
-      chipIdText,
-      version: versionText,
-      versionText
-    }
-  }
-
-  if (kind === 'unlock') {
-    if (bytes.length !== 8 || bytes[2] !== 0x08 || bytes[3] !== 0x4E || bytes[4] !== 0x00) {
-      throw new Error('解锁反馈帧不匹配')
-    }
-
-    return {
-      ack: bytes[5]
-    }
-  }
-
-  if (kind === 'program') {
-    if (bytes.length !== 8 || bytes[2] !== 0x44) {
-      throw new Error('编程反馈帧不匹配')
-    }
-
-    return {
-      ack: bytes[5],
-      address: (bytes[3] & 0xFF) | ((bytes[4] & 0xFF) << 8)
-    }
-  }
-
-  if (kind === 'flashCheck') {
-    if (bytes.length !== 9 || bytes[2] !== 0x19 || bytes[3] !== 0x43 || bytes[4] !== 0x43) {
-      throw new Error('全 Flash 校验反馈帧不匹配')
-    }
-
-    const flashCrc = ((bytes[5] & 0xFF) << 8) | (bytes[6] & 0xFF)
-
-    return {
-      flashCrc,
-      flashCrcText: formatBootloaderCrc(flashCrc)
-    }
-  }
-
-  if (kind === 'pageErase') {
-    if (bytes.length !== 8 || bytes[2] !== 0x08 || bytes[3] !== 0x50) {
-      throw new Error('页擦除反馈帧不匹配')
-    }
-
-    return {
-      ack: bytes[5],
-      enabled: bytes[4] === 0x45
-    }
-  }
-
-  return {}
-}
-
-function assertBootloaderAck(response, label) {
-  if (!response || response.ack === ACK) return
-  if (response.ack === NAK) throw new Error(`${label}失败:设备返回 NAK`)
-
-  throw new Error(`${label}失败:未知 ACK 0x${toHex(response.ack)}`)
-}
-
-module.exports = {
-  ACK,
-  BOOTLOADER_HEAD,
-  BOOTLOADER_CRC_OPTIONS,
-  NAK,
-  PROGRAM_CHUNK_SIZE,
-  alignBootloaderBuffer,
-  assertBootloaderAck,
-  buildBootloaderFrame,
-  buildExitFrame,
-  buildFlashCheckFrame,
-  buildHandshakeFrame,
-  buildPageEraseFrame,
-  buildProgramFrame,
-  buildUnlockFrame,
-  calculateBootloaderCrc,
-  formatBootloaderCrc,
-  getBootloaderExpectedLength,
-  getBootloaderResponseLength,
-  isBootloaderFrame,
-  parseBootloaderResponse,
-  toHex
-}

+ 216 - 1
protocols/bootloader/index.js

@@ -1,3 +1,218 @@
+const {
+  BYTE_ORDER_HIGH,
+  appendCrc16Ccitt,
+  crc16Ccitt,
+  hasValidCrc16Ccitt
+} = require('../../utils/crc.js')
+
+const BOOTLOADER_HEAD = [0x46, 0x54]
+const ACK = 0x06
+const NAK = 0x15
+const PROGRAM_CHUNK_SIZE = 128
+const BOOTLOADER_CRC_OPTIONS = {
+  byteOrder: BYTE_ORDER_HIGH
+}
+
+function isBootloaderFrame(bytes) {
+  return Array.isArray(bytes)
+    && bytes.length >= 2
+    && bytes[0] === BOOTLOADER_HEAD[0]
+    && bytes[1] === BOOTLOADER_HEAD[1]
+}
+
+function getBootloaderResponseLength(bytes) {
+  if (!isBootloaderFrame(bytes) || bytes.length < 3) return 0
+  if (bytes[2] === 0x39) return 15
+  if (bytes[2] === 0x19) return 9
+
+  return 8
+}
+
+function getBootloaderExpectedLength(kind) {
+  if (kind === 'handshake') return 15
+  if (kind === 'flashCheck') return 9
+
+  return 8
+}
+
+function toHex(value, length = 2) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
+}
+
+function formatBootloaderCrc(value) {
+  return `0x${toHex(value, 4)}`
+}
+
+function calculateBootloaderCrc(bytes) {
+  return crc16Ccitt(Array.prototype.slice.call(bytes || []), BOOTLOADER_CRC_OPTIONS)
+}
+
+function buildBootloaderFrame(payload) {
+  return new Uint8Array(appendCrc16Ccitt(BOOTLOADER_HEAD.concat(payload), BOOTLOADER_CRC_OPTIONS))
+}
+
+function buildHandshakeFrame() {
+  return buildBootloaderFrame([0x39, 0x42, 0x4C])
+}
+
+function buildUnlockFrame() {
+  return buildBootloaderFrame([0x08, 0x4E, 0x00])
+}
+
+function buildProgramFrame(address, dataBytes, chunkSize = PROGRAM_CHUNK_SIZE) {
+  const payload = [
+    0x44,
+    address & 0xFF,
+    (address >> 8) & 0xFF
+  ]
+  const data = Array.prototype.slice.call(dataBytes || []).slice(0, chunkSize)
+
+  while (data.length < chunkSize) {
+    data.push(0x00)
+  }
+
+  return buildBootloaderFrame(payload.concat(data))
+}
+
+function buildFlashCheckFrame() {
+  return buildBootloaderFrame([0x19, 0x43, 0x43])
+}
+
+function buildPageEraseFrame(enabled) {
+  return buildBootloaderFrame([0x08, 0x50, enabled ? 0x45 : 0x44])
+}
+
+function buildExitFrame() {
+  return buildBootloaderFrame([0x08, 0x42, 0x42])
+}
+
+function parseAsciiField(bytes, offset, length) {
+  const chars = []
+
+  for (let index = 0; index < length; index += 1) {
+    const byte = bytes[offset + index] & 0xFF
+    if (byte === 0x00 || byte === 0xFF) continue
+    if (byte >= 0x20 && byte <= 0x7E) {
+      chars.push(String.fromCharCode(byte))
+    }
+  }
+
+  return chars.join('').trim() || '--'
+}
+
+function alignBootloaderBuffer(buffer) {
+  let headIndex = -1
+
+  for (let index = 0; index < buffer.length - 1; index += 1) {
+    if (buffer[index] === BOOTLOADER_HEAD[0] && buffer[index + 1] === BOOTLOADER_HEAD[1]) {
+      headIndex = index
+      break
+    }
+  }
+
+  if (headIndex > 0) {
+    buffer.splice(0, headIndex)
+  } else if (headIndex < 0 && buffer.length > 1) {
+    buffer.splice(0, buffer.length - 1)
+  }
+}
+
+function parseBootloaderResponse(bytes, kind) {
+  if (!hasValidCrc16Ccitt(bytes, BOOTLOADER_CRC_OPTIONS)) {
+    throw new Error('Bootloader 返回帧 CRC 校验失败')
+  }
+
+  if (kind === 'handshake') {
+    if (bytes.length !== 15 || bytes[2] !== 0x39 || bytes[3] !== 0x42 || bytes[4] !== 0x4C) {
+      throw new Error('握手反馈帧不匹配')
+    }
+
+    const versionText = parseAsciiField(bytes, 5, 4)
+    const chipIdText = parseAsciiField(bytes, 9, 4)
+
+    return {
+      chipId: chipIdText,
+      chipIdText,
+      version: versionText,
+      versionText
+    }
+  }
+
+  if (kind === 'unlock') {
+    if (bytes.length !== 8 || bytes[2] !== 0x08 || bytes[3] !== 0x4E || bytes[4] !== 0x00) {
+      throw new Error('解锁反馈帧不匹配')
+    }
+
+    return {
+      ack: bytes[5]
+    }
+  }
+
+  if (kind === 'program') {
+    if (bytes.length !== 8 || bytes[2] !== 0x44) {
+      throw new Error('编程反馈帧不匹配')
+    }
+
+    return {
+      ack: bytes[5],
+      address: (bytes[3] & 0xFF) | ((bytes[4] & 0xFF) << 8)
+    }
+  }
+
+  if (kind === 'flashCheck') {
+    if (bytes.length !== 9 || bytes[2] !== 0x19 || bytes[3] !== 0x43 || bytes[4] !== 0x43) {
+      throw new Error('全 Flash 校验反馈帧不匹配')
+    }
+
+    const flashCrc = ((bytes[5] & 0xFF) << 8) | (bytes[6] & 0xFF)
+
+    return {
+      flashCrc,
+      flashCrcText: formatBootloaderCrc(flashCrc)
+    }
+  }
+
+  if (kind === 'pageErase') {
+    if (bytes.length !== 8 || bytes[2] !== 0x08 || bytes[3] !== 0x50) {
+      throw new Error('页擦除反馈帧不匹配')
+    }
+
+    return {
+      ack: bytes[5],
+      enabled: bytes[4] === 0x45
+    }
+  }
+
+  return {}
+}
+
+function assertBootloaderAck(response, label) {
+  if (!response || response.ack === ACK) return
+  if (response.ack === NAK) throw new Error(`${label}失败:设备返回 NAK`)
+
+  throw new Error(`${label}失败:未知 ACK 0x${toHex(response.ack)}`)
+}
+
 module.exports = {
-  ...require('./frame.js')
+  ACK,
+  BOOTLOADER_HEAD,
+  BOOTLOADER_CRC_OPTIONS,
+  NAK,
+  PROGRAM_CHUNK_SIZE,
+  alignBootloaderBuffer,
+  assertBootloaderAck,
+  buildBootloaderFrame,
+  buildExitFrame,
+  buildFlashCheckFrame,
+  buildHandshakeFrame,
+  buildPageEraseFrame,
+  buildProgramFrame,
+  buildUnlockFrame,
+  calculateBootloaderCrc,
+  formatBootloaderCrc,
+  getBootloaderExpectedLength,
+  getBootloaderResponseLength,
+  isBootloaderFrame,
+  parseBootloaderResponse,
+  toHex
 }

+ 0 - 424
protocols/modbus-rtu/client.js

@@ -1,424 +0,0 @@
-const {
-  buildCodeInfoQueryFrame,
-  buildDebugReadMemoryFrame,
-  buildDebugWriteMemoryFrame,
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  getMaxDebugReadByteLength,
-  getMaxDebugWriteByteLength,
-  getMaxReadQuantity,
-  getMaxWriteMultipleRegisterQuantity
-} = require('./frame.js')
-const settingsService = require('../../store/settings-store.js')
-const transport = require('../../transport/ble-core.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 不支持读取')
-}
-
-function splitQuantity(startAddress, quantity, maxQuantity) {
-  const chunks = []
-  let address = Number(startAddress) || 0
-  let remaining = Math.max(0, Math.floor(Number(quantity) || 0))
-  const chunkLimit = Math.max(1, Math.floor(Number(maxQuantity) || remaining || 1))
-
-  while (remaining > 0) {
-    const chunkQuantity = Math.min(remaining, chunkLimit)
-    chunks.push({
-      address,
-      quantity: chunkQuantity
-    })
-    address += chunkQuantity
-    remaining -= chunkQuantity
-  }
-
-  return chunks
-}
-
-function getReadChunks(functionCode, startAddress, quantity, options = {}) {
-  const maxQuantity = getMaxReadQuantity(functionCode, options.maxFrameBytes)
-
-  return splitQuantity(startAddress, quantity, maxQuantity || quantity)
-}
-
-function getDebugReadChunks(startAddress, byteLength, options = {}) {
-  const maxByteLength = getMaxDebugReadByteLength(options.maxFrameBytes)
-
-  return splitQuantity(startAddress, byteLength, maxByteLength || byteLength)
-}
-
-function getDebugWriteChunks(startAddress, bytes, options = {}) {
-  const sourceBytes = Array.prototype.slice.call(bytes || [])
-  const maxByteLength = getMaxDebugWriteByteLength(options.maxFrameBytes)
-  const chunks = splitQuantity(startAddress, sourceBytes.length, maxByteLength || sourceBytes.length)
-  let offset = 0
-
-  return chunks.map((chunk) => {
-    const dataBytes = sourceBytes.slice(offset, offset + chunk.quantity)
-    offset += chunk.quantity
-
-    return {
-      ...chunk,
-      dataBytes
-    }
-  })
-}
-
-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,
-      quantity: chunk.quantity,
-      slaveAddress
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-async function sendDebugReadChunk(slaveAddress, memoryType, chunk, label, kind, options = {}) {
-  if (isBroadcastAddress(slaveAddress)) {
-    showBroadcastReadAlert(label)
-    return false
-  }
-
-  return transport.sendManagedFrame(
-    buildDebugReadMemoryFrame(slaveAddress, memoryType, chunk.address, chunk.quantity, {
-      maxFrameBytes: options.maxFrameBytes
-    }),
-    label,
-    {
-      address: chunk.address,
-      functionCode: 0x41,
-      kind,
-      memoryType,
-      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 response = await sendReadChunk(
-        slaveAddress,
-        functionCode,
-        chunk,
-        getChunkLabel(label, chunks, chunk),
-        kind,
-        options
-      )
-      if (!response) return null
-
-      if (functionCode === 0x01 || functionCode === 0x02) {
-        addCoilReadValues(readValues, chunk.address, chunk.quantity, response)
-      } else {
-        addWordReadValues(readValues, chunk.address, response)
-      }
-
-      if (typeof options.onChunk === 'function') {
-        options.onChunk(response, 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 response = await sendReadChunk(
-      slaveAddress,
-      functionCode,
-      chunk,
-      getChunkLabel(label, chunks, chunk),
-      kind,
-      options
-    )
-    if (!response) return null
-
-    const chunkWords = response.words || []
-    chunkWords.forEach((word, index) => {
-      words[chunk.address - startAddress + index] = Number(word) & 0xFFFF
-    })
-
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(response, chunk)
-    }
-  }
-
-  return words
-}
-
-async function readDebugMemory(slaveAddress, memoryType, startAddress, byteLength, label, kind = 'debug-memory-read', options = {}) {
-  const bytes = []
-  const chunks = getDebugReadChunks(startAddress, byteLength, options)
-
-  for (const chunk of chunks) {
-    const response = await sendDebugReadChunk(
-      slaveAddress,
-      memoryType,
-      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 queryCodeInfoBlock(slaveAddress, label = '查询Code信息块', kind = 'code-info-query', options = {}) {
-  if (isBroadcastAddress(slaveAddress)) {
-    showBroadcastReadAlert(label)
-    return null
-  }
-
-  const response = await transport.sendManagedFrame(
-    buildCodeInfoQueryFrame(slaveAddress),
-    label,
-    {
-      functionCode: 0x40,
-      kind,
-      quantity: 1,
-      slaveAddress
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-  if (!response) return null
-
-  return {
-    address: response.codeInfoAddress,
-    byteLength: response.codeInfoLength,
-    memoryType: response.memoryType
-  }
-}
-
-async function readCodeInfoBlock(slaveAddress, label = '读取Code信息块', kind = 'code-info-read', options = {}) {
-  const info = await queryCodeInfoBlock(slaveAddress, label, 'code-info-query', options)
-  if (!info) return null
-  if (info.memoryType !== 0x03) {
-    transport.showCommandAlert(label, '设备返回的信息块不在 code 区')
-    return null
-  }
-
-  const bytes = await readDebugMemory(
-    slaveAddress,
-    0x03,
-    info.address,
-    info.byteLength,
-    label,
-    kind,
-    options
-  )
-  if (!bytes) return null
-
-  return {
-    ...info,
-    bytes
-  }
-}
-
-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,
-      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,
-      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,
-      quantity: values.length,
-      slaveAddress
-    },
-    {
-      maxFrameBytes: options.maxFrameBytes,
-      showModal: options.showModal
-    }
-  )
-}
-
-async function writeDebugMemory(slaveAddress, memoryType, startAddress, bytes, label, kind = 'debug-memory-write', options = {}) {
-  const dataBytes = Array.prototype.slice.call(bytes || []).map((byte) => Number(byte) & 0xFF)
-  const chunks = getDebugWriteChunks(startAddress, dataBytes, options)
-
-  for (const chunk of chunks) {
-    const response = await transport.sendManagedFrame(
-      buildDebugWriteMemoryFrame(slaveAddress, memoryType, chunk.address, chunk.dataBytes, {
-        maxFrameBytes: options.maxFrameBytes
-      }),
-      getChunkLabel(label, chunks, chunk),
-      isBroadcastAddress(slaveAddress) ? null : {
-        address: chunk.address,
-        functionCode: 0x42,
-        kind,
-        memoryType,
-        quantity: chunk.quantity,
-        slaveAddress
-      },
-      {
-        maxFrameBytes: options.maxFrameBytes,
-        showModal: options.showModal
-      }
-    )
-    if (!response) return false
-
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(response, chunk)
-    }
-  }
-
-  return true
-}
-
-module.exports = {
-  getDebugReadChunks,
-  getDebugWriteChunks,
-  getReadChunks,
-  getSharedSlaveAddress,
-  getMaxReadQuantity,
-  queryCodeInfoBlock,
-  readBitValues,
-  readCodeInfoBlock,
-  readDebugMemory,
-  readRegisterWords,
-  readSingleHoldingWord,
-  readSpans,
-  splitQuantity,
-  getMaxWriteMultipleRegisterQuantity,
-  writeDebugMemory,
-  writeMultipleRegisters,
-  writeSingleCoil,
-  writeSingleRegister
-}

+ 0 - 264
protocols/modbus-rtu/frame.js

@@ -1,264 +0,0 @@
-const {
-  BYTE_ORDER_LOW,
-  appendCrc16Modbus,
-  hasValidCrc16Modbus
-} = require('../../utils/crc.js')
-
-const MODBUS_CRC_OPTIONS = {
-  byteOrder: BYTE_ORDER_LOW
-}
-const MAX_MODBUS_DMA_BYTES = 64
-const MODBUS_READ_RESPONSE_OVERHEAD = 5
-const MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD = 9
-const MODBUS_CODE_INFO_FUNCTION_CODE = 0x40
-const MODBUS_DEBUG_READ_FUNCTION_CODE = 0x41
-const MODBUS_DEBUG_WRITE_FUNCTION_CODE = 0x42
-const MODBUS_CODE_INFO_RESPONSE_LENGTH = 9
-const MODBUS_DEBUG_READ_RESPONSE_OVERHEAD = 6
-const MODBUS_DEBUG_WRITE_REQUEST_OVERHEAD = 9
-const MAX_READ_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) / 2)
-const MAX_READ_COIL_QUANTITY = (MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) * 8
-const MAX_WRITE_MULTIPLE_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2)
-const UNLIMITED_FRAME_BYTES = 0
-
-function toByte(value, label) {
-  if (!Number.isInteger(value) || value < 0 || value > 0xFF) {
-    throw new Error(`${label}必须在 0x00 至 0xFF 之间`)
-  }
-
-  return value
-}
-
-function toWord(value, label) {
-  if (!Number.isInteger(value) || value < 0 || value > 0xFFFF) {
-    throw new Error(`${label}必须在 0x0000 至 0xFFFF 之间`)
-  }
-
-  return value
-}
-
-function toMemoryType(value) {
-  const memoryType = toByte(value, '内存区域')
-  if (memoryType > 0x03) {
-    throw new Error('内存区域必须为 data/idata/xdata/code')
-  }
-
-  return memoryType
-}
-
-function toDebugByteLength(value, label = '字节长度') {
-  const byteLength = toWord(value, label)
-  if (byteLength === 0) {
-    throw new Error(`${label}必须大于 0`)
-  }
-  if (byteLength > 0xFF) {
-    throw new Error('单帧字节长度不能超过 255')
-  }
-
-  return byteLength
-}
-
-function splitWord(value) {
-  return [(value >> 8) & 0xFF, value & 0xFF]
-}
-
-function normalizeMaxFrameBytes(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const numberValue = Number(maxFrameBytes)
-  if (Number.isFinite(numberValue) && Math.round(numberValue) === UNLIMITED_FRAME_BYTES) return UNLIMITED_FRAME_BYTES
-  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
-
-  return MAX_MODBUS_DMA_BYTES
-}
-
-function getMaxReadQuantity(functionCode, maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
-  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
-
-  const dataBytes = frameBytes - MODBUS_READ_RESPONSE_OVERHEAD
-  if (dataBytes <= 0) return 0
-  if (functionCode === 0x01 || functionCode === 0x02) return dataBytes * 8
-  if (functionCode === 0x03 || functionCode === 0x04) return Math.floor(dataBytes / 2)
-
-  return 0
-}
-
-function getMaxWriteMultipleRegisterQuantity(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
-  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
-
-  return Math.max(0, Math.floor((frameBytes - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2))
-}
-
-function getMaxDebugReadByteLength(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
-  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFF
-
-  return Math.max(0, Math.min(0xFF, frameBytes - MODBUS_DEBUG_READ_RESPONSE_OVERHEAD))
-}
-
-function getMaxDebugWriteByteLength(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
-  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFF
-
-  return Math.max(0, Math.min(0xFF, frameBytes - MODBUS_DEBUG_WRITE_REQUEST_OVERHEAD))
-}
-
-function buildReadFrame(slaveAddress, functionCode, address, quantity, options = {}) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const command = toByte(functionCode, '功能码')
-  const startAddress = toWord(address, '寄存器地址')
-  const registerQuantity = toWord(quantity, '读取数量')
-  const maxQuantity = getMaxReadQuantity(command, options.maxFrameBytes)
-
-  if (![0x01, 0x02, 0x03, 0x04].includes(command)) {
-    throw new Error('当前功能码不是读取命令')
-  }
-  if (registerQuantity === 0) {
-    throw new Error('读取数量必须大于 0')
-  }
-  if ([0x03, 0x04].includes(command) && maxQuantity > 0 && registerQuantity > maxQuantity) {
-    throw new Error(`单帧最多读取 ${maxQuantity} 个寄存器`)
-  }
-  if ((command === 0x01 || command === 0x02) && maxQuantity > 0 && registerQuantity > maxQuantity) {
-    throw new Error(`单帧最多读取 ${maxQuantity} 个位状态`)
-  }
-
-  return appendCrc16Modbus(
-    [slave, command].concat(splitWord(startAddress), splitWord(registerQuantity)),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildWriteSingleCoilFrame(slaveAddress, address, checked) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const startAddress = toWord(address, '线圈地址')
-  const outputValue = checked ? 0xFF00 : 0x0000
-
-  return appendCrc16Modbus(
-    [slave, 0x05].concat(splitWord(startAddress), splitWord(outputValue)),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildWriteSingleRegisterFrame(slaveAddress, address, value) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const startAddress = toWord(address, '寄存器地址')
-  const registerValue = toWord(value, '写入值')
-
-  return appendCrc16Modbus(
-    [slave, 0x06].concat(splitWord(startAddress), splitWord(registerValue)),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildWriteMultipleRegistersFrame(slaveAddress, address, values, options = {}) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const startAddress = toWord(address, '寄存器地址')
-  const maxQuantity = getMaxWriteMultipleRegisterQuantity(options.maxFrameBytes)
-
-  if (!Array.isArray(values) || values.length === 0) {
-    throw new Error('请输入至少一个寄存器写入值')
-  }
-  if (maxQuantity > 0 && values.length > maxQuantity) {
-    throw new Error(`单帧最多写入 ${maxQuantity} 个寄存器`)
-  }
-
-  const registerBytes = values.reduce((result, value) => {
-    return result.concat(splitWord(toWord(value, '写入值')))
-  }, [])
-
-  return appendCrc16Modbus(
-    [slave, 0x10]
-      .concat(splitWord(startAddress), splitWord(values.length), [registerBytes.length], registerBytes),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildCodeInfoQueryFrame(slaveAddress) {
-  const slave = toByte(slaveAddress, '从站地址')
-
-  return appendCrc16Modbus(
-    [slave, MODBUS_CODE_INFO_FUNCTION_CODE],
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildDebugReadMemoryFrame(slaveAddress, memoryType, address, byteLength, options = {}) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const memType = toMemoryType(memoryType)
-  const startAddress = toWord(address, '内存地址')
-  const length = toDebugByteLength(byteLength)
-  const maxByteLength = getMaxDebugReadByteLength(options.maxFrameBytes)
-
-  if (maxByteLength > 0 && length > maxByteLength) {
-    throw new Error(`单帧最多读取 ${maxByteLength} 字节`)
-  }
-
-  return appendCrc16Modbus(
-    [slave, MODBUS_DEBUG_READ_FUNCTION_CODE, memType].concat(splitWord(startAddress), splitWord(length)),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function buildDebugWriteMemoryFrame(slaveAddress, memoryType, address, bytes, options = {}) {
-  const slave = toByte(slaveAddress, '从站地址')
-  const memType = toMemoryType(memoryType)
-  const startAddress = toWord(address, '内存地址')
-  const dataBytes = Array.prototype.slice.call(bytes || []).map((byte) => toByte(Number(byte), '写入字节'))
-  const length = toDebugByteLength(dataBytes.length)
-  const maxByteLength = getMaxDebugWriteByteLength(options.maxFrameBytes)
-
-  if (memType === 0x03) {
-    throw new Error('code 区暂不支持写入')
-  }
-  if (maxByteLength > 0 && length > maxByteLength) {
-    throw new Error(`单帧最多写入 ${maxByteLength} 字节`)
-  }
-
-  return appendCrc16Modbus(
-    [slave, MODBUS_DEBUG_WRITE_FUNCTION_CODE, memType]
-      .concat(splitWord(startAddress), splitWord(length), dataBytes),
-    MODBUS_CRC_OPTIONS
-  )
-}
-
-function formatHex(bytes) {
-  return bytes.map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join(' ')
-}
-
-function getReadResponseByteLength(functionCode, quantity) {
-  if (functionCode === 0x01 || functionCode === 0x02) return MODBUS_READ_RESPONSE_OVERHEAD + Math.ceil(Number(quantity || 0) / 8)
-  if (functionCode === 0x03 || functionCode === 0x04) return MODBUS_READ_RESPONSE_OVERHEAD + Number(quantity || 0) * 2
-  if (functionCode === MODBUS_CODE_INFO_FUNCTION_CODE) return MODBUS_CODE_INFO_RESPONSE_LENGTH
-  if (functionCode === MODBUS_DEBUG_READ_FUNCTION_CODE) return MODBUS_DEBUG_READ_RESPONSE_OVERHEAD + Number(quantity || 0)
-  if (functionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) return 9
-
-  return 0
-}
-
-module.exports = {
-  MAX_MODBUS_DMA_BYTES,
-  MAX_READ_COIL_QUANTITY,
-  MAX_READ_REGISTER_QUANTITY,
-  MAX_WRITE_MULTIPLE_REGISTER_QUANTITY,
-  MODBUS_CRC_OPTIONS,
-  MODBUS_CODE_INFO_FUNCTION_CODE,
-  MODBUS_CODE_INFO_RESPONSE_LENGTH,
-  MODBUS_DEBUG_READ_FUNCTION_CODE,
-  MODBUS_DEBUG_WRITE_FUNCTION_CODE,
-  UNLIMITED_FRAME_BYTES,
-  buildCodeInfoQueryFrame,
-  buildDebugReadMemoryFrame,
-  buildDebugWriteMemoryFrame,
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  formatHex,
-  getMaxDebugReadByteLength,
-  getMaxDebugWriteByteLength,
-  getMaxWriteMultipleRegisterQuantity,
-  getMaxReadQuantity,
-  getReadResponseByteLength,
-  hasValidCrc16Modbus
-}

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

@@ -1,5 +1,704 @@
+const {
+  padHex
+} = require('../../utils/base-utils.js')
+const {
+  bytesToWords
+} = require('../../utils/binary-utils.js')
+const {
+  BYTE_ORDER_LOW,
+  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 = {
+  byteOrder: BYTE_ORDER_LOW
+}
+const MAX_MODBUS_DMA_BYTES = 64
+const MODBUS_READ_RESPONSE_OVERHEAD = 5
+const MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD = 9
+const MAX_READ_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) / 2)
+const MAX_READ_COIL_QUANTITY = (MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) * 8
+const MAX_WRITE_MULTIPLE_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2)
+const UNLIMITED_FRAME_BYTES = 0
+
+const MODBUS_EXCEPTION_MESSAGES = {
+  0x01: '非法功能',
+  0x02: '非法数据地址',
+  0x03: '非法数据值',
+  0x04: '从站设备故障',
+  0x05: '确认',
+  0x06: '从站设备忙',
+  0x08: '存储奇偶性错误',
+  0x0A: '网关路径不可用',
+  0x0B: '网关目标设备响应失败'
+}
+
+function toByte(value, label) {
+  if (!Number.isInteger(value) || value < 0 || value > 0xFF) {
+    throw new Error(`${label}必须在 0x00 至 0xFF 之间`)
+  }
+
+  return value
+}
+
+function toWord(value, label) {
+  if (!Number.isInteger(value) || value < 0 || value > 0xFFFF) {
+    throw new Error(`${label}必须在 0x0000 至 0xFFFF 之间`)
+  }
+
+  return value
+}
+
+function splitWord(value) {
+  return [(value >> 8) & 0xFF, value & 0xFF]
+}
+
+function normalizeMaxFrameBytes(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
+  const numberValue = Number(maxFrameBytes)
+  if (Number.isFinite(numberValue) && Math.round(numberValue) === UNLIMITED_FRAME_BYTES) return UNLIMITED_FRAME_BYTES
+  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
+
+  return MAX_MODBUS_DMA_BYTES
+}
+
+function getMaxReadQuantity(functionCode, maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
+  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
+  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
+
+  const dataBytes = frameBytes - MODBUS_READ_RESPONSE_OVERHEAD
+  if (dataBytes <= 0) return 0
+  if (functionCode === 0x01 || functionCode === 0x02) return dataBytes * 8
+  if (functionCode === 0x03 || functionCode === 0x04) return Math.floor(dataBytes / 2)
+
+  return 0
+}
+
+function getMaxWriteMultipleRegisterQuantity(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
+  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
+  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
+
+  return Math.max(0, Math.floor((frameBytes - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2))
+}
+
+function buildReadFrame(slaveAddress, functionCode, address, quantity, options = {}) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const command = toByte(functionCode, '功能码')
+  const startAddress = toWord(address, '寄存器地址')
+  const registerQuantity = toWord(quantity, '读取数量')
+  const maxQuantity = getMaxReadQuantity(command, options.maxFrameBytes)
+
+  if ([0x01, 0x02, 0x03, 0x04].indexOf(command) < 0) {
+    throw new Error('当前功能码不是读取命令')
+  }
+  if (registerQuantity === 0) {
+    throw new Error('读取数量必须大于 0')
+  }
+  if ([0x03, 0x04].indexOf(command) >= 0 && maxQuantity > 0 && registerQuantity > maxQuantity) {
+    throw new Error(`单帧最多读取 ${maxQuantity} 个寄存器`)
+  }
+  if ((command === 0x01 || command === 0x02) && maxQuantity > 0 && registerQuantity > maxQuantity) {
+    throw new Error(`单帧最多读取 ${maxQuantity} 个位状态`)
+  }
+
+  return appendCrc16Modbus(
+    [slave, command].concat(splitWord(startAddress), splitWord(registerQuantity)),
+    MODBUS_CRC_OPTIONS
+  )
+}
+
+function buildWriteSingleCoilFrame(slaveAddress, address, checked) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '线圈地址')
+  const outputValue = checked ? 0xFF00 : 0x0000
+
+  return appendCrc16Modbus(
+    [slave, 0x05].concat(splitWord(startAddress), splitWord(outputValue)),
+    MODBUS_CRC_OPTIONS
+  )
+}
+
+function buildWriteSingleRegisterFrame(slaveAddress, address, value) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '寄存器地址')
+  const registerValue = toWord(value, '写入值')
+
+  return appendCrc16Modbus(
+    [slave, 0x06].concat(splitWord(startAddress), splitWord(registerValue)),
+    MODBUS_CRC_OPTIONS
+  )
+}
+
+function buildWriteMultipleRegistersFrame(slaveAddress, address, values, options = {}) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '寄存器地址')
+  const maxQuantity = getMaxWriteMultipleRegisterQuantity(options.maxFrameBytes)
+
+  if (!Array.isArray(values) || values.length === 0) {
+    throw new Error('请输入至少一个寄存器写入值')
+  }
+  if (maxQuantity > 0 && values.length > maxQuantity) {
+    throw new Error(`单帧最多写入 ${maxQuantity} 个寄存器`)
+  }
+
+  const registerBytes = values.reduce((result, value) => {
+    return result.concat(splitWord(toWord(value, '写入值')))
+  }, [])
+
+  return appendCrc16Modbus(
+    [slave, 0x10]
+      .concat(splitWord(startAddress), splitWord(values.length), [registerBytes.length], registerBytes),
+    MODBUS_CRC_OPTIONS
+  )
+}
+
+function formatHex(bytes) {
+  return bytes.map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join(' ')
+}
+
+function getReadResponseByteLength(functionCode, quantity) {
+  if (functionCode === 0x01 || functionCode === 0x02) return MODBUS_READ_RESPONSE_OVERHEAD + Math.ceil(Number(quantity || 0) / 8)
+  if (functionCode === 0x03 || functionCode === 0x04) return MODBUS_READ_RESPONSE_OVERHEAD + Number(quantity || 0) * 2
+
+  return 0
+}
+
+function parseModbusResponse(bytes) {
+  if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
+
+  const slaveAddress = bytes[0]
+  const functionCode = bytes[1]
+
+  if (functionCode & 0x80) {
+    return {
+      exceptionCode: bytes[2],
+      functionCode,
+      isException: true,
+      protocol: PROTOCOL_NAME,
+      slaveAddress,
+      sourceFunctionCode: functionCode & 0x7F
+    }
+  }
+
+  if (functionCode === 0x01 || functionCode === 0x02) {
+    const byteCount = bytes[2]
+    const dataEnd = 3 + byteCount
+    if (bytes.length < dataEnd + 2) return null
+
+    return {
+      byteCount,
+      dataBytes: bytes.slice(3, dataEnd),
+      functionCode,
+      isException: false,
+      protocol: PROTOCOL_NAME,
+      slaveAddress
+    }
+  }
+
+  if (functionCode === 0x03 || functionCode === 0x04) {
+    const byteCount = bytes[2]
+    const dataEnd = 3 + byteCount
+    if (bytes.length < dataEnd + 2) return null
+
+    return {
+      byteCount,
+      dataBytes: bytes.slice(3, dataEnd),
+      functionCode,
+      isException: false,
+      protocol: PROTOCOL_NAME,
+      slaveAddress,
+      words: bytesToWords(bytes.slice(3, dataEnd))
+    }
+  }
+
+  if (functionCode === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
+    return {
+      address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
+      functionCode,
+      isException: false,
+      protocol: PROTOCOL_NAME,
+      quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
+      slaveAddress
+    }
+  }
+
+  return {
+    functionCode,
+    isException: false,
+    protocol: PROTOCOL_NAME,
+    slaveAddress
+  }
+}
+
+function parseModbusRequest(bytes) {
+  if (!Array.isArray(bytes) || bytes.length < 4 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
+
+  const slaveAddress = bytes[0]
+  const functionCode = bytes[1]
+  if (bytes.length < 6) return null
+
+  const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
+  let quantity = 1
+  let value
+
+  if (functionCode === 0x01 || functionCode === 0x02 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
+    quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+  if (functionCode === 0x05 || functionCode === 0x06) {
+    value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+
+  return {
+    address,
+    functionCode,
+    kind: 'raw-hex',
+    protocol: PROTOCOL_NAME,
+    quantity,
+    value,
+    slaveAddress
+  }
+}
+
+function getExpectedResponseLength(expected, responseFunctionCode, responseBytes = []) {
+  if (!expected) return 0
+
+  if (responseFunctionCode === (expected.functionCode | 0x80)) {
+    return 5
+  }
+
+  if (responseFunctionCode === 0x01 || responseFunctionCode === 0x02) {
+    if (responseBytes.length < 3) return 0
+
+    return 3 + Number(responseBytes[2] || 0) + 2
+  }
+
+  if (responseFunctionCode === 0x03 || responseFunctionCode === 0x04) {
+    if (responseBytes.length < 3) return 0
+
+    return 3 + Number(responseBytes[2] || 0) + 2
+  }
+
+  if (responseFunctionCode === 0x05 || responseFunctionCode === 0x06 || responseFunctionCode === 0x10) {
+    return 8
+  }
+
+  return 0
+}
+
+function isExpectedResponse(response, expected) {
+  if (response.functionCode === 0x01 || response.functionCode === 0x02) {
+    return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
+  }
+
+  if (response.functionCode === 0x03 || response.functionCode === 0x04) {
+    return Array.isArray(response.words) && response.words.length >= expected.quantity
+  }
+
+  if (response.functionCode === 0x10) {
+    return response.address === expected.address && response.quantityOrValue === expected.quantity
+  }
+
+  if (response.functionCode === 0x05 || response.functionCode === 0x06) {
+    if (response.address !== expected.address) return false
+    if (Number.isInteger(expected.value)) return response.quantityOrValue === expected.value
+
+    return true
+  }
+
+  return true
+}
+
+function getExceptionText(code) {
+  return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
+}
+
+function formatExceptionMessage(response) {
+  const sourceFunctionCode = response && response.sourceFunctionCode
+  const exceptionCode = response && response.exceptionCode
+  const exceptionText = getExceptionText(exceptionCode)
+
+  return `设备返回异常帧:功能码 0x${padHex(sourceFunctionCode, 2)},异常码 0x${padHex(exceptionCode, 2)}(${exceptionText})`
+}
+
+function getReadBufferHint(expected) {
+  return expected ? getReadResponseByteLength(expected.functionCode, expected.quantity) : 0
+}
+
+function alignResponseBuffer(buffer, expected) {
+  if (!Array.isArray(buffer) || !buffer.length || !expected) return
+
+  const expectedFunctionCodes = [expected.functionCode, expected.functionCode | 0x80]
+  let matchIndex = -1
+
+  for (let index = 0; index < buffer.length - 1; index += 1) {
+    if (buffer[index] !== expected.slaveAddress) continue
+    if (expectedFunctionCodes.indexOf(buffer[index + 1]) < 0) continue
+
+    matchIndex = index
+    break
+  }
+
+  if (matchIndex > 0) {
+    buffer.splice(0, matchIndex)
+  } else if (matchIndex < 0 && buffer.length > 2) {
+    buffer.splice(0, buffer.length - 1)
+  }
+}
+
+function readResponseFromBuffer(buffer, expected, options = {}) {
+  if (!Array.isArray(buffer) || !buffer.length || !expected) {
+    return {
+      status: 'pending'
+    }
+  }
+
+  alignResponseBuffer(buffer, expected)
+
+  while (buffer.length >= 2) {
+    const responseFunctionCode = buffer[1]
+    const responseLength = getExpectedResponseLength(expected, responseFunctionCode, buffer)
+
+    if (!responseLength) {
+      return {
+        status: 'pending'
+      }
+    }
+
+    const frameLimit = normalizeMaxFrameBytes(
+      options.maxFrameBytes === undefined ? expected.maxFrameBytes : options.maxFrameBytes
+    )
+    if (frameLimit > 0 && responseLength > frameLimit) {
+      return {
+        frameLimit,
+        responseLength,
+        status: 'frame-too-long'
+      }
+    }
+
+    if (buffer.length < responseLength) {
+      return {
+        status: 'pending'
+      }
+    }
+
+    const frameBytes = buffer.slice(0, responseLength)
+    const response = parseModbusResponse(frameBytes)
+    if (!response) {
+      return {
+        frameBytes,
+        status: 'invalid'
+      }
+    }
+
+    const responseCode = response.isException ? response.sourceFunctionCode : response.functionCode
+    if (response.slaveAddress !== expected.slaveAddress || responseCode !== expected.functionCode) {
+      buffer.shift()
+      alignResponseBuffer(buffer, expected)
+      continue
+    }
+
+    if (response.isException) {
+      return {
+        message: formatExceptionMessage(response),
+        response,
+        status: 'exception'
+      }
+    }
+
+    if (!isExpectedResponse(response, expected)) {
+      return {
+        response,
+        status: 'mismatch'
+      }
+    }
+
+    buffer.splice(0, responseLength)
+    return {
+      response,
+      status: 'complete'
+    }
+  }
+
+  return {
+    status: 'pending'
+  }
+}
+
+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
+  let remaining = Math.max(0, Math.floor(Number(quantity) || 0))
+  const chunkLimit = Math.max(1, Math.floor(Number(maxQuantity) || remaining || 1))
+
+  while (remaining > 0) {
+    const chunkQuantity = Math.min(remaining, chunkLimit)
+    chunks.push({
+      address,
+      quantity: chunkQuantity
+    })
+    address += chunkQuantity
+    remaining -= chunkQuantity
+  }
+
+  return chunks
+}
+
+function getReadChunks(functionCode, startAddress, quantity, options = {}) {
+  const maxQuantity = getMaxReadQuantity(functionCode, options.maxFrameBytes)
+
+  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,
+  getExceptionText,
+  getExpectedResponseLength,
+  getReadBufferHint,
+  isExpectedResponse,
+  parseModbusRequest,
+  parseModbusResponse,
+  readResponseFromBuffer
+}
+
+const client = {
+  getReadChunks,
+  getSharedSlaveAddress,
+  getMaxReadQuantity,
+  readBitValues,
+  readRegisterWords,
+  readSingleHoldingWord,
+  readSpans,
+  splitQuantity,
+  getMaxWriteMultipleRegisterQuantity,
+  writeMultipleRegisters,
+  writeSingleCoil,
+  writeSingleRegister
+}
+
 module.exports = {
-  ...require('./frame.js'),
-  response: require('./response.js'),
-  client: require('./client.js')
+  MAX_MODBUS_DMA_BYTES,
+  MAX_READ_COIL_QUANTITY,
+  MAX_READ_REGISTER_QUANTITY,
+  MAX_WRITE_MULTIPLE_REGISTER_QUANTITY,
+  MODBUS_CRC_OPTIONS,
+  MODBUS_EXCEPTION_MESSAGES,
+  PROTOCOL_NAME,
+  UNLIMITED_FRAME_BYTES,
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  buildWriteSingleRegisterFrame,
+  client,
+  formatHex,
+  getMaxReadQuantity,
+  getMaxWriteMultipleRegisterQuantity,
+  getReadResponseByteLength,
+  hasValidCrc16Modbus,
+  response
 }

+ 0 - 413
protocols/modbus-rtu/response.js

@@ -1,413 +0,0 @@
-const {
-  MAX_MODBUS_DMA_BYTES,
-  MODBUS_CODE_INFO_FUNCTION_CODE,
-  MODBUS_CODE_INFO_RESPONSE_LENGTH,
-  MODBUS_CRC_OPTIONS,
-  MODBUS_DEBUG_READ_FUNCTION_CODE,
-  MODBUS_DEBUG_WRITE_FUNCTION_CODE,
-  getReadResponseByteLength,
-  hasValidCrc16Modbus
-} = require('./frame.js')
-const {
-  padHex
-} = require('../../utils/base-utils.js')
-const {
-  bytesToWords
-} = require('../../utils/binary-utils.js')
-
-const MODBUS_EXCEPTION_MESSAGES = {
-  0x01: '非法功能',
-  0x02: '非法数据地址',
-  0x03: '非法数据值',
-  0x04: '从站设备故障',
-  0x05: '确认',
-  0x06: '从站设备忙',
-  0x08: '存储奇偶性错误',
-  0x0A: '网关路径不可用',
-  0x0B: '网关目标设备响应失败'
-}
-const UNLIMITED_FRAME_BYTES = 0
-
-function normalizeMaxFrameBytes(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
-  const numberValue = Number(maxFrameBytes)
-  if (Number.isFinite(numberValue) && Math.round(numberValue) === UNLIMITED_FRAME_BYTES) return UNLIMITED_FRAME_BYTES
-  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
-
-  return MAX_MODBUS_DMA_BYTES
-}
-
-function parseModbusResponse(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
-
-  const slaveAddress = bytes[0]
-  const functionCode = bytes[1]
-
-  if (functionCode & 0x80) {
-    return {
-      exceptionCode: bytes[2],
-      functionCode,
-      isException: true,
-      slaveAddress,
-      sourceFunctionCode: functionCode & 0x7F
-    }
-  }
-
-  if (functionCode === 0x01 || functionCode === 0x02) {
-    const byteCount = bytes[2]
-    const dataEnd = 3 + byteCount
-    if (bytes.length < dataEnd + 2) return null
-
-    return {
-      byteCount,
-      dataBytes: bytes.slice(3, dataEnd),
-      functionCode,
-      isException: false,
-      slaveAddress
-    }
-  }
-
-  if (functionCode === 0x03 || functionCode === 0x04) {
-    const byteCount = bytes[2]
-    const dataEnd = 3 + byteCount
-    if (bytes.length < dataEnd + 2) return null
-
-    return {
-      byteCount,
-      dataBytes: bytes.slice(3, dataEnd),
-      functionCode,
-      isException: false,
-      slaveAddress,
-      words: bytesToWords(bytes.slice(3, dataEnd))
-    }
-  }
-
-  if (functionCode === MODBUS_CODE_INFO_FUNCTION_CODE) {
-    if (bytes.length < MODBUS_CODE_INFO_RESPONSE_LENGTH) return null
-
-    return {
-      address: ((bytes[3] << 8) | bytes[4]) & 0xFFFF,
-      byteLength: ((bytes[5] << 8) | bytes[6]) & 0xFFFF,
-      codeInfoAddress: ((bytes[3] << 8) | bytes[4]) & 0xFFFF,
-      codeInfoLength: ((bytes[5] << 8) | bytes[6]) & 0xFFFF,
-      functionCode,
-      isException: false,
-      memoryType: bytes[2],
-      slaveAddress
-    }
-  }
-
-  if (functionCode === MODBUS_DEBUG_READ_FUNCTION_CODE) {
-    const memoryType = bytes[2]
-    const byteCount = bytes[3]
-    const dataEnd = 4 + byteCount
-    if (bytes.length < dataEnd + 2) return null
-
-    return {
-      byteCount,
-      dataBytes: bytes.slice(4, dataEnd),
-      functionCode,
-      isException: false,
-      memoryType,
-      slaveAddress,
-      words: bytesToWords(bytes.slice(4, dataEnd).concat(byteCount % 2 ? [0] : []))
-    }
-  }
-
-  if (functionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) {
-    if (bytes.length < 9) return null
-
-    return {
-      address: ((bytes[3] << 8) | bytes[4]) & 0xFFFF,
-      byteLength: ((bytes[5] << 8) | bytes[6]) & 0xFFFF,
-      functionCode,
-      isException: false,
-      memoryType: bytes[2],
-      quantity: ((bytes[5] << 8) | bytes[6]) & 0xFFFF,
-      slaveAddress
-    }
-  }
-
-  if (functionCode === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
-    return {
-      address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
-      functionCode,
-      isException: false,
-      quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
-      slaveAddress
-    }
-  }
-
-  return {
-    functionCode,
-    isException: false,
-    slaveAddress
-  }
-}
-
-function parseModbusRequest(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 4 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
-
-  const slaveAddress = bytes[0]
-  const functionCode = bytes[1]
-  if (functionCode === MODBUS_CODE_INFO_FUNCTION_CODE) {
-    return {
-      functionCode,
-      kind: 'raw-hex',
-      quantity: 1,
-      slaveAddress
-    }
-  }
-
-  if (bytes.length < 6) return null
-
-  if (functionCode === MODBUS_DEBUG_READ_FUNCTION_CODE || functionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) {
-    if (bytes.length < 9) return null
-
-    const memoryType = bytes[2]
-    const address = ((bytes[3] << 8) | bytes[4]) & 0xFFFF
-    const quantity = ((bytes[5] << 8) | bytes[6]) & 0xFFFF
-    const request = {
-      address,
-      functionCode,
-      kind: 'raw-hex',
-      memoryType,
-      quantity,
-      slaveAddress
-    }
-
-    if (functionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) {
-      if (bytes.length < 7 + quantity + 2) return null
-      request.dataBytes = bytes.slice(7, 7 + quantity)
-    }
-
-    return request
-  }
-
-  const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
-  let quantity = 1
-  let value
-
-  if (functionCode === 0x01 || functionCode === 0x02 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
-    quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
-  }
-  if (functionCode === 0x05 || functionCode === 0x06) {
-    value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
-  }
-
-  return {
-    address,
-    functionCode,
-    kind: 'raw-hex',
-    quantity,
-    value,
-    slaveAddress
-  }
-}
-
-function getExpectedResponseLength(expected, responseFunctionCode, responseBytes = []) {
-  if (!expected) return 0
-
-  if (responseFunctionCode === (expected.functionCode | 0x80)) {
-    return 5
-  }
-
-  if (responseFunctionCode === 0x01 || responseFunctionCode === 0x02) {
-    if (responseBytes.length < 3) return 0
-
-    return 3 + Number(responseBytes[2] || 0) + 2
-  }
-
-  if (responseFunctionCode === 0x03 || responseFunctionCode === 0x04) {
-    if (responseBytes.length < 3) return 0
-
-    return 3 + Number(responseBytes[2] || 0) + 2
-  }
-
-  if (responseFunctionCode === MODBUS_CODE_INFO_FUNCTION_CODE) {
-    return MODBUS_CODE_INFO_RESPONSE_LENGTH
-  }
-
-  if (responseFunctionCode === MODBUS_DEBUG_READ_FUNCTION_CODE) {
-    if (responseBytes.length < 4) return 0
-
-    return 4 + Number(responseBytes[3] || 0) + 2
-  }
-
-  if (responseFunctionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) {
-    return 9
-  }
-
-  if (responseFunctionCode === 0x05 || responseFunctionCode === 0x06 || responseFunctionCode === 0x10) {
-    return 8
-  }
-
-  return 0
-}
-
-function isExpectedResponse(response, expected) {
-  if (response.functionCode === 0x01 || response.functionCode === 0x02) {
-    return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
-  }
-
-  if (response.functionCode === 0x03 || response.functionCode === 0x04) {
-    return Array.isArray(response.words) && response.words.length >= expected.quantity
-  }
-
-  if (response.functionCode === MODBUS_CODE_INFO_FUNCTION_CODE) {
-    return response.memoryType === 0x03
-      && response.codeInfoLength > 0
-  }
-
-  if (response.functionCode === MODBUS_DEBUG_READ_FUNCTION_CODE) {
-    return response.memoryType === expected.memoryType
-      && Array.isArray(response.dataBytes)
-      && response.dataBytes.length === expected.quantity
-  }
-
-  if (response.functionCode === MODBUS_DEBUG_WRITE_FUNCTION_CODE) {
-    return response.memoryType === expected.memoryType
-      && response.address === expected.address
-      && response.byteLength === expected.quantity
-  }
-
-  if (response.functionCode === 0x10) {
-    return response.address === expected.address && response.quantityOrValue === expected.quantity
-  }
-
-  if (response.functionCode === 0x05 || response.functionCode === 0x06) {
-    if (response.address !== expected.address) return false
-    if (Number.isInteger(expected.value)) return response.quantityOrValue === expected.value
-
-    return true
-  }
-
-  return true
-}
-
-function getExceptionText(code) {
-  return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
-}
-
-function formatExceptionMessage(response) {
-  const sourceFunctionCode = response && response.sourceFunctionCode
-  const exceptionCode = response && response.exceptionCode
-  const exceptionText = getExceptionText(exceptionCode)
-
-  return `设备返回异常帧:功能码 0x${padHex(sourceFunctionCode, 2)},异常码 0x${padHex(exceptionCode, 2)}(${exceptionText})`
-}
-
-function getReadBufferHint(expected) {
-  return expected ? getReadResponseByteLength(expected.functionCode, expected.quantity) : 0
-}
-
-function alignResponseBuffer(buffer, expected) {
-  if (!Array.isArray(buffer) || !buffer.length || !expected) return
-
-  const expectedFunctionCodes = [expected.functionCode, expected.functionCode | 0x80]
-  let matchIndex = -1
-
-  for (let index = 0; index < buffer.length - 1; index += 1) {
-    if (buffer[index] !== expected.slaveAddress) continue
-    if (!expectedFunctionCodes.includes(buffer[index + 1])) continue
-
-    matchIndex = index
-    break
-  }
-
-  if (matchIndex > 0) {
-    buffer.splice(0, matchIndex)
-  } else if (matchIndex < 0 && buffer.length > 2) {
-    buffer.splice(0, buffer.length - 1)
-  }
-}
-
-function readResponseFromBuffer(buffer, expected, options = {}) {
-  if (!Array.isArray(buffer) || !buffer.length || !expected) {
-    return {
-      status: 'pending'
-    }
-  }
-
-  alignResponseBuffer(buffer, expected)
-
-  while (buffer.length >= 2) {
-    const responseFunctionCode = buffer[1]
-    const responseLength = getExpectedResponseLength(expected, responseFunctionCode, buffer)
-
-    if (!responseLength) {
-      return {
-        status: 'pending'
-      }
-    }
-
-    const frameLimit = normalizeMaxFrameBytes(
-      options.maxFrameBytes === undefined ? expected.maxFrameBytes : options.maxFrameBytes
-    )
-    if (frameLimit > 0 && responseLength > frameLimit) {
-      return {
-        frameLimit,
-        responseLength,
-        status: 'frame-too-long'
-      }
-    }
-
-    if (buffer.length < responseLength) {
-      return {
-        status: 'pending'
-      }
-    }
-
-    const frameBytes = buffer.slice(0, responseLength)
-    const response = parseModbusResponse(frameBytes)
-    if (!response) {
-      return {
-        frameBytes,
-        status: 'invalid'
-      }
-    }
-
-    const responseCode = response.isException ? response.sourceFunctionCode : response.functionCode
-    if (response.slaveAddress !== expected.slaveAddress || responseCode !== expected.functionCode) {
-      buffer.shift()
-      alignResponseBuffer(buffer, expected)
-      continue
-    }
-
-    if (response.isException) {
-      return {
-        message: formatExceptionMessage(response),
-        response,
-        status: 'exception'
-      }
-    }
-
-    if (!isExpectedResponse(response, expected)) {
-      return {
-        response,
-        status: 'mismatch'
-      }
-    }
-
-    buffer.splice(0, responseLength)
-    return {
-      response,
-      status: 'complete'
-    }
-  }
-
-  return {
-    status: 'pending'
-  }
-}
-
-module.exports = {
-  MODBUS_EXCEPTION_MESSAGES,
-  formatExceptionMessage,
-  getExceptionText,
-  getExpectedResponseLength,
-  getReadBufferHint,
-  isExpectedResponse,
-  parseModbusRequest,
-  parseModbusResponse,
-  readResponseFromBuffer
-}

+ 733 - 0
protocols/storage-access/index.js

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

+ 43 - 15
protocols/transport-helpers.js

@@ -1,16 +1,9 @@
-const {
-  MODBUS_CRC_OPTIONS,
-  hasValidCrc16Modbus
-} = require('./modbus-rtu/frame.js')
-const {
-  getReadBufferHint,
-  parseModbusRequest,
-  readResponseFromBuffer
-} = require('./modbus-rtu/response.js')
+const modbusProtocol = require('./modbus-rtu/index.js')
+const storageAccessProtocol = require('./storage-access/index.js')
 const {
   getBootloaderResponseLength,
   isBootloaderFrame
-} = require('./bootloader/frame.js')
+} = require('./bootloader/index.js')
 const {
   BYTE_ORDER_HIGH,
   hasValidCrc16Ccitt
@@ -26,20 +19,55 @@ function inspectReceivedBytes(rawBytes, context = {}) {
     return hasValidCrc16Ccitt(rawBytes, { byteOrder: BYTE_ORDER_HIGH }) ? 'CRC OK' : 'CRC ERR'
   }
 
-  return hasValidCrc16Modbus(rawBytes, MODBUS_CRC_OPTIONS) ? 'CRC OK' : (context.pendingRequest ? '片段' : 'CRC ERR')
+  const pendingProtocol = context.pendingRequest && context.pendingRequest.protocol
+  if (pendingProtocol === 'storage-access') {
+    return storageAccessProtocol.hasValidStorageCrc(rawBytes) ? 'CRC OK' : '片段'
+  }
+  if (pendingProtocol === 'modbus-rtu') {
+    return modbusProtocol.hasValidCrc16Modbus(rawBytes, modbusProtocol.MODBUS_CRC_OPTIONS) ? 'CRC OK' : '片段'
+  }
+
+  if (storageAccessProtocol.hasValidStorageCrc(rawBytes)) return 'CRC OK'
+
+  return modbusProtocol.hasValidCrc16Modbus(rawBytes, modbusProtocol.MODBUS_CRC_OPTIONS) ? 'CRC OK' : (context.pendingRequest ? '片段' : 'CRC ERR')
 }
 
 function parseSendExpected(bytes) {
-  const expected = parseModbusRequest(bytes)
+  const storageExpected = storageAccessProtocol.response.parseStorageAccessRequest(bytes)
+  if (storageExpected) return storageExpected
+
+  const expected = modbusProtocol.response.parseModbusRequest(bytes)
   if (!expected) return expected
-  if (expected.slaveAddress === 0x00 && [0x05, 0x06, 0x10, 0x42].includes(expected.functionCode)) return null
-  if (expected.slaveAddress === 0x00 && [0x01, 0x02, 0x03, 0x04, 0x40, 0x41].includes(expected.functionCode)) return null
+  if (expected.slaveAddress === 0x00 && [0x05, 0x06, 0x10].includes(expected.functionCode)) return null
+  if (expected.slaveAddress === 0x00 && [0x01, 0x02, 0x03, 0x04].includes(expected.functionCode)) return null
 
   return expected
 }
 
+function getResponseBufferHint(expected) {
+  if (expected && expected.protocol === 'storage-access') {
+    return storageAccessProtocol.response.getReadBufferHint(expected)
+  }
+  if (expected && expected.protocol === 'modbus-rtu') {
+    return modbusProtocol.response.getReadBufferHint(expected)
+  }
+
+  return modbusProtocol.response.getReadBufferHint(expected)
+}
+
+function readResponseFromBuffer(buffer, expected, options = {}) {
+  if (expected && expected.protocol === 'storage-access') {
+    return storageAccessProtocol.response.readResponseFromBuffer(buffer, expected, options)
+  }
+  if (expected && expected.protocol === 'modbus-rtu') {
+    return modbusProtocol.response.readResponseFromBuffer(buffer, expected, options)
+  }
+
+  return modbusProtocol.response.readResponseFromBuffer(buffer, expected, options)
+}
+
 module.exports = {
-  getResponseBufferHint: getReadBufferHint,
+  getResponseBufferHint,
   inspectReceivedBytes,
   parseSendExpected,
   readResponseFromBuffer

+ 94 - 47
store/settings-store.js

@@ -9,22 +9,30 @@ const {
 } = require('../utils/platform-utils.js')
 
 const STORAGE_KEY = 'app-settings'
-const MODBUS_PROTOCOL_OPTIONS = [
-  { key: 'private', label: '私有协议' },
-  { key: 'generic', label: '通用协议' }
+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 = {
-  genericModbusAutoPollEnabled: false,
-  genericModbusMaxPacketLength: 64,
-  genericModbusPollInterval: 100,
-  modbusProtocolMode: 'private',
   modbusSlaveAddress: 'F0',
   nightModeEnabled: false,
-  nightModeFollowSystem: true
+  nightModeFollowSystem: true,
+  parameterAutoPollEnabled: false,
+  parameterMaxPacketLength: 64,
+  parameterPollInterval: 100,
+  protocolMode: PROTOCOL_MODE.STORAGE_ACCESS
 }
 const STATUS_POLL_MIN_INTERVAL = 100
 const STATUS_POLL_MAX_INTERVAL = 3000
-const GENERIC_MODBUS_MIN_PACKET_LENGTH = 32
+const PARAMETER_MIN_PACKET_LENGTH = 32
 
 const state = {
   ...DEFAULT_SETTINGS
@@ -43,21 +51,33 @@ function normalizeHexByte(value, fallback = DEFAULT_SETTINGS.modbusSlaveAddress)
   return parseInt(hexText, 16).toString(16).toUpperCase().padStart(2, '0')
 }
 
-function normalizeGenericPacketLength(value, fallback = DEFAULT_SETTINGS.genericModbusMaxPacketLength) {
+function normalizeParameterPacketLength(value, fallback = DEFAULT_SETTINGS.parameterMaxPacketLength) {
   const numberValue = toFiniteNumber(value, NaN)
   if (!Number.isFinite(numberValue)) return fallback
 
   const rounded = Math.round(numberValue)
   if (rounded <= 0) return 0
 
-  return Math.max(rounded, GENERIC_MODBUS_MIN_PACKET_LENGTH)
+  return Math.max(rounded, PARAMETER_MIN_PACKET_LENGTH)
 }
 
-function normalizeModbusProtocolMode(value) {
+function normalizeProtocolMode(value) {
   const key = String(value || '').trim()
-  const matched = MODBUS_PROTOCOL_OPTIONS.find((option) => option.key === key)
+  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 matched ? matched.key : DEFAULT_SETTINGS.modbusProtocolMode
+function isStorageAccessProtocol(value = state.protocolMode) {
+  return normalizeProtocolMode(value) === PROTOCOL_MODE.STORAGE_ACCESS
 }
 
 function parseHexByte(value, label = '从机地址') {
@@ -71,23 +91,50 @@ function parseHexByte(value, label = '从机地址') {
   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 normalizeSettings(settings = {}) {
+  const migratedSettings = migrateLegacySettings(settings)
+  const protocolMode = normalizeProtocolMode(migratedSettings.protocolMode)
+  const parameterAutoPollEnabled = !!migratedSettings.parameterAutoPollEnabled
+  const parameterMaxPacketLength = normalizeParameterPacketLength(
+    migratedSettings.parameterMaxPacketLength,
+    DEFAULT_SETTINGS.parameterMaxPacketLength
+  )
+  const parameterPollInterval = clampInteger(
+    migratedSettings.parameterPollInterval,
+    STATUS_POLL_MIN_INTERVAL,
+    STATUS_POLL_MAX_INTERVAL,
+    DEFAULT_SETTINGS.parameterPollInterval
+  )
+
   return {
-    genericModbusAutoPollEnabled: !!settings.genericModbusAutoPollEnabled,
-    genericModbusMaxPacketLength: normalizeGenericPacketLength(
-      settings.genericModbusMaxPacketLength,
-      DEFAULT_SETTINGS.genericModbusMaxPacketLength
-    ),
-    genericModbusPollInterval: clampInteger(
-      settings.genericModbusPollInterval,
-      STATUS_POLL_MIN_INTERVAL,
-      STATUS_POLL_MAX_INTERVAL,
-      DEFAULT_SETTINGS.genericModbusPollInterval
-    ),
-    modbusProtocolMode: normalizeModbusProtocolMode(settings.modbusProtocolMode),
-    modbusSlaveAddress: normalizeHexByte(settings.modbusSlaveAddress),
-    nightModeEnabled: !!settings.nightModeEnabled,
-    nightModeFollowSystem: settings.nightModeFollowSystem !== false
+    modbusSlaveAddress: normalizeHexByte(migratedSettings.modbusSlaveAddress),
+    nightModeEnabled: !!migratedSettings.nightModeEnabled,
+    nightModeFollowSystem: migratedSettings.nightModeFollowSystem !== false,
+    parameterAutoPollEnabled,
+    parameterMaxPacketLength,
+    parameterPollInterval,
+    protocolMode
   }
 }
 
@@ -135,10 +182,7 @@ function setState(changedData, options = {}) {
 function init() {
   if (initialized) return
 
-  Object.assign(state, normalizeSettings({
-    ...DEFAULT_SETTINGS,
-    ...readStoredSettings()
-  }))
+  Object.assign(state, normalizeSettings(readStoredSettings()))
   initialized = true
 }
 
@@ -182,10 +226,10 @@ function setModbusSlaveAddress(value) {
   })
 }
 
-function setModbusProtocolMode(value) {
+function setProtocolMode(value) {
   init()
   setState({
-    modbusProtocolMode: normalizeModbusProtocolMode(value)
+    protocolMode: normalizeProtocolMode(value)
   })
 }
 
@@ -194,41 +238,44 @@ function getModbusSlaveAddress() {
   return parseHexByte(state.modbusSlaveAddress, 'Modbus从机地址')
 }
 
-function setGenericModbusAutoPollEnabled(value) {
+function setParameterAutoPollEnabled(value) {
   init()
   setState({
-    genericModbusAutoPollEnabled: !!value
+    parameterAutoPollEnabled: !!value
   })
 }
 
-function setGenericModbusMaxPacketLength(value) {
+function setParameterMaxPacketLength(value) {
   init()
   setState({
-    genericModbusMaxPacketLength: normalizeGenericPacketLength(value, state.genericModbusMaxPacketLength)
+    parameterMaxPacketLength: normalizeParameterPacketLength(value, state.parameterMaxPacketLength)
   })
 }
 
-function setGenericModbusPollInterval(value) {
+function setParameterPollInterval(value) {
   init()
   setState({
-    genericModbusPollInterval: value
+    parameterPollInterval: value
   })
 }
 
 module.exports = {
-  GENERIC_MODBUS_MIN_PACKET_LENGTH,
-  MODBUS_PROTOCOL_OPTIONS,
+  PARAMETER_MIN_PACKET_LENGTH,
+  PROTOCOL_MODE,
+  PROTOCOL_OPTIONS,
   STATUS_POLL_MAX_INTERVAL,
   STATUS_POLL_MIN_INTERVAL,
   getModbusSlaveAddress,
   getState,
   init,
-  setGenericModbusAutoPollEnabled,
-  setGenericModbusMaxPacketLength,
-  setGenericModbusPollInterval,
-  setModbusProtocolMode,
+  isModbusProtocol,
+  isStorageAccessProtocol,
   setModbusSlaveAddress,
   setNightModeEnabled,
   setNightModeFollowSystem,
+  setParameterAutoPollEnabled,
+  setParameterMaxPacketLength,
+  setParameterPollInterval,
+  setProtocolMode,
   subscribe
 }

+ 9 - 1
store/theme-store.js

@@ -6,12 +6,20 @@ const {
 const TAB_ITEMS = [
   {
     pagePath: 'pages/home/home',
-    text: '首页',
+    text: '连接',
     iconPath: 'assets/tab/home.png',
     selectedIconPath: 'assets/tab/home-active.png',
     darkIconPath: 'assets/tab/home-dark.png',
     darkSelectedIconPath: 'assets/tab/home-active-dark.png'
   },
+  {
+    pagePath: 'pages/communication/communication',
+    text: '通讯',
+    iconPath: 'assets/tab/control.png',
+    selectedIconPath: 'assets/tab/control-active.png',
+    darkIconPath: 'assets/tab/control-dark.png',
+    darkSelectedIconPath: 'assets/tab/control-active-dark.png'
+  },
   {
     pagePath: 'pages/params/params',
     text: '参数',

+ 74 - 352
transport/ble-core.js

@@ -1,41 +1,43 @@
 const {
   notifyPageToast
 } = require('../utils/page-toast.js')
+const {
+  bytesToUtf8Text
+} = require('../utils/binary-utils.js')
+const {
+  DEFAULT_MAX_FRAME_BYTES,
+  arrayBufferToHex,
+  buildCharacteristicText,
+  formatBluetoothError,
+  formatFrameHex,
+  getCharacteristicRole,
+  hasTargetCharacteristic,
+  hexToArrayBuffer,
+  inferPacketSize,
+  isConnectionLostError,
+  isTargetUuid,
+  normalizeMaxFrameBytes,
+  resolvePacketSize,
+  validateHex
+} = require('./ble-utils.js')
+const {
+  DEFAULT_MAX_LOG_COUNT,
+  appendLog,
+  createClearLogsState,
+  createLogItem
+} = require('./ble-logs.js')
+const {
+  createProtocolHelperRegistry
+} = require('./protocol-helper-registry.js')
+const {
+  createBleDeviceRegistry
+} = require('./ble-device-registry.js')
 
 const SCAN_TIMEOUT = 15000
 const CONNECT_TIMEOUT = 10000
 const RSSI_REFRESH_INTERVAL = 2000
-const DEFAULT_PACKET_SIZE = 20
-const MODULE_PACKET_SIZES = [
-  {
-    packetSize: 0,
-    patterns: [/HC[-_ ]?05/i]
-  },
-  {
-    packetSize: 320,
-    patterns: [/BT[-_ ]?24/i, /\bBT24\b/i]
-  }
-]
 const RESPONSE_TIMEOUT = 1000
 const MAX_RESPONSE_BUFFER_BYTES = 128
-const DEFAULT_MAX_FRAME_BYTES = 64
-const MAX_LOG_COUNT = 100
-const TARGET_BLE_UUIDS = ['FFE0', 'FFE1']
-
-const bluetoothErrorMap = {
-  10000: '蓝牙模块未初始化,请重新扫描',
-  10001: '蓝牙不可用,请开启手机蓝牙',
-  10002: '未找到指定设备,请重新扫描',
-  10003: '连接失败,请靠近设备后重试',
-  10004: '未发现设备服务',
-  10005: '未发现设备特征值',
-  10006: '当前连接已断开',
-  10007: '当前特征值不支持此操作',
-  10008: '系统蓝牙异常,请稍后重试',
-  10009: '当前系统不支持 BLE',
-  10012: '蓝牙操作超时,请重试',
-  10013: '设备 ID 无效,请重新扫描'
-}
 
 const state = {
   adapterAvailable: false,
@@ -71,46 +73,14 @@ let sendQueue = []
 let isProcessingSendQueue = false
 let sendQueueGeneration = 0
 let sendJobSequence = 0
-let deviceMap = {}
-let deviceSequence = 0
 let logSequence = 0
 const subscribers = []
 const rawResponseSubscribers = []
-const protocolHelpers = {
-  getResponseBufferHint: null,
-  inspectReceivedBytes: null,
-  parseSendExpected: null,
-  readResponseFromBuffer: null
-}
-let protocolHelpersLoaded = false
-let protocolHelpersLoader = null
-
-function applyProtocolHelpers(helpers = {}) {
-  Object.assign(protocolHelpers, {
-    getResponseBufferHint: typeof helpers.getResponseBufferHint === 'function' ? helpers.getResponseBufferHint : null,
-    inspectReceivedBytes: typeof helpers.inspectReceivedBytes === 'function' ? helpers.inspectReceivedBytes : null,
-    parseSendExpected: typeof helpers.parseSendExpected === 'function' ? helpers.parseSendExpected : null,
-    readResponseFromBuffer: typeof helpers.readResponseFromBuffer === 'function' ? helpers.readResponseFromBuffer : null
-  })
-}
+const protocolHelperRegistry = createProtocolHelperRegistry()
+const deviceRegistry = createBleDeviceRegistry()
 
 function configureProtocolHelpers(helpers = {}) {
-  if (typeof helpers === 'function') {
-    protocolHelpersLoader = helpers
-    protocolHelpersLoaded = false
-    return
-  }
-
-  protocolHelpersLoader = null
-  protocolHelpersLoaded = true
-  applyProtocolHelpers(helpers)
-}
-
-function ensureProtocolHelpers() {
-  if (protocolHelpersLoaded || typeof protocolHelpersLoader !== 'function') return
-
-  protocolHelpersLoaded = true
-  applyProtocolHelpers(protocolHelpersLoader() || {})
+  protocolHelperRegistry.configure(helpers)
 }
 
 function setState(changedData) {
@@ -168,135 +138,14 @@ function callWx(apiName, params = {}) {
   })
 }
 
-function formatBluetoothError(error) {
-  if (!error) return '操作失败'
-
-  const message = bluetoothErrorMap[error.errCode]
-  if (message) return message
-
-  return error.errMsg || error.message || '蓝牙操作失败'
-}
-
-function normalizeDevice(device) {
-  const advertisServiceUUIDs = device.advertisServiceUUIDs || []
-  const displayName = String(device.name || device.localName || '').trim() || '未命名设备'
-  const packetSize = inferPacketSize({
-    displayName,
-    localName: device.localName,
-    name: device.name
-  })
-  const isTargetAdvertised = hasTargetAdvertisedUuid({
-    advertisServiceUUIDs
-  })
-
-  return {
-    deviceId: device.deviceId,
-    name: device.name || '',
-    localName: device.localName || '',
-    RSSI: device.RSSI,
-    advertisServiceUUIDs,
-    displayName,
-    isTargetAdvertised,
-    packetSize,
-    signalText: formatSignalText(device.RSSI),
-    serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
-    targetText: isTargetAdvertised ? '广播含目标 UUID' : '',
-    lastSeenAt: Date.now()
-  }
-}
-
-function formatSignalText(RSSI) {
-  return typeof RSSI === 'number' ? `${RSSI} dBm` : '--'
-}
-
-function inferPacketSize(device = {}) {
-  const text = [device.displayName, device.name, device.localName]
-    .map((value) => String(value || ''))
-    .join(' ')
-    .toUpperCase()
-
-  for (const item of MODULE_PACKET_SIZES) {
-    const matchedPattern = (item.patterns || []).some((pattern) => pattern.test(text))
-    if (matchedPattern) {
-      return item.packetSize
-    }
-  }
-
-  return DEFAULT_PACKET_SIZE
-}
-
-function resolvePacketSize(packetSize, frameLength) {
-  if (packetSize === 0) return frameLength || DEFAULT_PACKET_SIZE
-  if (Number.isInteger(packetSize) && packetSize > 0) return packetSize
-
-  return DEFAULT_PACKET_SIZE
-}
-
-function normalizeUuid(value) {
-  return String(value || '').replace(/-/g, '').toUpperCase()
-}
-
-function isTargetUuid(value) {
-  const uuid = normalizeUuid(value)
-
-  return TARGET_BLE_UUIDS.some((target) => uuid.indexOf(target) >= 0)
-}
-
-function hasTargetAdvertisedUuid(device) {
-  return (device.advertisServiceUUIDs || []).some(isTargetUuid)
-}
-
-function mergeAdvertisedServiceUUIDs(left = [], right = []) {
-  const uuidMap = {}
-  const uuids = []
-
-  left.concat(right).forEach((uuid) => {
-    const key = normalizeUuid(uuid)
-    if (!key || uuidMap[key]) return
-
-    uuidMap[key] = true
-    uuids.push(uuid)
-  })
-
-  return uuids
-}
-
-function normalizeHex(value) {
-  return String(value || '')
-    .replace(/0x/gi, '')
-    .replace(/[\s,;:_-]/g, '')
-    .toUpperCase()
-}
-
-function validateHex(value) {
-  const trimmed = String(value || '').trim()
-  const withoutPrefix = trimmed.replace(/0x/gi, '')
-  const compact = normalizeHex(trimmed)
-
-  if (!compact) return '请输入要发送的十六进制数据'
-  if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return '只支持十六进制字符'
-  if (compact.length % 2 !== 0) return '十六进制长度必须为偶数'
-
-  return ''
-}
-
-function normalizeMaxFrameBytes(maxFrameBytes) {
-  const numberValue = Number(maxFrameBytes)
-  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
-  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
-
-  return DEFAULT_MAX_FRAME_BYTES
-}
-
 function getResponseBufferHint(expected, options = {}) {
   if (Number.isFinite(Number(options.responseBufferHint))) {
     return Math.max(0, Math.round(Number(options.responseBufferHint)))
   }
 
-  ensureProtocolHelpers()
-
-  if (typeof protocolHelpers.getResponseBufferHint === 'function') {
-    return Math.max(0, Math.round(Number(protocolHelpers.getResponseBufferHint(expected)) || 0))
+  const getHint = protocolHelperRegistry.get('getResponseBufferHint')
+  if (typeof getHint === 'function') {
+    return Math.max(0, Math.round(Number(getHint(expected)) || 0))
   }
 
   return 0
@@ -314,28 +163,6 @@ function getResponseBufferLimit(expected, options = {}) {
   return Math.max(MAX_RESPONSE_BUFFER_BYTES, frameLimit + 8, responseLength + 8)
 }
 
-function hexToArrayBuffer(hexText) {
-  const hex = normalizeHex(hexText)
-  const buffer = new ArrayBuffer(hex.length / 2)
-  const view = new Uint8Array(buffer)
-
-  for (let index = 0; index < view.length; index += 1) {
-    view[index] = parseInt(hex.substr(index * 2, 2), 16)
-  }
-
-  return buffer
-}
-
-function arrayBufferToHex(buffer) {
-  if (!buffer) return ''
-
-  return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
-}
-
-function formatFrameHex(bytes) {
-  return Array.prototype.map.call(bytes || [], (item) => Number(item || 0).toString(16).padStart(2, '0')).join(' ').toUpperCase()
-}
-
 function validateDmaFrameLength(bytes, options = {}) {
   const maxFrameBytes = normalizeMaxFrameBytes(options.maxFrameBytes)
   if (maxFrameBytes === 0) return ''
@@ -355,46 +182,10 @@ function validateDmaFrameLength(bytes, options = {}) {
   return ''
 }
 
-function formatTime(timestamp) {
-  const date = new Date(timestamp)
-  const pad = (value, length = 2) => String(value).padStart(length, '0')
-
-  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
-}
-
-function getCharacteristicRole(properties = {}) {
-  const canWrite = !!(properties.write || properties.writeNoResponse)
-  const canNotify = !!(properties.notify || properties.indicate)
-
-  if (canWrite && canNotify) return '收发'
-  if (canWrite) return '发送'
-  if (canNotify) return '接收'
-  if (properties.read) return '读取'
-  return '其他'
-}
-
-function buildCharacteristicText(serviceId, characteristicId) {
-  if (!serviceId || !characteristicId) return '未选择'
-
-  return `${serviceId.slice(0, 8)} / ${characteristicId.slice(0, 8)}`
-}
-
-function hasTargetCharacteristic(discovery) {
-  return (discovery.services || []).some((service) => (
-    isTargetUuid(service.uuid) || (service.characteristics || []).some((item) => isTargetUuid(item.uuid))
-  ))
-}
-
-function addLog(direction, payload, note = '') {
+function addLog(direction, payload, note = '', extras = {}) {
   logSequence += 1
-  const logItem = {
-    id: `log-${Date.now()}-${logSequence}`,
-    direction,
-    note,
-    payload,
-    time: formatTime(Date.now())
-  }
-  const nextLogs = state.logs.concat(logItem).slice(-MAX_LOG_COUNT)
+  const logItem = createLogItem(direction, payload, note, extras, logSequence)
+  const nextLogs = appendLog(state.logs, logItem, DEFAULT_MAX_LOG_COUNT)
 
   setState({
     logScrollTarget: logItem.id,
@@ -405,11 +196,10 @@ function addLog(direction, payload, note = '') {
 function getReceiveCrcState(rawBytes) {
   if (!rawBytes || rawBytes.length < 4) return ''
 
-  ensureProtocolHelpers()
-
-  if (typeof protocolHelpers.inspectReceivedBytes === 'function') {
-    const note = protocolHelpers.inspectReceivedBytes(rawBytes, {
-      pendingRequest: !!pendingRequest
+  const inspectReceivedBytes = protocolHelperRegistry.get('inspectReceivedBytes')
+  if (typeof inspectReceivedBytes === 'function') {
+    const note = inspectReceivedBytes(rawBytes, {
+      pendingRequest: pendingRequest ? pendingRequest.expected : null
     })
 
     if (note) return note
@@ -467,59 +257,14 @@ function resetScanTimer() {
 }
 
 function mergeDevices(devices) {
-  if (!devices.length) return
-
-  devices.forEach((device) => {
-    if (!device.deviceId) return
-    const previousDevice = deviceMap[device.deviceId] || {}
-    const nextDevice = normalizeDevice(device)
-    const advertisServiceUUIDs = mergeAdvertisedServiceUUIDs(
-      previousDevice.advertisServiceUUIDs,
-      nextDevice.advertisServiceUUIDs
-    )
-    const isTargetAdvertised = !!previousDevice.isTargetAdvertised || hasTargetAdvertisedUuid({
-      advertisServiceUUIDs
-    })
-    const isTargetDevice = !!previousDevice.isTargetDevice
-    const seenIndex = previousDevice.seenIndex || (deviceSequence += 1)
-
-    deviceMap[device.deviceId] = {
-      ...previousDevice,
-      ...nextDevice,
-      advertisServiceUUIDs,
-      displayName: nextDevice.displayName === '未命名设备' && previousDevice.displayName
-        ? previousDevice.displayName
-        : nextDevice.displayName,
-      isTargetAdvertised,
-      isTargetDevice,
-      packetSize: nextDevice.packetSize || previousDevice.packetSize || DEFAULT_PACKET_SIZE,
-      seenIndex,
-      serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
-      targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '')
-    }
-  })
-
-  refreshDeviceList()
-}
+  const changed = deviceRegistry.mergeDevices(devices)
+  if (!changed) return
 
-function refreshDeviceList() {
-  const deviceList = getSortedDeviceList()
   setState({
-    devices: deviceList.slice(0, 30)
+    devices: deviceRegistry.getDeviceList()
   })
 }
 
-function getSortedDeviceList() {
-  return Object.keys(deviceMap)
-    .map((deviceId) => deviceMap[deviceId])
-    .sort((left, right) => {
-      const leftIndex = Number(left.seenIndex) || 0
-      const rightIndex = Number(right.seenIndex) || 0
-
-      return leftIndex - rightIndex
-    })
-}
-
 function clearPendingRequest() {
   if (!pendingRequest) return null
 
@@ -594,24 +339,12 @@ function applyRssiUpdate(deviceId, rssi) {
     return
   }
 
-  const signalText = formatSignalText(rssi)
-  const updatedDevice = {
-    ...state.connectedDevice,
-    RSSI: rssi,
-    lastSeenAt: Date.now(),
-    signalText
-  }
-
-  deviceMap[deviceId] = {
-    ...(deviceMap[deviceId] || {}),
-    RSSI: rssi,
-    lastSeenAt: updatedDevice.lastSeenAt,
-    signalText
-  }
+  const result = deviceRegistry.applyRssiUpdate(deviceId, rssi, state.connectedDevice)
+  if (!result || !result.updatedDevice) return
 
   setState({
-    connectedDevice: updatedDevice,
-    devices: getSortedDeviceList().slice(0, 30)
+    connectedDevice: result.updatedDevice,
+    devices: result.deviceList
   })
 }
 
@@ -651,14 +384,6 @@ function startRssiRefresh() {
   }, RSSI_REFRESH_INTERVAL)
 }
 
-function isConnectionLostError(error) {
-  if (!error) return false
-  if ([10000, 10001, 10006, 10013].includes(error.errCode)) return true
-
-  const message = String(error.errMsg || error.message || '').toLowerCase()
-  return message.includes('disconnect') || message.includes('not connected')
-}
-
 function finishPendingRequest(resolveValue) {
   const pending = clearPendingRequest()
 
@@ -671,9 +396,7 @@ function consumePendingResponseBuffer() {
   const pending = pendingRequest
   if (!pending || !Array.isArray(pending.responseBuffer)) return
 
-  ensureProtocolHelpers()
-
-  const responseReader = pending.responseReader || protocolHelpers.readResponseFromBuffer
+  const responseReader = pending.responseReader || protocolHelperRegistry.get('readResponseFromBuffer')
   if (typeof responseReader !== 'function') {
     const content = `${pending.label} 未配置响应解析器,已丢弃`
     addLog('SYS', content)
@@ -746,7 +469,7 @@ function consumePendingResponseBuffer() {
   }
 }
 
-function handleModbusResponse(bytes) {
+function handleReceivedResponseBytes(bytes) {
   if (!pendingRequest || !Array.isArray(bytes) || !bytes.length) return
 
   pendingRequest.responseBuffer = pendingRequest.responseBuffer.concat(bytes)
@@ -842,11 +565,14 @@ function init() {
     setState({
       rxCount: state.rxCount + byteLength
     })
-    addLog('RX', hex, crcState)
+    addLog('RX', hex, crcState, {
+      payloadBytes: rawBytes,
+      payloadText: bytesToUtf8Text(rawBytes)
+    })
     rawResponseSubscribers.slice().forEach((subscriber) => {
       subscriber(rawBytes, res)
     })
-    handleModbusResponse(rawBytes)
+    handleReceivedResponseBytes(rawBytes)
   })
 
   initialized = true
@@ -994,8 +720,7 @@ async function startDiscovery() {
 async function startScan() {
   if (state.isConnecting) return
 
-  deviceMap = {}
-  deviceSequence = 0
+  deviceRegistry.clear()
   setState({
     devices: [],
     errorText: ''
@@ -1024,8 +749,7 @@ async function startScan() {
 }
 
 function clearDevices() {
-  deviceMap = {}
-  deviceSequence = 0
+  deviceRegistry.clear()
   setState({
     devices: [],
     errorText: ''
@@ -1142,7 +866,7 @@ async function enableNotify(deviceId, serviceId, characteristicId) {
 }
 
 async function connectDeviceById(deviceId) {
-  const device = deviceMap[deviceId]
+  const device = deviceRegistry.getDevice(deviceId)
 
   if (!device || state.isConnecting) return
 
@@ -1169,14 +893,14 @@ async function connectDeviceById(deviceId) {
       ? await enableNotify(deviceId, discovery.notifyServiceId, discovery.notifyCharacteristicId)
       : false
     const isTargetDevice = hasTargetCharacteristic(discovery)
-    const connectedDevice = {
+    const connectedDevice = deviceRegistry.markConnectedDevice(deviceId, {
+      isTargetDevice
+    }) || {
       ...device,
       isTargetDevice,
       packetSize: device.packetSize || inferPacketSize(device),
       targetText: isTargetDevice ? '已发现目标特征' : device.targetText
     }
-    deviceMap[deviceId] = connectedDevice
-    refreshDeviceList()
 
     setState({
       characteristicText: buildCharacteristicText(discovery.writeServiceId, discovery.writeCharacteristicId),
@@ -1189,7 +913,8 @@ async function connectDeviceById(deviceId) {
       isConnecting: false,
       writeCharacteristicId: discovery.writeCharacteristicId,
       writeServiceId: discovery.writeServiceId,
-      writeType: discovery.writeType
+      writeType: discovery.writeType,
+      devices: deviceRegistry.getDeviceList()
     })
 
     startRssiRefresh()
@@ -1291,12 +1016,7 @@ function clearInput() {
 }
 
 function clearLogs() {
-  setState({
-    logScrollTarget: '',
-    logs: [],
-    rxCount: 0,
-    txCount: 0
-  })
+  setState(createClearLogsState())
 }
 
 function enqueueSendFrame(hexFrame, source, options = {}) {
@@ -1460,7 +1180,10 @@ async function executeSendFrame(hexFrame, source, options = {}) {
     setState({
       txCount: state.txCount + bytes.length
     })
-    addLog('TX', arrayBufferToHex(buffer), source)
+    addLog('TX', arrayBufferToHex(buffer), source, {
+      payloadBytes: Array.prototype.slice.call(bytes),
+      payloadText: bytesToUtf8Text(bytes)
+    })
 
     if (waitResponse) {
       return responsePromise
@@ -1516,11 +1239,10 @@ function sendRawFrameExact(frameBytes, source) {
 function sendHexFrame() {
   const errorText = validateHex(state.sendHex)
 
-  ensureProtocolHelpers()
-
-  const expected = errorText || typeof protocolHelpers.parseSendExpected !== 'function'
+  const parseSendExpected = protocolHelperRegistry.get('parseSendExpected')
+  const expected = errorText || typeof parseSendExpected !== 'function'
     ? null
-    : protocolHelpers.parseSendExpected(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
+    : parseSendExpected(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
 
   return enqueueSendFrame(state.sendHex, 'HEX', expected ? {
     expected

+ 137 - 0
transport/ble-device-registry.js

@@ -0,0 +1,137 @@
+const {
+  DEFAULT_PACKET_SIZE,
+  formatSignalText,
+  hasTargetAdvertisedUuid,
+  inferPacketSize,
+  mergeAdvertisedServiceUUIDs,
+  normalizeDevice
+} = require('./ble-utils.js')
+
+const DEFAULT_DEVICE_LIMIT = 30
+
+function createBleDeviceRegistry(options = {}) {
+  const deviceLimit = Math.max(1, Number(options.deviceLimit) || DEFAULT_DEVICE_LIMIT)
+  let deviceMap = {}
+  let deviceSequence = 0
+
+  function getDevice(deviceId) {
+    return deviceMap[deviceId] || null
+  }
+
+  function getSortedDeviceList() {
+    return Object.keys(deviceMap)
+      .map((deviceId) => deviceMap[deviceId])
+      .sort((left, right) => {
+        const leftIndex = Number(left.seenIndex) || 0
+        const rightIndex = Number(right.seenIndex) || 0
+
+        return leftIndex - rightIndex
+      })
+  }
+
+  function getDeviceList() {
+    return getSortedDeviceList().slice(0, deviceLimit)
+  }
+
+  function mergeDevices(devices = []) {
+    if (!Array.isArray(devices) || !devices.length) return false
+
+    let changed = false
+    devices.forEach((device) => {
+      if (!device || !device.deviceId) return
+      const previousDevice = deviceMap[device.deviceId] || {}
+      const nextDevice = normalizeDevice(device)
+      const advertisServiceUUIDs = mergeAdvertisedServiceUUIDs(
+        previousDevice.advertisServiceUUIDs,
+        nextDevice.advertisServiceUUIDs
+      )
+      const isTargetAdvertised = !!previousDevice.isTargetAdvertised || hasTargetAdvertisedUuid({
+        advertisServiceUUIDs
+      })
+      const isTargetDevice = !!previousDevice.isTargetDevice
+      const seenIndex = previousDevice.seenIndex || (deviceSequence += 1)
+
+      deviceMap[device.deviceId] = {
+        ...previousDevice,
+        ...nextDevice,
+        advertisServiceUUIDs,
+        displayName: nextDevice.displayName === '未命名设备' && previousDevice.displayName
+          ? previousDevice.displayName
+          : nextDevice.displayName,
+        isTargetAdvertised,
+        isTargetDevice,
+        packetSize: nextDevice.packetSize || previousDevice.packetSize || DEFAULT_PACKET_SIZE,
+        seenIndex,
+        serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
+        targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '')
+      }
+      changed = true
+    })
+
+    return changed
+  }
+
+  function markConnectedDevice(deviceId, discovery = {}) {
+    const device = getDevice(deviceId)
+    if (!device) return null
+
+    const isTargetDevice = !!discovery.isTargetDevice
+    const connectedDevice = {
+      ...device,
+      isTargetDevice,
+      packetSize: device.packetSize || inferPacketSize(device),
+      targetText: isTargetDevice ? '已发现目标特征' : device.targetText
+    }
+    deviceMap[deviceId] = connectedDevice
+
+    return connectedDevice
+  }
+
+  function applyRssiUpdate(deviceId, rssi, connectedDevice = null) {
+    if (typeof rssi !== 'number') return null
+    if (connectedDevice && connectedDevice.deviceId !== deviceId) return null
+
+    const signalText = formatSignalText(rssi)
+    const lastSeenAt = Date.now()
+    const updatedDevice = connectedDevice
+      ? {
+        ...connectedDevice,
+        RSSI: rssi,
+        lastSeenAt,
+        signalText
+      }
+      : null
+
+    deviceMap[deviceId] = {
+      ...(deviceMap[deviceId] || {}),
+      RSSI: rssi,
+      lastSeenAt,
+      signalText
+    }
+
+    return {
+      deviceList: getDeviceList(),
+      updatedDevice
+    }
+  }
+
+  function clear() {
+    deviceMap = {}
+    deviceSequence = 0
+  }
+
+  return {
+    applyRssiUpdate,
+    clear,
+    getDevice,
+    getDeviceList,
+    getSortedDeviceList,
+    markConnectedDevice,
+    mergeDevices
+  }
+}
+
+module.exports = {
+  DEFAULT_DEVICE_LIMIT,
+  createBleDeviceRegistry
+}

+ 39 - 0
transport/ble-logs.js

@@ -0,0 +1,39 @@
+const {
+  formatTime
+} = require('./ble-utils.js')
+
+const DEFAULT_MAX_LOG_COUNT = 100
+
+function createLogItem(direction, payload, note = '', extras = {}, sequence = 0, timestamp = Date.now()) {
+  return {
+    id: `log-${timestamp}-${sequence}`,
+    direction,
+    note,
+    payloadBytes: Array.isArray(extras.payloadBytes) ? extras.payloadBytes.slice() : null,
+    payloadText: typeof extras.payloadText === 'string' ? extras.payloadText : '',
+    payload,
+    time: formatTime(timestamp)
+  }
+}
+
+function appendLog(logs = [], logItem, maxLogCount = DEFAULT_MAX_LOG_COUNT) {
+  const limit = Math.max(1, Number(maxLogCount) || DEFAULT_MAX_LOG_COUNT)
+
+  return logs.concat(logItem).slice(-limit)
+}
+
+function createClearLogsState() {
+  return {
+    logScrollTarget: '',
+    logs: [],
+    rxCount: 0,
+    txCount: 0
+  }
+}
+
+module.exports = {
+  DEFAULT_MAX_LOG_COUNT,
+  appendLog,
+  createClearLogsState,
+  createLogItem
+}

+ 235 - 0
transport/ble-utils.js

@@ -0,0 +1,235 @@
+const DEFAULT_PACKET_SIZE = 20
+const DEFAULT_MAX_FRAME_BYTES = 64
+const TARGET_BLE_UUIDS = ['FFE0', 'FFE1']
+
+const MODULE_PACKET_SIZES = [
+  {
+    packetSize: 0,
+    patterns: [/HC[-_ ]?05/i]
+  },
+  {
+    packetSize: 320,
+    patterns: [/BT[-_ ]?24/i, /\bBT24\b/i]
+  }
+]
+
+const bluetoothErrorMap = {
+  10000: '蓝牙模块未初始化,请重新扫描',
+  10001: '蓝牙不可用,请开启手机蓝牙',
+  10002: '未找到指定设备,请重新扫描',
+  10003: '连接失败,请靠近设备后重试',
+  10004: '未发现设备服务',
+  10005: '未发现设备特征值',
+  10006: '当前连接已断开',
+  10007: '当前特征值不支持此操作',
+  10008: '系统蓝牙异常,请稍后重试',
+  10009: '当前系统不支持 BLE',
+  10012: '蓝牙操作超时,请重试',
+  10013: '设备 ID 无效,请重新扫描'
+}
+
+function formatBluetoothError(error) {
+  if (!error) return '操作失败'
+
+  const message = bluetoothErrorMap[error.errCode]
+  if (message) return message
+
+  return error.errMsg || error.message || '蓝牙操作失败'
+}
+
+function formatSignalText(RSSI) {
+  return typeof RSSI === 'number' ? `${RSSI} dBm` : '--'
+}
+
+function formatTime(timestamp) {
+  const date = new Date(timestamp)
+  const pad = (value, length = 2) => String(value).padStart(length, '0')
+
+  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
+}
+
+function inferPacketSize(device = {}) {
+  const text = [device.displayName, device.name, device.localName]
+    .map((value) => String(value || ''))
+    .join(' ')
+    .toUpperCase()
+
+  for (const item of MODULE_PACKET_SIZES) {
+    const matchedPattern = (item.patterns || []).some((pattern) => pattern.test(text))
+    if (matchedPattern) {
+      return item.packetSize
+    }
+  }
+
+  return DEFAULT_PACKET_SIZE
+}
+
+function resolvePacketSize(packetSize, frameLength) {
+  if (packetSize === 0) return frameLength || DEFAULT_PACKET_SIZE
+  if (Number.isInteger(packetSize) && packetSize > 0) return packetSize
+
+  return DEFAULT_PACKET_SIZE
+}
+
+function normalizeUuid(value) {
+  return String(value || '').replace(/-/g, '').toUpperCase()
+}
+
+function isTargetUuid(value) {
+  const uuid = normalizeUuid(value)
+
+  return TARGET_BLE_UUIDS.some((target) => uuid.indexOf(target) >= 0)
+}
+
+function hasTargetAdvertisedUuid(device) {
+  return (device.advertisServiceUUIDs || []).some(isTargetUuid)
+}
+
+function mergeAdvertisedServiceUUIDs(left = [], right = []) {
+  const uuidMap = {}
+  const uuids = []
+
+  left.concat(right).forEach((uuid) => {
+    const key = normalizeUuid(uuid)
+    if (!key || uuidMap[key]) return
+
+    uuidMap[key] = true
+    uuids.push(uuid)
+  })
+
+  return uuids
+}
+
+function normalizeDevice(device, options = {}) {
+  const advertisServiceUUIDs = device.advertisServiceUUIDs || []
+  const displayName = String(device.name || device.localName || '').trim() || '未命名设备'
+  const packetSize = inferPacketSize({
+    displayName,
+    localName: device.localName,
+    name: device.name
+  })
+  const isTargetAdvertised = hasTargetAdvertisedUuid({
+    advertisServiceUUIDs
+  })
+  const now = Number(options.now) || Date.now()
+
+  return {
+    deviceId: device.deviceId,
+    name: device.name || '',
+    localName: device.localName || '',
+    RSSI: device.RSSI,
+    advertisServiceUUIDs,
+    displayName,
+    isTargetAdvertised,
+    packetSize,
+    signalText: formatSignalText(device.RSSI),
+    serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
+    targetText: isTargetAdvertised ? '广播含目标 UUID' : '',
+    lastSeenAt: now
+  }
+}
+
+function normalizeHex(value) {
+  return String(value || '')
+    .replace(/0x/gi, '')
+    .replace(/[\s,;:_-]/g, '')
+    .toUpperCase()
+}
+
+function validateHex(value) {
+  const trimmed = String(value || '').trim()
+  const withoutPrefix = trimmed.replace(/0x/gi, '')
+  const compact = normalizeHex(trimmed)
+
+  if (!compact) return '请输入要发送的十六进制数据'
+  if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return '只支持十六进制字符'
+  if (compact.length % 2 !== 0) return '十六进制长度必须为偶数'
+
+  return ''
+}
+
+function normalizeMaxFrameBytes(maxFrameBytes) {
+  const numberValue = Number(maxFrameBytes)
+  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
+  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
+
+  return DEFAULT_MAX_FRAME_BYTES
+}
+
+function hexToArrayBuffer(hexText) {
+  const hex = normalizeHex(hexText)
+  const buffer = new ArrayBuffer(hex.length / 2)
+  const view = new Uint8Array(buffer)
+
+  for (let index = 0; index < view.length; index += 1) {
+    view[index] = parseInt(hex.substr(index * 2, 2), 16)
+  }
+
+  return buffer
+}
+
+function arrayBufferToHex(buffer) {
+  if (!buffer) return ''
+
+  return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
+}
+
+function formatFrameHex(bytes) {
+  return Array.prototype.map.call(bytes || [], (item) => Number(item || 0).toString(16).padStart(2, '0')).join(' ').toUpperCase()
+}
+
+function getCharacteristicRole(properties = {}) {
+  const canWrite = !!(properties.write || properties.writeNoResponse)
+  const canNotify = !!(properties.notify || properties.indicate)
+
+  if (canWrite && canNotify) return '收发'
+  if (canWrite) return '发送'
+  if (canNotify) return '接收'
+  if (properties.read) return '读取'
+  return '其他'
+}
+
+function buildCharacteristicText(serviceId, characteristicId) {
+  if (!serviceId || !characteristicId) return '未选择'
+
+  return `${serviceId.slice(0, 8)} / ${characteristicId.slice(0, 8)}`
+}
+
+function hasTargetCharacteristic(discovery) {
+  return (discovery.services || []).some((service) => (
+    isTargetUuid(service.uuid) || (service.characteristics || []).some((item) => isTargetUuid(item.uuid))
+  ))
+}
+
+function isConnectionLostError(error) {
+  if (!error) return false
+  if ([10000, 10001, 10006, 10013].includes(error.errCode)) return true
+
+  const message = String(error.errMsg || error.message || '').toLowerCase()
+  return message.includes('disconnect') || message.includes('not connected')
+}
+
+module.exports = {
+  DEFAULT_MAX_FRAME_BYTES,
+  DEFAULT_PACKET_SIZE,
+  arrayBufferToHex,
+  buildCharacteristicText,
+  formatBluetoothError,
+  formatFrameHex,
+  formatSignalText,
+  formatTime,
+  getCharacteristicRole,
+  hasTargetAdvertisedUuid,
+  hasTargetCharacteristic,
+  hexToArrayBuffer,
+  inferPacketSize,
+  isConnectionLostError,
+  isTargetUuid,
+  mergeAdvertisedServiceUUIDs,
+  normalizeDevice,
+  normalizeHex,
+  normalizeMaxFrameBytes,
+  normalizeUuid,
+  resolvePacketSize,
+  validateHex
+}

+ 73 - 0
transport/protocol-helper-registry.js

@@ -0,0 +1,73 @@
+const helperKeys = [
+  'getResponseBufferHint',
+  'inspectReceivedBytes',
+  'parseSendExpected',
+  'readResponseFromBuffer'
+]
+
+function createEmptyHelpers() {
+  return helperKeys.reduce((result, key) => {
+    result[key] = null
+    return result
+  }, {})
+}
+
+function normalizeHelpers(helpers = {}) {
+  const normalized = {}
+
+  helperKeys.forEach((key) => {
+    normalized[key] = typeof helpers[key] === 'function' ? helpers[key] : null
+  })
+
+  return normalized
+}
+
+function createProtocolHelperRegistry() {
+  let helpers = createEmptyHelpers()
+  let helpersLoaded = false
+  let helpersLoader = null
+
+  function ensureLoaded() {
+    if (helpersLoaded || typeof helpersLoader !== 'function') return
+
+    helpersLoaded = true
+    helpers = normalizeHelpers(helpersLoader() || {})
+  }
+
+  function configure(nextHelpers = {}) {
+    if (typeof nextHelpers === 'function') {
+      helpersLoader = nextHelpers
+      helpersLoaded = false
+      helpers = createEmptyHelpers()
+      return
+    }
+
+    helpersLoader = null
+    helpersLoaded = true
+    helpers = normalizeHelpers(nextHelpers)
+  }
+
+  function get(key) {
+    ensureLoaded()
+
+    return helpers[key] || null
+  }
+
+  function getHelpers() {
+    ensureLoaded()
+
+    return {
+      ...helpers
+    }
+  }
+
+  return {
+    configure,
+    get,
+    getHelpers
+  }
+}
+
+module.exports = {
+  createProtocolHelperRegistry
+}

+ 23 - 0
utils/binary-utils.js

@@ -34,6 +34,27 @@ function bytesToHex(bytes, separator = '') {
   return toByteArray(bytes).map((byte) => (byte & 0xFF).toString(16).toUpperCase().padStart(2, '0')).join(separator)
 }
 
+function bytesToAsciiText(bytes = []) {
+  return String.fromCharCode.apply(null, trimTrailingNullBytes(bytes).map((byte) => byte & 0xFF))
+}
+
+function bytesToUtf8Text(bytes = []) {
+  const trimmed = trimTrailingNullBytes(bytes)
+  if (!trimmed.length) return ''
+
+  let encoded = ''
+
+  trimmed.forEach((byte) => {
+    encoded += `%${(byte & 0xFF).toString(16).padStart(2, '0').toUpperCase()}`
+  })
+
+  try {
+    return decodeURIComponent(encoded)
+  } catch (error) {
+    return bytesToAsciiText(trimmed)
+  }
+}
+
 function formatBytes(byteLength) {
   const length = Number(byteLength) || 0
   if (length >= 1024 && length % 1024 === 0) return `${length / 1024} KB`
@@ -102,6 +123,8 @@ module.exports = {
   bytesToBase64,
   bytesToBin,
   bytesToHex,
+  bytesToAsciiText,
+  bytesToUtf8Text,
   bytesToWords,
   formatBytes,
   getByteFromWord,

+ 369 - 0
协议架构说明.md

@@ -0,0 +1,369 @@
+# 协议架构说明
+
+## 1. 分层原则
+
+小程序当前把链路、协议、功能服务和页面视图拆开处理:
+
+```text
+BLE 透传链路
+  transport/ble-core.js
+  transport/ble-utils.js
+
+协议层
+  protocols/modbus-rtu/index.js
+  protocols/storage-access/index.js
+  protocols/bootloader/index.js
+
+领域模型
+  domain/parameter-groups/
+  domain/storage-access/
+
+功能服务
+  features/manual-rtu/
+  features/storage-access/
+  features/parameter-groups/
+  features/communication/
+  features/bootloader/
+  features/tools/
+
+页面
+  pages/home/
+  pages/communication/
+  pages/params/
+  pages/settings/
+```
+
+协议层只负责帧格式、CRC、请求/响应解析和分片能力,不直接操作页面状态。
+
+功能服务负责把协议层组合成业务动作,例如手动生成 Modbus 帧、同步 code 信息块、读取参数组、串口原始发送。
+
+页面只负责展示、输入、按钮状态和用户反馈。
+
+功能服务和传输层只依赖每套协议的入口文件:
+
+- `protocols/modbus-rtu/index.js`
+- `protocols/storage-access/index.js`
+- `protocols/bootloader/index.js`
+
+协议层保持一个协议一个文件。`index.js` 内部可以继续暴露 `response`、`client` 这类逻辑分组对象,但不要重新拆出协议内部的 `frame.js`、`response.js`、`client.js` 给功能层引用。
+
+协议模式配置由 `store/settings-store.js` 统一维护,内部 key 使用协议目录名:
+
+- `storage-access`:存储访问协议。
+- `modbus-rtu`:标准 Modbus RTU。
+
+页面层使用 `isStorageAccessProtocol`、`isModbusProtocol` 这类展示布尔字段,业务判断由 settings store 的协议判断函数生成。
+
+历史配置字段只允许在 `store/settings-store.js` 的归一化入口迁移,例如旧的 `modbusProtocolMode`、`genericModbusMaxPacketLength` 会映射到新的 `protocolMode`、`parameterMaxPacketLength`。页面和功能服务不直接读取旧字段。
+
+## 2. 传输层
+
+目录:`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 的配置、懒加载和函数归一化。
+
+传输层只处理蓝牙透传链路,不直接理解标准 Modbus 或存储访问协议的业务语义。响应解析通过 `configureProtocolHelpers` 注入协议 helper,由协议层决定如何识别完整回复帧。
+
+## 3. 协议层
+
+### 3.1 标准 Modbus RTU
+
+入口:`protocols/modbus-rtu/index.js`
+
+职责:
+
+- 生成标准 Modbus RTU 请求帧。
+- 校验 Modbus CRC。
+- 解析标准 Modbus 响应。
+- 支持标准功能码 `0x01`、`0x02`、`0x03`、`0x04`、`0x05`、`0x06`、`0x10`。
+- 地址和数量按标准 Modbus 寄存器或线圈语义处理。
+
+标准 Modbus 不读取 `info` 区,也不解析 `Modbus_Code_Info_t`。
+
+### 3.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`。
+
+### 3.3 Bootloader
+
+入口:`protocols/bootloader/index.js`、`features/bootloader/`
+
+职责:
+
+- 保留独立升级协议。
+- 不依赖 Modbus RTU。
+- 不依赖存储访问协议。
+- 升级工具仍在设置页的工具区入口中维护。
+
+当前模块边界:
+
+- `protocols/bootloader/index.js`:Bootloader 帧构建、CRC、响应解析、ACK/NAK 校验。
+- `features/bootloader/service.js`:设置页升级状态、握手流程、固件加载、擦除/编程/校验流程编排。
+- `features/bootloader/firmware.js`:芯片型号识别、Flash 容量推断、固件大小校验和升级地址布局。
+- `features/bootloader/transport.js`:Bootloader 原始帧发送、响应等待、断连中止和超时处理。
+
+## 4. 领域模型
+
+### 4.1 参数组
+
+目录:`domain/parameter-groups/`
+
+职责:
+
+- 定义参数组和寄存器/变量的标准数据结构。
+- 支持标准 Modbus 寄存器组。
+- 支持存储访问同步出来的结构体组。
+- 支持字节地址变量、结构体字段、数组、bit field。
+- 负责输入值、显示值、原始字节、原始字、数据类型转换。
+
+参数组不是某一种协议的私有模型。标准 Modbus 和存储访问协议都会把可读写数据落到参数组模型上。
+
+当前模块拆分:
+
+- `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 展开。
+
+### 4.2 存储访问信息解析
+
+目录:`domain/storage-access/`
+
+职责:
+
+- 解析 code 区中的 `Modbus_Code_Info_t`。
+- 从 `struct_table` 生成参数组初始结构。
+- 根据 `mem_type` 映射到真实存储区域:
+  - `0x01 DATA`
+  - `0x02 IDATA`
+  - `0x03 XDATA`
+  - `0x04 CODE`
+- 不把 `0x0F INFO` 当成普通变量区域写入;同步时按普通只读响应解析 `0x0F` 回复。
+
+## 5. 功能服务
+
+### 5.1 手动 Modbus 指令
+
+目录:`features/manual-rtu/`
+
+职责:
+
+- 维护通讯页标准 Modbus 指令表单状态。
+- 根据输入生成 Modbus RTU 帧。
+- 支持写多个寄存器的多值输入。
+- 发送后等待并格式化响应。
+- 未连接蓝牙时仍允许生成帧;点击发送时再提示连接状态。
+
+当前模块拆分:
+
+- `service.js`:通讯页标准 Modbus 表单状态、弹窗开关和发送动作。
+- `frame-builder.js`:功能码列表、HEX 参数解析、标准 Modbus 帧生成、期望响应描述和回复文本格式化。
+- `multiple-registers.js`:写多个寄存器时的数量规范化、字段生成、数据类型切换、值编码和输入校验。
+
+### 5.2 存储访问同步
+
+目录:`features/storage-access/`
+
+职责:
+
+- 读取 `0x0F info` 的 `ADDR=0x0000`、`LEN=0x0004` 并解析返回数据。
+- 根据返回的地址和长度分片读取 `0x04 code` 中的完整信息块。
+- 调用 `domain/storage-access` 解析结构体表。
+- 将解析出的结构体组导入 `features/parameter-groups`。
+
+### 5.3 参数组服务
+
+目录:`features/parameter-groups/`
+
+职责:
+
+- 参数组持久化、导入、导出。
+- 标准 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`:参数页新建/编辑参数组、寄存器信息弹窗和结构体解析交互处理器。
+
+### 5.4 通讯页服务
+
+目录:`features/communication/`
+
+职责:
+
+- 串口原始发送。
+- 通讯页存储访问指令卡片。
+- 日志显示模式转换。
+- 根据设置中的协议模式显示对应的指令控制卡片。
+
+### 5.5 工具页服务
+
+目录:`features/tools/`
+
+职责:
+
+- 设置页工具入口导航。
+- 汇总 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`:三相接法、功率/电气量输入和驱动字段维护。
+
+## 6. 页面职责
+
+### 6.1 首页
+
+目录:`pages/home/`
+
+职责:
+
+- 蓝牙扫描。
+- 蓝牙连接。
+- 显示连接状态。
+
+首页不再承载 Modbus 指令生成。
+
+### 6.2 通讯页
+
+目录:`pages/communication/`
+
+布局顺序:
+
+1. 串口发送卡片。
+2. 当前协议对应的指令卡片。
+3. 收发日志。
+
+标准 Modbus 模式显示 Modbus 指令卡片。
+
+存储访问模式显示同步、读取、写入指令卡片。
+
+### 6.3 参数页
+
+目录:`pages/params/`
+
+职责:
+
+- 展示参数组列表。
+- 存储访问模式下通过同步按钮生成结构体组。
+- 存储访问模式下通过轮询读取结构体组。
+- 修改字段后在焦点切换时自动下发。
+- 标准 Modbus 模式下保留新建参数组、读组、写组能力。
+
+参数页不再包含手动 Modbus 指令窗口。
+
+### 6.4 设置页
+
+目录:`pages/settings/`
+
+职责:
+
+- 协议模式选择。
+- 从机地址配置。
+- 参数轮询间隔和最大包长配置。
+- Bootloader 升级。
+- CRC、滤波、SMD、制冷、三相功率等工具入口。
+
+## 7. 数据流
+
+### 7.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
+```
+
+### 7.2 存储访问参数读取数据流
+
+```text
+参数组轮询或手动读取
+  -> features/parameter-groups/service.readGroup
+    -> features/storage-access/memory-service.readMemory
+      -> protocols/storage-access/index.js readMemory
+        -> AREA 使用结构体表中的 mem_type
+        -> ADDR 使用结构体实例字节地址
+        -> LEN 使用结构体实例字节长度
+```
+
+### 7.3 标准 Modbus 指令数据流
+
+```text
+通讯页填写指令
+  -> features/manual-rtu/service 生成标准 Modbus RTU 帧
+  -> transport/ble-core.js 下发
+  -> protocols/modbus-rtu/index.js 解析回复
+  -> 通讯页显示回复与日志
+```
+
+## 8. 维护约束
+
+1. 不要把存储访问协议挂到 Modbus RTU 协议目录下。
+2. `0x0F info` 只作为只读同步区域,必须按普通读响应校验 CMD、ADDR、LEN、DATA 和 CRC。
+3. 存储访问协议的地址和长度始终按字节处理。
+4. 标准 Modbus 的地址和数量始终按标准寄存器/线圈语义处理。
+5. 参数组模型保持协议中立。
+6. Bootloader 升级和工具页保持独立,不随参数协议重构删除。
+7. 新功能优先放到对应协议或 feature 模块,不直接写进页面。

+ 291 - 0
存储访问协议.md

@@ -0,0 +1,291 @@
+# 存储访问协议
+
+## 1. 协议目标
+
+本协议用于上位机通过蓝牙透传链路按字节访问从机 MCU 的存储区域。协议为单主单从模型,不包含从机地址字段,不属于 Modbus RTU。
+
+小程序中该协议与标准 Modbus、Bootloader 升级协议平级。标准 Modbus 继续使用原有从机地址、功能码和 Modbus CRC;存储访问协议统一使用本文定义的 `CMD + ADDR + LEN + DATA + CRC` 格式,`0x0F info` 也按只读区域处理。
+
+## 2. 基本字节序
+
+除 CRC 算法内部计算外,协议字段均为大端序。
+
+```text
+16 位地址: ADDR_H ADDR_L
+16 位长度: LEN_H  LEN_L
+CRC 输出 : CRC_H  CRC_L
+```
+
+CRC 使用 `CRC16-CCITT-FALSE`:
+
+```text
+多项式: 0x1021
+初值  : 0xFFFF
+反转  : 输入不反转,输出不反转
+异或  : 0x0000
+顺序  : 高字节在前
+```
+
+## 3. CMD 定义
+
+每帧第 1 字节为 `CMD`。
+
+```text
+bit7      ERR    异常标志
+bit6      RW     读写标志
+bit5~bit0 AREA   存储区域编号
+```
+
+含义如下:
+
+| 位 | 值 | 含义 |
+|---|---:|---|
+| ERR | 0 | 正常请求或正常响应 |
+| ERR | 1 | 异常响应 |
+| RW | 0 | 读操作 |
+| RW | 1 | 写操作 |
+
+命令生成规则:
+
+```text
+读命令 CMD = AREA
+写命令 CMD = 0x40 | AREA
+异常 CMD_ERR = CMD | 0x80
+```
+
+## 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`。
+
+## 5. 帧格式
+
+### 5.0 info 同步请求/响应
+
+`info` 同步请求与普通读请求一致,地址和长度单位均为字节。当前定义固定读取 `ADDR=0x0000`、`LEN=0x0004`:
+
+```text
+0F 00 00 00 04 CRC_H CRC_L
+```
+
+回复与普通读响应一致,仍包含本次读取的 `ADDR` 与 `LEN`,随后 4 字节数据为 code 区内信息块的地址和长度:
+
+```text
+0F 00 00 00 04 CODE_ADDR_H CODE_ADDR_L CODE_LEN_H CODE_LEN_L CRC_H CRC_L
+```
+
+响应中的前两个字节地址 `00 00` 和长度 `00 04` 是本次 `info` 区读取的地址与长度。`CODE_ADDR` 和 `CODE_LEN` 是返回数据,表示 code 区内 `Modbus_Code_Info_t` 的字节地址和字节长度。
+
+### 5.1 读请求
+
+```text
+CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
+```
+
+长度固定 7 字节。
+
+### 5.2 写请求
+
+```text
+CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
+```
+
+长度为 `7 + LEN` 字节。
+
+### 5.3 正常读响应
+
+```text
+CMD ADDR_H ADDR_L LEN_H LEN_L DATA... CRC_H CRC_L
+```
+
+长度为 `7 + LEN` 字节。
+
+### 5.4 正常写响应
+
+```text
+CMD ADDR_H ADDR_L LEN_H LEN_L CRC_H CRC_L
+```
+
+长度固定 7 字节。
+
+### 5.5 异常响应
+
+```text
+CMD_ERR EXCEPTION_CODE CRC_H CRC_L
+```
+
+长度固定 4 字节。
+
+## 6. 异常码
+
+| 异常码 | 名称 | 说明 |
+|---:|---|---|
+| `0x01` | ILLEGAL_COMMAND | 非法命令 |
+| `0x02` | ILLEGAL_AREA | 非法区域 |
+| `0x03` | ILLEGAL_ADDRESS | 非法地址 |
+| `0x04` | ILLEGAL_LENGTH | 非法长度 |
+| `0x05` | WRITE_PROTECT | 写保护 |
+| `0x06` | DEVICE_BUSY | 设备忙 |
+| `0x07` | FORMAT_ERROR | 格式错误 |
+| `0x08` | ACCESS_DENIED | 访问拒绝 |
+| `0x09` | INTERNAL_ERROR | 内部错误 |
+| `0x0A` | ALIGNMENT_ERROR | 对齐错误 |
+| `0x0B` | RANGE_OVERFLOW | 地址范围溢出 |
+| `0x0C` | UNSUPPORTED_OPERATION | 不支持的操作 |
+
+## 7. info 同步流程
+
+连接后同步 code 区关键数据时,上位机先读取 `AREA=0x0F info` 的只读 4 字节数据,再访问 `AREA=0x04 code` 读取真实数据。
+
+### 7.1 读取 info 区关键数据
+
+读请求:
+
+```text
+0F 00 00 00 04 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
+```
+
+返回数据字段含义:
+
+| 字段 | 长度 | 说明 |
+|---|---:|---|
+| CODE_ADDR | 2 字节 | `Modbus_Code_Info_t` 在 code 区内的字节地址 |
+| CODE_LEN | 2 字节 | `Modbus_Code_Info_t` 的字节长度 |
+
+### 7.2 使用 code 区读取完整信息块
+
+上位机根据 `info` 返回数据继续读取:
+
+```text
+AREA = 0x04
+ADDR = CODE_ADDR
+LEN  = CODE_LEN
+```
+
+如果 `CODE_LEN` 超过当前链路最大包长,小程序按字节地址自动分片。例如最大帧长为 64 字节时,单帧读响应可承载的数据长度为:
+
+```text
+64 - 7 = 57 字节
+```
+
+小程序会依次读取:
+
+```text
+CODE_ADDR + 0x0000 ~ CODE_ADDR + 0x0038
+CODE_ADDR + 0x0039 ~ CODE_ADDR + 0x0071
+...
+```
+
+每个分片均使用 `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;
+```
+
+固定头长度为 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 | 结构体实例所在区域 |
+|---:|---|
+| `0x01` | data |
+| `0x02` | idata |
+| `0x03` | xdata |
+| `0x04` | code |
+
+`struct_table` 不应把结构体实例标为 `0x0F`,因为 `0x0F info` 只用于同步 code 区信息块地址和长度,不作为普通变量读写区域。
+
+## 9. 小程序同步后的处理
+
+小程序读取完整 `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
+struct_table[0]:
+  byte_addr = 0x2000
+  byte_len  = 64
+  mem_type  = 0x03
+  type_name = motor_runtime_t
+```
+
+则后续读取该结构体实例时发送:
+
+```text
+03 20 00 00 40 CRC_H CRC_L
+```
+
+含义为读取 `xdata` 区 `0x2000` 开始的 64 字节。
+
+## 10. 实现约束
+
+1. `code` 和 `info` 区默认只读。
+2. `LEN` 单位始终为字节,不是 Modbus 寄存器。
+3. 上位机必须校验响应 CMD、ADDR、LEN 和 CRC。
+4. 异常响应固定 4 字节。
+5. `0x0F info` 只用于连接同步信息块地址和长度,请求/回复均按普通只读帧处理,不作为普通变量读写区域。
+6. 如果固件更新了 `Modbus_Code_Info_t` 布局,应同步更新本文档和小程序解析器。