Kaynağa Gözat

更新合并

avery 4 gün önce
ebeveyn
işleme
122474896b
87 değiştirilmiş dosya ile 12751 ekleme ve 1326 silme
  1. BIN
      Bootloader通讯协议_V1.2.1.pdf
  2. BIN
      CMFA103F3950.pdf
  3. 2 0
      app.js
  4. 9 1
      app.json
  5. 1617 100
      app.wxss
  6. BIN
      assets/icons/audio-waveform-white.png
  7. BIN
      assets/icons/curve-white.png
  8. BIN
      assets/icons/funnel-white.png
  9. BIN
      assets/icons/hash-white.png
  10. BIN
      assets/icons/microchip-white.png
  11. BIN
      assets/icons/motor-white.png
  12. BIN
      assets/icons/oil-white.png
  13. BIN
      assets/icons/shield-alert-white.png
  14. BIN
      assets/icons/snowflake-white.png
  15. BIN
      assets/icons/wind-white.png
  16. BIN
      assets/icons/zap-white.png
  17. BIN
      assets/tab/control-active-dark.png
  18. BIN
      assets/tab/control-dark.png
  19. BIN
      assets/tab/home-active-dark.png
  20. BIN
      assets/tab/home-dark.png
  21. BIN
      assets/tab/params-active-dark.png
  22. BIN
      assets/tab/params-dark.png
  23. BIN
      assets/tab/settings-active-dark.png
  24. BIN
      assets/tab/settings-active.png
  25. BIN
      assets/tab/settings-dark.png
  26. BIN
      assets/tab/settings.png
  27. 5 1
      components/navigation-bar/navigation-bar.wxml
  28. 11 0
      components/navigation-bar/navigation-bar.wxss
  29. 18 66
      pages/home/home.js
  30. 11 11
      pages/home/home.wxml
  31. 29 20
      pages/home/home.wxss
  32. 68 4
      pages/index/index.js
  33. 120 6
      pages/index/index.wxml
  34. 260 0
      pages/index/index.wxss
  35. 407 105
      pages/params/params.js
  36. 405 346
      pages/params/params.wxml
  37. 131 0
      pages/settings/settings.js
  38. 5 0
      pages/settings/settings.json
  39. 759 0
      pages/settings/settings.wxml
  40. 153 0
      pages/settings/settings.wxss
  41. 14 2
      project.config.json
  42. 1 1
      project.private.config.json
  43. 45 2
      protrol.txt
  44. 45 0
      utils/base-utils.js
  45. 103 0
      utils/binary-utils.js
  46. 244 76
      utils/ble-transport.js
  47. 22 0
      utils/bootloader-frame.js
  48. 806 0
      utils/bootloader-service.js
  49. 103 0
      utils/calculator-helpers.js
  50. 44 16
      utils/control-page-state.js
  51. 113 156
      utils/control-service.js
  52. 21 0
      utils/control-view-model.js
  53. 16 3
      utils/conversions.js
  54. 234 0
      utils/crc-tool.js
  55. 657 0
      utils/crc.js
  56. 236 0
      utils/file-service.js
  57. 303 0
      utils/filter-calculator.js
  58. 923 0
      utils/generic-modbus-model.js
  59. 76 0
      utils/generic-modbus-poller.js
  60. 622 0
      utils/generic-modbus-service.js
  61. 533 0
      utils/hash.js
  62. 75 0
      utils/home-view-model.js
  63. 1 2
      utils/input-value-utils.js
  64. 236 0
      utils/modbus-access.js
  65. 59 77
      utils/modbus-rtu.js
  66. 21 0
      utils/motor-control-data.js
  67. 58 0
      utils/motor-control-protocol.js
  68. 104 0
      utils/motor-control-register-groups.js
  69. 193 20
      utils/params-page-state.js
  70. 72 227
      utils/params-service.js
  71. 315 0
      utils/params-view-model.js
  72. 14 0
      utils/platform-utils.js
  73. 245 0
      utils/reactance-calculator.js
  74. 281 0
      utils/refrigeration-calculator.js
  75. 10 0
      utils/register-value-utils.js
  76. 14 9
      utils/registers.js
  77. 265 0
      utils/settings-service.js
  78. 45 0
      utils/settings-view-model.js
  79. 295 0
      utils/smd-code-calculator.js
  80. 5 2
      utils/status-format.js
  81. 63 3
      utils/status-page-state.js
  82. 41 70
      utils/sync-service.js
  83. 184 0
      utils/theme-service.js
  84. 130 0
      utils/thermistor.js
  85. 400 0
      utils/three-phase-power-calculator.js
  86. 32 0
      utils/tool-navigation.js
  87. 422 0
      utils/tool-page.js

BIN
Bootloader通讯协议_V1.2.1.pdf


BIN
CMFA103F3950.pdf


+ 2 - 0
app.js

@@ -1,7 +1,9 @@
 const transport = require('./utils/ble-transport')
+const themeService = require('./utils/theme-service')
 
 App({
   onShow() {
+    themeService.syncWithSystemTheme()
     transport.handleAppShow()
   },
 

+ 9 - 1
app.json

@@ -1,8 +1,10 @@
 {
+  "darkmode": true,
   "pages": [
     "pages/home/home",
     "pages/index/index",
-    "pages/params/params"
+    "pages/params/params",
+    "pages/settings/settings"
   ],
   "window": {
     "navigationBarTextStyle": "black",
@@ -31,6 +33,12 @@
         "text": "参数",
         "iconPath": "assets/tab/params.png",
         "selectedIconPath": "assets/tab/params-active.png"
+      },
+      {
+        "pagePath": "pages/settings/settings",
+        "text": "设置",
+        "iconPath": "assets/tab/settings.png",
+        "selectedIconPath": "assets/tab/settings-active.png"
       }
     ]
   },

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1617 - 100
app.wxss


BIN
assets/icons/audio-waveform-white.png


BIN
assets/icons/curve-white.png


BIN
assets/icons/funnel-white.png


BIN
assets/icons/hash-white.png


BIN
assets/icons/microchip-white.png


BIN
assets/icons/motor-white.png


BIN
assets/icons/oil-white.png


BIN
assets/icons/shield-alert-white.png


BIN
assets/icons/snowflake-white.png


BIN
assets/icons/wind-white.png


BIN
assets/icons/zap-white.png


BIN
assets/tab/control-active-dark.png


BIN
assets/tab/control-dark.png


BIN
assets/tab/home-active-dark.png


BIN
assets/tab/home-dark.png


BIN
assets/tab/params-active-dark.png


BIN
assets/tab/params-dark.png


BIN
assets/tab/settings-active-dark.png


BIN
assets/tab/settings-active.png


BIN
assets/tab/settings-dark.png


BIN
assets/tab/settings.png


+ 5 - 1
components/navigation-bar/navigation-bar.wxml

@@ -1,3 +1,7 @@
 <view class="weui-navigation-bar">
-  <view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="background: {{background}}; {{safeAreaTop}};"></view>
+  <view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="background: {{background}}; {{safeAreaTop}};">
+    <view class="weui-navigation-bar__content">
+      <slot></slot>
+    </view>
+  </view>
 </view>

+ 11 - 0
components/navigation-bar/navigation-bar.wxss

@@ -19,3 +19,14 @@
   padding-top: env(safe-area-inset-top);
   box-sizing: border-box;
 }
+
+.weui-navigation-bar__content {
+  position: absolute;
+  left: 0;
+  right: 190rpx;
+  bottom: 0;
+  height: var(--height);
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+}

+ 18 - 66
pages/home/home.js

@@ -1,81 +1,32 @@
 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')
 const {
   createPageToast
 } = require('../../utils/page-toast')
 
-const DEFAULT_DEVICE_FILTER = 'all'
-const DEVICE_FILTER_OPTIONS = [
-  { key: 'all', label: '全部' },
-  { key: 'target', label: '目标' }
-]
-
-function isTargetDevice(device) {
-  return !!(device && (device.isTargetDevice || device.isTargetAdvertised))
-}
-
-function filterDevices(devices, filterMode) {
-  if (filterMode === 'target') return devices.filter(isTargetDevice)
-
-  return devices
-}
-
-function getPageState(transportState = transport.getState(), deviceFilterMode = DEFAULT_DEVICE_FILTER, syncState = syncService.getState()) {
-  const { connectedDevice } = transportState
-  const filteredDevices = filterDevices(transportState.devices, deviceFilterMode)
-  const allDeviceCount = transportState.devices.length
-  const filteredDeviceCount = filteredDevices.length
-  const connectionStatusText = connectedDevice
-    ? '已连接'
-    : (transportState.isConnecting ? '连接中' : '未连接')
-
-  return {
-    ...transportState,
-    allDeviceCount,
-    canClearDevices: !!allDeviceCount && !transportState.isConnecting,
-    canDisconnectDevice: !!connectedDevice,
-    canStartScan: !transportState.isConnecting,
-    canSyncRegisters: !!connectedDevice
-      && !transportState.isConnecting
-      && !syncState.isSyncing,
-    connectionCharacteristicText: connectedDevice ? transportState.characteristicText : '--',
-    connectionDeviceId: connectedDevice ? connectedDevice.deviceId : '--',
-    connectionName: connectedDevice ? connectedDevice.displayName : '',
-    connectionServiceCount: connectedDevice ? transportState.connectedServiceCount : '--',
-    connectionSignalText: connectedDevice ? connectedDevice.signalText : '--',
-    connectionStatusText,
-    devices: transportState.isDiscovering ? filteredDevices : [],
-    deviceCountText: allDeviceCount
-      ? (deviceFilterMode === 'target' ? `(${filteredDeviceCount}/${allDeviceCount})` : `(${allDeviceCount})`)
-      : '',
-    deviceFilterMode,
-    deviceFilterOptions: DEVICE_FILTER_OPTIONS,
-    isSyncing: syncState.isSyncing,
-    scanButtonText: transportState.isDiscovering ? '停止' : '扫描',
-    showDeviceSection: transportState.isDiscovering,
-    emptyDeviceText: allDeviceCount && deviceFilterMode === 'target'
-      ? '当前扫描结果中没有广播目标 UUID 的设备,可切回全部后连接确认特征值。'
-      : '请确认设备已上电并处于可广播或配网状态。',
-    emptyDeviceTitle: allDeviceCount && deviceFilterMode === 'target'
-      ? '没有匹配目标特征的设备'
-      : '还没有发现设备'
-  }
-}
-
 Page({
-  data: getPageState(),
+  data: getHomePageState(),
 
   onLoad() {
     this.pageToast = createPageToast(this, this.data)
     transport.init()
+    themeService.init()
     this.unsubscribeTransport = transport.subscribe((transportState) => {
-      const nextState = getPageState(transportState, this.data.deviceFilterMode, syncService.getState())
+      const nextState = getHomePageState(transportState, this.data.deviceFilterMode, syncService.getState())
 
       this.setData(nextState)
       this.pageToast.showFromState(nextState)
     })
     this.unsubscribeSync = syncService.subscribe((syncState) => {
-      this.setData(getPageState(transport.getState(), this.data.deviceFilterMode, syncState))
+      this.setData(getHomePageState(transport.getState(), this.data.deviceFilterMode, syncState))
+    })
+    this.unsubscribeTheme = themeService.subscribe((themeState) => {
+      this.setData(themeState)
     })
   },
 
@@ -106,6 +57,11 @@ Page({
       this.unsubscribeSync()
       this.unsubscribeSync = null
     }
+
+    if (this.unsubscribeTheme) {
+      this.unsubscribeTheme()
+      this.unsubscribeTheme = null
+    }
   },
 
   onCommandChange(event) {
@@ -156,10 +112,6 @@ Page({
     transport.sendHexFrame()
   },
 
-  openSetting() {
-    transport.openSetting()
-  },
-
   startScan() {
     if (!this.data.canStartScan) return
 
@@ -186,7 +138,7 @@ Page({
   onDeviceFilterTap(event) {
     const deviceFilterMode = event.currentTarget.dataset.filter || DEFAULT_DEVICE_FILTER
 
-    this.setData(getPageState(transport.getState(), deviceFilterMode))
+    this.setData(getHomePageState(transport.getState(), deviceFilterMode))
   },
 
   connectDevice(event) {

+ 11 - 11
pages/home/home.wxml

@@ -1,8 +1,8 @@
-<navigation-bar background="#FFF"></navigation-bar>
-<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+<navigation-bar background="{{themeMode === 'dark' ? '#111827' : '#FFF'}}"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
   {{toastText}}
 </view>
-<scroll-view class="scrollarea" scroll-y type="list">
+<scroll-view class="scrollarea {{themeClass}}" scroll-y type="list">
   <view class="page-shell">
     <view class="connected-panel">
       <view class="panel-header panel-header--with-actions">
@@ -41,9 +41,9 @@
         </view>
       </view>
       <view class="device-badges connection-badges">
-        <view class="connect-state {{connectedDevice ? 'connected' : ''}}">{{connectionStatusText}}</view>
-        <view class="rssi">{{connectionSignalText}}</view>
         <view class="traffic-badge">{{txCount}} / {{rxCount}} bytes</view>
+        <view class="rssi">{{connectionSignalText}}</view>
+        <view class="connect-state {{connectedDevice ? 'connected' : ''}}">{{connectionStatusText}}</view>
       </view>
       <view class="meta-grid">
         <view class="meta-item">
@@ -94,12 +94,12 @@
           <view class="device-info">
             <view class="device-main-row">
               <view class="device-name">{{item.displayName}}</view>
-              <view class="device-badges">
-                <view class="rssi">{{item.signalText}}</view>
-                <view wx:if="{{connectingDeviceId === item.deviceId}}" class="connect-state">连接中</view>
-                <view wx:elif="{{connectedDevice && connectedDevice.deviceId === item.deviceId}}" class="connect-state connected">已连接</view>
-                <view wx:else class="connect-state">连接</view>
-              </view>
+            </view>
+            <view class="device-badges device-badges--stacked">
+              <view wx:if="{{connectingDeviceId === item.deviceId}}" class="connect-state">连接中</view>
+              <view wx:elif="{{connectedDevice && connectedDevice.deviceId === item.deviceId}}" class="connect-state connected">已连接</view>
+              <view wx:else class="connect-state">连接</view>
+              <view class="rssi">{{item.signalText}}</view>
             </view>
             <view class="device-id">{{item.deviceId}}</view>
             <view class="device-meta-row">

+ 29 - 20
pages/home/home.wxss

@@ -24,25 +24,26 @@
 
 .connected-name {
   min-width: 0;
-  flex: 1;
   color: #111827;
   font-size: 34rpx;
   line-height: 1.35;
   font-weight: 800;
-  word-break: break-all;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .connection-badges {
-  justify-content: flex-start;
+  justify-content: flex-end;
   gap: 14rpx;
   padding: 8rpx 24rpx 0;
 }
 
 .traffic-badge {
   flex: none;
-  min-width: 144rpx;
-  text-align: left;
-  color: #b45309;
+  min-width: 156rpx;
+  text-align: right;
+  color: #64748b;
   font-size: 22rpx;
   line-height: 1.35;
   white-space: nowrap;
@@ -173,7 +174,9 @@
   font-size: 30rpx;
   line-height: 1.35;
   font-weight: 800;
-  word-break: break-all;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .device-badges {
@@ -184,6 +187,12 @@
   gap: 18rpx;
 }
 
+.device-badges--stacked {
+  justify-content: flex-start;
+  gap: 14rpx;
+  margin-top: 10rpx;
+}
+
 .device-id,
 .device-service,
 .device-target {
@@ -205,9 +214,9 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 112rpx;
+  width: 128rpx;
   height: 46rpx;
-  padding: 0 14rpx;
+  padding: 0 10rpx;
   border-radius: 999rpx;
   background: #f1f5f9;
   color: #475569;
@@ -222,7 +231,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 96rpx;
+  width: 112rpx;
   height: 46rpx;
   padding: 0;
   border-radius: 999rpx;
@@ -268,31 +277,31 @@
 
 .protocol-picker {
   width: 350rpx;
-  padding: 15rpx 16rpx;
-  border: 1rpx solid #e7edf3;
-  border-radius: 10rpx;
-  background: #fafbfd;
+  height: 70rpx;
+  padding: 0;
+  border: 0;
+  background: transparent;
   box-sizing: border-box;
 }
 
 .picker-value {
   color: #111827;
-  font-size: 25rpx;
-  line-height: 1.35;
+  font-size: 28rpx;
+  line-height: 70rpx;
   text-align: right;
 }
 
 .protocol-input {
   width: 100%;
-  height: 68rpx;
-  padding: 0 16rpx;
+  height: 70rpx;
+  padding: 0 18rpx;
   border: 1rpx solid #e7edf3;
   border-radius: 10rpx;
   background: #fafbfd;
   color: #111827;
   font-family: Menlo, Monaco, Consolas, monospace;
-  font-size: 27rpx;
-  line-height: 68rpx;
+  font-size: 28rpx;
+  line-height: 70rpx;
   text-align: right;
   box-sizing: border-box;
 }

+ 68 - 4
pages/index/index.js

@@ -1,18 +1,28 @@
 const controlService = require('../../utils/control-service')
+const themeService = require('../../utils/theme-service')
+const {
+  getControlPageState
+} = require('../../utils/control-view-model')
 const {
   createPageToast
 } = require('../../utils/page-toast')
 
 Page({
-  data: controlService.getState(),
+  data: getControlPageState(),
 
   onLoad() {
     this.pageToast = createPageToast(this, this.data)
     controlService.init()
-    this.unsubscribeControl = controlService.subscribe((nextState) => {
+    themeService.init()
+    this.unsubscribeControl = controlService.subscribe((controlState) => {
+      const nextState = getControlPageState(controlState)
+
       this.setData(nextState)
       this.pageToast.showFromState(nextState)
     })
+    this.unsubscribeTheme = themeService.subscribe((themeState) => {
+      this.setData(getControlPageState(controlService.getState(), themeState))
+    })
   },
 
   onShow() {
@@ -21,6 +31,7 @@ Page({
     }
 
     controlService.syncSharedInputs()
+    this.setData(getControlPageState())
   },
 
   onHide() {
@@ -39,6 +50,23 @@ Page({
       this.unsubscribeControl()
       this.unsubscribeControl = null
     }
+
+    if (this.unsubscribeTheme) {
+      this.unsubscribeTheme()
+      this.unsubscribeTheme = null
+    }
+  },
+
+  readStatus() {
+    if (!this.data.canReadStatus) return
+
+    controlService.readStatus()
+  },
+
+  onAutoReadStatusTap() {
+    if (!this.data.autoReadStatus && !this.data.canReadStatus) return
+
+    controlService.setAutoReadStatus(!this.data.autoReadStatus)
   },
 
   onSpeedCommandInput(event) {
@@ -50,14 +78,50 @@ Page({
   },
 
   readControlStatus() {
-    if (!this.data.connectedDevice) return
+    if (!this.data.connectedDevice || this.data.isBootloaderBusy) return
 
     controlService.readControlStatus()
   },
 
   onControlButtonTap(event) {
-    if (!this.data.connectedDevice) return
+    if (!this.data.connectedDevice || this.data.isBootloaderBusy) return
 
     controlService.sendControlCommand(event.currentTarget.dataset.key)
+  },
+
+  chooseFirmwareFile() {
+    if (this.data.isBootloaderBusy) return
+
+    controlService.chooseFirmwareFile('message')
+  },
+
+  openFirmwareFile() {
+    if (this.data.isBootloaderBusy) return
+
+    controlService.chooseFirmwareFile('local')
+  },
+
+  startFirmwareUpgrade() {
+    if (!this.data.connectedDevice || !this.data.isFirmwareReady || this.data.isBootloaderBusy) return
+
+    controlService.startFirmwareUpgrade()
+  },
+
+  readProgramChecksum() {
+    if (!this.data.connectedDevice || this.data.isBootloaderBusy) return
+
+    controlService.readProgramChecksum()
+  },
+
+  handshakeBootloader() {
+    if (!this.data.connectedDevice || this.data.isBootloaderBusy) return
+
+    controlService.handshakeBootloader()
+  },
+
+  exitBootloader() {
+    if (!this.data.connectedDevice || this.data.isBootloaderBusy) return
+
+    controlService.exitBootloader()
   }
 })

+ 120 - 6
pages/index/index.wxml

@@ -1,16 +1,58 @@
-<navigation-bar background="#FFF"></navigation-bar>
-<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+<navigation-bar background="{{themeMode === 'dark' ? '#111827' : '#FFF'}}"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
   {{toastText}}
 </view>
-<scroll-view class="scrollarea" scroll-y type="list">
+<scroll-view class="scrollarea {{themeClass}}" scroll-y type="list">
   <view class="page-shell">
+    <view class="panel status-summary-panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-status"></view>
+        <view class="panel-title">状态</view>
+        <view class="panel-actions panel-actions--status">
+          <view
+            class="panel-action-button auto-read-button {{autoReadStatus ? 'is-active' : ''}} {{autoReadStatus || canReadStatus ? '' : 'is-disabled'}}"
+            bindtap="onAutoReadStatusTap"
+          >
+            {{autoReadStatus ? '停止' : '自动'}}
+          </view>
+          <view
+            class="panel-action-button {{canReadStatus ? '' : 'is-disabled'}}"
+            bindtap="readStatus"
+          >
+            读取
+          </view>
+        </view>
+      </view>
+      <view class="status-summary-body">
+        <view class="status-summary-top">
+          <view class="status-summary-box status-summary-box--state">
+            <view class="status-summary-box-label">状态机</view>
+            <view class="status-summary-box-value">{{statusSummary.stateText}}</view>
+          </view>
+          <view class="status-summary-box status-summary-box--fault {{statusSummary.faultClass}}">
+            <view class="status-summary-box-label">故障码</view>
+            <view class="status-summary-box-value">{{statusSummary.faultText}}</view>
+          </view>
+        </view>
+        <view class="status-summary-line status-summary-line--metrics">
+          <view
+            wx:for="{{statusSummary.metrics}}"
+            wx:key="key"
+            class="status-metric"
+          >
+            {{item.displayText}}
+          </view>
+        </view>
+      </view>
+    </view>
+
     <view class="panel control-panel">
       <view class="panel-header panel-header--with-actions">
         <view class="panel-icon icon-control"></view>
         <view class="panel-title">控制指令</view>
         <view class="panel-actions panel-actions--three control-actions">
           <view
-            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
+            class="panel-action-button {{connectedDevice && !isBootloaderBusy ? '' : 'is-disabled'}}"
             bindtap="readControlStatus"
           >
             读取
@@ -18,7 +60,7 @@
           <view
             wx:for="{{controlActionButtons}}"
             wx:key="key"
-            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
+            class="panel-action-button {{connectedDevice && !isBootloaderBusy ? '' : 'is-disabled'}}"
             data-key="{{item.key}}"
             bindtap="onControlButtonTap"
           >
@@ -46,7 +88,7 @@
         <view wx:for="{{controlButtons}}" wx:key="key" class="control-cell control-cell--{{item.key}}">
           <button
             class="control-button control-button--{{item.key}}"
-            disabled="{{!connectedDevice}}"
+            disabled="{{!connectedDevice || isBootloaderBusy}}"
             data-key="{{item.key}}"
             bindtap="onControlButtonTap"
           >
@@ -55,5 +97,77 @@
         </view>
       </view>
     </view>
+
+    <view class="panel upgrade-panel">
+      <view class="panel-header panel-header--with-actions">
+        <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'}}"
+            bindtap="handshakeBootloader"
+          >
+            握手
+          </view>
+          <view
+            class="panel-action-button {{connectedDevice && !isBootloaderBusy ? '' : 'is-disabled'}}"
+            bindtap="readProgramChecksum"
+          >
+            读取
+          </view>
+          <view
+            class="panel-action-button {{connectedDevice && !isBootloaderBusy ? '' : 'is-disabled'}}"
+            bindtap="exitBootloader"
+          >
+            退出
+          </view>
+          <view
+            class="panel-action-button {{connectedDevice && isFirmwareReady && !isBootloaderBusy ? '' : 'is-disabled'}}"
+            bindtap="startFirmwareUpgrade"
+          >
+            升级
+          </view>
+        </view>
+      </view>
+      <view class="upgrade-body">
+        <view class="upgrade-row">
+          <text class="upgrade-label">芯片型号</text>
+          <text class="upgrade-value">{{chipModel}}</text>
+        </view>
+        <view class="upgrade-row upgrade-row--file">
+          <view class="upgrade-file-head">
+            <text class="upgrade-label">固件文件</text>
+            <view class="upgrade-file-meta">
+              <text class="upgrade-file-meta-item upgrade-file-meta-item--program">{{deviceProgramCrcText}}</text>
+              <text class="upgrade-file-meta-item upgrade-file-meta-item--checksum">{{firmwareChecksumText}}</text>
+              <text class="upgrade-file-meta-item upgrade-file-meta-item--size">{{firmwareSizeText}}</text>
+            </view>
+          </view>
+          <text class="upgrade-file-name">{{firmwareName ? firmwareName : '--'}}</text>
+        </view>
+        <view class="upgrade-row">
+          <text class="upgrade-label">Bootloader</text>
+          <text class="upgrade-value">{{bootloaderVersion}} / {{bootloaderChipId}}</text>
+        </view>
+        <view wx:if="{{bootloaderStatusText || bootloaderDetailText}}" class="upgrade-status {{isFirmwareReady ? 'upgrade-status--ready' : ''}}">
+          {{bootloaderStatusText}}<text wx:if="{{bootloaderDetailText}}"> · {{bootloaderDetailText}}</text>
+        </view>
+        <view class="upgrade-progress">
+          <view class="upgrade-progress-bar" style="width: {{bootloaderProgress}}%;"></view>
+        </view>
+      </view>
+    </view>
   </view>
 </scroll-view>

+ 260 - 0
pages/index/index.wxss

@@ -54,3 +54,263 @@ button[disabled].control-button {
   color: #94a3b8;
   box-shadow: none;
 }
+
+.status-summary-panel .panel-header {
+  padding-bottom: 16rpx;
+}
+
+.status-summary-body {
+  padding: 0 24rpx 22rpx;
+}
+
+.status-summary-top,
+.status-summary-line {
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+}
+
+.status-summary-top {
+  padding-top: 2rpx;
+  margin-bottom: 12rpx;
+}
+
+.status-summary-box {
+  flex: 1;
+  min-width: 0;
+  min-height: 88rpx;
+  padding: 14rpx 16rpx 16rpx;
+  border: 1rpx solid #e6ebf2;
+  border-radius: 14rpx;
+  background: #f9fbfd;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.status-summary-box-label {
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.2;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.status-summary-box-value {
+  min-width: 0;
+  margin-top: 6rpx;
+  color: #111827;
+  font-size: 27rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.status-summary-box--fault.is-warning {
+  border-color: rgba(194, 65, 12, 0.22);
+  background: #fff7ed;
+}
+
+.status-summary-box--fault.is-warning .status-summary-box-value {
+  color: var(--danger);
+}
+
+.status-summary-line--metrics {
+  justify-content: space-between;
+  gap: 8rpx;
+  padding: 14rpx 0 0;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.status-metric {
+  flex: none;
+  width: 160rpx;
+  min-width: 160rpx;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 27rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  text-align: center;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-sizing: border-box;
+}
+
+.theme-dark .status-summary-box {
+  border-color: #263241;
+  background: #17202c;
+}
+
+.theme-dark .status-summary-box-label {
+  color: #94a3b8;
+}
+
+.theme-dark .status-summary-box-value,
+.theme-dark .status-metric {
+  color: #e5e7eb;
+}
+
+.theme-dark .status-summary-box--fault.is-warning {
+  border-color: #7c2d12;
+  background: #33150f;
+}
+
+.theme-dark .status-summary-box--fault.is-warning .status-summary-box-value {
+  color: #fed7aa;
+}
+
+.theme-dark .status-summary-line--metrics {
+  border-color: #263241;
+}
+
+.upgrade-actions {
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  max-width: 286rpx;
+  row-gap: 8rpx;
+}
+
+.upgrade-body {
+  padding: 8rpx 24rpx 24rpx;
+}
+
+.upgrade-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20rpx;
+  min-height: 62rpx;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.upgrade-row:first-child {
+  border-top: 0;
+}
+
+.upgrade-row--file {
+  display: block;
+  min-height: 0;
+  padding-top: 14rpx;
+  padding-bottom: 14rpx;
+}
+
+.upgrade-file-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14rpx;
+}
+
+.upgrade-file-meta {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10rpx;
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+}
+
+.upgrade-file-meta-item {
+  flex: none;
+  min-width: 0;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.upgrade-file-meta-item--program {
+  color: var(--accent-dark);
+  font-weight: 800;
+}
+
+.upgrade-file-meta-item--checksum,
+.upgrade-file-meta-item--size {
+  color: #64748b;
+}
+
+.upgrade-file-name {
+  display: block;
+  margin-top: 8rpx;
+  color: #111827;
+  font-size: 24rpx;
+  line-height: 1.35;
+  font-weight: 700;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.upgrade-label {
+  flex: none;
+  color: #64748b;
+  font-size: 24rpx;
+  line-height: 1.35;
+}
+
+.upgrade-value {
+  min-width: 0;
+  flex: 1;
+  color: #111827;
+  font-size: 24rpx;
+  line-height: 1.35;
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.upgrade-status {
+  margin-top: 14rpx;
+  padding: 14rpx 16rpx;
+  border-radius: 12rpx;
+  background: #f8fafc;
+  color: #64748b;
+  font-size: 23rpx;
+  line-height: 1.45;
+  font-weight: 700;
+  word-break: break-all;
+}
+
+.upgrade-status--ready {
+  background: #effaf8;
+  color: var(--accent-dark);
+}
+
+.upgrade-progress {
+  position: relative;
+  height: 14rpx;
+  margin-top: 16rpx;
+  border-radius: 999rpx;
+  background: #e5e7eb;
+  overflow: hidden;
+}
+
+.upgrade-progress-bar {
+  height: 100%;
+  min-width: 0;
+  border-radius: inherit;
+  background: linear-gradient(90deg, #10b981 0%, #0f8f87 100%);
+  transition: width 0.18s ease;
+}
+
+.theme-dark .upgrade-progress {
+  background: #263241;
+}
+
+.theme-dark .upgrade-progress-bar {
+  background: linear-gradient(90deg, #2dd4bf 0%, #14b8a6 100%);
+}
+
+.theme-dark .upgrade-file-meta-item--checksum,
+.theme-dark .upgrade-file-meta-item--size,
+.theme-dark .upgrade-file-name {
+  color: #cbd5e1;
+}
+
+.theme-dark .upgrade-file-meta-item--program {
+  color: #5eead4;
+}

+ 407 - 105
pages/params/params.js

@@ -1,92 +1,62 @@
 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 {
-  getStatusPageState
-} = require('../../utils/status-page-state')
+  createGenericModbusPoller
+} = require('../../utils/generic-modbus-poller')
+const settingsService = require('../../utils/settings-service')
+const themeService = require('../../utils/theme-service')
+const {
+  createGenericGroupConfig,
+  createGenericGroupDialogState,
+  createGenericModbusDialogState,
+  createGenericRegisterChangedData,
+  createGenericRegisterDialogState,
+  findGenericGroup,
+  findGenericRegister,
+  getCombinedGroupKeys,
+  getCombinedGroupLabel,
+  getControlViewState,
+  getGenericDialogDataTypeState,
+  getGenericOption,
+  getGroupLabel,
+  getPageState,
+  getSettingsPageState,
+  getVisiblePageState,
+  hasWritableGroupChanges,
+  resolveActiveParamView
+} = require('../../utils/params-view-model')
 const syncService = require('../../utils/sync-service')
 const {
   createPageToast
 } = require('../../utils/page-toast')
 
-const GROUP_LABELS = {
-  dq: 'DQ轴电流环参数',
-  estimator: '估算器参数',
-  oil: '上油参数',
-  preposition: '预定位配置',
-  protection: '保护参数',
-  protectionSwitch: '保护控制',
-  speedLoop: '速度环路',
-  tailwind: '顺逆风配置',
-  vsp: 'VSP曲线'
-}
-
-const COLLAPSIBLE_CARDS = [
-  'motor',
-  'driver',
-  'estimator',
-  'dq',
-  'tailwind',
-  'preposition',
-  'speedLoop',
-  'vsp',
-  'oil',
-  'protectionSwitch',
-  'protection',
-  'status'
-]
-
-function createCollapseState() {
-  return COLLAPSIBLE_CARDS.reduce((result, key) => {
-    result[key] = true
-    return result
-  }, {})
-}
-
-function getGroupLabel(groupKey) {
-  return GROUP_LABELS[groupKey] || '参数'
-}
-
-function getControlViewState(controlState = controlService.getState()) {
-  return {
-    ...controlState,
-    ...getStatusPageState(),
-    canReadStatus: !!controlState.connectedDevice
-  }
-}
-
-function getCollapseState(collapsedCards) {
-  return {
-    ...createCollapseState(),
-    ...(collapsedCards || {})
-  }
-}
-
-function getPageState(
-  paramsState = syncService.getParamsSnapshot(),
-  controlState = controlService.getState(),
-  collapsedCards
-) {
-  return {
-    ...paramsPageState.refreshState(paramsState),
-    ...getControlViewState(controlState),
-    collapsedCards: getCollapseState(collapsedCards)
-  }
-}
-
 Page({
-  data: getPageState(),
+  data: {
+    ...getPageState(),
+    activeParamView: '',
+    genericModbusDialog: createGenericModbusDialogState()
+  },
+
+  onTabItemTap() {
+    this.backToParamsHome()
+  },
 
   onLoad() {
     this.pageToast = createPageToast(this, this.data)
+    this.genericModbusPoller = createGenericModbusPoller(() => this.data)
+    this.genericModbusTouchStarts = {}
     controlService.init()
+    genericModbusService.init()
+    themeService.init()
+    settingsService.init()
     this.unsubscribeSync = syncService.subscribe((syncState) => {
       if (!syncState.syncVersion || syncState.syncVersion === this.data.syncVersion) return
 
       const nextState = getPageState(
         syncService.getParamsSnapshot(),
-        controlService.getState(),
-        this.data.collapsedCards
+        controlService.getState()
       )
 
       this.setData(nextState)
@@ -97,6 +67,28 @@ Page({
 
       this.setData(nextState)
       this.pageToast.showFromState(nextState)
+      if (nextState.connectedDevice) {
+        this.scheduleVisibleGenericAutoReads()
+      } else {
+        this.clearGenericAutoTimers()
+      }
+    })
+    this.unsubscribeTheme = themeService.subscribe((themeState) => {
+      this.setData(themeState)
+    })
+    this.unsubscribeGenericModbus = genericModbusService.subscribe((genericState) => {
+      this.setData(genericState)
+    })
+    this.unsubscribeSettings = settingsService.subscribe((settingsState) => {
+      const nextState = getSettingsPageState(this.data, settingsState)
+      const activeParamView = nextState.activeParamView
+
+      this.setData(nextState)
+      if (activeParamView === 'genericModbus') {
+        setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+      } else {
+        this.clearGenericAutoTimers()
+      }
     })
   },
 
@@ -107,26 +99,17 @@ Page({
 
     controlService.syncSharedInputs()
 
-    const snapshot = syncService.getParamsSnapshot()
-    const nextState = snapshot.syncVersion && snapshot.syncVersion !== this.data.syncVersion
-      ? paramsPageState.refreshState(snapshot)
-      : paramsPageState.refreshState(this.data)
-
-    const controlViewState = getControlViewState()
-    const pageState = {
-      ...nextState,
-      ...controlViewState,
-      collapsedCards: getCollapseState(this.data.collapsedCards)
-    }
-
+    const pageState = getVisiblePageState(this.data)
     this.setData(pageState)
     this.pageToast.showFromState(pageState)
+    this.scheduleVisibleGenericAutoReads()
   },
 
   onHide() {
     if (this.pageToast) {
       this.pageToast.setActive(false)
     }
+    this.clearGenericAutoTimers()
   },
 
   onUnload() {
@@ -144,6 +127,23 @@ Page({
       this.unsubscribeControl()
       this.unsubscribeControl = null
     }
+
+    if (this.unsubscribeTheme) {
+      this.unsubscribeTheme()
+      this.unsubscribeTheme = null
+    }
+
+    if (this.unsubscribeGenericModbus) {
+      this.unsubscribeGenericModbus()
+      this.unsubscribeGenericModbus = null
+    }
+
+    if (this.unsubscribeSettings) {
+      this.unsubscribeSettings()
+      this.unsubscribeSettings = null
+    }
+
+    this.clearGenericAutoTimers()
   },
 
   async onGroupRead(event) {
@@ -170,20 +170,107 @@ Page({
     }
   },
 
+  async readCombinedGroups(viewKey) {
+    if (!this.data.connectedDevice) return false
+
+    const groupKeys = getCombinedGroupKeys(viewKey)
+    let nextState = this.data
+
+    for (const groupKey of groupKeys) {
+      const updatedState = await paramsService.readGroup(nextState, groupKey)
+      if (!updatedState) {
+        if (nextState !== this.data) this.setData(nextState)
+        return false
+      }
+
+      nextState = updatedState
+      this.setData(nextState)
+    }
+
+    if (this.pageToast) this.pageToast.show(`${getCombinedGroupLabel(viewKey)}读取完成`)
+
+    return true
+  },
+
+  async writeCombinedGroups(viewKey) {
+    if (!this.data.connectedDevice) return false
+
+    const groupKeys = getCombinedGroupKeys(viewKey)
+    let nextState = this.data
+    let writtenAny = false
+
+    for (const groupKey of groupKeys) {
+      if (!hasWritableGroupChanges(nextState, groupKey)) continue
+
+      const written = await paramsService.writeGroup(nextState, groupKey)
+      if (!written) {
+        if (writtenAny) this.setData(nextState)
+        return false
+      }
+
+      nextState = paramsPageState.clearGroupDirty(nextState, groupKey)
+      writtenAny = true
+      this.setData(nextState)
+    }
+
+    if (!writtenAny) {
+      if (this.pageToast) this.pageToast.show('暂无需要写入的参数')
+      return false
+    }
+
+    if (this.pageToast) this.pageToast.show(`${getCombinedGroupLabel(viewKey)}写入完成`)
+
+    return true
+  },
+
+  readStartupManagement() {
+    this.readCombinedGroups('startup')
+  },
+
+  writeStartupManagement() {
+    this.writeCombinedGroups('startup')
+  },
+
+  readSpeedManagement() {
+    this.readCombinedGroups('speed')
+  },
+
+  writeSpeedManagement() {
+    this.writeCombinedGroups('speed')
+  },
+
   onEstimatorUpdate() {
     this.setData(paramsPageState.refreshState(this.data))
     if (this.pageToast) this.pageToast.show('估算器参数更新完成')
   },
 
-  toggleCard(event) {
-    const cardKey = event.currentTarget.dataset.card
-    const collapsedCards = this.data.collapsedCards || {}
+  openParamView(event) {
+    if (this.pageToast) this.pageToast.clear()
+    this.closeGenericModbusDraft()
 
-    if (!cardKey) return
+    const activeParamView = event.currentTarget.dataset.view
+    if (!activeParamView) return
 
     this.setData({
-      [`collapsedCards.${cardKey}`]: !collapsedCards[cardKey]
+      activeParamView
     })
+    if (activeParamView === 'genericModbus') {
+      setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+    }
+  },
+
+  backToParamsHome() {
+    if (this.pageToast) this.pageToast.clear()
+    this.closeGenericModbusDraft()
+    this.clearGenericAutoTimers()
+
+    const activeParamView = resolveActiveParamView('', this.data)
+    this.setData({
+      activeParamView
+    })
+    if (activeParamView === 'genericModbus') {
+      setTimeout(() => this.scheduleVisibleGenericAutoReads(), 0)
+    }
   },
 
   onMotorParameterInput(event) {
@@ -200,22 +287,17 @@ Page({
     )
   },
 
-  readMotorParameters() {
-    if (!this.data.connectedDevice) return
-
-    controlService.readMotorParameters()
-  },
-
   writeMotorParameters() {
     if (!this.data.connectedDevice) return
 
     controlService.writeMotorParameters()
   },
 
-  readDriverParameters() {
+  async readDriverPageParameters() {
     if (!this.data.connectedDevice) return
 
-    controlService.readDriverParameters()
+    await controlService.readDriverParameters()
+    await controlService.readMotorParameters()
   },
 
   readStatus() {
@@ -224,16 +306,6 @@ Page({
     controlService.readStatus()
   },
 
-  onAutoReadStatusTap() {
-    if (!this.data.autoReadStatus && !this.data.canReadStatus) return
-
-    controlService.setAutoReadStatus(!this.data.autoReadStatus)
-  },
-
-  onAutoReadIntervalInput(event) {
-    controlService.setAutoReadInterval(event.detail.value)
-  },
-
   onInputChange(event) {
     this.setData(paramsPageState.applyParameterInput(
       this.data,
@@ -335,5 +407,235 @@ Page({
       Number(event.currentTarget.dataset.index),
       event.detail.value
     ))
+  },
+
+  noop() {},
+
+  updateGenericModbusDialog(changedData) {
+    this.setData({
+      genericModbusDialog: {
+        ...this.data.genericModbusDialog,
+        ...changedData
+      }
+    })
+  },
+
+  openGenericModbusDraft(event) {
+    const groupId = event && event.currentTarget && event.currentTarget.dataset
+      ? event.currentTarget.dataset.groupId
+      : ''
+    const group = groupId ? findGenericGroup(this.data.genericModbusGroups, groupId) : null
+
+    this.updateGenericModbusDialog(createGenericGroupDialogState(group))
+  },
+
+  closeGenericModbusDraft() {
+    this.genericModbusGroupLongPressGuard = ''
+    this.genericModbusRegisterLongPressGuard = ''
+    this.updateGenericModbusDialog(createGenericModbusDialogState())
+  },
+
+  onGenericDraftInput(event) {
+    const field = event.currentTarget.dataset.field
+    if (!field) return
+
+    this.updateGenericModbusDialog({
+      [field]: event.detail.value
+    })
+  },
+
+  onGenericDraftTypeChange(event) {
+    const registerTypeIndex = Number(event.detail.value)
+    const registerType = getGenericOption(this.data.genericModbusRegisterTypeOptions, registerTypeIndex)
+
+    this.updateGenericModbusDialog({
+      registerTypeIndex,
+      registerTypeText: registerType.label || ''
+    })
+  },
+
+  onGenericDialogDataTypeChange(event) {
+    const dataTypeIndex = Number(event.detail.value)
+
+    this.updateGenericModbusDialog(getGenericDialogDataTypeState(
+      this.data.genericModbusDialog,
+      this.data.genericModbusDataTypeOptions,
+      dataTypeIndex
+    ))
+  },
+
+  openGenericGroupEdit(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    if (!group) return
+
+    this.genericModbusGroupLongPressGuard = groupId
+
+    this.updateGenericModbusDialog(createGenericGroupDialogState(group))
+  },
+
+  openGenericRegisterInfo(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const registerIndex = Number(event.currentTarget.dataset.index)
+    const registerKey = `${groupId}:${registerIndex}`
+    if (this.genericModbusRegisterLongPressGuard === registerKey) {
+      this.genericModbusRegisterLongPressGuard = ''
+      return
+    }
+
+    const {
+      group,
+      register
+    } = findGenericRegister(this.data.genericModbusGroups, groupId, registerIndex)
+    if (!register) return
+
+    this.updateGenericModbusDialog(createGenericRegisterDialogState('viewRegister', group, register, registerIndex))
+  },
+
+  openGenericRegisterEdit(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const registerIndex = Number(event.currentTarget.dataset.index)
+    const {
+      group,
+      register
+    } = findGenericRegister(this.data.genericModbusGroups, groupId, registerIndex)
+    if (!register) return
+
+    this.genericModbusRegisterLongPressGuard = `${groupId}:${registerIndex}`
+    this.updateGenericModbusDialog(createGenericRegisterDialogState('editRegister', group, register, registerIndex))
+  },
+
+  async confirmGenericModbusDialog() {
+    const dialog = this.data.genericModbusDialog || createGenericModbusDialogState()
+    const mode = dialog.mode
+
+    if (mode === 'createGroup') {
+      const group = genericModbusService.addGroupFromConfig(createGenericGroupConfig(dialog))
+      if (group) {
+        if (this.pageToast) this.pageToast.show(`${group.name}已添加`)
+        this.closeGenericModbusDraft()
+      }
+      return
+    }
+
+    if (mode === 'editGroup') {
+      const group = genericModbusService.updateGroupConfig(dialog.groupId, createGenericGroupConfig(dialog))
+      if (group) {
+        if (this.pageToast) this.pageToast.show(`${group.name}已更新`)
+        this.closeGenericModbusDraft()
+      }
+      return
+    }
+
+    if (mode === 'editRegister') {
+      const changedData = createGenericRegisterChangedData(dialog, this.data.genericModbusDataTypeOptions)
+      genericModbusService.updateRegister(dialog.groupId, dialog.registerIndex, changedData)
+      if (this.pageToast) this.pageToast.show(`${dialog.name || '寄存器'}已更新`)
+      this.closeGenericModbusDraft()
+    }
+  },
+
+  async importGenericModbusJson() {
+    const count = await genericModbusService.importJsonFromMessageFile()
+    if (count && this.pageToast) this.pageToast.show(`已导入 ${count} 个寄存器组`)
+  },
+
+  async saveGenericModbusJson() {
+    const count = await genericModbusService.saveJsonToChat()
+    if (count && this.pageToast) this.pageToast.show(`已保存 ${count} 个寄存器组`)
+  },
+
+  toggleGenericModbusGroup(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    if (this.genericModbusGroupLongPressGuard === groupId) {
+      this.genericModbusGroupLongPressGuard = ''
+      return
+    }
+    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    if (!group) return
+
+    genericModbusService.setGroupExpanded(groupId, !group.expanded)
+  },
+
+  onGenericRegisterValueInput(event) {
+    genericModbusService.updateRegisterValue(
+      event.currentTarget.dataset.groupId,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    )
+  },
+
+  onGenericRegisterValueBlur(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const registerIndex = Number(event.currentTarget.dataset.index)
+    try {
+      genericModbusService.validateRegisterInputValue(groupId, registerIndex, event.detail.value)
+    } catch (error) {
+      if (this.pageToast) this.pageToast.show(error.message || '输入值无效', 'error')
+    }
+  },
+
+  async readGenericModbusGroup(event) {
+    if (!this.data.connectedDevice) return
+
+    const groupId = event.currentTarget.dataset.groupId
+    const ok = await genericModbusService.readGroup(groupId, {
+      maxPacketLength: this.data.genericModbusMaxPacketLength
+    })
+    if (ok && this.pageToast) this.pageToast.show('通用Modbus读取完成')
+  },
+
+  async writeGenericModbusGroup(event) {
+    if (!this.data.connectedDevice) return
+
+    const groupId = event.currentTarget.dataset.groupId
+    const ok = await genericModbusService.writeGroup(groupId)
+    if (ok && this.pageToast) this.pageToast.show('通用Modbus写入完成')
+  },
+
+  onGenericGroupTouchStart(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const touch = (event.changedTouches || [])[0]
+    if (!groupId || !touch) return
+
+    this.genericModbusTouchStarts[groupId] = touch.clientX
+  },
+
+  onGenericGroupTouchEnd(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    const group = findGenericGroup(this.data.genericModbusGroups, groupId)
+    const touch = (event.changedTouches || [])[0]
+    const startX = this.genericModbusTouchStarts[groupId]
+    if (!groupId || !group || group.expanded || !touch || !Number.isFinite(startX)) return
+
+    const deltaX = touch.clientX - startX
+    if (deltaX > 42) {
+      genericModbusService.setGroupDeleteVisible(groupId, true)
+    } else if (deltaX < -24) {
+      genericModbusService.setGroupDeleteVisible(groupId, false)
+    }
+  },
+
+  deleteGenericModbusGroup(event) {
+    const groupId = event.currentTarget.dataset.groupId
+    this.clearGenericAutoTimer(groupId)
+    genericModbusService.removeGroup(groupId)
+    if (this.pageToast) this.pageToast.show('寄存器组已删除')
+  },
+
+  clearGenericAutoTimer(groupId) {
+    if (this.genericModbusPoller) this.genericModbusPoller.clearTimer(groupId)
+  },
+
+  clearGenericAutoTimers() {
+    if (this.genericModbusPoller) this.genericModbusPoller.clearAll()
+  },
+
+  scheduleVisibleGenericAutoReads() {
+    if (this.genericModbusPoller) this.genericModbusPoller.scheduleVisible()
+  },
+
+  scheduleGenericAutoPoll(delay) {
+    if (this.genericModbusPoller) this.genericModbusPoller.schedule(delay)
   }
 })

+ 405 - 346
pages/params/params.wxml

@@ -1,42 +1,76 @@
-<navigation-bar background="#FFF"></navigation-bar>
-<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+<navigation-bar background="{{themeMode === 'dark' ? '#111827' : '#FFF'}}"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
   {{toastText}}
 </view>
-<scroll-view class="scrollarea" scroll-y type="list">
+<view wx:if="{{activeParamView}}" class="subpage-fixed-header {{activeParamView == 'genericModbus' ? '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' ? '状态' : ''}}
+    </view>
+    <view wx:if="{{activeParamView == 'driver'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="readDriverPageParameters">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="writeMotorParameters">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'protection'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="protection" bindtap="onGroupRead">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="protection" bindtap="onGroupWrite">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'estimator'}}" class="panel-actions panel-actions--three subpage-actions">
+      <view class="panel-action-button" bindtap="onEstimatorUpdate">更新</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="estimator" bindtap="onGroupRead">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="estimator" bindtap="onGroupWrite">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'dq'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="dq" bindtap="onGroupRead">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="dq" bindtap="onGroupWrite">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'startup'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="readStartupManagement">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="writeStartupManagement">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'speed'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="readSpeedManagement">读取</view>
+      <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" bindtap="writeSpeedManagement">写入</view>
+    </view>
+    <view wx:elif="{{activeParamView == 'genericModbus'}}" class="panel-actions subpage-actions">
+      <view class="panel-action-button" bindtap="saveGenericModbusJson">保存</view>
+      <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 == '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">
   <view class="page-shell">
-    <view class="panel {{collapsedCards.motor ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.motor ? 'is-collapsed' : ''}}"
-          data-card="motor"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-motor"></view>
-          <view class="panel-title">电机参数</view>
+    <block wx:if="{{activeParamView == 'driver'}}">
+      <view class="panel driver-summary-panel">
+        <view class="driver-summary-row driver-summary-row--top">
+          <text class="driver-summary-chip">{{chipModel || '--'}}</text>
+          <text class="driver-summary-checksum">{{flashChecksum || '--'}}</text>
         </view>
-        <view class="panel-actions">
-          <view
-            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
-            bindtap="readMotorParameters"
-          >
-            读取
-          </view>
-          <view
-            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
-            bindtap="writeMotorParameters"
-          >
-            写入
-          </view>
+        <view class="driver-summary-row driver-summary-row--model">
+          <text class="driver-summary-model">{{motorModel || '--'}}</text>
         </view>
-        <view
-          class="collapse-toggle {{collapsedCards.motor ? 'is-collapsed' : ''}}"
-          data-card="motor"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">硬件参数</view>
+        <view wx:for="{{readonlyParamRegisters}}" wx:key="name" class="param-row">
+          <view class="param-main">
+            <view class="param-name">{{item.name}}</view>
+            <view wx:if="{{!item.hideMeta}}" class="param-meta">{{item.addressDisplay}}</view>
+          </view>
+          <view class="param-value">{{item.displayValue || '--'}}{{item.unit ? ' ' + item.unit : ''}}</view>
         </view>
       </view>
-      <block wx:if="{{!collapsedCards.motor}}">
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">电机参数</view>
         <view wx:for="{{motorParameterInputRegisters}}" wx:key="name" class="param-row input-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
@@ -54,70 +88,60 @@
             />
           </view>
         </view>
-      </block>
-    </view>
+      </view>
+    </block>
 
-    <view class="panel {{collapsedCards.driver ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
+    <block wx:elif="{{activeParamView == 'protection'}}">
+      <view
+        wx:for="{{protectionSections}}"
+        wx:for-item="section"
+        wx:key="key"
+        class="panel protection-section-panel"
+      >
+        <view class="params-section-title">{{section.title}}</view>
         <view
-          class="panel-heading-toggle {{collapsedCards.driver ? 'is-collapsed' : ''}}"
-          data-card="driver"
-          bindtap="toggleCard"
+          wx:for="{{section.rows}}"
+          wx:for-item="row"
+          wx:key="key"
+          class="protection-field-row"
         >
-          <view class="panel-icon icon-chip"></view>
-          <view class="panel-title">驱动器硬件参数</view>
-        </view>
-        <view class="panel-actions">
           <view
-            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
-            bindtap="readDriverParameters"
+            wx:for="{{row.fields}}"
+            wx:for-item="field"
+            wx:key="name"
+            class="protection-field protection-field--{{field.kind}}"
           >
-            读取
+            <view class="protection-field-main">
+              <view class="param-name">{{field.label}}</view>
+              <view class="param-meta {{field.isDirty ? 'param-meta--dirty' : ''}}">{{field.addressDisplay}} {{field.metaValue}}</view>
+            </view>
+            <switch
+              wx:if="{{field.kind == 'switch'}}"
+              checked="{{field.value}}"
+              color="#0f766e"
+              disabled="{{!connectedDevice}}"
+              data-index="{{field.sourceIndex}}"
+              bindchange="onProtectionSwitchChange"
+            />
+            <view wx:else class="input-wrap">
+              <input
+                class="value-input {{field.unit ? 'value-input--with-unit' : ''}}"
+                type="{{field.unit ? 'text' : 'digit'}}"
+                placeholder="--"
+                value="{{field.inputValue}}"
+                data-index="{{field.sourceIndex}}"
+                data-input-group="protection"
+                bindinput="onProtectionInputChange"
+                bindblur="onInputBlur"
+              />
+            </view>
           </view>
         </view>
-        <view
-          class="collapse-toggle {{collapsedCards.driver ? 'is-collapsed' : ''}}"
-          data-card="driver"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.driver}}">
-        <view wx:for="{{readonlyParamRegisters}}" wx:key="name" class="param-row">
-          <view class="param-main">
-            <view class="param-name">{{item.name}}</view>
-            <view wx:if="{{!item.hideMeta}}" class="param-meta">{{item.addressDisplay}}</view>
-          </view>
-          <view class="param-value">{{item.displayValue || '--'}}{{item.unit ? ' ' + item.unit : ''}}</view>
-        </view>
-      </block>
-    </view>
+    </block>
 
-    <view wx:if="{{estimatorCalculatedDisplayRegisters.length || atoBandwidthDisplayRegisters.length}}" class="panel {{collapsedCards.estimator ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.estimator ? 'is-collapsed' : ''}}"
-          data-card="estimator"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-bars"></view>
-          <view class="panel-title">估算器参数</view>
-        </view>
-        <view class="panel-actions panel-actions--three">
-          <view class="panel-action-button" bindtap="onEstimatorUpdate">更新</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="estimator" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="estimator" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.estimator ? 'is-collapsed' : ''}}"
-          data-card="estimator"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
-      </view>
-      <block wx:if="{{!collapsedCards.estimator}}">
+    <block wx:elif="{{activeParamView == 'estimator'}}">
+      <view class="panel params-section-panel">
         <view wx:for="{{estimatorCalculatedDisplayRegisters}}" wx:key="name" class="param-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
@@ -148,32 +172,11 @@
             />
           </view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{dqGainDisplayRegisters.length}}" class="panel {{collapsedCards.dq ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.dq ? 'is-collapsed' : ''}}"
-          data-card="dq"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-tune"></view>
-          <view class="panel-title">DQ轴电流环参数</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="dq" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="dq" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.dq ? 'is-collapsed' : ''}}"
-          data-card="dq"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.dq}}">
+    </block>
+
+    <block wx:elif="{{activeParamView == 'dq'}}">
+      <view class="panel params-section-panel">
         <view
           wx:for="{{dqGainDisplayRegisters}}"
           wx:for-item="dqItem"
@@ -197,32 +200,12 @@
             />
           </view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{tailwindControlRegisters.length || tailwindCalculatedDisplayRegisters.length || tailwindAtoBandwidthDisplayRegisters.length}}" class="panel {{collapsedCards.tailwind ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.tailwind ? 'is-collapsed' : ''}}"
-          data-card="tailwind"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-wind"></view>
-          <view class="panel-title">顺逆风配置</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="tailwind" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="tailwind" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.tailwind ? 'is-collapsed' : ''}}"
-          data-card="tailwind"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.tailwind}}">
+    </block>
+
+    <block wx:elif="{{activeParamView == 'startup'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title">顺逆风配置</view>
         <view wx:for="{{tailwindControlRegisters}}" wx:key="address" class="param-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
@@ -266,32 +249,10 @@
             />
           </view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{prepositionSwitchRegisters.length || prepositionParameterDisplayRegisters.length}}" class="panel {{collapsedCards.preposition ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.preposition ? 'is-collapsed' : ''}}"
-          data-card="preposition"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-target"></view>
-          <view class="panel-title">预定位配置</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="preposition" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="preposition" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.preposition ? 'is-collapsed' : ''}}"
-          data-card="preposition"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.preposition}}">
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">预定位配置</view>
         <view wx:for="{{prepositionSwitchRegisters}}" wx:key="address" class="param-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
@@ -323,32 +284,12 @@
             />
           </view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{speedLoopInputDisplayRegisters.length || speedLoopCalculatedDisplayRegisters.length || speedLoopExtraDisplayRegisters.length}}" class="panel {{collapsedCards.speedLoop ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.speedLoop ? 'is-collapsed' : ''}}"
-          data-card="speedLoop"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-speed"></view>
-          <view class="panel-title">速度环路</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="speedLoop" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="speedLoop" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.speedLoop ? 'is-collapsed' : ''}}"
-          data-card="speedLoop"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.speedLoop}}">
+    </block>
+
+    <block wx:elif="{{activeParamView == 'speed'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title">速度环路</view>
         <view wx:for="{{speedLoopInputDisplayRegisters}}" wx:key="name">
           <view class="param-row input-row">
             <view class="param-main">
@@ -369,13 +310,6 @@
             </view>
           </view>
         </view>
-        <view wx:for="{{speedLoopCalculatedDisplayRegisters}}" wx:key="name" class="param-row">
-          <view class="param-main">
-            <view class="param-name">{{item.name}}</view>
-            <view class="param-meta {{item.isDirty ? 'param-meta--dirty' : ''}}">{{item.addressDisplay}} {{item.writeValue || '--'}}</view>
-          </view>
-          <view class="param-value">{{item.writeValue || '--'}}{{item.unit ? ' ' + item.unit : ''}}</view>
-        </view>
         <view wx:for="{{speedLoopExtraDisplayRegisters}}" wx:key="name">
           <view class="param-row input-row">
             <view class="param-main">
@@ -396,32 +330,10 @@
             </view>
           </view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{vspCurveRegisters.length}}" class="panel {{collapsedCards.vsp ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.vsp ? 'is-collapsed' : ''}}"
-          data-card="vsp"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-curve"></view>
-          <view class="panel-title">VSP曲线</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="vsp" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="vsp" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.vsp ? 'is-collapsed' : ''}}"
-          data-card="vsp"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.vsp}}">
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">VSP曲线</view>
         <view wx:for="{{vspCurveRegisters}}" wx:key="name">
           <view class="param-row input-row">
             <view class="param-main">
@@ -449,32 +361,10 @@
           </view>
           <view class="param-value">{{speedSlopeRegister.writeValue || '--'}}{{speedSlopeRegister.unit ? ' ' + speedSlopeRegister.unit : ''}}</view>
         </view>
-      </block>
-    </view>
-
-    <view wx:if="{{oilParameterInputRegisters.length}}" class="panel {{collapsedCards.oil ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.oil ? 'is-collapsed' : ''}}"
-          data-card="oil"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-oil"></view>
-          <view class="panel-title">上油参数</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="oil" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="oil" bindtap="onGroupWrite">写入</view>
-        </view>
-        <view
-          class="collapse-toggle {{collapsedCards.oil ? 'is-collapsed' : ''}}"
-          data-card="oil"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
-        </view>
       </view>
-      <block wx:if="{{!collapsedCards.oil}}">
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">上油参数</view>
         <view wx:for="{{oilParameterInputRegisters}}" wx:key="name" class="param-row input-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
@@ -493,141 +383,310 @@
             />
           </view>
         </view>
-      </block>
-    </view>
+      </view>
+    </block>
 
-    <view class="panel {{collapsedCards.protectionSwitch ? 'panel--collapsed' : ''}}">
-      <view class="panel-header panel-header--with-actions">
+    <block wx:elif="{{activeParamView == 'genericModbus'}}">
+      <view wx:if="{{!genericModbusGroups.length && !genericModbusDialog.visible}}" class="empty-state generic-empty-state">
+        <view class="empty-title">暂无寄存器组</view>
+        <view class="empty-text">点击右上角 + 添加,或从聊天记录导入 JSON</view>
+      </view>
+
+      <view
+        wx:for="{{genericModbusGroups}}"
+        wx:for-item="group"
+        wx:key="id"
+        class="generic-group-shell {{group.deleteVisible ? 'is-delete-visible' : ''}}"
+      >
         <view
-          class="panel-heading-toggle {{collapsedCards.protectionSwitch ? 'is-collapsed' : ''}}"
-          data-card="protectionSwitch"
-          bindtap="toggleCard"
+          wx:if="{{!group.expanded && group.deleteVisible}}"
+          class="generic-delete-action"
+          data-group-id="{{group.id}}"
+          bindtap="deleteGenericModbusGroup"
         >
-          <view class="panel-icon icon-shield-check"></view>
-          <view class="panel-title">保护控制</view>
-        </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="protectionSwitch" bindtap="onGroupRead">读取</view>
+          -
         </view>
         <view
-          class="collapse-toggle {{collapsedCards.protectionSwitch ? 'is-collapsed' : ''}}"
-          data-card="protectionSwitch"
-          bindtap="toggleCard"
+          class="panel generic-group-panel {{group.expanded ? '' : 'panel--collapsed'}}"
+          data-group-id="{{group.id}}"
+          bindtouchstart="onGenericGroupTouchStart"
+          bindtouchend="onGenericGroupTouchEnd"
         >
-          <view class="collapse-indicator"></view>
+          <view class="panel-header panel-header--with-actions">
+            <view
+              class="panel-heading-toggle"
+              data-group-id="{{group.id}}"
+              bindtap="toggleGenericModbusGroup"
+            >
+              <view class="panel-icon icon-terminal"></view>
+              <view class="generic-group-title-wrap">
+                <view class="panel-title" data-group-id="{{group.id}}" catchlongpress="openGenericGroupEdit">{{group.name}}</view>
+                <view class="param-meta generic-group-meta">{{group.addressRangeText}} · {{group.quantity}}/{{group.wordQuantity}}{{group.addressWarningText ? ' · ' + group.addressWarningText : ''}}</view>
+              </view>
+            </view>
+            <view class="panel-actions generic-group-actions">
+              <view
+                class="panel-action-button {{connectedDevice && !group.addressOverflow ? '' : 'is-disabled'}}"
+                data-group-id="{{group.id}}"
+                bindtap="readGenericModbusGroup"
+              >
+                读取
+              </view>
+              <view
+                wx:if="{{group.writable}}"
+                class="panel-action-button {{connectedDevice && !group.addressOverflow ? '' : 'is-disabled'}}"
+                data-group-id="{{group.id}}"
+                bindtap="writeGenericModbusGroup"
+              >
+                写入
+              </view>
+              <view class="entry-chevron {{group.expanded ? 'is-expanded' : ''}}"></view>
+            </view>
+          </view>
+
+          <block wx:if="{{group.expanded}}">
+            <view
+              wx:for="{{group.registers}}"
+              wx:for-item="register"
+              wx:for-index="registerIndex"
+              wx:key="id"
+              class="generic-register-row"
+            >
+              <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>
+            </view>
+          </block>
         </view>
       </view>
-      <block wx:if="{{!collapsedCards.protectionSwitch}}">
-        <view wx:for="{{protectionSwitchRegisters}}" wx:key="address" class="param-row">
+    </block>
+
+    <block wx:elif="{{activeParamView == 'status'}}">
+      <view class="panel params-section-panel">
+        <view wx:for="{{statusRegisters}}" wx:key="name" class="param-row">
           <view class="param-main">
             <view class="param-name">{{item.name}}</view>
-            <view class="param-meta {{item.isDirty ? 'param-meta--dirty' : ''}}">{{item.addressDisplay}} {{item.writeValue}}</view>
+            <view class="param-meta">{{item.addressDisplay}} {{item.rawValue}}</view>
           </view>
-          <switch
-            checked="{{item.value}}"
-            color="#0f766e"
-            disabled="{{!connectedDevice}}"
-            data-index="{{index}}"
-            bindchange="onProtectionSwitchChange"
-          />
+          <view class="param-value">{{item.displayValue}}{{item.displayUnit ? ' ' + item.displayUnit : ''}}</view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:else>
+      <view class="panel panel--collapsed param-entry-panel" data-view="driver" bindtap="openParamView">
+        <view class="panel-header panel-header--with-actions">
+          <view class="panel-heading-toggle">
+            <view class="panel-icon icon-chip"></view>
+            <view class="panel-title">驱动器参数</view>
+          </view>
+          <view class="entry-chevron"></view>
+        </view>
+      </view>
+
+    <view class="panel panel--collapsed param-entry-panel" data-view="estimator" bindtap="openParamView">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-heading-toggle">
+          <view class="panel-icon icon-bars"></view>
+          <view class="panel-title">估算器参数</view>
         </view>
-      </block>
+        <view class="entry-chevron"></view>
+      </view>
     </view>
 
-    <view class="panel {{collapsedCards.protection ? 'panel--collapsed' : ''}}">
+    <view class="panel panel--collapsed param-entry-panel" data-view="dq" bindtap="openParamView">
       <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.protection ? 'is-collapsed' : ''}}"
-          data-card="protection"
-          bindtap="toggleCard"
-        >
-          <view class="panel-icon icon-shield-alert"></view>
-          <view class="panel-title">保护参数</view>
+        <view class="panel-heading-toggle">
+          <view class="panel-icon icon-tune"></view>
+          <view class="panel-title">DQ轴电流环参数</view>
         </view>
-        <view class="panel-actions">
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="protection" bindtap="onGroupRead">读取</view>
-          <view class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}" data-group="protection" bindtap="onGroupWrite">写入</view>
+        <view class="entry-chevron"></view>
+      </view>
+    </view>
+
+    <view class="panel panel--collapsed param-entry-panel" data-view="startup" bindtap="openParamView">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-heading-toggle">
+          <view class="panel-icon icon-target"></view>
+          <view class="panel-title">启动位置管理</view>
         </view>
-        <view
-          class="collapse-toggle {{collapsedCards.protection ? 'is-collapsed' : ''}}"
-          data-card="protection"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
+        <view class="entry-chevron"></view>
+      </view>
+    </view>
+
+    <view class="panel panel--collapsed param-entry-panel" data-view="speed" bindtap="openParamView">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-heading-toggle">
+          <view class="panel-icon icon-speed"></view>
+          <view class="panel-title">速度管理</view>
         </view>
+        <view class="entry-chevron"></view>
       </view>
-      <block wx:if="{{!collapsedCards.protection}}">
-        <view wx:for="{{protectionDisplayRegisters}}" wx:key="name" class="param-row">
-          <view class="param-main">
-            <view class="param-name">{{item.name}}</view>
-            <view class="param-meta {{item.isDirty ? 'param-meta--dirty' : ''}}">{{item.addressDisplay}} {{item.writeValue || '--'}}</view>
-          </view>
-          <view class="input-wrap">
-            <input
-              class="value-input {{item.unit ? 'value-input--with-unit' : ''}}"
-              type="{{item.unit ? 'text' : 'digit'}}"
-              placeholder="--"
-              value="{{item.inputValue}}"
-              data-index="{{item.sourceIndex}}"
-              data-input-group="protection"
-              bindinput="onProtectionInputChange"
-              bindblur="onInputBlur"
-            />
-          </view>
+    </view>
+
+    <view class="panel panel--collapsed param-entry-panel" data-view="protection" bindtap="openParamView">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-heading-toggle">
+          <view class="panel-icon icon-shield-check"></view>
+          <view class="panel-title">保护</view>
         </view>
-      </block>
+        <view class="entry-chevron"></view>
+      </view>
     </view>
 
-    <view class="panel {{collapsedCards.status ? 'panel--collapsed' : ''}}">
+    <view class="panel panel--collapsed param-entry-panel" data-view="status" bindtap="openParamView">
       <view class="panel-header panel-header--with-actions">
-        <view
-          class="panel-heading-toggle {{collapsedCards.status ? 'is-collapsed' : ''}}"
-          data-card="status"
-          bindtap="toggleCard"
-        >
+        <view class="panel-heading-toggle">
           <view class="panel-icon icon-status"></view>
           <view class="panel-title">状态</view>
         </view>
-        <view class="panel-actions panel-actions--status">
-          <view class="status-auto-controls">
-            <input
-              class="auto-read-interval"
-              type="number"
-              value="{{autoReadInterval}}"
-              bindinput="onAutoReadIntervalInput"
-            />
-            <text class="auto-read-unit">ms</text>
-            <view
-              class="panel-action-button auto-read-button {{autoReadStatus ? 'is-active' : ''}} {{autoReadStatus || canReadStatus ? '' : 'is-disabled'}}"
-              bindtap="onAutoReadStatusTap"
-            >
-              {{autoReadStatus ? '停止' : '自动'}}
-            </view>
+        <view class="entry-chevron"></view>
+      </view>
+    </view>
+    </block>
+  </view>
+</scroll-view>
+<view wx:if="{{genericModbusDialog.visible}}" class="generic-dialog-mask {{themeClass}}" bindtap="closeGenericModbusDraft">
+  <view class="generic-dialog" catchtap="noop">
+    <view class="generic-dialog-header">
+      <view class="generic-dialog-title">{{genericModbusDialog.title}}</view>
+      <view class="generic-dialog-close" bindtap="closeGenericModbusDraft">×</view>
+    </view>
+
+    <block wx:if="{{genericModbusDialog.mode == 'createGroup' || genericModbusDialog.mode == 'editGroup'}}">
+      <view class="generic-dialog-body">
+        <view class="generic-config-row">
+          <view class="param-main">
+            <view class="param-name">寄存器组名</view>
+            <view class="param-meta">每组寄存器地址连续</view>
           </view>
-          <view
-            class="panel-action-button {{canReadStatus ? '' : 'is-disabled'}}"
-            bindtap="readStatus"
+          <input
+            class="value-input generic-value-input"
+            data-field="groupName"
+            value="{{genericModbusDialog.groupName}}"
+            bindinput="onGenericDraftInput"
+          />
+        </view>
+        <view class="generic-config-row">
+          <view class="param-main">
+            <view class="param-name">寄存器类型</view>
+            <view class="param-meta">决定读取功能码与是否可写</view>
+          </view>
+          <picker
+            mode="selector"
+            range="{{genericModbusRegisterTypeOptions}}"
+            range-key="label"
+            value="{{genericModbusDialog.registerTypeIndex}}"
+            bindchange="onGenericDraftTypeChange"
           >
-            读取
+            <view class="generic-picker-value">{{genericModbusDialog.registerTypeText}}</view>
+          </picker>
+        </view>
+        <view class="generic-config-row">
+          <view class="param-main">
+            <view class="param-name">寄存器起始地址</view>
+            <view class="param-meta">16进制,例如 00A0</view>
           </view>
+          <input
+            class="value-input generic-value-input"
+            data-field="startAddress"
+            value="{{genericModbusDialog.startAddress}}"
+            bindinput="onGenericDraftInput"
+          />
         </view>
-        <view
-          class="collapse-toggle {{collapsedCards.status ? 'is-collapsed' : ''}}"
-          data-card="status"
-          bindtap="toggleCard"
-        >
-          <view class="collapse-indicator"></view>
+        <view class="generic-config-row">
+          <view class="param-main">
+            <view class="param-name">寄存器数量</view>
+            <view class="param-meta">1 - 256</view>
+          </view>
+          <input
+            class="value-input generic-value-input"
+            type="number"
+            data-field="quantity"
+            value="{{genericModbusDialog.quantity}}"
+            bindinput="onGenericDraftInput"
+          />
         </view>
       </view>
-      <block wx:if="{{!collapsedCards.status}}">
-        <view wx:for="{{statusRegisters}}" 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>
+    </block>
+
+    <block wx:elif="{{genericModbusDialog.mode == 'editRegister' || genericModbusDialog.mode == 'viewRegister'}}">
+      <view class="generic-dialog-body">
+        <view class="generic-info-stack">
+          <view class="generic-info-row">
+            <view class="generic-info-label">名称</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.name}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="name" value="{{genericModbusDialog.name}}" bindinput="onGenericDraftInput" />
+          </view>
+          <view class="generic-info-row">
+            <view class="generic-info-label">地址</view>
+            <view class="generic-info-value">{{genericModbusDialog.addressText}}</view>
+          </view>
+          <view wx:if="{{genericModbusDialog.showDataType}}" class="generic-info-row">
+            <view class="generic-info-label">类型</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.dataTypeText}}</view>
+            <picker wx:else mode="selector" range="{{genericModbusDataTypeOptions}}" range-key="label" value="{{genericModbusDialog.dataTypeIndex}}" bindchange="onGenericDialogDataTypeChange">
+              <view class="generic-picker-value">{{genericModbusDialog.dataTypeText}}</view>
+            </picker>
+          </view>
+          <view wx:if="{{genericModbusDialog.showTextLength}}" class="generic-info-row">
+            <view class="generic-info-label">长度</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.textByteLength || '--'}}B</view>
+            <input wx:else class="value-input generic-value-input" type="number" data-field="textByteLength" value="{{genericModbusDialog.textByteLength}}" bindinput="onGenericDraftInput" />
+          </view>
+          <view class="generic-info-row">
+            <view class="generic-info-label">备注</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.remark || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="remark" value="{{genericModbusDialog.remark}}" bindinput="onGenericDraftInput" />
+          </view>
+          <view wx:if="{{genericModbusDialog.showUnit}}" class="generic-info-row">
+            <view class="generic-info-label">单位</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.unit || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="unit" value="{{genericModbusDialog.unit}}" bindinput="onGenericDraftInput" />
+          </view>
+          <view wx:if="{{genericModbusDialog.mode == 'viewRegister' || genericModbusDialog.showRange}}" class="generic-info-row">
+            <view class="generic-info-label">最小值</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.minValue || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="minValue" value="{{genericModbusDialog.minValue}}" bindinput="onGenericDraftInput" />
+          </view>
+          <view wx:if="{{genericModbusDialog.mode == 'viewRegister' || genericModbusDialog.showRange}}" class="generic-info-row">
+            <view class="generic-info-label">最大值</view>
+            <view wx:if="{{genericModbusDialog.mode == 'viewRegister'}}" class="generic-info-value">{{genericModbusDialog.maxValue || '--'}}</view>
+            <input wx:else class="value-input generic-value-input" data-field="maxValue" value="{{genericModbusDialog.maxValue}}" bindinput="onGenericDraftInput" />
           </view>
-          <view class="param-value">{{item.displayValue}}{{item.displayUnit ? ' ' + item.displayUnit : ''}}</view>
         </view>
-      </block>
+      </view>
+    </block>
+
+    <view class="generic-draft-actions">
+      <view class="panel-action-button" bindtap="closeGenericModbusDraft">{{genericModbusDialog.cancelText}}</view>
+      <view wx:if="{{genericModbusDialog.confirmText}}" class="panel-action-button is-active" bindtap="confirmGenericModbusDialog">{{genericModbusDialog.confirmText}}</view>
     </view>
   </view>
-</scroll-view>
+</view>

+ 131 - 0
pages/settings/settings.js

@@ -0,0 +1,131 @@
+const settingsService = require('../../utils/settings-service')
+const themeService = require('../../utils/theme-service')
+const toolNavigation = require('../../utils/tool-navigation')
+const {
+  createPageToast
+} = require('../../utils/page-toast')
+const {
+  createToolInitialState,
+  toolPageHandlers
+} = require('../../utils/tool-page')
+const {
+  getSettingsPageState
+} = require('../../utils/settings-view-model')
+
+Page({
+  data: {
+    ...getSettingsPageState(),
+    ...createToolInitialState(),
+    activeSettingsTitle: '',
+    activeSettingsView: ''
+  },
+
+  onLoad() {
+    this.pageToast = createPageToast(this, this.data)
+    this.crcFileBytes = null
+    settingsService.init()
+    themeService.init()
+
+    this.unsubscribeSettings = settingsService.subscribe((settingsState) => {
+      this.setData(getSettingsPageState(settingsState, themeService.getState()))
+    })
+    this.unsubscribeTheme = themeService.subscribe((themeState) => {
+      this.setData(getSettingsPageState(settingsService.getState(), themeState))
+    })
+  },
+
+  onTabItemTap() {
+    this.backToSettingsHome()
+  },
+
+  onShow() {
+    if (this.pageToast) {
+      this.pageToast.setActive(true)
+    }
+
+    this.setData(getSettingsPageState())
+  },
+
+  onHide() {
+    if (this.pageToast) {
+      this.pageToast.setActive(false)
+    }
+  },
+
+  onUnload() {
+    if (this.pageToast) {
+      this.pageToast.destroy()
+      this.pageToast = null
+    }
+
+    if (this.unsubscribeSettings) {
+      this.unsubscribeSettings()
+      this.unsubscribeSettings = null
+    }
+
+    if (this.unsubscribeTheme) {
+      this.unsubscribeTheme()
+      this.unsubscribeTheme = null
+    }
+  },
+
+  onNightModeEnabledChange(event) {
+    settingsService.setNightModeEnabled(!!event.detail.value)
+  },
+
+  onNightModeFollowSystemChange(event) {
+    settingsService.setNightModeFollowSystem(!!event.detail.value)
+  },
+
+  onModbusSlaveAddressBlur(event) {
+    settingsService.setModbusSlaveAddress(event.detail.value)
+  },
+
+  onModbusProtocolChange(event) {
+    const option = this.data.modbusProtocolOptions[Number(event.detail.value)]
+    if (!option) return
+
+    settingsService.setModbusProtocolFilter(option.key)
+  },
+
+  onGenericModbusAutoPollChange(event) {
+    settingsService.setGenericModbusAutoPollEnabled(!!event.detail.value)
+  },
+
+  onGenericModbusPollIntervalBlur(event) {
+    settingsService.setGenericModbusPollInterval(event.detail.value)
+  },
+
+  onGenericModbusMaxPacketLengthBlur(event) {
+    settingsService.setGenericModbusMaxPacketLength(event.detail.value)
+  },
+
+  onUserStatusCountBlur(event) {
+    settingsService.setUserStatusCount(event.detail.value, this.data.maxUserStatusCount)
+  },
+
+  onStatusPollIntervalBlur(event) {
+    settingsService.setStatusPollInterval(event.detail.value)
+  },
+
+  openToolEntry(event) {
+    const view = event.currentTarget.dataset.view
+    if (!toolNavigation.isToolView(view)) return
+
+    if (this.pageToast) this.pageToast.clear()
+    this.setData({
+      activeSettingsTitle: toolNavigation.getToolTitle(view),
+      activeSettingsView: view
+    })
+  },
+
+  backToSettingsHome() {
+    if (this.pageToast) this.pageToast.clear()
+    this.setData({
+      activeSettingsTitle: '',
+      activeSettingsView: ''
+    })
+  },
+
+  ...toolPageHandlers
+})

+ 5 - 0
pages/settings/settings.json

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

+ 759 - 0
pages/settings/settings.wxml

@@ -0,0 +1,759 @@
+<navigation-bar background="{{themeMode === 'dark' ? '#111827' : '#FFF'}}"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}} {{themeClass}}">
+  {{toastText}}
+</view>
+<view wx:if="{{activeSettingsView}}" class="subpage-fixed-header {{themeClass}}">
+  <view class="subpage-page-header">
+    <view class="subpage-back" bindtap="backToSettingsHome">
+      <view class="subpage-back-icon"></view>
+    </view>
+    <view class="subpage-page-title">{{activeSettingsTitle}}</view>
+  </view>
+</view>
+<scroll-view class="scrollarea {{themeClass}} {{activeSettingsView ? 'scrollarea--subpage' : ''}}" scroll-y type="list">
+  <view class="page-shell">
+    <block wx:if="{{activeSettingsView}}">
+    <block wx:if="{{activeSettingsView == 'crc'}}">
+      <view class="panel params-section-panel crc-algorithm-panel {{crcAlgorithmCollapsed ? 'panel--collapsed' : ''}}">
+        <view class="param-row input-row">
+          <picker
+            class="crc-algorithm-picker"
+            mode="selector"
+            range="{{crcPresetOptions}}"
+            range-key="label"
+            value="{{crcPresetIndex}}"
+            bindchange="onCrcPresetChange"
+          >
+            <view class="crc-algorithm-picker-content">
+              <view class="param-main">
+                <view class="param-name">算法</view>
+              </view>
+              <view class="generic-picker-value crc-picker-value">{{crcPresetOptions[crcPresetIndex].label}}</view>
+            </view>
+          </picker>
+          <view
+            class="collapse-toggle {{crcAlgorithmCollapsed ? 'is-collapsed' : ''}}"
+            bindtap="toggleCrcAlgorithmPanel"
+          >
+            <view class="collapse-indicator"></view>
+          </view>
+        </view>
+        <view wx:if="{{!crcAlgorithmCollapsed && (crcShowCrcConfig || crcShowHmacKey || crcShowPbkdf2Config)}}" class="crc-algorithm-fields">
+          <block wx:if="{{crcShowCrcConfig}}">
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">位宽</view>
+                <view class="param-meta">1 - 64</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" type="number" data-field="crcWidth" value="{{crcWidth}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">Poly</view>
+                <view class="param-meta">HEX</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" data-field="crcPoly" value="{{crcPoly}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">初始值</view>
+                <view class="param-meta">HEX</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" data-field="crcInitialValue" value="{{crcInitialValue}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">结果异或值</view>
+                <view class="param-meta">HEX</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" data-field="crcXorOut" value="{{crcXorOut}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="crc-switch-row">
+              <view class="crc-switch-field">
+                <view class="param-name">输入反转</view>
+                <switch checked="{{crcReflectIn}}" color="#0f766e" data-field="crcReflectIn" bindchange="onCrcReflectChange" />
+              </view>
+              <view class="crc-switch-field">
+                <view class="param-name">输出反转</view>
+                <switch checked="{{crcReflectOut}}" color="#0f766e" data-field="crcReflectOut" bindchange="onCrcReflectChange" />
+              </view>
+            </view>
+          </block>
+          <block wx:elif="{{crcShowHmacKey}}">
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">密钥</view>
+                <view class="param-meta">UTF-8</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" data-field="crcHmacKey" value="{{crcHmacKey}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+          </block>
+          <block wx:elif="{{crcShowPbkdf2Config}}">
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">盐值</view>
+                <view class="param-meta">UTF-8</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" data-field="crcPbkdf2Salt" value="{{crcPbkdf2Salt}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">迭代次数</view>
+                <view class="param-meta">1 - 100000</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" type="number" data-field="crcPbkdf2Iterations" value="{{crcPbkdf2Iterations}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+            <view class="param-row input-row">
+              <view class="param-main">
+                <view class="param-name">输出长度</view>
+                <view class="param-meta">1 - 4096 Byte</view>
+              </view>
+              <view class="input-wrap">
+                <input class="value-input crc-config-input" type="number" data-field="crcPbkdf2Length" value="{{crcPbkdf2Length}}" bindinput="onCrcConfigInput" />
+              </view>
+            </view>
+          </block>
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title crc-data-card-header">
+          <view class="crc-data-title">数据</view>
+          <view class="crc-data-header-actions">
+            <picker
+              class="crc-data-type-picker"
+              mode="selector"
+              range="{{crcInputTypeOptions}}"
+              range-key="label"
+              value="{{crcInputTypeIndex}}"
+              bindchange="onCrcInputTypeChange"
+            >
+              <view class="crc-data-type-value">{{crcInputTypeOptions[crcInputTypeIndex].label}}</view>
+            </picker>
+            <view class="panel-action-button" bindtap="loadCrcFileFromMessage">加载</view>
+            <view class="panel-action-button" bindtap="clearCrcInput">清空</view>
+            <view class="panel-action-button is-active" bindtap="calculateCrc">计算</view>
+          </view>
+        </view>
+        <view wx:if="{{crcFileName}}" class="param-row">
+          <view class="param-main">
+            <view class="param-name">文件</view>
+            <view class="param-meta">{{crcFileSizeText}}</view>
+          </view>
+          <view class="crc-file-name">{{crcFileName}}</view>
+        </view>
+        <view class="crc-data-wrap">
+          <textarea
+            class="crc-data-input"
+            maxlength="-1"
+            auto-height
+            value="{{crcDataText}}"
+            bindinput="onCrcDataInput"
+          />
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">结果</view>
+        <view class="param-row crc-calc-result-row">
+          <view class="param-main crc-calc-result-main">
+            <view class="param-name">HEX</view>
+            <view class="crc-calc-result-value" data-value="{{crcResultHex}}" bindtap="copyToolResult">{{crcResultHex}}</view>
+          </view>
+        </view>
+        <view wx:if="{{crcShowBinResult}}" class="param-row crc-calc-result-row crc-calc-result-row--bin">
+          <view class="param-main crc-calc-result-main">
+            <view class="param-name">BIN</view>
+            <view class="crc-calc-result-value crc-calc-result-value--bin" data-value="{{crcResultBin}}" bindtap="copyToolResult">
+              <text wx:for="{{crcResultBinLines}}" wx:key="id" class="crc-result-bin-line">{{item.text}}</text>
+            </view>
+          </view>
+        </view>
+        <view class="param-row crc-calc-result-row">
+          <view class="param-main crc-calc-result-main">
+            <view class="param-name">Base64</view>
+            <view class="crc-calc-result-value" data-value="{{crcResultBase64}}" bindtap="copyToolResult">{{crcResultBase64}}</view>
+          </view>
+        </view>
+        <view wx:if="{{crcErrorText}}" class="param-row">
+          <view class="param-main">
+            <view class="param-name">错误</view>
+          </view>
+          <view class="crc-error-value">{{crcErrorText}}</view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeSettingsView == 'filter'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title filter-section-title">
+          <view class="filter-section-title-text">参数</view>
+          <view class="filter-mode-actions">
+            <view class="filter-mode-button" bindtap="toggleFilterNetwork">{{filterNetworkText}}</view>
+            <view class="filter-mode-button" bindtap="toggleFilterResponse">{{filterResponseText}}</view>
+            <view class="filter-mode-button" bindtap="clearFilterInputs">清除</view>
+          </view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">电阻 R</view>
+            <view wx:if="{{filterComputedKey == 'resistance'}}" class="param-meta">自动计算</view>
+          </view>
+          <view class="filter-input-wrap">
+            <input
+              class="value-input filter-value-input {{filterComputedKey == 'resistance' ? 'filter-value-input--computed' : ''}}"
+              type="digit"
+              placeholder="--"
+              data-field="resistance"
+              value="{{filterResistanceDisplayValue}}"
+              bindinput="onFilterResistanceInput"
+              bindblur="onFilterValueBlur"
+            />
+            <picker
+              class="filter-unit-picker"
+              mode="selector"
+              range="{{filterResistanceUnitOptions}}"
+              range-key="label"
+              value="{{filterResistanceUnitIndex}}"
+              bindchange="onFilterResistanceUnitChange"
+            >
+              <view class="filter-unit-value">{{filterResistanceUnitText}}</view>
+            </picker>
+          </view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">{{filterReactiveName}} {{filterReactiveSymbol}}</view>
+            <view wx:if="{{filterComputedKey == 'reactive'}}" class="param-meta">自动计算</view>
+          </view>
+          <view class="filter-input-wrap">
+            <input
+              class="value-input filter-value-input {{filterComputedKey == 'reactive' ? 'filter-value-input--computed' : ''}}"
+              type="digit"
+              placeholder="--"
+              data-field="reactive"
+              value="{{filterReactiveDisplayValue}}"
+              bindinput="onFilterReactiveInput"
+              bindblur="onFilterValueBlur"
+            />
+            <picker
+              class="filter-unit-picker"
+              mode="selector"
+              range="{{filterReactiveUnitOptions}}"
+              range-key="label"
+              value="{{filterReactiveUnitIndex}}"
+              bindchange="onFilterReactiveUnitChange"
+            >
+              <view class="filter-unit-value">{{filterReactiveUnitText}}</view>
+            </picker>
+          </view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">-3dB截止频率</view>
+            <view wx:if="{{filterComputedKey == 'frequency'}}" class="param-meta">自动计算</view>
+          </view>
+          <view class="filter-input-wrap">
+            <input
+              class="value-input filter-value-input {{filterComputedKey == 'frequency' ? 'filter-value-input--computed' : ''}}"
+              type="digit"
+              placeholder="--"
+              data-field="frequency"
+              value="{{filterFrequencyDisplayValue}}"
+              bindinput="onFilterFrequencyInput"
+              bindblur="onFilterValueBlur"
+            />
+            <picker
+              class="filter-unit-picker"
+              mode="selector"
+              range="{{filterFrequencyUnitOptions}}"
+              range-key="label"
+              value="{{filterFrequencyUnitIndex}}"
+              bindchange="onFilterFrequencyUnitChange"
+            >
+              <view class="filter-unit-value">{{filterFrequencyUnitText}}</view>
+            </picker>
+          </view>
+        </view>
+        <view wx:if="{{filterErrorText}}" class="filter-error-inline">{{filterErrorText}}</view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title filter-diagram-title">
+          <view>电路图</view>
+          <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>
+            <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>
+            <view class="filter-diagram-wire"></view>
+            <view class="filter-diagram-port">Vout</view>
+          </view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeSettingsView == 'reactance'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title filter-section-title">
+          <view class="filter-section-title-text">参数</view>
+          <view class="filter-mode-actions">
+            <view class="filter-mode-button" bindtap="toggleReactanceMode">{{reactanceModeText}}</view>
+            <view class="filter-mode-button" bindtap="clearReactanceInputs">清除</view>
+          </view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">频率 f</view>
+          </view>
+          <view class="filter-input-wrap">
+            <input
+              class="value-input filter-value-input"
+              type="digit"
+              placeholder="--"
+              data-field="frequency"
+              value="{{reactanceFrequencyDisplayValue}}"
+              bindinput="onReactanceFrequencyInput"
+              bindblur="onReactanceValueBlur"
+            />
+            <picker
+              class="filter-unit-picker"
+              mode="selector"
+              range="{{reactanceFrequencyUnitOptions}}"
+              range-key="label"
+              value="{{reactanceFrequencyUnitIndex}}"
+              bindchange="onReactanceFrequencyUnitChange"
+            >
+              <view class="filter-unit-value">{{reactanceFrequencyUnitText}}</view>
+            </picker>
+          </view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">{{reactanceReactiveName}} {{reactanceReactiveSymbol}}</view>
+          </view>
+          <view class="filter-input-wrap">
+            <input
+              class="value-input filter-value-input"
+              type="digit"
+              placeholder="--"
+              data-field="reactive"
+              value="{{reactanceReactiveDisplayValue}}"
+              bindinput="onReactanceReactiveInput"
+              bindblur="onReactanceValueBlur"
+            />
+            <picker
+              class="filter-unit-picker"
+              mode="selector"
+              range="{{reactanceReactiveUnitOptions}}"
+              range-key="label"
+              value="{{reactanceReactiveUnitIndex}}"
+              bindchange="onReactanceReactiveUnitChange"
+            >
+              <view class="filter-unit-value">{{reactanceReactiveUnitText}}</view>
+            </picker>
+          </view>
+        </view>
+        <view wx:if="{{reactanceErrorText}}" class="filter-error-inline">{{reactanceErrorText}}</view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title filter-diagram-title">
+          <view>结果</view>
+          <view class="filter-diagram-mode">{{reactanceModeText}}</view>
+        </view>
+        <view
+          wx:for="{{reactanceResultRows}}"
+          wx:for-item="row"
+          wx:key="label"
+          class="param-row smd-result-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{row.label}}</view>
+            <view class="param-meta">{{row.meta}}</view>
+          </view>
+          <view class="smd-result-value">{{row.value}}</view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeSettingsView == 'smdCode'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title">
+          <view class="smd-section-title-text">类型</view>
+          <view class="smd-mode-actions">
+            <view
+              wx:for="{{smdKindOptions}}"
+              wx:key="key"
+              class="smd-mode-button {{smdKindKey == item.key ? 'is-active' : ''}}"
+              data-kind="{{item.key}}"
+              bindtap="onSmdKindTap"
+            >
+              {{item.label}}
+            </view>
+          </view>
+        </view>
+        <view class="smd-format-row">
+          <view
+            wx:for="{{smdFormatOptions}}"
+            wx:key="key"
+            class="smd-format-button {{smdFormatKey == item.key ? 'is-active' : ''}}"
+            data-format="{{item.key}}"
+            bindtap="onSmdFormatTap"
+          >
+            {{item.label}}
+          </view>
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title">
+          <view class="smd-section-title-text">编码</view>
+          <view wx:if="{{smdCodeText}}" class="panel-action-button" bindtap="clearSmdCodeInput">清空</view>
+        </view>
+        <view class="param-row input-row">
+          <view class="param-main">
+            <view class="param-name">EIA</view>
+            <view class="param-meta">{{smdKindText}} · {{smdFormatText}}</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input smd-code-input"
+              placeholder="{{smdFormatKey == 'eia96' ? '01A' : smdFormatKey == 'eia198' ? 'A4' : '103'}}"
+              value="{{smdCodeText}}"
+              bindinput="onSmdCodeInput"
+            />
+          </view>
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">结果</view>
+        <view class="param-row smd-result-row">
+          <view class="param-main">
+            <view class="param-name">显示值</view>
+            <view class="param-meta {{smdErrorText ? 'param-meta--dirty' : ''}}">{{smdErrorText || smdFormulaText || '输入编码后自动计算'}}</view>
+          </view>
+          <view class="smd-result-value {{smdErrorText ? 'smd-result-value--error' : ''}}">{{smdResultText}}</view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeSettingsView == 'refrigeration'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title refrigeration-title-row">
+          <view class="smd-section-title-text">公式</view>
+          <picker
+            class="refrigeration-mode-picker"
+            mode="selector"
+            range="{{coolingModeOptions}}"
+            range-key="label"
+            value="{{coolingModeIndex}}"
+            bindchange="onCoolingModeChange"
+          >
+            <view class="generic-picker-value refrigeration-picker-value">
+              {{coolingModeText}}
+            </view>
+          </picker>
+        </view>
+        <view class="smd-format-row refrigeration-formula-row">
+          <view class="refrigeration-formula-text">{{coolingFormulaText}}</view>
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title">
+          <view class="smd-section-title-text">输入</view>
+          <view wx:if="{{coolingAnyInput}}" class="panel-action-button" bindtap="clearCoolingInputs">清空</view>
+        </view>
+        <view
+          wx:for="{{coolingFieldRows}}"
+          wx:for-item="field"
+          wx:key="key"
+          class="param-row input-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{field.label}}</view>
+            <view class="param-meta">{{field.unit}}</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input smd-code-input refrigeration-input"
+              type="text"
+              placeholder="{{field.placeholder}}"
+              value="{{field.value}}"
+              data-field="{{field.key}}"
+              bindinput="onCoolingInput"
+            />
+          </view>
+        </view>
+      </view>
+
+      <view class="panel params-section-panel">
+        <view class="params-section-title">结果</view>
+        <view wx:if="{{coolingErrorText}}" class="filter-error-inline">{{coolingErrorText}}</view>
+        <view wx:if="{{!coolingResultRows.length && !coolingErrorText}}" class="param-row smd-result-row">
+          <view class="param-main">
+            <view class="param-name">显示值</view>
+            <view class="param-meta">输入参数后自动计算</view>
+          </view>
+          <view class="smd-result-value">--</view>
+        </view>
+        <view
+          wx:for="{{coolingResultRows}}"
+          wx:for-item="row"
+          wx:key="label"
+          class="param-row smd-result-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{row.label}}</view>
+          </view>
+          <view class="smd-result-value">{{row.value}}</view>
+        </view>
+      </view>
+    </block>
+
+    <block wx:elif="{{activeSettingsView == 'threePhasePower'}}">
+      <view class="panel params-section-panel">
+        <view class="params-section-title smd-section-title">
+          <view class="smd-section-title-text">参数</view>
+          <view class="smd-mode-actions">
+            <view
+              wx:for="{{threePhaseConnectionOptions}}"
+              wx:key="key"
+              class="smd-mode-button {{threePhaseConnectionKey == item.key ? 'is-active' : ''}}"
+              data-connection="{{item.key}}"
+              bindtap="onThreePhaseConnectionTap"
+            >
+              {{item.label}}
+            </view>
+            <view class="panel-action-button" bindtap="clearThreePhaseInputs">清除</view>
+          </view>
+        </view>
+        <view wx:if="{{threePhaseErrorText}}" class="filter-error-inline">{{threePhaseErrorText}}</view>
+        <view
+          wx:for="{{threePhaseRows}}"
+          wx:for-item="row"
+          wx:key="key"
+          class="param-row {{row.editable ? 'input-row' : ''}}"
+        >
+          <view class="param-main">
+            <view class="param-name">{{row.label}}</view>
+            <view wx:if="{{row.unit}}" class="param-meta">{{row.unit}}</view>
+          </view>
+          <view wx:if="{{!row.editable}}" class="smd-result-value">{{row.value}}</view>
+          <view wx:if="{{row.editable}}" class="input-wrap">
+            <input
+              class="value-input smd-code-input three-phase-input"
+              type="text"
+              placeholder="{{row.placeholder}}"
+              value="{{row.value}}"
+              data-field="{{row.field}}"
+              bindinput="onThreePhaseInput"
+            />
+          </view>
+        </view>
+      </view>
+    </block>
+
+
+    </block>
+    <block wx:else>
+    <view class="panel settings-section-panel">
+      <view class="params-section-title">通用设置</view>
+      <view class="settings-row">
+        <view class="settings-row-main">
+          <view class="param-name">夜间模式启用</view>
+          <view class="param-meta">当前 {{themeMode === 'dark' ? '夜间' : '日间'}}</view>
+        </view>
+        <switch
+          checked="{{nightModeEnabledSwitch}}"
+          color="#0f766e"
+          disabled="{{nightModeFollowSystem}}"
+          bindchange="onNightModeEnabledChange"
+        />
+      </view>
+      <view class="settings-row">
+        <view class="settings-row-main">
+          <view class="param-name">夜间模式跟随系统</view>
+          <view class="param-meta">{{nightModeFollowSystem ? '已跟随' : '手动控制'}}</view>
+        </view>
+        <switch
+          checked="{{nightModeFollowSystem}}"
+          color="#0f766e"
+          bindchange="onNightModeFollowSystemChange"
+        />
+      </view>
+    </view>
+
+    <view class="panel settings-section-panel">
+      <view class="params-section-title">Modbus</view>
+      <view class="settings-row">
+        <view class="settings-row-main">
+          <view class="param-name">协议筛选</view>
+          <view class="param-meta">用于选择当前调试协议</view>
+        </view>
+        <picker
+          mode="selector"
+          range="{{modbusProtocolOptions}}"
+          range-key="label"
+          value="{{modbusProtocolIndex}}"
+          bindchange="onModbusProtocolChange"
+        >
+          <view class="settings-picker-value">{{modbusProtocolText}}</view>
+        </picker>
+      </view>
+      <view class="settings-row settings-row--input">
+        <view class="settings-row-main">
+          <view class="param-name">从机地址</view>
+          <view class="param-meta">00 - FF</view>
+        </view>
+        <view class="settings-input-wrap">
+          <input
+            class="value-input settings-value-input settings-value-input--hex"
+            maxlength="2"
+            value="{{modbusSlaveAddress}}"
+            bindblur="onModbusSlaveAddressBlur"
+            bindconfirm="onModbusSlaveAddressBlur"
+          />
+        </view>
+      </view>
+      <view wx:if="{{!isGenericProtocol}}" class="settings-row settings-row--input">
+        <view class="settings-row-main">
+          <view class="param-name">用户状态个数</view>
+          <view class="param-meta">0 - {{maxUserStatusCount}}</view>
+        </view>
+        <view class="settings-input-wrap">
+          <input
+            class="value-input settings-value-input"
+            type="number"
+            value="{{userStatusCount}}"
+            bindblur="onUserStatusCountBlur"
+            bindconfirm="onUserStatusCountBlur"
+          />
+        </view>
+      </view>
+      <view wx:if="{{!isGenericProtocol}}" class="settings-row settings-row--input">
+        <view class="settings-row-main">
+          <view class="param-name">状态轮询间隔</view>
+          <view class="param-meta">{{statusPollMinInterval}} - {{statusPollMaxInterval}} ms</view>
+        </view>
+        <view class="settings-input-wrap settings-input-wrap--unit">
+          <input
+            class="value-input settings-value-input settings-value-input--unit"
+            type="number"
+            value="{{statusPollInterval}}"
+            bindblur="onStatusPollIntervalBlur"
+            bindconfirm="onStatusPollIntervalBlur"
+          />
+          <text class="settings-unit settings-unit--inside">ms</text>
+        </view>
+      </view>
+      <view wx:if="{{isGenericProtocol}}" class="settings-row">
+        <view class="settings-row-main">
+          <view class="param-name">自动轮询</view>
+          <view class="param-meta">{{genericModbusAutoPollEnabled ? '已启用' : '已停止'}}</view>
+        </view>
+        <switch
+          checked="{{genericModbusAutoPollEnabled}}"
+          color="#0f766e"
+          bindchange="onGenericModbusAutoPollChange"
+        />
+      </view>
+      <view wx:if="{{isGenericProtocol}}" class="settings-row settings-row--input">
+        <view class="settings-row-main">
+          <view class="param-name">轮询间隔</view>
+          <view class="param-meta">{{statusPollMinInterval}} - {{statusPollMaxInterval}} ms</view>
+        </view>
+        <view class="settings-input-wrap settings-input-wrap--unit">
+          <input
+            class="value-input settings-value-input settings-value-input--unit"
+            type="number"
+            value="{{genericModbusPollInterval}}"
+            bindblur="onGenericModbusPollIntervalBlur"
+            bindconfirm="onGenericModbusPollIntervalBlur"
+          />
+          <text class="settings-unit settings-unit--inside">ms</text>
+        </view>
+      </view>
+      <view wx:if="{{isGenericProtocol}}" class="settings-row settings-row--input">
+        <view class="settings-row-main">
+          <view class="param-name">最大包长</view>
+          <view class="param-meta">0 为无限制,最小 {{genericModbusMinPacketLength}} 字节</view>
+        </view>
+        <view class="settings-input-wrap settings-input-wrap--unit">
+          <input
+            class="value-input settings-value-input settings-value-input--unit"
+            type="number"
+            value="{{genericModbusMaxPacketLength}}"
+            bindblur="onGenericModbusMaxPacketLengthBlur"
+            bindconfirm="onGenericModbusMaxPacketLengthBlur"
+          />
+          <text class="settings-unit settings-unit--inside">B</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="panel settings-section-panel">
+      <view class="params-section-title">工具</view>
+      <view
+        wx:for="{{toolEntries}}"
+        wx:key="view"
+        class="settings-row settings-tool-row"
+        data-view="{{item.view}}"
+        bindtap="openToolEntry"
+      >
+        <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>
+          </view>
+          <view class="param-name settings-tool-title">{{item.label}}</view>
+        </view>
+        <view class="entry-chevron"></view>
+      </view>
+    </view>
+    </block>
+  </view>
+</scroll-view>

+ 153 - 0
pages/settings/settings.wxss

@@ -0,0 +1,153 @@
+.settings-section-panel {
+  overflow: hidden;
+}
+
+.settings-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18rpx;
+  min-height: 104rpx;
+  padding: 0 24rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.settings-row:first-of-type {
+  border-top: 0;
+}
+
+.settings-row-main {
+  min-width: 0;
+  flex: 1;
+}
+
+.settings-tool-row {
+  min-height: 92rpx;
+}
+
+.settings-tool-row:active {
+  opacity: 0.72;
+}
+
+.settings-tool-main {
+  min-width: 0;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+}
+
+.settings-tool-icon-frame {
+  flex: none;
+  position: relative;
+  width: 34rpx;
+  height: 34rpx;
+  border-radius: 10rpx;
+  background:
+    radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.26) 0%, rgba(255, 255, 255, 0.1) 24%, rgba(255, 255, 255, 0) 54%),
+    linear-gradient(180deg, var(--icon-start, #129a91) 0%, var(--icon-end, #08746e) 100%);
+  border: 1rpx solid rgba(255, 255, 255, 0.12);
+  box-shadow: 0 8rpx 16rpx rgba(15, 143, 135, 0.14);
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.settings-tool-icon-image {
+  position: absolute;
+  left: 6rpx;
+  top: 6rpx;
+  width: 22rpx;
+  height: 22rpx;
+}
+
+.settings-tool-title {
+  flex: 1;
+  white-space: nowrap;
+  word-break: keep-all;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.settings-input-wrap {
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.settings-input-wrap--unit {
+  position: relative;
+}
+
+.settings-picker-value {
+  width: 300rpx;
+  min-width: 300rpx;
+  max-width: 300rpx;
+  height: 70rpx;
+  color: #111827;
+  font-size: 28rpx;
+  line-height: 70rpx;
+  font-weight: 800;
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  box-sizing: border-box;
+}
+
+.settings-value-input {
+  width: 300rpx;
+}
+
+.settings-value-input--hex {
+  width: 300rpx;
+}
+
+.settings-value-input--unit {
+  padding-right: 62rpx;
+}
+
+.settings-unit {
+  flex: none;
+  color: #64748b;
+  font-size: 23rpx;
+  line-height: 1.35;
+  font-weight: 700;
+}
+
+.settings-unit--inside {
+  position: absolute;
+  right: 18rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  pointer-events: none;
+}
+
+.theme-dark .settings-row {
+  border-color: #263241;
+}
+
+.theme-dark .settings-unit {
+  color: #94a3b8;
+}
+
+.theme-dark .settings-picker-value {
+  color: #e5e7eb;
+}
+
+@media (max-width: 360px) {
+  .settings-picker-value {
+    width: 260rpx;
+    min-width: 260rpx;
+    max-width: 260rpx;
+  }
+
+  .settings-value-input {
+    width: 260rpx;
+  }
+
+  .settings-value-input--hex {
+    width: 260rpx;
+  }
+}

+ 14 - 2
project.config.json

@@ -30,11 +30,23 @@
       {
         "type": "file",
         "value": "protrol.txt"
+      },
+      {
+        "type": "file",
+        "value": "Bootloader通讯协议_V1.2.1.pdf"
+      },
+      {
+        "type": "file",
+        "value": "CMFA103F3950.pdf"
+      },
+      {
+        "type": "file",
+        "value": "assets/LUCIDE_LICENSE.txt"
       }
     ],
     "include": []
   },
-  "appid": "wx750afa22d7ff75fc",
+  "appid": "wxf2e66fa80c479ed8",
   "editorSetting": {},
   "libVersion": "3.16.1"
-}
+}

+ 1 - 1
project.private.config.json

@@ -1,6 +1,6 @@
 {
   "libVersion": "3.16.1",
-  "projectname": "%E8%93%9D%E7%89%99%E5%B7%A5%E5%85%B7",
+  "projectname": "个人云服务",
   "setting": {
     "urlCheck": true,
     "coverView": true,

+ 45 - 2
protrol.txt

@@ -22,6 +22,7 @@
   0E uint8_t 缺相保护使能
   0F uint8_t PWM 丢失保护
   10 uint8_t 串口丢失保护
+  11 uint8_t 故障立即恢复
 
 估算器配置参数 27
   30 uint16_t OBS_E1K
@@ -93,8 +94,6 @@
   8D uint16_t 串口丢失检测时间
 
 只读参数寄存器 20
-  A8-AB char[8] 芯片型号
-  AC-B3 char[16] 型号
   A0 uint8_t 载波频率 高 8 位
   A0 uint8_t 基准电压 低 8 位,单位 0.1V
   A1 uint16_t 运放倍数
@@ -102,6 +101,8 @@
   A3 uint16_t 全区 Flash 校验码
   A4-A5 float 母线电压分压比
   A6-A7 float 模拟输入电压分压比
+  A8-AB char[8] 芯片型号
+  AC-B3 char[16] 型号
 
 状态类寄存器 29
   C0 uint8_t 状态机 高 8 位
@@ -135,3 +136,45 @@
   TPWM_VALUE = 1 / SAMP_FREQ
   BASE_FREQ = 速度基准 / 60 * 极对数
   MAX_OMEGA_RAD_SEC = 2 * 3.1415926 * BASE_FREQ
+
+固件日期 2025-05-25
+电机参数
+  电阻
+  电感LQ
+  电感LD
+  极对数
+  转向
+  速度基准
+  电机型号
+硬件参数
+  基准电压
+  母线电压分压比
+  模拟输入电压分压比
+  采样电阻
+  运放倍数
+  载波频率
+  芯片型号
+速度曲线
+  开机电压
+  关机电压
+  速度最小值
+  速度最大值
+  曲线VSP最大值
+  曲线VSP最小值
+  曲线斜率
+  上油转速
+  上油时间
+  外环输出最大值
+保护参数
+  硬件过流值
+  软件过流值
+  功率保护值
+  速度保护最大值
+  速度保护最小值
+  温度保护值
+  温度恢复值
+  过压保护值
+  欠压保护值
+  过压恢复值
+  欠压恢复值
+

+ 45 - 0
utils/base-utils.js

@@ -0,0 +1,45 @@
+let idSeed = 0
+
+function clampInteger(value, minValue, maxValue, fallback) {
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return fallback
+
+  return Math.min(Math.max(Math.round(numberValue), minValue), maxValue)
+}
+
+function createId(prefix = 'id') {
+  idSeed += 1
+
+  return `${prefix}-${Date.now()}-${idSeed}`
+}
+
+function delay(ms) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, ms)
+  })
+}
+
+function parseHexInteger(value, fallback = 0) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return fallback
+
+  const parsed = parseInt(text, 16)
+  return Number.isFinite(parsed) ? parsed : fallback
+}
+
+function normalizeTextValue(value) {
+  return String(value === undefined || value === null ? '' : value)
+}
+
+function padHex(value, length = 4) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
+}
+
+module.exports = {
+  clampInteger,
+  createId,
+  delay,
+  normalizeTextValue,
+  padHex,
+  parseHexInteger
+}

+ 103 - 0
utils/binary-utils.js

@@ -0,0 +1,103 @@
+function toByteArray(bytes) {
+  if (!bytes) return []
+  if (bytes instanceof ArrayBuffer) return Array.prototype.slice.call(new Uint8Array(bytes))
+  if (ArrayBuffer.isView(bytes)) return Array.prototype.slice.call(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength))
+
+  return Array.prototype.slice.call(bytes)
+}
+
+function bytesToBase64(bytes) {
+  const source = toByteArray(bytes)
+  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+  let output = ''
+
+  for (let index = 0; index < source.length; index += 3) {
+    const first = source[index] & 0xFF
+    const second = index + 1 < source.length ? source[index + 1] & 0xFF : 0
+    const third = index + 2 < source.length ? source[index + 2] & 0xFF : 0
+    const triple = (first << 16) | (second << 8) | third
+
+    output += alphabet[(triple >> 18) & 0x3F]
+    output += alphabet[(triple >> 12) & 0x3F]
+    output += index + 1 < source.length ? alphabet[(triple >> 6) & 0x3F] : '='
+    output += index + 2 < source.length ? alphabet[triple & 0x3F] : '='
+  }
+
+  return output
+}
+
+function bytesToBin(bytes) {
+  return toByteArray(bytes).map((byte) => (byte & 0xFF).toString(2).padStart(8, '0')).join('')
+}
+
+function bytesToHex(bytes, separator = '') {
+  return toByteArray(bytes).map((byte) => (byte & 0xFF).toString(16).toUpperCase().padStart(2, '0')).join(separator)
+}
+
+function bytesToWords(bytes = []) {
+  const words = []
+
+  for (let index = 0; index + 1 < bytes.length; index += 2) {
+    const highByte = bytes[index] || 0
+    const lowByte = bytes[index + 1] || 0
+    words.push(((highByte << 8) | lowByte) & 0xFFFF)
+  }
+
+  return words
+}
+
+function getByteFromWord(word, byteOffset = 0) {
+  const value = Number(word) & 0xFFFF
+
+  return byteOffset === 0 ? ((value >> 8) & 0xFF) : (value & 0xFF)
+}
+
+function stringToUtf8Bytes(text) {
+  const bytes = []
+  const encoded = encodeURIComponent(String(text || ''))
+
+  for (let index = 0; index < encoded.length; index += 1) {
+    const char = encoded[index]
+    if (char === '%') {
+      bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16) & 0xFF)
+      index += 2
+    } else {
+      bytes.push(char.charCodeAt(0) & 0xFF)
+    }
+  }
+
+  return bytes
+}
+
+function trimTrailingNullBytes(bytes = []) {
+  let end = bytes.length
+
+  while (end > 0 && bytes[end - 1] === 0x00) {
+    end -= 1
+  }
+
+  return bytes.slice(0, end)
+}
+
+function wordsToBytes(words = [], byteLength = words.length * 2) {
+  const bytes = []
+
+  for (let index = 0; index < words.length; index += 1) {
+    const word = Number(words[index]) & 0xFFFF
+    bytes.push((word >> 8) & 0xFF, word & 0xFF)
+  }
+
+  return bytes.slice(0, Math.max(0, byteLength))
+}
+
+module.exports = {
+  bytesToBase64,
+  bytesToBin,
+  bytesToHex,
+  bytesToWords,
+  getByteFromWord,
+  stringToUtf8Bytes,
+  toByteArray,
+  trimTrailingNullBytes,
+  wordsToBytes
+}

+ 244 - 76
utils/ble-transport.js

@@ -5,16 +5,42 @@ const {
   buildWriteSingleRegisterFrame,
   formatHex,
   getReadResponseByteLength,
+  MODBUS_CRC_OPTIONS,
   MAX_MODBUS_DMA_BYTES,
-  hasValidCrc
+  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')
 
 const SCAN_TIMEOUT = 15000
 const CONNECT_TIMEOUT = 10000
+const RSSI_REFRESH_INTERVAL = 2000
 const DEFAULT_PACKET_SIZE = 20
+const MODULE_PACKET_SIZES = [
+  {
+    packetSize: 0,
+    patterns: [/HC[-_ ]?05/i]
+  },
+  {
+    packetSize: 320,
+    patterns: [/BT[-_ ]?24/i, /\bBT24\b/i]
+  }
+]
 const RESPONSE_TIMEOUT = 1000
 const MAX_RESPONSE_BUFFER_BYTES = 128
 const MAX_LOG_COUNT = 100
@@ -34,6 +60,7 @@ const MODBUS_EXCEPTION_MESSAGES = {
 
 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' },
@@ -71,7 +98,7 @@ const state = {
   isSending: false,
   logScrollTarget: '',
   logs: [],
-  commandIndex: 1,
+  commandIndex: 2,
   commandValue: '0001',
   commandValueLabel: '读取数量',
   coilEnabled: true,
@@ -94,6 +121,8 @@ const state = {
 
 let initialized = false
 let scanTimer = null
+let rssiTimer = null
+let isReadingRssi = false
 let pendingRequest = null
 let sendQueue = []
 let isProcessingSendQueue = false
@@ -103,6 +132,7 @@ let deviceMap = {}
 let deviceSequence = 0
 let logSequence = 0
 const subscribers = []
+const rawResponseSubscribers = []
 
 function setState(changedData) {
   Object.assign(state, changedData)
@@ -119,10 +149,6 @@ function getState() {
   }
 }
 
-function getSlaveAddress() {
-  return parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
-}
-
 function subscribe(subscriber) {
   if (typeof subscriber !== 'function') return () => {}
 
@@ -135,6 +161,17 @@ function subscribe(subscriber) {
   }
 }
 
+function subscribeRawResponse(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  rawResponseSubscribers.push(subscriber)
+
+  return () => {
+    const index = rawResponseSubscribers.indexOf(subscriber)
+    if (index >= 0) rawResponseSubscribers.splice(index, 1)
+  }
+}
+
 function callWx(apiName, params = {}) {
   return new Promise((resolve, reject) => {
     const api = wx[apiName]
@@ -164,6 +201,11 @@ function formatBluetoothError(error) {
 function normalizeDevice(device) {
   const advertisServiceUUIDs = device.advertisServiceUUIDs || []
   const displayName = String(device.name || device.localName || '').trim() || '未命名设备'
+  const packetSize = inferPacketSize({
+    displayName,
+    localName: device.localName,
+    name: device.name
+  })
   const isTargetAdvertised = hasTargetAdvertisedUuid({
     advertisServiceUUIDs
   })
@@ -176,13 +218,41 @@ function normalizeDevice(device) {
     advertisServiceUUIDs,
     displayName,
     isTargetAdvertised,
-    signalText: typeof device.RSSI === 'number' ? `${device.RSSI} dBm` : '--',
+    packetSize,
+    signalText: formatSignalText(device.RSSI),
     serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
     targetText: isTargetAdvertised ? '广播含目标 UUID' : '',
     lastSeenAt: Date.now()
   }
 }
 
+function formatSignalText(RSSI) {
+  return typeof RSSI === 'number' ? `${RSSI} dBm` : '--'
+}
+
+function inferPacketSize(device = {}) {
+  const text = [device.displayName, device.name, device.localName]
+    .map((value) => String(value || ''))
+    .join(' ')
+    .toUpperCase()
+
+  for (const item of MODULE_PACKET_SIZES) {
+    const matchedPattern = (item.patterns || []).some((pattern) => pattern.test(text))
+    if (matchedPattern) {
+      return item.packetSize
+    }
+  }
+
+  return DEFAULT_PACKET_SIZE
+}
+
+function resolvePacketSize(packetSize, frameLength) {
+  if (packetSize === 0) return frameLength || DEFAULT_PACKET_SIZE
+  if (Number.isInteger(packetSize) && packetSize > 0) return packetSize
+
+  return DEFAULT_PACKET_SIZE
+}
+
 function normalizeUuid(value) {
   return String(value || '').replace(/-/g, '').toUpperCase()
 }
@@ -255,6 +325,27 @@ function parseRegisterValues(value) {
     .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
+}
+
+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)
+  }
+
+  return Math.max(MAX_RESPONSE_BUFFER_BYTES, frameLimit + 8, responseLength + 8)
+}
+
 function getCommand(index) {
   return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
 }
@@ -326,18 +417,8 @@ function arrayBufferToHex(buffer) {
   return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
 }
 
-function bytesToWords(bytes) {
-  const words = []
-
-  for (let index = 0; index + 1 < bytes.length; index += 2) {
-    words.push(((bytes[index] << 8) | bytes[index + 1]) & 0xFFFF)
-  }
-
-  return words
-}
-
 function parseModbusResponse(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc(bytes)) return null
+  if (!Array.isArray(bytes) || bytes.length < 5 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
 
   const slaveAddress = bytes[0]
   const functionCode = bytes[1]
@@ -352,7 +433,7 @@ function parseModbusResponse(bytes) {
     }
   }
 
-  if (functionCode === 0x01) {
+  if (functionCode === 0x01 || functionCode === 0x02) {
     const byteCount = bytes[2]
     const dataEnd = 3 + byteCount
     if (bytes.length < dataEnd + 2) return null
@@ -399,7 +480,7 @@ function parseModbusResponse(bytes) {
 }
 
 function parseModbusRequest(bytes) {
-  if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc(bytes)) return null
+  if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc16Modbus(bytes, MODBUS_CRC_OPTIONS)) return null
 
   const slaveAddress = bytes[0]
   const functionCode = bytes[1]
@@ -407,7 +488,7 @@ function parseModbusRequest(bytes) {
   let quantity = 1
   let value
 
-  if (functionCode === 0x01 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
+  if (functionCode === 0x01 || functionCode === 0x02 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
     quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
   }
   if (functionCode === 0x05 || functionCode === 0x06) {
@@ -425,16 +506,19 @@ function parseModbusRequest(bytes) {
 }
 
 function validateDmaFrameLength(bytes, expected) {
-  if (bytes.length > MAX_MODBUS_DMA_BYTES) {
-    return `发送帧长度 ${bytes.length} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
+  const maxFrameBytes = normalizeMaxFrameBytes(expected && expected.maxFrameBytes)
+  if (maxFrameBytes === 0) return ''
+
+  if (bytes.length > maxFrameBytes) {
+    return `发送帧长度 ${bytes.length} 字节,超过最大包长 ${maxFrameBytes} 字节限制`
   }
 
   if (!expected) return ''
 
   const responseLength = getReadResponseByteLength(expected.functionCode, expected.quantity)
 
-  if (responseLength > MAX_MODBUS_DMA_BYTES) {
-    return `预计返回帧长度 ${responseLength} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
+  if (responseLength > maxFrameBytes) {
+    return `预计返回帧长度 ${responseLength} 字节,超过最大包长 ${maxFrameBytes} 字节限制`
   }
 
   return ''
@@ -470,10 +554,6 @@ function hasTargetCharacteristic(discovery) {
   ))
 }
 
-function padHex(value, length = 4) {
-  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
-}
-
 function getExceptionText(code) {
   return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
 }
@@ -495,6 +575,19 @@ 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 '片段'
+
+    return hasValidCrc16Ccitt(rawBytes, { byteOrder: BYTE_ORDER_HIGH }) ? 'CRC OK' : 'CRC ERR'
+  }
+
+  return hasValidCrc16Modbus(rawBytes, MODBUS_CRC_OPTIONS) ? 'CRC OK' : (pendingRequest ? '片段' : 'CRC ERR')
+}
+
 function showCommandAlert(title, content) {
   const message = content || title || '操作失败'
 
@@ -569,6 +662,7 @@ function mergeDevices(devices) {
         : nextDevice.displayName,
       isTargetAdvertised,
       isTargetDevice,
+      packetSize: nextDevice.packetSize || previousDevice.packetSize || DEFAULT_PACKET_SIZE,
       seenIndex,
       serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
       targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '')
@@ -579,7 +673,14 @@ function mergeDevices(devices) {
 }
 
 function refreshDeviceList() {
-  const deviceList = Object.keys(deviceMap)
+  const deviceList = getSortedDeviceList()
+  setState({
+    devices: deviceList.slice(0, 30)
+  })
+}
+
+function getSortedDeviceList() {
+  return Object.keys(deviceMap)
     .map((deviceId) => deviceMap[deviceId])
     .sort((left, right) => {
       const leftIndex = Number(left.seenIndex) || 0
@@ -587,10 +688,6 @@ function refreshDeviceList() {
 
       return leftIndex - rightIndex
     })
-
-  setState({
-    devices: deviceList.slice(0, 30)
-  })
 }
 
 function clearPendingRequest() {
@@ -639,6 +736,7 @@ function resetSendRuntimeState() {
 }
 
 function clearConnectedState(changedData = {}) {
+  stopRssiRefresh()
   resetSendRuntimeState()
   setState({
     characteristicText: '未选择',
@@ -653,6 +751,76 @@ function clearConnectedState(changedData = {}) {
   })
 }
 
+function stopRssiRefresh() {
+  if (rssiTimer) {
+    clearInterval(rssiTimer)
+    rssiTimer = null
+  }
+  isReadingRssi = false
+}
+
+function applyRssiUpdate(deviceId, rssi) {
+  if (!state.connectedDevice || state.connectedDevice.deviceId !== deviceId || typeof rssi !== 'number') {
+    return
+  }
+
+  const signalText = formatSignalText(rssi)
+  const updatedDevice = {
+    ...state.connectedDevice,
+    RSSI: rssi,
+    lastSeenAt: Date.now(),
+    signalText
+  }
+
+  deviceMap[deviceId] = {
+    ...(deviceMap[deviceId] || {}),
+    RSSI: rssi,
+    lastSeenAt: updatedDevice.lastSeenAt,
+    signalText
+  }
+
+  setState({
+    connectedDevice: updatedDevice,
+    devices: getSortedDeviceList().slice(0, 30)
+  })
+}
+
+async function refreshConnectedRssi() {
+  const { connectedDevice } = state
+  if (!connectedDevice || typeof wx === 'undefined' || typeof wx.getBLEDeviceRSSI !== 'function') return
+  if (isReadingRssi) return
+
+  isReadingRssi = true
+  try {
+    const result = await callWx('getBLEDeviceRSSI', {
+      deviceId: connectedDevice.deviceId
+    })
+    if (!state.connectedDevice || state.connectedDevice.deviceId !== connectedDevice.deviceId) return
+    applyRssiUpdate(connectedDevice.deviceId, result && result.RSSI)
+  } catch (error) {
+    if (isConnectionLostError(error)) {
+      clearConnectedState({
+        errorText: formatBluetoothError(error)
+      })
+    }
+  } finally {
+    isReadingRssi = false
+  }
+}
+
+function startRssiRefresh() {
+  stopRssiRefresh()
+
+  if (!state.connectedDevice || typeof wx === 'undefined' || typeof wx.getBLEDeviceRSSI !== 'function') {
+    return
+  }
+
+  refreshConnectedRssi()
+  rssiTimer = setInterval(() => {
+    refreshConnectedRssi()
+  }, RSSI_REFRESH_INTERVAL)
+}
+
 function isConnectionLostError(error) {
   if (!error) return false
   if ([10000, 10001, 10006, 10013].includes(error.errCode)) return true
@@ -662,7 +830,7 @@ function isConnectionLostError(error) {
 }
 
 function isExpectedResponse(response, expected) {
-  if (response.functionCode === 0x01) {
+  if (response.functionCode === 0x01 || response.functionCode === 0x02) {
     return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
   }
 
@@ -691,7 +859,7 @@ function getExpectedResponseLength(expected, responseFunctionCode, responseBytes
     return 5
   }
 
-  if (responseFunctionCode === 0x01) {
+  if (responseFunctionCode === 0x01 || responseFunctionCode === 0x02) {
     if (responseBytes.length < 3) return 0
 
     return 3 + Number(responseBytes[2] || 0) + 2
@@ -753,8 +921,9 @@ function consumePendingResponseBuffer() {
 
   if (!responseLength) return
 
-  if (responseLength > MAX_MODBUS_DMA_BYTES) {
-    const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过 DMA 限制,已丢弃`
+  const frameLimit = normalizeMaxFrameBytes(pending.expected && pending.expected.maxFrameBytes)
+  if (frameLimit > 0 && responseLength > frameLimit) {
+    const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过最大包长 ${frameLimit} 字节限制,已丢弃`
     addLog('SYS', content)
     finishPendingRequest(false)
     if (pending.showModal) {
@@ -818,7 +987,8 @@ function handleModbusResponse(bytes) {
   if (!pendingRequest || !Array.isArray(bytes) || !bytes.length) return
 
   pendingRequest.responseBuffer = pendingRequest.responseBuffer.concat(bytes)
-  if (pendingRequest.responseBuffer.length > MAX_RESPONSE_BUFFER_BYTES) {
+  const bufferLimit = pendingRequest.responseBufferLimit || MAX_RESPONSE_BUFFER_BYTES
+  if (pendingRequest.responseBuffer.length > bufferLimit) {
     const pending = pendingRequest
     const content = `${pending.label} 返回数据超过缓冲区,已丢弃`
 
@@ -851,6 +1021,7 @@ function createPendingRequest(label, expected, options = {}) {
       label,
       resolve,
       timer,
+      responseBufferLimit: getResponseBufferLimit(expected, options.maxFrameBytes),
       showModal: options.showModal !== false,
       responseBuffer: []
     }
@@ -902,14 +1073,15 @@ function init() {
     const hex = arrayBufferToHex(res.value)
     const byteLength = res.value ? res.value.byteLength : 0
     const rawBytes = Array.prototype.slice.call(new Uint8Array(res.value || new ArrayBuffer(0)))
-    const crcState = rawBytes.length >= 4
-      ? (hasValidCrc(rawBytes) ? 'CRC OK' : (pendingRequest ? '片段' : 'CRC ERR'))
-      : ''
+    const crcState = getReceiveCrcState(rawBytes)
 
     setState({
       rxCount: state.rxCount + byteLength
     })
     addLog('RX', hex, crcState)
+    rawResponseSubscribers.slice().forEach((subscriber) => {
+      subscriber(rawBytes, res)
+    })
     handleModbusResponse(rawBytes)
   })
 
@@ -1236,6 +1408,7 @@ async function connectDeviceById(deviceId) {
     const connectedDevice = {
       ...device,
       isTargetDevice,
+      packetSize: device.packetSize || inferPacketSize(device),
       targetText: isTargetDevice ? '已发现目标特征' : device.targetText
     }
     deviceMap[deviceId] = connectedDevice
@@ -1255,6 +1428,7 @@ async function connectDeviceById(deviceId) {
       writeType: discovery.writeType
     })
 
+    startRssiRefresh()
     addLog('SYS', `已连接 ${device.displayName}`)
   } catch (error) {
     resetSendRuntimeState()
@@ -1321,27 +1495,18 @@ async function refreshNativeConnectionState() {
 
 function handleAppHide() {
   clearScanTimer()
+  stopRssiRefresh()
   resetSendRuntimeState()
   if (state.isDiscovering) {
     stopScan()
   }
 }
 
-function handleAppShow() {
+async function handleAppShow() {
   init()
-  refreshNativeConnectionState()
-}
-
-async function openSetting() {
-  try {
-    await callWx('openSetting')
-    setState({
-      errorText: ''
-    })
-  } catch (error) {
-    setState({
-      errorText: formatBluetoothError(error)
-    })
+  const connected = await refreshNativeConnectionState()
+  if (connected && state.connectedDevice) {
+    startRssiRefresh()
   }
 }
 
@@ -1463,7 +1628,7 @@ function enqueueSendFrame(hexFrame, source, options = {}) {
 
   const buffer = hexToArrayBuffer(hexFrame)
   const bytes = new Uint8Array(buffer)
-  const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
+  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options.expected)
 
   if (dmaFrameLengthError) {
     setState({
@@ -1559,7 +1724,7 @@ async function executeSendFrame(hexFrame, source, options = {}) {
 
   const buffer = hexToArrayBuffer(hexFrame)
   const bytes = new Uint8Array(buffer)
-  const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
+  const dmaFrameLengthError = options.skipDmaCheck ? '' : validateDmaFrameLength(bytes, options.expected)
 
   if (dmaFrameLengthError) {
     setState({
@@ -1568,7 +1733,10 @@ async function executeSendFrame(hexFrame, source, options = {}) {
     return false
   }
 
-  const chunkSize = DEFAULT_PACKET_SIZE
+  const chunkSize = resolvePacketSize(
+    options.chunkSize === undefined ? connectedDevice.packetSize : options.chunkSize,
+    bytes.length
+  )
   const waitResponse = !!options.expected
   const responsePromise = waitResponse
     ? createPendingRequest(source, options.expected, options)
@@ -1625,14 +1793,25 @@ async function executeSendFrame(hexFrame, source, options = {}) {
 
 function sendManagedFrame(frameBytes, label, expected, options = {}) {
   return enqueueSendFrame(formatHex(frameBytes), label, {
-    expected,
+    expected: expected ? {
+      ...expected,
+      maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes
+    } : expected,
     showModal: options.showModal !== false,
-    timeout: options.timeout || RESPONSE_TIMEOUT
+    timeout: options.timeout || RESPONSE_TIMEOUT,
+    maxFrameBytes: options.maxFrameBytes === undefined ? MAX_MODBUS_DMA_BYTES : options.maxFrameBytes
   })
 }
 
-function sendFrame(hexFrame, source, options = {}) {
-  return enqueueSendFrame(hexFrame, source, options)
+function sendRawFrameExact(frameBytes, source) {
+  const bytes = frameBytes instanceof Uint8Array
+    ? frameBytes
+    : new Uint8Array(frameBytes || [])
+
+  return enqueueSendFrame(formatHex(Array.prototype.slice.call(bytes)), source, {
+    chunkSize: 0,
+    skipDmaCheck: true
+  })
 }
 
 function sendHexFrame() {
@@ -1663,30 +1842,19 @@ setState(createProtocolState(
 ))
 
 module.exports = {
-  arrayBufferToHex,
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
   clearDevices,
   clearInput,
   clearLogs,
   connectDeviceById,
   disconnectDevice,
-  formatHex,
   getState,
-  getSlaveAddress,
   handleAppHide,
   handleAppShow,
-  hexToArrayBuffer,
   init,
-  normalizeHex,
-  openSetting,
-  parseModbusRequest,
-  sendFrame,
   sendGeneratedFrame,
   sendHexFrame,
   sendManagedFrame,
+  sendRawFrameExact,
   setCommandIndex,
   setProtocolInput,
   setSendHex,
@@ -1694,5 +1862,5 @@ module.exports = {
   startScan,
   stopScan,
   subscribe,
-  validateHex
+  subscribeRawResponse
 }

+ 22 - 0
utils/bootloader-frame.js

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

+ 806 - 0
utils/bootloader-service.js

@@ -0,0 +1,806 @@
+const transport = require('./ble-transport')
+const {
+  BYTE_ORDER_HIGH,
+  crc16Ccitt,
+  appendCrc16Ccitt,
+  hasValidCrc16Ccitt
+} = require('./crc')
+const {
+  BOOTLOADER_HEAD
+} = require('./bootloader-frame')
+const {
+  softReset
+} = require('./motor-control-protocol')
+const {
+  delay
+} = require('./base-utils')
+const {
+  formatBytes,
+  isCancelError,
+  loadSelectedFile
+} = require('./file-service')
+
+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
+}
+const FLASH_LAYOUTS = {
+  16: {
+    capacity: FILE_SIZES[16],
+    startAddress: 0x0400,
+    endAddress: 0x4000
+  },
+  32: {
+    capacity: FILE_SIZES[32],
+    startAddress: 0x0800,
+    endAddress: 0x8000
+  }
+}
+const FLASH_SIZE_TEXT = Object.keys(FILE_SIZES)
+  .map((sizeKb) => formatBytes(FILE_SIZES[sizeKb]))
+  .join(' 或 ')
+const CHIP_FLASH_SIZE_KB = {
+  FU6572L: 32,
+  FU6572N: 32,
+  FU6572T: 32,
+  FU6565N: 32,
+  FU6565T: 32,
+  FU6563N: 32,
+  FU6562L: 32,
+  FU6562LA: 32,
+  FU6562Q: 32,
+  FU6562S: 32,
+  FU6562T: 32,
+  FU6532N: 32,
+  FU6532T: 32,
+  FU6522L: 32,
+  FU6522N: 32,
+  FU6522T: 32,
+  FU6812L2: 16,
+  FU6812N2: 16,
+  FU6812S2: 16,
+  FU6812V: 16,
+  FU6861Q2: 16,
+  FU6861N2: 16,
+  FU6861NF2: 16,
+  FU6861L2: 16,
+  FU6862L: 16,
+  FU6862Q: 16,
+  FU6872P: 16
+}
+const CHIP_FAMILY_FLASH_SIZE_KB = {
+  65: 32,
+  68: 16
+}
+
+const state = {
+  bootloaderChipId: '--',
+  bootloaderDetailText: '',
+  bootloaderProgress: 0,
+  bootloaderStatusText: '',
+  bootloaderVersion: '--',
+  chipModel: '--',
+  deviceProgramCrcText: '--',
+  firmwareChecksumText: '--',
+  firmwareName: '',
+  firmwareSize: 0,
+  firmwareSizeText: '--',
+  firmwareValidText: '未选择',
+  isBootloaderBusy: false,
+  isFirmwareReady: false
+}
+
+let firmwareBytes = null
+let initialized = false
+let unsubscribeTransport = null
+let activeResponseWaiter = null
+const subscribers = []
+
+function getState() {
+  return {
+    ...state
+  }
+}
+
+function setState(changedData) {
+  Object.assign(state, changedData)
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(getState())
+  })
+}
+
+function abortActiveResponseWaiter(message) {
+  if (!activeResponseWaiter) return false
+
+  const waiter = activeResponseWaiter
+  activeResponseWaiter = null
+  waiter.abort(new Error(message || '蓝牙已断开'))
+  return true
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+function init() {
+  transport.init()
+  if (initialized) return
+
+  unsubscribeTransport = transport.subscribe((transportState) => {
+    if (!transportState.connectedDevice) {
+      abortActiveResponseWaiter('蓝牙已断开')
+    }
+
+    if (!transportState.connectedDevice && state.isBootloaderBusy) {
+      transport.showCommandAlert('BootLoader', '蓝牙已断开,升级已停止')
+      setState({
+        bootloaderDetailText: '蓝牙已断开,升级已停止',
+        bootloaderStatusText: '升级失败',
+        isBootloaderBusy: false
+      })
+    }
+  })
+
+  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 : ''
+}
+
+function extractChipModels(chipModel) {
+  const model = normalizeModel(chipModel).toUpperCase()
+  return model.match(/FU\d{4}[A-Z0-9]*/g) || []
+}
+
+function inferFlashSizeKb(chipModel) {
+  const models = extractChipModels(chipModel)
+
+  for (const model of models) {
+    if (CHIP_FLASH_SIZE_KB[model]) return CHIP_FLASH_SIZE_KB[model]
+
+    const family = model.slice(2, 4)
+    if (CHIP_FAMILY_FLASH_SIZE_KB[family]) return CHIP_FAMILY_FLASH_SIZE_KB[family]
+  }
+
+  const text = normalizeModel(chipModel).toUpperCase()
+  if (/(^|[^0-9])32\s*K(B)?([^0-9]|$)/.test(text)) return 32
+  if (/(^|[^0-9])16\s*K(B)?([^0-9]|$)/.test(text)) return 16
+
+  return null
+}
+
+function inferFlashSizeKbFromBytes(byteLength) {
+  return Number(Object.keys(FILE_SIZES).find((sizeKb) => FILE_SIZES[sizeKb] === byteLength)) || null
+}
+
+function inferUpgradeFlashSizeKb(chipModel, byteLength) {
+  return inferFlashSizeKb(chipModel) || inferFlashSizeKbFromBytes(byteLength)
+}
+
+function getFlashLayout() {
+  const sizeKb = inferUpgradeFlashSizeKb(state.chipModel, state.firmwareSize)
+  return sizeKb ? FLASH_LAYOUTS[sizeKb] : null
+}
+
+function getFirmwareValidation(byteLength = state.firmwareSize, chipModel = state.chipModel) {
+  const chipSizeKb = inferFlashSizeKb(chipModel)
+  const firmwareSizeKb = inferFlashSizeKbFromBytes(byteLength)
+  const sizeKb = chipSizeKb || firmwareSizeKb
+  const layout = sizeKb ? FLASH_LAYOUTS[sizeKb] : null
+  const normalizedChipModel = normalizeModel(chipModel)
+
+  if (!byteLength) {
+    return {
+      isReady: false,
+      text: chipSizeKb
+        ? `需要 ${formatBytes(FLASH_LAYOUTS[chipSizeKb].capacity)} .bin`
+        : `请选择 ${FLASH_SIZE_TEXT} .bin`
+    }
+  }
+
+  if (!layout) {
+    return {
+      isReady: false,
+      text: `文件 ${formatBytes(byteLength)},应为 ${FLASH_SIZE_TEXT}`
+    }
+  }
+
+  if (byteLength !== layout.capacity) {
+    return {
+      isReady: false,
+      text: `文件 ${formatBytes(byteLength)},应为 ${formatBytes(layout.capacity)}`
+    }
+  }
+
+  return {
+    isReady: true,
+    text: chipSizeKb
+      ? `匹配 ${formatBytes(layout.capacity)}`
+      : `${normalizedChipModel ? `${normalizedChipModel} 未识别,` : '未读到芯片型号,'}按 ${formatBytes(layout.capacity)} 尝试`
+  }
+}
+
+function setChipModel(chipModel) {
+  const nextChipModel = normalizeModel(chipModel) || '--'
+  const validation = getFirmwareValidation(state.firmwareSize, nextChipModel)
+
+  setState({
+    chipModel: nextChipModel,
+    bootloaderDetailText: validation.text,
+    firmwareValidText: validation.text,
+    isFirmwareReady: validation.isReady
+  })
+}
+
+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 buffer = []
+
+  return new Promise((resolve, reject) => {
+    let settled = false
+    let timer = null
+    let unsubscribe = () => {}
+    const waiter = {
+      abort: (error) => {
+        cleanup()
+        reject(error)
+      }
+    }
+
+    abortActiveResponseWaiter('新的 BootLoader 响应等待已开始')
+    activeResponseWaiter = waiter
+
+    unsubscribe = transport.subscribeRawResponse((bytes) => {
+      buffer.push.apply(buffer, bytes)
+      alignBootloaderBuffer(buffer)
+      if (buffer.length < expectedLength) return
+
+      const frame = buffer.slice(0, expectedLength)
+
+      try {
+        const response = parseResponse(frame, kind)
+        cleanup()
+        resolve(response)
+      } catch (error) {
+        if (options.ignoreInvalid) {
+          buffer.shift()
+          return
+        }
+
+        cleanup()
+        reject(error)
+      }
+    })
+    timer = setTimeout(() => {
+      cleanup()
+      reject(new Error(`${kind} 响应超时`))
+    }, timeout || RESPONSE_TIMEOUT_MS)
+
+    function cleanup() {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      if (activeResponseWaiter === waiter) {
+        activeResponseWaiter = null
+      }
+      unsubscribe()
+    }
+  })
+}
+
+async function sendBootloaderFrame(frame, label, kind, timeout) {
+  const responsePromise = kind ? waitForResponse(kind, timeout) : null
+  const sent = await transport.sendRawFrameExact(frame, label)
+
+  if (!sent) {
+    if (responsePromise) {
+      responsePromise.catch(() => {})
+      abortActiveResponseWaiter(`${label}发送失败`)
+    }
+    throw new Error(`${label}发送失败`)
+  }
+
+  return responsePromise ? responsePromise : true
+}
+
+async function sendSoftReset() {
+  return softReset({
+    kind: 'bootloader-soft-reset'
+  })
+}
+
+async function sendHandshakeKeepAlive() {
+  if (state.isBootloaderBusy) return false
+
+  const frame = buildHandshakeFrame()
+  let finished = false
+
+  setState({
+    bootloaderDetailText: `0/${HANDSHAKE_ATTEMPTS}`,
+    bootloaderProgress: 0,
+    bootloaderStatusText: '握手中',
+    isBootloaderBusy: true
+  })
+
+  const responsePromise = waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
+    ignoreInvalid: true
+  }).then((response) => {
+    finished = true
+    return response
+  }).catch((error) => {
+    finished = true
+    throw error
+  })
+  responsePromise.catch(() => {})
+
+  try {
+    for (let attempt = 0; attempt < HANDSHAKE_ATTEMPTS && !finished; attempt += 1) {
+      const sent = await transport.sendRawFrameExact(frame, 'Bootloader握手')
+      if (!sent) throw new Error('握手帧发送失败')
+
+      setState({
+        bootloaderDetailText: `${attempt + 1}/${HANDSHAKE_ATTEMPTS}`,
+        bootloaderProgress: Math.round((attempt + 1) / HANDSHAKE_ATTEMPTS * 100)
+      })
+
+      if (attempt < HANDSHAKE_ATTEMPTS - 1) {
+        await delay(HANDSHAKE_INTERVAL_MS)
+      }
+    }
+
+    const response = await responsePromise
+
+    setState({
+      bootloaderChipId: response.chipIdText,
+      bootloaderDetailText: getHandshakeDetail(response),
+      bootloaderProgress: 100,
+      bootloaderStatusText: '握手成功',
+      bootloaderVersion: response.versionText,
+      isBootloaderBusy: false
+    })
+    return true
+  } catch (error) {
+    abortActiveResponseWaiter('握手已停止')
+    const message = error && error.message ? error.message : '握手失败'
+    transport.showCommandAlert('BootLoader握手', message)
+    setState({
+      bootloaderDetailText: message,
+      bootloaderStatusText: '握手失败',
+      isBootloaderBusy: false
+    })
+    return false
+  }
+}
+
+async function handshakeUntilReady() {
+  const frame = buildHandshakeFrame()
+
+  setState({
+    bootloaderDetailText: '软复位',
+    bootloaderProgress: 0,
+    bootloaderStatusText: '握手中'
+  })
+
+  const resetResponse = await sendSoftReset()
+  setState({
+    bootloaderDetailText: resetResponse ? '等待握手' : '软复位未响应,继续握手'
+  })
+
+  let lastError = null
+  let finished = false
+  const responsePromise = waitForResponse('handshake', HANDSHAKE_TIMEOUT_MS, {
+    ignoreInvalid: true
+  }).then((response) => {
+    finished = true
+    return response
+  }).catch((error) => {
+    finished = true
+    throw error
+  })
+
+  for (let attempt = 0; attempt < HANDSHAKE_ATTEMPTS && !finished; attempt += 1) {
+    try {
+      await transport.sendRawFrameExact(frame, 'Bootloader握手')
+    } catch (error) {
+      lastError = error
+    }
+
+    if (!finished && attempt < HANDSHAKE_ATTEMPTS - 1) {
+      await delay(HANDSHAKE_INTERVAL_MS)
+    }
+  }
+
+  try {
+    const response = await responsePromise
+
+    setState({
+      bootloaderChipId: response.chipIdText,
+      bootloaderDetailText: getHandshakeDetail(response),
+      bootloaderVersion: response.versionText,
+      bootloaderStatusText: '握手成功'
+    })
+
+    return response
+  } catch (error) {
+    throw new Error(lastError ? `Bootloader握手失败:${lastError.message}` : `Bootloader握手失败:${error.message}`)
+  }
+}
+
+async function chooseFirmwareFile(source = 'message') {
+  if (state.isBootloaderBusy) return false
+
+  try {
+    const file = await loadSelectedFile(source, {
+      extensionMessage: '请选择 .bin 固件文件',
+      extensions: ['bin'],
+      fallbackName: 'firmware.bin'
+    })
+    firmwareBytes = file.bytes
+    const firmwareCrcText = formatCrc(crc16Ccitt(
+      Array.prototype.slice.call(firmwareBytes),
+      BOOTLOADER_CRC_OPTIONS
+    ))
+    const firmwareSizeText = formatBytes(firmwareBytes.length)
+    const validation = getFirmwareValidation(firmwareBytes.length)
+
+    setState({
+      bootloaderDetailText: validation.text,
+      bootloaderProgress: 0,
+      bootloaderStatusText: validation.isReady ? '固件已加载' : '固件不匹配',
+      deviceProgramCrcText: '--',
+      firmwareChecksumText: firmwareCrcText,
+      firmwareName: file.name || 'firmware.bin',
+      firmwareSize: firmwareBytes.length,
+      firmwareSizeText,
+      firmwareValidText: validation.text,
+      isFirmwareReady: validation.isReady
+    })
+
+    return true
+  } catch (error) {
+    const message = error && (error.errMsg || error.message)
+      ? (error.errMsg || error.message)
+      : '读取固件失败'
+
+    if (!isCancelError(error)) {
+      transport.showCommandAlert('固件文件', message)
+    }
+
+    return false
+  }
+}
+
+async function startUpgrade() {
+  if (state.isBootloaderBusy) return false
+  if (!firmwareBytes || !state.isFirmwareReady) {
+    transport.showCommandAlert('固件不匹配', state.firmwareValidText || `请先选择 ${FLASH_SIZE_TEXT} .bin 文件`)
+    return false
+  }
+
+  const layout = getFlashLayout()
+  if (!layout) {
+    transport.showCommandAlert('固件大小', `请选择 ${FLASH_SIZE_TEXT} .bin 文件`)
+    return false
+  }
+
+  setState({
+    bootloaderDetailText: '',
+    bootloaderProgress: 0,
+    bootloaderStatusText: '握手中',
+    isBootloaderBusy: true
+  })
+
+  try {
+    await handshakeUntilReady()
+
+    setState({
+      bootloaderDetailText: '编程解锁',
+      bootloaderStatusText: '升级中'
+    })
+    assertAck(await sendBootloaderFrame(buildUnlockFrame(), 'Bootloader解锁', 'unlock'), '编程解锁')
+
+    setState({
+      bootloaderDetailText: '开启页擦除',
+      bootloaderStatusText: '升级中'
+    })
+    assertAck(await sendBootloaderFrame(buildPageEraseFrame(true), '页擦除使能', 'pageErase'), '页擦除使能')
+
+    const totalBytes = layout.endAddress - layout.startAddress
+    let programmedBytes = 0
+
+    for (let address = layout.startAddress; address < layout.endAddress; address += PROGRAM_CHUNK_SIZE) {
+      const chunk = firmwareBytes.slice(address, address + PROGRAM_CHUNK_SIZE)
+      const response = await sendBootloaderFrame(
+        buildProgramFrame(address, chunk),
+        `编程 0x${toHex(address, 4)}`,
+        'program',
+        PROGRAM_RESPONSE_TIMEOUT_MS
+      )
+
+      assertAck(response, `编程 0x${toHex(address, 4)}`)
+      if (response.address !== address) {
+        throw new Error(`编程地址反馈不匹配:0x${toHex(response.address, 4)}`)
+      }
+
+      programmedBytes = Math.min(totalBytes, programmedBytes + PROGRAM_CHUNK_SIZE)
+      const progress = Math.min(99, Math.round(programmedBytes / totalBytes * 100))
+      setState({
+        bootloaderDetailText: `0x${toHex(address, 4)}`,
+        bootloaderProgress: progress,
+        bootloaderStatusText: `升级中 ${progress}%`
+      })
+    }
+
+    const checkResponse = await sendBootloaderFrame(buildFlashCheckFrame(), '全Flash校验', 'flashCheck')
+    await sendBootloaderFrame(buildExitFrame(), '退出Bootloader')
+
+    setState({
+      bootloaderDetailText: '校验通过',
+      bootloaderProgress: 100,
+      bootloaderStatusText: '升级完成',
+      deviceProgramCrcText: checkResponse.flashCrcText,
+      isBootloaderBusy: false
+    })
+    return true
+  } catch (error) {
+    const message = error && error.message ? error.message : '升级失败'
+    transport.showCommandAlert('Bootloader升级', message)
+    setState({
+      bootloaderDetailText: message,
+      bootloaderStatusText: '升级失败',
+      isBootloaderBusy: false
+    })
+    return false
+  }
+}
+
+async function readProgramChecksum() {
+  if (state.isBootloaderBusy) return false
+
+  setState({
+    bootloaderDetailText: '',
+    bootloaderStatusText: '读取中'
+  })
+
+  try {
+    const response = await sendBootloaderFrame(buildFlashCheckFrame(), '读取程序校验码', 'flashCheck')
+
+    setState({
+      bootloaderDetailText: '程序校验码已读取',
+      bootloaderStatusText: '读取完成',
+      deviceProgramCrcText: response.flashCrcText
+    })
+    return true
+  } catch (error) {
+    const message = error && error.message ? error.message : '读取程序校验码失败'
+    transport.showCommandAlert('程序校验码', message)
+    setState({
+      bootloaderDetailText: message,
+      bootloaderStatusText: '读取失败'
+    })
+    return false
+  }
+}
+
+async function exitBootloader() {
+  if (state.isBootloaderBusy) return false
+
+  try {
+    const sent = await sendBootloaderFrame(buildExitFrame(), '退出BootLoader')
+    if (!sent) throw new Error('退出命令发送失败')
+
+    setState({
+      bootloaderDetailText: '',
+      bootloaderStatusText: '已退出 BootLoader'
+    })
+    return true
+  } catch (error) {
+    const message = error && error.message ? error.message : '退出 BootLoader 失败'
+    transport.showCommandAlert('退出 BootLoader', message)
+    setState({
+      bootloaderDetailText: message,
+      bootloaderStatusText: '退出失败'
+    })
+    return false
+  }
+}
+
+module.exports = {
+  chooseFirmwareFile,
+  getState,
+  init,
+  readProgramChecksum,
+  sendHandshakeKeepAlive,
+  setChipModel,
+  exitBootloader,
+  startUpgrade,
+  subscribe
+}

+ 103 - 0
utils/calculator-helpers.js

@@ -0,0 +1,103 @@
+const DEFAULT_MAGNITUDE_STEPS = [
+  { min: 1000, decimals: 2 },
+  { min: 1, decimals: 4 },
+  { min: 0.001, decimals: 6 },
+  { min: 0, decimals: 9 }
+]
+
+function getOption(options, index) {
+  return options[Number(index)] || options[0]
+}
+
+function normalizeIndex(index, options, fallback = 0) {
+  const numericIndex = Number(index)
+  if (!Number.isInteger(numericIndex) || numericIndex < 0 || numericIndex >= options.length) return fallback
+
+  return numericIndex
+}
+
+function parseLooseNumber(value, allowNegative = true) {
+  const text = String(value === undefined || value === null ? '' : value).trim().replace(/,/g, '.')
+  if (!text) return null
+
+  const numberValue = Number(text)
+  if (!Number.isFinite(numberValue)) return NaN
+  if (!allowNegative && numberValue < 0) return NaN
+
+  return numberValue
+}
+
+function parsePositiveNumber(value) {
+  const numberValue = parseLooseNumber(value, true)
+  if (numberValue === null) return null
+
+  return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : NaN
+}
+
+function trimFixed(text) {
+  return text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
+}
+
+function formatMagnitudeNumber(value, options = {}) {
+  const {
+    fallbackText = '--',
+    steps = DEFAULT_MAGNITUDE_STEPS,
+    zeroText = '0'
+  } = options
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return fallbackText
+  if (numberValue === 0) return zeroText
+
+  const absValue = Math.abs(numberValue)
+  const selectedStep = (steps || DEFAULT_MAGNITUDE_STEPS).find((step) => absValue >= step.min)
+    || (steps || DEFAULT_MAGNITUDE_STEPS)[(steps || DEFAULT_MAGNITUDE_STEPS).length - 1]
+  let text = trimFixed(numberValue.toFixed(selectedStep.decimals))
+
+  if (text === '0' && absValue > 0) {
+    text = trimFixed(numberValue.toFixed(12))
+  }
+
+  return text
+}
+
+function selectBestUnit(units, baseValue, fallbackIndex = 0) {
+  const absValue = Math.abs(Number(baseValue))
+  if (!Number.isFinite(absValue) || absValue <= 0) {
+    return {
+      index: fallbackIndex,
+      unit: getOption(units, fallbackIndex)
+    }
+  }
+
+  let selectedIndex = 0
+  for (let index = 0; index < units.length; index += 1) {
+    if (absValue / units[index].factor >= 1) selectedIndex = index
+  }
+
+  return {
+    index: selectedIndex,
+    unit: getOption(units, selectedIndex)
+  }
+}
+
+function formatScaledValue(baseValue, unit, options = {}) {
+  const {
+    includeUnit = false
+  } = options
+  const selectedUnit = unit || { factor: 1, label: '' }
+  const valueText = formatMagnitudeNumber(baseValue / selectedUnit.factor, options)
+
+  return includeUnit && selectedUnit.label ? `${valueText} ${selectedUnit.label}` : valueText
+}
+
+module.exports = {
+  DEFAULT_MAGNITUDE_STEPS,
+  formatMagnitudeNumber,
+  formatScaledValue,
+  getOption,
+  normalizeIndex,
+  parseLooseNumber,
+  parsePositiveNumber,
+  selectBestUnit,
+  trimFixed
+}

+ 44 - 16
utils/control-page-state.js

@@ -5,6 +5,9 @@ const {
   speedCommandRegister,
   statusRegisters
 } = require('./registers')
+const {
+  parseHexInteger
+} = require('./base-utils')
 const {
   getSharedInputDefault,
   mergeInputValues,
@@ -24,6 +27,7 @@ const {
 const {
   floatToWords,
   getRegisterWordCache,
+  toRegisterWord,
   toAddressKey,
   wordsToFloat
 } = require('./register-value-utils')
@@ -33,15 +37,18 @@ const {
 
 const AUTO_READ_MIN_INTERVAL = 100
 const AUTO_READ_MAX_INTERVAL = 3000
-const DEFAULT_AUTO_READ_INTERVAL = 1000
+const DEFAULT_AUTO_READ_INTERVAL = 100
 const MOTOR_PARAM_START_ADDRESS = 0x60
 const MOTOR_PARAM_WORD_COUNT = 8
 const DRIVER_PARAM_START_ADDRESS = 0xA0
 const STATUS_START_ADDRESS = 0xC0
+const USER_STATUS_START_ADDRESS = 0xD3
+const MAX_USER_STATUS_COUNT = statusRegisters.filter((item) => item.name.indexOf('用户状态字') === 0).length
+const DEFAULT_USER_STATUS_COUNT = 0
 
 function getRegisterSpanWordCount(registers, startAddress) {
   const endAddress = registers.reduce((maxAddress, item) => {
-    const address = parseInt(item.address, 16)
+    const address = parseHexInteger(item.address)
     if (!Number.isFinite(address)) return maxAddress
 
     return Math.max(maxAddress, address + (item.registerCount || 1))
@@ -51,7 +58,20 @@ function getRegisterSpanWordCount(registers, startAddress) {
 }
 
 const DRIVER_PARAM_WORD_COUNT = getRegisterSpanWordCount(readonlyParamRegisters, DRIVER_PARAM_START_ADDRESS)
-const STATUS_WORD_COUNT = getRegisterSpanWordCount(statusRegisters, STATUS_START_ADDRESS)
+const BASE_STATUS_WORD_COUNT = USER_STATUS_START_ADDRESS - STATUS_START_ADDRESS
+const DRIVER_SUMMARY_REGISTER_NAMES = [
+  '芯片型号',
+  '全区 Flash 校验码',
+  '型号'
+]
+
+function isDriverSummaryRegister(item) {
+  return DRIVER_SUMMARY_REGISTER_NAMES.includes(item.name)
+}
+
+function getDriverReadonlyParamRegisters(registers = readonlyParamRegisters) {
+  return registers.filter((item) => !isDriverSummaryRegister(item))
+}
 
 function getInputValues(registers) {
   return registers.reduce((result, item) => {
@@ -69,15 +89,6 @@ function updateMotorWriteValues(registers) {
   }))
 }
 
-function toRegisterWord(value) {
-  const numberValue = toFiniteNumber(value, NaN)
-  if (!Number.isFinite(numberValue)) return null
-
-  const wordValue = Math.round(numberValue)
-
-  return wordValue >= 0 && wordValue <= 0xFFFF ? wordValue : null
-}
-
 function formatReadInputValue(item, value) {
   if (!Number.isFinite(value)) return ''
   if (item.name === 'LD' || item.name === 'LQ') return formatFixedValue(value, 6)
@@ -122,6 +133,14 @@ function clampNumber(value, minValue, maxValue, fallback) {
   return Math.min(Math.max(Math.round(numberValue), minValue), maxValue)
 }
 
+function getUserStatusCount(value) {
+  return clampNumber(value, 0, MAX_USER_STATUS_COUNT, DEFAULT_USER_STATUS_COUNT)
+}
+
+function getStatusWordCount(userStatusCount = DEFAULT_USER_STATUS_COUNT) {
+  return BASE_STATUS_WORD_COUNT + getUserStatusCount(userStatusCount)
+}
+
 function cloneRegister(item) {
   return {
     ...item
@@ -141,10 +160,14 @@ function createInitialState() {
     isReadingMotor: false,
     isSending: false,
     isWritingMotor: false,
+    chipModel: '--',
+    flashChecksum: '--',
+    motorModel: '--',
     motorParameterInputRegisters,
-    readonlyParamRegisters,
+    readonlyParamRegisters: getDriverReadonlyParamRegisters(),
     speedCommand: speedCommandRegister,
-    systemTip: ''
+    systemTip: '',
+    userStatusCount: DEFAULT_USER_STATUS_COUNT
   }
 }
 
@@ -409,7 +432,10 @@ function applyDriverParameterReadValues(data, words) {
   })
 
   return {
-    readonlyParamRegisters: data.readonlyParamRegisters.map((item) => ({
+    chipModel: displayValues['芯片型号'],
+    flashChecksum: displayValues['全区 Flash 校验码'],
+    motorModel: displayValues['型号'],
+    readonlyParamRegisters: getDriverReadonlyParamRegisters(data.readonlyParamRegisters).map((item) => ({
       ...item,
       displayValue: displayValues[item.name] || item.displayValue || '--'
     }))
@@ -429,10 +455,10 @@ module.exports = {
   AUTO_READ_MIN_INTERVAL,
   DRIVER_PARAM_START_ADDRESS,
   DRIVER_PARAM_WORD_COUNT,
+  MAX_USER_STATUS_COUNT,
   MOTOR_PARAM_START_ADDRESS,
   MOTOR_PARAM_WORD_COUNT,
   STATUS_START_ADDRESS,
-  STATUS_WORD_COUNT,
   applyControlReadValues,
   applyControlSuccess,
   applyDriverParameterReadValues,
@@ -449,7 +475,9 @@ module.exports = {
   buildMotorMainWriteValues,
   clampNumber,
   createInitialState,
+  getStatusWordCount,
   getControlButtonWriteValue,
   getRegisterWordCache,
+  getUserStatusCount,
   setSharedInputValues
 }

+ 113 - 156
utils/control-service.js

@@ -1,18 +1,21 @@
 const {
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  buildWriteSingleRegisterFrame,
-  MAX_READ_REGISTER_QUANTITY
-} = require('./modbus-rtu')
-const controlState = require('./control-page-state')
-const transport = require('./ble-transport')
+  controlState
+} = require('./motor-control-data')
 const {
-  addCoilReadValues
-} = require('./register-value-utils')
-
-let state = controlState.createInitialState()
+  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')
+
+let state = {
+  ...controlState.createInitialState(),
+  ...bootloaderService.getState()
+}
 let autoReadTimer = null
+let unsubscribeSettings = null
 let unsubscribeTransport = null
 const subscribers = []
 
@@ -38,55 +41,23 @@ function setState(changedData) {
   notify()
 }
 
-function splitReadChunks(startAddress, quantity) {
-  const chunks = []
-  let offset = 0
-
-  while (offset < quantity) {
-    const chunkQuantity = Math.min(quantity - offset, MAX_READ_REGISTER_QUANTITY)
-    chunks.push({
-      address: startAddress + offset,
-      quantity: chunkQuantity
-    })
-    offset += chunkQuantity
-  }
-
-  return chunks
-}
-
-async function readRegisterChunks(slaveAddress, functionCode, startAddress, quantity, label, expectedKind, options = {}) {
-  const chunks = splitReadChunks(startAddress, quantity)
-  const words = []
-
-  for (const chunk of chunks) {
-    const response = await transport.sendManagedFrame(
-      buildReadFrame(slaveAddress, functionCode, chunk.address, chunk.quantity),
-      chunks.length > 1 ? `${label} ${chunk.address.toString(16).toUpperCase()}-${(chunk.address + chunk.quantity - 1).toString(16).toUpperCase()}` : label,
-      {
-        address: chunk.address,
-        functionCode,
-        kind: expectedKind,
-        quantity: chunk.quantity,
-        slaveAddress
-      },
-      {
-        showModal: options.showModal
-      }
-    )
-
-    if (!response) return null
+function applySettingsState(settings) {
+  const autoReadInterval = controlState.clampNumber(
+    settings.statusPollInterval,
+    controlState.AUTO_READ_MIN_INTERVAL,
+    controlState.AUTO_READ_MAX_INTERVAL,
+    state.autoReadInterval
+  )
+  const userStatusCount = controlState.getUserStatusCount(settings.userStatusCount)
 
-    const chunkWords = response.words || []
-    chunkWords.forEach((word, index) => {
-      words[chunk.address - startAddress + index] = Number(word) & 0xFFFF
-    })
+  setState({
+    autoReadInterval,
+    userStatusCount
+  })
 
-    if (typeof options.onChunk === 'function') {
-      options.onChunk(response, chunk)
-    }
+  if (state.autoReadStatus) {
+    scheduleAutoReadStatus(autoReadInterval)
   }
-
-  return words
 }
 
 function subscribe(subscriber) {
@@ -103,6 +74,12 @@ function subscribe(subscriber) {
 
 function init() {
   transport.init()
+  bootloaderService.init()
+  settingsService.init()
+
+  if (!unsubscribeSettings) {
+    unsubscribeSettings = settingsService.subscribe(applySettingsState)
+  }
 
   if (unsubscribeTransport) return
 
@@ -115,6 +92,10 @@ function init() {
 
     setState(nextState)
   })
+
+  bootloaderService.subscribe((bootloaderState) => {
+    setState(bootloaderState)
+  })
 }
 
 function syncSharedInputs() {
@@ -140,22 +121,17 @@ function applyMotorReadWords(words, startAddress = controlState.MOTOR_PARAM_STAR
 }
 
 function applyDriverReadWords(words) {
-  setState(controlState.applyDriverParameterReadValues(state, words))
+  const changedState = controlState.applyDriverParameterReadValues(state, words)
+  if (changedState.chipModel) {
+    bootloaderService.setChipModel(changedState.chipModel)
+  }
+  setState(changedState)
 }
 
 function applyStatusReadWords(words, startAddress = controlState.STATUS_START_ADDRESS) {
   setState(controlState.applyStatusReadValues(words, startAddress))
 }
 
-function getSharedSlaveAddress() {
-  try {
-    return transport.getSlaveAddress()
-  } catch (error) {
-    transport.showCommandAlert('从机地址错误', error.message)
-    return null
-  }
-}
-
 function updateMotorParameterInput(index, value) {
   setState(controlState.applyMotorParameterInput(state, index, value))
 }
@@ -183,7 +159,7 @@ function getSpeedCommandWriteWord() {
 }
 
 async function sendSpeedCommand() {
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const writeWord = getSpeedCommandWriteWord()
@@ -192,18 +168,13 @@ async function sendSpeedCommand() {
     return false
   }
 
-  const address = parseInt(state.speedCommand.address, 16)
-  const response = await transport.sendManagedFrame(
-    buildWriteSingleRegisterFrame(slaveAddress, address, writeWord),
+  const address = parseHexInteger(state.speedCommand.address)
+  const response = await modbusAccess.writeSingleRegister(
+    slaveAddress,
+    address,
+    writeWord,
     '转速命令',
-    {
-      address,
-      functionCode: 0x06,
-      kind: 'speed-command-write',
-      quantity: 1,
-      value: writeWord,
-      slaveAddress
-    }
+    'speed-command-write'
   )
 
   if (response) {
@@ -223,22 +194,10 @@ async function sendControlCommand(key) {
     .find((item) => item.key === key)
   if (!button) return
 
-  const slaveAddress = getSharedSlaveAddress()
-  if (slaveAddress === null) return
-
-  const writeValue = controlState.getControlButtonWriteValue(button)
-  const address = parseInt(button.address, 16)
-  const coilEnabled = Number(writeValue) !== 0
-  const response = await transport.sendManagedFrame(
-    buildWriteSingleCoilFrame(slaveAddress, address, coilEnabled),
-    button.name,
+  const response = await motorControlProtocol.writeControlButton(
+    button,
     {
-      address,
-      functionCode: 0x05,
-      kind: 'control-write',
-      quantity: 1,
-      value: coilEnabled ? 0xFF00 : 0x0000,
-      slaveAddress
+      kind: 'control-write'
     }
   )
 
@@ -248,32 +207,24 @@ async function sendControlCommand(key) {
 }
 
 async function readControlStatus() {
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const startAddress = 0x00
   const quantity = 3
-  const response = await transport.sendManagedFrame(
-    buildReadFrame(slaveAddress, 0x01, startAddress, quantity),
+  const coilValues = await modbusAccess.readBitValues(
+    slaveAddress,
+    0x01,
+    startAddress,
+    quantity,
     '控制状态读取',
-    {
-      address: startAddress,
-      functionCode: 0x01,
-      kind: 'control-status-read',
-      quantity,
-      slaveAddress
-    }
+    'control-status-read'
   )
 
-  if (!response) return false
-
-  const readValues = {
-    coils: {}
-  }
+  if (!coilValues) return false
 
-  addCoilReadValues(readValues, startAddress, quantity, response)
   setState({
-    ...controlState.applyControlReadValues(state, readValues.coils),
+    ...controlState.applyControlReadValues(state, coilValues),
     systemTip: '控制状态读取完成'
   })
 
@@ -283,7 +234,7 @@ async function readControlStatus() {
 async function readMotorParameters() {
   if (state.isReadingMotor) return
 
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   setState({
@@ -293,7 +244,7 @@ async function readMotorParameters() {
   })
 
   try {
-    const words = await readRegisterChunks(
+    const words = await modbusAccess.readRegisterWords(
       slaveAddress,
       0x03,
       controlState.MOTOR_PARAM_START_ADDRESS,
@@ -320,7 +271,7 @@ async function readMotorParameters() {
 async function writeMotorParameters() {
   if (state.isWritingMotor) return
 
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   const mainWrite = controlState.buildMotorMainWriteValues(state)
@@ -336,20 +287,12 @@ async function writeMotorParameters() {
   })
 
   try {
-    const mainResponse = await transport.sendManagedFrame(
-      buildWriteMultipleRegistersFrame(
-        slaveAddress,
-        controlState.MOTOR_PARAM_START_ADDRESS,
-        mainWrite.values
-      ),
+    const mainResponse = await modbusAccess.writeMultipleRegisters(
+      slaveAddress,
+      controlState.MOTOR_PARAM_START_ADDRESS,
+      mainWrite.values,
       '电机参数写入',
-      {
-        address: controlState.MOTOR_PARAM_START_ADDRESS,
-        functionCode: 0x10,
-        kind: 'motor-main-write',
-        quantity: mainWrite.values.length,
-        slaveAddress
-      }
+      'motor-main-write'
     )
     if (!mainResponse) return
 
@@ -367,7 +310,7 @@ async function writeMotorParameters() {
 async function readDriverParameters() {
   if (state.isReadingDriver) return
 
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return
 
   setState({
@@ -377,20 +320,24 @@ async function readDriverParameters() {
   })
 
   try {
-    const words = await readRegisterChunks(
+    const words = await modbusAccess.readRegisterWords(
       slaveAddress,
       0x04,
       controlState.DRIVER_PARAM_START_ADDRESS,
       controlState.DRIVER_PARAM_WORD_COUNT,
-      '驱动器硬件参数读取',
+      '驱动器参数读取',
       'driver-read',
       { showModal: true }
     )
 
     if (words) {
+      const changedState = controlState.applyDriverParameterReadValues(state, words)
+      if (changedState.chipModel) {
+        bootloaderService.setChipModel(changedState.chipModel)
+      }
       setState({
-        ...controlState.applyDriverParameterReadValues(state, words),
-        systemTip: '驱动器硬件参数读取完成'
+        ...changedState,
+        systemTip: '驱动器参数读取完成'
       })
     }
   } finally {
@@ -400,47 +347,55 @@ async function readDriverParameters() {
   }
 }
 
-function setAutoReadStatus(autoReadStatus) {
+function chooseFirmwareFile(source) {
+  return bootloaderService.chooseFirmwareFile(source)
+}
+
+function startFirmwareUpgrade() {
+  stopAutoReadStatus()
   setState({
-    autoReadStatus
+    autoReadStatus: false
   })
 
-  if (autoReadStatus) {
-    scheduleAutoReadStatus(0)
-    return
-  }
+  return bootloaderService.startUpgrade()
+}
 
-  stopAutoReadStatus()
+function readProgramChecksum() {
+  return bootloaderService.readProgramChecksum()
 }
 
-function setAutoReadInterval(value) {
-  const autoReadInterval = controlState.clampNumber(
-    value,
-    controlState.AUTO_READ_MIN_INTERVAL,
-    controlState.AUTO_READ_MAX_INTERVAL,
-    state.autoReadInterval
-  )
+function handshakeBootloader() {
+  return bootloaderService.sendHandshakeKeepAlive()
+}
+
+function exitBootloader() {
+  return bootloaderService.exitBootloader()
+}
 
+function setAutoReadStatus(autoReadStatus) {
   setState({
-    autoReadInterval
+    autoReadStatus
   })
 
-  if (state.autoReadStatus) {
-    scheduleAutoReadStatus(autoReadInterval)
+  if (autoReadStatus) {
+    scheduleAutoReadStatus(0)
+    return
   }
+
+  stopAutoReadStatus()
 }
 
 async function readStatus(options = {}) {
   if (options.auto && !state.connectedDevice) return false
 
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
-  const words = await readRegisterChunks(
+  const words = await modbusAccess.readRegisterWords(
     slaveAddress,
     0x04,
     controlState.STATUS_START_ADDRESS,
-    controlState.STATUS_WORD_COUNT,
+    controlState.getStatusWordCount(state.userStatusCount),
     '状态读取',
     'status-read',
     { showModal: !options.auto }
@@ -483,15 +438,17 @@ module.exports = {
   applyDriverReadWords,
   applyMotorReadWords,
   applyStatusReadWords,
+  exitBootloader,
+  handshakeBootloader,
   readControlStatus,
   readDriverParameters,
   readMotorParameters,
+  readProgramChecksum,
   readStatus,
   sendControlCommand,
-  sendSpeedCommand,
-  setAutoReadInterval,
   setAutoReadStatus,
-  stopAutoReadStatus,
+  chooseFirmwareFile,
+  startFirmwareUpgrade,
   subscribe,
   syncSharedInputs,
   updateMotorParameterBlur,

+ 21 - 0
utils/control-view-model.js

@@ -0,0 +1,21 @@
+const controlService = require('./control-service')
+const themeService = require('./theme-service')
+const {
+  getStatusSummaryState
+} = require('./status-page-state')
+
+function getControlPageState(
+  controlState = controlService.getState(),
+  themeState = themeService.getState()
+) {
+  return {
+    ...controlState,
+    ...themeState,
+    canReadStatus: !!controlState.connectedDevice && !controlState.isBootloaderBusy,
+    statusSummary: getStatusSummaryState()
+  }
+}
+
+module.exports = {
+  getControlPageState
+}

+ 16 - 3
utils/conversions.js

@@ -7,6 +7,10 @@ const {
   getSharedInputValues,
   toFiniteNumber
 } = require('./calculation-context')
+const {
+  rawToTemperature,
+  temperatureToRaw
+} = require('./thermistor')
 
 function getSpeedBase(inputValues = {}, driverParams = DEFAULT_DRIVER_PARAMS) {
   const candidates = [
@@ -190,6 +194,12 @@ function calculateProtectionWriteValue(item, actualValue, driverParams = getDriv
     return formatWriteValue(actualValue / currentSampleMax / voltageSampleMax * SCALE_MAX)
   }
 
+  if (item.name === '温度保护值' || item.name === '温度恢复值') {
+    const rawValue = temperatureToRaw(actualValue, SCALE_MAX)
+
+    return rawValue === null ? '--' : formatWriteValue(rawValue)
+  }
+
   return item.type === 'float' ? formatFixedValue(Number(actualValue), 2) : formatWriteValue(actualValue)
 }
 
@@ -227,6 +237,10 @@ function calculateParameterReadValue(item, rawValue, inputValues = {}, driverPar
     return ratio * currentSampleMax * voltageSampleMax
   }
 
+  if (item.name === '温度保护值' || item.name === '温度恢复值') {
+    return rawToTemperature(rawNumber, SCALE_MAX)
+  }
+
   return null
 }
 
@@ -245,7 +259,7 @@ function calculateStatusValue(name, rawValue, driverParams = getDriverParams())
 
   if (name === '母线电压') return ratio * baseVoltage * busVoltageDividerRatio
   if (name === '母线电流') return ratio * currentBase
-  if (name === 'NTC 电压') return ratio * baseVoltage
+  if (name === 'NTC 温度') return rawToTemperature(rawNumber, SCALE_MAX)
   if (name === '模拟输入电压') return analogInputDividerRatio ? ratio * baseVoltage / analogInputDividerRatio : 0
   if (name === '估算速度') return ratio * speedBase
   if (name === '估算功率') return ratio * voltageSampleMax * currentSampleMax
@@ -263,6 +277,5 @@ module.exports = {
   calculateSpeedCommandWriteValue,
   calculateSpeedSlope,
   calculateStatusValue,
-  formatFixedValue,
-  getSampleLimits
+  formatFixedValue
 }

+ 234 - 0
utils/crc-tool.js

@@ -0,0 +1,234 @@
+const {
+  CRC_ALGORITHM_PRESETS,
+  calculateCrc
+} = require('./crc')
+const {
+  HASH_ALGORITHM_PRESETS,
+  calculateHash
+} = require('./hash')
+const {
+  formatBytes,
+  loadSelectedFile
+} = require('./file-service')
+const {
+  stringToUtf8Bytes
+} = require('./binary-utils')
+
+const INPUT_TYPE_OPTIONS = [
+  { key: 'hex', label: 'HEX' },
+  { key: 'string', label: 'String' },
+  { key: 'base64', label: 'Base64' }
+]
+const ALGORITHM_PRESETS = CRC_ALGORITHM_PRESETS
+  .map((preset) => ({ ...preset, kind: 'crc' }))
+  .concat(HASH_ALGORITHM_PRESETS)
+const CRC_CONFIG_FIELDS = [
+  'crcInitialValue',
+  'crcPoly',
+  'crcWidth',
+  'crcXorOut'
+]
+
+function clonePreset(preset) {
+  return {
+    ...preset
+  }
+}
+
+function getPreset(index) {
+  return clonePreset(ALGORITHM_PRESETS[Number(index)] || ALGORITHM_PRESETS[0])
+}
+
+function getCustomPresetIndex() {
+  return Math.max(0, ALGORITHM_PRESETS.findIndex((preset) => preset.custom))
+}
+
+function getInputType(index) {
+  return INPUT_TYPE_OPTIONS[Number(index)] || INPUT_TYPE_OPTIONS[0]
+}
+
+function createPresetState(presetIndex = 0) {
+  const preset = getPreset(presetIndex)
+  const kind = preset.kind || 'crc'
+
+  return {
+    crcAlgorithmCollapsed: !(preset.custom || kind === 'hmac' || kind === 'pbkdf2'),
+    crcAlgorithmKind: kind,
+    crcInitialValue: preset.init || 'FFFF',
+    crcPoly: preset.poly || '1021',
+    crcPresetIndex: Number(presetIndex),
+    crcReflectIn: !!preset.reflectIn,
+    crcReflectOut: !!preset.reflectOut,
+    crcShowBinResult: kind === 'crc',
+    crcShowCrcConfig: kind === 'crc',
+    crcShowHmacKey: kind === 'hmac',
+    crcShowPbkdf2Config: kind === 'pbkdf2',
+    crcWidth: String(preset.width || 16),
+    crcXorOut: preset.xorOut || '0000'
+  }
+}
+
+function createInitialState() {
+  return {
+    ...createPresetState(0),
+    crcDataLengthText: '0 bytes',
+    crcDataText: '',
+    crcErrorText: '',
+    crcFileName: '',
+    crcFileSizeText: '',
+    crcHmacKey: '',
+    crcInputTypeIndex: 0,
+    crcInputTypeOptions: INPUT_TYPE_OPTIONS,
+    crcPbkdf2Iterations: '1000',
+    crcPbkdf2Length: '32',
+    crcPbkdf2Salt: '',
+    crcPresetOptions: ALGORITHM_PRESETS.map(clonePreset),
+    crcResultBase64: '--',
+    crcResultBin: '--',
+    crcResultBinLines: splitBinaryResult('--'),
+    crcResultHex: '--'
+  }
+}
+
+function normalizeHexData(text) {
+  return String(text || '')
+    .replace(/0x/gi, '')
+    .replace(/[\s,_:-]/g, '')
+}
+
+function parseHexBytes(text) {
+  const hexText = normalizeHexData(text)
+  if (!hexText) return []
+  if (!/^[0-9A-F]+$/i.test(hexText)) throw new Error('HEX 数据包含非法字符')
+  if (hexText.length % 2 !== 0) throw new Error('HEX 数据长度必须为偶数字符')
+
+  const bytes = []
+  for (let index = 0; index < hexText.length; index += 2) {
+    bytes.push(parseInt(hexText.slice(index, index + 2), 16))
+  }
+
+  return bytes
+}
+
+function parseBase64Bytes(text) {
+  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+  let normalized = String(text || '').replace(/\s/g, '')
+  if (!normalized) return []
+  if (normalized.length % 4 === 1 || /[^A-Za-z0-9+/=]/.test(normalized)) {
+    throw new Error('Base64 数据无效')
+  }
+  while (normalized.length % 4 !== 0) {
+    normalized += '='
+  }
+
+  const bytes = []
+
+  for (let index = 0; index < normalized.length; index += 4) {
+    const chars = normalized.slice(index, index + 4)
+    const values = chars.split('').map((char) => (char === '=' ? 0 : alphabet.indexOf(char)))
+    if (values.some((value) => value < 0)) throw new Error('Base64 数据无效')
+
+    const triple = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3]
+    bytes.push((triple >> 16) & 0xFF)
+    if (chars[2] !== '=') bytes.push((triple >> 8) & 0xFF)
+    if (chars[3] !== '=') bytes.push(triple & 0xFF)
+  }
+
+  return bytes
+}
+
+function parseInputBytes(dataText, inputTypeIndex) {
+  const inputType = getInputType(inputTypeIndex)
+  if (inputType.key === 'hex') return parseHexBytes(dataText)
+  if (inputType.key === 'base64') return parseBase64Bytes(dataText)
+
+  return stringToUtf8Bytes(dataText)
+}
+
+function splitBinaryResult(value) {
+  const text = String(value || '--')
+  if (text === '--' || text.length <= 32) return [
+    {
+      id: 'bin-line-0',
+      text
+    }
+  ]
+
+  const lineLength = Math.ceil(text.length / 2)
+
+  return [
+    {
+      id: 'bin-line-0',
+      text: text.slice(0, lineLength)
+    },
+    {
+      id: 'bin-line-1',
+      text: text.slice(lineLength)
+    }
+  ]
+}
+
+function getConfigFromState(state) {
+  const preset = (state.crcPresetOptions || [])[Number(state.crcPresetIndex)] || {}
+
+  return {
+    init: state.crcInitialValue,
+    key: preset.key || '',
+    poly: state.crcPoly,
+    reflectIn: !!state.crcReflectIn,
+    reflectOut: !!state.crcReflectOut,
+    useLookupTable: !!preset.key && !preset.custom,
+    width: state.crcWidth,
+    xorOut: state.crcXorOut
+  }
+}
+
+function getHashConfigFromState(state) {
+  const preset = (state.crcPresetOptions || [])[Number(state.crcPresetIndex)] || {}
+
+  return {
+    hmacKey: state.crcHmacKey,
+    key: preset.key || '',
+    pbkdf2Iterations: state.crcPbkdf2Iterations,
+    pbkdf2Length: state.crcPbkdf2Length,
+    pbkdf2Salt: state.crcPbkdf2Salt
+  }
+}
+
+function calculateFromState(state, fileBytes) {
+  const bytes = fileBytes
+    ? Array.prototype.slice.call(fileBytes)
+    : parseInputBytes(state.crcDataText, state.crcInputTypeIndex)
+  const preset = (state.crcPresetOptions || [])[Number(state.crcPresetIndex)] || {}
+  const result = (preset.kind || 'crc') === 'crc'
+    ? calculateCrc(bytes, getConfigFromState(state))
+    : calculateHash(bytes, getHashConfigFromState(state))
+
+  return {
+    crcDataLengthText: formatBytes(bytes.length),
+    crcErrorText: '',
+    crcResultBase64: result.base64,
+    crcResultBin: result.bin,
+    crcResultBinLines: splitBinaryResult(result.bin),
+    crcResultHex: result.hex
+  }
+}
+
+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
+}

+ 657 - 0
utils/crc.js

@@ -0,0 +1,657 @@
+const {
+  bytesToBase64,
+  toByteArray
+} = require('./binary-utils')
+const {
+  clampInteger
+} = require('./base-utils')
+
+const CRC16_MODBUS_TABLE = [
+  0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
+  0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
+  0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
+  0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
+  0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
+  0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
+  0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
+  0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
+  0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
+  0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
+  0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
+  0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
+  0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
+  0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
+  0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
+  0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
+  0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
+  0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
+  0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
+  0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
+  0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
+  0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
+  0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
+  0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
+  0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
+  0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
+  0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
+  0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
+  0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
+  0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
+  0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
+  0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
+]
+
+const CRC16_CCITT_INIT = 0xFFFF
+const CRC16_CCITT_POLY = 0x1021
+const BYTE_ORDER_HIGH = 'high'
+const BYTE_ORDER_LOW = 'low'
+const CRC_TABLE_CACHE = {}
+
+function createReflectByteTable() {
+  const table = []
+
+  for (let index = 0; index < 256; index += 1) {
+    let source = index
+    let reflected = 0
+
+    for (let bit = 0; bit < 8; bit += 1) {
+      reflected = (reflected << 1) | (source & 0x01)
+      source >>= 1
+    }
+
+    table[index] = reflected & 0xFF
+  }
+
+  return table
+}
+
+const REFLECT_BYTE_TABLE = createReflectByteTable()
+
+function getCrcNumberMask(width) {
+  if (width === 8) return 0xFF
+  if (width === 16) return 0xFFFF
+
+  return 0
+}
+
+function createMsbCrcTable(width, polynomial) {
+  const table = []
+  const mask = getCrcNumberMask(width)
+  const topBit = 1 << (width - 1)
+  const shift = width - 8
+  const poly = polynomial & mask
+
+  for (let index = 0; index < 256; index += 1) {
+    let crc = shift > 0 ? (index << shift) : index
+
+    for (let bit = 0; bit < 8; bit += 1) {
+      crc = (crc & topBit)
+        ? ((crc << 1) ^ poly)
+        : (crc << 1)
+      crc &= mask
+    }
+
+    table[index] = crc
+  }
+
+  return table
+}
+
+function getMsbCrcTable(width, polynomial) {
+  const mask = getCrcNumberMask(width)
+  if (!mask) return null
+
+  const key = `${width}:${polynomial & mask}`
+  if (!CRC_TABLE_CACHE[key]) {
+    CRC_TABLE_CACHE[key] = createMsbCrcTable(width, polynomial)
+  }
+
+  return CRC_TABLE_CACHE[key]
+}
+
+function reflectNumberBits(value, width) {
+  if (width === 8) return REFLECT_BYTE_TABLE[value & 0xFF]
+  if (width === 16) {
+    return ((REFLECT_BYTE_TABLE[value & 0xFF] << 8) | REFLECT_BYTE_TABLE[(value >> 8) & 0xFF]) & 0xFFFF
+  }
+
+  return Number(reflectBits(BigInt(value), width))
+}
+
+const CRC16_CCITT_TABLE = getMsbCrcTable(16, CRC16_CCITT_POLY)
+
+function hasOwnOption(options, key) {
+  return Object.prototype.hasOwnProperty.call(options, key)
+}
+
+function getSourceOptions(options) {
+  return typeof options === 'number'
+    ? { initialValue: options }
+    : (options || {})
+}
+
+function normalizeChecksumOptions(options = {}, defaultByteOrder = BYTE_ORDER_HIGH, requireByteOrder = false) {
+  if (typeof options === 'number') {
+    if (requireByteOrder) {
+      throw new Error("16位校验需要指定 byteOrder: 'high' 或 'low'")
+    }
+
+    return {
+      byteOrder: defaultByteOrder,
+      initialValue: options
+    }
+  }
+
+  const source = options || {}
+  const hasByteOrder = hasOwnOption(source, 'byteOrder') || hasOwnOption(source, 'lowByteFirst')
+  if (requireByteOrder && !hasByteOrder) {
+    throw new Error("16位校验需要指定 byteOrder: 'high' 或 'low'")
+  }
+
+  let byteOrder = source.byteOrder || defaultByteOrder
+
+  if (source.lowByteFirst === true || byteOrder === 'low' || byteOrder === 'little' || byteOrder === 'le') {
+    byteOrder = BYTE_ORDER_LOW
+  } else {
+    byteOrder = BYTE_ORDER_HIGH
+  }
+
+  return {
+    ...source,
+    byteOrder
+  }
+}
+
+function getOptionNumber(options, key, fallback, mask) {
+  const value = hasOwnOption(options, key) ? Number(options[key]) : fallback
+  if (!Number.isFinite(value)) return fallback & mask
+
+  return value & mask
+}
+
+function appendChecksum8Value(bytes, checksum) {
+  const frame = toByteArray(bytes)
+
+  return frame.concat([checksum & 0xFF])
+}
+
+function hasValidChecksum8By(bytes, checksumFunction, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 2) return false
+
+  const expected = checksumFunction(frame.slice(0, -1), options) & 0xFF
+  const received = frame[frame.length - 1] & 0xFF
+
+  return expected === received
+}
+
+function appendChecksum16(bytes, checksum, options = {}) {
+  const frame = toByteArray(bytes)
+  const normalized = normalizeChecksumOptions(options)
+  const highByte = (checksum >> 8) & 0xFF
+  const lowByte = checksum & 0xFF
+
+  return normalized.byteOrder === BYTE_ORDER_LOW
+    ? frame.concat([lowByte, highByte])
+    : frame.concat([highByte, lowByte])
+}
+
+function readChecksum16(bytes, options = {}) {
+  const frame = toByteArray(bytes)
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true)
+  const firstByte = frame[frame.length - 2] & 0xFF
+  const secondByte = frame[frame.length - 1] & 0xFF
+
+  return normalized.byteOrder === BYTE_ORDER_LOW
+    ? (firstByte | (secondByte << 8))
+    : ((firstByte << 8) | secondByte)
+}
+
+function hasValidChecksum16(bytes, checksumFunction, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 2) return false
+
+  const expected = checksumFunction(frame.slice(0, -2), options) & 0xFFFF
+  const received = readChecksum16(frame, options)
+
+  return expected === received
+}
+
+function checksum8(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options)
+  const initialValue = getOptionNumber(normalized, 'initialValue', 0x00, 0xFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x00, 0xFF)
+  let checksum = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    checksum = (checksum + (byte & 0xFF)) & 0xFF
+  })
+
+  return (checksum ^ finalXor) & 0xFF
+}
+
+function appendChecksum8(bytes, options = {}) {
+  return appendChecksum8Value(bytes, checksum8(bytes, options))
+}
+
+function hasValidChecksum8Value(bytes, options = {}) {
+  return hasValidChecksum8By(bytes, checksum8, options)
+}
+
+function xor8(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options)
+  const initialValue = getOptionNumber(normalized, 'initialValue', 0x00, 0xFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x00, 0xFF)
+  let checksum = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    checksum = (checksum ^ (byte & 0xFF)) & 0xFF
+  })
+
+  return (checksum ^ finalXor) & 0xFF
+}
+
+function appendXor8(bytes, options = {}) {
+  return appendChecksum8Value(bytes, xor8(bytes, options))
+}
+
+function hasValidXor8(bytes, options = {}) {
+  return hasValidChecksum8By(bytes, xor8, options)
+}
+
+function lrc8(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options)
+  const sum = checksum8(bytes, {
+    ...normalized,
+    finalXor: 0x00
+  })
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x00, 0xFF)
+
+  return (((-sum) & 0xFF) ^ finalXor) & 0xFF
+}
+
+function appendLrc8(bytes, options = {}) {
+  return appendChecksum8Value(bytes, lrc8(bytes, options))
+}
+
+function hasValidLrc8(bytes, options = {}) {
+  return hasValidChecksum8By(bytes, lrc8, options)
+}
+
+function crc8(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options)
+  const polynomial = getOptionNumber(normalized, 'polynomial', 0x07, 0xFF)
+  const initialValue = getOptionNumber(normalized, 'initialValue', 0x00, 0xFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x00, 0xFF)
+  const table = getMsbCrcTable(8, polynomial)
+  let crc = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    crc = table[(crc ^ (byte & 0xFF)) & 0xFF]
+  })
+
+  return (crc ^ finalXor) & 0xFF
+}
+
+function appendCrc8(bytes, options = {}) {
+  return appendChecksum8Value(bytes, crc8(bytes, options))
+}
+
+function hasValidCrc8(bytes, options = {}) {
+  return hasValidChecksum8By(bytes, crc8, options)
+}
+
+function checksum16(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options)
+  const initialValue = getOptionNumber(normalized, 'initialValue', 0x0000, 0xFFFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x0000, 0xFFFF)
+  let checksum = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    checksum = (checksum + (byte & 0xFF)) & 0xFFFF
+  })
+
+  return (checksum ^ finalXor) & 0xFFFF
+}
+
+function appendChecksum16Value(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true)
+
+  return appendChecksum16(bytes, checksum16(bytes, normalized), normalized)
+}
+
+function hasValidChecksum16Value(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true)
+
+  return hasValidChecksum16(bytes, checksum16, normalized)
+}
+
+function crc16Ibm(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_LOW)
+  const initialValue = getOptionNumber(normalized, 'initialValue', 0x0000, 0xFFFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x0000, 0xFFFF)
+  let crc = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    crc = (crc >> 8) ^ CRC16_MODBUS_TABLE[(crc ^ byte) & 0xFF]
+  })
+
+  return (crc ^ finalXor) & 0xFFFF
+}
+
+function appendCrc16Ibm(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_LOW, true)
+
+  return appendChecksum16(bytes, crc16Ibm(bytes, normalized), normalized)
+}
+
+function hasValidCrc16Ibm(bytes, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 4) return false
+
+  return hasValidChecksum16(frame, crc16Ibm, normalizeChecksumOptions(options, BYTE_ORDER_LOW, true))
+}
+
+function crc16Modbus(bytes, options = {}) {
+  const source = getSourceOptions(options)
+  const normalized = {
+    ...normalizeChecksumOptions(options, BYTE_ORDER_LOW),
+    initialValue: hasOwnOption(source, 'initialValue') ? source.initialValue : 0xFFFF
+  }
+
+  return crc16Ibm(bytes, normalized)
+}
+
+function appendCrc16Modbus(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_LOW, true)
+
+  return appendChecksum16(bytes, crc16Modbus(bytes, normalized), normalized)
+}
+
+function hasValidCrc16Modbus(bytes, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 4) return false
+
+  return hasValidChecksum16(frame, crc16Modbus, normalizeChecksumOptions(options, BYTE_ORDER_LOW, true))
+}
+
+function crc16Ccitt(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH)
+  const initialValue = getOptionNumber(normalized, 'initialValue', CRC16_CCITT_INIT, 0xFFFF)
+  const finalXor = getOptionNumber(normalized, 'finalXor', 0x0000, 0xFFFF)
+  let crc = initialValue
+
+  toByteArray(bytes).forEach((byte) => {
+    crc = ((crc << 8) ^ CRC16_CCITT_TABLE[((crc >> 8) ^ byte) & 0xFF]) & 0xFFFF
+  })
+
+  return (crc ^ finalXor) & 0xFFFF
+}
+
+function appendCrc16Ccitt(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true)
+
+  return appendChecksum16(bytes, crc16Ccitt(bytes, normalized), normalized)
+}
+
+function hasValidCrc16Ccitt(bytes, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 4) return false
+
+  return hasValidChecksum16(frame, crc16Ccitt, normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true))
+}
+
+function crc16Xmodem(bytes, options = {}) {
+  const source = getSourceOptions(options)
+  const normalized = {
+    ...normalizeChecksumOptions(source, BYTE_ORDER_HIGH),
+    initialValue: hasOwnOption(source, 'initialValue') ? source.initialValue : 0x0000
+  }
+
+  return crc16Ccitt(bytes, normalized)
+}
+
+function appendCrc16Xmodem(bytes, options = {}) {
+  const normalized = normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true)
+
+  return appendChecksum16(bytes, crc16Xmodem(bytes, normalized), normalized)
+}
+
+function hasValidCrc16Xmodem(bytes, options = {}) {
+  const frame = toByteArray(bytes)
+  if (frame.length < 4) return false
+
+  return hasValidChecksum16(frame, crc16Xmodem, normalizeChecksumOptions(options, BYTE_ORDER_HIGH, true))
+}
+
+const CRC_ALGORITHM_PRESETS = [
+  { key: 'crc-8', label: 'CRC-8', width: 8, poly: '07', init: '00', xorOut: '00', reflectIn: false, reflectOut: false },
+  { key: 'crc-8-itu', label: 'CRC-8-ITU', width: 8, poly: '07', init: '00', xorOut: '55', reflectIn: false, reflectOut: false },
+  { key: 'crc-8-rohc', label: 'CRC-8-ROHC', width: 8, poly: '07', init: 'FF', xorOut: '00', reflectIn: true, reflectOut: true },
+  { key: 'crc-8-maxim', label: 'CRC-8-MAXIM', width: 8, poly: '31', init: '00', xorOut: '00', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-ibm', label: 'CRC-16-IBM', width: 16, poly: '8005', init: '0000', xorOut: '0000', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-usb', label: 'CRC-16-USB', width: 16, poly: '8005', init: 'FFFF', xorOut: 'FFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-maxim', label: 'CRC-16-MAXIM', width: 16, poly: '8005', init: '0000', xorOut: 'FFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-ccitt', label: 'CRC-16-CCITT', width: 16, poly: '1021', init: '0000', xorOut: '0000', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-ccitt-false', label: 'CRC-16-CCITT-FALSE', width: 16, poly: '1021', init: 'FFFF', xorOut: '0000', reflectIn: false, reflectOut: false },
+  { key: 'crc-16-x25', label: 'CRC-16-X25', width: 16, poly: '1021', init: 'FFFF', xorOut: 'FFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-xmodem', label: 'CRC-16-XMODEM', width: 16, poly: '1021', init: '0000', xorOut: '0000', reflectIn: false, reflectOut: false },
+  { key: 'crc-16-xmodem2', label: 'CRC-16-XMODEM2', width: 16, poly: '8408', init: '0000', xorOut: '0000', reflectIn: true, reflectOut: true },
+  { key: 'crc-16-dnp', label: 'CRC-16-DNP', width: 16, poly: '3D65', init: '0000', xorOut: 'FFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-24-q', label: 'CRC-24-Q', width: 24, poly: '864CFB', init: '000000', xorOut: '000000', reflectIn: false, reflectOut: false },
+  { key: 'crc-32', label: 'CRC-32', width: 32, poly: '04C11DB7', init: 'FFFFFFFF', xorOut: 'FFFFFFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-32-c', label: 'CRC-32-C', width: 32, poly: '1EDC6F41', init: 'FFFFFFFF', xorOut: 'FFFFFFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-32-koopman', label: 'CRC-32-KOOPMAN', width: 32, poly: '741B8CD7', init: 'FFFFFFFF', xorOut: 'FFFFFFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-32-mpeg-2', label: 'CRC-32-MPEG-2', width: 32, poly: '04C11DB7', init: 'FFFFFFFF', xorOut: '00000000', reflectIn: false, reflectOut: false },
+  { key: 'crc-64-iso', label: 'CRC-64-ISO', width: 64, poly: '000000000000001B', init: 'FFFFFFFFFFFFFFFF', xorOut: 'FFFFFFFFFFFFFFFF', reflectIn: true, reflectOut: true },
+  { key: 'crc-64-ecma', label: 'CRC-64-ECMA', width: 64, poly: '42F0E1EBA9EA3693', init: 'FFFFFFFFFFFFFFFF', xorOut: 'FFFFFFFFFFFFFFFF', reflectIn: true, reflectOut: true },
+  { key: 'custom', label: '自定义', width: 16, poly: '1021', init: 'FFFF', xorOut: '0000', reflectIn: false, reflectOut: false, custom: true }
+]
+
+function maskForWidth(width) {
+  const bitWidth = Number(width)
+  if (!Number.isInteger(bitWidth) || bitWidth < 1 || bitWidth > 64) {
+    throw new Error('CRC 位宽需为 1 - 64')
+  }
+
+  return (1n << BigInt(bitWidth)) - 1n
+}
+
+function normalizeHexText(value, fallback = '0') {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return fallback
+
+  return text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+}
+
+function parseHexBigInt(value, label, fallback = '0') {
+  if (typeof value === 'bigint') return value
+  if (typeof value === 'number' && Number.isFinite(value)) return BigInt(Math.max(0, Math.trunc(value)))
+
+  const hexText = normalizeHexText(value, fallback)
+  if (!/^[0-9A-F]+$/i.test(hexText)) {
+    throw new Error(`${label}需为十六进制`)
+  }
+
+  return BigInt(`0x${hexText}`)
+}
+
+function reflectBits(value, width) {
+  let source = BigInt(value)
+  let reflected = 0n
+
+  for (let index = 0; index < width; index += 1) {
+    reflected = (reflected << 1n) | (source & 1n)
+    source >>= 1n
+  }
+
+  return reflected
+}
+
+function normalizeCrcConfig(config = {}) {
+  const width = clampInteger(config.width, 1, 64, 16)
+  const mask = maskForWidth(width)
+  const polyValue = config.poly !== undefined ? config.poly : config.polynomial
+  const initValue = config.init !== undefined ? config.init : config.initialValue
+  const xorValue = config.xorOut !== undefined ? config.xorOut : config.finalXor
+  const presetKey = String(config.key || config.presetKey || '')
+
+  return {
+    finalXor: parseHexBigInt(xorValue, '结果异或值', '0') & mask,
+    initialValue: parseHexBigInt(initValue, '初始值', '0') & mask,
+    mask,
+    presetKey,
+    polynomial: parseHexBigInt(polyValue, 'Poly', '0') & mask,
+    reflectIn: !!(config.reflectIn || config.refin),
+    reflectOut: !!(config.reflectOut || config.refout),
+    useLookupTable: config.useLookupTable === true || (
+      !!presetKey && presetKey !== 'custom' && (width === 8 || width === 16)
+    ),
+    width
+  }
+}
+
+function computeCrcTable(bytes, normalized) {
+  if (!normalized.useLookupTable) return null
+
+  const width = normalized.width
+  const mask = getCrcNumberMask(width)
+  if (!mask) return null
+
+  const table = getMsbCrcTable(width, Number(normalized.polynomial & BigInt(mask)))
+  const shift = width - 8
+  let crc = Number(normalized.initialValue & BigInt(mask))
+
+  toByteArray(bytes).forEach((sourceByte) => {
+    const byte = normalized.reflectIn
+      ? REFLECT_BYTE_TABLE[sourceByte & 0xFF]
+      : (sourceByte & 0xFF)
+    const tableIndex = shift > 0
+      ? (((crc >> shift) ^ byte) & 0xFF)
+      : ((crc ^ byte) & 0xFF)
+
+    crc = shift > 0
+      ? (((crc << 8) & mask) ^ table[tableIndex])
+      : table[tableIndex]
+    crc &= mask
+  })
+
+  if (normalized.reflectOut) {
+    crc = reflectNumberBits(crc, width) & mask
+  }
+
+  return BigInt((crc ^ Number(normalized.finalXor & BigInt(mask))) & mask)
+}
+
+function computeCrc(bytes, config = {}) {
+  const normalized = normalizeCrcConfig(config)
+  const tableValue = computeCrcTable(bytes, normalized)
+  if (tableValue !== null) return tableValue
+
+  const topBit = 1n << BigInt(normalized.width - 1)
+  let crc = normalized.initialValue
+
+  toByteArray(bytes).forEach((sourceByte) => {
+    const byte = normalized.reflectIn
+      ? Number(reflectBits(BigInt(sourceByte & 0xFF), 8))
+      : (sourceByte & 0xFF)
+
+    for (let bitMask = 0x80; bitMask > 0; bitMask >>= 1) {
+      let bitSet = (crc & topBit) !== 0n
+      crc = (crc << 1n) & normalized.mask
+
+      if (byte & bitMask) {
+        bitSet = !bitSet
+      }
+
+      if (bitSet) {
+        crc = (crc ^ normalized.polynomial) & normalized.mask
+      }
+    }
+  })
+
+  if (normalized.reflectOut) {
+    crc = reflectBits(crc, normalized.width) & normalized.mask
+  }
+
+  return (crc ^ normalized.finalXor) & normalized.mask
+}
+
+function formatCrcHex(value, width) {
+  const hexLength = Math.max(1, Math.ceil(Number(width || 1) / 4))
+
+  return `0x${BigInt(value).toString(16).toUpperCase().padStart(hexLength, '0')}`
+}
+
+function formatCrcBin(value, width) {
+  return BigInt(value).toString(2).padStart(Number(width || 1), '0')
+}
+
+function crcValueToBytes(value, width) {
+  const byteLength = Math.max(1, Math.ceil(Number(width || 1) / 8))
+  const result = []
+  let source = BigInt(value)
+
+  for (let index = byteLength - 1; index >= 0; index -= 1) {
+    result[index] = Number(source & 0xFFn)
+    source >>= 8n
+  }
+
+  return result
+}
+
+function calculateCrc(bytes, config = {}) {
+  const normalized = normalizeCrcConfig(config)
+  const value = computeCrc(bytes, normalized)
+  const resultBytes = crcValueToBytes(value, normalized.width)
+
+  return {
+    base64: bytesToBase64(resultBytes),
+    bin: formatCrcBin(value, normalized.width),
+    bytes: resultBytes,
+    hex: formatCrcHex(value, normalized.width),
+    value,
+    width: normalized.width
+  }
+}
+
+module.exports = {
+  BYTE_ORDER_HIGH,
+  BYTE_ORDER_LOW,
+  CRC_ALGORITHM_PRESETS,
+  appendChecksum16: appendChecksum16Value,
+  appendChecksum8,
+  appendCrc16Ccitt,
+  appendCrc16Ibm,
+  appendCrc16Modbus,
+  appendCrc16Xmodem,
+  appendCrc8,
+  appendLrc8,
+  appendXor8,
+  bytesToBase64,
+  calculateCrc,
+  checksum16,
+  checksum8,
+  computeCrc,
+  crc16Ccitt,
+  crc16Ibm,
+  crc16Modbus,
+  crc16Xmodem,
+  crc8,
+  crcValueToBytes,
+  formatCrcBin,
+  formatCrcHex,
+  hasValidChecksum16: hasValidChecksum16Value,
+  hasValidChecksum8: hasValidChecksum8Value,
+  hasValidCrc16Ccitt,
+  hasValidCrc16Ibm,
+  hasValidCrc16Modbus,
+  hasValidCrc16Xmodem,
+  hasValidCrc8,
+  hasValidLrc8,
+  hasValidXor8,
+  lrc8,
+  normalizeCrcConfig,
+  readChecksum16,
+  xor8
+}

+ 236 - 0
utils/file-service.js

@@ -0,0 +1,236 @@
+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`
+}
+
+function formatExportStamp(date = new Date()) {
+  const pad = (value, length = 2) => String(value).padStart(length, '0')
+
+  return [
+    date.getFullYear(),
+    pad(date.getMonth() + 1),
+    pad(date.getDate()),
+    '-',
+    pad(date.getHours()),
+    pad(date.getMinutes()),
+    pad(date.getSeconds())
+  ].join('')
+}
+
+function normalizeExtensions(extensions = []) {
+  return extensions
+    .map((extension) => String(extension || '').trim().replace(/^\./, '').toLowerCase())
+    .filter(Boolean)
+}
+
+function getFileName(file, fallback = '未命名文件') {
+  return String(file && file.name ? file.name : fallback)
+}
+
+function getFilePath(file) {
+  return file && (file.path || file.tempFilePath) ? (file.path || file.tempFilePath) : ''
+}
+
+function getFirstSelectedFile(result, fallbackName = '未命名文件') {
+  const file = result && Array.isArray(result.tempFiles) ? result.tempFiles[0] : null
+  if (!file) throw new Error('没有选择文件')
+
+  const filePath = getFilePath(file)
+  if (!filePath) throw new Error('无法读取所选文件路径')
+
+  return {
+    file,
+    name: getFileName(file, fallbackName),
+    path: filePath
+  }
+}
+
+function assertFileExtension(fileInfo, extensions = [], message = '文件格式不符') {
+  const normalizedExtensions = normalizeExtensions(extensions)
+  if (!normalizedExtensions.length) return
+
+  const nameText = `${fileInfo && fileInfo.name ? fileInfo.name : ''} ${fileInfo && fileInfo.path ? fileInfo.path : ''}`
+  const matched = normalizedExtensions.some((extension) => (
+    new RegExp(`\\.${extension}$`, 'i').test(nameText)
+  ))
+
+  if (!matched) throw new Error(message)
+}
+
+function chooseMessageFile(options = {}) {
+  const wxApi = getWxApi()
+  const extensions = normalizeExtensions(options.extensions || options.extension || [])
+
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.chooseMessageFile !== 'function') {
+      reject(new Error('当前微信版本不支持从聊天记录选择文件'))
+      return
+    }
+
+    const chooseOptions = {
+      count: options.count || 1,
+      type: options.type || 'file',
+      success: resolve,
+      fail: reject
+    }
+
+    if (extensions.length) chooseOptions.extension = extensions
+    wxApi.chooseMessageFile(chooseOptions)
+  })
+}
+
+function chooseLocalFile(options = {}) {
+  const wxApi = getWxApi()
+  const extensions = normalizeExtensions(options.extensions || options.extension || [])
+
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.chooseFile !== 'function') {
+      reject(new Error(options.unsupportedMessage || '当前微信版本不支持打开本地文件,请从聊天记录选择'))
+      return
+    }
+
+    const chooseOptions = {
+      count: options.count || 1,
+      type: options.type || 'file',
+      success: resolve,
+      fail: reject
+    }
+
+    if (extensions.length) chooseOptions.extension = extensions
+    wxApi.chooseFile(chooseOptions)
+  })
+}
+
+function chooseFile(source = 'message', options = {}) {
+  return source === 'local'
+    ? chooseLocalFile(options)
+    : chooseMessageFile(options)
+}
+
+function readFile(filePath, options = {}) {
+  const wxApi = getWxApi()
+
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.getFileSystemManager !== 'function') {
+      reject(new Error('当前微信版本不支持读取文件'))
+      return
+    }
+
+    const fs = wxApi.getFileSystemManager()
+    const readOptions = {
+      filePath,
+      success: (res) => resolve(res.data),
+      fail: reject
+    }
+
+    if (options.encoding) readOptions.encoding = options.encoding
+    fs.readFile(readOptions)
+  })
+}
+
+function toUint8Array(data) {
+  if (data instanceof Uint8Array) return data
+  if (data instanceof ArrayBuffer) return new Uint8Array(data)
+  if (ArrayBuffer.isView(data)) {
+    return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
+  }
+
+  return new Uint8Array(data || new ArrayBuffer(0))
+}
+
+async function loadSelectedFile(source = 'message', options = {}) {
+  const result = await chooseFile(source, options)
+  const fileInfo = getFirstSelectedFile(result, options.fallbackName || '未命名文件')
+
+  assertFileExtension(fileInfo, options.extensions || options.extension || [], options.extensionMessage || '文件格式不符')
+
+  const data = await readFile(fileInfo.path, {
+    encoding: options.encoding
+  })
+  const bytes = options.encoding ? null : toUint8Array(data)
+
+  return {
+    ...fileInfo,
+    bytes,
+    data,
+    size: bytes ? bytes.length : String(data || '').length,
+    sizeText: formatBytes(bytes ? bytes.length : String(data || '').length),
+    text: options.encoding ? String(data || '') : ''
+  }
+}
+
+function getUserDataFilePath(fileName) {
+  const wxApi = getWxApi()
+  const userDataPath = wxApi.env && wxApi.env.USER_DATA_PATH
+
+  if (!userDataPath) throw new Error('当前微信版本不支持生成文件')
+
+  return `${userDataPath}/${fileName}`
+}
+
+function writeTextFile(filePath, data, encoding = 'utf8') {
+  const wxApi = getWxApi()
+  if (typeof wxApi.getFileSystemManager !== 'function') {
+    throw new Error('当前微信版本不支持生成文件')
+  }
+
+  const fs = wxApi.getFileSystemManager()
+  if (typeof fs.writeFileSync !== 'function') {
+    throw new Error('当前微信版本不支持同步生成文件')
+  }
+
+  fs.writeFileSync(filePath, data, encoding)
+}
+
+function shareFileToChat(filePath, fileName) {
+  const wxApi = getWxApi()
+
+  return new Promise((resolve, reject) => {
+    if (typeof wxApi.shareFileMessage !== 'function') {
+      reject(new Error('当前微信版本不支持发送文件到聊天'))
+      return
+    }
+
+    wxApi.shareFileMessage({
+      fileName,
+      filePath,
+      success: resolve,
+      fail: reject
+    })
+  })
+}
+
+async function saveTextFileToChat(fileName, data) {
+  const filePath = getUserDataFilePath(fileName)
+
+  writeTextFile(filePath, data, 'utf8')
+  await shareFileToChat(filePath, fileName)
+
+  return filePath
+}
+
+module.exports = {
+  assertFileExtension,
+  chooseFile,
+  chooseLocalFile,
+  chooseMessageFile,
+  formatBytes,
+  formatExportStamp,
+  getFirstSelectedFile,
+  getUserDataFilePath,
+  isCancelError,
+  loadSelectedFile,
+  readFile,
+  saveTextFileToChat,
+  shareFileToChat,
+  toUint8Array,
+  writeTextFile
+}

+ 303 - 0
utils/filter-calculator.js

@@ -0,0 +1,303 @@
+const {
+  formatMagnitudeNumber,
+  formatScaledValue,
+  getOption,
+  normalizeIndex,
+  parsePositiveNumber,
+  selectBestUnit
+} = require('./calculator-helpers')
+
+const TWO_PI = 2 * Math.PI
+
+const NETWORK_OPTIONS = [
+  { key: 'rc', label: 'RC' },
+  { key: 'rl', label: 'RL' }
+]
+const RESPONSE_OPTIONS = [
+  { key: 'lowpass', label: '低通' },
+  { key: 'highpass', label: '高通' }
+]
+const RESISTANCE_UNIT_OPTIONS = [
+  { label: 'mΩ', factor: 1e-3 },
+  { label: 'Ω', factor: 1 },
+  { label: 'kΩ', factor: 1e3 },
+  { label: 'MΩ', factor: 1e6 },
+  { label: 'GΩ', factor: 1e9 }
+]
+const CAPACITANCE_UNIT_OPTIONS = [
+  { label: 'pF', factor: 1e-12 },
+  { label: 'nF', factor: 1e-9 },
+  { label: 'μF', factor: 1e-6 },
+  { label: 'mF', factor: 1e-3 },
+  { label: 'F', factor: 1 }
+]
+const INDUCTANCE_UNIT_OPTIONS = [
+  { label: 'pH', factor: 1e-12 },
+  { label: 'nH', factor: 1e-9 },
+  { label: 'μH', factor: 1e-6 },
+  { label: 'mH', factor: 1e-3 },
+  { label: 'H', factor: 1 },
+  { label: 'kH', factor: 1e3 }
+]
+const FREQUENCY_UNIT_OPTIONS = [
+  { label: 'Hz', factor: 1 },
+  { label: 'kHz', factor: 1e3 },
+  { label: 'MHz', factor: 1e6 },
+  { label: 'GHz', factor: 1e9 },
+  { label: 'THz', factor: 1e12 }
+]
+
+function getReactiveUnitOptions(networkKey) {
+  return networkKey === 'rl' ? INDUCTANCE_UNIT_OPTIONS : CAPACITANCE_UNIT_OPTIONS
+}
+
+function getDiagramComponents(networkKey, responseKey) {
+  if (networkKey === 'rc' && responseKey === 'highpass') {
+    return { series: 'c', seriesLabel: 'C', shunt: 'r', shuntLabel: 'R' }
+  }
+  if (networkKey === 'rl' && responseKey === 'lowpass') {
+    return { series: 'l', seriesLabel: 'L', shunt: 'r', shuntLabel: 'R' }
+  }
+  if (networkKey === 'rl' && responseKey === 'highpass') {
+    return { series: 'r', seriesLabel: 'R', shunt: 'l', shuntLabel: 'L' }
+  }
+
+  return { series: 'r', seriesLabel: 'R', shunt: 'c', shuntLabel: 'C' }
+}
+
+function calculateMissingValue(networkKey, values) {
+  const hasResistance = Number.isFinite(values.resistance)
+  const hasReactive = Number.isFinite(values.reactive)
+  const hasFrequency = Number.isFinite(values.frequency)
+  const validCount = [hasResistance, hasReactive, hasFrequency].filter(Boolean).length
+
+  if (validCount < 2) {
+    return {
+      computedKey: '',
+      errorText: ''
+    }
+  }
+
+  if (validCount > 2) {
+    return {
+      computedKey: '',
+      errorText: '保留两项,清空一项用于计算'
+    }
+  }
+
+  if (networkKey === 'rl') {
+    if (!hasResistance) {
+      return {
+        computedKey: 'resistance',
+        value: TWO_PI * values.frequency * values.reactive
+      }
+    }
+    if (!hasReactive) {
+      return {
+        computedKey: 'reactive',
+        value: values.resistance / (TWO_PI * values.frequency)
+      }
+    }
+    return {
+      computedKey: 'frequency',
+      value: values.resistance / (TWO_PI * values.reactive)
+    }
+  }
+
+  if (!hasResistance) {
+    return {
+      computedKey: 'resistance',
+      value: 1 / (TWO_PI * values.frequency * values.reactive)
+    }
+  }
+  if (!hasReactive) {
+    return {
+      computedKey: 'reactive',
+      value: 1 / (TWO_PI * values.frequency * values.resistance)
+    }
+  }
+  return {
+    computedKey: 'frequency',
+    value: 1 / (TWO_PI * values.resistance * values.reactive)
+  }
+}
+
+function buildState(source = {}) {
+  const networkIndex = normalizeIndex(source.filterNetworkIndex, NETWORK_OPTIONS, 0)
+  const responseIndex = normalizeIndex(source.filterResponseIndex, RESPONSE_OPTIONS, 0)
+  const network = getOption(NETWORK_OPTIONS, networkIndex)
+  const response = getOption(RESPONSE_OPTIONS, responseIndex)
+  const reactiveUnitOptions = getReactiveUnitOptions(network.key)
+  const resistanceUnitIndex = normalizeIndex(source.filterResistanceUnitIndex, RESISTANCE_UNIT_OPTIONS, 1)
+  const capacitanceUnitIndex = normalizeIndex(source.filterCapacitanceUnitIndex, CAPACITANCE_UNIT_OPTIONS, 1)
+  const inductanceUnitIndex = normalizeIndex(source.filterInductanceUnitIndex, INDUCTANCE_UNIT_OPTIONS, 3)
+  const frequencyUnitIndex = normalizeIndex(source.filterFrequencyUnitIndex, FREQUENCY_UNIT_OPTIONS, 0)
+  const reactiveUnitIndex = network.key === 'rl' ? inductanceUnitIndex : capacitanceUnitIndex
+  const resistanceUnit = getOption(RESISTANCE_UNIT_OPTIONS, resistanceUnitIndex)
+  const reactiveUnit = getOption(reactiveUnitOptions, reactiveUnitIndex)
+  const frequencyUnit = getOption(FREQUENCY_UNIT_OPTIONS, frequencyUnitIndex)
+  const resistanceText = String(source.filterResistanceValue || '')
+  const reactiveText = String(source.filterReactiveValue || '')
+  const frequencyText = String(source.filterFrequencyValue || '')
+  const resistanceNumber = parsePositiveNumber(resistanceText)
+  const reactiveNumber = parsePositiveNumber(reactiveText)
+  const frequencyNumber = parsePositiveNumber(frequencyText)
+  const invalidInput = [resistanceNumber, reactiveNumber, frequencyNumber].some((value) => Number.isNaN(value))
+  const values = {
+    frequency: Number.isFinite(frequencyNumber) ? frequencyNumber * frequencyUnit.factor : null,
+    reactive: Number.isFinite(reactiveNumber) ? reactiveNumber * reactiveUnit.factor : null,
+    resistance: Number.isFinite(resistanceNumber) ? resistanceNumber * resistanceUnit.factor : null
+  }
+  const calculated = invalidInput
+    ? { computedKey: '', errorText: '输入值需大于 0' }
+    : calculateMissingValue(network.key, values)
+  const computedValue = Number.isFinite(calculated.value) ? calculated.value : null
+  const diagram = getDiagramComponents(network.key, response.key)
+  let resistanceDisplayUnit = resistanceUnit
+  let resistanceDisplayUnitIndex = resistanceUnitIndex
+  let reactiveDisplayUnit = reactiveUnit
+  let reactiveDisplayUnitIndex = reactiveUnitIndex
+  let frequencyDisplayUnit = frequencyUnit
+  let frequencyDisplayUnitIndex = frequencyUnitIndex
+
+  let resistanceDisplayValue = Number.isFinite(resistanceNumber)
+    ? formatMagnitudeNumber(resistanceNumber, { fallbackText: '' })
+    : resistanceText
+  let reactiveDisplayValue = Number.isFinite(reactiveNumber)
+    ? formatMagnitudeNumber(reactiveNumber, { fallbackText: '' })
+    : reactiveText
+  let frequencyDisplayValue = Number.isFinite(frequencyNumber)
+    ? formatMagnitudeNumber(frequencyNumber, { fallbackText: '' })
+    : frequencyText
+
+  if (computedValue !== null && calculated.computedKey === 'resistance') {
+    const selected = selectBestUnit(RESISTANCE_UNIT_OPTIONS, computedValue, resistanceUnitIndex)
+    resistanceDisplayUnit = selected.unit
+    resistanceDisplayUnitIndex = selected.index
+    resistanceDisplayValue = formatScaledValue(computedValue, resistanceDisplayUnit, { fallbackText: '' })
+  } else if (computedValue !== null && calculated.computedKey === 'reactive') {
+    const selected = selectBestUnit(reactiveUnitOptions, computedValue, reactiveUnitIndex)
+    reactiveDisplayUnit = selected.unit
+    reactiveDisplayUnitIndex = selected.index
+    reactiveDisplayValue = formatScaledValue(computedValue, reactiveDisplayUnit, { fallbackText: '' })
+  } else if (computedValue !== null && calculated.computedKey === 'frequency') {
+    const selected = selectBestUnit(FREQUENCY_UNIT_OPTIONS, computedValue, frequencyUnitIndex)
+    frequencyDisplayUnit = selected.unit
+    frequencyDisplayUnitIndex = selected.index
+    frequencyDisplayValue = formatScaledValue(computedValue, frequencyDisplayUnit, { fallbackText: '' })
+  }
+
+  return {
+    filterCapacitanceUnitIndex: network.key === 'rc' ? reactiveDisplayUnitIndex : capacitanceUnitIndex,
+    filterComputedKey: calculated.computedKey || '',
+    filterErrorText: calculated.errorText || '',
+    filterFrequencyDisplayValue: frequencyDisplayValue,
+    filterFrequencyUnitIndex: frequencyDisplayUnitIndex,
+    filterFrequencyUnitOptions: FREQUENCY_UNIT_OPTIONS,
+    filterFrequencyUnitText: frequencyDisplayUnit.label,
+    filterFrequencyValue: frequencyText,
+    filterFormulaText: network.key === 'rl' ? 'fc = R / (2πL)' : 'fc = 1 / (2πRC)',
+    filterInductanceUnitIndex: network.key === 'rl' ? reactiveDisplayUnitIndex : inductanceUnitIndex,
+    filterNetworkIndex: networkIndex,
+    filterNetworkKey: network.key,
+    filterNetworkOptions: NETWORK_OPTIONS,
+    filterNetworkText: network.label,
+    filterReactiveDisplayValue: reactiveDisplayValue,
+    filterReactiveName: network.key === 'rl' ? '电感' : '电容',
+    filterReactiveSymbol: network.key === 'rl' ? 'L' : 'C',
+    filterReactiveUnitIndex: reactiveDisplayUnitIndex,
+    filterReactiveUnitOptions: reactiveUnitOptions,
+    filterReactiveUnitText: reactiveDisplayUnit.label,
+    filterReactiveValue: reactiveText,
+    filterResistanceDisplayValue: resistanceDisplayValue,
+    filterResistanceUnitIndex: resistanceDisplayUnitIndex,
+    filterResistanceUnitOptions: RESISTANCE_UNIT_OPTIONS,
+    filterResistanceUnitText: resistanceDisplayUnit.label,
+    filterResistanceValue: resistanceText,
+    filterResponseIndex: responseIndex,
+    filterResponseKey: response.key,
+    filterResponseOptions: RESPONSE_OPTIONS,
+    filterResponseText: response.label,
+    filterSeriesComponentKey: diagram.series,
+    filterSeriesComponentLabel: diagram.seriesLabel,
+    filterShuntComponentKey: diagram.shunt,
+    filterShuntComponentLabel: diagram.shuntLabel
+  }
+}
+
+function createInitialState() {
+  return buildState({
+    filterCapacitanceUnitIndex: 1,
+    filterFrequencyUnitIndex: 0,
+    filterInductanceUnitIndex: 3,
+    filterNetworkIndex: 0,
+    filterReactiveValue: '',
+    filterResistanceUnitIndex: 1,
+    filterResistanceValue: '',
+    filterResponseIndex: 0
+  })
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+function normalizeValue(state, fieldKey, fieldValue) {
+  const source = buildState(state)
+  const config = {
+    frequency: {
+      options: FREQUENCY_UNIT_OPTIONS,
+      unitIndex: source.filterFrequencyUnitIndex,
+      unitIndexKey: 'filterFrequencyUnitIndex',
+      valueKey: 'filterFrequencyValue'
+    },
+    reactive: {
+      options: source.filterReactiveUnitOptions,
+      unitIndex: source.filterReactiveUnitIndex,
+      unitIndexKey: source.filterNetworkKey === 'rl'
+        ? 'filterInductanceUnitIndex'
+        : 'filterCapacitanceUnitIndex',
+      valueKey: 'filterReactiveValue'
+    },
+    resistance: {
+      options: RESISTANCE_UNIT_OPTIONS,
+      unitIndex: source.filterResistanceUnitIndex,
+      unitIndexKey: 'filterResistanceUnitIndex',
+      valueKey: 'filterResistanceValue'
+    }
+  }[fieldKey]
+
+  if (!config) return source
+
+  const text = fieldValue === undefined ? source[config.valueKey] : fieldValue
+  const numberValue = parsePositiveNumber(text)
+  if (!Number.isFinite(numberValue)) {
+    return updateState(source, {
+      [config.valueKey]: text
+    })
+  }
+
+  const currentUnit = getOption(config.options, config.unitIndex)
+  const baseValue = numberValue * currentUnit.factor
+  const selected = selectBestUnit(config.options, baseValue, config.unitIndex)
+
+  return updateState(source, {
+    [config.unitIndexKey]: selected.index,
+    [config.valueKey]: formatScaledValue(baseValue, selected.unit, { fallbackText: '' })
+  })
+}
+
+module.exports = {
+  CAPACITANCE_UNIT_OPTIONS,
+  FREQUENCY_UNIT_OPTIONS,
+  INDUCTANCE_UNIT_OPTIONS,
+  NETWORK_OPTIONS,
+  RESISTANCE_UNIT_OPTIONS,
+  RESPONSE_OPTIONS,
+  createInitialState,
+  normalizeValue,
+  updateState
+}

+ 923 - 0
utils/generic-modbus-model.js

@@ -0,0 +1,923 @@
+const {
+  formatFixedValue
+} = require('./conversions')
+const {
+  clampInteger,
+  createId,
+  normalizeTextValue,
+  padHex
+} = require('./base-utils')
+const {
+  bytesToWords,
+  getByteFromWord,
+  trimTrailingNullBytes,
+  wordsToBytes
+} = require('./binary-utils')
+
+const MAX_MODBUS_ADDRESS = 0xFFFF
+const MAX_GENERIC_MODBUS_ITEMS = 256
+const DEFAULT_TEXT_BYTE_LENGTH = 32
+const MAX_TEXT_BYTE_LENGTH = 32
+const REGISTER_TYPE_OPTIONS = [
+  {
+    functionCode: 0x03,
+    key: 'holding',
+    label: '保持寄存器',
+    writable: true
+  },
+  {
+    functionCode: 0x01,
+    key: 'coil',
+    label: '线圈',
+    writable: true
+  },
+  {
+    functionCode: 0x02,
+    key: 'discrete',
+    label: '离散输入状态',
+    writable: false
+  },
+  {
+    functionCode: 0x04,
+    key: 'input',
+    label: '输入寄存器',
+    writable: false
+  }
+]
+const DATA_TYPE_OPTIONS = [
+  {
+    byteLength: 1,
+    key: 'int8_t',
+    label: 'int8_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 1,
+    key: 'uint8_t',
+    label: 'uint8_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 2,
+    key: 'int16_t',
+    label: 'int16_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 2,
+    key: 'uint16_t',
+    label: 'uint16_t',
+    kind: 'number',
+    wordCount: 1
+  },
+  {
+    byteLength: 4,
+    key: 'int32_t',
+    label: 'int32_t',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 4,
+    key: 'uint32_t',
+    label: 'uint32_t',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 4,
+    key: 'float',
+    label: 'float',
+    kind: 'number',
+    wordCount: 2
+  },
+  {
+    byteLength: 32,
+    key: 'utf8',
+    label: 'UTF-8',
+    kind: 'text',
+    maxByteLength: MAX_TEXT_BYTE_LENGTH,
+    wordCount: 16
+  },
+  {
+    byteLength: 32,
+    key: 'ascii',
+    label: 'ASCII',
+    kind: 'text',
+    maxByteLength: MAX_TEXT_BYTE_LENGTH,
+    wordCount: 16
+  },
+  {
+    byteLength: 2,
+    key: 'hex',
+    label: 'HEX',
+    kind: 'hex',
+    wordCount: 1
+  }
+]
+const DEFAULT_REGISTER_TYPE = REGISTER_TYPE_OPTIONS[0].key
+const DEFAULT_DATA_TYPE = 'uint16_t'
+
+function normalizeAddress(value, fallback = 0) {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? clampInteger(value, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
+  }
+
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return fallback
+
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+  if (/^[0-9A-F]+$/i.test(hexText)) {
+    const parsedHex = parseInt(hexText, 16)
+    return Number.isFinite(parsedHex) ? clampInteger(parsedHex, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
+  }
+
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? clampInteger(numberValue, 0, MAX_MODBUS_ADDRESS, fallback) : fallback
+}
+
+function parseConfigAddress(value) {
+  if (typeof value === 'number') {
+    return clampInteger(value, 0, MAX_MODBUS_ADDRESS, 0)
+  }
+
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+
+  if (!/^[0-9A-F]{1,4}$/i.test(hexText)) {
+    throw new Error('寄存器起始地址无效')
+  }
+
+  return parseInt(hexText, 16)
+}
+
+function parseConfigQuantity(value, maxQuantity) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  const quantity = Number(text)
+
+  if (!Number.isInteger(quantity) || quantity < 1 || quantity > maxQuantity) {
+    throw new Error(`寄存器数量需为 1 - ${maxQuantity}`)
+  }
+
+  return quantity
+}
+
+function getRegisterType(typeKey) {
+  return REGISTER_TYPE_OPTIONS.find((item) => item.key === typeKey) || REGISTER_TYPE_OPTIONS[0]
+}
+
+function getRegisterTypeIndex(typeKey) {
+  return Math.max(0, REGISTER_TYPE_OPTIONS.findIndex((item) => item.key === getRegisterType(typeKey).key))
+}
+
+function getDataType(dataType) {
+  return DATA_TYPE_OPTIONS.find((item) => item.key === dataType)
+    || DATA_TYPE_OPTIONS.find((item) => item.key === DEFAULT_DATA_TYPE)
+    || DATA_TYPE_OPTIONS[0]
+}
+
+function getDataTypeIndex(dataType) {
+  return Math.max(0, DATA_TYPE_OPTIONS.findIndex((item) => item.key === getDataType(dataType).key))
+}
+
+function normalizeTextByteLength(value, fallback = DEFAULT_TEXT_BYTE_LENGTH) {
+  const numberValue = Number(value)
+  const rounded = Number.isFinite(numberValue) ? Math.round(numberValue) : fallback
+
+  return Math.min(Math.max(rounded, 1), MAX_TEXT_BYTE_LENGTH)
+}
+
+function alignEvenByteLength(byteLength) {
+  const length = Math.max(1, Math.round(Number(byteLength) || 1))
+
+  return length % 2 === 0 ? length : length + 1
+}
+
+function getRegisterTextByteLength(register = {}) {
+  return normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+}
+
+function getRegisterByteLength(dataType, register = {}) {
+  const type = getDataType(dataType)
+  if (type.kind === 'text') return alignEvenByteLength(getRegisterTextByteLength(register))
+
+  return type.byteLength || ((type.wordCount || 1) * 2)
+}
+
+function getRegisterWordCount(dataType, register = {}) {
+  return Math.max(1, Math.ceil(getRegisterByteLength(dataType, register) / 2))
+}
+
+function getRegisterWordCountAtOffset(dataType, byteOffset, register = {}) {
+  const byteLength = getRegisterByteLength(dataType, register)
+  return Math.max(1, Math.ceil((byteOffset + byteLength) / 2))
+}
+
+function getEncodeByteLimit(register) {
+  return isTextRegister(register.dataType) ? getRegisterTextByteLength(register) : getRegisterByteLength(register.dataType, register)
+}
+
+function isTextRegister(dataType) {
+  return getDataType(dataType).kind === 'text'
+}
+
+function isByteRegister(dataType) {
+  const key = getDataType(dataType).key
+
+  return key === 'int8_t' || key === 'uint8_t'
+}
+
+function isBitRegisterType(registerType) {
+  return registerType === 'coil' || registerType === 'discrete'
+}
+
+function isHexRegister(dataType) {
+  return getDataType(dataType).key === 'hex'
+}
+
+function isNumericRegister(dataType) {
+  return getDataType(dataType).kind === 'number'
+}
+
+function supportsRange(dataType) {
+  return isNumericRegister(dataType) || isHexRegister(dataType)
+}
+
+function supportsUnit(dataType) {
+  return isNumericRegister(dataType)
+}
+
+function padWordHex(value) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(4, '0')
+}
+
+function formatRawWordText(words = []) {
+  if (!Array.isArray(words) || !words.length) return '--'
+
+  return words.map((word) => `0x${padWordHex(word)}`).join(' ')
+}
+
+function formatAddressRange(startAddress, wordCount) {
+  const address = normalizeAddress(startAddress, 0)
+  const count = Math.max(1, Number(wordCount) || 1)
+  const endAddress = address + count - 1
+  const safeEndAddress = Math.min(endAddress, MAX_MODBUS_ADDRESS)
+  const overflowText = endAddress > MAX_MODBUS_ADDRESS ? '+' : ''
+
+  if (count <= 1) return `0x${padWordHex(address)}`
+
+  return `0x${padWordHex(address)}-0x${padWordHex(safeEndAddress)}${overflowText}`
+}
+
+function formatRegisterAddressText(address, byteOffset, byteLength, registerType) {
+  if (isBitRegisterType(registerType)) return `0x${padHex(address)}`
+  if (byteLength === 1) return `0x${padHex(address)}${byteOffset === 0 ? 'H' : 'L'}`
+
+  return `0x${padHex(address)}`
+}
+
+function isAddressRangeOverflow(startAddress, wordCount) {
+  const address = normalizeAddress(startAddress, 0)
+  const count = Math.max(1, Number(wordCount) || 1)
+
+  return address + count - 1 > MAX_MODBUS_ADDRESS
+}
+
+function encodeAsciiBytes(text, byteLimit = 32) {
+  const bytes = []
+  const stringValue = normalizeTextValue(text)
+
+  for (let index = 0; index < stringValue.length; index += 1) {
+    const code = stringValue.charCodeAt(index)
+    if (code > 0x7F) {
+      throw new Error('ASCII 文本只能包含 0x00 - 0x7F 字符')
+    }
+    bytes.push(code)
+    if (bytes.length > byteLimit) break
+  }
+
+  if (bytes.length > byteLimit) {
+    throw new Error(`长文本最长 ${byteLimit} 字节`)
+  }
+
+  return bytes
+}
+
+function encodeUtf8Bytes(text, byteLimit = 32) {
+  const bytes = []
+  const encoded = encodeURIComponent(normalizeTextValue(text))
+
+  for (let index = 0; index < encoded.length; index += 1) {
+    const char = encoded[index]
+    if (char === '%') {
+      const byte = parseInt(encoded.slice(index + 1, index + 3), 16)
+      if (!Number.isFinite(byte)) break
+      bytes.push(byte & 0xFF)
+      index += 2
+    } else {
+      bytes.push(char.charCodeAt(0) & 0xFF)
+    }
+    if (bytes.length > byteLimit) break
+  }
+
+  if (bytes.length > byteLimit) {
+    throw new Error(`长文本最长 ${byteLimit} 字节`)
+  }
+
+  return bytes
+}
+
+function decodeAsciiBytes(bytes = []) {
+  return String.fromCharCode.apply(null, trimTrailingNullBytes(bytes).map((byte) => byte & 0xFF))
+}
+
+function decodeUtf8Bytes(bytes = []) {
+  const trimmed = trimTrailingNullBytes(bytes)
+  if (!trimmed.length) return ''
+
+  let encoded = ''
+
+  trimmed.forEach((byte) => {
+    encoded += `%${(byte & 0xFF).toString(16).padStart(2, '0').toUpperCase()}`
+  })
+
+  try {
+    return decodeURIComponent(encoded)
+  } catch (error) {
+    return decodeAsciiBytes(trimmed)
+  }
+}
+
+function encodeTextBytes(text, dataType, byteLimit = MAX_TEXT_BYTE_LENGTH) {
+  const normalizedType = getDataType(dataType).key
+
+  if (normalizedType === 'ascii') return encodeAsciiBytes(text, byteLimit)
+  return encodeUtf8Bytes(text, byteLimit)
+}
+
+function decodeTextBytes(bytes, dataType) {
+  const normalizedType = getDataType(dataType).key
+
+  return normalizedType === 'ascii'
+    ? decodeAsciiBytes(bytes)
+    : decodeUtf8Bytes(bytes)
+}
+
+function formatIntegerValue(value, dataType) {
+  const type = getDataType(dataType).key
+  const numberValue = Number(value)
+
+  if (!Number.isFinite(numberValue)) return '--'
+  if (type === 'int8_t') return String(((Math.round(numberValue) << 24) >> 24))
+  if (type === 'uint8_t') return String(Math.round(numberValue) & 0xFF)
+  if (type === 'int16_t') return String(((Math.round(numberValue) << 16) >> 16))
+  if (type === 'uint16_t') return String(Math.round(numberValue) & 0xFFFF)
+  if (type === 'int32_t') return String((Math.round(numberValue) | 0))
+  if (type === 'uint32_t') return String(Math.round(numberValue) >>> 0)
+
+  return String(Math.round(numberValue))
+}
+
+function formatHexValue(value) {
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  return `0x${padWordHex(Math.round(numberValue) & 0xFFFF)}`
+}
+
+function formatFloatValue(value) {
+  return formatFixedValue(value, 6).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
+}
+
+function parseIntegerText(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const isHex = /^[-+]?0x[0-9a-f]+$/i.test(text) || /^0x[0-9a-f]+$/i.test(text)
+  const parsed = isHex ? parseInt(text, 16) : Number(text)
+
+  return Number.isFinite(parsed) ? parsed : null
+}
+
+function parseHexText(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+  if (/^[0-9A-F]{1,4}$/i.test(hexText)) {
+    const parsedHex = parseInt(hexText, 16)
+    return Number.isFinite(parsedHex) ? parsedHex : null
+  }
+
+  return null
+}
+
+function getRegisterValueTypeLabel(dataType) {
+  return getDataType(dataType).label
+}
+
+function getMaxQuantity() {
+  return MAX_GENERIC_MODBUS_ITEMS
+}
+
+function parseCoilValue(value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text || text === '--') return null
+  if (['1', 'true', 'TRUE', 'on', 'ON', '开'].includes(text)) return 1
+  if (['0', 'false', 'FALSE', 'off', 'OFF', '关'].includes(text)) return 0
+
+  const coilValue = Number(text)
+  return Number.isFinite(coilValue) ? (coilValue ? 1 : 0) : null
+}
+
+function getNumericRange(dataType) {
+  const type = getDataType(dataType).key
+
+  if (type === 'int8_t') return { max: 127, min: -128 }
+  if (type === 'uint8_t') return { max: 0xFF, min: 0 }
+  if (type === 'int16_t') return { max: 32767, min: -32768 }
+  if (type === 'uint16_t') return { max: 0xFFFF, min: 0 }
+  if (type === 'int32_t') return { max: 2147483647, min: -2147483648 }
+  if (type === 'uint32_t') return { max: 0xFFFFFFFF, min: 0 }
+  if (type === 'hex') return { max: 0xFFFF, min: 0 }
+
+  return { max: Number.POSITIVE_INFINITY, min: Number.NEGATIVE_INFINITY }
+}
+
+function parseNumberText(value, dataType) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text || text === '--') return null
+
+  if (getDataType(dataType).key === 'float') {
+    const parsed = Number(text)
+    return Number.isFinite(parsed) ? parsed : null
+  }
+  if (isHexRegister(dataType)) return parseHexText(text)
+
+  return parseIntegerText(text)
+}
+
+function parseRangeBoundary(value, dataType, label) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!text) return null
+
+  const parsed = parseNumberText(text, dataType)
+  if (parsed === null) {
+    throw new Error(`${label}无效`)
+  }
+
+  return parsed
+}
+
+function validateNumericValue(register, value) {
+  const dataType = getDataType(register.dataType).key
+  const range = getNumericRange(dataType)
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return false
+
+  if (dataType !== 'float' && Math.round(numberValue) !== numberValue) {
+    throw new Error(`${register.name || '寄存器'} 需要整数`)
+  }
+  if (numberValue < range.min || numberValue > range.max) {
+    throw new Error(`${register.name || '寄存器'} 超出 ${dataType} 范围`)
+  }
+
+  const minValue = parseRangeBoundary(register.minValue, dataType, `${register.name || '寄存器'} 最小值`)
+  const maxValue = parseRangeBoundary(register.maxValue, dataType, `${register.name || '寄存器'} 最大值`)
+  if (minValue !== null && numberValue < minValue) {
+    throw new Error(`${register.name || '寄存器'} 小于限制最小值`)
+  }
+  if (maxValue !== null && numberValue > maxValue) {
+    throw new Error(`${register.name || '寄存器'} 大于限制最大值`)
+  }
+
+  return true
+}
+
+function floatToWords(value) {
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  view.setFloat32(0, Number(value), false)
+
+  return [view.getUint16(0, false), view.getUint16(2, false)]
+}
+
+function wordsToFloat(words) {
+  if (!Array.isArray(words) || words.length < 2) return null
+
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  view.setUint16(0, Number(words[0]) & 0xFFFF, false)
+  view.setUint16(2, Number(words[1]) & 0xFFFF, false)
+
+  return view.getFloat32(0, false)
+}
+
+function encodeRegisterWords(register) {
+  const dataType = getDataType(register.dataType).key
+  const valueText = normalizeTextValue(register.inputValue)
+
+  if (isTextRegister(dataType)) {
+    const byteLimit = getEncodeByteLimit(register)
+    const byteLength = getRegisterByteLength(dataType, register)
+    const bytes = encodeTextBytes(valueText, dataType, byteLimit)
+    const paddedBytes = bytes.slice()
+
+    while (paddedBytes.length < byteLength) {
+      paddedBytes.push(0)
+    }
+
+    return bytesToWords(paddedBytes.slice(0, byteLength))
+  }
+
+  const numberValue = parseNumberText(valueText, dataType)
+  if (numberValue === null) return null
+  validateNumericValue(register, numberValue)
+
+  if (dataType === 'float') return floatToWords(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]
+
+  const unsignedValue = rounded < 0 ? 0x100000000 + rounded : rounded
+  return [
+    Math.floor(unsignedValue / 0x10000) & 0xFFFF,
+    unsignedValue & 0xFFFF
+  ]
+}
+
+function decodeRegisterValue(register, words) {
+  const dataType = getDataType(register.dataType).key
+
+  if (!Array.isArray(words) || words.length < getRegisterWordCount(dataType, register)) return null
+
+  if (isTextRegister(dataType)) {
+    return decodeTextBytes(wordsToBytes(words, getEncodeByteLimit(register)), dataType)
+  }
+  if (dataType === 'float') {
+    return wordsToFloat(words)
+  }
+  if (dataType === 'int8_t') {
+    const byteValue = getByteFromWord(words[0], register.byteOffset)
+    return byteValue & 0x80 ? byteValue - 0x100 : byteValue
+  }
+  if (dataType === 'uint8_t') {
+    return getByteFromWord(words[0], register.byteOffset)
+  }
+  if (dataType === 'int16_t') {
+    const wordValue = Number(words[0]) & 0xFFFF
+    return wordValue & 0x8000 ? wordValue - 0x10000 : wordValue
+  }
+  if (dataType === 'uint16_t') {
+    return Number(words[0]) & 0xFFFF
+  }
+  if (dataType === 'hex') {
+    return Number(words[0]) & 0xFFFF
+  }
+
+  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 unsignedValue
+}
+
+function formatRegisterValue(register, rawValue) {
+  if (rawValue === null || rawValue === undefined) return '--'
+
+  const dataType = getDataType(register.dataType).key
+  if (isTextRegister(dataType)) return normalizeTextValue(rawValue)
+  if (dataType === 'hex') return formatHexValue(rawValue)
+  if (dataType === 'float') return formatFloatValue(rawValue)
+
+  return formatIntegerValue(rawValue, dataType)
+}
+
+function formatCoilDisplayValue(value) {
+  return Number(value) ? '1' : '0'
+}
+
+function getRegisterSavedValue(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
+  if (register.value !== undefined && register.value !== null) return normalizeTextValue(register.value)
+
+  return null
+}
+
+function normalizeRegisterDataType(register, registerType) {
+  if (isBitRegisterType(registerType)) return DEFAULT_DATA_TYPE
+
+  return getDataType(register.dataType || register.type || DEFAULT_DATA_TYPE).key
+}
+
+function normalizeRegister(register, group, index, address, byteOffset = 0) {
+  const registerType = getRegisterType(group.registerType).key
+  const dataType = normalizeRegisterDataType(register, registerType)
+  const textByteLength = isTextRegister(dataType)
+    ? normalizeTextByteLength(register.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+    : ''
+  const defaultValue = normalizeTextValue(register.defaultValue)
+  const savedValue = getRegisterSavedValue(register)
+  const inputValue = savedValue === null ? defaultValue : savedValue
+  const rawValue = register.rawValue === undefined ? null : register.rawValue
+  const byteLength = isBitRegisterType(registerType) ? 1 : getRegisterByteLength(dataType, { textByteLength })
+  const registerCount = isBitRegisterType(registerType) ? 1 : getRegisterWordCountAtOffset(dataType, byteOffset, { textByteLength })
+  const canShowUnit = !isBitRegisterType(registerType) && supportsUnit(dataType)
+  const rawWords = Array.isArray(register.rawWords)
+    ? register.rawWords.slice(0, registerCount).map((word) => Number(word) & 0xFFFF)
+    : []
+  const rawValueText = rawValue === null
+    ? '--'
+    : (isBitRegisterType(registerType) ? formatCoilDisplayValue(rawValue) : formatRawWordText(rawWords))
+  const displayValue = rawValue === null
+    ? (inputValue.trim() ? inputValue : '--')
+    : formatRegisterValue({ ...register, dataType, byteOffset }, rawValue)
+
+  return {
+    address,
+    addressRangeText: isBitRegisterType(registerType)
+      ? `0x${padHex(address)}`
+      : formatAddressRange(address, registerCount),
+    addressText: formatRegisterAddressText(address, byteOffset, byteLength, registerType),
+    byteLength,
+    byteLengthText: isBitRegisterType(registerType)
+      ? '1bit'
+      : (textByteLength && textByteLength !== byteLength ? `${textByteLength}B/占${byteLength}B` : `${byteLength}B`),
+    dataType,
+    dataTypeIndex: getDataTypeIndex(dataType),
+    dataTypeText: getRegisterValueTypeLabel(dataType),
+    defaultValue,
+    displayValue,
+    id: register.id || createId('gm-reg'),
+    inputType: isTextRegister(dataType) ? 'text' : 'text',
+    inputValue,
+    isDirty: !!register.isDirty,
+    maxValue: normalizeTextValue(register.maxValue),
+    minValue: normalizeTextValue(register.minValue),
+    name: register.name || `寄存器 ${index + 1}`,
+    rawValue,
+    rawValueText,
+    rawWords,
+    registerCount,
+    byteOffset,
+    registerType,
+    showDataType: !isBitRegisterType(registerType),
+    showRange: !isBitRegisterType(registerType) && supportsRange(dataType),
+    showTextLength: !isBitRegisterType(registerType) && isTextRegister(dataType),
+    showUnit: canShowUnit,
+    textByteLength,
+    unit: canShowUnit ? normalizeTextValue(register.unit).trim() : '',
+    remark: register.remark || ''
+  }
+}
+
+function normalizeGroup(group) {
+  const registerType = getRegisterType(group.registerType || group.type || DEFAULT_REGISTER_TYPE)
+  const startAddress = normalizeAddress(group.startAddress, 0)
+  const maxQuantity = getMaxQuantity(registerType.key)
+  const sourceRegisters = Array.isArray(group.registers) ? group.registers : []
+  const hasExplicitQuantity = group.quantity !== undefined && group.quantity !== null && group.quantity !== ''
+  const quantity = hasExplicitQuantity
+    ? clampInteger(group.quantity, 1, maxQuantity, 1)
+    : clampInteger(sourceRegisters.length || 1, 1, maxQuantity, 1)
+  const baseGroup = {
+    deleteVisible: !!group.deleteVisible,
+    expanded: group.expanded === true,
+    id: group.id || createId('gm-group'),
+    name: String(group.name || group.groupName || '寄存器组').trim() || '寄存器组',
+    quantity,
+    registerType: registerType.key,
+    startAddress,
+    touchStartX: 0
+  }
+  const registers = []
+  let nextAddress = startAddress
+  let nextByteOffset = 0
+
+  for (let index = 0; index < quantity; index += 1) {
+    const sourceRegister = sourceRegisters[index] || {}
+    const dataType = normalizeRegisterDataType(sourceRegister, baseGroup.registerType)
+    const textByteLength = isTextRegister(dataType)
+      ? normalizeTextByteLength(sourceRegister.textByteLength, DEFAULT_TEXT_BYTE_LENGTH)
+      : ''
+    const isBitRegister = isBitRegisterType(baseGroup.registerType)
+    let address = startAddress + index
+    let byteOffset = 0
+
+    if (!isBitRegister) {
+      const byteLength = getRegisterByteLength(dataType, { textByteLength })
+      if (!isByteRegister(dataType) && nextByteOffset % 2 !== 0) {
+        nextByteOffset += 1
+      }
+
+      address = startAddress + Math.floor(nextByteOffset / 2)
+      byteOffset = nextByteOffset % 2
+      nextByteOffset += byteLength
+    }
+
+    const register = normalizeRegister(sourceRegister, baseGroup, index, address, byteOffset)
+    registers.push(register)
+    if (isBitRegister) nextAddress += register.registerCount
+  }
+
+  const wordQuantity = isBitRegisterType(baseGroup.registerType)
+    ? Math.max(1, nextAddress - startAddress)
+    : Math.max(1, Math.ceil(nextByteOffset / 2))
+  const addressOverflow = isAddressRangeOverflow(startAddress, wordQuantity)
+  const endAddress = startAddress + wordQuantity - 1
+
+  return {
+    ...baseGroup,
+    addressRangeText: formatAddressRange(startAddress, wordQuantity),
+    addressOverflow,
+    addressWarningText: addressOverflow ? '地址超出 0xFFFF' : '',
+    endAddressText: addressOverflow ? `0x${padHex(MAX_MODBUS_ADDRESS)}+` : `0x${padHex(endAddress)}`,
+    functionCode: registerType.functionCode,
+    isReadOnly: !registerType.writable,
+    maxQuantity,
+    registerTypeIndex: getRegisterTypeIndex(registerType.key),
+    registerTypeText: registerType.label,
+    registers,
+    startAddressText: `0x${padHex(startAddress)}`,
+    wordQuantity,
+    writable: registerType.writable
+  }
+}
+
+function normalizeGroupConfig(config = {}) {
+  const registerType = config.registerTypeIndex !== undefined && config.registerTypeIndex !== null
+    ? (REGISTER_TYPE_OPTIONS[Number(config.registerTypeIndex)] || REGISTER_TYPE_OPTIONS[0])
+    : getRegisterType(config.registerType || config.type || DEFAULT_REGISTER_TYPE)
+  const maxQuantity = getMaxQuantity(registerType.key)
+
+  return {
+    name: String(config.name || config.groupName || '寄存器组').trim() || '寄存器组',
+    quantity: parseConfigQuantity(config.quantity, maxQuantity),
+    registerType: registerType.key,
+    startAddress: parseConfigAddress(config.startAddress)
+  }
+}
+
+function getRegisterJsonValue(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) {
+    return normalizeTextValue(register.inputValue)
+  }
+
+  if (register.defaultValue !== undefined && register.defaultValue !== null) {
+    return normalizeTextValue(register.defaultValue)
+  }
+
+  if (register.displayValue !== undefined && register.displayValue !== null && register.displayValue !== '--') {
+    return normalizeTextValue(register.displayValue)
+  }
+
+  return ''
+}
+
+function normalizeImportedRegisterDataType(register) {
+  const dataType = register.dataType || register.type || DEFAULT_DATA_TYPE
+
+  return getDataType(dataType).key
+}
+
+function cloneImportedGroup(group) {
+  return {
+    name: group.name,
+    quantity: group.quantity,
+    registerType: group.registerType || group.type || DEFAULT_REGISTER_TYPE,
+    registers: (Array.isArray(group.registers) ? group.registers : []).map((register) => ({
+      dataType: normalizeImportedRegisterDataType(register),
+      defaultValue: register.defaultValue,
+      inputValue: register.inputValue,
+      maxValue: register.maxValue,
+      minValue: register.minValue,
+      name: register.name,
+      textByteLength: register.textByteLength,
+      remark: register.remark,
+      unit: register.unit,
+      value: register.value
+    })),
+    startAddress: group.startAddress
+  }
+}
+
+function splitWordSpans(startAddress, quantity, maxQuantity) {
+  const spans = []
+  let address = normalizeAddress(startAddress, 0)
+  let remaining = Math.max(0, Math.floor(Number(quantity) || 0))
+
+  while (remaining > 0) {
+    const spanQuantity = Math.min(remaining, maxQuantity)
+
+    spans.push({
+      address,
+      quantity: spanQuantity
+    })
+
+    address += spanQuantity
+    remaining -= spanQuantity
+  }
+
+  return spans
+}
+
+function getRegisterWriteValueText(register) {
+  if (register.inputValue !== undefined && register.inputValue !== null) return normalizeTextValue(register.inputValue)
+  if (register.defaultValue !== undefined && register.defaultValue !== null) return normalizeTextValue(register.defaultValue)
+
+  return ''
+}
+
+function getRegisterWordsFromWordCache(register, wordCache) {
+  const words = []
+  for (let offset = 0; offset < register.registerCount; offset += 1) {
+    const word = wordCache[register.address + offset]
+    if (word === undefined) return null
+    words.push(word)
+  }
+
+  return words
+}
+
+function decodeRegisterFromWordCache(register, wordCache) {
+  const words = getRegisterWordsFromWordCache(register, wordCache)
+  if (!words) return null
+
+  return decodeRegisterValue(register, words)
+}
+
+function getRegisterEncodedWords(register) {
+  return encodeRegisterWords({
+    ...register,
+    inputValue: getRegisterWriteValueText(register)
+  })
+}
+
+function validateRegisterValue(register, value) {
+  const valueText = normalizeTextValue(
+    value === undefined || value === null ? getRegisterWriteValueText(register) : value
+  ).trim()
+  if (!valueText || valueText === '--') return true
+
+  if (registerTypeIsBit(register)) {
+    if (parseCoilValue(valueText) === null) {
+      throw new Error(`${register.name || '线圈'} 只能填写 0 或 1`)
+    }
+    return true
+  }
+
+  const dataType = getDataType(register.dataType).key
+  if (isTextRegister(dataType)) {
+    encodeTextBytes(valueText, dataType, getEncodeByteLimit(register))
+    return true
+  }
+
+  const numberValue = parseNumberText(valueText, dataType)
+  if (numberValue === null) {
+    throw new Error(`${register.name || '寄存器'} 输入值无效`)
+  }
+
+  return validateNumericValue(register, numberValue)
+}
+
+function registerTypeIsBit(register) {
+  return !!register && isBitRegisterType(register.registerType)
+}
+
+module.exports = {
+  DATA_TYPE_OPTIONS,
+  DEFAULT_DATA_TYPE,
+  DEFAULT_REGISTER_TYPE,
+  MAX_MODBUS_ADDRESS,
+  REGISTER_TYPE_OPTIONS,
+  cloneImportedGroup,
+  decodeRegisterFromWordCache,
+  decodeRegisterValue,
+  formatCoilDisplayValue,
+  formatRegisterValue,
+  getDataType,
+  getRegisterEncodedWords,
+  getRegisterJsonValue,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  isAddressRangeOverflow,
+  isBitRegisterType,
+  isByteRegister,
+  normalizeGroup,
+  normalizeGroupConfig,
+  parseCoilValue,
+  registerTypeIsBit,
+  splitWordSpans,
+  validateRegisterValue
+}

+ 76 - 0
utils/generic-modbus-poller.js

@@ -0,0 +1,76 @@
+const genericModbusService = require('./generic-modbus-service')
+
+const POLL_TIMER_ID = '__genericPoll'
+
+function shouldPoll(data) {
+  return !!data
+    && data.activeParamView === 'genericModbus'
+    && !!data.connectedDevice
+    && !!data.genericModbusAutoPollEnabled
+}
+
+function getPollableGroups(data) {
+  return (data.genericModbusGroups || []).filter((group) => group.isReadOnly && !group.addressOverflow)
+}
+
+function createGenericModbusPoller(getData) {
+  const timers = {}
+
+  function clearTimer(timerId) {
+    if (!timers[timerId]) return
+
+    clearTimeout(timers[timerId])
+    delete timers[timerId]
+  }
+
+  function clearAll() {
+    Object.keys(timers).forEach(clearTimer)
+  }
+
+  function schedule(delay) {
+    clearTimer(POLL_TIMER_ID)
+    timers[POLL_TIMER_ID] = setTimeout(async () => {
+      const data = getData()
+      if (!shouldPoll(data)) {
+        clearTimer(POLL_TIMER_ID)
+        return
+      }
+
+      for (const group of getPollableGroups(data)) {
+        const latestData = getData()
+        if (!shouldPoll(latestData)) break
+
+        await genericModbusService.readGroup(group.id, {
+          maxPacketLength: latestData.genericModbusMaxPacketLength,
+          showModal: false
+        })
+      }
+
+      const latestData = getData()
+      if (shouldPoll(latestData)) {
+        schedule(latestData.genericModbusPollInterval || 100)
+      }
+    }, delay)
+  }
+
+  function scheduleVisible() {
+    const data = getData()
+    if (!shouldPoll(data)) {
+      clearAll()
+      return
+    }
+
+    schedule(data.genericModbusPollInterval || 100)
+  }
+
+  return {
+    clearAll,
+    clearTimer,
+    schedule,
+    scheduleVisible
+  }
+}
+
+module.exports = {
+  createGenericModbusPoller
+}

+ 622 - 0
utils/generic-modbus-service.js

@@ -0,0 +1,622 @@
+const {
+  formatExportStamp,
+  isCancelError,
+  loadSelectedFile,
+  saveTextFileToChat
+} = require('./file-service')
+const {
+  getWxApi
+} = require('./platform-utils')
+const {
+  parseHexInteger
+} = require('./base-utils')
+const transport = require('./ble-transport')
+const settingsService = require('./settings-service')
+const modbusAccess = require('./modbus-access')
+const {
+  DATA_TYPE_OPTIONS,
+  REGISTER_TYPE_OPTIONS,
+  cloneImportedGroup,
+  decodeRegisterFromWordCache,
+  decodeRegisterValue,
+  formatCoilDisplayValue,
+  formatRegisterValue,
+  getDataType,
+  getRegisterEncodedWords,
+  getRegisterJsonValue,
+  getRegisterWordsFromWordCache,
+  getRegisterWriteValueText,
+  isAddressRangeOverflow,
+  isBitRegisterType,
+  isByteRegister,
+  normalizeGroup,
+  normalizeGroupConfig,
+  parseCoilValue,
+  registerTypeIsBit,
+  splitWordSpans,
+  validateRegisterValue
+} = require('./generic-modbus-model')
+
+const STORAGE_KEY = 'generic-modbus-groups-json'
+const JSON_DOCUMENT_TYPE = 'generic-modbus-rtu'
+const JSON_SCHEMA_VERSION = 2
+
+let initialized = false
+const subscribers = []
+let state = {
+  genericModbusDataTypeOptions: DATA_TYPE_OPTIONS,
+  genericModbusGroups: [],
+  genericModbusRegisterTypeOptions: REGISTER_TYPE_OPTIONS
+}
+
+function notify() {
+  const nextState = getState()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(nextState)
+  })
+}
+
+function setState(changedData, options = {}) {
+  state = {
+    ...state,
+    ...changedData
+  }
+
+  if (options.persist !== false) persistGroups()
+  notify()
+}
+
+function resolveMaxPacketLength(value) {
+  const settings = settingsService.getState()
+  const numberValue = Number(value === undefined ? settings.genericModbusMaxPacketLength : value)
+  if (Number.isFinite(numberValue) && Math.round(numberValue) === 0) return 0
+  if (Number.isFinite(numberValue) && numberValue > 0) return Math.round(numberValue)
+
+  return 64
+}
+
+function getWriteSpanMaxQuantity(totalQuantity, maxPacketLength) {
+  if (maxPacketLength === 0) return Math.max(1, totalQuantity)
+
+  return Math.max(1, modbusAccess.getMaxWriteMultipleRegisterQuantity(maxPacketLength))
+}
+
+function toPersistedGroups(groups) {
+  return groups.map((group) => ({
+    name: group.name,
+    registerType: group.registerType,
+    startAddress: group.startAddress,
+    quantity: group.quantity,
+    registers: group.registers.map((register) => ({
+      dataType: register.dataType,
+      defaultValue: register.defaultValue,
+      name: register.name,
+      maxValue: register.maxValue,
+      minValue: register.minValue,
+      textByteLength: register.textByteLength,
+      remark: register.remark,
+      unit: register.unit,
+      value: getRegisterJsonValue(register)
+    }))
+  }))
+}
+
+function toJsonData(groups = state.genericModbusGroups, options = {}) {
+  const jsonData = {
+    groups: toPersistedGroups(groups),
+    type: JSON_DOCUMENT_TYPE,
+    version: JSON_SCHEMA_VERSION
+  }
+
+  if (options.includeExportedAt) {
+    jsonData.exportedAt = new Date().toISOString()
+  }
+
+  return jsonData
+}
+
+function toJsonText(groups = state.genericModbusGroups, options = {}) {
+  return JSON.stringify(toJsonData(groups, options), null, 2)
+}
+
+function parseJsonGroups(jsonText) {
+  const parsed = typeof jsonText === 'string' ? JSON.parse(jsonText) : jsonText
+  const groups = Array.isArray(parsed)
+    ? parsed
+    : (Array.isArray(parsed && parsed.groups) ? parsed.groups : parsed && parsed.genericModbusGroups)
+
+  if (parsed && parsed.type && parsed.type !== JSON_DOCUMENT_TYPE) {
+    throw new Error('JSON 文件不是通用Modbus配置')
+  }
+
+  if (parsed && parsed.version && parsed.version !== JSON_SCHEMA_VERSION) {
+    throw new Error('JSON 版本不兼容')
+  }
+
+  if (!Array.isArray(groups)) {
+    throw new Error('JSON 中没有找到寄存器组数组')
+  }
+
+  return groups
+}
+
+function readStoredGroups() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.getStorageSync !== 'function') return []
+
+  try {
+    const jsonText = wxApi.getStorageSync(STORAGE_KEY)
+    if (jsonText) return parseJsonGroups(jsonText).map(cloneImportedGroup)
+  } catch (error) {
+    return []
+  }
+
+  return []
+}
+
+function persistGroups() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.setStorageSync !== 'function') return
+
+  try {
+    wxApi.setStorageSync(STORAGE_KEY, toJsonText())
+  } catch (error) {}
+}
+
+function init() {
+  if (initialized) return
+
+  state = {
+    ...state,
+    genericModbusGroups: readStoredGroups().map(normalizeGroup)
+  }
+  initialized = true
+}
+
+function getState() {
+  return {
+    ...state,
+    genericModbusDataTypeOptions: DATA_TYPE_OPTIONS,
+    genericModbusRegisterTypeOptions: REGISTER_TYPE_OPTIONS
+  }
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  init()
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+function getShareFileName() {
+  return `generic-modbus-rtu-${formatExportStamp()}.json`
+}
+
+async function importJsonFromMessageFile() {
+  try {
+    const file = await loadSelectedFile('message', {
+      encoding: 'utf8',
+      extensionMessage: '请选择 .json 寄存器配置文件',
+      extensions: ['json'],
+      fallbackName: 'generic-modbus.json'
+    })
+    const jsonText = file.text
+    const importedGroups = parseJsonGroups(jsonText).map(cloneImportedGroup).map(normalizeGroup)
+    if (!importedGroups.length) throw new Error('JSON 中没有可导入的寄存器组')
+
+    setState({
+      genericModbusGroups: state.genericModbusGroups.concat(importedGroups)
+    })
+
+    return importedGroups.length
+  } catch (error) {
+    const message = error && error.message ? error.message : '导入通用Modbus配置失败'
+    transport.showCommandAlert('通用Modbus导入', message)
+    return 0
+  }
+}
+
+async function saveJsonToChat() {
+  try {
+    if (!state.genericModbusGroups.length) {
+      throw new Error('没有可保存的寄存器组')
+    }
+
+    const jsonText = toJsonText(state.genericModbusGroups, {
+      includeExportedAt: true
+    })
+
+    await saveTextFileToChat(getShareFileName(), jsonText)
+
+    return state.genericModbusGroups.length
+  } catch (error) {
+    const message = error && error.message ? error.message : '保存通用Modbus配置失败'
+
+    if (!isCancelError(error)) {
+      transport.showCommandAlert('通用Modbus保存', message)
+    }
+
+    return 0
+  }
+}
+
+function addGroupFromConfig(config = {}) {
+  let groupConfig
+
+  try {
+    groupConfig = normalizeGroupConfig(config)
+  } catch (error) {
+    transport.showCommandAlert('通用Modbus添加', error.message || '寄存器组配置无效')
+    return null
+  }
+
+  if (isAddressRangeOverflow(groupConfig.startAddress, groupConfig.quantity)) {
+    transport.showCommandAlert('通用Modbus添加', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  const group = normalizeGroup({
+    ...groupConfig,
+    expanded: false
+  })
+
+  if (group.addressOverflow) {
+    transport.showCommandAlert('通用Modbus添加', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  setState({
+    genericModbusGroups: state.genericModbusGroups.concat(group)
+  })
+
+  return group
+}
+
+function updateGroupConfig(groupId, config = {}) {
+  const group = findGroup(groupId)
+  if (!group) return null
+
+  let nextConfig
+  try {
+    nextConfig = normalizeGroupConfig({
+      ...group,
+      ...config
+    })
+  } catch (error) {
+    transport.showCommandAlert('通用Modbus更新', error.message || '寄存器组配置无效')
+    return null
+  }
+
+  if (isAddressRangeOverflow(nextConfig.startAddress, nextConfig.quantity)) {
+    transport.showCommandAlert('通用Modbus更新', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  const updatedGroup = normalizeGroup({
+    ...group,
+    ...nextConfig
+  })
+
+  if (updatedGroup.addressOverflow) {
+    transport.showCommandAlert('通用Modbus更新', '地址范围超出 0xFFFF')
+    return null
+  }
+
+  setState({
+    genericModbusGroups: state.genericModbusGroups.map((item) => (
+      item.id === groupId ? updatedGroup : item
+    ))
+  })
+
+  return updatedGroup
+}
+
+function updateGroups(mapper) {
+  setState({
+    genericModbusGroups: state.genericModbusGroups.map((group, index) => normalizeGroup(mapper(group, index)))
+  })
+}
+
+function findGroup(groupId) {
+  return state.genericModbusGroups.find((group) => group.id === groupId)
+}
+
+function setGroupExpanded(groupId, expanded) {
+  updateGroups((group) => group.id === groupId
+    ? {
+      ...group,
+      deleteVisible: false,
+      expanded
+    }
+    : group)
+}
+
+function setGroupDeleteVisible(groupId, deleteVisible) {
+  updateGroups((group) => group.id === groupId
+    ? {
+      ...group,
+      deleteVisible
+    }
+    : group)
+}
+
+function removeGroup(groupId) {
+  setState({
+    genericModbusGroups: state.genericModbusGroups.filter((group) => group.id !== groupId)
+  })
+}
+
+function updateRegister(groupId, registerIndex, changedData) {
+  updateGroups((group) => {
+    if (group.id !== groupId) return group
+    const shouldResetReadState = Object.prototype.hasOwnProperty.call(changedData, 'dataType')
+      || Object.prototype.hasOwnProperty.call(changedData, 'textByteLength')
+
+    return {
+      ...group,
+      registers: group.registers.map((register, currentIndex) => (
+        currentIndex === registerIndex
+          ? {
+            ...register,
+            ...(shouldResetReadState ? { rawValue: null, rawWords: [] } : {}),
+            ...changedData
+          }
+          : register
+      ))
+    }
+  })
+}
+
+function updateRegisterValue(groupId, registerIndex, value) {
+  updateRegister(groupId, registerIndex, {
+    inputValue: value,
+    isDirty: true
+  })
+}
+
+function validateRegisterInputValue(groupId, registerIndex, value) {
+  const group = findGroup(groupId)
+  if (!group) return false
+
+  const register = group.registers[registerIndex]
+  if (!register) return false
+
+  return validateRegisterValue(register, value)
+}
+
+async function readGroup(groupId, options = {}) {
+  const group = findGroup(groupId)
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  if (!group || slaveAddress === null) return false
+  if (group.addressOverflow) {
+    transport.showCommandAlert('通用Modbus读取', '寄存器地址范围超出 0xFFFF')
+    return false
+  }
+
+  const totalQuantity = Math.max(1, group.wordQuantity || group.quantity || 0)
+  const maxPacketLength = resolveMaxPacketLength(options.maxPacketLength)
+  const wordCache = {}
+
+  const values = await modbusAccess.readSpans(
+    slaveAddress,
+    group.functionCode,
+    [{
+      address: group.startAddress,
+      quantity: totalQuantity
+    }],
+    group.name || '通用Modbus读取',
+    'generic-modbus-read',
+    {
+      maxFrameBytes: maxPacketLength,
+      showModal: options.showModal !== false
+    }
+  )
+  if (!values) return false
+
+  if (isBitRegisterType(group.registerType)) {
+    Object.keys(values.coils || {}).forEach((addressText) => {
+      wordCache[parseHexInteger(addressText)] = Number(values.coils[addressText]) ? 1 : 0
+    })
+  } else {
+    Object.keys(values.words || {}).forEach((addressText) => {
+      wordCache[parseHexInteger(addressText)] = Number(values.words[addressText]) & 0xFFFF
+    })
+  }
+
+  updateGroups((item) => {
+    if (item.id !== groupId) return item
+
+    const nextRegisters = item.registers.map((register) => {
+      const rawWords = registerTypeIsBit(register) ? [] : getRegisterWordsFromWordCache(register, wordCache)
+      const rawValue = registerTypeIsBit(register)
+        ? decodeRegisterFromWordCache(register, wordCache)
+        : (rawWords ? decodeRegisterValue(register, rawWords) : null)
+      const displayValue = rawValue === null || rawValue === undefined
+        ? '--'
+        : (registerTypeIsBit(register)
+          ? formatCoilDisplayValue(rawValue)
+          : formatRegisterValue(register, rawValue))
+
+      return {
+        ...register,
+        displayValue,
+        inputValue: item.writable ? displayValue : register.inputValue,
+        isDirty: false,
+        rawValue,
+        rawWords: rawWords || []
+      }
+    })
+
+    return {
+      ...item,
+      registers: nextRegisters
+    }
+  })
+
+  return true
+}
+
+async function writeGroup(groupId) {
+  const group = findGroup(groupId)
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  const maxPacketLength = resolveMaxPacketLength()
+  if (!group || slaveAddress === null) return false
+  if (!group.writable) {
+    transport.showCommandAlert('通用Modbus写入', '当前寄存器组为只读')
+    return false
+  }
+  if (group.addressOverflow) {
+    transport.showCommandAlert('通用Modbus写入', '寄存器地址范围超出 0xFFFF')
+    return false
+  }
+
+  const writtenRegisters = []
+
+  if (group.registerType === 'coil') {
+    for (let index = 0; index < group.registers.length; index += 1) {
+      const register = group.registers[index]
+      const coilValue = parseCoilValue(getRegisterWriteValueText(register))
+
+      if (coilValue === null) {
+        transport.showCommandAlert('通用Modbus写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
+        return false
+      }
+
+      const response = await modbusAccess.writeSingleCoil(
+        slaveAddress,
+        group.startAddress + index,
+        !!coilValue,
+        register.name || group.name || '通用Modbus写入',
+        'generic-modbus-coil-write',
+        {
+          maxFrameBytes: maxPacketLength
+        }
+      )
+      if (!response) return false
+
+      writtenRegisters.push({
+        rawValue: coilValue,
+        rawWords: [],
+        displayValue: formatCoilDisplayValue(coilValue)
+      })
+    }
+  } 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)
+
+      if (!Array.isArray(registerWords) || !registerWords.length) {
+        transport.showCommandAlert('通用Modbus写入', `${register.name || `寄存器 ${index + 1}`} 没有有效写入值`)
+        return false
+      }
+
+      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
+        }
+      }
+    }
+
+    const writtenWordCache = words.reduce((cache, word, offset) => {
+      cache[group.startAddress + offset] = word
+      return cache
+    }, {})
+
+    group.registers.forEach((register) => {
+      const rawWords = getRegisterWordsFromWordCache(register, writtenWordCache) || []
+      const rawValue = decodeRegisterValue(register, rawWords)
+      const displayValue = formatRegisterValue(register, rawValue)
+
+      writtenRegisters.push({
+        rawWords,
+        rawValue,
+        displayValue
+      })
+    })
+
+    const maxWriteQuantity = getWriteSpanMaxQuantity(words.length, maxPacketLength)
+    const spans = splitWordSpans(group.startAddress, words.length, maxWriteQuantity)
+    let cursor = 0
+
+    for (const span of spans) {
+      const spanWords = words.slice(cursor, cursor + span.quantity)
+      cursor += span.quantity
+
+      const response = await modbusAccess.writeMultipleRegisters(
+        slaveAddress,
+        span.address,
+        spanWords,
+        group.name || '通用Modbus写入',
+        'generic-modbus-write',
+        {
+          maxFrameBytes: maxPacketLength
+        }
+      )
+      if (!response) return false
+    }
+  }
+
+  updateGroups((item) => {
+    if (item.id !== groupId) return item
+
+    let writtenIndex = 0
+
+    return {
+      ...item,
+      registers: item.registers.map((register) => {
+        const written = writtenRegisters[writtenIndex] || {}
+        writtenIndex += 1
+        const hasDisplayValue = Object.prototype.hasOwnProperty.call(written, 'displayValue')
+        const hasRawValue = Object.prototype.hasOwnProperty.call(written, 'rawValue')
+        const hasRawWords = Object.prototype.hasOwnProperty.call(written, 'rawWords')
+
+        return {
+          ...register,
+          displayValue: hasDisplayValue ? written.displayValue : register.displayValue,
+          inputValue: hasDisplayValue ? written.displayValue : register.inputValue,
+          isDirty: false,
+          rawValue: hasRawValue ? written.rawValue : register.rawValue,
+          rawWords: hasRawWords ? written.rawWords : register.rawWords
+        }
+      })
+    }
+  })
+
+  return true
+}
+
+module.exports = {
+  DATA_TYPE_OPTIONS,
+  REGISTER_TYPE_OPTIONS,
+  addGroupFromConfig,
+  getState,
+  importJsonFromMessageFile,
+  init,
+  readGroup,
+  removeGroup,
+  saveJsonToChat,
+  setGroupDeleteVisible,
+  setGroupExpanded,
+  subscribe,
+  updateGroupConfig,
+  updateRegister,
+  updateRegisterValue,
+  validateRegisterInputValue,
+  writeGroup
+}

+ 533 - 0
utils/hash.js

@@ -0,0 +1,533 @@
+const {
+  bytesToBase64,
+  bytesToBin,
+  bytesToHex,
+  stringToUtf8Bytes,
+  toByteArray
+} = require('./binary-utils')
+
+const MASK_64 = 0xFFFFFFFFFFFFFFFFn
+
+const HASH_ALGORITHM_PRESETS = [
+  { key: 'md5', label: 'MD5', kind: 'hash', hash: 'md5' },
+  { key: 'sha1', label: 'SHA1', kind: 'hash', hash: 'sha1' },
+  { key: 'sha224', label: 'SHA224', kind: 'hash', hash: 'sha224' },
+  { key: 'sha256', label: 'SHA256', kind: 'hash', hash: 'sha256' },
+  { key: 'sha384', label: 'SHA384', kind: 'hash', hash: 'sha384' },
+  { key: 'sha512', label: 'SHA512', kind: 'hash', hash: 'sha512' },
+  { key: 'hmac-md5', label: 'HMAC-MD5', kind: 'hmac', hash: 'md5' },
+  { key: 'hmac-sha1', label: 'HMAC-SHA1', kind: 'hmac', hash: 'sha1' },
+  { key: 'hmac-sha224', label: 'HMAC-SHA224', kind: 'hmac', hash: 'sha224' },
+  { key: 'hmac-sha256', label: 'HMAC-SHA256', kind: 'hmac', hash: 'sha256' },
+  { key: 'hmac-sha384', label: 'HMAC-SHA384', kind: 'hmac', hash: 'sha384' },
+  { key: 'hmac-sha512', label: 'HMAC-SHA512', kind: 'hmac', hash: 'sha512' },
+  { key: 'pbkdf2', label: 'PBKDF2', kind: 'pbkdf2', hash: 'sha256' }
+]
+
+const SHA256_K = [
+  0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5,
+  0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
+  0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3,
+  0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
+  0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC,
+  0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
+  0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7,
+  0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
+  0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13,
+  0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
+  0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3,
+  0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
+  0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5,
+  0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
+  0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208,
+  0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2
+]
+
+const SHA512_K = [
+  0x428A2F98D728AE22n, 0x7137449123EF65CDn, 0xB5C0FBCFEC4D3B2Fn, 0xE9B5DBA58189DBBCn,
+  0x3956C25BF348B538n, 0x59F111F1B605D019n, 0x923F82A4AF194F9Bn, 0xAB1C5ED5DA6D8118n,
+  0xD807AA98A3030242n, 0x12835B0145706FBEn, 0x243185BE4EE4B28Cn, 0x550C7DC3D5FFB4E2n,
+  0x72BE5D74F27B896Fn, 0x80DEB1FE3B1696B1n, 0x9BDC06A725C71235n, 0xC19BF174CF692694n,
+  0xE49B69C19EF14AD2n, 0xEFBE4786384F25E3n, 0x0FC19DC68B8CD5B5n, 0x240CA1CC77AC9C65n,
+  0x2DE92C6F592B0275n, 0x4A7484AA6EA6E483n, 0x5CB0A9DCBD41FBD4n, 0x76F988DA831153B5n,
+  0x983E5152EE66DFABn, 0xA831C66D2DB43210n, 0xB00327C898FB213Fn, 0xBF597FC7BEEF0EE4n,
+  0xC6E00BF33DA88FC2n, 0xD5A79147930AA725n, 0x06CA6351E003826Fn, 0x142929670A0E6E70n,
+  0x27B70A8546D22FFCn, 0x2E1B21385C26C926n, 0x4D2C6DFC5AC42AEDn, 0x53380D139D95B3DFn,
+  0x650A73548BAF63DEn, 0x766A0ABB3C77B2A8n, 0x81C2C92E47EDAEE6n, 0x92722C851482353Bn,
+  0xA2BFE8A14CF10364n, 0xA81A664BBC423001n, 0xC24B8B70D0F89791n, 0xC76C51A30654BE30n,
+  0xD192E819D6EF5218n, 0xD69906245565A910n, 0xF40E35855771202An, 0x106AA07032BBD1B8n,
+  0x19A4C116B8D2D0C8n, 0x1E376C085141AB53n, 0x2748774CDF8EEB99n, 0x34B0BCB5E19B48A8n,
+  0x391C0CB3C5C95A63n, 0x4ED8AA4AE3418ACBn, 0x5B9CCA4F7763E373n, 0x682E6FF3D6B2B8A3n,
+  0x748F82EE5DEFB2FCn, 0x78A5636F43172F60n, 0x84C87814A1F0AB72n, 0x8CC702081A6439ECn,
+  0x90BEFFFA23631E28n, 0xA4506CEBDE82BDE9n, 0xBEF9A3F7B2C67915n, 0xC67178F2E372532Bn,
+  0xCA273ECEEA26619Cn, 0xD186B8C721C0C207n, 0xEADA7DD6CDE0EB1En, 0xF57D4F7FEE6ED178n,
+  0x06F067AA72176FBAn, 0x0A637DC5A2C898A6n, 0x113F9804BEF90DAEn, 0x1B710B35131C471Bn,
+  0x28DB77F523047D84n, 0x32CAAB7B40C72493n, 0x3C9EBE0A15C9BEBCn, 0x431D67C49C100D4Cn,
+  0x4CC5D4BECB3E42B6n, 0x597F299CFC657E2An, 0x5FCB6FAB3AD6FAECn, 0x6C44198C4A475817n
+]
+
+function add32(...values) {
+  return values.reduce((sum, value) => (sum + (value >>> 0)) >>> 0, 0)
+}
+
+function rotl32(value, bits) {
+  return ((value << bits) | (value >>> (32 - bits))) >>> 0
+}
+
+function rotr32(value, bits) {
+  return ((value >>> bits) | (value << (32 - bits))) >>> 0
+}
+
+function writeWord32BE(value, output) {
+  output.push((value >>> 24) & 0xFF, (value >>> 16) & 0xFF, (value >>> 8) & 0xFF, value & 0xFF)
+}
+
+function writeWord32LE(value, output) {
+  output.push(value & 0xFF, (value >>> 8) & 0xFF, (value >>> 16) & 0xFF, (value >>> 24) & 0xFF)
+}
+
+function padBlock64BE(bytes) {
+  const message = toByteArray(bytes)
+  const bitLength = BigInt(message.length) * 8n
+  const padded = message.slice()
+
+  padded.push(0x80)
+  while (padded.length % 64 !== 56) padded.push(0)
+  for (let shift = 56; shift >= 0; shift -= 8) {
+    padded.push(Number((bitLength >> BigInt(shift)) & 0xFFn))
+  }
+
+  return padded
+}
+
+function md5(bytes) {
+  const shifts = [
+    7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
+    5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
+    4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
+    6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
+  ]
+  const constants = []
+  const message = toByteArray(bytes)
+  const bitLength = BigInt(message.length) * 8n
+  const padded = message.slice()
+
+  for (let index = 0; index < 64; index += 1) {
+    constants[index] = Math.floor(Math.abs(Math.sin(index + 1)) * 0x100000000) >>> 0
+  }
+
+  padded.push(0x80)
+  while (padded.length % 64 !== 56) padded.push(0)
+  for (let shift = 0; shift < 64; shift += 8) {
+    padded.push(Number((bitLength >> BigInt(shift)) & 0xFFn))
+  }
+
+  let a0 = 0x67452301
+  let b0 = 0xEFCDAB89
+  let c0 = 0x98BADCFE
+  let d0 = 0x10325476
+
+  for (let offset = 0; offset < padded.length; offset += 64) {
+    const words = []
+    for (let index = 0; index < 16; index += 1) {
+      const base = offset + index * 4
+      words[index] = (
+        (padded[base] & 0xFF) |
+        ((padded[base + 1] & 0xFF) << 8) |
+        ((padded[base + 2] & 0xFF) << 16) |
+        ((padded[base + 3] & 0xFF) << 24)
+      ) >>> 0
+    }
+
+    let a = a0
+    let b = b0
+    let c = c0
+    let d = d0
+
+    for (let index = 0; index < 64; index += 1) {
+      let f
+      let g
+
+      if (index < 16) {
+        f = (b & c) | ((~b) & d)
+        g = index
+      } else if (index < 32) {
+        f = (d & b) | ((~d) & c)
+        g = (5 * index + 1) % 16
+      } else if (index < 48) {
+        f = b ^ c ^ d
+        g = (3 * index + 5) % 16
+      } else {
+        f = c ^ (b | (~d))
+        g = (7 * index) % 16
+      }
+
+      const next = d
+      d = c
+      c = b
+      b = add32(b, rotl32(add32(a, f, constants[index], words[g]), shifts[index]))
+      a = next
+    }
+
+    a0 = add32(a0, a)
+    b0 = add32(b0, b)
+    c0 = add32(c0, c)
+    d0 = add32(d0, d)
+  }
+
+  const output = []
+  ;[a0, b0, c0, d0].forEach((word) => writeWord32LE(word, output))
+  return output
+}
+
+function sha1(bytes) {
+  const padded = padBlock64BE(bytes)
+  const words = []
+  let h0 = 0x67452301
+  let h1 = 0xEFCDAB89
+  let h2 = 0x98BADCFE
+  let h3 = 0x10325476
+  let h4 = 0xC3D2E1F0
+
+  for (let offset = 0; offset < padded.length; offset += 64) {
+    for (let index = 0; index < 16; index += 1) {
+      const base = offset + index * 4
+      words[index] = (
+        ((padded[base] & 0xFF) << 24) |
+        ((padded[base + 1] & 0xFF) << 16) |
+        ((padded[base + 2] & 0xFF) << 8) |
+        (padded[base + 3] & 0xFF)
+      ) >>> 0
+    }
+    for (let index = 16; index < 80; index += 1) {
+      words[index] = rotl32(words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16], 1)
+    }
+
+    let a = h0
+    let b = h1
+    let c = h2
+    let d = h3
+    let e = h4
+
+    for (let index = 0; index < 80; index += 1) {
+      let f
+      let k
+
+      if (index < 20) {
+        f = (b & c) | ((~b) & d)
+        k = 0x5A827999
+      } else if (index < 40) {
+        f = b ^ c ^ d
+        k = 0x6ED9EBA1
+      } else if (index < 60) {
+        f = (b & c) | (b & d) | (c & d)
+        k = 0x8F1BBCDC
+      } else {
+        f = b ^ c ^ d
+        k = 0xCA62C1D6
+      }
+
+      const temp = add32(rotl32(a, 5), f, e, k, words[index])
+      e = d
+      d = c
+      c = rotl32(b, 30)
+      b = a
+      a = temp
+    }
+
+    h0 = add32(h0, a)
+    h1 = add32(h1, b)
+    h2 = add32(h2, c)
+    h3 = add32(h3, d)
+    h4 = add32(h4, e)
+  }
+
+  const output = []
+  ;[h0, h1, h2, h3, h4].forEach((word) => writeWord32BE(word, output))
+  return output
+}
+
+function sha256Family(bytes, mode) {
+  const padded = padBlock64BE(bytes)
+  const words = []
+  const hash = mode === 'sha224'
+    ? [0xC1059ED8, 0x367CD507, 0x3070DD17, 0xF70E5939, 0xFFC00B31, 0x68581511, 0x64F98FA7, 0xBEFA4FA4]
+    : [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
+
+  for (let offset = 0; offset < padded.length; offset += 64) {
+    for (let index = 0; index < 16; index += 1) {
+      const base = offset + index * 4
+      words[index] = (
+        ((padded[base] & 0xFF) << 24) |
+        ((padded[base + 1] & 0xFF) << 16) |
+        ((padded[base + 2] & 0xFF) << 8) |
+        (padded[base + 3] & 0xFF)
+      ) >>> 0
+    }
+    for (let index = 16; index < 64; index += 1) {
+      const s0 = rotr32(words[index - 15], 7) ^ rotr32(words[index - 15], 18) ^ (words[index - 15] >>> 3)
+      const s1 = rotr32(words[index - 2], 17) ^ rotr32(words[index - 2], 19) ^ (words[index - 2] >>> 10)
+      words[index] = add32(words[index - 16], s0, words[index - 7], s1)
+    }
+
+    let a = hash[0]
+    let b = hash[1]
+    let c = hash[2]
+    let d = hash[3]
+    let e = hash[4]
+    let f = hash[5]
+    let g = hash[6]
+    let h = hash[7]
+
+    for (let index = 0; index < 64; index += 1) {
+      const s1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)
+      const ch = (e & f) ^ ((~e) & g)
+      const temp1 = add32(h, s1, ch, SHA256_K[index], words[index])
+      const s0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)
+      const maj = (a & b) ^ (a & c) ^ (b & c)
+      const temp2 = add32(s0, maj)
+
+      h = g
+      g = f
+      f = e
+      e = add32(d, temp1)
+      d = c
+      c = b
+      b = a
+      a = add32(temp1, temp2)
+    }
+
+    hash[0] = add32(hash[0], a)
+    hash[1] = add32(hash[1], b)
+    hash[2] = add32(hash[2], c)
+    hash[3] = add32(hash[3], d)
+    hash[4] = add32(hash[4], e)
+    hash[5] = add32(hash[5], f)
+    hash[6] = add32(hash[6], g)
+    hash[7] = add32(hash[7], h)
+  }
+
+  const output = []
+  hash.slice(0, mode === 'sha224' ? 7 : 8).forEach((word) => writeWord32BE(word, output))
+  return output
+}
+
+function rotr64(value, bits) {
+  const shift = BigInt(bits)
+  return ((value >> shift) | (value << (64n - shift))) & MASK_64
+}
+
+function writeWord64BE(value, output) {
+  for (let shift = 56; shift >= 0; shift -= 8) {
+    output.push(Number((value >> BigInt(shift)) & 0xFFn))
+  }
+}
+
+function readWord64BE(bytes, offset) {
+  let value = 0n
+  for (let index = 0; index < 8; index += 1) {
+    value = (value << 8n) | BigInt(bytes[offset + index] & 0xFF)
+  }
+
+  return value
+}
+
+function padBlock128BE(bytes) {
+  const message = toByteArray(bytes)
+  const bitLength = BigInt(message.length) * 8n
+  const padded = message.slice()
+
+  padded.push(0x80)
+  while (padded.length % 128 !== 112) padded.push(0)
+  for (let shift = 120; shift >= 0; shift -= 8) {
+    padded.push(Number((bitLength >> BigInt(shift)) & 0xFFn))
+  }
+
+  return padded
+}
+
+function sha512Family(bytes, mode) {
+  const padded = padBlock128BE(bytes)
+  const words = []
+  const hash = mode === 'sha384'
+    ? [
+        0xCBBB9D5DC1059ED8n, 0x629A292A367CD507n, 0x9159015A3070DD17n, 0x152FECD8F70E5939n,
+        0x67332667FFC00B31n, 0x8EB44A8768581511n, 0xDB0C2E0D64F98FA7n, 0x47B5481DBEFA4FA4n
+      ]
+    : [
+        0x6A09E667F3BCC908n, 0xBB67AE8584CAA73Bn, 0x3C6EF372FE94F82Bn, 0xA54FF53A5F1D36F1n,
+        0x510E527FADE682D1n, 0x9B05688C2B3E6C1Fn, 0x1F83D9ABFB41BD6Bn, 0x5BE0CD19137E2179n
+      ]
+
+  for (let offset = 0; offset < padded.length; offset += 128) {
+    for (let index = 0; index < 16; index += 1) {
+      words[index] = readWord64BE(padded, offset + index * 8)
+    }
+    for (let index = 16; index < 80; index += 1) {
+      const s0 = rotr64(words[index - 15], 1) ^ rotr64(words[index - 15], 8) ^ (words[index - 15] >> 7n)
+      const s1 = rotr64(words[index - 2], 19) ^ rotr64(words[index - 2], 61) ^ (words[index - 2] >> 6n)
+      words[index] = (words[index - 16] + s0 + words[index - 7] + s1) & MASK_64
+    }
+
+    let a = hash[0]
+    let b = hash[1]
+    let c = hash[2]
+    let d = hash[3]
+    let e = hash[4]
+    let f = hash[5]
+    let g = hash[6]
+    let h = hash[7]
+
+    for (let index = 0; index < 80; index += 1) {
+      const s1 = rotr64(e, 14) ^ rotr64(e, 18) ^ rotr64(e, 41)
+      const ch = (e & f) ^ ((MASK_64 ^ e) & g)
+      const temp1 = (h + s1 + ch + SHA512_K[index] + words[index]) & MASK_64
+      const s0 = rotr64(a, 28) ^ rotr64(a, 34) ^ rotr64(a, 39)
+      const maj = (a & b) ^ (a & c) ^ (b & c)
+      const temp2 = (s0 + maj) & MASK_64
+
+      h = g
+      g = f
+      f = e
+      e = (d + temp1) & MASK_64
+      d = c
+      c = b
+      b = a
+      a = (temp1 + temp2) & MASK_64
+    }
+
+    hash[0] = (hash[0] + a) & MASK_64
+    hash[1] = (hash[1] + b) & MASK_64
+    hash[2] = (hash[2] + c) & MASK_64
+    hash[3] = (hash[3] + d) & MASK_64
+    hash[4] = (hash[4] + e) & MASK_64
+    hash[5] = (hash[5] + f) & MASK_64
+    hash[6] = (hash[6] + g) & MASK_64
+    hash[7] = (hash[7] + h) & MASK_64
+  }
+
+  const output = []
+  hash.slice(0, mode === 'sha384' ? 6 : 8).forEach((word) => writeWord64BE(word, output))
+  return output
+}
+
+function digestBytes(hash, bytes) {
+  if (hash === 'md5') return md5(bytes)
+  if (hash === 'sha1') return sha1(bytes)
+  if (hash === 'sha224') return sha256Family(bytes, 'sha224')
+  if (hash === 'sha256') return sha256Family(bytes, 'sha256')
+  if (hash === 'sha384') return sha512Family(bytes, 'sha384')
+  if (hash === 'sha512') return sha512Family(bytes, 'sha512')
+
+  throw new Error('不支持的哈希算法')
+}
+
+function getBlockSize(hash) {
+  return hash === 'sha384' || hash === 'sha512' ? 128 : 64
+}
+
+function hmacBytes(hash, keyBytes, dataBytes) {
+  const blockSize = getBlockSize(hash)
+  let key = toByteArray(keyBytes)
+
+  if (key.length > blockSize) {
+    key = digestBytes(hash, key)
+  }
+  while (key.length < blockSize) key.push(0)
+
+  const innerPad = []
+  const outerPad = []
+  for (let index = 0; index < blockSize; index += 1) {
+    innerPad[index] = key[index] ^ 0x36
+    outerPad[index] = key[index] ^ 0x5C
+  }
+
+  return digestBytes(hash, outerPad.concat(digestBytes(hash, innerPad.concat(toByteArray(dataBytes)))))
+}
+
+function parsePositiveInteger(value, label, fallback, maxValue) {
+  const numberValue = Number(value || fallback)
+  if (!Number.isInteger(numberValue) || numberValue <= 0) {
+    throw new Error(`${label}需为正整数`)
+  }
+  if (numberValue > maxValue) {
+    throw new Error(`${label}不能超过 ${maxValue}`)
+  }
+
+  return numberValue
+}
+
+function pbkdf2Bytes(hash, passwordBytes, saltBytes, iterations, outputLength) {
+  const rounds = parsePositiveInteger(iterations, '迭代次数', 1000, 100000)
+  const length = parsePositiveInteger(outputLength, '输出长度', 32, 4096)
+  const hashLength = digestBytes(hash, []).length
+  const blockCount = Math.ceil(length / hashLength)
+  const output = []
+
+  for (let blockIndex = 1; blockIndex <= blockCount; blockIndex += 1) {
+    const blockIndexBytes = [
+      (blockIndex >>> 24) & 0xFF,
+      (blockIndex >>> 16) & 0xFF,
+      (blockIndex >>> 8) & 0xFF,
+      blockIndex & 0xFF
+    ]
+    let previous = hmacBytes(hash, passwordBytes, toByteArray(saltBytes).concat(blockIndexBytes))
+    const block = previous.slice()
+
+    for (let round = 1; round < rounds; round += 1) {
+      previous = hmacBytes(hash, passwordBytes, previous)
+      for (let index = 0; index < block.length; index += 1) {
+        block[index] ^= previous[index]
+      }
+    }
+
+    output.push(...block)
+  }
+
+  return output.slice(0, length)
+}
+
+function getPreset(key) {
+  return HASH_ALGORITHM_PRESETS.find((preset) => preset.key === key) || HASH_ALGORITHM_PRESETS[0]
+}
+
+function calculateHash(bytes, config = {}) {
+  const preset = getPreset(config.key)
+  const dataBytes = toByteArray(bytes)
+  let resultBytes
+
+  if (preset.kind === 'hmac') {
+    resultBytes = hmacBytes(preset.hash, stringToUtf8Bytes(config.hmacKey || ''), dataBytes)
+  } else if (preset.kind === 'pbkdf2') {
+    resultBytes = pbkdf2Bytes(
+      preset.hash,
+      dataBytes,
+      stringToUtf8Bytes(config.pbkdf2Salt || ''),
+      config.pbkdf2Iterations,
+      config.pbkdf2Length
+    )
+  } else {
+    resultBytes = digestBytes(preset.hash, dataBytes)
+  }
+
+  return {
+    base64: bytesToBase64(resultBytes),
+    bin: bytesToBin(resultBytes),
+    bytes: resultBytes,
+    hex: bytesToHex(resultBytes),
+    width: resultBytes.length * 8
+  }
+}
+
+module.exports = {
+  HASH_ALGORITHM_PRESETS,
+  bytesToBase64,
+  bytesToBin,
+  bytesToHex,
+  calculateHash,
+  digestBytes,
+  hmacBytes,
+  pbkdf2Bytes,
+  stringToUtf8Bytes,
+  toByteArray
+}

+ 75 - 0
utils/home-view-model.js

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

+ 1 - 2
utils/input-value-utils.js

@@ -25,6 +25,5 @@ function appendInputUnit(item, value) {
 }
 
 module.exports = {
-  appendInputUnit,
-  getInputTextWithoutUnit
+  appendInputUnit
 }

+ 236 - 0
utils/modbus-access.js

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

+ 59 - 77
utils/modbus-rtu.js

@@ -1,44 +1,19 @@
-const CRC_TABLE = [
-  0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
-  0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
-  0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
-  0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
-  0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
-  0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
-  0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
-  0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
-  0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
-  0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
-  0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
-  0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
-  0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
-  0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
-  0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
-  0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
-  0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
-  0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
-  0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
-  0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
-  0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
-  0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
-  0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
-  0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
-  0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
-  0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
-  0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
-  0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
-  0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
-  0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
-  0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
-  0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
-]
-
+const {
+  BYTE_ORDER_LOW,
+  appendCrc16Modbus,
+  hasValidCrc16Modbus
+} = require('./crc')
+
+const MODBUS_CRC_OPTIONS = {
+  byteOrder: BYTE_ORDER_LOW
+}
 const MAX_MODBUS_DMA_BYTES = 64
 const MODBUS_READ_RESPONSE_OVERHEAD = 5
 const MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD = 9
 const MAX_READ_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) / 2)
 const MAX_READ_COIL_QUANTITY = (MAX_MODBUS_DMA_BYTES - MODBUS_READ_RESPONSE_OVERHEAD) * 8
 const MAX_WRITE_MULTIPLE_REGISTER_QUANTITY = Math.floor((MAX_MODBUS_DMA_BYTES - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2)
+const UNLIMITED_FRAME_BYTES = 0
 
 function toByte(value, label) {
   if (!Number.isInteger(value) || value < 0 || value > 0xFF) {
@@ -60,52 +35,57 @@ function splitWord(value) {
   return [(value >> 8) & 0xFF, value & 0xFF]
 }
 
-function calculateCrc(bytes) {
-  let crc = 0xFFFF
+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)
 
-  bytes.forEach((byte) => {
-    crc = (crc >> 8) ^ CRC_TABLE[(crc ^ byte) & 0xFF]
-  })
-
-  return crc
+  return MAX_MODBUS_DMA_BYTES
 }
 
-function appendCrc(bytes) {
-  const crc = calculateCrc(bytes)
+function getMaxReadQuantity(functionCode, maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
+  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
+  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
 
-  return bytes.concat([crc & 0xFF, (crc >> 8) & 0xFF])
-}
+  const dataBytes = frameBytes - MODBUS_READ_RESPONSE_OVERHEAD
+  if (dataBytes <= 0) return 0
+  if (functionCode === 0x01 || functionCode === 0x02) return dataBytes * 8
+  if (functionCode === 0x03 || functionCode === 0x04) return Math.floor(dataBytes / 2)
 
-function hasValidCrc(bytes) {
-  if (!bytes || bytes.length < 4) return false
+  return 0
+}
 
-  const frame = bytes.slice(0, bytes.length - 2)
-  const expected = appendCrc(frame)
+function getMaxWriteMultipleRegisterQuantity(maxFrameBytes = MAX_MODBUS_DMA_BYTES) {
+  const frameBytes = normalizeMaxFrameBytes(maxFrameBytes)
+  if (frameBytes === UNLIMITED_FRAME_BYTES) return 0xFFFF
 
-  return expected[expected.length - 2] === bytes[bytes.length - 2]
-    && expected[expected.length - 1] === bytes[bytes.length - 1]
+  return Math.max(0, Math.floor((frameBytes - MODBUS_WRITE_MULTIPLE_REQUEST_OVERHEAD) / 2))
 }
 
-function buildReadFrame(slaveAddress, functionCode, address, quantity) {
+function buildReadFrame(slaveAddress, functionCode, address, quantity, options = {}) {
   const slave = toByte(slaveAddress, '从站地址')
   const command = toByte(functionCode, '功能码')
   const startAddress = toWord(address, '寄存器地址')
   const registerQuantity = toWord(quantity, '读取数量')
+  const maxQuantity = getMaxReadQuantity(command, options.maxFrameBytes)
 
-  if (![0x01, 0x03, 0x04].includes(command)) {
+  if (![0x01, 0x02, 0x03, 0x04].includes(command)) {
     throw new Error('当前功能码不是读取命令')
   }
   if (registerQuantity === 0) {
     throw new Error('读取数量必须大于 0')
   }
-  if ([0x03, 0x04].includes(command) && registerQuantity > MAX_READ_REGISTER_QUANTITY) {
-    throw new Error(`单帧最多读取 ${MAX_READ_REGISTER_QUANTITY} 个寄存器`)
+  if ([0x03, 0x04].includes(command) && maxQuantity > 0 && registerQuantity > maxQuantity) {
+    throw new Error(`单帧最多读取 ${maxQuantity} 个寄存器`)
   }
-  if (command === 0x01 && registerQuantity > MAX_READ_COIL_QUANTITY) {
-    throw new Error(`单帧最多读取 ${MAX_READ_COIL_QUANTITY} 个线圈`)
+  if ((command === 0x01 || command === 0x02) && maxQuantity > 0 && registerQuantity > maxQuantity) {
+    throw new Error(`单帧最多读取 ${maxQuantity} 个位状态`)
   }
 
-  return appendCrc([slave, command].concat(splitWord(startAddress), splitWord(registerQuantity)))
+  return appendCrc16Modbus(
+    [slave, command].concat(splitWord(startAddress), splitWord(registerQuantity)),
+    MODBUS_CRC_OPTIONS
+  )
 }
 
 function buildWriteSingleCoilFrame(slaveAddress, address, checked) {
@@ -113,7 +93,10 @@ function buildWriteSingleCoilFrame(slaveAddress, address, checked) {
   const startAddress = toWord(address, '线圈地址')
   const outputValue = checked ? 0xFF00 : 0x0000
 
-  return appendCrc([slave, 0x05].concat(splitWord(startAddress), splitWord(outputValue)))
+  return appendCrc16Modbus(
+    [slave, 0x05].concat(splitWord(startAddress), splitWord(outputValue)),
+    MODBUS_CRC_OPTIONS
+  )
 }
 
 function buildWriteSingleRegisterFrame(slaveAddress, address, value) {
@@ -121,27 +104,32 @@ function buildWriteSingleRegisterFrame(slaveAddress, address, value) {
   const startAddress = toWord(address, '寄存器地址')
   const registerValue = toWord(value, '写入值')
 
-  return appendCrc([slave, 0x06].concat(splitWord(startAddress), splitWord(registerValue)))
+  return appendCrc16Modbus(
+    [slave, 0x06].concat(splitWord(startAddress), splitWord(registerValue)),
+    MODBUS_CRC_OPTIONS
+  )
 }
 
-function buildWriteMultipleRegistersFrame(slaveAddress, address, values) {
+function buildWriteMultipleRegistersFrame(slaveAddress, address, values, options = {}) {
   const slave = toByte(slaveAddress, '从站地址')
   const startAddress = toWord(address, '寄存器地址')
+  const maxQuantity = getMaxWriteMultipleRegisterQuantity(options.maxFrameBytes)
 
   if (!Array.isArray(values) || values.length === 0) {
     throw new Error('请输入至少一个寄存器写入值')
   }
-  if (values.length > MAX_WRITE_MULTIPLE_REGISTER_QUANTITY) {
-    throw new Error(`单帧最多写入 ${MAX_WRITE_MULTIPLE_REGISTER_QUANTITY} 个寄存器`)
+  if (maxQuantity > 0 && values.length > maxQuantity) {
+    throw new Error(`单帧最多写入 ${maxQuantity} 个寄存器`)
   }
 
   const registerBytes = values.reduce((result, value) => {
     return result.concat(splitWord(toWord(value, '写入值')))
   }, [])
 
-  return appendCrc(
+  return appendCrc16Modbus(
     [slave, 0x10]
-      .concat(splitWord(startAddress), splitWord(values.length), [registerBytes.length], registerBytes)
+      .concat(splitWord(startAddress), splitWord(values.length), [registerBytes.length], registerBytes),
+    MODBUS_CRC_OPTIONS
   )
 }
 
@@ -149,15 +137,8 @@ function formatHex(bytes) {
   return bytes.map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join(' ')
 }
 
-function getMaxReadQuantity(functionCode) {
-  if (functionCode === 0x01) return MAX_READ_COIL_QUANTITY
-  if (functionCode === 0x03 || functionCode === 0x04) return MAX_READ_REGISTER_QUANTITY
-
-  return 0
-}
-
 function getReadResponseByteLength(functionCode, quantity) {
-  if (functionCode === 0x01) return MODBUS_READ_RESPONSE_OVERHEAD + Math.ceil(Number(quantity || 0) / 8)
+  if (functionCode === 0x01 || functionCode === 0x02) return MODBUS_READ_RESPONSE_OVERHEAD + Math.ceil(Number(quantity || 0) / 8)
   if (functionCode === 0x03 || functionCode === 0x04) return MODBUS_READ_RESPONSE_OVERHEAD + Number(quantity || 0) * 2
 
   return 0
@@ -168,14 +149,15 @@ module.exports = {
   MAX_READ_COIL_QUANTITY,
   MAX_READ_REGISTER_QUANTITY,
   MAX_WRITE_MULTIPLE_REGISTER_QUANTITY,
-  appendCrc,
+  MODBUS_CRC_OPTIONS,
+  UNLIMITED_FRAME_BYTES,
   buildReadFrame,
   buildWriteMultipleRegistersFrame,
   buildWriteSingleCoilFrame,
   buildWriteSingleRegisterFrame,
-  calculateCrc,
   formatHex,
+  getMaxWriteMultipleRegisterQuantity,
   getMaxReadQuantity,
   getReadResponseByteLength,
-  hasValidCrc
+  hasValidCrc16Modbus
 }

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

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

+ 58 - 0
utils/motor-control-protocol.js

@@ -0,0 +1,58 @@
+const modbusAccess = require('./modbus-access')
+const {
+  parseHexInteger
+} = require('./base-utils')
+const {
+  controlButtonRegisters
+} = require('./registers')
+
+function getControlButton(key) {
+  return controlButtonRegisters.find((item) => item.key === key) || null
+}
+
+function getControlButtonWriteValue(button) {
+  if (!button) return 0
+
+  return button.writeValue
+}
+
+async function writeControlButton(button, options = {}) {
+  if (!button) return false
+
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
+  if (slaveAddress === null) return false
+
+  const address = parseHexInteger(button.address)
+  const coilEnabled = Number(getControlButtonWriteValue(button)) !== 0
+
+  return modbusAccess.writeSingleCoil(
+    slaveAddress,
+    address,
+    coilEnabled,
+    options.label || button.name,
+    options.kind || 'motor-control-write',
+    {
+      showModal: options.showModal
+    }
+  )
+}
+
+function writeControlButtonByKey(key, options = {}) {
+  return writeControlButton(getControlButton(key), options)
+}
+
+function softReset(options = {}) {
+  return writeControlButtonByKey('reset', {
+    label: '软复位',
+    kind: 'motor-control-soft-reset',
+    showModal: false,
+    ...options
+  })
+}
+
+module.exports = {
+  getControlButton,
+  softReset,
+  writeControlButton,
+  writeControlButtonByKey
+}

+ 104 - 0
utils/motor-control-register-groups.js

@@ -0,0 +1,104 @@
+const {
+  parseHexInteger
+} = require('./base-utils')
+const {
+  getRegisterCount
+} = require('./registers')
+
+function parseRegisterAddress(address) {
+  return parseHexInteger(address)
+}
+
+function getAreaKey(item) {
+  return (item.area && item.area.key) || item.areaKey || 'holding'
+}
+
+function getGroupItems(data, groupKey) {
+  if (groupKey === 'vsp') return data.vspCurveRegisters.concat([data.speedSlopeRegister])
+  if (groupKey === 'speedLoop') {
+    return data.speedLoopInputDisplayRegisters
+      .concat(data.speedLoopExtraDisplayRegisters)
+  }
+  if (groupKey === 'estimator') {
+    return data.estimatorCalculatedDisplayRegisters.concat(data.atoBandwidthDisplayRegisters)
+  }
+  if (groupKey === 'tailwind') {
+    return data.tailwindControlRegisters
+      .concat(data.tailwindCalculatedDisplayRegisters, data.tailwindAtoBandwidthDisplayRegisters)
+  }
+  if (groupKey === 'preposition') return data.prepositionSwitchRegisters.concat(data.prepositionParameterDisplayRegisters)
+  if (groupKey === 'oil') return data.oilParameterInputRegisters
+  if (groupKey === 'dq') return data.dqGainDisplayRegisters
+  if (groupKey === 'protection') return data.protectionSwitchRegisters.concat(data.protectionDisplayRegisters)
+
+  return []
+}
+
+function expandAtoItems(item) {
+  if (!item.kpAddress || !item.kiAddress) return [item]
+
+  return [
+    {
+      address: item.kpAddress,
+      areaKey: 'holding',
+      name: `${item.name} KP`,
+      type: 'uint16_t',
+      writeValue: item.kpWriteValue
+    },
+    {
+      address: item.kiAddress,
+      areaKey: 'holding',
+      name: `${item.name} KI`,
+      type: 'uint16_t',
+      writeValue: item.kiWriteValue
+    }
+  ]
+}
+
+function expandItems(items) {
+  return items.reduce((result, item) => result.concat(expandAtoItems(item)), [])
+}
+
+function makeReadSpans(entries) {
+  const sortedEntries = entries
+    .map((item) => ({
+      address: parseRegisterAddress(item.address),
+      count: getRegisterCount(item)
+    }))
+    .filter((item) => Number.isFinite(item.address) && item.count > 0)
+    .sort((left, right) => left.address - right.address)
+  const spans = []
+
+  sortedEntries.forEach((entry) => {
+    const last = spans[spans.length - 1]
+
+    if (last && entry.address <= last.address + last.quantity) {
+      const end = Math.max(last.address + last.quantity, entry.address + entry.count)
+      last.quantity = end - last.address
+      return
+    }
+
+    spans.push({
+      address: entry.address,
+      quantity: entry.count
+    })
+  })
+
+  return spans
+}
+
+function mergeReadValues(target, source) {
+  if (!target || !source) return
+
+  Object.assign(target.coils, source.coils || {})
+  Object.assign(target.words, source.words || {})
+}
+
+module.exports = {
+  expandItems,
+  getAreaKey,
+  getGroupItems,
+  makeReadSpans,
+  mergeReadValues,
+  parseRegisterAddress
+}

+ 193 - 20
utils/params-page-state.js

@@ -12,6 +12,9 @@ const {
   tailwindSwitchRegisters,
   getByteRegisterValue
 } = require('./registers')
+const {
+  parseHexInteger
+} = require('./base-utils')
 const {
   getSharedInputValues,
   mergeInputValues,
@@ -47,9 +50,6 @@ const SPEED_LOOP_INPUT_ORDER = [
   'SOUT_MAX'
 ]
 
-const SPEED_LOOP_CALCULATED_NAMES = [
-]
-
 const TAILWIND_CALCULATED_NAMES = [
   'SPEED_KLPF_TAILWIND',
   'OBS_EA_KS_TAILWIND'
@@ -63,6 +63,129 @@ const ESTIMATOR_CALCULATED_PREFIXES = [
   'FOC_KFG'
 ]
 
+const PROTECTION_SECTION_DEFINITIONS = [
+  {
+    key: 'base',
+    title: '基础',
+    rows: [
+      [
+        { kind: 'switch', name: '保护使能' },
+        { kind: 'switch', name: '恢复使能' },
+        { kind: 'input', name: '故障恢复时间' }
+      ]
+    ]
+  },
+  {
+    key: 'hardwareCurrent',
+    title: '硬件过流',
+    rows: [
+      [
+        { kind: 'input', name: '硬件过流值', label: '硬件过流' }
+      ]
+    ]
+  },
+  {
+    key: 'current',
+    title: '电流',
+    rows: [
+      [
+        { kind: 'switch', name: '电流保护使能' },
+        { kind: 'input', name: '软件过流值' }
+      ]
+    ]
+  },
+  {
+    key: 'phase',
+    title: '缺相',
+    rows: [
+      [
+        { kind: 'switch', name: '缺相保护使能' }
+      ]
+    ]
+  },
+  {
+    key: 'voltage',
+    title: '电压',
+    rows: [
+      [
+        { kind: 'switch', name: '电压保护使能' }
+      ],
+      [
+        { kind: 'input', name: '过压保护值', label: '过压值' },
+        { kind: 'input', name: '欠压保护值', label: '欠压值' }
+      ],
+      [
+        { kind: 'input', name: '过压恢复值' },
+        { kind: 'input', name: '欠压恢复值' }
+      ]
+    ]
+  },
+  {
+    key: 'stall',
+    title: '堵转',
+    rows: [
+      [
+        { kind: 'switch', name: '堵转保护使能' }
+      ],
+      [
+        { kind: 'input', name: '速度限制最大值' },
+        { kind: 'input', name: '速度限制最小值' }
+      ],
+      [
+        { kind: 'input', name: '反电动势低阈值' },
+        { kind: 'input', name: '反电动势高阈值' }
+      ],
+      [
+        { kind: 'input', name: '速度中间值' }
+      ]
+    ]
+  },
+  {
+    key: 'power',
+    title: '功率',
+    rows: [
+      [
+        { kind: 'switch', name: '功率保护使能' },
+        { kind: 'input', name: '功率保护值' },
+        { kind: 'input', name: '功率保护时间' }
+      ]
+    ]
+  },
+  {
+    key: 'temperature',
+    title: '温度',
+    rows: [
+      [
+        { kind: 'switch', name: '温度保护使能' }
+      ],
+      [
+        { kind: 'input', name: '温度保护值' },
+        { kind: 'input', name: '温度恢复值' },
+        { kind: 'input', name: '温度保护时间' }
+      ]
+    ]
+  },
+  {
+    key: 'serial',
+    title: '串口',
+    rows: [
+      [
+        { kind: 'switch', name: '串口保护使能' },
+        { kind: 'input', name: '串口丢失检测时间' }
+      ]
+    ]
+  },
+  {
+    key: 'pwm',
+    title: 'PWM',
+    rows: [
+      [
+        { kind: 'switch', name: 'PWM丢失保护使能' }
+      ]
+    ]
+  }
+]
+
 function formatInputValue(item, value) {
   if (value === '' || value === undefined || value === null) return '--'
 
@@ -146,16 +269,12 @@ function isTailwindAtoRegister(item) {
   return item.suffix === 'TAILWIND'
 }
 
-function isSpeedLoopCalculatedRegister(item) {
-  return isNameIn(SPEED_LOOP_CALCULATED_NAMES, item)
-}
-
 function isTailwindCalculatedRegister(item) {
   return isNameIn(TAILWIND_CALCULATED_NAMES, item)
 }
 
 function isEstimatorCalculatedRegister(item) {
-  if (isSpeedLoopCalculatedRegister(item) || isTailwindCalculatedRegister(item)) return false
+  if (isTailwindCalculatedRegister(item)) return false
 
   return ESTIMATOR_CALCULATED_PREFIXES.some((prefix) => item.name.startsWith(prefix))
 }
@@ -167,9 +286,53 @@ function sortByNameOrder(registers, nameOrder) {
     .sort((left, right) => nameOrder.indexOf(left.name) - nameOrder.indexOf(right.name))
 }
 
-function buildProtectionGroups(registers) {
+function mapByName(registers) {
+  return registers.reduce((result, item) => {
+    result[item.name] = item
+    return result
+  }, {})
+}
+
+function buildProtectionField(definition, registerMap, switchMap) {
+  const source = definition.kind === 'switch'
+    ? switchMap[definition.name]
+    : registerMap[definition.name]
+
+  if (!source) return null
+
+  return {
+    ...source,
+    kind: definition.kind,
+    label: definition.label || source.name,
+    metaValue: source.writeValue === 0 ? '0' : (source.writeValue || '--')
+  }
+}
+
+function buildProtectionGroups(registers, switches = []) {
+  const protectionDisplayRegisters = addSourceIndex(registers)
+  const protectionSwitchDisplayRegisters = addSourceIndex(switches)
+  const registerMap = mapByName(protectionDisplayRegisters)
+  const switchMap = mapByName(protectionSwitchDisplayRegisters)
+
   return {
-    protectionDisplayRegisters: addSourceIndex(registers)
+    protectionDisplayRegisters,
+    protectionSections: PROTECTION_SECTION_DEFINITIONS.map((section) => ({
+      ...section,
+      rows: section.rows
+        .map((row, rowIndex) => {
+          const fields = row
+            .map((definition) => buildProtectionField(definition, registerMap, switchMap))
+            .filter(Boolean)
+
+          return fields.length
+            ? {
+              fields,
+              key: `${section.key}-${rowIndex}`
+            }
+            : null
+        })
+        .filter(Boolean)
+    })).filter((section) => section.rows.length)
   }
 }
 
@@ -191,7 +354,7 @@ function getRegisterReadValue(item, readValues) {
   }
 
   if (item.type === 'float') {
-    const nextAddress = (parseInt(item.address, 16) + 1).toString(16).toUpperCase()
+    const nextAddress = (parseHexInteger(item.address) + 1).toString(16).toUpperCase()
     return wordsToFloat(firstWord, getWord(readValues, nextAddress))
   }
 
@@ -311,7 +474,6 @@ function clearGroupDirty(data, groupKey) {
     nextState.parameterInputRegisters = clearDirty(data.parameterInputRegisters, (item) => (
       SPEED_LOOP_INPUT_ORDER.includes(item.name)
     ))
-    nextState.calculatedParameterRegisters = clearDirty(data.calculatedParameterRegisters, isSpeedLoopCalculatedRegister)
     nextState.speedLoopExtraRegisters = clearDirty(data.speedLoopExtraRegisters)
   }
   if (groupKey === 'vsp') {
@@ -322,8 +484,10 @@ function clearGroupDirty(data, groupKey) {
     }
   }
   if (groupKey === 'oil') nextState.oilParameterInputRegisters = clearDirty(data.oilParameterInputRegisters)
-  if (groupKey === 'protectionSwitch') nextState.protectionSwitchRegisters = clearDirty(data.protectionSwitchRegisters)
-  if (groupKey === 'protection') nextState.protectionRegisters = clearDirty(data.protectionRegisters)
+  if (groupKey === 'protection') {
+    nextState.protectionRegisters = clearDirty(data.protectionRegisters)
+    nextState.protectionSwitchRegisters = clearDirty(data.protectionSwitchRegisters)
+  }
 
   return {
     ...nextState,
@@ -355,10 +519,15 @@ function clearTailwindSwitchDirty(data, index) {
 }
 
 function clearProtectionSwitchDirty(data, index) {
-  return {
+  const nextState = {
     ...data,
     protectionSwitchRegisters: clearRegisterDirty(data.protectionSwitchRegisters, index)
   }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
 }
 
 function buildViewState(state) {
@@ -372,7 +541,6 @@ function buildViewState(state) {
   return {
     vspCurveRegisters: sortByNameOrder(inputRegisters, VSP_CURVE_ORDER),
     speedLoopInputDisplayRegisters: sortByNameOrder(inputRegisters, SPEED_LOOP_INPUT_ORDER),
-    speedLoopCalculatedDisplayRegisters: calculatedRegisters.filter(isSpeedLoopCalculatedRegister),
     speedLoopExtraDisplayRegisters: speedLoopExtras,
     atoBandwidthDisplayRegisters: atoRegisters.filter((item) => !isTailwindAtoRegister(item)),
     tailwindAtoBandwidthDisplayRegisters: atoRegisters.filter(isTailwindAtoRegister),
@@ -382,7 +550,7 @@ function buildViewState(state) {
     prepositionParameterDisplayRegisters: addSourceIndex(state.prepositionParameterInputRegisters),
     dqGainDisplayRegisters: dqRegisters,
     estimatorCalculatedDisplayRegisters: calculatedRegisters.filter(isEstimatorCalculatedRegister),
-    ...buildProtectionGroups(state.protectionRegisters)
+    ...buildProtectionGroups(state.protectionRegisters, state.protectionSwitchRegisters)
   }
 }
 
@@ -400,11 +568,11 @@ function createInitialState() {
     prepositionParameterInputRegisters,
     prepositionSwitchRegisters: [],
     protectionDisplayRegisters: [],
+    protectionSections: [],
     protectionRegisters,
     protectionSwitchRegisters,
     speedLoopExtraRegisters,
     speedLoopExtraDisplayRegisters: [],
-    speedLoopCalculatedDisplayRegisters: [],
     speedLoopInputDisplayRegisters: [],
     speedSlopeRegister,
     tailwindAtoBandwidthDisplayRegisters: [],
@@ -634,7 +802,7 @@ function applyTailwindSwitchChange(data, index, checked) {
 }
 
 function applyProtectionSwitchChange(data, index, checked) {
-  return {
+  const nextState = {
     ...data,
     protectionSwitchRegisters: data.protectionSwitchRegisters.map((item, currentIndex) => {
       if (currentIndex !== index) return item
@@ -647,6 +815,11 @@ function applyProtectionSwitchChange(data, index, checked) {
       }
     })
   }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
 }
 
 function applyProtectionInput(data, index, value) {
@@ -664,7 +837,7 @@ function applyProtectionInput(data, index, value) {
   return {
     ...data,
     protectionRegisters: nextRegisters,
-    ...buildProtectionGroups(nextRegisters)
+    ...buildProtectionGroups(nextRegisters, data.protectionSwitchRegisters)
   }
 }
 

+ 72 - 227
utils/params-service.js

@@ -1,39 +1,21 @@
 const {
-  buildReadFrame,
-  buildWriteMultipleRegistersFrame,
-  buildWriteSingleCoilFrame,
-  MAX_READ_COIL_QUANTITY,
-  MAX_READ_REGISTER_QUANTITY
-} = require('./modbus-rtu')
-const paramsPageState = require('./params-page-state')
+  paramsState: paramsPageState
+} = require('./motor-control-data')
+const {
+  expandItems,
+  getAreaKey,
+  getGroupItems,
+  makeReadSpans,
+  mergeReadValues,
+  parseRegisterAddress
+} = require('./motor-control-register-groups')
 const transport = require('./ble-transport')
+const modbusAccess = require('./modbus-access')
 const {
-  addCoilReadValues,
-  addWordReadValues,
-  floatToWords
+  floatToWords,
+  toRegisterWord
 } = require('./register-value-utils')
 
-function getSharedSlaveAddress() {
-  try {
-    return transport.getSlaveAddress()
-  } catch (error) {
-    transport.showCommandAlert('从机地址错误', error.message)
-    return null
-  }
-}
-
-function parseAddress(address) {
-  return parseInt(String(address || '0'), 16)
-}
-
-function getAreaKey(item) {
-  return (item.area && item.area.key) || item.areaKey || 'holding'
-}
-
-function getRegisterCount(item) {
-  return item.registerCount || (item.type === 'float' ? 2 : 1)
-}
-
 function hasWriteValue(value) {
   return value !== '' && value !== undefined && value !== null && value !== '--'
 }
@@ -47,169 +29,60 @@ function toWriteNumber(value) {
   return numberValue
 }
 
-function toWord(value) {
-  const numberValue = Number(value)
-  if (!Number.isFinite(numberValue)) return null
-
-  const word = Math.round(numberValue)
-
-  return word >= 0 && word <= 0xFFFF ? word : null
-}
-
-function getGroupItems(data, groupKey) {
-  if (groupKey === 'vsp') return data.vspCurveRegisters.concat([data.speedSlopeRegister])
-  if (groupKey === 'speedLoop') {
-    return data.speedLoopInputDisplayRegisters
-      .concat(data.speedLoopCalculatedDisplayRegisters, data.speedLoopExtraDisplayRegisters)
-  }
-  if (groupKey === 'estimator') {
-    return data.estimatorCalculatedDisplayRegisters.concat(data.atoBandwidthDisplayRegisters)
-  }
-  if (groupKey === 'tailwind') {
-    return data.tailwindControlRegisters
-      .concat(data.tailwindCalculatedDisplayRegisters, data.tailwindAtoBandwidthDisplayRegisters)
-  }
-  if (groupKey === 'preposition') return data.prepositionSwitchRegisters.concat(data.prepositionParameterDisplayRegisters)
-  if (groupKey === 'oil') return data.oilParameterInputRegisters
-  if (groupKey === 'dq') return data.dqGainDisplayRegisters
-  if (groupKey === 'protectionSwitch') return data.protectionSwitchRegisters
-  if (groupKey === 'protection') return data.protectionDisplayRegisters
-
-  return []
-}
-
-function expandAtoItems(item) {
-  if (!item.kpAddress || !item.kiAddress) return [item]
-
-  return [
-    {
-      address: item.kpAddress,
-      areaKey: 'holding',
-      name: `${item.name} KP`,
-      type: 'uint16_t',
-      writeValue: item.kpWriteValue
-    },
-    {
-      address: item.kiAddress,
-      areaKey: 'holding',
-      name: `${item.name} KI`,
-      type: 'uint16_t',
-      writeValue: item.kiWriteValue
-    }
-  ]
-}
-
-function expandItems(items) {
-  return items.reduce((result, item) => result.concat(expandAtoItems(item)), [])
-}
-
-function makeReadSpans(entries) {
-  const sortedEntries = entries
-    .map((item) => ({
-      address: parseAddress(item.address),
-      count: getRegisterCount(item)
-    }))
-    .filter((item) => Number.isFinite(item.address) && item.count > 0)
-    .sort((left, right) => left.address - right.address)
-  const spans = []
-
-  sortedEntries.forEach((entry) => {
-    const last = spans[spans.length - 1]
-
-    if (last && entry.address <= last.address + last.quantity) {
-      const end = Math.max(last.address + last.quantity, entry.address + entry.count)
-      last.quantity = end - last.address
-      return
-    }
-
-    spans.push({
-      address: entry.address,
-      quantity: entry.count
-    })
-  })
-
-  return spans
-}
-
-function splitReadSpans(spans, maxQuantity) {
-  return spans.reduce((result, span) => {
-    let address = span.address
-    let remaining = span.quantity
-
-    while (remaining > 0) {
-      const quantity = Math.min(remaining, maxQuantity)
-      result.push({
-        address,
-        quantity
-      })
-      address += quantity
-      remaining -= quantity
-    }
-
-    return result
-  }, [])
-}
-
 async function readGroup(data, groupKey) {
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const items = expandItems(getGroupItems(data, groupKey))
   const coilItems = items.filter((item) => getAreaKey(item) === 'coil')
   const holdingItems = items.filter((item) => getAreaKey(item) === 'holding')
   const inputItems = items.filter((item) => getAreaKey(item) === 'input')
+  const coilSpans = makeReadSpans(coilItems)
+  const holdingSpans = makeReadSpans(holdingItems)
+  const inputSpans = makeReadSpans(inputItems)
   const readValues = {
     coils: {},
     words: {}
   }
   let sent = false
 
-  for (const span of splitReadSpans(makeReadSpans(coilItems), MAX_READ_COIL_QUANTITY)) {
+  if (coilSpans.length) {
     sent = true
-    const response = await transport.sendManagedFrame(
-      buildReadFrame(slaveAddress, 0x01, span.address, span.quantity),
+    const values = await modbusAccess.readSpans(
+      slaveAddress,
+      0x01,
+      coilSpans,
       '参数读取',
-      {
-        address: span.address,
-        functionCode: 0x01,
-        kind: 'params-read',
-        quantity: span.quantity,
-        slaveAddress
-      }
+      'params-read'
     )
-    addCoilReadValues(readValues, span.address, span.quantity, response)
+    if (!values) return false
+    mergeReadValues(readValues, values)
   }
 
-  for (const span of splitReadSpans(makeReadSpans(holdingItems), MAX_READ_REGISTER_QUANTITY)) {
+  if (holdingSpans.length) {
     sent = true
-    const response = await transport.sendManagedFrame(
-      buildReadFrame(slaveAddress, 0x03, span.address, span.quantity),
+    const values = await modbusAccess.readSpans(
+      slaveAddress,
+      0x03,
+      holdingSpans,
       '参数读取',
-      {
-        address: span.address,
-        functionCode: 0x03,
-        kind: 'params-read',
-        quantity: span.quantity,
-        slaveAddress
-      }
+      'params-read'
     )
-    addWordReadValues(readValues, span.address, response)
+    if (!values) return false
+    mergeReadValues(readValues, values)
   }
 
-  for (const span of splitReadSpans(makeReadSpans(inputItems), MAX_READ_REGISTER_QUANTITY)) {
+  if (inputSpans.length) {
     sent = true
-    const response = await transport.sendManagedFrame(
-      buildReadFrame(slaveAddress, 0x04, span.address, span.quantity),
+    const values = await modbusAccess.readSpans(
+      slaveAddress,
+      0x04,
+      inputSpans,
       '参数读取',
-      {
-        address: span.address,
-        functionCode: 0x04,
-        kind: 'params-read',
-        quantity: span.quantity,
-        slaveAddress
-      }
+      'params-read'
     )
-    addWordReadValues(readValues, span.address, response)
+    if (!values) return false
+    mergeReadValues(readValues, values)
   }
 
   if (!sent) {
@@ -224,25 +97,6 @@ async function readGroup(data, groupKey) {
   return paramsPageState.applyReadValues(data, readValues)
 }
 
-async function readSingleHoldingWord(slaveAddress, address) {
-  const response = await transport.sendManagedFrame(
-    buildReadFrame(slaveAddress, 0x03, address, 1),
-    '读取配对寄存器',
-    {
-      address,
-      functionCode: 0x03,
-      kind: 'params-pair-read',
-      quantity: 1,
-      slaveAddress
-    },
-    {}
-  )
-
-  if (!response || !Array.isArray(response.words) || response.words.length < 1) return null
-
-  return response.words[0] & 0xFFFF
-}
-
 async function buildHoldingWriteEntries(slaveAddress, items) {
   const normalEntries = []
   const byteGroups = {}
@@ -251,7 +105,7 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
     if (getAreaKey(item) !== 'holding') return
 
     if (item.type === 'uint8_t' && item.bytePosition) {
-      const address = parseAddress(item.address)
+      const address = parseRegisterAddress(item.address)
       const group = byteGroups[address] || {
         address,
         high: null,
@@ -267,12 +121,12 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
 
     const words = item.type === 'float'
       ? floatToWords(writeNumber)
-      : [toWord(writeNumber)]
+      : [toRegisterWord(writeNumber)]
 
     if (!words || words.some((word) => word === null)) return
 
     normalEntries.push({
-      address: parseAddress(item.address),
+      address: parseRegisterAddress(item.address),
       label: item.name,
       values: words
     })
@@ -280,14 +134,19 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
 
   for (const addressText of Object.keys(byteGroups)) {
     const group = byteGroups[addressText]
-    const highValue = group.high ? toWord(group.high.writeValue) : null
-    const lowValue = group.low ? toWord(group.low.writeValue) : null
+    const highValue = group.high ? toRegisterWord(group.high.writeValue) : null
+    const lowValue = group.low ? toRegisterWord(group.low.writeValue) : null
 
     if (highValue === null && lowValue === null) continue
 
     let baseWord = 0
     if (highValue === null || lowValue === null) {
-      const readWord = await readSingleHoldingWord(slaveAddress, group.address)
+      const readWord = await modbusAccess.readSingleHoldingWord(
+        slaveAddress,
+        group.address,
+        '读取配对寄存器',
+        'params-pair-read'
+      )
       if (!Number.isInteger(readWord)) continue
 
       baseWord = readWord
@@ -309,30 +168,25 @@ async function buildHoldingWriteEntries(slaveAddress, items) {
 }
 
 async function writeGroup(data, groupKey) {
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   const items = expandItems(getGroupItems(data, groupKey))
-  const coilItems = items.filter((item) => getAreaKey(item) === 'coil')
+  const coilItems = items.filter((item) => getAreaKey(item) === 'coil' && item.isDirty)
   const holdingItems = items.filter((item) => getAreaKey(item) === 'holding')
   let sent = false
 
   for (const item of coilItems) {
     const checked = Number(item.writeValue) !== 0
-    const address = parseAddress(item.address)
+    const address = parseRegisterAddress(item.address)
 
     sent = true
-    const response = await transport.sendManagedFrame(
-      buildWriteSingleCoilFrame(slaveAddress, address, checked),
+    const response = await modbusAccess.writeSingleCoil(
+      slaveAddress,
+      address,
+      checked,
       item.name,
-      {
-        address,
-        functionCode: 0x05,
-        kind: 'params-coil-write',
-        quantity: 1,
-        value: checked ? 0xFF00 : 0x0000,
-        slaveAddress
-      }
+      'params-coil-write'
     )
     if (!response) return false
   }
@@ -340,16 +194,12 @@ async function writeGroup(data, groupKey) {
   const holdingEntries = await buildHoldingWriteEntries(slaveAddress, holdingItems)
   for (const entry of holdingEntries) {
     sent = true
-    const response = await transport.sendManagedFrame(
-      buildWriteMultipleRegistersFrame(slaveAddress, entry.address, entry.values),
+    const response = await modbusAccess.writeMultipleRegisters(
+      slaveAddress,
+      entry.address,
+      entry.values,
       entry.label,
-      {
-        address: entry.address,
-        functionCode: 0x10,
-        kind: 'params-holding-write',
-        quantity: entry.values.length,
-        slaveAddress
-      }
+      'params-holding-write'
     )
     if (!response) return false
   }
@@ -362,23 +212,18 @@ async function writeGroup(data, groupKey) {
 }
 
 async function writeSwitchRegister(item) {
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null || !item) return false
 
-  const address = parseAddress(item.address)
+  const address = parseRegisterAddress(item.address)
   const checked = Number(item.writeValue) !== 0
 
-  return transport.sendManagedFrame(
-    buildWriteSingleCoilFrame(slaveAddress, address, checked),
+  return modbusAccess.writeSingleCoil(
+    slaveAddress,
+    address,
+    checked,
     item.name,
-    {
-      address,
-      functionCode: 0x05,
-      kind: 'params-switch-write',
-      quantity: 1,
-      value: checked ? 0xFF00 : 0x0000,
-      slaveAddress
-    }
+    'params-switch-write'
   )
 }
 

+ 315 - 0
utils/params-view-model.js

@@ -0,0 +1,315 @@
+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 {
+  getStatusPageState
+} = require('./status-page-state')
+const themeService = require('./theme-service')
+
+const GROUP_LABELS = {
+  dq: 'DQ轴电流环参数',
+  estimator: '估算器参数',
+  oil: '上油参数',
+  preposition: '预定位配置',
+  protection: '保护',
+  speedLoop: '速度环路',
+  tailwind: '顺逆风配置',
+  vsp: 'VSP曲线'
+}
+
+const COMBINED_GROUPS = {
+  speed: ['speedLoop', 'vsp', 'oil'],
+  startup: ['tailwind', 'preposition']
+}
+
+const COMBINED_GROUP_LABELS = {
+  speed: '速度管理',
+  startup: '启动位置管理'
+}
+const PARAM_VIEWS = [
+  'driver',
+  'protection',
+  'estimator',
+  'dq',
+  'startup',
+  'speed',
+  'genericModbus',
+  'status'
+]
+
+function getGroupLabel(groupKey) {
+  return GROUP_LABELS[groupKey] || '参数'
+}
+
+function getCombinedGroupKeys(viewKey) {
+  return COMBINED_GROUPS[viewKey] || []
+}
+
+function getCombinedGroupLabel(viewKey) {
+  return COMBINED_GROUP_LABELS[viewKey] || '参数'
+}
+
+function hasDirtyItem(items = []) {
+  return items.some((item) => !!item && !!item.isDirty)
+}
+
+function hasWritableGroupChanges(data, groupKey) {
+  if (groupKey === 'tailwind') {
+    return hasDirtyItem(data.tailwindSwitchRegisters) || hasDirtyItem(data.tailwindAtoBandwidthDisplayRegisters)
+  }
+  if (groupKey === 'preposition') {
+    return hasDirtyItem(data.prepositionSwitchRegisters) || hasDirtyItem(data.prepositionParameterDisplayRegisters)
+  }
+  if (groupKey === 'speedLoop') {
+    return hasDirtyItem(data.speedLoopInputDisplayRegisters) || hasDirtyItem(data.speedLoopExtraDisplayRegisters)
+  }
+  if (groupKey === 'vsp') {
+    return hasDirtyItem(data.vspCurveRegisters) || !!(data.speedSlopeRegister && data.speedSlopeRegister.isDirty)
+  }
+  if (groupKey === 'oil') return hasDirtyItem(data.oilParameterInputRegisters)
+
+  return false
+}
+
+function getControlViewState(controlState = controlService.getState()) {
+  return {
+    ...controlState,
+    ...getStatusPageState(controlState.userStatusCount),
+    canReadStatus: !!controlState.connectedDevice
+  }
+}
+
+function getProtocolFlags(settingsState = settingsService.getState()) {
+  return {
+    isGenericProtocol: settingsState.modbusProtocolFilter === 'generic',
+    isMotorControlProtocol: settingsState.modbusProtocolFilter !== 'generic'
+  }
+}
+
+function getPageState(
+  paramsState = syncService.getParamsSnapshot(),
+  controlState = controlService.getState()
+) {
+  const settingsState = settingsService.getState()
+
+  return {
+    ...paramsPageState.refreshState(paramsState),
+    ...getControlViewState(controlState),
+    ...genericModbusService.getState(),
+    ...themeService.getState(),
+    ...settingsState,
+    ...getProtocolFlags(settingsState)
+  }
+}
+
+function resolveActiveParamView(currentView, settingsState) {
+  if (settingsState.modbusProtocolFilter === 'generic') {
+    return currentView === 'genericModbus' ? currentView : 'genericModbus'
+  }
+
+  return PARAM_VIEWS.includes(currentView) && currentView !== 'genericModbus' ? currentView : ''
+}
+
+function getSettingsPageState(currentData, settingsState) {
+  const activeParamView = resolveActiveParamView(currentData.activeParamView, settingsState)
+
+  return {
+    ...settingsState,
+    activeParamView,
+    ...getProtocolFlags(settingsState)
+  }
+}
+
+function getVisiblePageState(currentData) {
+  const snapshot = syncService.getParamsSnapshot()
+  const nextParamsState = snapshot.syncVersion && snapshot.syncVersion !== currentData.syncVersion
+    ? paramsPageState.refreshState(snapshot)
+    : paramsPageState.refreshState(currentData)
+  const pageState = {
+    ...nextParamsState,
+    ...getControlViewState(),
+    ...genericModbusService.getState(),
+    ...themeService.getState(),
+    ...settingsService.getState()
+  }
+  return {
+    ...pageState,
+    activeParamView: resolveActiveParamView(currentData.activeParamView, pageState),
+    ...getProtocolFlags(pageState)
+  }
+}
+
+function getGenericOption(options, index) {
+  return options[Number(index)] || options[0] || {}
+}
+
+function createGenericModbusDialogState(overrides = {}) {
+  const registerType = getGenericOption(genericModbusService.REGISTER_TYPE_OPTIONS, 0)
+  const dataType = getGenericOption(genericModbusService.DATA_TYPE_OPTIONS, 0)
+
+  return {
+    cancelText: '取消',
+    confirmText: '确认',
+    dataTypeIndex: 0,
+    dataTypeText: dataType.label || '',
+    groupId: '',
+    groupName: '',
+    mode: '',
+    name: '',
+    quantity: '1',
+    registerIndex: -1,
+    registerTypeIndex: 0,
+    registerTypeText: registerType.label || '',
+    remark: '',
+    startAddress: '0000',
+    title: '',
+    textByteLength: '32',
+    showTextLength: false,
+    unit: '',
+    visible: false,
+    maxValue: '',
+    minValue: '',
+    addressText: '',
+    displayValue: '',
+    rawValueText: '',
+    showDataType: false,
+    showRange: false,
+    showUnit: false,
+    readOnly: false,
+    ...overrides
+  }
+}
+
+function createGenericGroupDialogState(group) {
+  const isEdit = !!group
+  const registerTypeIndex = isEdit ? (group.registerTypeIndex || 0) : 0
+  const registerType = getGenericOption(genericModbusService.REGISTER_TYPE_OPTIONS, registerTypeIndex)
+
+  return createGenericModbusDialogState({
+    confirmText: isEdit ? '保存' : '确认',
+    groupId: isEdit ? group.id : '',
+    groupName: isEdit ? group.name : '寄存器组',
+    mode: isEdit ? 'editGroup' : 'createGroup',
+    quantity: isEdit ? String(group.quantity || 1) : '1',
+    registerTypeIndex,
+    registerTypeText: registerType.label || '',
+    startAddress: isEdit && group.startAddressText ? group.startAddressText.replace(/^0x/i, '') : '0000',
+    title: isEdit ? '编辑寄存器组' : '添加寄存器组',
+    visible: true
+  })
+}
+
+function createGenericRegisterDialogState(mode, group, register, registerIndex) {
+  const isView = mode === 'viewRegister'
+  const dataTypeIndex = register.dataTypeIndex || 0
+  const dataType = getGenericOption(genericModbusService.DATA_TYPE_OPTIONS, dataTypeIndex)
+
+  return createGenericModbusDialogState({
+    cancelText: isView ? '关闭' : '取消',
+    confirmText: isView ? '' : '保存',
+    dataTypeIndex,
+    dataTypeText: register.dataTypeText || dataType.label || '',
+    groupId: group.id,
+    groupName: group.name,
+    mode,
+    name: register.name,
+    registerIndex,
+    registerTypeIndex: group.registerTypeIndex || 0,
+    remark: register.remark || '',
+    startAddress: group.startAddressText ? group.startAddressText.replace(/^0x/i, '') : '0000',
+    title: isView ? '寄存器信息' : '寄存器配置',
+    textByteLength: String(register.textByteLength || '32'),
+    showTextLength: !!register.showTextLength,
+    unit: register.unit || '',
+    visible: true,
+    maxValue: register.maxValue || '',
+    minValue: register.minValue || '',
+    addressText: register.addressRangeText || register.addressText || '',
+    displayValue: register.displayValue || '',
+    rawValueText: register.rawValueText || '--',
+    showDataType: !!register.showDataType,
+    showRange: !!register.showRange,
+    showUnit: !!register.showUnit,
+    readOnly: isView
+  })
+}
+
+function getGenericDialogDataTypeState(dialog, dataTypeOptions, dataTypeIndex) {
+  const dataType = getGenericOption(dataTypeOptions, dataTypeIndex)
+  const isTextType = dataType.kind === 'text'
+  const showUnit = dataType.kind === 'number' && dataType.key !== 'hex'
+
+  return {
+    dataTypeIndex,
+    dataTypeText: dataType.label || '',
+    maxValue: isTextType ? '' : dialog.maxValue,
+    minValue: isTextType ? '' : dialog.minValue,
+    showRange: !isTextType,
+    showTextLength: isTextType,
+    showUnit,
+    textByteLength: isTextType ? (dialog.textByteLength || '32') : dialog.textByteLength,
+    unit: showUnit ? dialog.unit : ''
+  }
+}
+
+function createGenericGroupConfig(dialog) {
+  return {
+    groupName: dialog.groupName,
+    quantity: dialog.quantity,
+    registerTypeIndex: dialog.registerTypeIndex,
+    startAddress: dialog.startAddress
+  }
+}
+
+function createGenericRegisterChangedData(dialog, dataTypeOptions) {
+  const dataType = getGenericOption(dataTypeOptions, dialog.dataTypeIndex)
+  const isTextType = dataType.kind === 'text'
+  const showUnit = dataType.kind === 'number' && dataType.key !== 'hex'
+
+  return {
+    name: dialog.name,
+    dataType: dataType.key,
+    maxValue: isTextType ? '' : dialog.maxValue,
+    minValue: isTextType ? '' : dialog.minValue,
+    remark: dialog.remark,
+    textByteLength: isTextType ? dialog.textByteLength : '',
+    unit: showUnit ? dialog.unit : ''
+  }
+}
+
+function findGenericGroup(groups, groupId) {
+  return (groups || []).find((item) => item.id === groupId) || null
+}
+
+function findGenericRegister(groups, groupId, registerIndex) {
+  const group = findGenericGroup(groups, groupId)
+  const register = group && group.registers ? group.registers[registerIndex] : null
+
+  return {
+    group,
+    register
+  }
+}
+
+module.exports = {
+  createGenericGroupConfig,
+  createGenericGroupDialogState,
+  createGenericModbusDialogState,
+  createGenericRegisterChangedData,
+  createGenericRegisterDialogState,
+  findGenericGroup,
+  findGenericRegister,
+  getCombinedGroupKeys,
+  getCombinedGroupLabel,
+  getControlViewState,
+  getGenericDialogDataTypeState,
+  getGenericOption,
+  getGroupLabel,
+  getPageState,
+  getSettingsPageState,
+  getVisiblePageState,
+  hasWritableGroupChanges,
+  resolveActiveParamView
+}

+ 14 - 0
utils/platform-utils.js

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

+ 245 - 0
utils/reactance-calculator.js

@@ -0,0 +1,245 @@
+const {
+  formatMagnitudeNumber,
+  formatScaledValue,
+  getOption,
+  normalizeIndex,
+  parsePositiveNumber,
+  selectBestUnit
+} = require('./calculator-helpers')
+
+const TWO_PI = 2 * Math.PI
+
+const MODE_OPTIONS = [
+  { key: 'capacitive', label: '容抗' },
+  { key: 'inductive', label: '感抗' }
+]
+
+const REACTANCE_UNIT_OPTIONS = [
+  { label: 'mΩ', factor: 1e-3 },
+  { label: 'Ω', factor: 1 },
+  { label: 'kΩ', factor: 1e3 },
+  { label: 'MΩ', factor: 1e6 },
+  { label: 'GΩ', factor: 1e9 }
+]
+
+const ADMITTANCE_UNIT_OPTIONS = [
+  { label: 'pS', factor: 1e-12 },
+  { label: 'nS', factor: 1e-9 },
+  { label: 'μS', factor: 1e-6 },
+  { label: 'mS', factor: 1e-3 },
+  { label: 'S', factor: 1 },
+  { label: 'kS', factor: 1e3 },
+  { label: 'MS', factor: 1e6 }
+]
+
+const CAPACITANCE_UNIT_OPTIONS = [
+  { label: 'pF', factor: 1e-12 },
+  { label: 'nF', factor: 1e-9 },
+  { label: 'μF', factor: 1e-6 },
+  { label: 'mF', factor: 1e-3 },
+  { label: 'F', factor: 1 }
+]
+
+const INDUCTANCE_UNIT_OPTIONS = [
+  { label: 'pH', factor: 1e-12 },
+  { label: 'nH', factor: 1e-9 },
+  { label: 'μH', factor: 1e-6 },
+  { label: 'mH', factor: 1e-3 },
+  { label: 'H', factor: 1 },
+  { label: 'kH', factor: 1e3 }
+]
+
+const FREQUENCY_UNIT_OPTIONS = [
+  { label: 'Hz', factor: 1 },
+  { label: 'kHz', factor: 1e3 },
+  { label: 'MHz', factor: 1e6 },
+  { label: 'GHz', factor: 1e9 },
+  { label: 'THz', factor: 1e12 }
+]
+
+function getReactiveUnitOptions(modeKey) {
+  return modeKey === 'inductive' ? INDUCTANCE_UNIT_OPTIONS : CAPACITANCE_UNIT_OPTIONS
+}
+
+function formatUnitValue(baseValue, options, fallbackIndex = 0) {
+  if (!Number.isFinite(baseValue)) return '--'
+
+  const selected = selectBestUnit(options, baseValue, fallbackIndex)
+  return formatScaledValue(baseValue, selected.unit, {
+    fallbackText: '',
+    includeUnit: true
+  })
+}
+
+function createEmptyResultRows(modeKey) {
+  const isInductive = modeKey === 'inductive'
+
+  return [
+    {
+      label: isInductive ? '感抗 XL' : '容抗 XC',
+      meta: isInductive ? 'XL = 2πfL' : 'XC = 1 / (2πfC)',
+      value: '--'
+    },
+    {
+      label: isInductive ? '感抗导纳 YL' : '容抗导纳 YC',
+      meta: 'Y = 1 / X',
+      value: '--'
+    }
+  ]
+}
+
+function calculateResultRows(modeKey, frequency, reactive) {
+  const rows = createEmptyResultRows(modeKey)
+  if (!Number.isFinite(frequency) || !Number.isFinite(reactive)) return rows
+
+  const reactance = modeKey === 'inductive'
+    ? TWO_PI * frequency * reactive
+    : 1 / (TWO_PI * frequency * reactive)
+  const admittance = 1 / reactance
+
+  return [
+    {
+      ...rows[0],
+      value: formatUnitValue(reactance, REACTANCE_UNIT_OPTIONS, 1)
+    },
+    {
+      ...rows[1],
+      value: formatUnitValue(admittance, ADMITTANCE_UNIT_OPTIONS, 4)
+    }
+  ]
+}
+
+function buildState(source = {}) {
+  const modeIndex = normalizeIndex(source.reactanceModeIndex, MODE_OPTIONS, 0)
+  const mode = getOption(MODE_OPTIONS, modeIndex)
+  const reactiveUnitOptions = getReactiveUnitOptions(mode.key)
+  const capacitanceUnitIndex = normalizeIndex(source.reactanceCapacitanceUnitIndex, CAPACITANCE_UNIT_OPTIONS, 1)
+  const frequencyUnitIndex = normalizeIndex(source.reactanceFrequencyUnitIndex, FREQUENCY_UNIT_OPTIONS, 0)
+  const inductanceUnitIndex = normalizeIndex(source.reactanceInductanceUnitIndex, INDUCTANCE_UNIT_OPTIONS, 3)
+  const reactiveUnitIndex = mode.key === 'inductive' ? inductanceUnitIndex : capacitanceUnitIndex
+  const frequencyUnit = getOption(FREQUENCY_UNIT_OPTIONS, frequencyUnitIndex)
+  const reactiveUnit = getOption(reactiveUnitOptions, reactiveUnitIndex)
+  const frequencyText = String(source.reactanceFrequencyValue || '')
+  const reactiveText = String(source.reactanceReactiveValue || '')
+  const frequencyNumber = parsePositiveNumber(frequencyText)
+  const reactiveNumber = parsePositiveNumber(reactiveText)
+  const invalidInput = [frequencyNumber, reactiveNumber].some((value) => Number.isNaN(value))
+  const values = {
+    frequency: Number.isFinite(frequencyNumber) ? frequencyNumber * frequencyUnit.factor : null,
+    reactive: Number.isFinite(reactiveNumber) ? reactiveNumber * reactiveUnit.factor : null
+  }
+  let frequencyDisplayUnit = frequencyUnit
+  let frequencyDisplayUnitIndex = frequencyUnitIndex
+  let reactiveDisplayUnit = reactiveUnit
+  let reactiveDisplayUnitIndex = reactiveUnitIndex
+
+  let frequencyDisplayValue = Number.isFinite(frequencyNumber)
+    ? formatMagnitudeNumber(frequencyNumber, { fallbackText: '' })
+    : frequencyText
+  let reactiveDisplayValue = Number.isFinite(reactiveNumber)
+    ? formatMagnitudeNumber(reactiveNumber, { fallbackText: '' })
+    : reactiveText
+
+  const resultRows = invalidInput
+    ? createEmptyResultRows(mode.key)
+    : calculateResultRows(mode.key, values.frequency, values.reactive)
+
+  return {
+    reactanceCapacitanceUnitIndex: mode.key === 'capacitive' ? reactiveDisplayUnitIndex : capacitanceUnitIndex,
+    reactanceErrorText: invalidInput ? '输入值需大于 0' : '',
+    reactanceFrequencyDisplayValue: frequencyDisplayValue,
+    reactanceFrequencyUnitIndex: frequencyDisplayUnitIndex,
+    reactanceFrequencyUnitOptions: FREQUENCY_UNIT_OPTIONS,
+    reactanceFrequencyUnitText: frequencyDisplayUnit.label,
+    reactanceFrequencyValue: frequencyText,
+    reactanceInductanceUnitIndex: mode.key === 'inductive' ? reactiveDisplayUnitIndex : inductanceUnitIndex,
+    reactanceModeIndex: modeIndex,
+    reactanceModeKey: mode.key,
+    reactanceModeOptions: MODE_OPTIONS,
+    reactanceModeText: mode.label,
+    reactanceReactiveDisplayValue: reactiveDisplayValue,
+    reactanceReactiveName: mode.key === 'inductive' ? '电感' : '电容',
+    reactanceReactiveSymbol: mode.key === 'inductive' ? 'L' : 'C',
+    reactanceReactiveUnitIndex: reactiveDisplayUnitIndex,
+    reactanceReactiveUnitOptions: reactiveUnitOptions,
+    reactanceReactiveUnitText: reactiveDisplayUnit.label,
+    reactanceReactiveValue: reactiveText,
+    reactanceResultRows: resultRows
+  }
+}
+
+function createInitialState() {
+  return buildState({
+    reactanceCapacitanceUnitIndex: 1,
+    reactanceFrequencyUnitIndex: 0,
+    reactanceInductanceUnitIndex: 3,
+    reactanceModeIndex: 0,
+    reactanceReactiveValue: ''
+  })
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+function clearInputs(state = {}) {
+  return updateState(state, {
+    reactanceFrequencyValue: '',
+    reactanceReactiveValue: ''
+  })
+}
+
+function normalizeValue(state, fieldKey, fieldValue) {
+  const source = buildState(state)
+  const config = {
+    frequency: {
+      options: FREQUENCY_UNIT_OPTIONS,
+      unitIndex: source.reactanceFrequencyUnitIndex,
+      unitIndexKey: 'reactanceFrequencyUnitIndex',
+      valueKey: 'reactanceFrequencyValue'
+    },
+    reactive: {
+      options: source.reactanceReactiveUnitOptions,
+      unitIndex: source.reactanceReactiveUnitIndex,
+      unitIndexKey: source.reactanceModeKey === 'inductive'
+        ? 'reactanceInductanceUnitIndex'
+        : 'reactanceCapacitanceUnitIndex',
+      valueKey: 'reactanceReactiveValue'
+    }
+  }[fieldKey]
+
+  if (!config) return source
+
+  const text = fieldValue === undefined ? source[config.valueKey] : fieldValue
+  const numberValue = parsePositiveNumber(text)
+  if (!Number.isFinite(numberValue)) {
+    return updateState(source, {
+      [config.valueKey]: text
+    })
+  }
+
+  const currentUnit = getOption(config.options, config.unitIndex)
+  const baseValue = numberValue * currentUnit.factor
+  const selected = selectBestUnit(config.options, baseValue, config.unitIndex)
+
+  return updateState(source, {
+    [config.unitIndexKey]: selected.index,
+    [config.valueKey]: formatScaledValue(baseValue, selected.unit, { fallbackText: '' })
+  })
+}
+
+module.exports = {
+  ADMITTANCE_UNIT_OPTIONS,
+  CAPACITANCE_UNIT_OPTIONS,
+  FREQUENCY_UNIT_OPTIONS,
+  INDUCTANCE_UNIT_OPTIONS,
+  MODE_OPTIONS,
+  REACTANCE_UNIT_OPTIONS,
+  clearInputs,
+  createInitialState,
+  normalizeValue,
+  updateState
+}

+ 281 - 0
utils/refrigeration-calculator.js

@@ -0,0 +1,281 @@
+const {
+  formatMagnitudeNumber,
+  getOption,
+  normalizeIndex,
+  parseLooseNumber,
+  selectBestUnit
+} = require('./calculator-helpers')
+
+const MODE_OPTIONS = [
+  { key: 'water', label: '水侧冷量' },
+  { key: 'airSensible', label: '风侧显热' },
+  { key: 'airEnthalpy', label: '风侧焓差' },
+  { key: 'cop', label: 'COP' },
+  { key: 'carnot', label: '卡诺COP' },
+  { key: 'temperature', label: '过热过冷' }
+]
+
+const FIELD_GROUPS = {
+  airEnthalpy: [
+    { key: 'coolingAirFlow', label: '风量', unit: 'm³/h', placeholder: '3000' },
+    { key: 'coolingAirInletEnthalpy', label: '入口焓值', unit: 'kJ/kg', placeholder: '58' },
+    { key: 'coolingAirOutletEnthalpy', label: '出口焓值', unit: 'kJ/kg', placeholder: '42' }
+  ],
+  airSensible: [
+    { key: 'coolingAirFlow', label: '风量', unit: 'm³/h', placeholder: '3000' },
+    { key: 'coolingAirInletTemp', label: '入口温度', unit: '°C', placeholder: '27' },
+    { key: 'coolingAirOutletTemp', label: '出口温度', unit: '°C', placeholder: '15' }
+  ],
+  carnot: [
+    { key: 'coolingEvapTemp', label: '蒸发温度', unit: '°C', placeholder: '0' },
+    { key: 'coolingCondTemp', label: '冷凝温度', unit: '°C', placeholder: '40' }
+  ],
+  cop: [
+    { key: 'coolingCapacity', label: '制冷量', unit: 'kW', placeholder: '10' },
+    { key: 'coolingInputPower', label: '输入功率', unit: 'kW', placeholder: '2.5' }
+  ],
+  temperature: [
+    { key: 'coolingSuctionTemp', label: '吸气温度', unit: '°C', placeholder: '8' },
+    { key: 'coolingEvapTemp', label: '蒸发饱和温度', unit: '°C', placeholder: '2' },
+    { key: 'coolingCondTemp', label: '冷凝饱和温度', unit: '°C', placeholder: '45' },
+    { key: 'coolingLiquidTemp', label: '液管温度', unit: '°C', placeholder: '39' }
+  ],
+  water: [
+    { key: 'coolingWaterFlow', label: '水流量', unit: 'm³/h', placeholder: '10' },
+    { key: 'coolingWaterInlet', label: '入口温度', unit: '°C', placeholder: '12' },
+    { key: 'coolingWaterOutlet', label: '出口温度', unit: '°C', placeholder: '7' }
+  ]
+}
+
+const FORMULA_TEXT = {
+  airEnthalpy: 'Q = 风量 × Δh / 3000',
+  airSensible: 'Q = 0.000335 × 风量 × ΔT',
+  carnot: 'COP = Te(K) / (Tc(K) - Te(K))',
+  cop: 'COP = 制冷量 / 输入功率',
+  temperature: '过热度 = 吸气 - 蒸发饱和,过冷度 = 冷凝饱和 - 液管',
+  water: 'Q = 1.163 × 水流量 × ΔT'
+}
+
+const POWER_UNITS = [
+  { label: 'W', factor: 0.001 },
+  { label: 'kW', factor: 1 },
+  { label: 'MW', factor: 1000 }
+]
+const COOLING_FORMAT_STEPS = [
+  { min: 1000, decimals: 2 },
+  { min: 1, decimals: 3 },
+  { min: 0.001, decimals: 5 },
+  { min: 0, decimals: 8 }
+]
+
+const COOLING_VALUE_KEYS = [
+  'coolingAirFlow',
+  'coolingAirInletEnthalpy',
+  'coolingAirInletTemp',
+  'coolingAirOutletEnthalpy',
+  'coolingAirOutletTemp',
+  'coolingCapacity',
+  'coolingCondTemp',
+  'coolingEvapTemp',
+  'coolingInputPower',
+  'coolingLiquidTemp',
+  'coolingSuctionTemp',
+  'coolingWaterFlow',
+  'coolingWaterInlet',
+  'coolingWaterOutlet'
+]
+
+function formatNumber(value) {
+  return formatMagnitudeNumber(value, {
+    fallbackText: '--',
+    steps: COOLING_FORMAT_STEPS
+  })
+}
+
+function formatPower(kwValue) {
+  const unit = selectBestUnit(POWER_UNITS, kwValue, 1).unit
+
+  return `${formatNumber(kwValue / unit.factor)} ${unit.label}`
+}
+
+function makeRow(label, value, unit = '') {
+  return {
+    label,
+    value: unit ? `${formatNumber(value)} ${unit}` : formatNumber(value)
+  }
+}
+
+function hasAll(values, keys) {
+  return keys.every((key) => Number.isFinite(values[key]))
+}
+
+function calculate(modeKey, values) {
+  if (Object.keys(values).some((key) => Number.isNaN(values[key]))) {
+    return {
+      errorText: '输入值格式无效',
+      resultRows: []
+    }
+  }
+
+  if (modeKey === 'water') {
+    if (!hasAll(values, ['coolingWaterFlow', 'coolingWaterInlet', 'coolingWaterOutlet'])) return { errorText: '', resultRows: [] }
+    if (values.coolingWaterFlow < 0) return { errorText: '流量不能为负数', resultRows: [] }
+
+    const deltaT = Math.abs(values.coolingWaterInlet - values.coolingWaterOutlet)
+    const capacityKw = 1.163 * values.coolingWaterFlow * deltaT
+
+    return {
+      errorText: '',
+      resultRows: [
+        { label: '冷量', value: formatPower(capacityKw) },
+        makeRow('温差', deltaT, '°C'),
+        makeRow('冷吨', capacityKw / 3.517, 'RT')
+      ]
+    }
+  }
+
+  if (modeKey === 'airSensible') {
+    if (!hasAll(values, ['coolingAirFlow', 'coolingAirInletTemp', 'coolingAirOutletTemp'])) return { errorText: '', resultRows: [] }
+    if (values.coolingAirFlow < 0) return { errorText: '风量不能为负数', resultRows: [] }
+
+    const deltaT = Math.abs(values.coolingAirInletTemp - values.coolingAirOutletTemp)
+    const capacityKw = 0.000335 * values.coolingAirFlow * deltaT
+
+    return {
+      errorText: '',
+      resultRows: [
+        { label: '显热冷量', value: formatPower(capacityKw) },
+        makeRow('温差', deltaT, '°C'),
+        makeRow('冷吨', capacityKw / 3.517, 'RT')
+      ]
+    }
+  }
+
+  if (modeKey === 'airEnthalpy') {
+    if (!hasAll(values, ['coolingAirFlow', 'coolingAirInletEnthalpy', 'coolingAirOutletEnthalpy'])) return { errorText: '', resultRows: [] }
+    if (values.coolingAirFlow < 0) return { errorText: '风量不能为负数', resultRows: [] }
+
+    const deltaH = Math.abs(values.coolingAirInletEnthalpy - values.coolingAirOutletEnthalpy)
+    const capacityKw = values.coolingAirFlow * deltaH / 3000
+
+    return {
+      errorText: '',
+      resultRows: [
+        { label: '总冷量', value: formatPower(capacityKw) },
+        makeRow('焓差', deltaH, 'kJ/kg'),
+        makeRow('冷吨', capacityKw / 3.517, 'RT')
+      ]
+    }
+  }
+
+  if (modeKey === 'cop') {
+    if (!hasAll(values, ['coolingCapacity', 'coolingInputPower'])) return { errorText: '', resultRows: [] }
+    if (values.coolingCapacity < 0 || values.coolingInputPower <= 0) return { errorText: '制冷量需大于等于0,输入功率需大于0', resultRows: [] }
+
+    const cop = values.coolingCapacity / values.coolingInputPower
+
+    return {
+      errorText: '',
+      resultRows: [
+        makeRow('COP', cop),
+        makeRow('EER', cop * 3.412),
+        { label: '制冷量', value: formatPower(values.coolingCapacity) }
+      ]
+    }
+  }
+
+  if (modeKey === 'carnot') {
+    if (!hasAll(values, ['coolingEvapTemp', 'coolingCondTemp'])) return { errorText: '', resultRows: [] }
+
+    const evapK = values.coolingEvapTemp + 273.15
+    const condK = values.coolingCondTemp + 273.15
+    if (evapK <= 0 || condK <= evapK) return { errorText: '冷凝温度需高于蒸发温度,且绝对温度需大于0K', resultRows: [] }
+
+    const coolingCop = evapK / (condK - evapK)
+    const heatingCop = condK / (condK - evapK)
+
+    return {
+      errorText: '',
+      resultRows: [
+        makeRow('制冷COP', coolingCop),
+        makeRow('制热COP', heatingCop),
+        makeRow('温差', values.coolingCondTemp - values.coolingEvapTemp, '°C')
+      ]
+    }
+  }
+
+  if (modeKey === 'temperature') {
+    if (!hasAll(values, ['coolingSuctionTemp', 'coolingEvapTemp', 'coolingCondTemp', 'coolingLiquidTemp'])) return { errorText: '', resultRows: [] }
+
+    return {
+      errorText: '',
+      resultRows: [
+        makeRow('过热度', values.coolingSuctionTemp - values.coolingEvapTemp, '°C'),
+        makeRow('过冷度', values.coolingCondTemp - values.coolingLiquidTemp, '°C')
+      ]
+    }
+  }
+
+  return {
+    errorText: '',
+    resultRows: []
+  }
+}
+
+function buildState(source = {}) {
+  const modeIndex = normalizeIndex(source.coolingModeIndex, MODE_OPTIONS, 0)
+  const mode = getOption(MODE_OPTIONS, modeIndex)
+  const rawValues = COOLING_VALUE_KEYS.reduce((result, key) => {
+    result[key] = String(source[key] === undefined || source[key] === null ? '' : source[key])
+    return result
+  }, {})
+  const values = COOLING_VALUE_KEYS.reduce((result, key) => {
+    result[key] = parseLooseNumber(rawValues[key])
+    return result
+  }, {})
+  const result = calculate(mode.key, values)
+  const fieldRows = (FIELD_GROUPS[mode.key] || []).map((field) => ({
+    ...field,
+    value: rawValues[field.key]
+  }))
+
+  return {
+    ...rawValues,
+    coolingAnyInput: COOLING_VALUE_KEYS.some((key) => rawValues[key].trim()),
+    coolingErrorText: result.errorText,
+    coolingFieldRows: fieldRows,
+    coolingFormulaText: FORMULA_TEXT[mode.key] || '',
+    coolingModeIndex: modeIndex,
+    coolingModeKey: mode.key,
+    coolingModeOptions: MODE_OPTIONS,
+    coolingModeText: mode.label,
+    coolingResultRows: result.resultRows
+  }
+}
+
+function createInitialState() {
+  return buildState({})
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+function clearInputs(state) {
+  const changedData = COOLING_VALUE_KEYS.reduce((result, key) => {
+    result[key] = ''
+    return result
+  }, {})
+
+  return updateState(state, changedData)
+}
+
+module.exports = {
+  MODE_OPTIONS,
+  clearInputs,
+  createInitialState,
+  updateState
+}

+ 10 - 0
utils/register-value-utils.js

@@ -49,6 +49,15 @@ function floatToWords(value) {
   return [view.getUint16(0, false), view.getUint16(2, false)]
 }
 
+function toRegisterWord(value) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return null
+
+  const word = Math.round(numberValue)
+
+  return word >= 0 && word <= 0xFFFF ? word : null
+}
+
 function addCoilReadValues(readValues, startAddress, quantity, response) {
   if (!readValues || !readValues.coils || !response || !Array.isArray(response.dataBytes)) return
 
@@ -82,5 +91,6 @@ module.exports = {
   floatToWords,
   getRegisterWordCache,
   toAddressKey,
+  toRegisterWord,
   wordsToFloat
 }

+ 14 - 9
utils/registers.js

@@ -1,3 +1,7 @@
+const {
+  parseHexInteger
+} = require('./base-utils')
+
 const MODBUS_AREAS = {
   coil: {
     key: 'coil',
@@ -44,7 +48,7 @@ function getRegisterCount(item) {
 function getAddressDisplay(address, registerCount) {
   if (registerCount <= 1) return hex(address)
 
-  const start = parseInt(address, 16)
+  const start = parseHexInteger(address)
   const end = start + registerCount - 1
 
   return `${hex(address)}-${hex(end.toString(16).toUpperCase())}`
@@ -153,9 +157,9 @@ const protectionSwitchRegisters = withArea([
   { address: '0C', type: 'uint8_t', name: '功率保护使能', value: false, writeValue: 0 },
   { address: '0D', type: 'uint8_t', name: '温度保护使能', value: false, writeValue: 0 },
   { address: '0E', type: 'uint8_t', name: '缺相保护使能', value: false, writeValue: 0 },
-  { address: '0F', type: 'uint8_t', name: 'PWM 丢失保护', value: false, writeValue: 0 },
-  { address: '10', type: 'uint8_t', name: '串口丢失保护', value: false, writeValue: 0 }
-], 'coil', '保护控制')
+  { address: '0F', type: 'uint8_t', name: 'PWM丢失保护使能', value: false, writeValue: 0 },
+  { address: '10', type: 'uint8_t', name: '串口保护使能', value: false, writeValue: 0 }
+], 'coil', '保护')
 
 const estimatorRegisters = withArea([
   { address: OBS_ADDRESSES.EK1, type: 'uint16_t', name: 'OBS_E1K', conversion: '2047 * 3.0 / 125.0 * LQ / TPWM_VALUE * 电流基准 / 电压基准' },
@@ -363,11 +367,11 @@ const protectionRegisters = withArea([
   { address: '85', type: 'uint16_t', name: '反电动势高阈值' },
   { address: '86', type: 'uint16_t', name: '速度中间值', unit: 'RPM' },
   { address: '87', type: 'uint16_t', name: '功率保护值', unit: 'W', conversion: '32767 * 保护值 / 电流采样最大值 / 电压采样最大值' },
-  { address: '88', type: 'uint16_t', name: '功率保护检测时间', unit: 'ms' },
+  { address: '88', type: 'uint16_t', name: '功率保护时间', unit: 'ms' },
   { address: '89', type: 'uint16_t', name: '温度保护值', unit: '℃' },
   { address: '8A', type: 'uint16_t', name: '温度恢复值', unit: '℃' },
-  { address: '8B', type: 'uint16_t', name: '温度保护检测时间', unit: 'ms' },
-  { address: '8C', type: 'uint16_t', name: '故障恢复检测时间', unit: 'ms' },
+  { address: '8B', type: 'uint16_t', name: '温度保护时间', unit: 'ms' },
+  { address: '8C', type: 'uint16_t', name: '故障恢复时间', unit: 's' },
   { address: '8D', type: 'uint16_t', name: '串口丢失检测时间', unit: 'ms' }
 ], 'holding', '保护配置')
 
@@ -406,7 +410,7 @@ const statusRegisters = withArea([
   { address: 'CC', type: 'uint16_t', name: '母线电压', unit: '0.1V', displayUnit: 'V' },
   { address: 'CD', type: 'uint16_t', name: '母线电流', unit: '0.01A', displayUnit: 'A' },
   { address: 'CE', type: 'uint16_t', name: '估算功率', unit: 'W' },
-  { address: 'CF', type: 'uint16_t', name: 'NTC 电压', unit: 'V', displayUnit: 'V' },
+  { address: 'CF', type: 'uint16_t', name: 'NTC 温度', unit: '℃', displayUnit: '℃' },
   { address: 'D0', type: 'uint16_t', name: '模拟输入电压', unit: 'V', displayUnit: 'V' },
   { address: 'D1', type: 'uint16_t', name: '频率', unit: 'Hz', displayUnit: 'Hz' },
   { address: 'D2', type: 'uint16_t', name: '占空比', unit: '%', displayUnit: '%' },
@@ -436,5 +440,6 @@ module.exports = {
   speedSlopeRegister,
   statusRegisters,
   tailwindSwitchRegisters,
-  getByteRegisterValue
+  getByteRegisterValue,
+  getRegisterCount
 }

+ 265 - 0
utils/settings-service.js

@@ -0,0 +1,265 @@
+const {
+  toFiniteNumber
+} = require('./calculation-context')
+const {
+  clampInteger
+} = require('./base-utils')
+const {
+  getWxApi
+} = require('./platform-utils')
+
+const STORAGE_KEY = 'app-settings'
+const MODBUS_PROTOCOL_OPTIONS = [
+  { key: 'motor-control', label: '电机控制协议' },
+  { key: 'generic', label: '通用协议' }
+]
+const DEFAULT_SETTINGS = {
+  genericModbusAutoPollEnabled: false,
+  genericModbusMaxPacketLength: 64,
+  genericModbusPollInterval: 100,
+  modbusSlaveAddress: 'F0',
+  modbusProtocolFilter: MODBUS_PROTOCOL_OPTIONS[0].key,
+  nightModeEnabled: false,
+  nightModeFollowSystem: true,
+  statusPollInterval: 100,
+  userStatusCount: 0
+}
+const STATUS_POLL_MIN_INTERVAL = 100
+const STATUS_POLL_MAX_INTERVAL = 3000
+const GENERIC_MODBUS_MIN_PACKET_LENGTH = 32
+
+const state = {
+  ...DEFAULT_SETTINGS
+}
+
+let initialized = false
+const subscribers = []
+
+function normalizeHexByte(value, fallback = DEFAULT_SETTINGS.modbusSlaveAddress) {
+  const fallbackText = String(fallback || DEFAULT_SETTINGS.modbusSlaveAddress).toUpperCase()
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+
+  if (!/^[0-9A-F]{1,2}$/i.test(hexText)) return fallbackText
+
+  return parseInt(hexText, 16).toString(16).toUpperCase().padStart(2, '0')
+}
+
+function normalizeGenericPacketLength(value, fallback = DEFAULT_SETTINGS.genericModbusMaxPacketLength) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return fallback
+
+  const rounded = Math.round(numberValue)
+  if (rounded <= 0) return 0
+
+  return Math.max(rounded, GENERIC_MODBUS_MIN_PACKET_LENGTH)
+}
+
+function normalizeModbusProtocolFilter(value) {
+  const key = String(value || '').trim()
+  const matchedOption = MODBUS_PROTOCOL_OPTIONS.find((option) => option.key === key)
+
+  return matchedOption ? matchedOption.key : DEFAULT_SETTINGS.modbusProtocolFilter
+}
+
+function parseHexByte(value, label = '从机地址') {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  const hexText = text.toUpperCase().startsWith('0X') ? text.slice(2) : text
+
+  if (!/^[0-9A-F]{1,2}$/i.test(hexText)) {
+    throw new Error(`${label}需为 00 - FF`)
+  }
+
+  return parseInt(hexText, 16)
+}
+
+function normalizeSettings(settings = {}) {
+  return {
+    genericModbusAutoPollEnabled: !!settings.genericModbusAutoPollEnabled,
+    genericModbusMaxPacketLength: normalizeGenericPacketLength(
+      settings.genericModbusMaxPacketLength,
+      DEFAULT_SETTINGS.genericModbusMaxPacketLength
+    ),
+    genericModbusPollInterval: clampInteger(
+      settings.genericModbusPollInterval,
+      STATUS_POLL_MIN_INTERVAL,
+      STATUS_POLL_MAX_INTERVAL,
+      DEFAULT_SETTINGS.genericModbusPollInterval
+    ),
+    modbusSlaveAddress: normalizeHexByte(settings.modbusSlaveAddress),
+    modbusProtocolFilter: normalizeModbusProtocolFilter(settings.modbusProtocolFilter),
+    nightModeEnabled: !!settings.nightModeEnabled,
+    nightModeFollowSystem: settings.nightModeFollowSystem !== false,
+    statusPollInterval: clampInteger(
+      settings.statusPollInterval,
+      STATUS_POLL_MIN_INTERVAL,
+      STATUS_POLL_MAX_INTERVAL,
+      DEFAULT_SETTINGS.statusPollInterval
+    ),
+    userStatusCount: clampInteger(settings.userStatusCount, 0, 999, DEFAULT_SETTINGS.userStatusCount)
+  }
+}
+
+function readStoredSettings() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.getStorageSync !== 'function') return {}
+
+  try {
+    return wxApi.getStorageSync(STORAGE_KEY) || {}
+  } catch (error) {
+    return {}
+  }
+}
+
+function persistSettings() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.setStorageSync !== 'function') return
+
+  try {
+    wxApi.setStorageSync(STORAGE_KEY, getState())
+  } catch (error) {}
+}
+
+function notify() {
+  const nextState = getState()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(nextState)
+  })
+}
+
+function setState(changedData, options = {}) {
+  Object.assign(state, normalizeSettings({
+    ...state,
+    ...changedData
+  }))
+
+  if (options.persist !== false) {
+    persistSettings()
+  }
+
+  notify()
+}
+
+function init() {
+  if (initialized) return
+
+  Object.assign(state, normalizeSettings({
+    ...DEFAULT_SETTINGS,
+    ...readStoredSettings()
+  }))
+  initialized = true
+}
+
+function getState() {
+  return {
+    ...state
+  }
+}
+
+function subscribe(subscriber) {
+  if (typeof subscriber !== 'function') return () => {}
+
+  init()
+  subscribers.push(subscriber)
+  subscriber(getState())
+
+  return () => {
+    const index = subscribers.indexOf(subscriber)
+    if (index >= 0) subscribers.splice(index, 1)
+  }
+}
+
+function setNightModeEnabled(value) {
+  init()
+  setState({
+    nightModeEnabled: !!value
+  })
+}
+
+function setNightModeFollowSystem(value) {
+  init()
+  setState({
+    nightModeFollowSystem: !!value
+  })
+}
+
+function setModbusSlaveAddress(value) {
+  init()
+  setState({
+    modbusSlaveAddress: normalizeHexByte(value, state.modbusSlaveAddress)
+  })
+}
+
+function getModbusSlaveAddress() {
+  init()
+  return parseHexByte(state.modbusSlaveAddress, 'Modbus从机地址')
+}
+
+function setModbusProtocolFilter(value) {
+  init()
+  setState({
+    modbusProtocolFilter: normalizeModbusProtocolFilter(value)
+  })
+}
+
+function setGenericModbusAutoPollEnabled(value) {
+  init()
+  setState({
+    genericModbusAutoPollEnabled: !!value
+  })
+}
+
+function setGenericModbusMaxPacketLength(value) {
+  init()
+  setState({
+    genericModbusMaxPacketLength: normalizeGenericPacketLength(value, state.genericModbusMaxPacketLength)
+  })
+}
+
+function setGenericModbusPollInterval(value) {
+  init()
+  setState({
+    genericModbusPollInterval: value
+  })
+}
+
+function getModbusProtocolFilter() {
+  init()
+  return state.modbusProtocolFilter
+}
+
+function setStatusPollInterval(value) {
+  init()
+  setState({
+    statusPollInterval: value
+  })
+}
+
+function setUserStatusCount(value, maxValue = 999) {
+  init()
+  setState({
+    userStatusCount: clampInteger(value, 0, maxValue, state.userStatusCount)
+  })
+}
+
+module.exports = {
+  GENERIC_MODBUS_MIN_PACKET_LENGTH,
+  MODBUS_PROTOCOL_OPTIONS,
+  STATUS_POLL_MAX_INTERVAL,
+  STATUS_POLL_MIN_INTERVAL,
+  getModbusProtocolFilter,
+  getModbusSlaveAddress,
+  getState,
+  init,
+  setGenericModbusAutoPollEnabled,
+  setGenericModbusMaxPacketLength,
+  setGenericModbusPollInterval,
+  setModbusSlaveAddress,
+  setModbusProtocolFilter,
+  setNightModeEnabled,
+  setNightModeFollowSystem,
+  setStatusPollInterval,
+  setUserStatusCount,
+  subscribe
+}

+ 45 - 0
utils/settings-view-model.js

@@ -0,0 +1,45 @@
+const settingsService = require('./settings-service')
+const themeService = require('./theme-service')
+const controlState = require('./control-page-state')
+const toolNavigation = require('./tool-navigation')
+
+function getModbusProtocolMeta(settingsState) {
+  const modbusProtocolOptions = settingsService.MODBUS_PROTOCOL_OPTIONS
+  const modbusProtocolIndex = Math.max(0, modbusProtocolOptions.findIndex((option) => (
+    option.key === settingsState.modbusProtocolFilter
+  )))
+  const modbusProtocolText = (modbusProtocolOptions[modbusProtocolIndex] || modbusProtocolOptions[0]).label
+
+  return {
+    isGenericProtocol: settingsState.modbusProtocolFilter === 'generic',
+    modbusProtocolIndex,
+    modbusProtocolOptions,
+    modbusProtocolText
+  }
+}
+
+function getSettingsPageState(
+  settingsState = settingsService.getState(),
+  themeState = themeService.getState()
+) {
+  const nightModeEnabledSwitch = settingsState.nightModeFollowSystem
+    ? themeState.themeMode === 'dark'
+    : settingsState.nightModeEnabled
+
+  return {
+    ...settingsState,
+    ...themeState,
+    ...getModbusProtocolMeta(settingsState),
+    genericModbusMinPacketLength: settingsService.GENERIC_MODBUS_MIN_PACKET_LENGTH,
+    maxUserStatusCount: controlState.MAX_USER_STATUS_COUNT,
+    nightModeEnabledSwitch,
+    statusPollMaxInterval: settingsService.STATUS_POLL_MAX_INTERVAL,
+    statusPollMinInterval: settingsService.STATUS_POLL_MIN_INTERVAL,
+    toolEntries: toolNavigation.getToolEntries()
+  }
+}
+
+module.exports = {
+  getModbusProtocolMeta,
+  getSettingsPageState
+}

+ 295 - 0
utils/smd-code-calculator.js

@@ -0,0 +1,295 @@
+const {
+  formatMagnitudeNumber,
+  getOption,
+  normalizeIndex,
+  selectBestUnit
+} = require('./calculator-helpers')
+
+const KIND_OPTIONS = [
+  { key: 'resistor', label: '电阻' },
+  { key: 'capacitor', label: '电容' }
+]
+
+const RESISTOR_FORMAT_OPTIONS = [
+  { key: '3digit', label: '3位' },
+  { key: '4digit', label: '4位' },
+  { key: 'eia96', label: 'EIA-96' }
+]
+
+const CAPACITOR_FORMAT_OPTIONS = [
+  { key: '3digit', label: '3位' },
+  { key: '4digit', label: '4位' },
+  { key: 'eia198', label: 'EIA-198' }
+]
+
+const RESISTOR_UNITS = [
+  { label: 'mΩ', factor: 1e-3 },
+  { label: 'Ω', factor: 1 },
+  { label: 'kΩ', factor: 1e3 },
+  { label: 'MΩ', factor: 1e6 },
+  { label: 'GΩ', factor: 1e9 }
+]
+
+const CAPACITOR_UNITS = [
+  { label: 'pF', factor: 1e-12 },
+  { label: 'nF', factor: 1e-9 },
+  { label: 'μF', factor: 1e-6 },
+  { label: 'mF', factor: 1e-3 },
+  { label: 'F', factor: 1 }
+]
+
+const EIA_96_VALUES = [
+  100, 102, 105, 107, 110, 113, 115, 118, 121, 124, 127, 130,
+  133, 137, 140, 143, 147, 150, 154, 158, 162, 165, 169, 174,
+  178, 182, 187, 191, 196, 200, 205, 210, 215, 221, 226, 232,
+  237, 243, 249, 255, 261, 267, 274, 280, 287, 294, 301, 309,
+  316, 324, 332, 340, 348, 357, 365, 374, 383, 392, 402, 412,
+  422, 432, 442, 453, 464, 475, 487, 499, 511, 523, 536, 549,
+  562, 576, 590, 604, 619, 634, 649, 665, 681, 698, 715, 732,
+  750, 768, 787, 806, 825, 845, 866, 887, 909, 931, 953, 976
+]
+
+const EIA_96_MULTIPLIERS = {
+  A: 1,
+  B: 10,
+  C: 100,
+  D: 1000,
+  E: 10000,
+  F: 100000,
+  H: 10,
+  R: 0.01,
+  S: 0.1,
+  X: 0.1,
+  Y: 0.01,
+  Z: 0.001
+}
+
+const EIA_198_VALUES = {
+  A: 1.0,
+  B: 1.1,
+  C: 1.2,
+  D: 1.3,
+  E: 1.5,
+  F: 1.6,
+  G: 1.8,
+  H: 2.0,
+  J: 2.2,
+  K: 2.4,
+  a: 2.5,
+  L: 2.7,
+  M: 3.0,
+  N: 3.3,
+  b: 3.5,
+  P: 3.6,
+  Q: 3.9,
+  d: 4.0,
+  R: 4.3,
+  e: 4.5,
+  S: 4.7,
+  f: 5.0,
+  T: 5.1,
+  U: 5.6,
+  m: 6.0,
+  V: 6.2,
+  W: 6.8,
+  n: 7.0,
+  X: 7.5,
+  t: 8.0,
+  Y: 8.2,
+  y: 9.0,
+  Z: 9.1
+}
+
+const EIA_198_MULTIPLIERS = {
+  0: 1,
+  1: 10,
+  2: 100,
+  3: 1000,
+  4: 10000,
+  5: 100000,
+  6: 1000000,
+  7: 10000000,
+  8: 0.01,
+  9: 0.1
+}
+
+function formatNumber(value) {
+  return formatMagnitudeNumber(value, { fallbackText: '' })
+}
+
+function formatValue(baseValue, units, fallbackIndex = 0) {
+  const unit = selectBestUnit(units, baseValue, fallbackIndex).unit
+
+  return `${formatNumber(baseValue / unit.factor)} ${unit.label}`
+}
+
+function sanitizeCode(text) {
+  return String(text === undefined || text === null ? '' : text).trim().replace(/\s+/g, '')
+}
+
+function parseDecimalCode(code, unitName) {
+  if (!/^[0-9]*[Rr][0-9]+$/.test(code)) return null
+
+  const normalized = code.replace(/[Rr]/, '.')
+  const value = Number(normalized)
+  if (!Number.isFinite(value)) throw new Error('编码格式无效')
+
+  return {
+    formula: `${code} = ${formatNumber(value)} ${unitName}`,
+    value
+  }
+}
+
+function parseDigitCode(code, significantDigits, unitName) {
+  const decimalParsed = parseDecimalCode(code, unitName)
+  if (decimalParsed) return decimalParsed
+
+  if (!new RegExp(`^\\d{${significantDigits + 1}}$`).test(code)) {
+    throw new Error(`请输入${significantDigits + 1}位数字编码`)
+  }
+
+  if (/^0+$/.test(code)) {
+    return {
+      formula: `${code} = 0 ${unitName}`,
+      value: 0
+    }
+  }
+
+  const significant = Number(code.slice(0, significantDigits))
+  const multiplier = Number(code.slice(significantDigits))
+  const value = significant * Math.pow(10, multiplier)
+
+  return {
+    formula: `${significant} × 10^${multiplier} ${unitName}`,
+    value
+  }
+}
+
+function parseEia96(code) {
+  const normalized = code.toUpperCase()
+  const match = normalized.match(/^(\d{2})([A-Z])$/)
+  if (!match) throw new Error('请输入2位数字加1位字母')
+
+  const valueIndex = Number(match[1])
+  const multiplier = EIA_96_MULTIPLIERS[match[2]]
+  if (valueIndex < 1 || valueIndex > 96 || multiplier === undefined) {
+    throw new Error('EIA-96编码无效')
+  }
+
+  const baseValue = EIA_96_VALUES[valueIndex - 1]
+
+  return {
+    formula: `${match[1]}=${baseValue},${match[2]}=×${formatNumber(multiplier)}`,
+    value: baseValue * multiplier
+  }
+}
+
+function parseEia198(code) {
+  const match = code.match(/^([A-Za-z])(\d)$/) || code.match(/^(\d)([A-Za-z])$/)
+  if (!match) throw new Error('请输入1位字母加1位数字')
+
+  const letter = Number.isNaN(Number(match[1])) ? match[1] : match[2]
+  const digit = Number.isNaN(Number(match[1])) ? match[2] : match[1]
+  const baseValue = EIA_198_VALUES[letter]
+  const multiplier = EIA_198_MULTIPLIERS[digit]
+  if (baseValue === undefined || multiplier === undefined) {
+    throw new Error('EIA-198编码无效')
+  }
+
+  return {
+    formula: `${letter}=${formatNumber(baseValue)},${digit}=×${formatNumber(multiplier)}pF`,
+    value: baseValue * multiplier * 1e-12
+  }
+}
+
+function calculate(kindKey, formatKey, codeText) {
+  const code = sanitizeCode(codeText)
+  if (!code) {
+    return {
+      formulaText: '',
+      resultText: '--',
+      errorText: ''
+    }
+  }
+
+  try {
+    if (kindKey === 'capacitor') {
+      const parsed = formatKey === 'eia198'
+        ? parseEia198(code)
+        : parseDigitCode(code, formatKey === '4digit' ? 3 : 2, 'pF')
+      const value = formatKey === 'eia198' ? parsed.value : parsed.value * 1e-12
+
+      return {
+        formulaText: parsed.formula,
+        resultText: formatValue(value, CAPACITOR_UNITS),
+        errorText: ''
+      }
+    }
+
+    const parsed = formatKey === 'eia96'
+      ? parseEia96(code)
+      : parseDigitCode(code, formatKey === '4digit' ? 3 : 2, 'Ω')
+
+    return {
+      formulaText: parsed.formula,
+        resultText: formatValue(parsed.value, RESISTOR_UNITS, 1),
+      errorText: ''
+    }
+  } catch (error) {
+    return {
+      formulaText: '',
+      resultText: '--',
+      errorText: error.message || '编码格式无效'
+    }
+  }
+}
+
+function buildState(source = {}) {
+  const kindIndex = normalizeIndex(source.smdKindIndex, KIND_OPTIONS, 0)
+  const kind = getOption(KIND_OPTIONS, kindIndex)
+  const formatOptions = kind.key === 'capacitor' ? CAPACITOR_FORMAT_OPTIONS : RESISTOR_FORMAT_OPTIONS
+  const currentFormatKey = source.smdFormatKey || (source.smdFormatOptions && source.smdFormatOptions[source.smdFormatIndex || 0] && source.smdFormatOptions[source.smdFormatIndex || 0].key)
+  let formatIndex = formatOptions.findIndex((item) => item.key === currentFormatKey)
+  if (formatIndex < 0) formatIndex = normalizeIndex(source.smdFormatIndex, formatOptions, 0)
+  const format = getOption(formatOptions, formatIndex)
+  const codeText = sanitizeCode(source.smdCodeText)
+  const result = calculate(kind.key, format.key, codeText)
+
+  return {
+    smdCodeText: codeText,
+    smdErrorText: result.errorText,
+    smdFormatIndex: formatIndex,
+    smdFormatKey: format.key,
+    smdFormatOptions: formatOptions,
+    smdFormatText: format.label,
+    smdFormulaText: result.formulaText,
+    smdKindIndex: kindIndex,
+    smdKindKey: kind.key,
+    smdKindOptions: KIND_OPTIONS,
+    smdKindText: kind.label,
+    smdResultText: result.resultText
+  }
+}
+
+function createInitialState() {
+  return buildState({
+    smdCodeText: '',
+    smdFormatIndex: 0,
+    smdKindIndex: 0
+  })
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+module.exports = {
+  CAPACITOR_FORMAT_OPTIONS,
+  KIND_OPTIONS,
+  RESISTOR_FORMAT_OPTIONS,
+  createInitialState,
+  updateState
+}

+ 5 - 2
utils/status-format.js

@@ -1,6 +1,9 @@
 const {
   getFaultText
 } = require('./calculation-context')
+const {
+  parseHexInteger
+} = require('./base-utils')
 const {
   calculateStatusValue,
   formatFixedValue
@@ -32,7 +35,7 @@ const FORMULA_STATUS_NAMES = [
   '母线电压',
   '模拟输入电压',
   '母线电流',
-  'NTC 电压',
+  'NTC 温度',
   '估算速度',
   '估算功率',
   '频率',
@@ -83,7 +86,7 @@ function updateStatusRegisterWords(registers, startAddress, words) {
   const start = Number(startAddress)
 
   registers.forEach((item) => {
-    const offset = parseInt(item.address, 16) - start
+    const offset = parseHexInteger(item.address) - start
     if (offset < 0 || offset >= words.length) return
 
     const wordValue = Number(words[offset]) & 0xFFFF

+ 63 - 3
utils/status-page-state.js

@@ -1,16 +1,76 @@
 const {
   statusRegisters
 } = require('./registers')
+const {
+  MAX_USER_STATUS_COUNT,
+  getUserStatusCount
+} = require('./control-page-state')
 const {
   formatStatusRegisters
 } = require('./status-format')
 
-function getStatusPageState() {
+const STATUS_SUMMARY_METRICS = [
+  { key: 'speed', name: '估算速度', unit: 'RPM', decimals: 0 },
+  { key: 'voltage', name: '母线电压', unit: 'V', decimals: 1 },
+  { key: 'power', name: '估算功率', unit: 'W', decimals: 1 },
+  { key: 'temperature', name: 'NTC 温度', unit: '℃', decimals: 0 }
+]
+
+function getVisibleStatusRegisters(userStatusCount) {
+  const count = getUserStatusCount(userStatusCount)
+
+  return statusRegisters.filter((item) => (
+    item.name.indexOf('用户状态字') !== 0 ||
+    Number(item.name.replace('用户状态字 ', '')) <= count
+  ))
+}
+
+function getStatusPageState(userStatusCount) {
+  return {
+    maxUserStatusCount: MAX_USER_STATUS_COUNT,
+    statusRegisters: formatStatusRegisters(getVisibleStatusRegisters(userStatusCount))
+  }
+}
+
+function getFormattedStatusMap() {
+  const formattedRegisters = formatStatusRegisters(statusRegisters)
+
+  return formattedRegisters.reduce((result, item) => {
+    result[item.name] = item
+    return result
+  }, {})
+}
+
+function formatMetricText(item, unit, decimals) {
+  if (!item || item.displayValue === undefined || item.displayValue === '--') return '--'
+
+  const numberValue = Number(item.displayValue)
+  const displayValue = Number.isFinite(numberValue)
+    ? numberValue.toFixed(decimals)
+    : String(item.displayValue)
+
+  return `${displayValue}${unit}`
+}
+
+function getStatusSummaryState() {
+  const registerMap = getFormattedStatusMap()
+  const stateValue = registerMap['状态机'] && registerMap['状态机'].displayValue
+  const faultValue = registerMap['故障码'] && registerMap['故障码'].displayValue
+  const faultText = faultValue || '--'
+  const isFault = faultText !== '--' && faultText !== '无故障'
+
   return {
-    statusRegisters: formatStatusRegisters(statusRegisters)
+    faultClass: isFault ? 'is-warning' : '',
+    faultText,
+    metrics: STATUS_SUMMARY_METRICS.map((config) => ({
+      key: config.key,
+      displayText: formatMetricText(registerMap[config.name], config.unit, config.decimals)
+    })),
+    stateText: stateValue || '--'
   }
 }
 
 module.exports = {
-  getStatusPageState
+  getStatusPageState,
+  getStatusSummaryState
 }

+ 41 - 70
utils/sync-service.js

@@ -1,11 +1,10 @@
-const {
-  buildReadFrame,
-  getMaxReadQuantity
-} = require('./modbus-rtu')
-const controlState = require('./control-page-state')
 const controlService = require('./control-service')
-const paramsPageState = require('./params-page-state')
+const {
+  controlState,
+  paramsState: paramsPageState
+} = require('./motor-control-data')
 const transport = require('./ble-transport')
+const modbusAccess = require('./modbus-access')
 const {
   notifyPageToast
 } = require('./page-toast')
@@ -28,8 +27,8 @@ const READ_STEPS = [
     functionCode: 0x01,
     label: '同步线圈 00-10',
     quantity: 17,
-    onResponse(response, step) {
-      addCoilReadValues(readValues, step.address, step.quantity, response)
+    onResponse(response, chunk) {
+      addCoilReadValues(readValues, chunk.address, chunk.quantity, response)
       controlService.applyControlReadValues(readValues.coils)
     }
   },
@@ -38,8 +37,8 @@ const READ_STEPS = [
     functionCode: 0x03,
     label: '同步估算器参数 30-4A',
     quantity: 27,
-    onResponse(response, step) {
-      addWordReadValues(readValues, step.address, response)
+    onResponse(response, chunk) {
+      addWordReadValues(readValues, chunk.address, response)
     }
   },
   {
@@ -47,56 +46,33 @@ const READ_STEPS = [
     functionCode: 0x03,
     label: '同步参数配置 60-8D',
     quantity: 46,
-    onResponse(response, step) {
-      addWordReadValues(readValues, step.address, response)
-      controlService.applyMotorReadWords(response.words || [], step.address)
+    onResponse(response, chunk) {
+      addWordReadValues(readValues, chunk.address, response)
+      controlService.applyMotorReadWords(response.words || [], chunk.address)
     }
   },
   {
     address: controlState.DRIVER_PARAM_START_ADDRESS,
     functionCode: 0x04,
-    label: '同步驱动器硬件参数 A0-B3',
+    label: '同步驱动器参数 A0-B3',
     quantity: controlState.DRIVER_PARAM_WORD_COUNT,
-    onResponse(response, step) {
-      addWordReadValues(readValues, step.address, response)
+    onResponse(response, chunk) {
+      addWordReadValues(readValues, chunk.address, response)
       controlService.applyDriverReadWords(response.words || [])
     }
   },
   {
     address: controlState.STATUS_START_ADDRESS,
     functionCode: 0x04,
-    label: '同步状态 C0-DC',
-    quantity: controlState.STATUS_WORD_COUNT,
-    onResponse(response, step) {
-      addWordReadValues(readValues, step.address, response)
-      controlService.applyStatusReadWords(response.words || [], step.address)
+    label: '同步状态',
+    quantity: () => controlState.getStatusWordCount(controlService.getState().userStatusCount),
+    onResponse(response, chunk) {
+      addWordReadValues(readValues, chunk.address, response)
+      controlService.applyStatusReadWords(response.words || [], chunk.address)
     }
   }
 ]
 
-function splitReadStep(step) {
-  const maxQuantity = getMaxReadQuantity(step.functionCode)
-  if (!maxQuantity || step.quantity <= maxQuantity) return [step]
-
-  const chunks = []
-  let offset = 0
-
-  while (offset < step.quantity) {
-    const quantity = Math.min(step.quantity - offset, maxQuantity)
-    const address = step.address + offset
-
-    chunks.push({
-      ...step,
-      address,
-      label: `${step.label} ${address.toString(16).toUpperCase()}-${(address + quantity - 1).toString(16).toUpperCase()}`,
-      quantity
-    })
-    offset += quantity
-  }
-
-  return chunks
-}
-
 let syncing = false
 const subscribers = []
 
@@ -138,15 +114,6 @@ function subscribe(subscriber) {
   }
 }
 
-function getSharedSlaveAddress() {
-  try {
-    return transport.getSlaveAddress()
-  } catch (error) {
-    transport.showCommandAlert('从机地址错误', error.message)
-    return null
-  }
-}
-
 function resetReadValues() {
   readValues.coils = {}
   readValues.words = {}
@@ -165,7 +132,7 @@ async function syncAllRegisters() {
   const transportState = transport.getState()
   if (!transportState.connectedDevice) return false
 
-  const slaveAddress = getSharedSlaveAddress()
+  const slaveAddress = modbusAccess.getSharedSlaveAddress()
   if (slaveAddress === null) return false
 
   setSyncing(true)
@@ -173,24 +140,28 @@ async function syncAllRegisters() {
 
   try {
     for (const step of READ_STEPS) {
-      for (const chunk of splitReadStep(step)) {
-        const response = await transport.sendManagedFrame(
-          buildReadFrame(slaveAddress, chunk.functionCode, chunk.address, chunk.quantity),
-          chunk.label,
-          {
-            address: chunk.address,
-            functionCode: chunk.functionCode,
-            kind: 'sync-read',
-            quantity: chunk.quantity,
-            slaveAddress
+      const quantity = typeof step.quantity === 'function' ? step.quantity() : step.quantity
+      const values = await modbusAccess.readSpans(
+        slaveAddress,
+        step.functionCode,
+        [{
+          address: step.address,
+          quantity
+        }],
+        step.label,
+        'sync-read',
+        {
+          onChunk(response, chunk) {
+            if (typeof step.onResponse === 'function') {
+              step.onResponse(response, chunk)
+            }
           }
-        )
-
-        if (!response) return false
-        if (typeof chunk.onResponse === 'function') {
-          chunk.onResponse(response, chunk)
         }
-      }
+      )
+
+      if (!values) return false
+      if (values.coils) Object.assign(readValues.coils, values.coils)
+      if (values.words) Object.assign(readValues.words, values.words)
     }
 
     paramsSnapshot = paramsPageState.applyReadValues(paramsSnapshot, readValues)

+ 184 - 0
utils/theme-service.js

@@ -0,0 +1,184 @@
+const settingsService = require('./settings-service')
+const {
+  getWxApi
+} = require('./platform-utils')
+
+const TAB_ITEMS = [
+  {
+    pagePath: 'pages/home/home',
+    text: '首页',
+    iconPath: 'assets/tab/home.png',
+    selectedIconPath: 'assets/tab/home-active.png',
+    darkIconPath: 'assets/tab/home-dark.png',
+    darkSelectedIconPath: 'assets/tab/home-active-dark.png'
+  },
+  {
+    pagePath: 'pages/index/index',
+    text: '控制',
+    iconPath: 'assets/tab/control.png',
+    selectedIconPath: 'assets/tab/control-active.png',
+    darkIconPath: 'assets/tab/control-dark.png',
+    darkSelectedIconPath: 'assets/tab/control-active-dark.png'
+  },
+  {
+    pagePath: 'pages/params/params',
+    text: '参数',
+    iconPath: 'assets/tab/params.png',
+    selectedIconPath: 'assets/tab/params-active.png',
+    darkIconPath: 'assets/tab/params-dark.png',
+    darkSelectedIconPath: 'assets/tab/params-active-dark.png'
+  },
+  {
+    pagePath: 'pages/settings/settings',
+    text: '设置',
+    iconPath: 'assets/tab/settings.png',
+    selectedIconPath: 'assets/tab/settings-active.png',
+    darkIconPath: 'assets/tab/settings-dark.png',
+    darkSelectedIconPath: 'assets/tab/settings-active-dark.png'
+  }
+]
+
+const state = {
+  themeMode: 'light',
+  themeClass: ''
+}
+
+let initialized = false
+let unsubscribeSettings = null
+const subscribers = []
+
+function normalizeTheme(theme) {
+  return theme === 'dark' ? 'dark' : (theme === 'light' ? 'light' : '')
+}
+
+function getSystemTheme() {
+  try {
+    const wxApi = getWxApi()
+    const appBaseInfo = typeof wxApi.getAppBaseInfo === 'function' ? wxApi.getAppBaseInfo() : {}
+    const appTheme = normalizeTheme(appBaseInfo.theme)
+    if (appTheme) return appTheme
+
+    const systemInfo = typeof wxApi.getSystemInfoSync === 'function' ? wxApi.getSystemInfoSync() : {}
+    const systemTheme = normalizeTheme(systemInfo.theme)
+    if (systemTheme) return systemTheme
+
+    const windowInfo = typeof wxApi.getWindowInfo === 'function' ? wxApi.getWindowInfo() : {}
+    const windowTheme = normalizeTheme(windowInfo.theme)
+    if (windowTheme) return windowTheme
+
+    return 'light'
+  } catch (error) {
+    return 'light'
+  }
+}
+
+function getThemeState(themeMode) {
+  const isDark = themeMode === 'dark'
+
+  return {
+    themeClass: isDark ? 'theme-dark' : '',
+    themeMode: isDark ? 'dark' : 'light'
+  }
+}
+
+function getEffectiveThemeMode() {
+  const settings = settingsService.getState()
+  if (settings.nightModeFollowSystem) return getSystemTheme()
+
+  return settings.nightModeEnabled ? 'dark' : 'light'
+}
+
+function setState(nextState) {
+  Object.assign(state, nextState)
+  applyTabBarStyle()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(getState())
+  })
+}
+
+function refreshThemeState() {
+  setState(getThemeState(getEffectiveThemeMode()))
+}
+
+function applyTabBarStyle() {
+  const wxApi = getWxApi()
+  if (typeof wxApi.setTabBarStyle !== 'function') return
+
+  const isDark = state.themeMode === 'dark'
+
+  try {
+    wxApi.setTabBarStyle({
+      backgroundColor: isDark ? '#111827' : '#ffffff',
+      borderStyle: isDark ? 'white' : 'black',
+      color: isDark ? '#94a3b8' : '#64748b',
+      selectedColor: isDark ? '#5eead4' : '#0f766e'
+    })
+    if (typeof wxApi.setTabBarItem === 'function') {
+      TAB_ITEMS.forEach((item, index) => {
+        wxApi.setTabBarItem({
+          index,
+          pagePath: item.pagePath,
+          text: item.text,
+          iconPath: isDark ? item.darkIconPath : item.iconPath,
+          selectedIconPath: isDark ? item.darkSelectedIconPath : item.selectedIconPath
+        })
+      })
+    }
+  } catch (error) {}
+}
+
+function init() {
+  settingsService.init()
+
+  if (!unsubscribeSettings) {
+    unsubscribeSettings = settingsService.subscribe(() => {
+      refreshThemeState()
+    })
+  }
+
+  refreshThemeState()
+
+  if (initialized) return
+
+  const wxApi = getWxApi()
+  if (typeof wxApi.onThemeChange === 'function') {
+    wxApi.onThemeChange((res) => {
+      if (!settingsService.getState().nightModeFollowSystem) return
+
+      setState(getThemeState(normalizeTheme(res && res.theme) || getSystemTheme()))
+    })
+  }
+
+  initialized = true
+}
+
+function getState() {
+  return {
+    ...state
+  }
+}
+
+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 syncWithSystemTheme() {
+  init()
+  refreshThemeState()
+}
+
+module.exports = {
+  getState,
+  init,
+  subscribe,
+  syncWithSystemTheme
+}

+ 130 - 0
utils/thermistor.js

@@ -0,0 +1,130 @@
+const PULLUP_RESISTOR_KOHM = 10
+
+const RT_TABLE = [
+  [-40, 345.275], [-39, 322.791], [-38, 301.925], [-37, 282.549],
+  [-36, 264.549], [-35, 247.816], [-34, 232.254], [-33, 217.774],
+  [-32, 204.292], [-31, 191.735], [-30, 180.032], [-29, 169.12],
+  [-28, 158.941], [-27, 149.441], [-26, 140.571], [-25, 132.284],
+  [-24, 124.522], [-23, 117.266], [-22, 110.48], [-21, 104.13],
+  [-20, 98.185], [-19, 92.618], [-18, 87.402], [-17, 82.513],
+  [-16, 77.927], [-15, 73.626], [-14, 69.588], [-13, 65.797],
+  [-12, 62.237], [-11, 58.89], [-10, 55.744], [-9, 52.786],
+  [-8, 50.002], [-7, 47.382], [-6, 44.916], [-5, 42.592],
+  [-4, 40.4], [-3, 38.333], [-2, 36.385], [-1, 34.548],
+  [0, 32.814], [1, 31.179], [2, 29.636], [3, 28.178],
+  [4, 26.8], [5, 25.497], [6, 24.263], [7, 23.096],
+  [8, 21.992], [9, 20.947], [10, 19.958], [11, 19.022],
+  [12, 18.135], [13, 17.294], [14, 16.498], [15, 15.742],
+  [16, 15.025], [17, 14.345], [18, 13.699], [19, 13.086],
+  [20, 12.504], [21, 11.951], [22, 11.426], [23, 10.926],
+  [24, 10.452], [25, 10], [26, 9.57], [27, 9.162],
+  [28, 8.773], [29, 8.402], [30, 8.049], [31, 7.713],
+  [32, 7.393], [33, 7.088], [34, 6.797], [35, 6.52],
+  [36, 6.255], [37, 6.003], [38, 5.762], [39, 5.532],
+  [40, 5.313], [41, 5.103], [42, 4.903], [43, 4.711],
+  [44, 4.529], [45, 4.354], [46, 4.187], [47, 4.027],
+  [48, 3.874], [49, 3.728], [50, 3.588], [51, 3.454],
+  [52, 3.326], [53, 3.203], [54, 3.086], [55, 2.973],
+  [56, 2.865], [57, 2.761], [58, 2.662], [59, 2.567],
+  [60, 2.476], [61, 2.388], [62, 2.304], [63, 2.224],
+  [64, 2.146], [65, 2.072], [66, 2.001], [67, 1.932],
+  [68, 1.866], [69, 1.803], [70, 1.742], [71, 1.684],
+  [72, 1.628], [73, 1.574], [74, 1.522], [75, 1.472],
+  [76, 1.424], [77, 1.378], [78, 1.333], [79, 1.29],
+  [80, 1.249], [81, 1.209], [82, 1.171], [83, 1.134],
+  [84, 1.099], [85, 1.065], [86, 1.032], [87, 1],
+  [88, 0.969], [89, 0.94], [90, 0.911], [91, 0.884],
+  [92, 0.857], [93, 0.831], [94, 0.807], [95, 0.783],
+  [96, 0.76], [97, 0.738], [98, 0.716], [99, 0.695],
+  [100, 0.675], [101, 0.656], [102, 0.637], [103, 0.619],
+  [104, 0.602], [105, 0.585], [106, 0.569], [107, 0.553],
+  [108, 0.538], [109, 0.523], [110, 0.508], [111, 0.495],
+  [112, 0.481], [113, 0.468], [114, 0.456], [115, 0.443],
+  [116, 0.432], [117, 0.42], [118, 0.409], [119, 0.399],
+  [120, 0.388], [121, 0.378], [122, 0.368], [123, 0.359],
+  [124, 0.35], [125, 0.341]
+]
+
+function interpolate(x, x0, y0, x1, y1) {
+  if (x1 === x0) return y0
+
+  return y0 + (x - x0) * (y1 - y0) / (x1 - x0)
+}
+
+function resistanceToTemperature(resistanceKohm) {
+  const resistance = Number(resistanceKohm)
+  if (!Number.isFinite(resistance) || resistance <= 0) return null
+
+  if (resistance >= RT_TABLE[0][1]) return RT_TABLE[0][0]
+  const last = RT_TABLE[RT_TABLE.length - 1]
+  if (resistance <= last[1]) return last[0]
+
+  for (let index = 0; index < RT_TABLE.length - 1; index += 1) {
+    const [temp0, resistance0] = RT_TABLE[index]
+    const [temp1, resistance1] = RT_TABLE[index + 1]
+
+    if (resistance <= resistance0 && resistance >= resistance1) {
+      return interpolate(resistance, resistance0, temp0, resistance1, temp1)
+    }
+  }
+
+  return null
+}
+
+function temperatureToResistance(temperatureCelsius) {
+  const temperature = Number(temperatureCelsius)
+  if (!Number.isFinite(temperature)) return null
+
+  if (temperature <= RT_TABLE[0][0]) return RT_TABLE[0][1]
+  const last = RT_TABLE[RT_TABLE.length - 1]
+  if (temperature >= last[0]) return last[1]
+
+  for (let index = 0; index < RT_TABLE.length - 1; index += 1) {
+    const [temp0, resistance0] = RT_TABLE[index]
+    const [temp1, resistance1] = RT_TABLE[index + 1]
+
+    if (temperature >= temp0 && temperature <= temp1) {
+      return interpolate(temperature, temp0, resistance0, temp1, resistance1)
+    }
+  }
+
+  return null
+}
+
+function voltageRatioToResistance(voltageRatio) {
+  const ratio = Number(voltageRatio)
+  if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return null
+
+  return PULLUP_RESISTOR_KOHM * ratio / (1 - ratio)
+}
+
+function resistanceToVoltageRatio(resistanceKohm) {
+  const resistance = Number(resistanceKohm)
+  if (!Number.isFinite(resistance) || resistance <= 0) return null
+
+  return resistance / (PULLUP_RESISTOR_KOHM + resistance)
+}
+
+function rawToTemperature(rawValue, scaleMax) {
+  const rawNumber = Number(rawValue)
+  const maxValue = Number(scaleMax)
+  if (!Number.isFinite(rawNumber) || !Number.isFinite(maxValue) || maxValue <= 0) return null
+  if (rawNumber <= 0) return RT_TABLE[RT_TABLE.length - 1][0]
+  if (rawNumber >= maxValue) return RT_TABLE[0][0]
+
+  return resistanceToTemperature(voltageRatioToResistance(rawNumber / maxValue))
+}
+
+function temperatureToRaw(temperatureCelsius, scaleMax) {
+  const resistance = temperatureToResistance(temperatureCelsius)
+  const ratio = resistanceToVoltageRatio(resistance)
+  const maxValue = Number(scaleMax)
+  if (!Number.isFinite(ratio) || !Number.isFinite(maxValue) || maxValue <= 0) return null
+
+  return ratio * maxValue
+}
+
+module.exports = {
+  rawToTemperature,
+  temperatureToRaw
+}

+ 400 - 0
utils/three-phase-power-calculator.js

@@ -0,0 +1,400 @@
+const {
+  formatMagnitudeNumber,
+  getOption,
+  normalizeIndex,
+  parseLooseNumber
+} = require('./calculator-helpers')
+
+const SQRT3 = Math.sqrt(3)
+const DEG_PER_RAD = 180 / Math.PI
+const RAD_PER_DEG = Math.PI / 180
+
+const CONNECTION_OPTIONS = [
+  { key: 'star', label: '星形' },
+  { key: 'delta', label: '三角形' }
+]
+
+const ROW_OPTIONS = [
+  { key: 'lineVoltage', label: '线电压 UL', unit: 'V', placeholder: '380', editable: true, field: 'threePhaseLineVoltage' },
+  { key: 'lineCurrent', label: '线电流 IL', unit: 'A', placeholder: '10', editable: true, field: 'threePhaseLineCurrent' },
+  { key: 'phaseVoltage', label: '相电压 UP', unit: 'V', placeholder: '220', editable: true, field: 'threePhasePhaseVoltage' },
+  { key: 'phaseCurrent', label: '相电流 IP', unit: 'A', placeholder: '10', editable: true, field: 'threePhasePhaseCurrent' },
+  { key: 'apparentPower', label: '视在功率 S', unit: 'VA', placeholder: '6580', editable: true, field: 'threePhaseApparentPower' },
+  { key: 'activePower', label: '实际功率 P', unit: 'W', placeholder: '5000', editable: true, field: 'threePhaseActivePower' },
+  { key: 'reactivePower', label: '无功功率 Q', unit: 'var', placeholder: '3000', editable: true, field: 'threePhaseReactivePower' },
+  { key: 'powerFactor', label: '功率因素 PF', unit: '', placeholder: '0.85', editable: true, field: 'threePhasePowerFactor' },
+  { key: 'phaseAngle', label: '相位角 φ', unit: '°', placeholder: '31.8', editable: true, field: 'threePhasePhaseAngle' }
+]
+
+const ELECTRICAL_INPUT_KEYS = [
+  'threePhaseLineVoltage',
+  'threePhaseLineCurrent',
+  'threePhasePhaseVoltage',
+  'threePhasePhaseCurrent',
+  'threePhaseApparentPower'
+]
+const POWER_INPUT_KEYS = [
+  'threePhaseActivePower',
+  'threePhaseReactivePower',
+  'threePhasePowerFactor',
+  'threePhasePhaseAngle'
+]
+const INPUT_KEYS = ELECTRICAL_INPUT_KEYS.concat(POWER_INPUT_KEYS)
+const POWER_DRIVER_KEYS = [
+  'threePhaseActivePower',
+  'threePhaseReactivePower',
+  'threePhasePowerFactor',
+  'threePhasePhaseAngle'
+]
+
+function formatNumber(value) {
+  return formatMagnitudeNumber(value, { fallbackText: '--' })
+}
+
+function getSignFrom(values) {
+  if (Number.isFinite(values.reactivePower) && values.reactivePower !== 0) {
+    return values.reactivePower < 0 ? -1 : 1
+  }
+  if (Number.isFinite(values.phaseAngle) && values.phaseAngle !== 0) {
+    return values.phaseAngle < 0 ? -1 : 1
+  }
+
+  return 1
+}
+
+function getPreferredPowerKey(source, values) {
+  const preferred = source.threePhasePowerDriver
+  if (POWER_DRIVER_KEYS.includes(preferred)) {
+    const valueName = {
+      threePhaseActivePower: 'activePower',
+      threePhaseReactivePower: 'reactivePower',
+      threePhasePowerFactor: 'powerFactor',
+      threePhasePhaseAngle: 'phaseAngle'
+    }[preferred]
+
+    if (Number.isFinite(values[valueName])) return preferred
+  }
+
+  return POWER_DRIVER_KEYS.find((key) => {
+    const valueName = {
+      threePhaseActivePower: 'activePower',
+      threePhaseReactivePower: 'reactivePower',
+      threePhasePowerFactor: 'powerFactor',
+      threePhasePhaseAngle: 'phaseAngle'
+    }[key]
+
+    return Number.isFinite(values[valueName])
+  }) || ''
+}
+
+function deriveFromKnownS(values, preferredKey) {
+  const result = {
+    activePower: values.activePower,
+    phaseAngle: values.phaseAngle,
+    powerFactor: values.powerFactor,
+    reactivePower: values.reactivePower
+  }
+  const apparentPower = values.apparentPower
+  const qSign = getSignFrom(values)
+  const driver = preferredKey || getPreferredPowerKey({}, values)
+
+  if (!Number.isFinite(apparentPower)) return result
+
+  if (driver === 'threePhaseActivePower' && Number.isFinite(values.activePower)) {
+    if (values.activePower > apparentPower) return { ...result, errorText: '实际功率不能大于视在功率' }
+
+    result.powerFactor = apparentPower === 0 ? 0 : values.activePower / apparentPower
+    result.reactivePower = qSign * Math.sqrt(Math.max(0, apparentPower * apparentPower - values.activePower * values.activePower))
+    result.phaseAngle = Math.atan2(result.reactivePower, values.activePower) * DEG_PER_RAD
+    return result
+  }
+
+  if (driver === 'threePhaseReactivePower' && Number.isFinite(values.reactivePower)) {
+    if (Math.abs(values.reactivePower) > apparentPower) return { ...result, errorText: '无功功率绝对值不能大于视在功率' }
+
+    result.activePower = Math.sqrt(Math.max(0, apparentPower * apparentPower - values.reactivePower * values.reactivePower))
+    result.powerFactor = apparentPower === 0 ? 0 : result.activePower / apparentPower
+    result.phaseAngle = Math.atan2(values.reactivePower, result.activePower) * DEG_PER_RAD
+    return result
+  }
+
+  if (driver === 'threePhasePowerFactor' && Number.isFinite(values.powerFactor)) {
+    result.activePower = apparentPower * values.powerFactor
+    result.reactivePower = qSign * apparentPower * Math.sqrt(Math.max(0, 1 - values.powerFactor * values.powerFactor))
+    result.phaseAngle = Math.atan2(result.reactivePower, result.activePower) * DEG_PER_RAD
+    return result
+  }
+
+  if (driver === 'threePhasePhaseAngle' && Number.isFinite(values.phaseAngle)) {
+    const angleRad = values.phaseAngle * RAD_PER_DEG
+    result.powerFactor = Math.cos(angleRad)
+    result.activePower = apparentPower * result.powerFactor
+    result.reactivePower = apparentPower * Math.sin(angleRad)
+    return result
+  }
+
+  return result
+}
+
+function derivePowerTriangle(values, preferredKey) {
+  if (Number.isFinite(values.apparentPower)) {
+    return deriveFromKnownS(values, preferredKey)
+  }
+
+  const result = {
+    activePower: values.activePower,
+    apparentPower: null,
+    phaseAngle: values.phaseAngle,
+    powerFactor: values.powerFactor,
+    reactivePower: values.reactivePower
+  }
+  const p = values.activePower
+  const q = values.reactivePower
+  const pf = values.powerFactor
+  const angle = values.phaseAngle
+
+  if (Number.isFinite(p) && Number.isFinite(q)) {
+    result.apparentPower = Math.sqrt(p * p + q * q)
+    result.powerFactor = result.apparentPower === 0 ? 0 : p / result.apparentPower
+    result.phaseAngle = Math.atan2(q, p) * DEG_PER_RAD
+    return result
+  }
+
+  if (Number.isFinite(p) && Number.isFinite(pf)) {
+    if (pf <= 0 && p > 0) return { ...result, errorText: '功率因素为 0 时实际功率只能为 0' }
+
+    result.apparentPower = pf === 0 ? 0 : p / pf
+    result.reactivePower = getSignFrom(values) * Math.sqrt(Math.max(0, result.apparentPower * result.apparentPower - p * p))
+    result.phaseAngle = Math.atan2(result.reactivePower, p) * DEG_PER_RAD
+    return result
+  }
+
+  if (Number.isFinite(p) && Number.isFinite(angle)) {
+    const angleRad = angle * RAD_PER_DEG
+    const cosValue = Math.cos(angleRad)
+    if (cosValue <= 0 && p > 0) return { ...result, errorText: '相位角需小于 90° 才能由实际功率反推' }
+
+    result.powerFactor = cosValue
+    result.apparentPower = cosValue === 0 ? 0 : p / cosValue
+    result.reactivePower = p * Math.tan(angleRad)
+    return result
+  }
+
+  if (Number.isFinite(q) && Number.isFinite(pf)) {
+    const reactiveRatio = Math.sqrt(Math.max(0, 1 - pf * pf))
+    if (reactiveRatio === 0 && q !== 0) return { ...result, errorText: '功率因素为 1 时无功功率应为 0' }
+
+    result.apparentPower = reactiveRatio === 0 ? 0 : Math.abs(q) / reactiveRatio
+    result.activePower = result.apparentPower * pf
+    result.phaseAngle = Math.atan2(q, result.activePower) * DEG_PER_RAD
+    return result
+  }
+
+  if (Number.isFinite(q) && Number.isFinite(angle)) {
+    const tanValue = Math.tan(angle * RAD_PER_DEG)
+    if (Math.abs(tanValue) < 1e-12 && q !== 0) return { ...result, errorText: '相位角为 0° 时无功功率应为 0' }
+
+    result.activePower = tanValue === 0 ? 0 : q / tanValue
+    if (result.activePower < 0) return { ...result, errorText: '无功功率与相位角方向不一致' }
+
+    result.apparentPower = Math.sqrt(result.activePower * result.activePower + q * q)
+    result.powerFactor = result.apparentPower === 0 ? 0 : result.activePower / result.apparentPower
+    return result
+  }
+
+  return result
+}
+
+function validate(values) {
+  if ([values.lineVoltage, values.lineCurrent, values.phaseVoltage, values.phaseCurrent, values.apparentPower, values.activePower, values.reactivePower, values.powerFactor, values.phaseAngle]
+    .some((value) => Number.isNaN(value))) {
+    return '输入值格式无效'
+  }
+  if (Number.isFinite(values.lineVoltage) && values.lineVoltage <= 0) return '线电压需大于 0'
+  if (Number.isFinite(values.lineCurrent) && values.lineCurrent <= 0) return '线电流需大于 0'
+  if (Number.isFinite(values.phaseVoltage) && values.phaseVoltage <= 0) return '相电压需大于 0'
+  if (Number.isFinite(values.phaseCurrent) && values.phaseCurrent <= 0) return '相电流需大于 0'
+  if (Number.isFinite(values.apparentPower) && values.apparentPower < 0) return '视在功率不能为负数'
+  if (Number.isFinite(values.activePower) && values.activePower < 0) return '实际功率不能为负数'
+  if (Number.isFinite(values.powerFactor) && (values.powerFactor < 0 || values.powerFactor > 1)) return '功率因素范围为 0-1'
+  if (Number.isFinite(values.phaseAngle) && Math.abs(values.phaseAngle) > 90) return '相位角范围为 -90° 到 90°'
+
+  return ''
+}
+
+function assignVoltageFromLine(result, connectionKey, lineVoltage) {
+  result.lineVoltage = lineVoltage
+  result.phaseVoltage = connectionKey === 'star' ? lineVoltage / SQRT3 : lineVoltage
+}
+
+function assignVoltageFromPhase(result, connectionKey, phaseVoltage) {
+  result.phaseVoltage = phaseVoltage
+  result.lineVoltage = connectionKey === 'star' ? phaseVoltage * SQRT3 : phaseVoltage
+}
+
+function assignCurrentFromLine(result, connectionKey, lineCurrent) {
+  result.lineCurrent = lineCurrent
+  result.phaseCurrent = connectionKey === 'star' ? lineCurrent : lineCurrent / SQRT3
+}
+
+function assignCurrentFromPhase(result, connectionKey, phaseCurrent) {
+  result.phaseCurrent = phaseCurrent
+  result.lineCurrent = connectionKey === 'star' ? phaseCurrent : phaseCurrent * SQRT3
+}
+
+function resolveElectricalValues(connectionKey, values, preferredKey = '') {
+  const result = {
+    apparentPower: Number.isFinite(values.apparentPower) ? values.apparentPower : null,
+    lineCurrent: null,
+    lineVoltage: null,
+    phaseCurrent: null,
+    phaseVoltage: null
+  }
+
+  if (preferredKey === 'threePhasePhaseVoltage' && Number.isFinite(values.phaseVoltage)) {
+    assignVoltageFromPhase(result, connectionKey, values.phaseVoltage)
+  } else if (preferredKey === 'threePhaseLineVoltage' && Number.isFinite(values.lineVoltage)) {
+    assignVoltageFromLine(result, connectionKey, values.lineVoltage)
+  } else if (Number.isFinite(values.lineVoltage)) {
+    assignVoltageFromLine(result, connectionKey, values.lineVoltage)
+  } else if (Number.isFinite(values.phaseVoltage)) {
+    assignVoltageFromPhase(result, connectionKey, values.phaseVoltage)
+  }
+
+  if (preferredKey === 'threePhasePhaseCurrent' && Number.isFinite(values.phaseCurrent)) {
+    assignCurrentFromPhase(result, connectionKey, values.phaseCurrent)
+  } else if (preferredKey === 'threePhaseLineCurrent' && Number.isFinite(values.lineCurrent)) {
+    assignCurrentFromLine(result, connectionKey, values.lineCurrent)
+  } else if (Number.isFinite(values.lineCurrent)) {
+    assignCurrentFromLine(result, connectionKey, values.lineCurrent)
+  } else if (Number.isFinite(values.phaseCurrent)) {
+    assignCurrentFromPhase(result, connectionKey, values.phaseCurrent)
+  }
+
+  if (!Number.isFinite(result.apparentPower) && Number.isFinite(result.lineVoltage) && Number.isFinite(result.lineCurrent)) {
+    result.apparentPower = SQRT3 * result.lineVoltage * result.lineCurrent
+  }
+  if (!Number.isFinite(result.lineCurrent) && Number.isFinite(result.apparentPower) && Number.isFinite(result.lineVoltage) && result.lineVoltage > 0) {
+    assignCurrentFromLine(result, connectionKey, result.apparentPower / (SQRT3 * result.lineVoltage))
+  }
+  if (!Number.isFinite(result.lineVoltage) && Number.isFinite(result.apparentPower) && Number.isFinite(result.lineCurrent) && result.lineCurrent > 0) {
+    assignVoltageFromLine(result, connectionKey, result.apparentPower / (SQRT3 * result.lineCurrent))
+  }
+
+  return result
+}
+
+function formatEditableValue(value) {
+  return Number.isFinite(value) ? formatNumber(value) : ''
+}
+
+function buildRows(connectionKey, values, powerResult, preferredElectricalKey = '') {
+  const electricalValues = resolveElectricalValues(connectionKey, {
+    ...values,
+    apparentPower: Number.isFinite(values.apparentPower) ? values.apparentPower : powerResult.apparentPower
+  }, preferredElectricalKey)
+  const displayValues = {
+    activePower: Number.isFinite(powerResult.activePower) ? powerResult.activePower : values.activePower,
+    apparentPower: electricalValues.apparentPower,
+    lineCurrent: electricalValues.lineCurrent,
+    lineVoltage: electricalValues.lineVoltage,
+    phaseAngle: Number.isFinite(powerResult.phaseAngle) ? powerResult.phaseAngle : values.phaseAngle,
+    phaseCurrent: electricalValues.phaseCurrent,
+    phaseVoltage: electricalValues.phaseVoltage,
+    powerFactor: Number.isFinite(powerResult.powerFactor) ? powerResult.powerFactor : values.powerFactor,
+    reactivePower: Number.isFinite(powerResult.reactivePower) ? powerResult.reactivePower : values.reactivePower
+  }
+  const displayText = {
+    activePower: formatEditableValue(displayValues.activePower),
+    apparentPower: formatEditableValue(displayValues.apparentPower),
+    lineCurrent: formatEditableValue(displayValues.lineCurrent),
+    lineVoltage: formatEditableValue(displayValues.lineVoltage),
+    phaseAngle: formatEditableValue(displayValues.phaseAngle),
+    phaseCurrent: formatEditableValue(displayValues.phaseCurrent),
+    phaseVoltage: formatEditableValue(displayValues.phaseVoltage),
+    powerFactor: formatEditableValue(displayValues.powerFactor),
+    reactivePower: formatEditableValue(displayValues.reactivePower)
+  }
+
+  return ROW_OPTIONS.map((row) => ({
+    ...row,
+    value: displayText[row.key] || (row.editable ? '' : '--')
+  }))
+}
+
+function buildState(source = {}) {
+  const connectionIndex = normalizeIndex(source.threePhaseConnectionIndex, CONNECTION_OPTIONS, 0)
+  const connection = getOption(CONNECTION_OPTIONS, connectionIndex)
+  const rawValues = INPUT_KEYS.reduce((result, key) => {
+    result[key] = String(source[key] === undefined || source[key] === null ? '' : source[key])
+    return result
+  }, {})
+  const values = {
+    activePower: parseLooseNumber(rawValues.threePhaseActivePower),
+    apparentPower: parseLooseNumber(rawValues.threePhaseApparentPower),
+    lineCurrent: parseLooseNumber(rawValues.threePhaseLineCurrent),
+    lineVoltage: parseLooseNumber(rawValues.threePhaseLineVoltage),
+    phaseAngle: parseLooseNumber(rawValues.threePhasePhaseAngle),
+    phaseCurrent: parseLooseNumber(rawValues.threePhasePhaseCurrent),
+    phaseVoltage: parseLooseNumber(rawValues.threePhasePhaseVoltage),
+    powerFactor: parseLooseNumber(rawValues.threePhasePowerFactor),
+    reactivePower: parseLooseNumber(rawValues.threePhaseReactivePower)
+  }
+
+  const validationError = validate(values)
+  const preferredElectricalKey = ELECTRICAL_INPUT_KEYS.includes(source.threePhaseElectricalDriver)
+    ? source.threePhaseElectricalDriver
+    : ''
+  const electricalValues = validationError
+    ? {}
+    : resolveElectricalValues(connection.key, values, preferredElectricalKey)
+  const preferredPowerKey = getPreferredPowerKey(source, values)
+  const powerResult = validationError
+    ? {}
+    : derivePowerTriangle({
+      ...values,
+      ...electricalValues
+    }, preferredPowerKey)
+  const errorText = validationError || powerResult.errorText || ''
+
+  return {
+    ...rawValues,
+    threePhaseConnectionIndex: connectionIndex,
+    threePhaseConnectionKey: connection.key,
+    threePhaseConnectionOptions: CONNECTION_OPTIONS,
+    threePhaseElectricalDriver: preferredElectricalKey,
+    threePhaseErrorText: errorText,
+    threePhasePowerDriver: preferredPowerKey,
+    threePhaseRows: buildRows(connection.key, values, powerResult || {}, preferredElectricalKey)
+  }
+}
+
+function createInitialState() {
+  return buildState({})
+}
+
+function updateState(state, changedData = {}) {
+  return buildState({
+    ...state,
+    ...changedData
+  })
+}
+
+function clearInputs(state = {}) {
+  return updateState(state, INPUT_KEYS.reduce((result, key) => {
+    result[key] = ''
+    return result
+  }, {
+    threePhaseElectricalDriver: '',
+    threePhasePowerDriver: ''
+  }))
+}
+
+module.exports = {
+  CONNECTION_OPTIONS,
+  ELECTRICAL_INPUT_KEYS,
+  POWER_DRIVER_KEYS,
+  clearInputs,
+  createInitialState,
+  updateState
+}

+ 32 - 0
utils/tool-navigation.js

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

+ 422 - 0
utils/tool-page.js

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

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor