avery 20 ساعت پیش
والد
کامیت
40196bf189
77فایلهای تغییر یافته به همراه3337 افزوده شده و 1332 حذف شده
  1. 21 4
      app.js
  2. 0 1
      app.json
  3. 278 237
      app.wxss
  4. 9 0
      assets/filter-diagram/capacitor-h.svg
  5. 12 0
      assets/filter-diagram/inductor-h.svg
  6. 8 14
      assets/filter-diagram/resistor-h.svg
  7. 4 0
      domain/generic-modbus/index.js
  8. 178 36
      domain/generic-modbus/model.js
  9. 277 0
      domain/generic-modbus/struct-parser.js
  10. 0 0
      domain/motor-control/calculation-context.js
  11. 7 7
      domain/motor-control/control-state.js
  12. 2 2
      domain/motor-control/conversions.js
  13. 21 0
      domain/motor-control/data.js
  14. 7 0
      domain/motor-control/index.js
  15. 1 1
      domain/motor-control/input-value-utils.js
  16. 6 6
      domain/motor-control/params-state.js
  17. 2 2
      domain/motor-control/register-groups.js
  18. 1 1
      domain/motor-control/registers.js
  19. 4 4
      domain/motor-control/status-format.js
  20. 48 4
      domain/motor-control/status-state.js
  21. 0 0
      domain/motor-control/thermistor.js
  22. 3 0
      features/bootloader/index.js
  23. 28 189
      features/bootloader/service.js
  24. 12 0
      features/generic-modbus/index.js
  25. 2 2
      features/generic-modbus/poller.js
  26. 93 33
      features/generic-modbus/service.js
  27. 125 0
      features/home/service.js
  28. 8 6
      features/home/view-model.js
  29. 518 0
      features/manual-rtu/service.js
  30. 19 19
      features/motor-control/control-service.js
  31. 3 3
      features/motor-control/control-view-model.js
  32. 16 0
      features/motor-control/index.js
  33. 15 15
      features/motor-control/params-service.js
  34. 34 11
      features/motor-control/params-view-model.js
  35. 5 5
      features/motor-control/protocol-service.js
  36. 8 8
      features/motor-control/sync-service.js
  37. 4 4
      features/settings/view-model.js
  38. 9 0
      features/tools/index.js
  39. 0 0
      features/tools/navigation.js
  40. 13 8
      features/tools/page.js
  41. 3 0
      minitest/test.config.json
  42. 74 52
      pages/home/home.js
  43. 68 1
      pages/home/home.wxml
  44. 65 0
      pages/home/home.wxss
  45. 4 10
      pages/index/index.js
  46. 1 7
      pages/index/index.wxml
  47. 393 19
      pages/params/params.js
  48. 107 44
      pages/params/params.wxml
  49. 6 6
      pages/settings/settings.js
  50. 27 31
      pages/settings/settings.wxml
  51. 14 9
      project.config.json
  52. 218 0
      protocols/bootloader/frame.js
  53. 3 0
      protocols/bootloader/index.js
  54. 4 4
      protocols/modbus-rtu/client.js
  55. 1 1
      protocols/modbus-rtu/frame.js
  56. 5 0
      protocols/modbus-rtu/index.js
  57. 298 0
      protocols/modbus-rtu/response.js
  58. 41 0
      protocols/transport-helpers.js
  59. 4 9
      repositories/file.js
  60. 3 3
      store/settings-store.js
  61. 2 2
      store/theme-store.js
  62. 0 0
      tools/calculator-helpers.js
  63. 4 18
      tools/crc-hash/crc-tool.js
  64. 1 1
      tools/crc-hash/hash.js
  65. 4 0
      tools/crc-hash/index.js
  66. 22 9
      tools/filter/index.js
  67. 1 1
      tools/reactance/index.js
  68. 1 1
      tools/refrigeration/index.js
  69. 1 1
      tools/smd-code/index.js
  70. 1 1
      tools/three-phase-power/index.js
  71. 119 434
      transport/ble-core.js
  72. 9 0
      utils/binary-utils.js
  73. 0 22
      utils/bootloader-frame.js
  74. 2 2
      utils/crc.js
  75. 0 21
      utils/motor-control-data.js
  76. 29 0
      utils/number-format.js
  77. 1 1
      utils/register-value-utils.js

+ 21 - 4
app.js

@@ -1,10 +1,27 @@
-const transport = require('./utils/ble-transport')
-const themeService = require('./utils/theme-service')
+const transport = require('./transport/ble-core.js')
+const themeService = require('./store/theme-store.js')
+
+transport.configureProtocolHelpers(() => require('./protocols/transport-helpers.js'))
+
+function deferStartupWork(task) {
+  if (typeof task !== 'function') return
+
+  if (typeof setTimeout === 'function') {
+    setTimeout(task, 120)
+    return
+  }
+
+  task()
+}
 
 App({
   onShow() {
-    themeService.syncWithSystemTheme()
-    transport.handleAppShow()
+    deferStartupWork(() => {
+      try {
+        themeService.syncWithSystemTheme()
+        Promise.resolve(transport.handleAppShow()).catch(() => {})
+      } catch (error) {}
+    })
   },
 
   onHide() {

+ 0 - 1
app.json

@@ -53,7 +53,6 @@
     "skyline": {
       "defaultDisplayBlock": true,
       "defaultContentBox": true,
-      "tagNameStyleIsolation": "legacy",
       "disableABTest": true,
       "sdkVersionBegin": "3.0.0",
       "sdkVersionEnd": "15.255.255"

+ 278 - 237
app.wxss

@@ -257,7 +257,8 @@ page {
 }
 
 .theme-dark .generic-value-input,
-.theme-dark .crc-data-input {
+.theme-dark .crc-data-input,
+.theme-dark .generic-struct-input {
   border-color: #334155;
   background: #111827;
   color: #e5e7eb;
@@ -268,10 +269,38 @@ page {
 }
 
 .theme-dark .generic-register-row,
-.theme-dark .generic-config-row {
+.theme-dark .generic-config-row,
+.theme-dark .generic-struct-section {
   border-color: #263241;
 }
 
+.theme-dark .generic-register-row.is-drag-armed {
+  background: rgba(45, 212, 191, 0.06);
+}
+
+.theme-dark .generic-register-row.is-dragging {
+  border-color: rgba(45, 212, 191, 0.32);
+  background: linear-gradient(180deg, rgba(17, 94, 89, 0.68), rgba(15, 118, 110, 0.42));
+  box-shadow: 0 16rpx 42rpx rgba(2, 6, 23, 0.5);
+}
+
+.theme-dark .generic-register-drag-bar {
+  background: #64748b;
+}
+
+.theme-dark .generic-register-drag-handle.is-drag-armed {
+  background: rgba(45, 212, 191, 0.1);
+}
+
+.theme-dark .generic-register-drag-handle.is-dragging {
+  background: rgba(45, 212, 191, 0.16);
+}
+
+.theme-dark .generic-register-drag-handle.is-drag-armed .generic-register-drag-bar,
+.theme-dark .generic-register-drag-handle.is-dragging .generic-register-drag-bar {
+  background: #5eead4;
+}
+
 .theme-dark .generic-readonly-value,
 .theme-dark .generic-register-unit {
   color: #5eead4;
@@ -679,14 +708,11 @@ page {
 }
 
 .panel-icon::before {
+  display: none;
   left: 7rpx;
   top: 7rpx;
   width: 24rpx;
   height: 24rpx;
-  background-image: var(--icon-image);
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: contain;
 }
 
 .panel-icon::after {
@@ -701,115 +727,96 @@ page {
 .icon-chip {
   --icon-start: #5a86a6;
   --icon-end: #2f5e7c;
-  --icon-image: url("/assets/icons/chip-white.png");
 }
 
 .icon-control {
   --icon-start: #18a58b;
   --icon-end: #0e746f;
-  --icon-image: url("/assets/icons/control-white.png");
 }
 
 .icon-bluetooth {
   --icon-start: #16a8cf;
   --icon-end: #0f7a9a;
-  --icon-image: url("/assets/icons/bluetooth-connected-white.png");
 }
 
 .icon-radar {
   --icon-start: #23b0d7;
   --icon-end: #137e8f;
-  --icon-image: url("/assets/icons/radar-white.png");
 }
 
 .icon-terminal {
   --icon-start: #63758f;
   --icon-end: #324056;
-  --icon-image: url("/assets/icons/terminal-white.png");
 }
 
 .icon-send {
   --icon-start: #39bdf0;
   --icon-end: #1684c5;
-  --icon-image: url("/assets/icons/send-white.png");
 }
 
 .icon-history {
   --icon-start: #64748b;
   --icon-end: #475569;
-  --icon-image: url("/assets/icons/history-white.png");
 }
 
 .icon-status {
   --icon-start: #14a79a;
   --icon-end: #2563eb;
-  --icon-image: url("/assets/icons/status-white.png");
 }
 
 .icon-bars {
   --icon-start: #148f85;
   --icon-end: #105f8b;
-  --icon-image: url("/assets/icons/estimator-white.png");
 }
 
 .icon-tune {
   --icon-start: #17a59f;
   --icon-end: #0d7280;
-  --icon-image: url("/assets/icons/sliders-white.png");
 }
 
 .icon-speed {
   --icon-start: #f7a623;
   --icon-end: #d97f0c;
-  --icon-image: url("/assets/icons/speed-white.png");
 }
 
 .icon-target {
   --icon-start: #21a37e;
   --icon-end: #0f766e;
-  --icon-image: url("/assets/icons/target-white.png");
 }
 
 .icon-shield-check {
   --icon-start: #16a34a;
   --icon-end: #0f766e;
-  --icon-image: url("/assets/icons/shield-check-white.png");
 }
 
 .icon-crc {
   --icon-start: #2563eb;
   --icon-end: #0f766e;
-  --icon-image: url("/assets/icons/hash-white.png");
 }
 
 .icon-filter {
   --icon-start: #f59e0b;
   --icon-end: #d97706;
-  --icon-image: url("/assets/icons/funnel-white.png");
 }
 
 .icon-reactance {
   --icon-start: #0ea5e9;
   --icon-end: #0f766e;
-  --icon-image: url("/assets/icons/audio-waveform-white.png");
 }
 
 .icon-smd {
   --icon-start: #64748b;
   --icon-end: #334155;
-  --icon-image: url("/assets/icons/microchip-white.png");
 }
 
 .icon-snow {
   --icon-start: #38bdf8;
   --icon-end: #2563eb;
-  --icon-image: url("/assets/icons/snowflake-white.png");
 }
 
 .icon-three-phase {
   --icon-start: #7c3aed;
   --icon-end: #2563eb;
-  --icon-image: url("/assets/icons/zap-white.png");
 }
 
 .param-row {
@@ -1011,6 +1018,35 @@ page {
   font-size: 28rpx;
 }
 
+.generic-struct-section {
+  padding: 16rpx 24rpx 22rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.generic-struct-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18rpx;
+  margin-bottom: 14rpx;
+}
+
+.generic-struct-input {
+  width: 100%;
+  min-height: 220rpx;
+  max-height: 360rpx;
+  padding: 16rpx 18rpx;
+  border: 1rpx solid #dbe5ee;
+  border-radius: 12rpx;
+  background: #f8fafc;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 22rpx;
+  line-height: 1.45;
+  box-sizing: border-box;
+}
+
 .generic-draft-actions {
   display: flex;
   justify-content: flex-end;
@@ -1162,10 +1198,6 @@ page {
   transform: rotate(-45deg);
 }
 
-.generic-group-actions .entry-chevron.is-expanded {
-  transform: rotate(45deg);
-}
-
 .generic-register-row {
   display: flex;
   align-items: center;
@@ -1174,7 +1206,77 @@ page {
   min-height: 112rpx;
   padding: 12rpx 18rpx;
   border-top: 1rpx solid #edf2f7;
+  border-radius: 18rpx;
   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;
+  will-change: transform;
+}
+
+.generic-register-row.is-drag-armed {
+  background: rgba(15, 143, 135, 0.04);
+}
+
+.generic-register-row.is-dragging {
+  position: relative;
+  border-color: rgba(15, 143, 135, 0.28);
+  background: linear-gradient(180deg, rgba(15, 143, 135, 0.12), rgba(15, 143, 135, 0.08));
+  box-shadow: 0 14rpx 38rpx rgba(15, 23, 42, 0.18);
+}
+
+.generic-register-row.is-shift-up,
+.generic-register-row.is-shift-down {
+  z-index: 1;
+}
+
+.generic-register-drag-handle {
+  flex: none;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 5rpx;
+  width: 34rpx;
+  min-height: 72rpx;
+  margin-left: -4rpx;
+  border-radius: 14rpx;
+  transition: background-color 0.18s ease, transform 0.18s ease;
+}
+
+.generic-register-layout-spacer {
+  flex: none;
+  width: 12rpx;
+  min-height: 72rpx;
+}
+
+.generic-register-drag-handle.is-drag-armed {
+  background: rgba(15, 143, 135, 0.08);
+}
+
+.generic-register-drag-handle.is-dragging {
+  background: rgba(15, 143, 135, 0.14);
+  transform: scale(1.04);
+}
+
+.generic-register-drag-bar {
+  width: 20rpx;
+  height: 3rpx;
+  border-radius: 999rpx;
+  background: #94a3b8;
+  transition: background-color 0.18s ease, transform 0.18s ease, opacity 0.18s ease;
+}
+
+.generic-register-drag-handle.is-drag-armed .generic-register-drag-bar,
+.generic-register-drag-handle.is-dragging .generic-register-drag-bar {
+  background: #0f8f87;
+}
+
+.generic-register-drag-handle.is-dragging .generic-register-drag-bar:nth-child(1) {
+  transform: translateX(-2rpx);
+}
+
+.generic-register-drag-handle.is-dragging .generic-register-drag-bar:nth-child(3) {
+  transform: translateX(2rpx);
 }
 
 .generic-register-main {
@@ -1244,6 +1346,21 @@ page {
   font-size: 25rpx;
 }
 
+.generic-group-detail-panel {
+  overflow: hidden;
+}
+
+.generic-group-detail-meta {
+  padding: 14rpx 18rpx;
+  border-bottom: 1rpx solid #edf2f7;
+  color: #64748b;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 21rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  box-sizing: border-box;
+}
+
 .crc-picker-value {
   min-width: 300rpx;
   max-width: 300rpx;
@@ -1525,233 +1642,186 @@ page {
 
 .filter-diagram {
   position: relative;
-  min-height: 280rpx;
-  padding: 38rpx 8rpx 34rpx;
+  min-height: 320rpx;
+  padding: 30rpx 0 30rpx;
+  border-top: 1rpx solid #edf2f7;
+  background: #ffffff;
   box-sizing: border-box;
 }
 
-.filter-diagram-row {
-  display: flex;
-  align-items: center;
-  width: 66%;
+.filter-schematic {
+  position: relative;
+  width: 560rpx;
+  height: 260rpx;
   margin: 0 auto;
+  color: #111827;
 }
 
-.filter-diagram-row--top {
-  height: 112rpx;
-}
-
-.filter-diagram-port {
-  flex: none;
-  width: 48rpx;
-  color: #475569;
-  font-size: 22rpx;
-  line-height: 1.2;
+.filter-schematic-label {
+  position: absolute;
+  color: #111827;
+  font-size: 32rpx;
+  line-height: 1;
   font-weight: 900;
-  text-align: center;
+  font-style: italic;
+  letter-spacing: 0;
 }
 
-.filter-diagram-wire,
-.filter-diagram-branch-wire,
-.filter-diagram-ground-line,
-.filter-diagram-ground-stem {
-  background: #94a3b8;
+.filter-schematic-label--input {
+  left: 0;
+  top: 122rpx;
 }
 
-.filter-diagram-wire {
-  flex: 1;
-  min-width: 8rpx;
-  height: 3rpx;
-  border-radius: 999rpx;
+.filter-schematic-label--output {
+  left: 426rpx;
+  top: 118rpx;
 }
 
-.filter-diagram-component {
-  flex: none;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 74rpx;
-  height: 58rpx;
-  color: #0f766e;
-  box-sizing: border-box;
+.filter-schematic-wire,
+.filter-schematic-dot {
+  position: absolute;
+  background: #111827;
 }
 
-.filter-diagram-component--r {
-  color: #c2410c;
+.filter-schematic-wire {
+  height: 4rpx;
+  border-radius: 999rpx;
 }
 
-.filter-diagram-component--c {
-  color: #0f766e;
+.filter-schematic-dot {
+  z-index: 3;
+  width: 18rpx;
+  height: 18rpx;
+  border-radius: 50%;
 }
 
-.filter-diagram-component--l {
-  color: #2563eb;
+.filter-schematic-dot--input-top {
+  left: 38rpx;
+  top: 58rpx;
 }
 
-.filter-diagram-component-icon {
-  position: relative;
-  flex: none;
-  width: 66rpx;
-  height: 40rpx;
-  color: inherit;
-  box-sizing: border-box;
+.filter-schematic-dot--input-bottom {
+  left: 38rpx;
+  top: 206rpx;
 }
 
-.filter-diagram-component-icon::before,
-.filter-diagram-component-icon::after,
-.filter-diagram-icon-loop {
-  position: absolute;
-  content: "";
-  box-sizing: border-box;
+.filter-schematic-dot--node-top {
+  left: 358rpx;
+  top: 58rpx;
 }
 
-.filter-diagram-component-icon--r::before {
-  left: 8rpx;
-  right: 8rpx;
-  top: 10rpx;
-  z-index: 1;
-  height: 18rpx;
-  border: 4rpx solid currentColor;
-  border-radius: 5rpx;
-  background: #ffffff;
+.filter-schematic-dot--node-bottom {
+  left: 358rpx;
+  top: 206rpx;
 }
 
-.filter-diagram-component-icon--r::after {
-  left: 0;
-  right: 0;
-  top: 18rpx;
-  z-index: 0;
-  height: 4rpx;
-  border-radius: 999rpx;
-  background: currentColor;
+.filter-schematic-dot--output-top {
+  left: 514rpx;
+  top: 58rpx;
 }
 
-.filter-diagram-component-icon--c::before {
-  left: 15rpx;
-  top: 2rpx;
-  width: 4rpx;
-  height: 32rpx;
-  background: currentColor;
-  box-shadow: 24rpx 0 0 currentColor;
+.filter-schematic-dot--output-bottom {
+  left: 514rpx;
+  top: 206rpx;
 }
 
-.filter-diagram-component-icon--c::after {
-  left: 0;
-  right: 0;
-  top: 18rpx;
-  height: 4rpx;
-  border-radius: 999rpx;
-  background: linear-gradient(90deg, currentColor 0 15rpx, transparent 15rpx 39rpx, currentColor 39rpx 100%);
+.filter-schematic-wire--top-input {
+  left: 56rpx;
+  top: 65rpx;
+  width: 100rpx;
 }
 
-.filter-diagram-component-icon--l {
-  display: flex;
-  align-items: flex-end;
-  justify-content: center;
-  gap: 0;
+.filter-schematic-wire--top-middle {
+  left: 268rpx;
+  top: 65rpx;
+  width: 98rpx;
 }
 
-.filter-diagram-component-icon--l::before {
-  left: 0;
-  top: 27rpx;
-  width: 8rpx;
-  height: 4rpx;
-  border-radius: 999rpx;
-  background: currentColor;
+.filter-schematic-wire--top-output {
+  left: 366rpx;
+  top: 65rpx;
+  width: 148rpx;
 }
 
-.filter-diagram-component-icon--l::after {
-  right: 0;
-  top: 27rpx;
-  width: 8rpx;
-  height: 4rpx;
-  border-radius: 999rpx;
-  background: currentColor;
+.filter-schematic-wire--bottom-input {
+  left: 56rpx;
+  top: 213rpx;
+  width: 310rpx;
 }
 
-.filter-diagram-component-icon--l .filter-diagram-icon-loop {
-  position: relative;
-  width: 20rpx;
-  height: 24rpx;
-  margin-left: -3rpx;
-  border: 4rpx solid currentColor;
-  border-bottom: 0;
-  border-radius: 16rpx 16rpx 0 0;
+.filter-schematic-wire--bottom-output {
+  left: 366rpx;
+  top: 213rpx;
+  width: 148rpx;
 }
 
-.filter-diagram-component--shunt {
-  width: 72rpx;
-  height: 72rpx;
+.filter-schematic-wire--branch-top {
+  left: 365rpx;
+  top: 76rpx;
+  width: 4rpx;
+  height: 40rpx;
 }
 
-.filter-diagram-component--shunt .filter-diagram-component-icon {
-  transform: rotate(90deg);
+.filter-schematic-wire--branch-bottom {
+  left: 365rpx;
+  top: 208rpx;
+  width: 4rpx;
+  height: 8rpx;
 }
 
-.filter-diagram-node-wrap {
-  flex: none;
-  position: relative;
+.filter-schematic-component {
+  position: absolute;
   z-index: 2;
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 38rpx;
-  height: 62rpx;
-}
-
-.filter-diagram-node {
-  width: 16rpx;
-  height: 16rpx;
-  border-radius: 50%;
-  background: #0f8f87;
-  box-shadow: 0 0 0 4rpx rgba(15, 143, 135, 0.12);
-}
-
-.filter-diagram-branch {
-  position: absolute;
-  left: 50%;
-  top: 42rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  transform: translateX(-50%);
+  box-sizing: border-box;
 }
 
-.filter-diagram-branch-wire {
-  width: 3rpx;
-  height: 42rpx;
-  border-radius: 999rpx;
+.filter-schematic-component--series {
+  left: 156rpx;
+  top: 40rpx;
+  width: 112rpx;
+  height: 54rpx;
 }
 
-.filter-diagram-ground {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 4rpx;
-  margin-top: 12rpx;
+.filter-schematic-component--shunt {
+  left: 342rpx;
+  top: 116rpx;
+  width: 50rpx;
+  height: 96rpx;
 }
 
-.filter-diagram-ground-stem {
-  width: 3rpx;
-  height: 26rpx;
-  border-radius: 999rpx;
+.filter-schematic-symbol {
+  display: block;
+  width: 100%;
+  height: 100%;
 }
 
-.filter-diagram-ground-line {
-  height: 2rpx;
-  border-radius: 999rpx;
+.filter-schematic-symbol--vertical {
+  width: 104rpx;
+  height: 48rpx;
+  transform: rotate(90deg);
 }
 
-.filter-diagram-ground-line--1 {
-  width: 48rpx;
+.filter-schematic-mark {
+  position: absolute;
+  z-index: 4;
+  color: #111827;
+  font-size: 34rpx;
+  line-height: 1;
+  font-weight: 700;
+  font-style: italic;
 }
 
-.filter-diagram-ground-line--2 {
-  width: 34rpx;
+.filter-schematic-mark--series {
+  left: 202rpx;
+  top: 0;
 }
 
-.filter-diagram-ground-line--3 {
-  width: 20rpx;
+.filter-schematic-mark--shunt {
+  left: 305rpx;
+  top: 142rpx;
 }
 
 .theme-dark .filter-unit-value {
@@ -1770,39 +1840,24 @@ page {
   color: #99f6e4;
 }
 
-.theme-dark .filter-diagram-port {
-  color: #94a3b8;
-}
-
-.theme-dark .filter-diagram-mode {
-  color: #5eead4;
-}
-
-.theme-dark .filter-diagram-wire,
-.theme-dark .filter-diagram-branch-wire,
-.theme-dark .filter-diagram-ground-line,
-.theme-dark .filter-diagram-ground-stem {
-  background: #64748b;
-}
-
-.theme-dark .filter-diagram-component-icon--r::before {
-  background: #17202c;
-}
-
-.theme-dark .filter-diagram-component {
-  color: #e5e7eb;
+.theme-dark .filter-diagram {
+  border-color: #edf2f7;
+  background: #ffffff;
 }
 
-.theme-dark .filter-diagram-component--r {
-  color: #fed7aa;
+.theme-dark .filter-schematic,
+.theme-dark .filter-schematic-label,
+.theme-dark .filter-schematic-mark {
+  color: #1f2937;
 }
 
-.theme-dark .filter-diagram-component--c {
-  color: #99f6e4;
+.theme-dark .filter-diagram-mode {
+  color: #5eead4;
 }
 
-.theme-dark .filter-diagram-component--l {
-  color: #bfdbfe;
+.theme-dark .filter-schematic-wire,
+.theme-dark .filter-schematic-dot {
+  background: #111827;
 }
 
 .smd-section-title {
@@ -2159,23 +2214,9 @@ page {
     padding-right: 4rpx;
   }
 
-  .filter-diagram-row {
-    width: 74%;
-  }
-
-  .filter-diagram-port {
-    width: 44rpx;
-    font-size: 20rpx;
-  }
-
-  .filter-diagram-component {
-    width: 68rpx;
-    height: 54rpx;
-  }
-
-  .filter-diagram-component--shunt {
-    width: 64rpx;
-    height: 64rpx;
+  .filter-schematic {
+    transform: scale(0.94);
+    transform-origin: center center;
   }
 
   .smd-code-input {

+ 9 - 0
assets/filter-diagram/capacitor-h.svg

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="64" height="32" viewBox="0 0 64 32" xmlns="http://www.w3.org/2000/svg">
+  <g fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round">
+    <path d="M0 16H18" stroke-width="3"/>
+    <path d="M18 6V26" stroke-width="4"/>
+    <path d="M28 6V26" stroke-width="4"/>
+    <path d="M28 16H64" stroke-width="3"/>
+  </g>
+</svg>

+ 12 - 0
assets/filter-diagram/inductor-h.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="64" height="32" viewBox="0 0 64 32" xmlns="http://www.w3.org/2000/svg">
+  <g fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round">
+    <path d="M0 16H10" stroke-width="3"/>
+    <path d="M10 16
+             A6 6 0 0 1 22 16
+             A6 6 0 0 1 34 16
+             A6 6 0 0 1 46 16
+             A6 6 0 0 1 58 16" stroke-width="3"/>
+    <path d="M58 16H64" stroke-width="3"/>
+  </g>
+</svg>

+ 8 - 14
assets/filter-diagram/resistor-h.svg

@@ -1,14 +1,8 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 
-  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<!-- Hand Coded by Eadthem Akip with AkelPad notepad    why draw when you can write?-->
-<svg  width="64" height="32"
-	xmlns:svg="http://www.w3.org/2000/svg" 
-	xmlns="http://www.w3.org/2000/svg" 
-	version="1.1" id="svg3654">
-	<defs id="defs3657" />
-	<path
-		d="M 0,16 0,16 L 8,16 L 12,12 L 16,16 L 20,20 L 24,16 L 28,12 L 32,16 L 36,20 L 40,16 L 44,12 L 48,16 L 52,20 L 56,16 L 64,16"
-		style="fill:none;fill-opacity:0.75;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-		id="path3124" />
-</svg>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="64" height="32" viewBox="0 0 64 32" xmlns="http://www.w3.org/2000/svg">
+  <g fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round">
+    <path d="M0 16H12" stroke-width="3"/>
+    <rect x="12" y="7" width="40" height="18" stroke-width="3"/>
+    <path d="M52 16H64" stroke-width="3"/>
+  </g>
+</svg>

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

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

+ 178 - 36
utils/generic-modbus-model.js → domain/generic-modbus/model.js

@@ -1,18 +1,18 @@
 const {
   formatFixedValue
-} = require('./conversions')
+} = require('../../utils/number-format.js')
 const {
   clampInteger,
   createId,
   normalizeTextValue,
   padHex
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   bytesToWords,
   getByteFromWord,
   trimTrailingNullBytes,
   wordsToBytes
-} = require('./binary-utils')
+} = require('../../utils/binary-utils.js')
 
 const MAX_MODBUS_ADDRESS = 0xFFFF
 const MAX_GENERIC_MODBUS_ITEMS = 256
@@ -120,6 +120,8 @@ const DATA_TYPE_OPTIONS = [
 ]
 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'
 
 function normalizeAddress(value, fallback = 0) {
   if (typeof value === 'number') {
@@ -200,9 +202,17 @@ function getRegisterTextByteLength(register = {}) {
   return normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
 }
 
+function isStructLayout(layout) {
+  return layout === GROUP_LAYOUT_STRUCT
+}
+
 function getRegisterByteLength(dataType, register = {}) {
   const type = getDataType(dataType)
-  if (type.kind === 'text') return alignEvenByteLength(getRegisterTextByteLength(register))
+  if (type.kind === 'text') {
+    const byteLength = getRegisterTextByteLength(register)
+
+    return isStructLayout(register.layout) ? byteLength : alignEvenByteLength(byteLength)
+  }
 
   return type.byteLength || ((type.wordCount || 1) * 2)
 }
@@ -518,13 +528,77 @@ function wordsToFloat(words) {
   return view.getFloat32(0, false)
 }
 
-function encodeRegisterWords(register) {
+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 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 (isTextRegister(dataType)) {
     const byteLimit = getEncodeByteLimit(register)
-    const byteLength = getRegisterByteLength(dataType, register)
     const bytes = encodeTextBytes(valueText, dataType, byteLimit)
     const paddedBytes = bytes.slice()
 
@@ -532,25 +606,35 @@ function encodeRegisterWords(register) {
       paddedBytes.push(0)
     }
 
-    return bytesToWords(paddedBytes.slice(0, byteLength))
+    return paddedBytes.slice(0, byteLength)
   }
 
   const numberValue = parseNumberText(valueText, dataType)
   if (numberValue === null) return null
   validateNumericValue(register, numberValue)
 
-  if (dataType === 'float') return floatToWords(numberValue)
+  if (dataType === 'float') return floatToBytes(numberValue)
 
   const rounded = Math.round(numberValue)
-  if (dataType === 'int8_t') return [((rounded < 0 ? 0x100 + rounded : rounded) & 0xFF)]
-  if (dataType === 'uint8_t') return [rounded & 0xFF]
-  if (dataType === 'int16_t' || dataType === 'uint16_t' || dataType === 'hex') return [rounded & 0xFFFF]
+  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)
+  }
 
-  const unsignedValue = rounded < 0 ? 0x100000000 + rounded : rounded
-  return [
-    Math.floor(unsignedValue / 0x10000) & 0xFFFF,
-    unsignedValue & 0xFFFF
-  ]
+  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) {
@@ -558,39 +642,38 @@ function decodeRegisterValue(register, words) {
 
   if (!Array.isArray(words) || words.length < getRegisterWordCount(dataType, register)) return null
 
+  const bytes = getRegisterDataBytes(register, words)
+  const byteLength = getRegisterByteLength(dataType, register)
+  if (bytes.length < byteLength) return null
+
   if (isTextRegister(dataType)) {
-    return decodeTextBytes(wordsToBytes(words, getEncodeByteLimit(register)), dataType)
+    return decodeTextBytes(bytes.slice(0, getEncodeByteLimit(register)), dataType)
   }
   if (dataType === 'float') {
-    return wordsToFloat(words)
+    return bytesToFloatValue(bytes)
   }
   if (dataType === 'int8_t') {
-    const byteValue = getByteFromWord(words[0], register.byteOffset)
+    const byteValue = bytes[0] & 0xFF
     return byteValue & 0x80 ? byteValue - 0x100 : byteValue
   }
   if (dataType === 'uint8_t') {
-    return getByteFromWord(words[0], register.byteOffset)
+    return bytes[0] & 0xFF
   }
   if (dataType === 'int16_t') {
-    const wordValue = Number(words[0]) & 0xFFFF
-    return wordValue & 0x8000 ? wordValue - 0x10000 : wordValue
+    return bytesToSignedInteger(bytes.slice(0, 2))
   }
   if (dataType === 'uint16_t') {
-    return Number(words[0]) & 0xFFFF
+    return bytesToUnsignedInteger(bytes.slice(0, 2))
   }
   if (dataType === 'hex') {
-    return Number(words[0]) & 0xFFFF
+    return bytesToUnsignedInteger(bytes.slice(0, 2))
   }
 
-  const highWord = Number(words[0]) & 0xFFFF
-  const lowWord = Number(words[1]) & 0xFFFF
-  const unsignedValue = highWord * 0x10000 + lowWord
-
   if (dataType === 'int32_t') {
-    return unsignedValue >= 0x80000000 ? unsignedValue - 0x100000000 : unsignedValue
+    return bytesToSignedInteger(bytes.slice(0, 4))
   }
 
-  return unsignedValue
+  return bytesToUnsignedInteger(bytes.slice(0, 4))
 }
 
 function formatRegisterValue(register, rawValue) {
@@ -623,6 +706,7 @@ function normalizeRegisterDataType(register, registerType) {
 
 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 dataType = normalizeRegisterDataType(register, registerType)
   const textByteLength = isTextRegister(dataType)
     ? normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
@@ -631,8 +715,8 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
   const savedValue = getRegisterSavedValue(register)
   const inputValue = savedValue === null ? defaultValue : savedValue
   const rawValue = register.rawValue === undefined ? null : register.rawValue
-  const byteLength = isBitRegisterType(registerType) ? 1 : getRegisterByteLength(dataType, { textByteLength })
-  const registerCount = isBitRegisterType(registerType) ? 1 : getRegisterWordCountAtOffset(dataType, byteOffset, { textByteLength })
+  const byteLength = isBitRegisterType(registerType) ? 1 : getRegisterByteLength(dataType, { layout, textByteLength })
+  const registerCount = isBitRegisterType(registerType) ? 1 : getRegisterWordCountAtOffset(dataType, byteOffset, { layout, textByteLength })
   const canShowUnit = !isBitRegisterType(registerType) && supportsUnit(dataType)
   const rawWords = Array.isArray(register.rawWords)
     ? register.rawWords.slice(0, registerCount).map((word) => Number(word) & 0xFFFF)
@@ -654,6 +738,7 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     byteLengthText: isBitRegisterType(registerType)
       ? '1bit'
       : (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),
@@ -662,6 +747,8 @@ function normalizeRegister(register, group, index, address, byteOffset = 0) {
     id: register.id || createId('gm-reg'),
     inputType: isTextRegister(dataType) ? 'text' : 'text',
     inputValue,
+    isStructField: layout === GROUP_LAYOUT_STRUCT || !!register.isStructField,
+    layout,
     isDirty: !!register.isDirty,
     maxValue: normalizeTextValue(register.maxValue),
     minValue: normalizeTextValue(register.minValue),
@@ -687,6 +774,7 @@ function normalizeGroup(group) {
   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 hasExplicitQuantity = group.quantity !== undefined && group.quantity !== null && group.quantity !== ''
   const quantity = hasExplicitQuantity
     ? clampInteger(group.quantity, 1, maxQuantity, 1)
@@ -695,6 +783,8 @@ function normalizeGroup(group) {
     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,
@@ -707,6 +797,7 @@ function normalizeGroup(group) {
 
   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)
@@ -716,24 +807,34 @@ function normalizeGroup(group) {
     let byteOffset = 0
 
     if (!isBitRegister) {
-      const byteLength = getRegisterByteLength(dataType, { textByteLength })
-      if (!isByteRegister(dataType) && nextByteOffset % 2 !== 0) {
+      const byteLength = getRegisterByteLength(dataType, { layout, textByteLength })
+      if (layout !== GROUP_LAYOUT_STRUCT && !isByteRegister(dataType) && nextByteOffset % 2 !== 0) {
         nextByteOffset += 1
       }
 
       address = startAddress + Math.floor(nextByteOffset / 2)
       byteOffset = nextByteOffset % 2
+      normalizedSourceRegister = {
+        ...sourceRegister,
+        byteStart: nextByteOffset
+      }
       nextByteOffset += byteLength
     }
 
-    const register = normalizeRegister(sourceRegister, baseGroup, index, address, byteOffset)
+    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
+    : alignEvenByteLength(byteLength)
   const wordQuantity = isBitRegisterType(baseGroup.registerType)
     ? Math.max(1, nextAddress - startAddress)
-    : Math.max(1, Math.ceil(nextByteOffset / 2))
+    : Math.max(1, paddedByteLength / 2)
   const addressOverflow = isAddressRangeOverflow(startAddress, wordQuantity)
   const endAddress = startAddress + wordQuantity - 1
 
@@ -745,10 +846,12 @@ function normalizeGroup(group) {
     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,
     startAddressText: `0x${padHex(startAddress)}`,
     wordQuantity,
     writable: registerType.writable
@@ -762,6 +865,7 @@ function normalizeGroupConfig(config = {}) {
   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,
@@ -793,6 +897,7 @@ function normalizeImportedRegisterDataType(register) {
 
 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,
@@ -803,6 +908,7 @@ function cloneImportedGroup(group) {
       maxValue: register.maxValue,
       minValue: register.minValue,
       name: register.name,
+      isStructField: !!register.isStructField,
       textByteLength: register.textByteLength,
       remark: register.remark,
       unit: register.unit,
@@ -864,6 +970,36 @@ function getRegisterEncodedWords(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 getGroupEncodedWords(group) {
+  const byteLength = Math.max(2, Number(group && group.paddedByteLength) || ((Number(group && group.wordQuantity) || 1) * 2))
+  const bytes = Array.from({ length: byteLength }, () => 0)
+  const registers = Array.isArray(group && group.registers) ? group.registers : []
+
+  registers.forEach((register) => {
+    const registerBytes = encodeRegisterBytes(register)
+    if (!Array.isArray(registerBytes) || !registerBytes.length) {
+      throw new Error(`${register.name || '寄存器'} 没有有效写入值`)
+    }
+
+    const byteStart = getRegisterByteStart(register, group.startAddress)
+    for (let offset = 0; offset < register.byteLength; offset += 1) {
+      if (byteStart + offset < bytes.length) {
+        bytes[byteStart + offset] = Number(registerBytes[offset] || 0) & 0xFF
+      }
+    }
+  })
+
+  return bytesToWords(bytes)
+}
+
 function validateRegisterValue(register, value) {
   const valueText = normalizeTextValue(
     value === undefined || value === null ? getRegisterWriteValueText(register) : value
@@ -899,6 +1035,8 @@ module.exports = {
   DATA_TYPE_OPTIONS,
   DEFAULT_DATA_TYPE,
   DEFAULT_REGISTER_TYPE,
+  GROUP_LAYOUT_REGISTER,
+  GROUP_LAYOUT_STRUCT,
   MAX_MODBUS_ADDRESS,
   REGISTER_TYPE_OPTIONS,
   cloneImportedGroup,
@@ -908,14 +1046,18 @@ module.exports = {
   formatRegisterValue,
   getDataType,
   getRegisterEncodedWords,
+  getGroupEncodedWords,
+  getRegisterWordCount,
   getRegisterJsonValue,
   getRegisterWordsFromWordCache,
   getRegisterWriteValueText,
   isAddressRangeOverflow,
   isBitRegisterType,
   isByteRegister,
+  isTextRegister,
   normalizeGroup,
   normalizeGroupConfig,
+  normalizeRegister,
   parseCoilValue,
   registerTypeIsBit,
   splitWordSpans,

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

@@ -0,0 +1,277 @@
+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 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
+  }
+
+  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 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 cleaned = String(text || '')
+    .replace(/=.*/, '')
+    .replace(/:\s*\d+\s*$/, '')
+    .replace(/\*/g, '')
+    .trim()
+  const match = cleaned.match(/^([A-Za-z_]\w*)\s*((?:\[[^\]]*\])*)$/)
+  if (!match) return null
+
+  return {
+    arrayDimensions: parseArrayDimensions(match[2]),
+    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 createRegisterFromField(field, dataType, originalTypeText) {
+  const arrayLength = field.arrayDimensions.reduce((total, value) => total * value, 1)
+  const hasArray = field.arrayDimensions.length > 0
+
+  if (hasArray && isAsciiArray(originalTypeText, dataType, field.name, arrayLength)) {
+    return [{
+      dataType: 'ascii',
+      name: field.name,
+      textByteLength: String(arrayLength)
+    }]
+  }
+
+  if (!hasArray) {
+    return [{
+      dataType,
+      name: field.name
+    }]
+  }
+
+  const registers = []
+  for (let index = 0; index < arrayLength; index += 1) {
+    registers.push({
+      dataType,
+      name: `${field.name}[${index}]`
+    })
+  }
+
+  return registers
+}
+
+function parseStructFields(body, aliases) {
+  const registers = []
+  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))
+    })
+  })
+
+  return registers
+}
+
+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'
+  }
+}
+
+module.exports = {
+  parseStructDefinition,
+  stripComments
+}

+ 0 - 0
utils/calculation-context.js → domain/motor-control/calculation-context.js


+ 7 - 7
utils/control-page-state.js → domain/motor-control/control-state.js

@@ -4,36 +4,36 @@ const {
   readonlyParamRegisters,
   speedCommandRegister,
   statusRegisters
-} = require('./registers')
+} = require('./registers.js')
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   getSharedInputDefault,
   mergeInputValues,
   setSharedInputValues,
   updateDriverParams,
   toFiniteNumber
-} = require('./calculation-context')
+} = require('./calculation-context.js')
 const {
   calculateParameterInputWriteValue,
   calculateSpeedCommandWriteValue,
   SCALE_MAX,
   formatFixedValue
-} = require('./conversions')
+} = require('./conversions.js')
 const {
   updateStatusRegisterWords
-} = require('./status-format')
+} = require('./status-format.js')
 const {
   floatToWords,
   getRegisterWordCache,
   toRegisterWord,
   toAddressKey,
   wordsToFloat
-} = require('./register-value-utils')
+} = require('../../utils/register-value-utils.js')
 const {
   appendInputUnit
-} = require('./input-value-utils')
+} = require('./input-value-utils.js')
 
 const AUTO_READ_MIN_INTERVAL = 100
 const AUTO_READ_MAX_INTERVAL = 3000

+ 2 - 2
utils/conversions.js → domain/motor-control/conversions.js

@@ -6,11 +6,11 @@ const {
   getDriverParams,
   getSharedInputValues,
   toFiniteNumber
-} = require('./calculation-context')
+} = require('./calculation-context.js')
 const {
   rawToTemperature,
   temperatureToRaw
-} = require('./thermistor')
+} = require('./thermistor.js')
 
 function getSpeedBase(inputValues = {}, driverParams = DEFAULT_DRIVER_PARAMS) {
   const candidates = [

+ 21 - 0
domain/motor-control/data.js

@@ -0,0 +1,21 @@
+const calculationContext = require('./calculation-context.js')
+const controlState = require('./control-state.js')
+const conversions = require('./conversions.js')
+const inputValueUtils = require('./input-value-utils.js')
+const paramsState = require('./params-state.js')
+const registerValueUtils = require('../../utils/register-value-utils.js')
+const registers = require('./registers.js')
+const statusFormat = require('./status-format.js')
+const statusState = require('./status-state.js')
+
+module.exports = {
+  calculationContext,
+  controlState,
+  conversions,
+  inputValueUtils,
+  paramsState,
+  registerValueUtils,
+  registers,
+  statusFormat,
+  statusState
+}

+ 7 - 0
domain/motor-control/index.js

@@ -0,0 +1,7 @@
+module.exports = {
+  conversions: require('./conversions.js'),
+  data: require('./data.js'),
+  registerGroups: require('./register-groups.js'),
+  registers: require('./registers.js'),
+  statusFormat: require('./status-format.js')
+}

+ 1 - 1
utils/input-value-utils.js → domain/motor-control/input-value-utils.js

@@ -1,6 +1,6 @@
 const {
   toFiniteNumber
-} = require('./calculation-context')
+} = require('./calculation-context.js')
 
 function getInputTextWithoutUnit(item, value) {
   const text = String(value === undefined || value === null ? '' : value).trim()

+ 6 - 6
utils/params-page-state.js → domain/motor-control/params-state.js

@@ -11,15 +11,15 @@ const {
   speedSlopeRegister,
   tailwindSwitchRegisters,
   getByteRegisterValue
-} = require('./registers')
+} = require('./registers.js')
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   getSharedInputValues,
   mergeInputValues,
   toFiniteNumber
-} = require('./calculation-context')
+} = require('./calculation-context.js')
 const {
   calculateAtoGainWriteValues,
   calculateDqGainWriteValue,
@@ -28,14 +28,14 @@ const {
   calculateProtectionWriteValue,
   calculateSpeedSlope,
   formatFixedValue
-} = require('./conversions')
+} = require('./conversions.js')
 const {
   toAddressKey,
   wordsToFloat
-} = require('./register-value-utils')
+} = require('../../utils/register-value-utils.js')
 const {
   appendInputUnit
-} = require('./input-value-utils')
+} = require('./input-value-utils.js')
 
 const VSP_CURVE_ORDER = [
   '开机电压',

+ 2 - 2
utils/motor-control-register-groups.js → domain/motor-control/register-groups.js

@@ -1,9 +1,9 @@
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   getRegisterCount
-} = require('./registers')
+} = require('./registers.js')
 
 function parseRegisterAddress(address) {
   return parseHexInteger(address)

+ 1 - 1
utils/registers.js → domain/motor-control/registers.js

@@ -1,6 +1,6 @@
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 
 const MODBUS_AREAS = {
   coil: {

+ 4 - 4
utils/status-format.js → domain/motor-control/status-format.js

@@ -1,16 +1,16 @@
 const {
   getFaultText
-} = require('./calculation-context')
+} = require('./calculation-context.js')
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   calculateStatusValue,
   formatFixedValue
-} = require('./conversions')
+} = require('./conversions.js')
 const {
   getByteRegisterValue
-} = require('./registers')
+} = require('./registers.js')
 
 const statusWordValues = {}
 

+ 48 - 4
utils/status-page-state.js → domain/motor-control/status-state.js

@@ -1,13 +1,13 @@
 const {
   statusRegisters
-} = require('./registers')
+} = require('./registers.js')
 const {
   MAX_USER_STATUS_COUNT,
   getUserStatusCount
-} = require('./control-page-state')
+} = require('./control-state.js')
 const {
   formatStatusRegisters
-} = require('./status-format')
+} = require('./status-format.js')
 
 const STATUS_SUMMARY_METRICS = [
   { key: 'speed', name: '估算速度', unit: 'RPM', decimals: 0 },
@@ -15,6 +15,38 @@ const STATUS_SUMMARY_METRICS = [
   { key: 'power', name: '估算功率', unit: 'W', decimals: 1 },
   { key: 'temperature', name: 'NTC 温度', unit: '℃', decimals: 0 }
 ]
+const STATUS_GROUPS = [
+  {
+    key: 'system',
+    title: '状态机 / 故障码',
+    names: ['状态机', '故障码']
+  },
+  {
+    key: 'dqVoltageCurrent',
+    title: 'DQ 电压电流',
+    names: ['UD', 'UQ', 'ID', 'IQ']
+  },
+  {
+    key: 'phaseCurrent',
+    title: '相电流',
+    names: ['A 相电流', 'B 相电流', 'C 相电流', '相电流最大值', '相电流最小值']
+  },
+  {
+    key: 'estimator',
+    title: '估算器状态',
+    names: ['估算速度', '估算反电动势']
+  },
+  {
+    key: 'bus',
+    title: '母线 / 温度',
+    names: ['母线电压', '母线电流', '估算功率', 'NTC 温度']
+  },
+  {
+    key: 'inputPwm',
+    title: '输入 / PWM',
+    names: ['模拟输入电压', '占空比', '频率']
+  }
+]
 
 function getVisibleStatusRegisters(userStatusCount) {
   const count = getUserStatusCount(userStatusCount)
@@ -26,9 +58,21 @@ function getVisibleStatusRegisters(userStatusCount) {
 }
 
 function getStatusPageState(userStatusCount) {
+  const registers = formatStatusRegisters(getVisibleStatusRegisters(userStatusCount))
+  const registerMap = registers.reduce((result, item) => {
+    result[item.name] = item
+    return result
+  }, {})
+
   return {
     maxUserStatusCount: MAX_USER_STATUS_COUNT,
-    statusRegisters: formatStatusRegisters(getVisibleStatusRegisters(userStatusCount))
+    statusRegisterGroups: STATUS_GROUPS.map((group) => ({
+      key: group.key,
+      title: group.title,
+      registers: group.names.map((name) => registerMap[name]).filter(Boolean)
+    })).filter((group) => group.registers.length),
+    statusRegisters: registers,
+    userStatusRegisters: registers.filter((item) => item.name.indexOf('用户状态字') === 0)
   }
 }
 

+ 0 - 0
utils/thermistor.js → domain/motor-control/thermistor.js


+ 3 - 0
features/bootloader/index.js

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

+ 28 - 189
utils/bootloader-service.js → features/bootloader/service.js

@@ -1,37 +1,39 @@
-const transport = require('./ble-transport')
+const transport = require('../../transport/ble-core.js')
 const {
-  BYTE_ORDER_HIGH,
-  crc16Ccitt,
-  appendCrc16Ccitt,
-  hasValidCrc16Ccitt
-} = require('./crc')
-const {
-  BOOTLOADER_HEAD
-} = require('./bootloader-frame')
+  PROGRAM_CHUNK_SIZE,
+  alignBootloaderBuffer,
+  assertBootloaderAck,
+  buildExitFrame,
+  buildFlashCheckFrame,
+  buildHandshakeFrame,
+  buildPageEraseFrame,
+  buildProgramFrame,
+  buildUnlockFrame,
+  calculateBootloaderCrc,
+  formatBootloaderCrc,
+  getBootloaderExpectedLength,
+  parseBootloaderResponse,
+  toHex
+} = require('../../protocols/bootloader/frame.js')
 const {
   softReset
-} = require('./motor-control-protocol')
+} = require('../motor-control/protocol-service.js')
 const {
   delay
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
-  formatBytes,
   isCancelError,
   loadSelectedFile
-} = require('./file-service')
+} = require('../../repositories/file.js')
+const {
+  formatBytes
+} = require('../../utils/binary-utils.js')
 
-const HEAD = BOOTLOADER_HEAD
-const BOOTLOADER_CRC_OPTIONS = {
-  byteOrder: BYTE_ORDER_HIGH
-}
-const ACK = 0x06
-const NAK = 0x15
 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 PROGRAM_CHUNK_SIZE = 128
 const FILE_SIZES = {
   16: 16 * 1024,
   32: 32 * 1024
@@ -164,14 +166,6 @@ function init() {
   initialized = true
 }
 
-function toHex(value, length = 2) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
-}
-
-function formatCrc(value) {
-  return `0x${toHex(value, 4)}`
-}
-
 function normalizeModel(value) {
   const text = String(value || '').trim()
   return text && text !== '--' ? text : ''
@@ -262,166 +256,14 @@ function setChipModel(chipModel) {
   })
 }
 
-function buildFrame(payload) {
-  return new Uint8Array(appendCrc16Ccitt(HEAD.concat(payload), BOOTLOADER_CRC_OPTIONS))
-}
-
-function buildHandshakeFrame() {
-  return buildFrame([0x39, 0x42, 0x4C])
-}
-
-function buildUnlockFrame() {
-  return buildFrame([0x08, 0x4E, 0x00])
-}
-
-function buildProgramFrame(address, dataBytes) {
-  const payload = [
-    0x44,
-    address & 0xFF,
-    (address >> 8) & 0xFF
-  ]
-  const data = Array.prototype.slice.call(dataBytes || []).slice(0, PROGRAM_CHUNK_SIZE)
-
-  while (data.length < PROGRAM_CHUNK_SIZE) {
-    data.push(0x00)
-  }
-
-  return buildFrame(payload.concat(data))
-}
-
-function buildFlashCheckFrame() {
-  return buildFrame([0x19, 0x43, 0x43])
-}
-
-function buildPageEraseFrame(enabled) {
-  return buildFrame([0x08, 0x50, enabled ? 0x45 : 0x44])
-}
-
-function buildExitFrame() {
-  return buildFrame([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 getHandshakeDetail(response) {
   if (!response) return '--'
 
   return `${response.versionText || '--'} / ${response.chipIdText || '--'}`
 }
 
-function alignBootloaderBuffer(buffer) {
-  let headIndex = -1
-
-  for (let index = 0; index < buffer.length - 1; index += 1) {
-    if (buffer[index] === HEAD[0] && buffer[index + 1] === 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 parseResponse(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: formatCrc(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 assertAck(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)}`)
-}
-
-function getExpectedLength(kind) {
-  if (kind === 'handshake') return 15
-  if (kind === 'flashCheck') return 9
-  return 8
-}
-
 function waitForResponse(kind, timeout, options = {}) {
-  const expectedLength = getExpectedLength(kind)
+  const expectedLength = getBootloaderExpectedLength(kind)
   const buffer = []
 
   return new Promise((resolve, reject) => {
@@ -446,7 +288,7 @@ function waitForResponse(kind, timeout, options = {}) {
       const frame = buffer.slice(0, expectedLength)
 
       try {
-        const response = parseResponse(frame, kind)
+        const response = parseBootloaderResponse(frame, kind)
         cleanup()
         resolve(response)
       } catch (error) {
@@ -624,10 +466,7 @@ async function chooseFirmwareFile(source = 'message') {
       fallbackName: 'firmware.bin'
     })
     firmwareBytes = file.bytes
-    const firmwareCrcText = formatCrc(crc16Ccitt(
-      Array.prototype.slice.call(firmwareBytes),
-      BOOTLOADER_CRC_OPTIONS
-    ))
+    const firmwareCrcText = formatBootloaderCrc(calculateBootloaderCrc(firmwareBytes))
     const firmwareSizeText = formatBytes(firmwareBytes.length)
     const validation = getFirmwareValidation(firmwareBytes.length)
 
@@ -685,13 +524,13 @@ async function startUpgrade() {
       bootloaderDetailText: '编程解锁',
       bootloaderStatusText: '升级中'
     })
-    assertAck(await sendBootloaderFrame(buildUnlockFrame(), 'Bootloader解锁', 'unlock'), '编程解锁')
+    assertBootloaderAck(await sendBootloaderFrame(buildUnlockFrame(), 'Bootloader解锁', 'unlock'), '编程解锁')
 
     setState({
       bootloaderDetailText: '开启页擦除',
       bootloaderStatusText: '升级中'
     })
-    assertAck(await sendBootloaderFrame(buildPageEraseFrame(true), '页擦除使能', 'pageErase'), '页擦除使能')
+    assertBootloaderAck(await sendBootloaderFrame(buildPageEraseFrame(true), '页擦除使能', 'pageErase'), '页擦除使能')
 
     const totalBytes = layout.endAddress - layout.startAddress
     let programmedBytes = 0
@@ -705,7 +544,7 @@ async function startUpgrade() {
         PROGRAM_RESPONSE_TIMEOUT_MS
       )
 
-      assertAck(response, `编程 0x${toHex(address, 4)}`)
+      assertBootloaderAck(response, `编程 0x${toHex(address, 4)}`)
       if (response.address !== address) {
         throw new Error(`编程地址反馈不匹配:0x${toHex(response.address, 4)}`)
       }

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

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

+ 2 - 2
utils/generic-modbus-poller.js → features/generic-modbus/poller.js

@@ -1,10 +1,10 @@
-const genericModbusService = require('./generic-modbus-service')
+const genericModbusService = require('./service.js')
 
 const POLL_TIMER_ID = '__genericPoll'
 
 function shouldPoll(data) {
   return !!data
-    && data.activeParamView === 'genericModbus'
+    && (data.activeParamView === 'genericModbus' || data.activeParamView === 'genericModbusGroup')
     && !!data.connectedDevice
     && !!data.genericModbusAutoPollEnabled
 }

+ 93 - 33
utils/generic-modbus-service.js → features/generic-modbus/service.js

@@ -3,16 +3,16 @@ const {
   isCancelError,
   loadSelectedFile,
   saveTextFileToChat
-} = require('./file-service')
+} = require('../../repositories/file.js')
 const {
   getWxApi
-} = require('./platform-utils')
+} = require('../../utils/platform-utils.js')
 const {
   parseHexInteger
-} = require('./base-utils')
-const transport = require('./ble-transport')
-const settingsService = require('./settings-service')
-const modbusAccess = require('./modbus-access')
+} = 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,
@@ -22,6 +22,7 @@ const {
   formatCoilDisplayValue,
   formatRegisterValue,
   getDataType,
+  getGroupEncodedWords,
   getRegisterEncodedWords,
   getRegisterJsonValue,
   getRegisterWordsFromWordCache,
@@ -35,7 +36,10 @@ const {
   registerTypeIsBit,
   splitWordSpans,
   validateRegisterValue
-} = require('./generic-modbus-model')
+} = require('../../domain/generic-modbus/model.js')
+const {
+  parseStructDefinition: parseStructDefinitionSource
+} = require('../../domain/generic-modbus/struct-parser.js')
 
 const STORAGE_KEY = 'generic-modbus-groups-json'
 const JSON_DOCUMENT_TYPE = 'generic-modbus-rtu'
@@ -79,11 +83,12 @@ function resolveMaxPacketLength(value) {
 function getWriteSpanMaxQuantity(totalQuantity, maxPacketLength) {
   if (maxPacketLength === 0) return Math.max(1, totalQuantity)
 
-  return Math.max(1, modbusAccess.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
+  return Math.max(1, modbusClient.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
 }
 
 function toPersistedGroups(groups) {
   return groups.map((group) => ({
+    layout: group.layout,
     name: group.name,
     registerType: group.registerType,
     startAddress: group.startAddress,
@@ -91,6 +96,7 @@ function toPersistedGroups(groups) {
     registers: group.registers.map((register) => ({
       dataType: register.dataType,
       defaultValue: register.defaultValue,
+      isStructField: register.isStructField,
       name: register.name,
       maxValue: register.maxValue,
       minValue: register.minValue,
@@ -262,8 +268,11 @@ function addGroupFromConfig(config = {}) {
     return null
   }
 
+  const registers = Array.isArray(config.registers) ? config.registers : []
   const group = normalizeGroup({
     ...groupConfig,
+    layout: config.layout,
+    ...(registers.length ? { registers } : {}),
     expanded: false
   })
 
@@ -299,9 +308,11 @@ function updateGroupConfig(groupId, config = {}) {
     return null
   }
 
+  const registers = Array.isArray(config.registers) ? config.registers : group.registers
   const updatedGroup = normalizeGroup({
     ...group,
-    ...nextConfig
+    ...nextConfig,
+    registers
   })
 
   if (updatedGroup.addressOverflow) {
@@ -353,6 +364,38 @@ function removeGroup(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
@@ -391,9 +434,13 @@ function validateRegisterInputValue(groupId, registerIndex, value) {
   return validateRegisterValue(register, value)
 }
 
+function parseStructDefinition(sourceText) {
+  return parseStructDefinitionSource(sourceText)
+}
+
 async function readGroup(groupId, options = {}) {
   const group = findGroup(groupId)
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (!group || slaveAddress === null) return false
   if (group.addressOverflow) {
     transport.showCommandAlert('通用Modbus读取', '寄存器地址范围超出 0xFFFF')
@@ -404,7 +451,7 @@ async function readGroup(groupId, options = {}) {
   const maxPacketLength = resolveMaxPacketLength(options.maxPacketLength)
   const wordCache = {}
 
-  const values = await modbusAccess.readSpans(
+  const values = await modbusClient.readSpans(
     slaveAddress,
     group.functionCode,
     [{
@@ -465,7 +512,7 @@ async function readGroup(groupId, options = {}) {
 
 async function writeGroup(groupId) {
   const group = findGroup(groupId)
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   const maxPacketLength = resolveMaxPacketLength()
   if (!group || slaveAddress === null) return false
   if (!group.writable) {
@@ -489,7 +536,7 @@ async function writeGroup(groupId) {
         return false
       }
 
-      const response = await modbusAccess.writeSingleCoil(
+      const response = await modbusClient.writeSingleCoil(
         slaveAddress,
         group.startAddress + index,
         !!coilValue,
@@ -508,29 +555,40 @@ async function writeGroup(groupId) {
       })
     }
   } else {
-    const words = Array.from({ length: Math.max(1, group.wordQuantity || 1) }, () => 0)
-    for (let index = 0; index < group.registers.length; index += 1) {
-      const register = group.registers[index]
-      const registerWords = getRegisterEncodedWords(register)
+    let words
 
-      if (!Array.isArray(registerWords) || !registerWords.length) {
-        transport.showCommandAlert('通用Modbus写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
-        return false
-      }
+    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)
 
-      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
+          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) => {
@@ -558,7 +616,7 @@ async function writeGroup(groupId) {
       const spanWords = words.slice(cursor, cursor + span.quantity)
       cursor += span.quantity
 
-      const response = await modbusAccess.writeMultipleRegisters(
+      const response = await modbusClient.writeMultipleRegisters(
         slaveAddress,
         span.address,
         spanWords,
@@ -608,8 +666,10 @@ module.exports = {
   getState,
   importJsonFromMessageFile,
   init,
+  parseStructDefinition,
   readGroup,
   removeGroup,
+  reorderRegister,
   saveJsonToChat,
   setGroupDeleteVisible,
   setGroupExpanded,

+ 125 - 0
features/home/service.js

@@ -0,0 +1,125 @@
+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,
+  getHomePageState
+} = require('./view-model.js')
+
+let syncService = null
+let initScheduled = false
+const syncSubscriptionHooks = []
+
+function deferStartupWork(task) {
+  if (typeof task !== 'function') return
+
+  if (typeof setTimeout === 'function') {
+    setTimeout(task, 120)
+    return
+  }
+
+  task()
+}
+
+function getSyncService() {
+  if (!syncService) {
+    syncService = require('../motor-control/sync-service.js')
+    syncSubscriptionHooks.slice().forEach((hook) => {
+      hook(syncService)
+    })
+  }
+
+  return syncService
+}
+
+function getSyncState() {
+  return syncService ? syncService.getState() : {
+    isSyncing: false
+  }
+}
+
+function init() {
+  if (initScheduled) return
+
+  initScheduled = true
+  deferStartupWork(() => {
+    try {
+      themeService.init()
+    } catch (error) {}
+  })
+}
+
+function getState(deviceFilterMode = DEFAULT_DEVICE_FILTER) {
+  return getHomePageState(
+    transport.getState(),
+    deviceFilterMode,
+    getSyncState(),
+    themeService.getState(),
+    manualRtuService.getState()
+  )
+}
+
+function subscribeState(getDeviceFilterMode, subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  const getFilterMode = typeof getDeviceFilterMode === 'function'
+    ? getDeviceFilterMode
+    : () => DEFAULT_DEVICE_FILTER
+  const emit = () => subscriber(getState(getFilterMode()))
+  let syncUnsubscribe = null
+  const syncHook = (service) => {
+    if (!syncUnsubscribe) {
+      syncUnsubscribe = service.subscribe(emit)
+    }
+  }
+
+  syncSubscriptionHooks.push(syncHook)
+  if (syncService) syncHook(syncService)
+
+  const unsubscribers = [
+    transport.subscribe(emit),
+    manualRtuService.subscribe(emit),
+    themeService.subscribe(emit)
+  ]
+
+  return () => {
+    unsubscribers.forEach((unsubscribe) => {
+      if (typeof unsubscribe === 'function') unsubscribe()
+    })
+
+    if (typeof syncUnsubscribe === 'function') syncUnsubscribe()
+
+    const index = syncSubscriptionHooks.indexOf(syncHook)
+    if (index >= 0) syncSubscriptionHooks.splice(index, 1)
+  }
+}
+
+function toggleScan(isDiscovering) {
+  return isDiscovering ? transport.stopScan() : transport.startScan()
+}
+
+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,
+  syncRegisters: () => getSyncService().syncAllRegisters(),
+  toggleScan,
+  validateProtocolMultipleValue: manualRtuService.validateProtocolMultipleValue
+}

+ 8 - 6
utils/home-view-model.js → features/home/view-model.js

@@ -1,6 +1,6 @@
-const transport = require('./ble-transport')
-const syncService = require('./sync-service')
-const themeService = require('./theme-service')
+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'
 const DEVICE_FILTER_OPTIONS = [
@@ -21,8 +21,9 @@ function filterDevices(devices, filterMode) {
 function getHomePageState(
   transportState = transport.getState(),
   deviceFilterMode = DEFAULT_DEVICE_FILTER,
-  syncState = syncService.getState(),
-  themeState = themeService.getState()
+  syncState = {},
+  themeState = themeService.getState(),
+  manualRtuState = manualRtuService.getState()
 ) {
   const { connectedDevice } = transportState
   const filteredDevices = filterDevices(transportState.devices, deviceFilterMode)
@@ -34,6 +35,7 @@ function getHomePageState(
 
   return {
     ...transportState,
+    ...manualRtuState,
     ...themeState,
     allDeviceCount,
     canClearDevices: !!allDeviceCount && !transportState.isConnecting,
@@ -60,7 +62,7 @@ function getHomePageState(
     emptyDeviceTitle: allDeviceCount && deviceFilterMode === 'target'
       ? '没有匹配目标特征的设备'
       : '还没有发现设备',
-    isSyncing: syncState.isSyncing,
+    isSyncing: !!syncState.isSyncing,
     scanButtonText: transportState.isDiscovering ? '停止' : '扫描',
     showDeviceSection: transportState.isDiscovering
   }

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

@@ -0,0 +1,518 @@
+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' }
+]
+
+const state = {
+  commandIndex: 2,
+  commandRegisterQuantity: '0001',
+  commandValue: '0001',
+  commandValueLabel: '读取数量',
+  coilEnabled: true,
+  generatedHex: '',
+  protocolCommands: MODBUS_COMMANDS,
+  protocolDataTypeOptions: DATA_TYPE_OPTIONS,
+  protocolErrorText: '',
+  protocolMultipleDialog: {
+    visible: false
+  },
+  protocolMultipleValues: [],
+  registerAddress: '0000',
+  showCoilValue: false,
+  showCommandValue: true,
+  showRegisterQuantity: false,
+  slaveAddress: 'F0'
+}
+
+const subscribers = []
+
+function setState(changedData) {
+  Object.assign(state, changedData)
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(getState())
+  })
+}
+
+function getState() {
+  return {
+    ...state,
+    protocolCommands: state.protocolCommands.slice(),
+    protocolDataTypeOptions: state.protocolDataTypeOptions.slice(),
+    protocolMultipleDialog: {
+      ...state.protocolMultipleDialog
+    },
+    protocolMultipleValues: state.protocolMultipleValues.map((item) => ({
+      ...item
+    }))
+  }
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+function 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
+  let nextCommandValue = changedData.commandValue
+  if (
+    command.inputMode === 'multiple'
+    && Object.prototype.hasOwnProperty.call(changedData, 'registerAddress')
+    && !Object.prototype.hasOwnProperty.call(changedData, 'protocolMultipleValues')
+  ) {
+    try {
+      const startAddress = parseHexNumber(changedData.registerAddress, '起始地址', 0xFFFF)
+      nextMultipleValues = normalizeManualMultipleValues(
+        normalizeManualMultipleQuantity(state.commandRegisterQuantity),
+        state.protocolMultipleValues,
+        startAddress
+      )
+      nextCommandValue = getManualMultipleValueText(nextMultipleValues)
+    } catch (error) {}
+  }
+  const nextState = {
+    commandIndex: state.commandIndex,
+    commandRegisterQuantity: state.commandRegisterQuantity,
+    slaveAddress: state.slaveAddress,
+    registerAddress: state.registerAddress,
+    commandValue: state.commandValue,
+    coilEnabled: state.coilEnabled,
+    ...changedData,
+    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
+    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {})
+  }
+
+  setState({
+    ...changedData,
+    ...(nextMultipleValues ? { protocolMultipleValues: nextMultipleValues } : {}),
+    ...(nextCommandValue !== undefined ? { commandValue: nextCommandValue } : {}),
+    ...createProtocolState(
+      nextState.commandIndex,
+      nextState.slaveAddress,
+      nextState.registerAddress,
+      nextState.commandValue,
+      nextState.coilEnabled,
+      nextState.commandRegisterQuantity
+    )
+  })
+}
+
+function setCommandIndex(index) {
+  const commandIndex = Number(index)
+  const command = getCommand(commandIndex)
+  const commandValue = command.inputMode === 'multiple'
+    ? getManualMultipleValueText(state.protocolMultipleValues)
+    : getDefaultCommandValue(command)
+
+  setProtocolInput({
+    commandIndex,
+    commandValue,
+    coilEnabled: true,
+    commandRegisterQuantity: state.commandRegisterQuantity
+  })
+}
+
+function openProtocolMultipleDialog() {
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({
+      protocolErrorText: error.message || '起始地址无效'
+    })
+    return
+  }
+
+  const quantity = normalizeManualMultipleQuantity(state.commandRegisterQuantity)
+  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
+  setState({
+    commandRegisterQuantity: formatManualMultipleQuantity(quantity),
+    protocolMultipleDialog: {
+      title: '写多个寄存器',
+      visible: true
+    },
+    protocolMultipleValues: values
+  })
+}
+
+function closeProtocolMultipleDialog() {
+  setState({
+    protocolMultipleDialog: {
+      visible: false
+    }
+  })
+}
+
+function setProtocolMultipleQuantity(value) {
+  const quantity = normalizeManualMultipleQuantity(value)
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({
+      commandRegisterQuantity: value,
+      protocolErrorText: error.message || '起始地址无效'
+    })
+    return
+  }
+  const values = normalizeManualMultipleValues(quantity, state.protocolMultipleValues, startAddress)
+  const commandValue = getManualMultipleValueText(values)
+
+  setProtocolInput({
+    commandRegisterQuantity: value,
+    commandValue,
+    protocolMultipleValues: values
+  })
+}
+
+function setProtocolMultipleValue(index, value) {
+  const registerIndex = Number(index)
+  const values = state.protocolMultipleValues.map((register, currentIndex) => (
+    currentIndex === registerIndex
+      ? {
+        ...register,
+        inputValue: value
+      }
+      : register
+  ))
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const normalizedValues = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), values, startAddress)
+  const commandValue = getManualMultipleValueText(normalizedValues)
+
+  setProtocolInput({
+    commandValue,
+    protocolMultipleValues: normalizedValues
+  })
+}
+
+function setProtocolMultipleType(index, dataTypeIndex) {
+  const 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
+  ))
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
+
+  setProtocolInput({
+    commandValue: getManualMultipleValueText(values),
+    protocolMultipleValues: values
+  })
+}
+
+function setProtocolMultipleTextLength(index, value) {
+  const registerIndex = Number(index)
+  const changedValues = state.protocolMultipleValues.map((register, currentIndex) => (
+    currentIndex === registerIndex
+      ? createManualMultipleRegister(currentIndex, {
+        ...register,
+        textByteLength: value
+      })
+      : register
+  ))
+  let startAddress = 0
+  try {
+    startAddress = parseHexNumber(state.registerAddress, '起始地址', 0xFFFF)
+  } catch (error) {
+    setState({ protocolErrorText: error.message || '起始地址无效' })
+    return
+  }
+  const values = normalizeManualMultipleValues(normalizeManualMultipleQuantity(state.commandRegisterQuantity), changedValues, startAddress)
+
+  setProtocolInput({
+    commandValue: getManualMultipleValueText(values),
+    protocolMultipleValues: values
+  })
+}
+
+function validateProtocolMultipleValue(index, value) {
+  const register = state.protocolMultipleValues[Number(index)]
+  if (!register) return false
+
+  return validateRegisterValue(register, 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
+  }
+}
+
+function sendGeneratedFrame() {
+  if (!state.generatedHex) return false
+
+  const expected = buildGeneratedExpectedResponse()
+
+  return transport.enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
+    expected
+  } : {})
+}
+
+setState(createProtocolState(
+  state.commandIndex,
+  state.slaveAddress,
+  state.registerAddress,
+  state.commandValue,
+  state.coilEnabled,
+  state.commandRegisterQuantity
+))
+
+module.exports = {
+  buildGeneratedExpectedResponse,
+  closeProtocolMultipleDialog,
+  getState,
+  openProtocolMultipleDialog,
+  sendGeneratedFrame,
+  setCommandIndex,
+  setProtocolInput,
+  setProtocolMultipleQuantity,
+  setProtocolMultipleTextLength,
+  setProtocolMultipleType,
+  setProtocolMultipleValue,
+  subscribe,
+  validateProtocolMultipleValue
+}

+ 19 - 19
utils/control-service.js → features/motor-control/control-service.js

@@ -1,14 +1,14 @@
 const {
   controlState
-} = require('./motor-control-data')
+} = require('../../domain/motor-control/data.js')
 const {
   parseHexInteger
-} = require('./base-utils')
-const transport = require('./ble-transport')
-const bootloaderService = require('./bootloader-service')
-const settingsService = require('./settings-service')
-const modbusAccess = require('./modbus-access')
-const motorControlProtocol = require('./motor-control-protocol')
+} = require('../../utils/base-utils.js')
+const transport = require('../../transport/ble-core.js')
+const bootloaderService = require('../bootloader/service.js')
+const settingsService = require('../../store/settings-store.js')
+const modbusClient = require('../../protocols/modbus-rtu/client.js')
+const motorControlProtocol = require('./protocol-service.js')
 
 let state = {
   ...controlState.createInitialState(),
@@ -159,7 +159,7 @@ function getSpeedCommandWriteWord() {
 }
 
 async function sendSpeedCommand() {
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const writeWord = getSpeedCommandWriteWord()
@@ -169,7 +169,7 @@ async function sendSpeedCommand() {
   }
 
   const address = parseHexInteger(state.speedCommand.address)
-  const response = await modbusAccess.writeSingleRegister(
+  const response = await modbusClient.writeSingleRegister(
     slaveAddress,
     address,
     writeWord,
@@ -207,12 +207,12 @@ async function sendControlCommand(key) {
 }
 
 async function readControlStatus() {
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const startAddress = 0x00
   const quantity = 3
-  const coilValues = await modbusAccess.readBitValues(
+  const coilValues = await modbusClient.readBitValues(
     slaveAddress,
     0x01,
     startAddress,
@@ -234,7 +234,7 @@ async function readControlStatus() {
 async function readMotorParameters() {
   if (state.isReadingMotor) return
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   setState({
@@ -244,7 +244,7 @@ async function readMotorParameters() {
   })
 
   try {
-    const words = await modbusAccess.readRegisterWords(
+    const words = await modbusClient.readRegisterWords(
       slaveAddress,
       0x03,
       controlState.MOTOR_PARAM_START_ADDRESS,
@@ -271,7 +271,7 @@ async function readMotorParameters() {
 async function writeMotorParameters() {
   if (state.isWritingMotor) return
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   const mainWrite = controlState.buildMotorMainWriteValues(state)
@@ -287,7 +287,7 @@ async function writeMotorParameters() {
   })
 
   try {
-    const mainResponse = await modbusAccess.writeMultipleRegisters(
+    const mainResponse = await modbusClient.writeMultipleRegisters(
       slaveAddress,
       controlState.MOTOR_PARAM_START_ADDRESS,
       mainWrite.values,
@@ -310,7 +310,7 @@ async function writeMotorParameters() {
 async function readDriverParameters() {
   if (state.isReadingDriver) return
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   setState({
@@ -320,7 +320,7 @@ async function readDriverParameters() {
   })
 
   try {
-    const words = await modbusAccess.readRegisterWords(
+    const words = await modbusClient.readRegisterWords(
       slaveAddress,
       0x04,
       controlState.DRIVER_PARAM_START_ADDRESS,
@@ -388,10 +388,10 @@ function setAutoReadStatus(autoReadStatus) {
 async function readStatus(options = {}) {
   if (options.auto && !state.connectedDevice) return false
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
-  const words = await modbusAccess.readRegisterWords(
+  const words = await modbusClient.readRegisterWords(
     slaveAddress,
     0x04,
     controlState.STATUS_START_ADDRESS,

+ 3 - 3
utils/control-view-model.js → features/motor-control/control-view-model.js

@@ -1,8 +1,8 @@
-const controlService = require('./control-service')
-const themeService = require('./theme-service')
+const controlService = require('./control-service.js')
+const themeService = require('../../store/theme-store.js')
 const {
   getStatusSummaryState
-} = require('./status-page-state')
+} = require('../../domain/motor-control/status-state.js')
 
 function getControlPageState(
   controlState = controlService.getState(),

+ 16 - 0
features/motor-control/index.js

@@ -0,0 +1,16 @@
+const controlViewModel = require('./control-view-model.js')
+const domain = require('../../domain/motor-control/index.js')
+const paramsViewModel = require('./params-view-model.js')
+
+module.exports = {
+  controlService: require('./control-service.js'),
+  controlViewModel,
+  domain,
+  paramsPageState: domain.data.paramsState,
+  paramsService: require('./params-service.js'),
+  paramsViewModel,
+  protocolService: require('./protocol-service.js'),
+  syncService: require('./sync-service.js'),
+  ...controlViewModel,
+  ...paramsViewModel
+}

+ 15 - 15
utils/params-service.js → features/motor-control/params-service.js

@@ -1,6 +1,6 @@
 const {
   paramsState: paramsPageState
-} = require('./motor-control-data')
+} = require('../../domain/motor-control/data.js')
 const {
   expandItems,
   getAreaKey,
@@ -8,13 +8,13 @@ const {
   makeReadSpans,
   mergeReadValues,
   parseRegisterAddress
-} = require('./motor-control-register-groups')
-const transport = require('./ble-transport')
-const modbusAccess = require('./modbus-access')
+} = require('../../domain/motor-control/register-groups.js')
+const transport = require('../../transport/ble-core.js')
+const modbusClient = require('../../protocols/modbus-rtu/client.js')
 const {
   floatToWords,
   toRegisterWord
-} = require('./register-value-utils')
+} = require('../../utils/register-value-utils.js')
 
 function hasWriteValue(value) {
   return value !== '' && value !== undefined && value !== null && value !== '--'
@@ -30,7 +30,7 @@ function toWriteNumber(value) {
 }
 
 async function readGroup(data, groupKey) {
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const items = expandItems(getGroupItems(data, groupKey))
@@ -48,7 +48,7 @@ async function readGroup(data, groupKey) {
 
   if (coilSpans.length) {
     sent = true
-    const values = await modbusAccess.readSpans(
+    const values = await modbusClient.readSpans(
       slaveAddress,
       0x01,
       coilSpans,
@@ -61,7 +61,7 @@ async function readGroup(data, groupKey) {
 
   if (holdingSpans.length) {
     sent = true
-    const values = await modbusAccess.readSpans(
+    const values = await modbusClient.readSpans(
       slaveAddress,
       0x03,
       holdingSpans,
@@ -74,7 +74,7 @@ async function readGroup(data, groupKey) {
 
   if (inputSpans.length) {
     sent = true
-    const values = await modbusAccess.readSpans(
+    const values = await modbusClient.readSpans(
       slaveAddress,
       0x04,
       inputSpans,
@@ -141,7 +141,7 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
 
     let baseWord = 0
     if (highValue === null || lowValue === null) {
-      const readWord = await modbusAccess.readSingleHoldingWord(
+      const readWord = await modbusClient.readSingleHoldingWord(
         slaveAddress,
         group.address,
         '读取配对寄存器',
@@ -168,7 +168,7 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
 }
 
 async function writeGroup(data, groupKey) {
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const items = expandItems(getGroupItems(data, groupKey))
@@ -181,7 +181,7 @@ async function writeGroup(data, groupKey) {
     const address = parseRegisterAddress(item.address)
 
     sent = true
-    const response = await modbusAccess.writeSingleCoil(
+    const response = await modbusClient.writeSingleCoil(
       slaveAddress,
       address,
       checked,
@@ -194,7 +194,7 @@ async function writeGroup(data, groupKey) {
   const holdingEntries = await buildHoldingWriteEntries(slaveAddress, holdingItems)
   for (const entry of holdingEntries) {
     sent = true
-    const response = await modbusAccess.writeMultipleRegisters(
+    const response = await modbusClient.writeMultipleRegisters(
       slaveAddress,
       entry.address,
       entry.values,
@@ -212,13 +212,13 @@ async function writeGroup(data, groupKey) {
 }
 
 async function writeSwitchRegister(item) {
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null || !item) return false
 
   const address = parseRegisterAddress(item.address)
   const checked = Number(item.writeValue) !== 0
 
-  return modbusAccess.writeSingleCoil(
+  return modbusClient.writeSingleCoil(
     slaveAddress,
     address,
     checked,

+ 34 - 11
utils/params-view-model.js → features/motor-control/params-view-model.js

@@ -1,12 +1,12 @@
-const controlService = require('./control-service')
-const genericModbusService = require('./generic-modbus-service')
-const paramsPageState = require('./params-page-state')
-const settingsService = require('./settings-service')
-const syncService = require('./sync-service')
+const controlService = require('./control-service.js')
+const genericModbusService = require('../generic-modbus/service.js')
+const paramsPageState = require('../../domain/motor-control/params-state.js')
+const settingsService = require('../../store/settings-store.js')
+const syncService = require('./sync-service.js')
 const {
   getStatusPageState
-} = require('./status-page-state')
-const themeService = require('./theme-service')
+} = require('../../domain/motor-control/status-state.js')
+const themeService = require('../../store/theme-store.js')
 
 const GROUP_LABELS = {
   dq: 'DQ轴电流环参数',
@@ -36,6 +36,7 @@ const PARAM_VIEWS = [
   'startup',
   'speed',
   'genericModbus',
+  'genericModbusGroup',
   'status'
 ]
 
@@ -106,10 +107,10 @@ function getPageState(
 
 function resolveActiveParamView(currentView, settingsState) {
   if (settingsState.modbusProtocolFilter === 'generic') {
-    return currentView === 'genericModbus' ? currentView : 'genericModbus'
+    return currentView === 'genericModbus' || currentView === 'genericModbusGroup' ? currentView : 'genericModbus'
   }
 
-  return PARAM_VIEWS.includes(currentView) && currentView !== 'genericModbus' ? currentView : ''
+  return PARAM_VIEWS.includes(currentView) && currentView !== 'genericModbus' && currentView !== 'genericModbusGroup' ? currentView : ''
 }
 
 function getSettingsPageState(currentData, settingsState) {
@@ -178,6 +179,9 @@ function createGenericModbusDialogState(overrides = {}) {
     showRange: false,
     showUnit: false,
     readOnly: false,
+    parsedStructRegisters: [],
+    structDefinition: '',
+    structParsedSummary: '',
     ...overrides
   }
 }
@@ -191,6 +195,7 @@ function createGenericGroupDialogState(group) {
     confirmText: isEdit ? '保存' : '确认',
     groupId: isEdit ? group.id : '',
     groupName: isEdit ? group.name : '寄存器组',
+    layout: isEdit ? (group.layout || 'register') : 'register',
     mode: isEdit ? 'editGroup' : 'createGroup',
     quantity: isEdit ? String(group.quantity || 1) : '1',
     registerTypeIndex,
@@ -255,11 +260,24 @@ function getGenericDialogDataTypeState(dialog, dataTypeOptions, dataTypeIndex) {
 }
 
 function createGenericGroupConfig(dialog) {
+  const registers = Array.isArray(dialog.parsedStructRegisters)
+    ? dialog.parsedStructRegisters
+    : []
+
   return {
     groupName: dialog.groupName,
-    quantity: dialog.quantity,
+    layout: registers.length ? 'struct' : (dialog.layout || 'register'),
+    quantity: registers.length ? String(registers.length) : dialog.quantity,
     registerTypeIndex: dialog.registerTypeIndex,
-    startAddress: dialog.startAddress
+    startAddress: dialog.startAddress,
+    ...(registers.length ? {
+      registers: registers.map((register) => ({
+        dataType: register.dataType,
+        isStructField: true,
+        name: register.name,
+        textByteLength: register.textByteLength
+      }))
+    } : {})
   }
 }
 
@@ -293,6 +311,10 @@ function findGenericRegister(groups, groupId, registerIndex) {
   }
 }
 
+function getActiveGenericGroup(groups, groupId) {
+  return findGenericGroup(groups, groupId) || null
+}
+
 module.exports = {
   createGenericGroupConfig,
   createGenericGroupDialogState,
@@ -301,6 +323,7 @@ module.exports = {
   createGenericRegisterDialogState,
   findGenericGroup,
   findGenericRegister,
+  getActiveGenericGroup,
   getCombinedGroupKeys,
   getCombinedGroupLabel,
   getControlViewState,

+ 5 - 5
utils/motor-control-protocol.js → features/motor-control/protocol-service.js

@@ -1,10 +1,10 @@
-const modbusAccess = require('./modbus-access')
+const modbusClient = require('../../protocols/modbus-rtu/client.js')
 const {
   parseHexInteger
-} = require('./base-utils')
+} = require('../../utils/base-utils.js')
 const {
   controlButtonRegisters
-} = require('./registers')
+} = require('../../domain/motor-control/registers.js')
 
 function getControlButton(key) {
   return controlButtonRegisters.find((item) => item.key === key) || null
@@ -19,13 +19,13 @@ function getControlButtonWriteValue(button) {
 async function writeControlButton(button, options = {}) {
   if (!button) return false
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const address = parseHexInteger(button.address)
   const coilEnabled = Number(getControlButtonWriteValue(button)) !== 0
 
-  return modbusAccess.writeSingleCoil(
+  return modbusClient.writeSingleCoil(
     slaveAddress,
     address,
     coilEnabled,

+ 8 - 8
utils/sync-service.js → features/motor-control/sync-service.js

@@ -1,17 +1,17 @@
-const controlService = require('./control-service')
+const controlService = require('./control-service.js')
 const {
   controlState,
   paramsState: paramsPageState
-} = require('./motor-control-data')
-const transport = require('./ble-transport')
-const modbusAccess = require('./modbus-access')
+} = require('../../domain/motor-control/data.js')
+const transport = require('../../transport/ble-core.js')
+const modbusClient = require('../../protocols/modbus-rtu/client.js')
 const {
   notifyPageToast
-} = require('./page-toast')
+} = require('../../utils/page-toast.js')
 const {
   addCoilReadValues,
   addWordReadValues
-} = require('./register-value-utils')
+} = require('../../utils/register-value-utils.js')
 
 const readValues = {
   coils: {},
@@ -132,7 +132,7 @@ async function syncAllRegisters() {
   const transportState = transport.getState()
   if (!transportState.connectedDevice) return false
 
-  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const slaveAddress = modbusClient.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   setSyncing(true)
@@ -141,7 +141,7 @@ async function syncAllRegisters() {
   try {
     for (const step of READ_STEPS) {
       const quantity = typeof step.quantity === 'function' ? step.quantity() : step.quantity
-      const values = await modbusAccess.readSpans(
+      const values = await modbusClient.readSpans(
         slaveAddress,
         step.functionCode,
         [{

+ 4 - 4
utils/settings-view-model.js → features/settings/view-model.js

@@ -1,7 +1,7 @@
-const settingsService = require('./settings-service')
-const themeService = require('./theme-service')
-const controlState = require('./control-page-state')
-const toolNavigation = require('./tool-navigation')
+const settingsService = require('../../store/settings-store.js')
+const themeService = require('../../store/theme-store.js')
+const controlState = require('../../domain/motor-control/control-state.js')
+const toolNavigation = require('../tools/navigation.js')
 
 function getModbusProtocolMeta(settingsState) {
   const modbusProtocolOptions = settingsService.MODBUS_PROTOCOL_OPTIONS

+ 9 - 0
features/tools/index.js

@@ -0,0 +1,9 @@
+const navigation = require('./navigation.js')
+const page = require('./page.js')
+
+module.exports = {
+  navigation,
+  page,
+  toolNavigation: navigation,
+  ...page
+}

+ 0 - 0
utils/tool-navigation.js → features/tools/navigation.js


+ 13 - 8
utils/tool-page.js → features/tools/page.js

@@ -1,12 +1,17 @@
-const crcTool = require('./crc-tool')
-const filterCalculator = require('./filter-calculator')
-const smdCodeCalculator = require('./smd-code-calculator')
-const refrigerationCalculator = require('./refrigeration-calculator')
-const reactanceCalculator = require('./reactance-calculator')
-const threePhasePowerCalculator = require('./three-phase-power-calculator')
+const {
+  crcTool
+} = require('../../tools/crc-hash/index.js')
+const filterCalculator = require('../../tools/filter/index.js')
+const smdCodeCalculator = require('../../tools/smd-code/index.js')
+const refrigerationCalculator = require('../../tools/refrigeration/index.js')
+const reactanceCalculator = require('../../tools/reactance/index.js')
+const threePhasePowerCalculator = require('../../tools/three-phase-power/index.js')
 const {
   getWxApi
-} = require('./platform-utils')
+} = require('../../utils/platform-utils.js')
+const {
+  loadSelectedFile
+} = require('../../repositories/file.js')
 
 function createToolInitialState() {
   return {
@@ -120,7 +125,7 @@ const toolPageHandlers = {
 
   async loadCrcFileFromMessage() {
     try {
-      const file = await crcTool.loadFileFromMessage()
+      const file = await loadSelectedFile('message')
       this.crcFileBytes = file.bytes
       this.setData({
         crcDataLengthText: file.sizeText,

+ 3 - 0
minitest/test.config.json

@@ -0,0 +1,3 @@
+{
+  "treeData": []
+}

+ 74 - 52
pages/home/home.js

@@ -1,33 +1,26 @@
-const transport = require('../../utils/ble-transport')
-const syncService = require('../../utils/sync-service')
-const themeService = require('../../utils/theme-service')
 const {
   DEFAULT_DEVICE_FILTER,
-  getHomePageState
-} = require('../../utils/home-view-model')
+  getState: getHomeFeatureState,
+  init: initHomeFeature,
+  subscribeState: subscribeHomeState,
+  ...homeService
+} = require('../../features/home/service.js')
 const {
   createPageToast
-} = require('../../utils/page-toast')
+} = require('../../utils/page-toast.js')
 
 Page({
-  data: getHomePageState(),
+  data: getHomeFeatureState(),
+
+  noop() {},
 
   onLoad() {
     this.pageToast = createPageToast(this, this.data)
-    transport.init()
-    themeService.init()
-    this.unsubscribeTransport = transport.subscribe((transportState) => {
-      const nextState = getHomePageState(transportState, this.data.deviceFilterMode, syncService.getState())
-
+    initHomeFeature()
+    this.unsubscribeHomeState = subscribeHomeState(() => this.data.deviceFilterMode, (nextState) => {
       this.setData(nextState)
       this.pageToast.showFromState(nextState)
     })
-    this.unsubscribeSync = syncService.subscribe((syncState) => {
-      this.setData(getHomePageState(transport.getState(), this.data.deviceFilterMode, syncState))
-    })
-    this.unsubscribeTheme = themeService.subscribe((themeState) => {
-      this.setData(themeState)
-    })
   },
 
   onShow() {
@@ -48,46 +41,80 @@ Page({
       this.pageToast = null
     }
 
-    if (this.unsubscribeTransport) {
-      this.unsubscribeTransport()
-      this.unsubscribeTransport = null
-    }
-
-    if (this.unsubscribeSync) {
-      this.unsubscribeSync()
-      this.unsubscribeSync = null
-    }
-
-    if (this.unsubscribeTheme) {
-      this.unsubscribeTheme()
-      this.unsubscribeTheme = null
+    if (this.unsubscribeHomeState) {
+      this.unsubscribeHomeState()
+      this.unsubscribeHomeState = null
     }
   },
 
   onCommandChange(event) {
-    transport.setCommandIndex(event.detail.value)
+    homeService.setCommandIndex(event.detail.value)
   },
 
   onSlaveAddressInput(event) {
-    transport.setProtocolInput({
+    homeService.setProtocolInput({
       slaveAddress: event.detail.value
     })
   },
 
   onRegisterAddressInput(event) {
-    transport.setProtocolInput({
+    homeService.setProtocolInput({
       registerAddress: event.detail.value
     })
   },
 
   onCommandValueInput(event) {
-    transport.setProtocolInput({
+    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) {
-    transport.setProtocolInput({
+    homeService.setProtocolInput({
       coilEnabled: !!event.detail.value
     })
   },
@@ -95,63 +122,58 @@ Page({
   sendGeneratedFrame() {
     if (!this.data.connectedDevice || !this.data.generatedHex) return
 
-    transport.sendGeneratedFrame()
+    homeService.sendGeneratedFrame()
   },
 
   onHexInput(event) {
-    transport.setSendHex(event.detail.value)
+    homeService.setSendHex(event.detail.value)
   },
 
   clearInput() {
-    transport.clearInput()
+    homeService.clearInput()
   },
 
   sendHexFrame() {
     if (!this.data.connectedDevice) return
 
-    transport.sendHexFrame()
+    homeService.sendHexFrame()
   },
 
   startScan() {
     if (!this.data.canStartScan) return
 
-    if (this.data.isDiscovering) {
-      transport.stopScan()
-      return
-    }
-
-    transport.startScan()
+    homeService.toggleScan(this.data.isDiscovering)
   },
 
   syncRegisters() {
     if (!this.data.canSyncRegisters) return
 
-    syncService.syncAllRegisters()
+    homeService.syncRegisters()
   },
 
   clearDevices() {
     if (!this.data.canClearDevices) return
 
-    transport.clearDevices()
+    homeService.clearDevices()
   },
 
   onDeviceFilterTap(event) {
     const deviceFilterMode = event.currentTarget.dataset.filter || DEFAULT_DEVICE_FILTER
 
-    this.setData(getHomePageState(transport.getState(), deviceFilterMode))
+    this.setData(getHomeFeatureState(deviceFilterMode))
   },
 
   connectDevice(event) {
-    transport.connectDeviceById(event.currentTarget.dataset.deviceId)
+    homeService.connectDeviceById(event.currentTarget.dataset.deviceId)
   },
 
   disconnectDevice() {
     if (!this.data.canDisconnectDevice) return
 
-    transport.disconnectDevice()
+    homeService.disconnectDevice()
   },
 
   clearLogs() {
-    transport.clearLogs()
+    homeService.clearLogs()
   }
 })

+ 68 - 1
pages/home/home.wxml

@@ -149,7 +149,7 @@
           />
         </view>
         <view class="protocol-row protocol-field-row">
-          <text class="protocol-label">协议寄存器</text>
+          <text class="protocol-label">起始地址</text>
           <input
             class="protocol-input protocol-row-input"
             type="text"
@@ -158,9 +158,23 @@
             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}}"
@@ -241,3 +255,56 @@
     </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>

+ 65 - 0
pages/home/home.wxss

@@ -311,6 +311,12 @@
   width: 350rpx;
 }
 
+.protocol-value-picker {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .coil-row {
   margin-top: 16rpx;
 }
@@ -356,6 +362,59 @@
   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;
@@ -473,4 +532,10 @@
     width: 280rpx;
   }
 
+  .protocol-multiple-type {
+    width: 200rpx;
+    min-width: 200rpx;
+    max-width: 200rpx;
+  }
+
 }

+ 4 - 10
pages/index/index.js

@@ -1,11 +1,11 @@
-const controlService = require('../../utils/control-service')
-const themeService = require('../../utils/theme-service')
+const themeService = require('../../store/theme-store.js')
 const {
+  controlService,
   getControlPageState
-} = require('../../utils/control-view-model')
+} = require('../../features/motor-control/index.js')
 const {
   createPageToast
-} = require('../../utils/page-toast')
+} = require('../../utils/page-toast.js')
 
 Page({
   data: getControlPageState(),
@@ -95,12 +95,6 @@ Page({
     controlService.chooseFirmwareFile('message')
   },
 
-  openFirmwareFile() {
-    if (this.data.isBootloaderBusy) return
-
-    controlService.chooseFirmwareFile('local')
-  },
-
   startFirmwareUpgrade() {
     if (!this.data.connectedDevice || !this.data.isFirmwareReady || this.data.isBootloaderBusy) return
 

+ 1 - 7
pages/index/index.wxml

@@ -103,17 +103,11 @@
         <view class="panel-icon icon-chip"></view>
         <view class="panel-title">BootLoader</view>
         <view class="panel-actions upgrade-actions">
-          <view
-            class="panel-action-button {{isBootloaderBusy ? 'is-disabled' : ''}}"
-            bindtap="openFirmwareFile"
-          >
-            打开
-          </view>
           <view
             class="panel-action-button {{isBootloaderBusy ? 'is-disabled' : ''}}"
             bindtap="chooseFirmwareFile"
           >
-            聊天
+            加载
           </view>
           <view
             class="panel-action-button {{connectedDevice && !isBootloaderBusy ? '' : 'is-disabled'}}"

+ 393 - 19
pages/params/params.js

@@ -1,13 +1,5 @@
-const paramsPageState = require('../../utils/params-page-state')
-const paramsService = require('../../utils/params-service')
-const controlService = require('../../utils/control-service')
-const genericModbusService = require('../../utils/generic-modbus-service')
-const {
-  createGenericModbusPoller
-} = require('../../utils/generic-modbus-poller')
-const settingsService = require('../../utils/settings-service')
-const themeService = require('../../utils/theme-service')
 const {
+  controlService,
   createGenericGroupConfig,
   createGenericGroupDialogState,
   createGenericModbusDialogState,
@@ -15,6 +7,7 @@ const {
   createGenericRegisterDialogState,
   findGenericGroup,
   findGenericRegister,
+  getActiveGenericGroup,
   getCombinedGroupKeys,
   getCombinedGroupLabel,
   getControlViewState,
@@ -25,17 +18,139 @@ const {
   getSettingsPageState,
   getVisiblePageState,
   hasWritableGroupChanges,
-  resolveActiveParamView
-} = require('../../utils/params-view-model')
-const syncService = require('../../utils/sync-service')
+  paramsPageState,
+  paramsService,
+  resolveActiveParamView,
+  syncService
+} = require('../../features/motor-control/index.js')
+const {
+  createGenericModbusPoller,
+  genericModbusService
+} = require('../../features/generic-modbus/index.js')
+const settingsService = require('../../store/settings-store.js')
+const themeService = require('../../store/theme-store.js')
 const {
   createPageToast
-} = require('../../utils/page-toast')
+} = 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
+  })
+}
 
 Page({
   data: {
     ...getPageState(),
     activeParamView: '',
+    activeGenericGroupId: '',
+    activeGenericRegisterRows: [],
     genericModbusDialog: createGenericModbusDialogState()
   },
 
@@ -47,6 +162,7 @@ Page({
     this.pageToast = createPageToast(this, this.data)
     this.genericModbusPoller = createGenericModbusPoller(() => this.data)
     this.genericModbusTouchStarts = {}
+    this.genericWindowWidth = getWindowWidth()
     controlService.init()
     genericModbusService.init()
     themeService.init()
@@ -77,14 +193,31 @@ Page({
       this.setData(themeState)
     })
     this.unsubscribeGenericModbus = genericModbusService.subscribe((genericState) => {
-      this.setData(genericState)
+      const activeGenericGroup = getActiveGenericGroup(genericState.genericModbusGroups, this.data.activeGenericGroupId)
+      this.setData({
+        ...genericState,
+        activeGenericGroup,
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag),
+        activeParamView: this.data.activeParamView === 'genericModbusGroup' && !activeGenericGroup
+          ? 'genericModbus'
+          : this.data.activeParamView
+      })
     })
     this.unsubscribeSettings = settingsService.subscribe((settingsState) => {
       const nextState = getSettingsPageState(this.data, settingsState)
       const activeParamView = nextState.activeParamView
 
-      this.setData(nextState)
-      if (activeParamView === 'genericModbus') {
+      const activeGenericGroup = getActiveGenericGroup(this.data.genericModbusGroups, this.data.activeGenericGroupId)
+      const safeActiveView = activeParamView === 'genericModbusGroup' && !activeGenericGroup
+        ? 'genericModbus'
+        : activeParamView
+      this.setData({
+        ...nextState,
+        activeParamView: safeActiveView,
+        activeGenericGroup,
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag)
+      })
+      if (safeActiveView === 'genericModbus' || safeActiveView === 'genericModbusGroup') {
         setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
       } else {
         this.clearGenericAutoTimers()
@@ -100,7 +233,14 @@ Page({
     controlService.syncSharedInputs()
 
     const pageState = getVisiblePageState(this.data)
-    this.setData(pageState)
+    this.setData({
+      ...pageState,
+      activeGenericGroup: getActiveGenericGroup(pageState.genericModbusGroups, this.data.activeGenericGroupId),
+      activeGenericRegisterRows: buildActiveGenericRegisterRows(
+        getActiveGenericGroup(pageState.genericModbusGroups, this.data.activeGenericGroupId),
+        this.genericModbusRegisterDrag
+      )
+    })
     this.pageToast.showFromState(pageState)
     this.scheduleVisibleGenericAutoReads()
   },
@@ -109,6 +249,7 @@ Page({
     if (this.pageToast) {
       this.pageToast.setActive(false)
     }
+    this.clearGenericRegisterDrag()
     this.clearGenericAutoTimers()
   },
 
@@ -247,6 +388,7 @@ Page({
   openParamView(event) {
     if (this.pageToast) this.pageToast.clear()
     this.closeGenericModbusDraft()
+    this.clearGenericRegisterDrag()
 
     const activeParamView = event.currentTarget.dataset.view
     if (!activeParamView) return
@@ -259,14 +401,33 @@ Page({
     }
   },
 
+  openGenericModbusGroup(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    if (!group) return
+
+    if (this.pageToast) this.pageToast.clear()
+    this.closeGenericModbusDraft()
+    this.setData({
+      activeGenericGroup: group,
+      activeGenericGroupId: groupId,
+      activeParamView: 'genericModbusGroup',
+      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, this.genericModbusRegisterDrag)
+    })
+  },
+
   backToParamsHome() {
     if (this.pageToast) this.pageToast.clear()
     this.closeGenericModbusDraft()
+    this.clearGenericRegisterDrag()
     this.clearGenericAutoTimers()
 
     const activeParamView = resolveActiveParamView('', this.data)
     this.setData({
-      activeParamView
+      activeGenericGroup: null,
+      activeGenericGroupId: '',
+      activeParamView,
+      activeGenericRegisterRows: []
     })
     if (activeParamView === 'genericModbus') {
       setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
@@ -439,16 +600,61 @@ Page({
     const field = event.currentTarget.dataset.field
     if (!field) return
 
+    const value = event.detail.value
     this.updateGenericModbusDialog({
-      [field]: event.detail.value
+      [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 || ''
     })
@@ -514,6 +720,12 @@ Page({
       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
     }
@@ -523,6 +735,12 @@ Page({
       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
     }
@@ -616,10 +834,133 @@ Page({
     }
   },
 
+  onGenericRegisterDragStart(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
+
+    this.genericModbusRegisterDrag = {
+      groupId,
+      index,
+      moved: false,
+      rowCenters: [],
+      rowOffset: getFallbackDragRowOffsetPx(this.genericWindowWidth),
+      startY: touch.clientY,
+      targetIndex: index,
+      translateY: 0
+    }
+
+    if (this.data.activeGenericGroupId === groupId) {
+      this.setData({
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(activeGenericGroup, this.genericModbusRegisterDrag)
+      })
+    }
+
+    this.measureGenericRegisterRows(this.genericModbusRegisterDrag)
+  },
+
+  onGenericRegisterDragMove(event) {
+    const touch = (event.changedTouches || [])[0]
+    if (!touch || !this.genericModbusRegisterDrag) return
+
+    const drag = this.genericModbusRegisterDrag
+    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    if (!group) return
+
+    const translateY = Math.round(touch.clientY - drag.startY)
+    const moved = Math.abs(translateY) > GENERIC_REGISTER_DRAG_THRESHOLD_PX
+    const targetIndex = moved
+      ? resolveDragTargetIndex(drag, touch.clientY, group.registers.length)
+      : drag.index
+
+    if (
+      drag.translateY === translateY
+      && drag.moved === moved
+      && drag.targetIndex === targetIndex
+    ) {
+      return
+    }
+
+    drag.translateY = translateY
+    drag.moved = moved
+    drag.targetIndex = targetIndex
+
+    if (this.data.activeGenericGroupId === group.id) {
+      this.setData({
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, drag)
+      })
+    }
+  },
+
+  onGenericRegisterDragEnd(event) {
+    const drag = this.genericModbusRegisterDrag
+    this.genericModbusRegisterDrag = null
+    if (!drag || !drag.groupId) return
+
+    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    if (!group) return
+
+    if (this.data.activeGenericGroupId === group.id) {
+      this.setData({
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+      })
+    }
+
+    if (!drag.moved) return
+
+    const targetIndex = clampIndex(
+      Number(drag.targetIndex) || drag.index,
+      0,
+      group.registers.length - 1
+    )
+    if (targetIndex === drag.index) return
+
+    const updatedGroup = genericModbusService.reorderRegister(drag.groupId, drag.index, targetIndex)
+    if (!updatedGroup) return
+
+    this.genericModbusRegisterLongPressGuard = `${drag.groupId}:${targetIndex}`
+    setTimeout(() => {
+      if (this.genericModbusRegisterLongPressGuard === `${drag.groupId}:${targetIndex}`) {
+        this.genericModbusRegisterLongPressGuard = ''
+      }
+    }, 260)
+
+    if (this.data.activeGenericGroupId === updatedGroup.id) {
+      this.setData({
+        activeGenericGroup: updatedGroup,
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(updatedGroup, null)
+      })
+    }
+  },
+
+  onGenericRegisterDragCancel() {
+    const drag = this.genericModbusRegisterDrag
+    this.genericModbusRegisterDrag = null
+    if (!drag || !drag.groupId) return
+
+    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+    if (!group || this.data.activeGenericGroupId !== group.id) return
+
+    this.setData({
+      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+    })
+  },
+
   deleteGenericModbusGroup(event) {
     const groupId = event.currentTarget.dataset.groupId
     this.clearGenericAutoTimer(groupId)
     genericModbusService.removeGroup(groupId)
+    if (this.data.activeGenericGroupId === groupId) {
+      this.setData({
+        activeGenericGroup: null,
+        activeGenericGroupId: '',
+        activeParamView: 'genericModbus'
+      })
+    }
     if (this.pageToast) this.pageToast.show('寄存器组已删除')
   },
 
@@ -637,5 +978,38 @@ Page({
 
   scheduleGenericAutoPoll(delay) {
     if (this.genericModbusPoller) this.genericModbusPoller.schedule(delay)
+  },
+
+  clearGenericRegisterDrag() {
+    if (!this.genericModbusRegisterDrag) return
+
+    const drag = this.genericModbusRegisterDrag
+    this.genericModbusRegisterDrag = null
+    const group = findGenericGroup(this.data.genericModbusGroups, drag.groupId)
+
+    this.setData({
+      activeGenericRegisterRows: buildActiveGenericRegisterRows(group, null)
+    })
+  },
+
+  measureGenericRegisterRows(dragReference) {
+    const query = this.createSelectorQuery()
+    query.selectAll('.generic-register-row').boundingClientRect((rects) => {
+      if (!this.genericModbusRegisterDrag || this.genericModbusRegisterDrag !== dragReference) return
+      if (!Array.isArray(rects) || !rects.length) return
+
+      dragReference.rowCenters = rects.map((rect) => Number(rect.top || 0) + Number(rect.height || 0) / 2)
+      dragReference.rowOffset = Math.max(
+        1,
+        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
+
+      this.setData({
+        activeGenericRegisterRows: buildActiveGenericRegisterRows(group, dragReference)
+      })
+    }).exec()
   }
 })

+ 107 - 44
pages/params/params.wxml

@@ -2,13 +2,13 @@
 <view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
   {{toastText}}
 </view>
-<view wx:if="{{activeParamView}}" class="subpage-fixed-header {{activeParamView == 'genericModbus' ? 'subpage-fixed-header--generic' : ''}} {{themeClass}}">
+<view wx:if="{{activeParamView}}" class="subpage-fixed-header {{activeParamView == 'genericModbus' || activeParamView == 'genericModbusGroup' ? 'subpage-fixed-header--generic' : ''}} {{themeClass}}">
   <view class="subpage-page-header">
     <view wx:if="{{!(isGenericProtocol && activeParamView == 'genericModbus')}}" class="subpage-back" bindtap="backToParamsHome">
       <view class="subpage-back-icon"></view>
     </view>
     <view class="subpage-page-title">
-      {{activeParamView == 'driver' ? '驱动器参数' : activeParamView == 'protection' ? '保护' : activeParamView == 'estimator' ? '估算器参数' : activeParamView == 'dq' ? 'DQ轴电流环参数' : activeParamView == 'startup' ? '启动位置管理' : activeParamView == 'speed' ? '速度管理' : activeParamView == 'genericModbus' ? '' : activeParamView == 'status' ? '状态' : ''}}
+      {{activeParamView == 'driver' ? '驱动器参数' : activeParamView == 'protection' ? '保护' : activeParamView == 'estimator' ? '估算器参数' : activeParamView == 'dq' ? 'DQ轴电流环参数' : activeParamView == 'startup' ? '启动位置管理' : activeParamView == 'speed' ? '速度管理' : activeParamView == 'genericModbus' ? '' : activeParamView == 'genericModbusGroup' ? (activeGenericGroup.name || '寄存器组') : activeParamView == 'status' ? '状态' : ''}}
     </view>
     <view wx:if="{{activeParamView == 'driver'}}" class="panel-actions subpage-actions">
       <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="readDriverPageParameters">读取</view>
@@ -40,12 +40,25 @@
       <view class="panel-action-button" bindtap="importGenericModbusJson">加载</view>
       <view class="panel-action-button panel-action-button--icon" bindtap="openGenericModbusDraft">+</view>
     </view>
+    <view wx:elif="{{activeParamView == 'genericModbusGroup'}}" class="panel-actions subpage-actions">
+      <view
+        class="panel-action-button {{connectedDevice && !activeGenericGroup.addressOverflow ? '' : 'is-disabled'}}"
+        data-group-id="{{activeGenericGroup.id}}"
+        bindtap="readGenericModbusGroup"
+      >读取</view>
+      <view
+        wx:if="{{activeGenericGroup.writable}}"
+        class="panel-action-button {{connectedDevice && !activeGenericGroup.addressOverflow ? '' : 'is-disabled'}}"
+        data-group-id="{{activeGenericGroup.id}}"
+        bindtap="writeGenericModbusGroup"
+      >写入</view>
+    </view>
     <view wx:elif="{{activeParamView == 'status'}}" class="panel-actions subpage-actions">
       <view class="panel-action-button {{canReadStatus ? '' : 'is-disabled'}}" bindtap="readStatus">读取</view>
     </view>
   </view>
 </view>
-<scroll-view class="scrollarea {{themeClass}} {{activeParamView ? 'scrollarea--subpage' : ''}} {{activeParamView == 'genericModbus' ? 'scrollarea--generic' : ''}}" scroll-y type="list">
+<scroll-view class="scrollarea {{themeClass}} {{activeParamView ? 'scrollarea--subpage' : ''}} {{activeParamView == 'genericModbus' || activeParamView == 'genericModbusGroup' ? 'scrollarea--generic' : ''}}" scroll-y type="list">
   <view class="page-shell">
     <block wx:if="{{activeParamView == 'driver'}}">
       <view class="panel driver-summary-panel">
@@ -416,7 +429,7 @@
             <view
               class="panel-heading-toggle"
               data-group-id="{{group.id}}"
-              bindtap="toggleGenericModbusGroup"
+              bindtap="openGenericModbusGroup"
             >
               <view class="panel-icon icon-terminal"></view>
               <view class="generic-group-title-wrap">
@@ -440,56 +453,89 @@
               >
                 写入
               </view>
-              <view class="entry-chevron {{group.expanded ? 'is-expanded' : ''}}"></view>
+              <view class="entry-chevron"></view>
             </view>
           </view>
 
-          <block wx:if="{{group.expanded}}">
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeParamView == 'genericModbusGroup'}}">
+      <view wx:if="{{activeGenericGroup}}" class="panel generic-group-detail-panel">
+        <view class="generic-group-detail-meta">
+          {{activeGenericGroup.addressRangeText}} · {{activeGenericGroup.quantity}}/{{activeGenericGroup.wordQuantity}}{{activeGenericGroup.addressWarningText ? ' · ' + activeGenericGroup.addressWarningText : ''}}
+        </view>
+        <view
+          wx:for="{{activeGenericRegisterRows.length ? activeGenericRegisterRows : activeGenericGroup.registers}}"
+          wx:for-item="register"
+          wx:for-index="registerIndex"
+          wx:key="id"
+          class="generic-register-row {{register.dragClass}}"
+          style="{{register.dragStyle}}"
+        >
+          <view
+            wx:if="{{!activeGenericGroup.isStructLayout}}"
+            class="generic-register-drag-handle {{register.dragHandleClass}}"
+            data-group-id="{{activeGenericGroup.id}}"
+            data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
+            catchtouchstart="onGenericRegisterDragStart"
+            catchtouchmove="onGenericRegisterDragMove"
+            catchtouchend="onGenericRegisterDragEnd"
+            catchtouchcancel="onGenericRegisterDragCancel"
+          >
+            <view class="generic-register-drag-bar"></view>
+            <view class="generic-register-drag-bar"></view>
+            <view class="generic-register-drag-bar"></view>
+          </view>
+          <view wx:else class="generic-register-layout-spacer"></view>
+          <view class="generic-register-main">
             <view
-              wx:for="{{group.registers}}"
-              wx:for-item="register"
-              wx:for-index="registerIndex"
-              wx:key="id"
-              class="generic-register-row"
+              class="generic-register-name"
+              data-group-id="{{activeGenericGroup.id}}"
+              data-index="{{register.sourceIndex !== undefined ? register.sourceIndex : registerIndex}}"
+              bindtap="openGenericRegisterInfo"
+              catchlongpress="openGenericRegisterEdit"
             >
-              <view class="generic-register-main">
-                <view
-                  class="generic-register-name"
-                  data-group-id="{{group.id}}"
-                  data-index="{{registerIndex}}"
-                  bindtap="openGenericRegisterInfo"
-                  catchlongpress="openGenericRegisterEdit"
-                >
-                  {{register.name}}
-                </view>
-                <view class="generic-register-meta">
-                  <text>{{register.addressText}} {{register.rawValueText}}</text>
-                </view>
-              </view>
-              <view class="generic-register-input-wrap {{register.showUnit && register.unit ? 'generic-register-input-wrap--unit' : ''}}">
-                <block wx:if="{{group.writable}}">
-                  <input
-                    class="value-input generic-register-value {{register.isDirty ? 'value-input--dirty' : ''}}"
-                    placeholder="--"
-                    data-group-id="{{group.id}}"
-                    data-index="{{registerIndex}}"
-                    value="{{register.inputValue}}"
-                    bindinput="onGenericRegisterValueInput"
-                    bindblur="onGenericRegisterValueBlur"
-                  />
-                  <view wx:if="{{register.showUnit && register.unit}}" class="generic-register-unit">{{register.unit}}</view>
-                </block>
-                <view wx:else class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
-              </view>
+              {{register.name}}
             </view>
-          </block>
+            <view class="generic-register-meta">
+              <text>{{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>
+            <view wx:else class="param-value generic-readonly-value">{{register.displayValue || '--'}}{{register.showUnit && register.unit ? ' ' + register.unit : ''}}</view>
+          </view>
         </view>
       </view>
     </block>
 
     <block wx:elif="{{activeParamView == 'status'}}">
-      <view class="panel params-section-panel">
-        <view wx:for="{{statusRegisters}}" wx:key="name" class="param-row">
+      <view wx:for="{{statusRegisterGroups}}" wx:for-item="group" wx:key="key" class="panel params-section-panel">
+        <view class="params-section-title">{{group.title}}</view>
+        <view wx:for="{{group.registers}}" wx:for-item="item" wx:key="name" class="param-row">
+          <view class="param-main">
+            <view class="param-name">{{item.name}}</view>
+            <view class="param-meta">{{item.addressDisplay}} {{item.rawValue}}</view>
+          </view>
+          <view class="param-value">{{item.displayValue}}{{item.displayUnit ? ' ' + item.displayUnit : ''}}</view>
+        </view>
+      </view>
+      <view wx:if="{{userStatusRegisters.length}}" class="panel params-section-panel">
+        <view class="params-section-title">用户状态</view>
+        <view wx:for="{{userStatusRegisters}}" wx:for-item="item" wx:key="name" class="param-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
             <view class="param-meta">{{item.addressDisplay}} {{item.rawValue}}</view>
@@ -623,7 +669,7 @@
         <view class="generic-config-row">
           <view class="param-main">
             <view class="param-name">寄存器数量</view>
-            <view class="param-meta">1 - 256</view>
+            <view class="param-meta">{{genericModbusDialog.structParsedSummary || '1 - 256'}}</view>
           </view>
           <input
             class="value-input generic-value-input"
@@ -633,6 +679,23 @@
             bindinput="onGenericDraftInput"
           />
         </view>
+        <view wx:if="{{genericModbusDialog.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>
+          <textarea
+            class="generic-struct-input"
+            maxlength="-1"
+            placeholder="粘贴 C 结构体定义"
+            data-field="structDefinition"
+            value="{{genericModbusDialog.structDefinition}}"
+            bindinput="onGenericDraftInput"
+          />
+        </view>
       </view>
     </block>
 

+ 6 - 6
pages/settings/settings.js

@@ -1,16 +1,16 @@
-const settingsService = require('../../utils/settings-service')
-const themeService = require('../../utils/theme-service')
-const toolNavigation = require('../../utils/tool-navigation')
+const settingsService = require('../../store/settings-store.js')
+const themeService = require('../../store/theme-store.js')
 const {
   createPageToast
-} = require('../../utils/page-toast')
+} = require('../../utils/page-toast.js')
 const {
   createToolInitialState,
+  toolNavigation,
   toolPageHandlers
-} = require('../../utils/tool-page')
+} = require('../../features/tools/index.js')
 const {
   getSettingsPageState
-} = require('../../utils/settings-view-model')
+} = require('../../features/settings/view-model.js')
 
 Page({
   data: {

+ 27 - 31
pages/settings/settings.wxml

@@ -298,38 +298,34 @@
           <view class="filter-diagram-mode">{{filterNetworkText}} · {{filterResponseText}}</view>
         </view>
         <view class="filter-diagram">
-          <view class="filter-diagram-row filter-diagram-row--top">
-            <view class="filter-diagram-port">Vin</view>
-            <view class="filter-diagram-wire"></view>
-            <view class="filter-diagram-component filter-diagram-component--series filter-diagram-component--{{filterSeriesComponentKey}}">
-              <view class="filter-diagram-component-icon filter-diagram-component-icon--{{filterSeriesComponentKey}}">
-                <view class="filter-diagram-icon-loop"></view>
-                <view class="filter-diagram-icon-loop"></view>
-                <view class="filter-diagram-icon-loop"></view>
-              </view>
+          <view class="filter-schematic">
+            <view class="filter-schematic-label filter-schematic-label--input">Vin</view>
+            <view class="filter-schematic-label filter-schematic-label--output">Vout</view>
+
+            <view class="filter-schematic-dot filter-schematic-dot--input-top"></view>
+            <view class="filter-schematic-dot filter-schematic-dot--input-bottom"></view>
+            <view class="filter-schematic-dot filter-schematic-dot--node-top"></view>
+            <view class="filter-schematic-dot filter-schematic-dot--node-bottom"></view>
+            <view class="filter-schematic-dot filter-schematic-dot--output-top"></view>
+            <view class="filter-schematic-dot filter-schematic-dot--output-bottom"></view>
+
+            <view class="filter-schematic-wire filter-schematic-wire--top-input"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--top-middle"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--top-output"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--bottom-input"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--bottom-output"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--branch-top"></view>
+            <view class="filter-schematic-wire filter-schematic-wire--branch-bottom"></view>
+
+            <view class="filter-schematic-mark filter-schematic-mark--series">{{filterSeriesLabel}}</view>
+            <view class="filter-schematic-component filter-schematic-component--series">
+              <image class="filter-schematic-symbol" src="{{filterSeriesComponentSrc}}" mode="aspectFit" />
             </view>
-            <view class="filter-diagram-wire"></view>
-            <view class="filter-diagram-node-wrap">
-              <view class="filter-diagram-node"></view>
-              <view class="filter-diagram-branch">
-                <view class="filter-diagram-branch-wire"></view>
-                <view class="filter-diagram-component filter-diagram-component--shunt filter-diagram-component--{{filterShuntComponentKey}}">
-                  <view class="filter-diagram-component-icon filter-diagram-component-icon--{{filterShuntComponentKey}}">
-                    <view class="filter-diagram-icon-loop"></view>
-                    <view class="filter-diagram-icon-loop"></view>
-                    <view class="filter-diagram-icon-loop"></view>
-                  </view>
-                </view>
-                <view class="filter-diagram-ground">
-                  <view class="filter-diagram-ground-stem"></view>
-                  <view class="filter-diagram-ground-line filter-diagram-ground-line--1"></view>
-                  <view class="filter-diagram-ground-line filter-diagram-ground-line--2"></view>
-                  <view class="filter-diagram-ground-line filter-diagram-ground-line--3"></view>
-                </view>
-              </view>
+
+            <view class="filter-schematic-mark filter-schematic-mark--shunt">{{filterShuntLabel}}</view>
+            <view class="filter-schematic-component filter-schematic-component--shunt">
+              <image class="filter-schematic-symbol filter-schematic-symbol--vertical" src="{{filterShuntComponentSrc}}" mode="aspectFit" />
             </view>
-            <view class="filter-diagram-wire"></view>
-            <view class="filter-diagram-port">Vout</view>
           </view>
         </view>
       </view>
@@ -747,7 +743,7 @@
       >
         <view class="settings-tool-main">
           <view class="settings-tool-icon-frame {{item.icon}}">
-            <image class="settings-tool-icon-image" src="{{item.iconSrc}}" mode="aspectFit"></image>
+            <image class="settings-tool-icon-image" src="{{item.iconSrc}}" mode="aspectFit" />
           </view>
           <view class="param-name settings-tool-title">{{item.label}}</view>
         </view>

+ 14 - 9
project.config.json

@@ -28,25 +28,30 @@
   "packOptions": {
     "ignore": [
       {
-        "type": "file",
-        "value": "protrol.txt"
+        "value": "protrol.txt",
+        "type": "file"
       },
       {
-        "type": "file",
-        "value": "Bootloader通讯协议_V1.2.1.pdf"
+        "value": "Bootloader通讯协议_V1.2.1.pdf",
+        "type": "file"
       },
       {
-        "type": "file",
-        "value": "CMFA103F3950.pdf"
+        "value": "CMFA103F3950.pdf",
+        "type": "file"
       },
       {
-        "type": "file",
-        "value": "assets/LUCIDE_LICENSE.txt"
+        "value": "assets/LUCIDE_LICENSE.txt",
+        "type": "file"
+      },
+      {
+        "type": "folder",
+        "value": "/minitest"
       }
     ],
     "include": []
   },
   "appid": "wxf2e66fa80c479ed8",
   "editorSetting": {},
-  "libVersion": "3.16.1"
+  "libVersion": "3.16.1",
+  "testRoot": "minitest/"
 }

+ 218 - 0
protocols/bootloader/frame.js

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

+ 3 - 0
protocols/bootloader/index.js

@@ -0,0 +1,3 @@
+module.exports = {
+  ...require('./frame.js')
+}

+ 4 - 4
utils/modbus-access.js → protocols/modbus-rtu/client.js

@@ -5,13 +5,13 @@ const {
   buildWriteSingleRegisterFrame,
   getMaxReadQuantity,
   getMaxWriteMultipleRegisterQuantity
-} = require('./modbus-rtu')
-const settingsService = require('./settings-service')
-const transport = require('./ble-transport')
+} = require('./frame.js')
+const settingsService = require('../../store/settings-store.js')
+const transport = require('../../transport/ble-core.js')
 const {
   addCoilReadValues,
   addWordReadValues
-} = require('./register-value-utils')
+} = require('../../utils/register-value-utils.js')
 
 function getSharedSlaveAddress(title = '从机地址错误') {
   try {

+ 1 - 1
utils/modbus-rtu.js → protocols/modbus-rtu/frame.js

@@ -2,7 +2,7 @@ const {
   BYTE_ORDER_LOW,
   appendCrc16Modbus,
   hasValidCrc16Modbus
-} = require('./crc')
+} = require('../../utils/crc.js')
 
 const MODBUS_CRC_OPTIONS = {
   byteOrder: BYTE_ORDER_LOW

+ 5 - 0
protocols/modbus-rtu/index.js

@@ -0,0 +1,5 @@
+module.exports = {
+  ...require('./frame.js'),
+  response: require('./response.js'),
+  client: require('./client.js')
+}

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

@@ -0,0 +1,298 @@
+const {
+  MAX_MODBUS_DMA_BYTES,
+  MODBUS_CRC_OPTIONS,
+  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 === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
+    return {
+      address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
+      functionCode,
+      isException: false,
+      quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
+      slaveAddress
+    }
+  }
+
+  return {
+    functionCode,
+    isException: false,
+    slaveAddress
+  }
+}
+
+function parseModbusRequest(bytes) {
+  if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
+
+  const slaveAddress = bytes[0]
+  const functionCode = bytes[1]
+  const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
+  let quantity = 1
+  let value
+
+  if (functionCode === 0x01 || functionCode === 0x02 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
+    quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+  if (functionCode === 0x05 || functionCode === 0x06) {
+    value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+
+  return {
+    address,
+    functionCode,
+    kind: 'raw-hex',
+    quantity,
+    value,
+    slaveAddress
+  }
+}
+
+function 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.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
+}

+ 41 - 0
protocols/transport-helpers.js

@@ -0,0 +1,41 @@
+const {
+  MODBUS_CRC_OPTIONS,
+  hasValidCrc16Modbus
+} = require('./modbus-rtu/frame.js')
+const {
+  getReadBufferHint,
+  parseModbusRequest,
+  readResponseFromBuffer
+} = require('./modbus-rtu/response.js')
+const {
+  getBootloaderResponseLength,
+  isBootloaderFrame
+} = require('./bootloader/frame.js')
+const {
+  BYTE_ORDER_HIGH,
+  hasValidCrc16Ccitt
+} = require('../utils/crc.js')
+
+function inspectReceivedBytes(rawBytes, context = {}) {
+  if (!Array.isArray(rawBytes) || rawBytes.length < 4) return ''
+
+  if (isBootloaderFrame(rawBytes)) {
+    const expectedLength = getBootloaderResponseLength(rawBytes)
+    if (expectedLength && rawBytes.length < expectedLength) return '片段'
+
+    return hasValidCrc16Ccitt(rawBytes, { byteOrder: BYTE_ORDER_HIGH }) ? 'CRC OK' : 'CRC ERR'
+  }
+
+  return hasValidCrc16Modbus(rawBytes, MODBUS_CRC_OPTIONS) ? 'CRC OK' : (context.pendingRequest ? '片段' : 'CRC ERR')
+}
+
+function parseSendExpected(bytes) {
+  return parseModbusRequest(bytes)
+}
+
+module.exports = {
+  getResponseBufferHint: getReadBufferHint,
+  inspectReceivedBytes,
+  parseSendExpected,
+  readResponseFromBuffer
+}

+ 4 - 9
utils/file-service.js → repositories/file.js

@@ -1,15 +1,10 @@
 const {
   getWxApi,
   isCancelError
-} = require('./platform-utils')
-
-function formatBytes(byteLength) {
-  const length = Number(byteLength) || 0
-  if (length >= 1024 && length % 1024 === 0) return `${length / 1024} KB`
-  if (length >= 1024) return `${(length / 1024).toFixed(2)} KB`
-
-  return `${length} bytes`
-}
+} = require('../utils/platform-utils.js')
+const {
+  formatBytes
+} = require('../utils/binary-utils.js')
 
 function formatExportStamp(date = new Date()) {
   const pad = (value, length = 2) => String(value).padStart(length, '0')

+ 3 - 3
utils/settings-service.js → store/settings-store.js

@@ -1,12 +1,12 @@
 const {
   toFiniteNumber
-} = require('./calculation-context')
+} = require('../utils/number-format.js')
 const {
   clampInteger
-} = require('./base-utils')
+} = require('../utils/base-utils.js')
 const {
   getWxApi
-} = require('./platform-utils')
+} = require('../utils/platform-utils.js')
 
 const STORAGE_KEY = 'app-settings'
 const MODBUS_PROTOCOL_OPTIONS = [

+ 2 - 2
utils/theme-service.js → store/theme-store.js

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

+ 0 - 0
utils/calculator-helpers.js → tools/calculator-helpers.js


+ 4 - 18
utils/crc-tool.js → tools/crc-hash/crc-tool.js

@@ -1,18 +1,15 @@
 const {
   CRC_ALGORITHM_PRESETS,
   calculateCrc
-} = require('./crc')
+} = require('../../utils/crc.js')
 const {
   HASH_ALGORITHM_PRESETS,
   calculateHash
-} = require('./hash')
+} = require('./hash.js')
 const {
   formatBytes,
-  loadSelectedFile
-} = require('./file-service')
-const {
   stringToUtf8Bytes
-} = require('./binary-utils')
+} = require('../../utils/binary-utils.js')
 
 const INPUT_TYPE_OPTIONS = [
   { key: 'hex', label: 'HEX' },
@@ -214,21 +211,10 @@ function calculateFromState(state, fileBytes) {
   }
 }
 
-async function loadFileFromMessage() {
-  const file = await loadSelectedFile('message')
-
-  return {
-    bytes: file.bytes,
-    name: file.name,
-    sizeText: file.sizeText
-  }
-}
-
 module.exports = {
   calculateFromState,
   createInitialState,
   createPresetState,
   CRC_CONFIG_FIELDS,
-  getCustomPresetIndex,
-  loadFileFromMessage
+  getCustomPresetIndex
 }

+ 1 - 1
utils/hash.js → tools/crc-hash/hash.js

@@ -4,7 +4,7 @@ const {
   bytesToHex,
   stringToUtf8Bytes,
   toByteArray
-} = require('./binary-utils')
+} = require('../../utils/binary-utils.js')
 
 const MASK_64 = 0xFFFFFFFFFFFFFFFFn
 

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

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

+ 22 - 9
utils/filter-calculator.js → tools/filter/index.js

@@ -5,9 +5,14 @@ const {
   normalizeIndex,
   parsePositiveNumber,
   selectBestUnit
-} = require('./calculator-helpers')
+} = require('../calculator-helpers.js')
 
 const TWO_PI = 2 * Math.PI
+const DIAGRAM_COMPONENT_SRC = {
+  c: '/assets/filter-diagram/capacitor-h.svg',
+  l: '/assets/filter-diagram/inductor-h.svg',
+  r: '/assets/filter-diagram/resistor-h.svg'
+}
 
 const NETWORK_OPTIONS = [
   { key: 'rc', label: 'RC' },
@@ -51,18 +56,26 @@ function getReactiveUnitOptions(networkKey) {
   return networkKey === 'rl' ? INDUCTANCE_UNIT_OPTIONS : CAPACITANCE_UNIT_OPTIONS
 }
 
+function getDiagramComponentSrc(componentKey) {
+  return DIAGRAM_COMPONENT_SRC[componentKey] || DIAGRAM_COMPONENT_SRC.r
+}
+
+function getDiagramComponentLabel(componentKey) {
+  return String(componentKey || '').toUpperCase()
+}
+
 function getDiagramComponents(networkKey, responseKey) {
   if (networkKey === 'rc' && responseKey === 'highpass') {
-    return { series: 'c', seriesLabel: 'C', shunt: 'r', shuntLabel: 'R' }
+    return { series: 'c', shunt: 'r' }
   }
   if (networkKey === 'rl' && responseKey === 'lowpass') {
-    return { series: 'l', seriesLabel: 'L', shunt: 'r', shuntLabel: 'R' }
+    return { series: 'l', shunt: 'r' }
   }
   if (networkKey === 'rl' && responseKey === 'highpass') {
-    return { series: 'r', seriesLabel: 'R', shunt: 'l', shuntLabel: 'L' }
+    return { series: 'r', shunt: 'l' }
   }
 
-  return { series: 'r', seriesLabel: 'R', shunt: 'c', shuntLabel: 'C' }
+  return { series: 'r', shunt: 'c' }
 }
 
 function calculateMissingValue(networkKey, values) {
@@ -218,10 +231,10 @@ function buildState(source = {}) {
     filterResponseKey: response.key,
     filterResponseOptions: RESPONSE_OPTIONS,
     filterResponseText: response.label,
-    filterSeriesComponentKey: diagram.series,
-    filterSeriesComponentLabel: diagram.seriesLabel,
-    filterShuntComponentKey: diagram.shunt,
-    filterShuntComponentLabel: diagram.shuntLabel
+    filterSeriesLabel: getDiagramComponentLabel(diagram.series),
+    filterSeriesComponentSrc: getDiagramComponentSrc(diagram.series),
+    filterShuntLabel: getDiagramComponentLabel(diagram.shunt),
+    filterShuntComponentSrc: getDiagramComponentSrc(diagram.shunt)
   }
 }
 

+ 1 - 1
utils/reactance-calculator.js → tools/reactance/index.js

@@ -5,7 +5,7 @@ const {
   normalizeIndex,
   parsePositiveNumber,
   selectBestUnit
-} = require('./calculator-helpers')
+} = require('../calculator-helpers.js')
 
 const TWO_PI = 2 * Math.PI
 

+ 1 - 1
utils/refrigeration-calculator.js → tools/refrigeration/index.js

@@ -4,7 +4,7 @@ const {
   normalizeIndex,
   parseLooseNumber,
   selectBestUnit
-} = require('./calculator-helpers')
+} = require('../calculator-helpers.js')
 
 const MODE_OPTIONS = [
   { key: 'water', label: '水侧冷量' },

+ 1 - 1
utils/smd-code-calculator.js → tools/smd-code/index.js

@@ -3,7 +3,7 @@ const {
   getOption,
   normalizeIndex,
   selectBestUnit
-} = require('./calculator-helpers')
+} = require('../calculator-helpers.js')
 
 const KIND_OPTIONS = [
   { key: 'resistor', label: '电阻' },

+ 1 - 1
utils/three-phase-power-calculator.js → tools/three-phase-power/index.js

@@ -3,7 +3,7 @@ const {
   getOption,
   normalizeIndex,
   parseLooseNumber
-} = require('./calculator-helpers')
+} = require('../calculator-helpers.js')
 
 const SQRT3 = Math.sqrt(3)
 const DEG_PER_RAD = 180 / Math.PI

+ 119 - 434
utils/ble-transport.js → transport/ble-core.js

@@ -1,31 +1,6 @@
-const {
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  formatHex,
-  getReadResponseByteLength,
-  MODBUS_CRC_OPTIONS,
-  MAX_MODBUS_DMA_BYTES,
-  hasValidCrc16Modbus
-} = require('./modbus-rtu')
-const {
-  BYTE_ORDER_HIGH,
-  hasValidCrc16Ccitt
-} = require('./crc')
-const {
-  getBootloaderResponseLength,
-  isBootloaderFrame
-} = require('./bootloader-frame')
 const {
   notifyPageToast
-} = require('./page-toast')
-const {
-  padHex
-} = require('./base-utils')
-const {
-  bytesToWords
-} = require('./binary-utils')
+} = require('../utils/page-toast.js')
 
 const SCAN_TIMEOUT = 15000
 const CONNECT_TIMEOUT = 10000
@@ -43,31 +18,10 @@ const MODULE_PACKET_SIZES = [
 ]
 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 MODBUS_EXCEPTION_MESSAGES = {
-  0x01: '非法功能',
-  0x02: '非法数据地址',
-  0x03: '非法数据值',
-  0x04: '从站设备故障',
-  0x05: '确认',
-  0x06: '从站设备忙',
-  0x08: '存储奇偶性错误',
-  0x0A: '网关路径不可用',
-  0x0B: '网关目标设备响应失败'
-}
-
-const MODBUS_COMMANDS = [
-  { key: 'readCoils', label: '01 读取线圈', functionCode: 0x01, inputMode: 'quantity' },
-  { key: 'readDiscreteInputs', label: '02 读取离散输入', functionCode: 0x02, inputMode: 'quantity' },
-  { key: 'readHolding', label: '03 读取保持寄存器', functionCode: 0x03, inputMode: 'quantity' },
-  { key: 'readInput', label: '04 读取输入寄存器', functionCode: 0x04, inputMode: 'quantity' },
-  { key: 'writeCoil', label: '05 写单线圈', functionCode: 0x05, inputMode: 'coil' },
-  { key: 'writeRegister', label: '06 写单寄存器', functionCode: 0x06, inputMode: 'single' },
-  { key: 'writeRegisters', label: '10 写多寄存器', functionCode: 0x10, inputMode: 'multiple' }
-]
-
 const bluetoothErrorMap = {
   10000: '蓝牙模块未初始化,请重新扫描',
   10001: '蓝牙不可用,请开启手机蓝牙',
@@ -98,22 +52,11 @@ const state = {
   isSending: false,
   logScrollTarget: '',
   logs: [],
-  commandIndex: 2,
-  commandValue: '0001',
-  commandValueLabel: '读取数量',
-  coilEnabled: true,
-  generatedHex: '',
   rxCount: 0,
   sendHex: '',
   sendQueueLength: 0,
-  protocolCommands: MODBUS_COMMANDS,
-  protocolErrorText: '',
-  registerAddress: '0000',
-  showCoilValue: false,
-  showCommandValue: true,
   systemTip: '',
   txCount: 0,
-  slaveAddress: 'F0',
   writeCharacteristicId: '',
   writeServiceId: '',
   writeType: ''
@@ -133,6 +76,42 @@ 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
+  })
+}
+
+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() || {})
+}
 
 function setState(changedData) {
   Object.assign(state, changedData)
@@ -301,102 +280,38 @@ function validateHex(value) {
   return ''
 }
 
-function parseHexNumber(value, label, maxValue) {
-  const text = String(value || '').trim().replace(/^0x/i, '')
-
-  if (!text || !/^[0-9a-fA-F]+$/.test(text)) {
-    throw new Error(`${label}请输入十六进制数值`)
-  }
-
-  const parsedValue = parseInt(text, 16)
-  if (parsedValue > maxValue) {
-    throw new Error(`${label}超出范围`)
-  }
-
-  return parsedValue
-}
-
-function parseRegisterValues(value) {
-  const text = String(value || '').trim()
-  if (!text) throw new Error('请输入寄存器写入值')
-
-  return text.split(/[\s,;]+/)
-    .filter(Boolean)
-    .map((item) => parseHexNumber(item, '写入值', 0xFFFF))
-}
-
 function normalizeMaxFrameBytes(maxFrameBytes) {
   const numberValue = Number(maxFrameBytes)
   if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
   if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
 
-  return MAX_MODBUS_DMA_BYTES
+  return DEFAULT_MAX_FRAME_BYTES
 }
 
-function getResponseBufferLimit(expected, maxFrameBytes) {
-  const responseLength = expected
-    ? getReadResponseByteLength(expected.functionCode, expected.quantity)
-    : 0
-  const frameLimit = normalizeMaxFrameBytes(maxFrameBytes)
-
-  if (frameLimit === 0) {
-    return Math.max(MAX_RESPONSE_BUFFER_BYTES, responseLength + 8)
+function getResponseBufferHint(expected, options = {}) {
+  if (Number.isFinite(Number(options.responseBufferHint))) {
+    return Math.max(0, Math.round(Number(options.responseBufferHint)))
   }
 
-  return Math.max(MAX_RESPONSE_BUFFER_BYTES, frameLimit + 8, responseLength + 8)
-}
-
-function getCommand(index) {
-  return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
-}
-
-function getDefaultCommandValue(command) {
-  if (command.inputMode === 'quantity') return '0001'
-  if (command.inputMode === 'coil') return 'ON'
-  if (command.inputMode === 'multiple') return '0000'
+  ensureProtocolHelpers()
 
-  return '0000'
-}
-
-function generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled) {
-  const slave = parseHexNumber(slaveAddress, '从站地址', 0xFF)
-  const address = parseHexNumber(registerAddress, '协议寄存器', 0xFFFF)
-
-  if (command.inputMode === 'quantity') {
-    const quantity = parseHexNumber(commandValue, '读取数量', 0xFFFF)
-    return buildReadFrame(slave, command.functionCode, address, quantity)
-  }
-  if (command.inputMode === 'coil') {
-    return buildWriteSingleCoilFrame(slave, address, coilEnabled)
-  }
-  if (command.inputMode === 'single') {
-    return buildWriteSingleRegisterFrame(slave, address, parseHexNumber(commandValue, '写入值', 0xFFFF))
+  if (typeof protocolHelpers.getResponseBufferHint === 'function') {
+    return Math.max(0, Math.round(Number(protocolHelpers.getResponseBufferHint(expected)) || 0))
   }
 
-  return buildWriteMultipleRegistersFrame(slave, address, parseRegisterValues(commandValue))
+  return 0
 }
 
-function createProtocolState(commandIndex, slaveAddress, registerAddress, commandValue, coilEnabled) {
-  const command = getCommand(commandIndex)
-  const commandValueLabel = command.inputMode === 'quantity' ? '读取数量' : '写入值'
+function getResponseBufferLimit(expected, options = {}) {
+  const responseLength = getResponseBufferHint(expected, options)
+  const maxFrameBytes = options.maxFrameBytes
+  const frameLimit = normalizeMaxFrameBytes(maxFrameBytes)
 
-  try {
-    return {
-      commandValueLabel,
-      generatedHex: formatHex(generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled)),
-      protocolErrorText: '',
-      showCoilValue: command.inputMode === 'coil',
-      showCommandValue: command.inputMode !== 'coil'
-    }
-  } catch (error) {
-    return {
-      commandValueLabel,
-      generatedHex: '',
-      protocolErrorText: error.message,
-      showCoilValue: command.inputMode === 'coil',
-      showCommandValue: command.inputMode !== 'coil'
-    }
+  if (frameLimit === 0) {
+    return Math.max(MAX_RESPONSE_BUFFER_BYTES, responseLength + 8)
   }
+
+  return Math.max(MAX_RESPONSE_BUFFER_BYTES, frameLimit + 8, responseLength + 8)
 }
 
 function hexToArrayBuffer(hexText) {
@@ -417,105 +332,21 @@ function arrayBufferToHex(buffer) {
   return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
 }
 
-function parseModbusResponse(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
-
-  const slaveAddress = bytes[0]
-  const functionCode = bytes[1]
-
-  if (functionCode & 0x80) {
-    return {
-      exceptionCode: bytes[2],
-      functionCode,
-      isException: true,
-      slaveAddress,
-      sourceFunctionCode: functionCode & 0x7F
-    }
-  }
-
-  if (functionCode === 0x01 || functionCode === 0x02) {
-    const byteCount = bytes[2]
-    const dataEnd = 3 + byteCount
-    if (bytes.length < dataEnd + 2) return null
-
-    return {
-      byteCount,
-      dataBytes: bytes.slice(3, dataEnd),
-      functionCode,
-      isException: false,
-      slaveAddress
-    }
-  }
-
-  if (functionCode === 0x03 || functionCode === 0x04) {
-    const byteCount = bytes[2]
-    const dataEnd = 3 + byteCount
-    if (bytes.length < dataEnd + 2) return null
-
-    return {
-      byteCount,
-      dataBytes: bytes.slice(3, dataEnd),
-      functionCode,
-      isException: false,
-      slaveAddress,
-      words: bytesToWords(bytes.slice(3, dataEnd))
-    }
-  }
-
-  if (functionCode === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
-    return {
-      address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
-      functionCode,
-      isException: false,
-      quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
-      slaveAddress
-    }
-  }
-
-  return {
-    functionCode,
-    isException: false,
-    slaveAddress
-  }
-}
-
-function parseModbusRequest(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
-
-  const slaveAddress = bytes[0]
-  const functionCode = bytes[1]
-  const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
-  let quantity = 1
-  let value
-
-  if (functionCode === 0x01 || functionCode === 0x02 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
-    quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
-  }
-  if (functionCode === 0x05 || functionCode === 0x06) {
-    value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
-  }
-
-  return {
-    address,
-    functionCode,
-    kind: 'raw-hex',
-    quantity,
-    value,
-    slaveAddress
-  }
+function formatFrameHex(bytes) {
+  return Array.prototype.map.call(bytes || [], (item) => Number(item || 0).toString(16).padStart(2, '0')).join(' ').toUpperCase()
 }
 
-function validateDmaFrameLength(bytes, expected) {
-  const maxFrameBytes = normalizeMaxFrameBytes(expected && expected.maxFrameBytes)
+function validateDmaFrameLength(bytes, options = {}) {
+  const maxFrameBytes = normalizeMaxFrameBytes(options.maxFrameBytes)
   if (maxFrameBytes === 0) return ''
 
   if (bytes.length > maxFrameBytes) {
     return `发送帧长度 ${bytes.length} 字节,超过最大包长 ${maxFrameBytes} 字节限制`
   }
 
-  if (!expected) return ''
+  if (!options.expected) return ''
 
-  const responseLength = getReadResponseByteLength(expected.functionCode, expected.quantity)
+  const responseLength = getResponseBufferHint(options.expected, options)
 
   if (responseLength > maxFrameBytes) {
     return `预计返回帧长度 ${responseLength} 字节,超过最大包长 ${maxFrameBytes} 字节限制`
@@ -554,10 +385,6 @@ function hasTargetCharacteristic(discovery) {
   ))
 }
 
-function getExceptionText(code) {
-  return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
-}
-
 function addLog(direction, payload, note = '') {
   logSequence += 1
   const logItem = {
@@ -578,14 +405,17 @@ function addLog(direction, payload, note = '') {
 function getReceiveCrcState(rawBytes) {
   if (!rawBytes || rawBytes.length < 4) return ''
 
-  if (isBootloaderFrame(rawBytes)) {
-    const expectedLength = getBootloaderResponseLength(rawBytes)
-    if (expectedLength && rawBytes.length < expectedLength) return '片段'
+  ensureProtocolHelpers()
+
+  if (typeof protocolHelpers.inspectReceivedBytes === 'function') {
+    const note = protocolHelpers.inspectReceivedBytes(rawBytes, {
+      pendingRequest: !!pendingRequest
+    })
 
-    return hasValidCrc16Ccitt(rawBytes, { byteOrder: BYTE_ORDER_HIGH }) ? 'CRC OK' : 'CRC ERR'
+    if (note) return note
   }
 
-  return hasValidCrc16Modbus(rawBytes, MODBUS_CRC_OPTIONS) ? 'CRC OK' : (pendingRequest ? '片段' : 'CRC ERR')
+  return pendingRequest ? '片段' : ''
 }
 
 function showCommandAlert(title, content) {
@@ -829,76 +659,6 @@ function isConnectionLostError(error) {
   return message.includes('disconnect') || message.includes('not connected')
 }
 
-function isExpectedResponse(response, expected) {
-  if (response.functionCode === 0x01 || response.functionCode === 0x02) {
-    return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
-  }
-
-  if (response.functionCode === 0x03 || response.functionCode === 0x04) {
-    return Array.isArray(response.words) && response.words.length >= expected.quantity
-  }
-
-  if (response.functionCode === 0x10) {
-    return response.address === expected.address && response.quantityOrValue === expected.quantity
-  }
-
-  if (response.functionCode === 0x05 || response.functionCode === 0x06) {
-    if (response.address !== expected.address) return false
-    if (Number.isInteger(expected.value)) return response.quantityOrValue === expected.value
-
-    return true
-  }
-
-  return true
-}
-
-function getExpectedResponseLength(expected, responseFunctionCode, responseBytes) {
-  if (!expected) return 0
-
-  if (responseFunctionCode === (expected.functionCode | 0x80)) {
-    return 5
-  }
-
-  if (responseFunctionCode === 0x01 || responseFunctionCode === 0x02) {
-    if (responseBytes.length < 3) return 0
-
-    return 3 + Number(responseBytes[2] || 0) + 2
-  }
-
-  if (responseFunctionCode === 0x03 || responseFunctionCode === 0x04) {
-    if (responseBytes.length < 3) return 0
-
-    return 3 + Number(responseBytes[2] || 0) + 2
-  }
-
-  if (responseFunctionCode === 0x05 || responseFunctionCode === 0x06 || responseFunctionCode === 0x10) {
-    return 8
-  }
-
-  return 0
-}
-
-function alignResponseBuffer(buffer, expected) {
-  if (!Array.isArray(buffer) || !buffer.length || !expected) return
-
-  const expectedFunctionCodes = [expected.functionCode, expected.functionCode | 0x80]
-  let matchIndex = -1
-
-  for (let index = 0; index < buffer.length - 1; index += 1) {
-    if (buffer[index] !== expected.slaveAddress) continue
-    if (!expectedFunctionCodes.includes(buffer[index + 1])) continue
-
-    matchIndex = index
-    break
-  }
-
-  if (matchIndex > 0) {
-    buffer.splice(0, matchIndex)
-  } else if (matchIndex < 0 && buffer.length > 2) {
-    buffer.splice(0, buffer.length - 1)
-  }
-}
-
 function finishPendingRequest(resolveValue) {
   const pending = clearPendingRequest()
 
@@ -911,19 +671,11 @@ function consumePendingResponseBuffer() {
   const pending = pendingRequest
   if (!pending || !Array.isArray(pending.responseBuffer)) return
 
-  const buffer = pending.responseBuffer
-  alignResponseBuffer(buffer, pending.expected)
-
-  if (buffer.length < 2) return
-
-  const responseFunctionCode = buffer[1]
-  const responseLength = getExpectedResponseLength(pending.expected, responseFunctionCode, buffer)
+  ensureProtocolHelpers()
 
-  if (!responseLength) return
-
-  const frameLimit = normalizeMaxFrameBytes(pending.expected && pending.expected.maxFrameBytes)
-  if (frameLimit > 0 && responseLength > frameLimit) {
-    const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过最大包长 ${frameLimit} 字节限制,已丢弃`
+  const responseReader = pending.responseReader || protocolHelpers.readResponseFromBuffer
+  if (typeof responseReader !== 'function') {
+    const content = `${pending.label} 未配置响应解析器,已丢弃`
     addLog('SYS', content)
     finishPendingRequest(false)
     if (pending.showModal) {
@@ -932,12 +684,13 @@ function consumePendingResponseBuffer() {
     return
   }
 
-  if (buffer.length < responseLength) return
+  const result = responseReader(pending.responseBuffer, pending.expected, {
+    maxFrameBytes: pending.expected && pending.expected.maxFrameBytes
+  })
+  if (!result || result.status === 'pending') return
 
-  const frameBytes = buffer.slice(0, responseLength)
-  const response = parseModbusResponse(frameBytes)
-  if (!response) {
-    const content = `${pending.label} 收到无效响应帧,已丢弃`
+  if (result.status === 'frame-too-long') {
+    const content = `${pending.label} 返回帧长度 ${result.responseLength} 字节,超过最大包长 ${result.frameLimit} 字节限制,已丢弃`
     addLog('SYS', content)
     finishPendingRequest(false)
     if (pending.showModal) {
@@ -946,17 +699,18 @@ function consumePendingResponseBuffer() {
     return
   }
 
-  const responseCode = response.isException ? response.sourceFunctionCode : response.functionCode
-  if (response.slaveAddress !== pending.expected.slaveAddress || responseCode !== pending.expected.functionCode) {
-    buffer.shift()
-    consumePendingResponseBuffer()
+  if (result.status === 'invalid') {
+    const content = `${pending.label} 收到无效响应帧,已丢弃`
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
     return
   }
 
-  if (response.isException) {
-    const exceptionText = getExceptionText(response.exceptionCode)
-    const content = `设备返回异常帧:功能码 0x${padHex(response.sourceFunctionCode, 2)},异常码 0x${padHex(response.exceptionCode, 2)}(${exceptionText})`
-
+  if (result.status === 'exception') {
+    const content = result.message || `${pending.label} 收到异常响应帧`
     addLog('SYS', content)
     finishPendingRequest(false)
     if (pending.showModal) {
@@ -965,7 +719,7 @@ function consumePendingResponseBuffer() {
     return
   }
 
-  if (!isExpectedResponse(response, pending.expected)) {
+  if (result.status === 'mismatch') {
     const content = `${pending.label} 收到不匹配响应,已丢弃`
     addLog('SYS', content)
     finishPendingRequest(false)
@@ -975,10 +729,19 @@ function consumePendingResponseBuffer() {
     return
   }
 
-  buffer.splice(0, responseLength)
-  finishPendingRequest(response)
+  if (result.status !== 'complete') {
+    const content = `${pending.label} 收到未知响应状态,已丢弃`
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
+    return
+  }
 
-  if (buffer.length) {
+  finishPendingRequest(result.response)
+
+  if (pending.responseBuffer.length) {
     consumePendingResponseBuffer()
   }
 }
@@ -1021,7 +784,8 @@ function createPendingRequest(label, expected, options = {}) {
       label,
       resolve,
       timer,
-      responseBufferLimit: getResponseBufferLimit(expected, options.maxFrameBytes),
+      responseBufferLimit: getResponseBufferLimit(expected, options),
+      responseReader: typeof options.responseReader === 'function' ? options.responseReader : null,
       showModal: options.showModal !== false,
       responseBuffer: []
     }
@@ -1503,6 +1267,8 @@ function handleAppHide() {
 }
 
 async function handleAppShow() {
+  if (!state.connectedDevice) return
+
   init()
   const connected = await refreshNativeConnectionState()
   if (connected && state.connectedDevice) {
@@ -1517,75 +1283,6 @@ function setSendHex(sendHex) {
   })
 }
 
-function setCommandIndex(value) {
-  const commandIndex = Number(value)
-  const command = getCommand(commandIndex)
-  const commandValue = getDefaultCommandValue(command)
-  const nextState = {
-    commandIndex,
-    commandValue,
-    coilEnabled: true
-  }
-
-  setState({
-    ...nextState,
-    ...createProtocolState(
-      nextState.commandIndex,
-      state.slaveAddress,
-      state.registerAddress,
-      nextState.commandValue,
-      nextState.coilEnabled
-    )
-  })
-}
-
-function setProtocolInput(changedData) {
-  const nextState = {
-    commandIndex: state.commandIndex,
-    slaveAddress: state.slaveAddress,
-    registerAddress: state.registerAddress,
-    commandValue: state.commandValue,
-    coilEnabled: state.coilEnabled,
-    ...changedData
-  }
-
-  setState({
-    ...changedData,
-    ...createProtocolState(
-      nextState.commandIndex,
-      nextState.slaveAddress,
-      nextState.registerAddress,
-      nextState.commandValue,
-      nextState.coilEnabled
-    )
-  })
-}
-
-function buildGeneratedExpectedResponse() {
-  try {
-    const command = getCommand(state.commandIndex)
-    const address = parseHexNumber(state.registerAddress, '协议寄存器', 0xFFFF)
-    const slaveAddress = parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
-    const quantity = command.inputMode === 'quantity'
-      ? parseHexNumber(state.commandValue, '读取数量', 0xFFFF)
-      : (command.inputMode === 'multiple' ? parseRegisterValues(state.commandValue).length : 1)
-    const value = command.inputMode === 'coil'
-      ? (state.coilEnabled ? 0xFF00 : 0x0000)
-      : (command.inputMode === 'single' ? parseHexNumber(state.commandValue, '写入值', 0xFFFF) : undefined)
-
-    return {
-      address,
-      functionCode: command.functionCode,
-      kind: 'manual-rtu',
-      quantity,
-      value,
-      slaveAddress
-    }
-  } catch (error) {
-    return null
-  }
-}
-
 function clearInput() {
   setState({
     sendHex: '',
@@ -1628,7 +1325,7 @@ function enqueueSendFrame(hexFrame, source, options = {}) {
 
   const buffer = hexToArrayBuffer(hexFrame)
   const bytes = new Uint8Array(buffer)
-  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options.expected)
+  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options)
 
   if (dmaFrameLengthError) {
     setState({
@@ -1724,7 +1421,7 @@ async function executeSendFrame(hexFrame, source, options = {}) {
 
   const buffer = hexToArrayBuffer(hexFrame)
   const bytes = new Uint8Array(buffer)
-  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options.expected)
+  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options)
 
   if (dmaFrameLengthError) {
     setState({
@@ -1792,14 +1489,16 @@ async function executeSendFrame(hexFrame, source, options = {}) {
 }
 
 function sendManagedFrame(frameBytes, label, expected, options = {}) {
-  return enqueueSendFrame(formatHex(frameBytes), label, {
+  return enqueueSendFrame(formatFrameHex(frameBytes), label, {
     expected: expected ? {
       ...expected,
-      maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes
+      maxFrameBytes: options.maxFrameBytes === undefined ? DEFAULT_MAX_FRAME_BYTES : options.maxFrameBytes
     } : expected,
+    responseBufferHint: options.responseBufferHint,
+    responseReader: options.responseReader,
     showModal: options.showModal !== false,
     timeout: options.timeout || RESPONSE_TIMEOUT,
-    maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes
+    maxFrameBytes: options.maxFrameBytes === undefined ? DEFAULT_MAX_FRAME_BYTES : options.maxFrameBytes
   })
 }
 
@@ -1808,7 +1507,7 @@ function sendRawFrameExact(frameBytes, source) {
     ? frameBytes
     : new Uint8Array(frameBytes || [])
 
-  return enqueueSendFrame(formatHex(Array.prototype.slice.call(bytes)), source, {
+  return enqueueSendFrame(formatFrameHex(Array.prototype.slice.call(bytes)), source, {
     chunkSize: 0,
     skipDmaCheck: true
   })
@@ -1816,47 +1515,33 @@ function sendRawFrameExact(frameBytes, source) {
 
 function sendHexFrame() {
   const errorText = validateHex(state.sendHex)
-  const expected = errorText ? null : parseModbusRequest(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
-
-  return enqueueSendFrame(state.sendHex, 'HEX', expected ? {
-    expected
-  } : {})
-}
 
-function sendGeneratedFrame() {
-  if (!state.generatedHex) return false
+  ensureProtocolHelpers()
 
-  const expected = buildGeneratedExpectedResponse()
+  const expected = errorText || typeof protocolHelpers.parseSendExpected !== 'function'
+    ? null
+    : protocolHelpers.parseSendExpected(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
 
-  return enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
+  return enqueueSendFrame(state.sendHex, 'HEX', expected ? {
     expected
   } : {})
 }
 
-setState(createProtocolState(
-  state.commandIndex,
-  state.slaveAddress,
-  state.registerAddress,
-  state.commandValue,
-  state.coilEnabled
-))
-
 module.exports = {
   clearDevices,
   clearInput,
   clearLogs,
+  configureProtocolHelpers,
   connectDeviceById,
   disconnectDevice,
+  enqueueSendFrame,
   getState,
   handleAppHide,
   handleAppShow,
   init,
-  sendGeneratedFrame,
   sendHexFrame,
   sendManagedFrame,
   sendRawFrameExact,
-  setCommandIndex,
-  setProtocolInput,
   setSendHex,
   showCommandAlert,
   startScan,

+ 9 - 0
utils/binary-utils.js

@@ -34,6 +34,14 @@ function bytesToHex(bytes, separator = '') {
   return toByteArray(bytes).map((byte) => (byte & 0xFF).toString(16).toUpperCase().padStart(2, '0')).join(separator)
 }
 
+function formatBytes(byteLength) {
+  const length = Number(byteLength) || 0
+  if (length >= 1024 && length % 1024 === 0) return `${length / 1024} KB`
+  if (length >= 1024) return `${(length / 1024).toFixed(2)} KB`
+
+  return `${length} bytes`
+}
+
 function bytesToWords(bytes = []) {
   const words = []
 
@@ -95,6 +103,7 @@ module.exports = {
   bytesToBin,
   bytesToHex,
   bytesToWords,
+  formatBytes,
   getByteFromWord,
   stringToUtf8Bytes,
   toByteArray,

+ 0 - 22
utils/bootloader-frame.js

@@ -1,22 +0,0 @@
-const BOOTLOADER_HEAD = [0x46, 0x54]
-
-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
-}
-
-module.exports = {
-  BOOTLOADER_HEAD,
-  getBootloaderResponseLength,
-  isBootloaderFrame
-}

+ 2 - 2
utils/crc.js

@@ -1,10 +1,10 @@
 const {
   bytesToBase64,
   toByteArray
-} = require('./binary-utils')
+} = require('./binary-utils.js')
 const {
   clampInteger
-} = require('./base-utils')
+} = require('./base-utils.js')
 
 const CRC16_MODBUS_TABLE = [
   0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,

+ 0 - 21
utils/motor-control-data.js

@@ -1,21 +0,0 @@
-const calculationContext = require('./calculation-context')
-const controlState = require('./control-page-state')
-const conversions = require('./conversions')
-const inputValueUtils = require('./input-value-utils')
-const paramsState = require('./params-page-state')
-const registerValueUtils = require('./register-value-utils')
-const registers = require('./registers')
-const statusFormat = require('./status-format')
-const statusState = require('./status-page-state')
-
-module.exports = {
-  calculationContext,
-  controlState,
-  conversions,
-  inputValueUtils,
-  paramsState,
-  registerValueUtils,
-  registers,
-  statusFormat,
-  statusState
-}

+ 29 - 0
utils/number-format.js

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

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

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