1
0
avery vor 1 Woche
Commit
7f676d51b5
64 geänderte Dateien mit 8418 neuen und 0 gelöschten Zeilen
  1. 31 0
      .eslintrc.js
  2. 14 0
      .gitignore
  3. 11 0
      app.js
  4. 57 0
      app.json
  5. 562 0
      app.wxss
  6. 44 0
      assets/LUCIDE_LICENSE.txt
  7. BIN
      assets/icons/bluetooth-connected-white.png
  8. BIN
      assets/icons/chip-white.png
  9. BIN
      assets/icons/control-white.png
  10. BIN
      assets/icons/curve-white.png
  11. BIN
      assets/icons/estimator-white.png
  12. BIN
      assets/icons/history-white.png
  13. BIN
      assets/icons/motor-white.png
  14. BIN
      assets/icons/oil-white.png
  15. BIN
      assets/icons/radar-white.png
  16. BIN
      assets/icons/send-white.png
  17. BIN
      assets/icons/shield-alert-white.png
  18. BIN
      assets/icons/shield-check-white.png
  19. BIN
      assets/icons/sliders-white.png
  20. BIN
      assets/icons/speed-white.png
  21. BIN
      assets/icons/status-white.png
  22. BIN
      assets/icons/target-white.png
  23. BIN
      assets/icons/terminal-white.png
  24. BIN
      assets/icons/wind-white.png
  25. BIN
      assets/tab/control-active.png
  26. BIN
      assets/tab/control.png
  27. BIN
      assets/tab/home-active.png
  28. BIN
      assets/tab/home.png
  29. BIN
      assets/tab/params-active.png
  30. BIN
      assets/tab/params.png
  31. 23 0
      components/navigation-bar/navigation-bar.js
  32. 5 0
      components/navigation-bar/navigation-bar.json
  33. 3 0
      components/navigation-bar/navigation-bar.wxml
  34. 21 0
      components/navigation-bar/navigation-bar.wxss
  35. 205 0
      pages/home/home.js
  36. 5 0
      pages/home/home.json
  37. 243 0
      pages/home/home.wxml
  38. 467 0
      pages/home/home.wxss
  39. 63 0
      pages/index/index.js
  40. 5 0
      pages/index/index.json
  41. 59 0
      pages/index/index.wxml
  42. 56 0
      pages/index/index.wxss
  43. 339 0
      pages/params/params.js
  44. 5 0
      pages/params/params.json
  45. 633 0
      pages/params/params.wxml
  46. 40 0
      project.config.json
  47. 21 0
      project.private.config.json
  48. 137 0
      protrol.txt
  49. 7 0
      sitemap.json
  50. 1698 0
      utils/ble-transport.js
  51. 127 0
      utils/calculation-context.js
  52. 455 0
      utils/control-page-state.js
  53. 502 0
      utils/control-service.js
  54. 268 0
      utils/conversions.js
  55. 30 0
      utils/input-value-utils.js
  56. 181 0
      utils/modbus-rtu.js
  57. 136 0
      utils/page-toast.js
  58. 717 0
      utils/params-page-state.js
  59. 389 0
      utils/params-service.js
  60. 86 0
      utils/register-value-utils.js
  61. 440 0
      utils/registers.js
  62. 106 0
      utils/status-format.js
  63. 16 0
      utils/status-page-state.js
  64. 211 0
      utils/sync-service.js

+ 31 - 0
.eslintrc.js

@@ -0,0 +1,31 @@
+/*
+ * Eslint config file
+ * Documentation: https://eslint.org/docs/user-guide/configuring/
+ * Install the Eslint extension before using this feature.
+ */
+module.exports = {
+  env: {
+    es6: true,
+    browser: true,
+    node: true,
+  },
+  ecmaFeatures: {
+    modules: true,
+  },
+  parserOptions: {
+    ecmaVersion: 2018,
+    sourceType: 'module',
+  },
+  globals: {
+    wx: true,
+    App: true,
+    Page: true,
+    getCurrentPages: true,
+    getApp: true,
+    Component: true,
+    requirePlugin: true,
+    requireMiniProgram: true,
+  },
+  // extends: 'eslint:recommended',
+  rules: {},
+}

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+# Windows
+[Dd]esktop.ini
+Thumbs.db
+$RECYCLE.BIN/
+
+# macOS
+.DS_Store
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+
+# Node.js
+node_modules/

+ 11 - 0
app.js

@@ -0,0 +1,11 @@
+const transport = require('./utils/ble-transport')
+
+App({
+  onShow() {
+    transport.handleAppShow()
+  },
+
+  onHide() {
+    transport.handleAppHide()
+  }
+})

+ 57 - 0
app.json

@@ -0,0 +1,57 @@
+{
+  "pages": [
+    "pages/home/home",
+    "pages/index/index",
+    "pages/params/params"
+  ],
+  "window": {
+    "navigationBarTextStyle": "black",
+    "navigationStyle": "custom"
+  },
+  "tabBar": {
+    "color": "#64748b",
+    "selectedColor": "#0f766e",
+    "backgroundColor": "#ffffff",
+    "borderStyle": "black",
+    "list": [
+      {
+        "pagePath": "pages/home/home",
+        "text": "首页",
+        "iconPath": "assets/tab/home.png",
+        "selectedIconPath": "assets/tab/home-active.png"
+      },
+      {
+        "pagePath": "pages/index/index",
+        "text": "控制",
+        "iconPath": "assets/tab/control.png",
+        "selectedIconPath": "assets/tab/control-active.png"
+      },
+      {
+        "pagePath": "pages/params/params",
+        "text": "参数",
+        "iconPath": "assets/tab/params.png",
+        "selectedIconPath": "assets/tab/params-active.png"
+      }
+    ]
+  },
+  "permission": {
+    "scope.userLocation": {
+      "desc": "用于安卓系统扫描附近蓝牙设备"
+    }
+  },
+  "style": "v2",
+  "renderer": "skyline",
+  "rendererOptions": {
+    "skyline": {
+      "defaultDisplayBlock": true,
+      "defaultContentBox": true,
+      "tagNameStyleIsolation": "legacy",
+      "disableABTest": true,
+      "sdkVersionBegin": "3.0.0",
+      "sdkVersionEnd": "15.255.255"
+    }
+  },
+  "componentFramework": "glass-easel",
+  "sitemapLocation": "sitemap.json",
+  "lazyCodeLoading": "requiredComponents"
+}

+ 562 - 0
app.wxss

@@ -0,0 +1,562 @@
+page {
+  --page-bg: #f6f8fb;
+  --text-main: #111827;
+  --accent: #0f8f87;
+  --accent-dark: #08746e;
+  --accent-soft: #e8f6f5;
+  --danger: #c2410c;
+  --shadow-soft: 0 10rpx 28rpx rgba(15, 23, 42, 0.05);
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
+  background: var(--page-bg);
+  color: var(--text-main);
+}
+
+.scrollarea {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.page-shell {
+  min-height: 100%;
+  padding: 24rpx 24rpx 52rpx;
+  box-sizing: border-box;
+}
+
+.page-toast {
+  position: fixed;
+  z-index: 50;
+  top: calc(env(safe-area-inset-top) + 108rpx);
+  left: 24rpx;
+  right: 24rpx;
+  min-height: 68rpx;
+  padding: 16rpx 22rpx;
+  border: 1rpx solid #d9edeb;
+  border-radius: 14rpx;
+  background: rgba(244, 251, 250, 0.98);
+  color: var(--accent-dark);
+  box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.16);
+  font-size: 25rpx;
+  line-height: 1.35;
+  font-weight: 700;
+  text-align: center;
+  word-break: break-all;
+  box-sizing: border-box;
+}
+
+.page-toast--error {
+  border-color: #ffd4bf;
+  background: rgba(255, 247, 237, 0.98);
+  color: var(--danger);
+}
+
+.panel {
+  margin-top: 24rpx;
+  border: 1rpx solid #e6ebf2;
+  border-radius: 18rpx;
+  background: #ffffff;
+  box-shadow: var(--shadow-soft);
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.panel:first-child {
+  margin-top: 0;
+}
+
+.panel-header {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+  padding: 24rpx 24rpx 8rpx;
+}
+
+.outside-header {
+  padding: 0;
+  margin-bottom: 16rpx;
+}
+
+.panel-icon {
+  flex: none;
+  position: relative;
+  width: 38rpx;
+  height: 38rpx;
+  border-radius: 12rpx;
+  background:
+    radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.26) 0%, rgba(255, 255, 255, 0.12) 22%, rgba(255, 255, 255, 0) 50%),
+    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.18);
+  overflow: hidden;
+}
+
+.panel-title {
+  min-width: 0;
+  color: #111827;
+  font-size: 31rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.panel-header--with-actions {
+  padding-right: 18rpx;
+}
+
+.panel-heading-toggle {
+  min-width: 0;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+}
+
+.panel-heading-toggle:active {
+  opacity: 0.72;
+}
+
+.panel-header--with-actions .panel-title {
+  flex: 1;
+  white-space: nowrap;
+  word-break: keep-all;
+}
+
+.collapse-indicator {
+  flex: none;
+  position: relative;
+  width: 28rpx;
+  height: 28rpx;
+}
+
+.collapse-indicator::before {
+  content: "";
+  position: absolute;
+  left: 8rpx;
+  top: 6rpx;
+  width: 10rpx;
+  height: 10rpx;
+  border-right: 3rpx solid #94a3b8;
+  border-bottom: 3rpx solid #94a3b8;
+  transform: rotate(45deg);
+  transform-origin: center;
+  transition: transform 0.16s ease;
+}
+
+.collapse-toggle.is-collapsed .collapse-indicator::before {
+  top: 9rpx;
+  transform: rotate(-45deg);
+}
+
+.collapse-toggle {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 34rpx;
+  height: 50rpx;
+}
+
+.collapse-toggle:active {
+  opacity: 0.72;
+}
+
+.panel--collapsed .panel-header {
+  padding-bottom: 24rpx;
+}
+
+.panel-actions {
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-left: auto;
+}
+
+.panel-actions--three {
+  gap: 6rpx;
+}
+
+.panel-actions--status {
+  gap: 8rpx;
+}
+
+.panel-action-button {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 86rpx;
+  min-width: 0;
+  height: 50rpx;
+  min-height: 0;
+  margin: 0;
+  padding: 0;
+  border: 1rpx solid #b9d8d1;
+  border-radius: 10rpx;
+  background: #ffffff;
+  color: var(--accent-dark);
+  font-size: 22rpx;
+  line-height: 1.3;
+  font-weight: 800;
+  box-sizing: border-box;
+}
+
+.panel-action-button::after {
+  border: 0;
+}
+
+.panel-action-button:active {
+  opacity: 0.72;
+}
+
+.panel-action-button.is-disabled {
+  background: #eef1f5;
+  color: #94a3b8;
+  box-shadow: none;
+}
+
+.panel-action-button.is-active {
+  border-color: var(--accent-dark);
+  background: var(--accent);
+  color: #ffffff;
+}
+
+.panel-icon::before,
+.panel-icon::after {
+  content: "";
+  position: absolute;
+  box-sizing: border-box;
+}
+
+.panel-icon::before {
+  left: 7rpx;
+  top: 7rpx;
+  width: 24rpx;
+  height: 24rpx;
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: contain;
+}
+
+.panel-icon::after {
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  border-radius: inherit;
+  box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.2);
+}
+
+.icon-motor {
+  --icon-start: #16b0a5;
+  --icon-end: #0d7c76;
+}
+
+.icon-motor::before {
+  background-image: url("/assets/icons/motor-white.png");
+}
+
+.icon-chip {
+  --icon-start: #5a86a6;
+  --icon-end: #2f5e7c;
+}
+
+.icon-chip::before {
+  background-image: url("/assets/icons/chip-white.png");
+}
+
+.icon-control {
+  --icon-start: #18a58b;
+  --icon-end: #0e746f;
+}
+
+.icon-control::before {
+  background-image: url("/assets/icons/control-white.png");
+}
+
+.icon-bluetooth {
+  --icon-start: #16a8cf;
+  --icon-end: #0f7a9a;
+}
+
+.icon-bluetooth::before {
+  background-image: url("/assets/icons/bluetooth-connected-white.png");
+}
+
+.icon-radar {
+  --icon-start: #23b0d7;
+  --icon-end: #137e8f;
+}
+
+.icon-radar::before {
+  background-image: url("/assets/icons/radar-white.png");
+}
+
+.icon-terminal {
+  --icon-start: #63758f;
+  --icon-end: #324056;
+}
+
+.icon-terminal::before {
+  background-image: url("/assets/icons/terminal-white.png");
+}
+
+.icon-send {
+  --icon-start: #39bdf0;
+  --icon-end: #1684c5;
+}
+
+.icon-send::before {
+  background-image: url("/assets/icons/send-white.png");
+}
+
+.icon-history {
+  --icon-start: #64748b;
+  --icon-end: #475569;
+}
+
+.icon-history::before {
+  background-image: url("/assets/icons/history-white.png");
+}
+
+.icon-status {
+  --icon-start: #14a79a;
+  --icon-end: #2563eb;
+}
+
+.icon-status::before {
+  background-image: url("/assets/icons/status-white.png");
+}
+
+.icon-bars {
+  --icon-start: #148f85;
+  --icon-end: #105f8b;
+}
+
+.icon-bars::before {
+  background-image: url("/assets/icons/estimator-white.png");
+}
+
+.icon-tune {
+  --icon-start: #17a59f;
+  --icon-end: #0d7280;
+}
+
+.icon-tune::before {
+  background-image: url("/assets/icons/sliders-white.png");
+}
+
+.icon-speed {
+  --icon-start: #f7a623;
+  --icon-end: #d97f0c;
+}
+
+.icon-speed::before {
+  background-image: url("/assets/icons/speed-white.png");
+}
+
+.icon-wind {
+  --icon-start: #22b8cf;
+  --icon-end: #0b7888;
+}
+
+.icon-wind::before {
+  background-image: url("/assets/icons/wind-white.png");
+}
+
+.icon-target {
+  --icon-start: #21a37e;
+  --icon-end: #0f766e;
+}
+
+.icon-target::before {
+  background-image: url("/assets/icons/target-white.png");
+}
+
+.icon-curve {
+  --icon-start: #0ea5a4;
+  --icon-end: #2563eb;
+}
+
+.icon-curve::before {
+  background-image: url("/assets/icons/curve-white.png");
+}
+
+.icon-shield-check {
+  --icon-start: #16a34a;
+  --icon-end: #0f766e;
+}
+
+.icon-shield-check::before {
+  background-image: url("/assets/icons/shield-check-white.png");
+}
+
+.icon-shield-alert {
+  --icon-start: #f59e0b;
+  --icon-end: #d97706;
+}
+
+.icon-shield-alert::before {
+  background-image: url("/assets/icons/shield-alert-white.png");
+}
+
+.icon-oil {
+  --icon-start: #f2b44c;
+  --icon-end: #d97815;
+}
+
+.icon-oil::before {
+  background-image: url("/assets/icons/oil-white.png");
+}
+
+.param-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18rpx;
+  min-height: 96rpx;
+  padding: 0 24rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.panel-header + .param-row,
+.panel-header + view .param-row:first-child {
+  border-top: 0;
+}
+
+.param-row:first-child {
+  border-top: 0;
+}
+
+.input-row {
+  min-height: 106rpx;
+}
+
+.param-main {
+  min-width: 0;
+  flex: 1;
+}
+
+.param-name {
+  min-width: 0;
+  color: #111827;
+  font-size: 28rpx;
+  line-height: 1.35;
+  font-weight: 700;
+  word-break: break-all;
+}
+
+.param-meta {
+  margin-top: 7rpx;
+  color: #6b7280;
+  font-size: 22rpx;
+  line-height: 1.35;
+  word-break: break-all;
+}
+
+.param-meta--dirty {
+  color: #d97706;
+  font-weight: 800;
+}
+
+.param-value {
+  flex: none;
+  max-width: 320rpx;
+  color: #0f8f87;
+  font-size: 30rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  text-align: right;
+  word-break: break-all;
+}
+
+.input-wrap {
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.value-input {
+  width: 300rpx;
+  height: 70rpx;
+  padding: 0 18rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-size: 28rpx;
+  line-height: 70rpx;
+  text-align: right;
+  box-sizing: border-box;
+}
+
+.value-input--with-unit {
+  width: 300rpx;
+}
+
+.value-input:focus {
+  border-color: #0f8f87;
+  background: #f4fbfa;
+}
+
+.status-auto-controls {
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+}
+
+.auto-read-interval {
+  width: 112rpx;
+  height: 50rpx;
+  padding: 0 12rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-size: 22rpx;
+  line-height: 50rpx;
+  text-align: right;
+  box-sizing: border-box;
+}
+
+.auto-read-unit {
+  color: #64748b;
+  font-size: 22rpx;
+}
+
+.auto-read-button {
+  margin-left: 2rpx;
+}
+
+@media (max-width: 360px) {
+  .panel-title {
+    font-size: 28rpx;
+  }
+
+  .param-name {
+    font-size: 26rpx;
+  }
+
+  .param-meta {
+    font-size: 21rpx;
+  }
+
+  .value-input {
+    width: 260rpx;
+  }
+
+  .value-input--with-unit {
+    width: 260rpx;
+  }
+
+  .auto-read-interval {
+    width: 96rpx;
+  }
+
+}

+ 44 - 0
assets/LUCIDE_LICENSE.txt

@@ -0,0 +1,44 @@
+Lucide icons used in this project are generated from lucide-static SVG assets.
+
+Source: https://lucide.dev/
+License: ISC
+
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+Some Lucide icons are derived from the Feather project under the MIT License.
+
+The MIT License (MIT)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

BIN
assets/icons/bluetooth-connected-white.png


BIN
assets/icons/chip-white.png


BIN
assets/icons/control-white.png


BIN
assets/icons/curve-white.png


BIN
assets/icons/estimator-white.png


BIN
assets/icons/history-white.png


BIN
assets/icons/motor-white.png


BIN
assets/icons/oil-white.png


BIN
assets/icons/radar-white.png


BIN
assets/icons/send-white.png


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


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


BIN
assets/icons/sliders-white.png


BIN
assets/icons/speed-white.png


BIN
assets/icons/status-white.png


BIN
assets/icons/target-white.png


BIN
assets/icons/terminal-white.png


BIN
assets/icons/wind-white.png


BIN
assets/tab/control-active.png


BIN
assets/tab/control.png


BIN
assets/tab/home-active.png


BIN
assets/tab/home.png


BIN
assets/tab/params-active.png


BIN
assets/tab/params.png


+ 23 - 0
components/navigation-bar/navigation-bar.js

@@ -0,0 +1,23 @@
+Component({
+  properties: {
+    background: {
+      type: String,
+      value: ''
+    }
+  },
+  lifetimes: {
+    attached() {
+      const deviceInfo = wx.getDeviceInfo ? wx.getDeviceInfo() : wx.getSystemInfoSync()
+      const windowInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
+      const platform = deviceInfo.platform
+      const isAndroid = platform === 'android'
+      const isDevtools = platform === 'devtools'
+      const { safeArea: { top = 0 } = {} } = windowInfo
+
+      this.setData({
+        ios: !isAndroid,
+        safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${top}px); padding-top: ${top}px` : ``
+      })
+    }
+  }
+})

+ 5 - 0
components/navigation-bar/navigation-bar.json

@@ -0,0 +1,5 @@
+{
+  "component": true,
+  "styleIsolation": "apply-shared",
+  "usingComponents": {}
+}

+ 3 - 0
components/navigation-bar/navigation-bar.wxml

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

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

@@ -0,0 +1,21 @@
+.weui-navigation-bar {
+  --weui-FG-0: rgba(0, 0, 0, 0.9);
+  --height: 44px;
+  flex: none;
+  overflow: hidden;
+  color: var(--weui-FG-0);
+}
+
+.weui-navigation-bar .android {
+  --height: 48px;
+}
+
+.weui-navigation-bar__inner {
+  position: relative;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: calc(var(--height) + env(safe-area-inset-top));
+  padding-top: env(safe-area-inset-top);
+  box-sizing: border-box;
+}

+ 205 - 0
pages/home/home.js

@@ -0,0 +1,205 @@
+const transport = require('../../utils/ble-transport')
+const syncService = require('../../utils/sync-service')
+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(),
+
+  onLoad() {
+    this.pageToast = createPageToast(this, this.data)
+    transport.init()
+    this.unsubscribeTransport = transport.subscribe((transportState) => {
+      const nextState = getPageState(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))
+    })
+  },
+
+  onShow() {
+    if (this.pageToast) {
+      this.pageToast.setActive(true)
+    }
+  },
+
+  onHide() {
+    if (this.pageToast) {
+      this.pageToast.setActive(false)
+    }
+  },
+
+  onUnload() {
+    if (this.pageToast) {
+      this.pageToast.destroy()
+      this.pageToast = null
+    }
+
+    if (this.unsubscribeTransport) {
+      this.unsubscribeTransport()
+      this.unsubscribeTransport = null
+    }
+
+    if (this.unsubscribeSync) {
+      this.unsubscribeSync()
+      this.unsubscribeSync = null
+    }
+  },
+
+  onCommandChange(event) {
+    transport.setCommandIndex(event.detail.value)
+  },
+
+  onSlaveAddressInput(event) {
+    transport.setProtocolInput({
+      slaveAddress: event.detail.value
+    })
+  },
+
+  onRegisterAddressInput(event) {
+    transport.setProtocolInput({
+      registerAddress: event.detail.value
+    })
+  },
+
+  onCommandValueInput(event) {
+    transport.setProtocolInput({
+      commandValue: event.detail.value
+    })
+  },
+
+  onCoilValueChange(event) {
+    transport.setProtocolInput({
+      coilEnabled: !!event.detail.value
+    })
+  },
+
+  sendGeneratedFrame() {
+    if (!this.data.connectedDevice || !this.data.generatedHex) return
+
+    transport.sendGeneratedFrame()
+  },
+
+  onHexInput(event) {
+    transport.setSendHex(event.detail.value)
+  },
+
+  clearInput() {
+    transport.clearInput()
+  },
+
+  sendHexFrame() {
+    if (!this.data.connectedDevice) return
+
+    transport.sendHexFrame()
+  },
+
+  openSetting() {
+    transport.openSetting()
+  },
+
+  startScan() {
+    if (!this.data.canStartScan) return
+
+    if (this.data.isDiscovering) {
+      transport.stopScan()
+      return
+    }
+
+    transport.startScan()
+  },
+
+  syncRegisters() {
+    if (!this.data.canSyncRegisters) return
+
+    syncService.syncAllRegisters()
+  },
+
+  clearDevices() {
+    if (!this.data.canClearDevices) return
+
+    transport.clearDevices()
+  },
+
+  onDeviceFilterTap(event) {
+    const deviceFilterMode = event.currentTarget.dataset.filter || DEFAULT_DEVICE_FILTER
+
+    this.setData(getPageState(transport.getState(), deviceFilterMode))
+  },
+
+  connectDevice(event) {
+    transport.connectDeviceById(event.currentTarget.dataset.deviceId)
+  },
+
+  disconnectDevice() {
+    if (!this.data.canDisconnectDevice) return
+
+    transport.disconnectDevice()
+  },
+
+  clearLogs() {
+    transport.clearLogs()
+  }
+})

+ 5 - 0
pages/home/home.json

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

+ 243 - 0
pages/home/home.wxml

@@ -0,0 +1,243 @@
+<navigation-bar background="#FFF"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+  {{toastText}}
+</view>
+<scroll-view class="scrollarea" scroll-y type="list">
+  <view class="page-shell">
+    <view class="connected-panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-bluetooth"></view>
+        <view class="panel-title">连接状态</view>
+        <view class="panel-actions connection-actions">
+          <view
+            class="panel-action-button {{canSyncRegisters ? '' : 'is-disabled'}}"
+            bindtap="syncRegisters"
+          >
+            同步
+          </view>
+          <view
+            class="panel-action-button {{canStartScan ? '' : 'is-disabled'}}"
+            bindtap="startScan"
+          >
+            {{scanButtonText}}
+          </view>
+          <view
+            class="panel-action-button {{canClearDevices ? '' : 'is-disabled'}}"
+            bindtap="clearDevices"
+          >
+            清空
+          </view>
+          <view
+            class="panel-action-button {{canDisconnectDevice ? '' : 'is-disabled'}}"
+            bindtap="disconnectDevice"
+          >
+            断开
+          </view>
+        </view>
+      </view>
+      <view class="connected-summary">
+        <view class="connected-name">
+          {{connectionName}}
+        </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>
+      <view class="meta-grid">
+        <view class="meta-item">
+          <text class="meta-label">设备 ID</text>
+          <text class="meta-value">{{connectionDeviceId}}</text>
+        </view>
+        <view class="meta-item">
+          <text class="meta-label">服务数</text>
+          <text class="meta-value">{{connectionServiceCount}}</text>
+        </view>
+        <view class="meta-item">
+          <text class="meta-label">特征值</text>
+          <text class="meta-value">{{connectionCharacteristicText}}</text>
+        </view>
+      </view>
+    </view>
+
+    <view wx:if="{{showDeviceSection}}" class="device-section">
+      <view class="panel-header outside-header">
+        <view class="panel-icon icon-radar"></view>
+        <view class="panel-title">附近设备 {{deviceCountText}}</view>
+        <view class="device-filter">
+          <view
+            wx:for="{{deviceFilterOptions}}"
+            wx:key="key"
+            class="device-filter-item {{deviceFilterMode === item.key ? 'is-active' : ''}}"
+            data-filter="{{item.key}}"
+            bindtap="onDeviceFilterTap"
+          >
+            {{item.label}}
+          </view>
+        </view>
+      </view>
+
+      <scroll-view class="device-scroll" scroll-y type="list">
+        <view wx:if="{{!devices.length}}" class="empty-state">
+          <view class="empty-title">{{emptyDeviceTitle}}</view>
+          <view class="empty-text">{{emptyDeviceText}}</view>
+        </view>
+
+        <view
+          wx:for="{{devices}}"
+          wx:key="deviceId"
+          class="device-card {{connectedDevice && connectedDevice.deviceId === item.deviceId ? 'device-card--connected' : ''}}"
+          data-device-id="{{item.deviceId}}"
+          bindtap="connectDevice"
+        >
+          <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-id">{{item.deviceId}}</view>
+            <view class="device-meta-row">
+              <view class="device-service">{{item.serviceText}}</view>
+              <view wx:if="{{item.targetText}}" class="device-target">{{item.targetText}}</view>
+            </view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <view class="panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-terminal"></view>
+        <view class="panel-title">Modbus RTU 指令</view>
+        <view class="panel-actions">
+          <view
+            class="panel-action-button {{!connectedDevice || !generatedHex ? 'is-disabled' : ''}}"
+            bindtap="sendGeneratedFrame"
+          >
+            下发
+          </view>
+        </view>
+      </view>
+      <view class="protocol-form">
+        <view class="protocol-row">
+          <text class="protocol-label">功能码</text>
+          <picker
+            class="protocol-picker"
+            mode="selector"
+            range="{{protocolCommands}}"
+            range-key="label"
+            value="{{commandIndex}}"
+            bindchange="onCommandChange"
+          >
+            <view class="picker-value">{{protocolCommands[commandIndex].label}}</view>
+          </picker>
+        </view>
+        <view class="protocol-row protocol-field-row">
+          <text class="protocol-label">从站地址</text>
+          <input
+            class="protocol-input protocol-row-input"
+            type="text"
+            maxlength="2"
+            value="{{slaveAddress}}"
+            bindinput="onSlaveAddressInput"
+          />
+        </view>
+        <view class="protocol-row protocol-field-row">
+          <text class="protocol-label">协议寄存器</text>
+          <input
+            class="protocol-input protocol-row-input"
+            type="text"
+            maxlength="4"
+            value="{{registerAddress}}"
+            bindinput="onRegisterAddressInput"
+          />
+        </view>
+        <view wx:if="{{showCommandValue}}" class="protocol-row protocol-field-row">
+          <text class="protocol-label">{{commandValueLabel}}</text>
+          <input
+            class="protocol-input protocol-row-input"
+            type="text"
+            value="{{commandValue}}"
+            bindinput="onCommandValueInput"
+          />
+        </view>
+        <view wx:if="{{showCoilValue}}" class="protocol-row coil-row">
+          <text class="protocol-label">线圈值</text>
+          <view class="coil-control">
+            <text>{{coilEnabled ? 'ON' : 'OFF'}}</text>
+            <switch checked="{{coilEnabled}}" color="#0f766e" bindchange="onCoilValueChange" />
+          </view>
+        </view>
+        <view class="generated-frame">
+          <view class="generated-title">生成帧</view>
+          <view class="generated-value">{{generatedHex || '--'}}</view>
+          <view wx:if="{{protocolErrorText}}" class="protocol-error">{{protocolErrorText}}</view>
+        </view>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel-header panel-header--with-actions">
+        <view class="panel-icon icon-send"></view>
+        <view class="panel-title">发送</view>
+        <view class="panel-actions">
+          <view class="panel-action-button" bindtap="clearInput">清空</view>
+          <view
+            class="panel-action-button {{!connectedDevice ? 'is-disabled' : ''}}"
+            bindtap="sendHexFrame"
+          >
+            发送
+          </view>
+        </view>
+      </view>
+      <textarea
+        class="hex-input"
+        maxlength="-1"
+        auto-height
+        placeholder="例如:01 03 00 00 00 01 84 0A"
+        value="{{sendHex}}"
+        bindinput="onHexInput"
+      />
+    </view>
+
+    <view class="panel">
+      <view class="panel-header panel-header--with-actions log-header">
+        <view class="panel-icon icon-history"></view>
+        <view class="panel-title">收发日志 {{logs.length ? '(' + logs.length + ')' : ''}}</view>
+        <view class="panel-actions">
+          <view class="panel-action-button" bindtap="clearLogs">清空</view>
+        </view>
+      </view>
+      <scroll-view
+        class="log-scroll"
+        scroll-y
+        scroll-with-animation
+        scroll-into-view="{{logScrollTarget}}"
+        type="list"
+      >
+        <view wx:if="{{!logs.length}}" class="empty-log">暂无收发数据</view>
+        <view
+          wx:for="{{logs}}"
+          wx:key="id"
+          id="{{item.id}}"
+          class="log-row log-row--{{item.direction}}"
+        >
+          <view class="log-meta">
+            <view class="log-tags">
+              <text class="log-direction">{{item.direction}}</text>
+              <text wx:if="{{item.note}}" class="log-note">{{item.note}}</text>
+            </view>
+            <text class="log-time">{{item.time}}</text>
+          </view>
+          <view class="log-payload">{{item.payload}}</view>
+        </view>
+      </scroll-view>
+    </view>
+  </view>
+</scroll-view>

+ 467 - 0
pages/home/home.wxss

@@ -0,0 +1,467 @@
+.connected-panel,
+.device-card,
+.empty-state {
+  border: 1rpx solid #e6ebf2;
+  border-radius: 18rpx;
+  background: #ffffff;
+  box-shadow: var(--shadow-soft);
+  box-sizing: border-box;
+}
+
+.connected-panel {
+  margin-top: 24rpx;
+  padding-bottom: 24rpx;
+}
+
+.connected-panel:first-child {
+  margin-top: 0;
+}
+
+.connected-summary {
+  min-height: 56rpx;
+  padding: 8rpx 24rpx 0;
+}
+
+.connected-name {
+  min-width: 0;
+  flex: 1;
+  color: #111827;
+  font-size: 34rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.connection-badges {
+  justify-content: flex-start;
+  gap: 14rpx;
+  padding: 8rpx 24rpx 0;
+}
+
+.traffic-badge {
+  flex: none;
+  min-width: 144rpx;
+  text-align: left;
+  color: #b45309;
+  font-size: 22rpx;
+  line-height: 1.35;
+  white-space: nowrap;
+}
+
+.meta-grid {
+  display: flex;
+  flex-direction: column;
+  margin: 18rpx 24rpx 0;
+}
+
+.meta-item {
+  display: flex;
+  justify-content: space-between;
+  gap: 20rpx;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.meta-item:first-child {
+  border-top: 0;
+}
+
+.meta-label {
+  flex: none;
+  color: #64748b;
+  font-size: 24rpx;
+}
+
+.meta-value {
+  min-width: 0;
+  color: #111827;
+  font-size: 24rpx;
+  text-align: right;
+  word-break: break-all;
+}
+
+.device-section {
+  margin-top: 30rpx;
+  padding-bottom: 10rpx;
+}
+
+.device-section .panel-title {
+  flex: 1;
+  white-space: nowrap;
+  word-break: keep-all;
+}
+
+.device-filter {
+  flex: none;
+  display: flex;
+  align-items: center;
+  padding: 4rpx;
+  border: 1rpx solid #d8e2ea;
+  border-radius: 12rpx;
+  background: #eef4f7;
+  box-sizing: border-box;
+}
+
+.device-filter-item {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 72rpx;
+  height: 42rpx;
+  border-radius: 8rpx;
+  color: #64748b;
+  font-size: 21rpx;
+  line-height: 1.2;
+  font-weight: 800;
+}
+
+.device-filter-item.is-active {
+  background: #ffffff;
+  color: var(--accent-dark);
+  box-shadow: 0 4rpx 10rpx rgba(15, 23, 42, 0.08);
+}
+
+.device-scroll {
+  height: 520rpx;
+  box-sizing: border-box;
+}
+
+.empty-state {
+  padding: 42rpx 28rpx;
+  text-align: center;
+}
+
+.empty-title {
+  color: #111827;
+  font-size: 30rpx;
+  line-height: 1.4;
+  font-weight: 800;
+}
+
+.empty-text {
+  margin-top: 10rpx;
+  color: #6b7280;
+  font-size: 25rpx;
+  line-height: 1.45;
+}
+
+.device-card {
+  margin-bottom: 18rpx;
+  padding: 18rpx 20rpx;
+}
+
+.device-card--connected {
+  border-color: #93d5cf;
+  background: #f0fdfa;
+}
+
+.device-info {
+  min-width: 0;
+}
+
+.device-main-row,
+.device-meta-row {
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+}
+
+.device-name {
+  min-width: 0;
+  flex: 1;
+  color: #111827;
+  font-size: 30rpx;
+  line-height: 1.35;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.device-badges {
+  flex: none;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 18rpx;
+}
+
+.device-id,
+.device-service,
+.device-target {
+  margin-top: 8rpx;
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.4;
+  word-break: break-all;
+}
+
+.device-target {
+  flex: none;
+  color: var(--accent-dark);
+  font-weight: 800;
+}
+
+.rssi {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 112rpx;
+  height: 46rpx;
+  padding: 0 14rpx;
+  border-radius: 999rpx;
+  background: #f1f5f9;
+  color: #475569;
+  font-size: 22rpx;
+  line-height: 1.35;
+  box-sizing: border-box;
+  white-space: nowrap;
+}
+
+.connect-state {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 96rpx;
+  height: 46rpx;
+  padding: 0;
+  border-radius: 999rpx;
+  background: var(--accent-soft);
+  color: var(--accent-dark);
+  font-size: 22rpx;
+  line-height: 1.35;
+  white-space: nowrap;
+}
+
+.connect-state.connected {
+  background: #dcfce7;
+  color: #166534;
+}
+
+.protocol-form {
+  padding: 8rpx 24rpx 0;
+}
+
+.protocol-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18rpx;
+  min-height: 76rpx;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.protocol-field-row {
+  justify-content: space-between;
+}
+
+.protocol-row:first-child {
+  border-top: 0;
+}
+
+.protocol-label {
+  color: #475569;
+  font-size: 24rpx;
+  line-height: 1.35;
+  font-weight: 700;
+}
+
+.protocol-picker {
+  width: 350rpx;
+  padding: 15rpx 16rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  box-sizing: border-box;
+}
+
+.picker-value {
+  color: #111827;
+  font-size: 25rpx;
+  line-height: 1.35;
+  text-align: right;
+}
+
+.protocol-input {
+  width: 100%;
+  height: 68rpx;
+  padding: 0 16rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 10rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 27rpx;
+  line-height: 68rpx;
+  text-align: right;
+  box-sizing: border-box;
+}
+
+.protocol-row-input {
+  flex: none;
+  width: 350rpx;
+}
+
+.coil-row {
+  margin-top: 16rpx;
+}
+
+.coil-control {
+  display: flex;
+  align-items: center;
+  gap: 14rpx;
+  color: var(--accent-dark);
+  font-size: 25rpx;
+  font-weight: 800;
+}
+
+.generated-frame {
+  margin-top: 18rpx;
+  margin-bottom: 24rpx;
+  padding: 16rpx 18rpx;
+  border: 1rpx solid #d9edeb;
+  border-radius: 12rpx;
+  background: #f4fbfa;
+}
+
+.generated-title {
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 1.35;
+}
+
+.generated-value {
+  margin-top: 8rpx;
+  color: #0f766e;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 25rpx;
+  line-height: 1.55;
+  font-weight: 800;
+  word-break: break-all;
+}
+
+.protocol-error {
+  margin-top: 8rpx;
+  color: var(--danger);
+  font-size: 23rpx;
+  line-height: 1.4;
+}
+
+.hex-input {
+  width: auto;
+  min-height: 190rpx;
+  margin: 12rpx 24rpx 24rpx;
+  padding: 20rpx;
+  border: 1rpx solid #e7edf3;
+  border-radius: 14rpx;
+  background: #fafbfd;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 27rpx;
+  line-height: 1.55;
+  box-sizing: border-box;
+}
+
+.log-header {
+  padding-right: 16rpx;
+}
+
+.empty-log {
+  padding: 42rpx 24rpx;
+  color: #64748b;
+  font-size: 25rpx;
+  line-height: 1.4;
+  text-align: center;
+}
+
+.log-scroll {
+  height: 500rpx;
+  border-top: 1rpx solid #edf2f7;
+  box-sizing: border-box;
+}
+
+.log-row {
+  padding: 18rpx 24rpx;
+  border-top: 1rpx solid #edf2f7;
+}
+
+.log-row:first-child {
+  border-top: 0;
+}
+
+.log-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18rpx;
+}
+
+.log-tags {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.log-direction {
+  color: #0f766e;
+  font-size: 23rpx;
+  line-height: 1.35;
+  font-weight: 900;
+}
+
+.log-note {
+  padding: 3rpx 9rpx;
+  border-radius: 999rpx;
+  background: #eff6ff;
+  color: #2563eb;
+  font-size: 20rpx;
+  line-height: 1.35;
+  font-weight: 800;
+}
+
+.log-row--RX .log-note {
+  background: #ecfdf5;
+  color: #047857;
+}
+
+.log-row--TX .log-direction {
+  color: #2563eb;
+}
+
+.log-row--SYS .log-direction {
+  color: #64748b;
+}
+
+.log-time {
+  color: #94a3b8;
+  font-size: 22rpx;
+  line-height: 1.35;
+}
+
+.log-payload {
+  margin-top: 8rpx;
+  color: #111827;
+  font-family: Menlo, Monaco, Consolas, monospace;
+  font-size: 25rpx;
+  line-height: 1.55;
+  word-break: break-all;
+}
+
+@media (max-width: 360px) {
+  .device-card {
+    padding: 16rpx;
+  }
+
+  .device-main-row {
+    align-items: flex-start;
+  }
+
+  .protocol-picker {
+    width: 280rpx;
+  }
+
+  .protocol-row-input {
+    width: 280rpx;
+  }
+
+}

+ 63 - 0
pages/index/index.js

@@ -0,0 +1,63 @@
+const controlService = require('../../utils/control-service')
+const {
+  createPageToast
+} = require('../../utils/page-toast')
+
+Page({
+  data: controlService.getState(),
+
+  onLoad() {
+    this.pageToast = createPageToast(this, this.data)
+    controlService.init()
+    this.unsubscribeControl = controlService.subscribe((nextState) => {
+      this.setData(nextState)
+      this.pageToast.showFromState(nextState)
+    })
+  },
+
+  onShow() {
+    if (this.pageToast) {
+      this.pageToast.setActive(true)
+    }
+
+    controlService.syncSharedInputs()
+  },
+
+  onHide() {
+    if (this.pageToast) {
+      this.pageToast.setActive(false)
+    }
+  },
+
+  onUnload() {
+    if (this.pageToast) {
+      this.pageToast.destroy()
+      this.pageToast = null
+    }
+
+    if (this.unsubscribeControl) {
+      this.unsubscribeControl()
+      this.unsubscribeControl = null
+    }
+  },
+
+  onSpeedCommandInput(event) {
+    controlService.updateSpeedCommandInput(event.detail.value)
+  },
+
+  onSpeedCommandBlur(event) {
+    controlService.updateSpeedCommandBlur(event.detail.value)
+  },
+
+  readControlStatus() {
+    if (!this.data.connectedDevice) return
+
+    controlService.readControlStatus()
+  },
+
+  onControlButtonTap(event) {
+    if (!this.data.connectedDevice) return
+
+    controlService.sendControlCommand(event.currentTarget.dataset.key)
+  }
+})

+ 5 - 0
pages/index/index.json

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

+ 59 - 0
pages/index/index.wxml

@@ -0,0 +1,59 @@
+<navigation-bar background="#FFF"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+  {{toastText}}
+</view>
+<scroll-view class="scrollarea" scroll-y type="list">
+  <view class="page-shell">
+    <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'}}"
+            bindtap="readControlStatus"
+          >
+            读取
+          </view>
+          <view
+            wx:for="{{controlActionButtons}}"
+            wx:key="key"
+            class="panel-action-button {{connectedDevice ? '' : 'is-disabled'}}"
+            data-key="{{item.key}}"
+            bindtap="onControlButtonTap"
+          >
+            {{item.name}}
+          </view>
+        </view>
+      </view>
+      <view class="param-row input-row">
+        <view class="param-main">
+          <view class="param-name">{{speedCommand.name}}</view>
+          <view class="param-meta {{speedCommand.isDirty ? 'param-meta--dirty' : ''}}">{{speedCommand.addressDisplay}} {{speedCommand.writeValue}}</view>
+        </view>
+        <view class="input-wrap">
+          <input
+            class="value-input"
+            type="{{speedCommand.unit ? 'text' : 'digit'}}"
+            placeholder="--"
+            value="{{speedCommand.inputValue}}"
+            bindinput="onSpeedCommandInput"
+            bindblur="onSpeedCommandBlur"
+          />
+        </view>
+      </view>
+      <view class="control-grid">
+        <view wx:for="{{controlButtons}}" wx:key="key" class="control-cell control-cell--{{item.key}}">
+          <button
+            class="control-button control-button--{{item.key}}"
+            disabled="{{!connectedDevice}}"
+            data-key="{{item.key}}"
+            bindtap="onControlButtonTap"
+          >
+            <text class="control-name">{{item.name}}</text>
+          </button>
+        </view>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 56 - 0
pages/index/index.wxss

@@ -0,0 +1,56 @@
+.control-button::after {
+  border: 0;
+}
+
+.control-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 14rpx;
+  padding: 0 20rpx 22rpx;
+}
+
+.control-cell {
+  width: calc((100% - 14rpx) / 2);
+  box-sizing: border-box;
+}
+
+.control-cell--power {
+  width: 100%;
+}
+
+.control-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  min-height: 86rpx;
+  margin: 0;
+  padding: 0 20rpx;
+  border: 1rpx solid #d8e2ea;
+  border-radius: 14rpx;
+  background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
+  color: #1f2937;
+  font-size: 26rpx;
+  line-height: 1.3;
+  font-weight: 800;
+  box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
+  box-sizing: border-box;
+}
+
+.control-button:active {
+  opacity: 0.72;
+}
+
+.control-name {
+  min-width: 0;
+  flex: 1;
+  color: #111827;
+  text-align: center;
+  word-break: break-all;
+}
+
+button[disabled].control-button {
+  background: #eef1f5;
+  color: #94a3b8;
+  box-shadow: none;
+}

+ 339 - 0
pages/params/params.js

@@ -0,0 +1,339 @@
+const paramsPageState = require('../../utils/params-page-state')
+const paramsService = require('../../utils/params-service')
+const controlService = require('../../utils/control-service')
+const {
+  getStatusPageState
+} = require('../../utils/status-page-state')
+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(),
+
+  onLoad() {
+    this.pageToast = createPageToast(this, this.data)
+    controlService.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
+      )
+
+      this.setData(nextState)
+      this.pageToast.showFromState(nextState)
+    })
+    this.unsubscribeControl = controlService.subscribe((controlState) => {
+      const nextState = getControlViewState(controlState)
+
+      this.setData(nextState)
+      this.pageToast.showFromState(nextState)
+    })
+  },
+
+  onShow() {
+    if (this.pageToast) {
+      this.pageToast.setActive(true)
+    }
+
+    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)
+    }
+
+    this.setData(pageState)
+    this.pageToast.showFromState(pageState)
+  },
+
+  onHide() {
+    if (this.pageToast) {
+      this.pageToast.setActive(false)
+    }
+  },
+
+  onUnload() {
+    if (this.pageToast) {
+      this.pageToast.destroy()
+      this.pageToast = null
+    }
+
+    if (this.unsubscribeSync) {
+      this.unsubscribeSync()
+      this.unsubscribeSync = null
+    }
+
+    if (this.unsubscribeControl) {
+      this.unsubscribeControl()
+      this.unsubscribeControl = null
+    }
+  },
+
+  async onGroupRead(event) {
+    if (!this.data.connectedDevice) return
+
+    const groupKey = event.currentTarget.dataset.group
+    const nextState = await paramsService.readGroup(this.data, groupKey)
+
+    if (nextState) {
+      this.setData(nextState)
+      if (this.pageToast) this.pageToast.show(`${getGroupLabel(groupKey)}读取完成`)
+    }
+  },
+
+  async onGroupWrite(event) {
+    if (!this.data.connectedDevice) return
+
+    const groupKey = event.currentTarget.dataset.group
+    const written = await paramsService.writeGroup(this.data, groupKey)
+
+    if (written) {
+      this.setData(paramsPageState.clearGroupDirty(this.data, groupKey))
+      if (this.pageToast) this.pageToast.show(`${getGroupLabel(groupKey)}写入完成`)
+    }
+  },
+
+  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 || {}
+
+    if (!cardKey) return
+
+    this.setData({
+      [`collapsedCards.${cardKey}`]: !collapsedCards[cardKey]
+    })
+  },
+
+  onMotorParameterInput(event) {
+    controlService.updateMotorParameterInput(
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    )
+  },
+
+  onMotorParameterBlur(event) {
+    controlService.updateMotorParameterBlur(
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    )
+  },
+
+  readMotorParameters() {
+    if (!this.data.connectedDevice) return
+
+    controlService.readMotorParameters()
+  },
+
+  writeMotorParameters() {
+    if (!this.data.connectedDevice) return
+
+    controlService.writeMotorParameters()
+  },
+
+  readDriverParameters() {
+    if (!this.data.connectedDevice) return
+
+    controlService.readDriverParameters()
+  },
+
+  readStatus() {
+    if (!this.data.canReadStatus) return
+
+    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,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onAtoBandwidthInput(event) {
+    this.setData(paramsPageState.applyAtoBandwidthInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onDqGainInput(event) {
+    this.setData(paramsPageState.applyDqGainInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onSpeedLoopExtraInput(event) {
+    this.setData(paramsPageState.applySpeedLoopExtraInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onOilParameterInput(event) {
+    this.setData(paramsPageState.applyOilParameterInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onPrepositionParameterInput(event) {
+    this.setData(paramsPageState.applyPrepositionParameterInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onInputBlur(event) {
+    this.setData(paramsPageState.applyInputBlur(
+      this.data,
+      event.currentTarget.dataset.inputGroup,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  },
+
+  onTailwindSwitchChange(event) {
+    if (!this.data.connectedDevice) return
+
+    const index = Number(event.currentTarget.dataset.index)
+    const nextState = paramsPageState.applyTailwindSwitchChange(
+      this.data,
+      index,
+      !!event.detail.value
+    )
+
+    this.setData(nextState)
+    paramsService.writeSwitchRegister(nextState.tailwindSwitchRegisters[index]).then((written) => {
+      if (written) {
+        this.setData(paramsPageState.clearTailwindSwitchDirty(this.data, index))
+        if (this.pageToast) this.pageToast.show(`${nextState.tailwindSwitchRegisters[index].name}写入完成`)
+      }
+    })
+  },
+
+  onProtectionSwitchChange(event) {
+    if (!this.data.connectedDevice) return
+
+    const index = Number(event.currentTarget.dataset.index)
+    const nextState = paramsPageState.applyProtectionSwitchChange(
+      this.data,
+      index,
+      !!event.detail.value
+    )
+
+    this.setData(nextState)
+    paramsService.writeSwitchRegister(nextState.protectionSwitchRegisters[index]).then((written) => {
+      if (written) {
+        this.setData(paramsPageState.clearProtectionSwitchDirty(this.data, index))
+        if (this.pageToast) this.pageToast.show(`${nextState.protectionSwitchRegisters[index].name}写入完成`)
+      }
+    })
+  },
+
+  onProtectionInputChange(event) {
+    this.setData(paramsPageState.applyProtectionInput(
+      this.data,
+      Number(event.currentTarget.dataset.index),
+      event.detail.value
+    ))
+  }
+})

+ 5 - 0
pages/params/params.json

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

+ 633 - 0
pages/params/params.wxml

@@ -0,0 +1,633 @@
+<navigation-bar background="#FFF"></navigation-bar>
+<view wx:if="{{toastText}}" class="page-toast page-toast--{{toastType}}">
+  {{toastText}}
+</view>
+<scroll-view class="scrollarea" 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>
+        </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>
+        <view
+          class="collapse-toggle {{collapsedCards.motor ? 'is-collapsed' : ''}}"
+          data-card="motor"
+          bindtap="toggleCard"
+        >
+          <view class="collapse-indicator"></view>
+        </view>
+      </view>
+      <block wx:if="{{!collapsedCards.motor}}">
+        <view wx:for="{{motorParameterInputRegisters}}" wx:key="name" class="param-row input-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 value-input--with-unit"
+              type="{{item.unit ? 'text' : 'digit'}}"
+              placeholder="--"
+              value="{{item.inputValue}}"
+              data-index="{{index}}"
+              bindinput="onMotorParameterInput"
+              bindblur="onMotorParameterBlur"
+            />
+          </view>
+        </view>
+      </block>
+    </view>
+
+    <view class="panel {{collapsedCards.driver ? 'panel--collapsed' : ''}}">
+      <view class="panel-header panel-header--with-actions">
+        <view
+          class="panel-heading-toggle {{collapsedCards.driver ? 'is-collapsed' : ''}}"
+          data-card="driver"
+          bindtap="toggleCard"
+        >
+          <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"
+          >
+            读取
+          </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>
+
+    <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}}">
+        <view wx:for="{{estimatorCalculatedDisplayRegisters}}" 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="{{atoBandwidthDisplayRegisters}}"
+          wx:for-item="atoItem"
+          wx:key="name"
+          class="param-row input-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{atoItem.name}}</view>
+            <view class="param-meta {{atoItem.isDirty ? 'param-meta--dirty' : ''}}">KP 0x{{atoItem.kpAddress}} {{atoItem.kpWriteValue || '--'}}  KI 0x{{atoItem.kiAddress}} {{atoItem.kiWriteValue || '--'}}</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input {{atoItem.unit ? 'value-input--with-unit' : ''}}"
+              type="{{atoItem.unit ? 'text' : 'digit'}}"
+              placeholder="--"
+              value="{{atoItem.inputValue}}"
+              data-index="{{atoItem.sourceIndex}}"
+              data-input-group="ato"
+              bindinput="onAtoBandwidthInput"
+              bindblur="onInputBlur"
+            />
+          </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}}">
+        <view
+          wx:for="{{dqGainDisplayRegisters}}"
+          wx:for-item="dqItem"
+          wx:key="name"
+          class="param-row input-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{dqItem.name}}</view>
+            <view class="param-meta {{dqItem.isDirty ? 'param-meta--dirty' : ''}}">{{dqItem.addressDisplay}} {{dqItem.writeValue || '--'}}</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input {{dqItem.unit ? 'value-input--with-unit' : ''}}"
+              type="{{dqItem.unit ? 'text' : 'digit'}}"
+              placeholder="--"
+              value="{{dqItem.inputValue}}"
+              data-index="{{dqItem.sourceIndex}}"
+              data-input-group="dq"
+              bindinput="onDqGainInput"
+              bindblur="onInputBlur"
+            />
+          </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}}">
+        <view wx:for="{{tailwindControlRegisters}}" wx:key="address" 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>
+          <switch
+            checked="{{item.value}}"
+            color="#0f766e"
+            disabled="{{!connectedDevice}}"
+            data-index="{{item.sourceIndex}}"
+            bindchange="onTailwindSwitchChange"
+          />
+        </view>
+        <view wx:for="{{tailwindCalculatedDisplayRegisters}}" 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="{{tailwindAtoBandwidthDisplayRegisters}}"
+          wx:for-item="atoItem"
+          wx:key="name"
+          class="param-row input-row"
+        >
+          <view class="param-main">
+            <view class="param-name">{{atoItem.name}}</view>
+            <view class="param-meta {{atoItem.isDirty ? 'param-meta--dirty' : ''}}">KP 0x{{atoItem.kpAddress}} {{atoItem.kpWriteValue || '--'}}  KI 0x{{atoItem.kiAddress}} {{atoItem.kiWriteValue || '--'}}</view>
+          </view>
+          <view class="input-wrap">
+            <input
+              class="value-input {{atoItem.unit ? 'value-input--with-unit' : ''}}"
+              type="{{atoItem.unit ? 'text' : 'digit'}}"
+              placeholder="--"
+              value="{{atoItem.inputValue}}"
+              data-index="{{atoItem.sourceIndex}}"
+              data-input-group="ato"
+              bindinput="onAtoBandwidthInput"
+              bindblur="onInputBlur"
+            />
+          </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 wx:for="{{prepositionSwitchRegisters}}" wx:key="address" 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>
+          <switch
+            checked="{{item.value}}"
+            color="#0f766e"
+            disabled="{{!connectedDevice}}"
+            data-index="{{item.sourceIndex}}"
+            bindchange="onTailwindSwitchChange"
+          />
+        </view>
+        <view wx:for="{{prepositionParameterDisplayRegisters}}" wx:key="name" class="param-row input-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="preposition"
+              bindinput="onPrepositionParameterInput"
+              bindblur="onInputBlur"
+            />
+          </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}}">
+        <view wx:for="{{speedLoopInputDisplayRegisters}}" wx:key="name">
+          <view class="param-row input-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="parameter"
+                bindinput="onInputChange"
+                bindblur="onInputBlur"
+              />
+            </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">
+              <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="speedLoopExtra"
+                bindinput="onSpeedLoopExtraInput"
+                bindblur="onInputBlur"
+              />
+            </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 wx:for="{{vspCurveRegisters}}" wx:key="name">
+          <view class="param-row input-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 || '--'}}{{item.actualText ? '  ' + item.actualText : ''}}</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="parameter"
+                bindinput="onInputChange"
+                bindblur="onInputBlur"
+              />
+            </view>
+          </view>
+        </view>
+        <view class="param-row">
+          <view class="param-main">
+            <view class="param-name">{{speedSlopeRegister.name}}</view>
+            <view class="param-meta {{speedSlopeRegister.isDirty ? 'param-meta--dirty' : ''}}">{{speedSlopeRegister.addressDisplay}} {{speedSlopeRegister.writeValue || '--'}}</view>
+          </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 wx:for="{{oilParameterInputRegisters}}" wx:key="name" class="param-row input-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="{{index}}"
+              data-input-group="oil"
+              bindinput="onOilParameterInput"
+              bindblur="onInputBlur"
+            />
+          </view>
+        </view>
+      </block>
+    </view>
+
+    <view class="panel {{collapsedCards.protectionSwitch ? 'panel--collapsed' : ''}}">
+      <view class="panel-header panel-header--with-actions">
+        <view
+          class="panel-heading-toggle {{collapsedCards.protectionSwitch ? 'is-collapsed' : ''}}"
+          data-card="protectionSwitch"
+          bindtap="toggleCard"
+        >
+          <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"
+        >
+          <view class="collapse-indicator"></view>
+        </view>
+      </view>
+      <block wx:if="{{!collapsedCards.protectionSwitch}}">
+        <view wx:for="{{protectionSwitchRegisters}}" wx:key="address" 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>
+          <switch
+            checked="{{item.value}}"
+            color="#0f766e"
+            disabled="{{!connectedDevice}}"
+            data-index="{{index}}"
+            bindchange="onProtectionSwitchChange"
+          />
+        </view>
+      </block>
+    </view>
+
+    <view class="panel {{collapsedCards.protection ? 'panel--collapsed' : ''}}">
+      <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>
+        <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>
+        <view
+          class="collapse-toggle {{collapsedCards.protection ? 'is-collapsed' : ''}}"
+          data-card="protection"
+          bindtap="toggleCard"
+        >
+          <view class="collapse-indicator"></view>
+        </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>
+      </block>
+    </view>
+
+    <view class="panel {{collapsedCards.status ? 'panel--collapsed' : ''}}">
+      <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-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>
+          <view
+            class="panel-action-button {{canReadStatus ? '' : 'is-disabled'}}"
+            bindtap="readStatus"
+          >
+            读取
+          </view>
+        </view>
+        <view
+          class="collapse-toggle {{collapsedCards.status ? 'is-collapsed' : ''}}"
+          data-card="status"
+          bindtap="toggleCard"
+        >
+          <view class="collapse-indicator"></view>
+        </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>
+          </view>
+          <view class="param-value">{{item.displayValue}}{{item.displayUnit ? ' ' + item.displayUnit : ''}}</view>
+        </view>
+      </block>
+    </view>
+  </view>
+</scroll-view>

+ 40 - 0
project.config.json

@@ -0,0 +1,40 @@
+{
+  "setting": {
+    "es6": true,
+    "postcss": true,
+    "minified": true,
+    "uglifyFileName": false,
+    "enhance": true,
+    "packNpmRelationList": [],
+    "babelSetting": {
+      "ignore": [],
+      "disablePlugins": [],
+      "outputPath": ""
+    },
+    "useCompilerPlugins": false,
+    "minifyWXML": true,
+    "compileWorklet": false,
+    "uploadWithSourceMap": true,
+    "packNpmManually": false,
+    "minifyWXSS": true,
+    "localPlugins": false,
+    "disableUseStrict": false,
+    "condition": false,
+    "swc": false,
+    "disableSWC": true
+  },
+  "compileType": "miniprogram",
+  "simulatorPluginLibVersion": {},
+  "packOptions": {
+    "ignore": [
+      {
+        "type": "file",
+        "value": "protrol.txt"
+      }
+    ],
+    "include": []
+  },
+  "appid": "wx750afa22d7ff75fc",
+  "editorSetting": {},
+  "libVersion": "3.16.1"
+}

+ 21 - 0
project.private.config.json

@@ -0,0 +1,21 @@
+{
+  "libVersion": "3.16.1",
+  "projectname": "%E8%93%9D%E7%89%99%E5%B7%A5%E5%85%B7",
+  "setting": {
+    "urlCheck": true,
+    "coverView": true,
+    "lazyloadPlaceholderEnable": false,
+    "skylineRenderEnable": false,
+    "preloadBackgroundData": false,
+    "autoAudits": false,
+    "showShadowRootInWxmlPanel": true,
+    "compileHotReLoad": true,
+    "useApiHook": true,
+    "useStaticServer": false,
+    "useLanDebug": false,
+    "showES6CompileOption": false,
+    "checkInvalidKey": true,
+    "ignoreDevUnusedFiles": true,
+    "bigPackageSizeSupport": false
+  }
+}

+ 137 - 0
protrol.txt

@@ -0,0 +1,137 @@
+说明
+  Modbus RTU 一个寄存器为 16 位。
+  float 占用两个连续 16 位寄存器。
+  同一地址的两个 uint8_t 字段,高 8 位在前,低 8 位在后。
+  下位机单次 DMA 长度为 64 字节,03/04 单帧最多读取 29 个寄存器,10 单帧最多写入 27 个寄存器。
+
+控制类寄存器 17
+  00 uint8_t 协议控制使能
+  01 uint8_t 开关机
+  02 uint8_t 转向切换
+  03 uint8_t 配置固化
+  04 uint8_t 软复位
+  05 uint8_t 顺逆风启用
+  06 uint8_t 预定位启用
+  07 uint8_t 保护使能
+  08 uint8_t 恢复使能
+  09 uint8_t 电压保护使能
+  0A uint8_t 电流保护使能
+  0B uint8_t 堵转保护使能
+  0C uint8_t 功率保护使能
+  0D uint8_t 温度保护使能
+  0E uint8_t 缺相保护使能
+  0F uint8_t PWM 丢失保护
+  10 uint8_t 串口丢失保护
+
+估算器配置参数 27
+  30 uint16_t OBS_E1K
+  31 uint16_t OBS_E2K
+  32 uint16_t OBS_E3K
+  33 uint16_t OBS_E4K
+  34 uint16_t FOC_KFG
+  35 uint16_t SPEED_KLPF
+  36 uint16_t OBS_FBASE
+  37 uint16_t OBS_EA_KS
+  38 uint16_t OBS_KP_START
+  39 uint16_t OBS_KI_START
+  3A uint16_t OBS_KP_RUN1
+  3B uint16_t OBS_KI_RUN1
+  3C uint16_t OBS_KP_RUN2
+  3D uint16_t OBS_KI_RUN2
+  3E uint16_t OBS_KP_RUN3
+  3F uint16_t OBS_KI_RUN3
+  40 uint16_t OBS_KP_RUN4
+  41 uint16_t OBS_KI_RUN4
+  42 uint16_t DQ_KP_START
+  43 uint16_t DQ_KI_START
+  44 uint16_t DQ_KP_RUN
+  45 uint16_t DQ_KI_RUN
+  46 uint16_t SPEED_KLPF_TAILWIND
+  47 uint16_t OBS_EA_KS_TAILWIND
+  48 uint16_t OBS_KP_TAILWIND
+  49 uint16_t OBS_KI_TAILWIND
+  4A uint16_t 预定位角度
+
+参数配置 46
+  60-61 float LD
+  62-63 float LQ
+  64-65 float RS
+  66 uint16_t 极对数
+  67 uint16_t 速度基准
+  68 uint16_t 转速命令
+  69 uint16_t 速度最大值
+  6A uint16_t 速度最小值
+  6B uint16_t SOUT_MAX
+  6C-6D float 启动加速加速度
+  6E-6F float 启动减速加速度
+  70-71 float 运行加速加速度
+  72-73 float 运行减速加速度
+  74 uint16_t 开机电压
+  75 uint16_t 关机电压
+  76 uint16_t 调速最高电压
+  77 uint16_t 调速最低电压
+  78-79 float 调速曲线斜率
+  7A uint16_t 上油转速
+  7B uint16_t 上油时间
+  7C uint16_t 硬件过流值
+  7D uint16_t 软件过流值
+  7E uint16_t 过压保护值
+  7F uint16_t 欠压保护值
+  80 uint16_t 过压恢复值
+  81 uint16_t 欠压恢复值
+  82 uint16_t 速度限制最大值
+  83 uint16_t 速度限制最小值
+  84 uint16_t 反电动势低阈值
+  85 uint16_t 反电动势高阈值
+  86 uint16_t 速度中间值
+  87 uint16_t 功率保护值
+  88 uint16_t 功率保护检测时间
+  89 uint16_t 温度保护值
+  8A uint16_t 温度恢复值
+  8B uint16_t 温度保护检测时间
+  8C uint16_t 故障恢复检测时间
+  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 运放倍数
+  A2 uint16_t 采样电阻,单位 mΩ
+  A3 uint16_t 全区 Flash 校验码
+  A4-A5 float 母线电压分压比
+  A6-A7 float 模拟输入电压分压比
+
+状态类寄存器 29
+  C0 uint8_t 状态机 高 8 位
+  C0 uint8_t 故障码 低 8 位
+  C1 int16_t UQ
+  C2 int16_t UD
+  C3 int16_t IQ
+  C4 int16_t ID
+  C5 int16_t A 相电流
+  C6 int16_t B 相电流
+  C7 int16_t C 相电流
+  C8 uint16_t 相电流最大值
+  C9 uint16_t 相电流最小值
+  CA int16_t 估算速度,单位 RPM
+  CB uint16_t 估算反电动势
+  CC uint16_t 母线电压,单位 0.1V
+  CD uint16_t 母线电流,单位 0.01A
+  CE uint16_t 估算功率,单位 W
+  CF uint16_t NTC 电压,显示单位 V
+  D0 uint16_t 模拟输入电压,显示单位 V
+  D1 uint16_t 频率,显示单位 Hz
+  D2 uint16_t 占空比,显示单位 %
+  D3-DC uint16_t 用户状态字 1-10
+
+参数说明
+  电流采样最大值 = 基准电压 / 2 / 采样电阻 / 运放倍数
+  电压采样最大值 = 分压比 * 基准电压
+  电流基准 = 电流采样最大值 * 2
+  电压基准 = 电压采样最大值 / 1.732
+  SAMP_FREQ = 载波频率 * 1000
+  TPWM_VALUE = 1 / SAMP_FREQ
+  BASE_FREQ = 速度基准 / 60 * 极对数
+  MAX_OMEGA_RAD_SEC = 2 * 3.1415926 * BASE_FREQ

+ 7 - 0
sitemap.json

@@ -0,0 +1,7 @@
+{
+    "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
+    "rules": [{
+    "action": "allow",
+    "page": "*"
+    }]
+}

+ 1698 - 0
utils/ble-transport.js

@@ -0,0 +1,1698 @@
+const {
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  buildWriteSingleRegisterFrame,
+  formatHex,
+  getReadResponseByteLength,
+  MAX_MODBUS_DMA_BYTES,
+  hasValidCrc
+} = require('./modbus-rtu')
+const {
+  notifyPageToast
+} = require('./page-toast')
+
+const SCAN_TIMEOUT = 15000
+const CONNECT_TIMEOUT = 10000
+const DEFAULT_PACKET_SIZE = 20
+const RESPONSE_TIMEOUT = 1000
+const MAX_RESPONSE_BUFFER_BYTES = 128
+const MAX_LOG_COUNT = 100
+const TARGET_BLE_UUIDS = ['FFE0', 'FFE1']
+
+const MODBUS_EXCEPTION_MESSAGES = {
+  0x01: '非法功能',
+  0x02: '非法数据地址',
+  0x03: '非法数据值',
+  0x04: '从站设备故障',
+  0x05: '确认',
+  0x06: '从站设备忙',
+  0x08: '存储奇偶性错误',
+  0x0A: '网关路径不可用',
+  0x0B: '网关目标设备响应失败'
+}
+
+const MODBUS_COMMANDS = [
+  { key: 'readCoils', label: '01 读取线圈', functionCode: 0x01, inputMode: 'quantity' },
+  { key: 'readHolding', label: '03 读取保持寄存器', functionCode: 0x03, inputMode: 'quantity' },
+  { key: 'readInput', label: '04 读取输入寄存器', functionCode: 0x04, inputMode: 'quantity' },
+  { key: 'writeCoil', label: '05 写单线圈', functionCode: 0x05, inputMode: 'coil' },
+  { key: 'writeRegister', label: '06 写单寄存器', functionCode: 0x06, inputMode: 'single' },
+  { key: 'writeRegisters', label: '10 写多寄存器', functionCode: 0x10, inputMode: 'multiple' }
+]
+
+const bluetoothErrorMap = {
+  10000: '蓝牙模块未初始化,请重新扫描',
+  10001: '蓝牙不可用,请开启手机蓝牙',
+  10002: '未找到指定设备,请重新扫描',
+  10003: '连接失败,请靠近设备后重试',
+  10004: '未发现设备服务',
+  10005: '未发现设备特征值',
+  10006: '当前连接已断开',
+  10007: '当前特征值不支持此操作',
+  10008: '系统蓝牙异常,请稍后重试',
+  10009: '当前系统不支持 BLE',
+  10012: '蓝牙操作超时,请重试',
+  10013: '设备 ID 无效,请重新扫描'
+}
+
+const state = {
+  adapterAvailable: false,
+  adapterOpened: false,
+  characteristicText: '未选择',
+  connectedDevice: null,
+  connectedServiceCount: 0,
+  connectingDeviceId: '',
+  devices: [],
+  errorText: '',
+  isAwaitingResponse: false,
+  isConnecting: false,
+  isDiscovering: false,
+  isSending: false,
+  logScrollTarget: '',
+  logs: [],
+  commandIndex: 1,
+  commandValue: '0001',
+  commandValueLabel: '读取数量',
+  coilEnabled: true,
+  generatedHex: '',
+  rxCount: 0,
+  sendHex: '',
+  sendQueueLength: 0,
+  protocolCommands: MODBUS_COMMANDS,
+  protocolErrorText: '',
+  registerAddress: '0000',
+  showCoilValue: false,
+  showCommandValue: true,
+  systemTip: '',
+  txCount: 0,
+  slaveAddress: 'F0',
+  writeCharacteristicId: '',
+  writeServiceId: '',
+  writeType: ''
+}
+
+let initialized = false
+let scanTimer = null
+let pendingRequest = null
+let sendQueue = []
+let isProcessingSendQueue = false
+let sendQueueGeneration = 0
+let sendJobSequence = 0
+let deviceMap = {}
+let deviceSequence = 0
+let logSequence = 0
+const subscribers = []
+
+function setState(changedData) {
+  Object.assign(state, changedData)
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(getState())
+  })
+}
+
+function getState() {
+  return {
+    ...state,
+    devices: state.devices.slice(),
+    logs: state.logs.slice()
+  }
+}
+
+function getSlaveAddress() {
+  return parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
+}
+
+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 callWx(apiName, params = {}) {
+  return new Promise((resolve, reject) => {
+    const api = wx[apiName]
+
+    if (typeof api !== 'function') {
+      reject(new Error(`${apiName} 不可用`))
+      return
+    }
+
+    api({
+      ...params,
+      success: resolve,
+      fail: reject
+    })
+  })
+}
+
+function formatBluetoothError(error) {
+  if (!error) return '操作失败'
+
+  const message = bluetoothErrorMap[error.errCode]
+  if (message) return message
+
+  return error.errMsg || error.message || '蓝牙操作失败'
+}
+
+function normalizeDevice(device) {
+  const advertisServiceUUIDs = device.advertisServiceUUIDs || []
+  const displayName = String(device.name || device.localName || '').trim() || '未命名设备'
+  const isTargetAdvertised = hasTargetAdvertisedUuid({
+    advertisServiceUUIDs
+  })
+
+  return {
+    deviceId: device.deviceId,
+    name: device.name || '',
+    localName: device.localName || '',
+    RSSI: device.RSSI,
+    advertisServiceUUIDs,
+    displayName,
+    isTargetAdvertised,
+    signalText: typeof device.RSSI === 'number' ? `${device.RSSI} dBm` : '--',
+    serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
+    targetText: isTargetAdvertised ? '广播含目标 UUID' : '',
+    lastSeenAt: Date.now()
+  }
+}
+
+function normalizeUuid(value) {
+  return String(value || '').replace(/-/g, '').toUpperCase()
+}
+
+function isTargetUuid(value) {
+  const uuid = normalizeUuid(value)
+
+  return TARGET_BLE_UUIDS.some((target) => uuid.indexOf(target) >= 0)
+}
+
+function hasTargetAdvertisedUuid(device) {
+  return (device.advertisServiceUUIDs || []).some(isTargetUuid)
+}
+
+function mergeAdvertisedServiceUUIDs(left = [], right = []) {
+  const uuidMap = {}
+  const uuids = []
+
+  left.concat(right).forEach((uuid) => {
+    const key = normalizeUuid(uuid)
+    if (!key || uuidMap[key]) return
+
+    uuidMap[key] = true
+    uuids.push(uuid)
+  })
+
+  return uuids
+}
+
+function normalizeHex(value) {
+  return String(value || '')
+    .replace(/0x/gi, '')
+    .replace(/[\s,;:_-]/g, '')
+    .toUpperCase()
+}
+
+function validateHex(value) {
+  const trimmed = String(value || '').trim()
+  const withoutPrefix = trimmed.replace(/0x/gi, '')
+  const compact = normalizeHex(trimmed)
+
+  if (!compact) return '请输入要发送的十六进制数据'
+  if (/[^0-9a-fA-F\s,;:_-]/.test(withoutPrefix)) return '只支持十六进制字符'
+  if (compact.length % 2 !== 0) return '十六进制长度必须为偶数'
+
+  return ''
+}
+
+function parseHexNumber(value, label, maxValue) {
+  const text = String(value || '').trim().replace(/^0x/i, '')
+
+  if (!text || !/^[0-9a-fA-F]+$/.test(text)) {
+    throw new Error(`${label}请输入十六进制数值`)
+  }
+
+  const parsedValue = parseInt(text, 16)
+  if (parsedValue > maxValue) {
+    throw new Error(`${label}超出范围`)
+  }
+
+  return parsedValue
+}
+
+function parseRegisterValues(value) {
+  const text = String(value || '').trim()
+  if (!text) throw new Error('请输入寄存器写入值')
+
+  return text.split(/[\s,;]+/)
+    .filter(Boolean)
+    .map((item) => parseHexNumber(item, '写入值', 0xFFFF))
+}
+
+function getCommand(index) {
+  return MODBUS_COMMANDS[index] || MODBUS_COMMANDS[0]
+}
+
+function getDefaultCommandValue(command) {
+  if (command.inputMode === 'quantity') return '0001'
+  if (command.inputMode === 'coil') return 'ON'
+  if (command.inputMode === 'multiple') return '0000'
+
+  return '0000'
+}
+
+function generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled) {
+  const slave = parseHexNumber(slaveAddress, '从站地址', 0xFF)
+  const address = parseHexNumber(registerAddress, '协议寄存器', 0xFFFF)
+
+  if (command.inputMode === 'quantity') {
+    const quantity = parseHexNumber(commandValue, '读取数量', 0xFFFF)
+    return buildReadFrame(slave, command.functionCode, address, quantity)
+  }
+  if (command.inputMode === 'coil') {
+    return buildWriteSingleCoilFrame(slave, address, coilEnabled)
+  }
+  if (command.inputMode === 'single') {
+    return buildWriteSingleRegisterFrame(slave, address, parseHexNumber(commandValue, '写入值', 0xFFFF))
+  }
+
+  return buildWriteMultipleRegistersFrame(slave, address, parseRegisterValues(commandValue))
+}
+
+function createProtocolState(commandIndex, slaveAddress, registerAddress, commandValue, coilEnabled) {
+  const command = getCommand(commandIndex)
+  const commandValueLabel = command.inputMode === 'quantity' ? '读取数量' : '写入值'
+
+  try {
+    return {
+      commandValueLabel,
+      generatedHex: formatHex(generateModbusFrame(command, slaveAddress, registerAddress, commandValue, coilEnabled)),
+      protocolErrorText: '',
+      showCoilValue: command.inputMode === 'coil',
+      showCommandValue: command.inputMode !== 'coil'
+    }
+  } catch (error) {
+    return {
+      commandValueLabel,
+      generatedHex: '',
+      protocolErrorText: error.message,
+      showCoilValue: command.inputMode === 'coil',
+      showCommandValue: command.inputMode !== 'coil'
+    }
+  }
+}
+
+function hexToArrayBuffer(hexText) {
+  const hex = normalizeHex(hexText)
+  const buffer = new ArrayBuffer(hex.length / 2)
+  const view = new Uint8Array(buffer)
+
+  for (let index = 0; index < view.length; index += 1) {
+    view[index] = parseInt(hex.substr(index * 2, 2), 16)
+  }
+
+  return buffer
+}
+
+function arrayBufferToHex(buffer) {
+  if (!buffer) return ''
+
+  return Array.prototype.map.call(new Uint8Array(buffer), (item) => item.toString(16).padStart(2, '0')).join(' ').toUpperCase()
+}
+
+function 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
+
+  const slaveAddress = bytes[0]
+  const functionCode = bytes[1]
+
+  if (functionCode & 0x80) {
+    return {
+      exceptionCode: bytes[2],
+      functionCode,
+      isException: true,
+      slaveAddress,
+      sourceFunctionCode: functionCode & 0x7F
+    }
+  }
+
+  if (functionCode === 0x01) {
+    const byteCount = bytes[2]
+    const dataEnd = 3 + byteCount
+    if (bytes.length < dataEnd + 2) return null
+
+    return {
+      byteCount,
+      dataBytes: bytes.slice(3, dataEnd),
+      functionCode,
+      isException: false,
+      slaveAddress
+    }
+  }
+
+  if (functionCode === 0x03 || functionCode === 0x04) {
+    const byteCount = bytes[2]
+    const dataEnd = 3 + byteCount
+    if (bytes.length < dataEnd + 2) return null
+
+    return {
+      byteCount,
+      dataBytes: bytes.slice(3, dataEnd),
+      functionCode,
+      isException: false,
+      slaveAddress,
+      words: bytesToWords(bytes.slice(3, dataEnd))
+    }
+  }
+
+  if (functionCode === 0x05 || functionCode === 0x06 || functionCode === 0x10) {
+    return {
+      address: ((bytes[2] << 8) | bytes[3]) & 0xFFFF,
+      functionCode,
+      isException: false,
+      quantityOrValue: ((bytes[4] << 8) | bytes[5]) & 0xFFFF,
+      slaveAddress
+    }
+  }
+
+  return {
+    functionCode,
+    isException: false,
+    slaveAddress
+  }
+}
+
+function parseModbusRequest(bytes) {
+  if (!Array.isArray(bytes) || bytes.length < 6 || !hasValidCrc(bytes)) return null
+
+  const slaveAddress = bytes[0]
+  const functionCode = bytes[1]
+  const address = ((bytes[2] << 8) | bytes[3]) & 0xFFFF
+  let quantity = 1
+  let value
+
+  if (functionCode === 0x01 || functionCode === 0x03 || functionCode === 0x04 || functionCode === 0x10) {
+    quantity = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+  if (functionCode === 0x05 || functionCode === 0x06) {
+    value = ((bytes[4] << 8) | bytes[5]) & 0xFFFF
+  }
+
+  return {
+    address,
+    functionCode,
+    kind: 'raw-hex',
+    quantity,
+    value,
+    slaveAddress
+  }
+}
+
+function validateDmaFrameLength(bytes, expected) {
+  if (bytes.length > MAX_MODBUS_DMA_BYTES) {
+    return `发送帧长度 ${bytes.length} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
+  }
+
+  if (!expected) return ''
+
+  const responseLength = getReadResponseByteLength(expected.functionCode, expected.quantity)
+
+  if (responseLength > MAX_MODBUS_DMA_BYTES) {
+    return `预计返回帧长度 ${responseLength} 字节,超过下位机 DMA ${MAX_MODBUS_DMA_BYTES} 字节限制`
+  }
+
+  return ''
+}
+
+function formatTime(timestamp) {
+  const date = new Date(timestamp)
+  const pad = (value, length = 2) => String(value).padStart(length, '0')
+
+  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
+}
+
+function getCharacteristicRole(properties = {}) {
+  const canWrite = !!(properties.write || properties.writeNoResponse)
+  const canNotify = !!(properties.notify || properties.indicate)
+
+  if (canWrite && canNotify) return '收发'
+  if (canWrite) return '发送'
+  if (canNotify) return '接收'
+  if (properties.read) return '读取'
+  return '其他'
+}
+
+function buildCharacteristicText(serviceId, characteristicId) {
+  if (!serviceId || !characteristicId) return '未选择'
+
+  return `${serviceId.slice(0, 8)} / ${characteristicId.slice(0, 8)}`
+}
+
+function hasTargetCharacteristic(discovery) {
+  return (discovery.services || []).some((service) => (
+    isTargetUuid(service.uuid) || (service.characteristics || []).some((item) => isTargetUuid(item.uuid))
+  ))
+}
+
+function padHex(value, length = 4) {
+  return Number(value || 0).toString(16).toUpperCase().padStart(length, '0')
+}
+
+function getExceptionText(code) {
+  return MODBUS_EXCEPTION_MESSAGES[code] || '未知异常'
+}
+
+function addLog(direction, payload, note = '') {
+  logSequence += 1
+  const logItem = {
+    id: `log-${Date.now()}-${logSequence}`,
+    direction,
+    note,
+    payload,
+    time: formatTime(Date.now())
+  }
+  const nextLogs = state.logs.concat(logItem).slice(-MAX_LOG_COUNT)
+
+  setState({
+    logScrollTarget: logItem.id,
+    logs: nextLogs
+  })
+}
+
+function showCommandAlert(title, content) {
+  const message = content || title || '操作失败'
+
+  notifyPageToast(message, 'error')
+  setState({
+    errorText: message
+  })
+}
+
+function clearScanTimer() {
+  if (!scanTimer) return
+
+  clearTimeout(scanTimer)
+  scanTimer = null
+}
+
+async function stopScan() {
+  clearScanTimer()
+
+  try {
+    await callWx('stopBluetoothDevicesDiscovery')
+  } catch (error) {
+    if (error.errCode !== 10000) {
+      setState({
+        errorText: formatBluetoothError(error)
+      })
+    }
+  }
+
+  setState({
+    isDiscovering: false
+  })
+}
+
+function resetScanTimer() {
+  clearScanTimer()
+
+  scanTimer = setTimeout(() => {
+    stopScan()
+
+    if (!state.devices.length) {
+      setState({
+        systemTip: '安卓真机请确认系统定位已开启,并允许微信使用附近设备或位置信息。'
+      })
+    }
+  }, SCAN_TIMEOUT)
+}
+
+function mergeDevices(devices) {
+  if (!devices.length) return
+
+  devices.forEach((device) => {
+    if (!device.deviceId) return
+    const previousDevice = deviceMap[device.deviceId] || {}
+    const nextDevice = normalizeDevice(device)
+    const advertisServiceUUIDs = mergeAdvertisedServiceUUIDs(
+      previousDevice.advertisServiceUUIDs,
+      nextDevice.advertisServiceUUIDs
+    )
+    const isTargetAdvertised = !!previousDevice.isTargetAdvertised || hasTargetAdvertisedUuid({
+      advertisServiceUUIDs
+    })
+    const isTargetDevice = !!previousDevice.isTargetDevice
+    const seenIndex = previousDevice.seenIndex || (deviceSequence += 1)
+
+    deviceMap[device.deviceId] = {
+      ...previousDevice,
+      ...nextDevice,
+      advertisServiceUUIDs,
+      displayName: nextDevice.displayName === '未命名设备' && previousDevice.displayName
+        ? previousDevice.displayName
+        : nextDevice.displayName,
+      isTargetAdvertised,
+      isTargetDevice,
+      seenIndex,
+      serviceText: advertisServiceUUIDs.length ? advertisServiceUUIDs.join(', ') : '未广播服务',
+      targetText: isTargetDevice ? '已发现目标特征' : (isTargetAdvertised ? '广播含目标 UUID' : '')
+    }
+  })
+
+  refreshDeviceList()
+}
+
+function refreshDeviceList() {
+  const deviceList = Object.keys(deviceMap)
+    .map((deviceId) => deviceMap[deviceId])
+    .sort((left, right) => {
+      const leftIndex = Number(left.seenIndex) || 0
+      const rightIndex = Number(right.seenIndex) || 0
+
+      return leftIndex - rightIndex
+    })
+
+  setState({
+    devices: deviceList.slice(0, 30)
+  })
+}
+
+function clearPendingRequest() {
+  if (!pendingRequest) return null
+
+  const pending = pendingRequest
+  clearTimeout(pendingRequest.timer)
+  pendingRequest = null
+  setState({
+    isAwaitingResponse: false
+  })
+
+  return pending
+}
+
+function cancelPendingRequest() {
+  const pending = clearPendingRequest()
+
+  if (pending) {
+    pending.resolve(false)
+  }
+}
+
+function clearSendQueue() {
+  if (!sendQueue.length) return
+
+  const queuedJobs = sendQueue.splice(0)
+  queuedJobs.forEach((job) => {
+    job.resolve(false)
+  })
+  setState({
+    sendQueueLength: 0
+  })
+}
+
+function resetSendRuntimeState() {
+  sendQueueGeneration += 1
+  cancelPendingRequest()
+  clearSendQueue()
+  isProcessingSendQueue = false
+  setState({
+    isAwaitingResponse: false,
+    isSending: false,
+    sendQueueLength: 0
+  })
+}
+
+function clearConnectedState(changedData = {}) {
+  resetSendRuntimeState()
+  setState({
+    characteristicText: '未选择',
+    connectedDevice: null,
+    connectedServiceCount: 0,
+    connectingDeviceId: '',
+    isConnecting: false,
+    writeCharacteristicId: '',
+    writeServiceId: '',
+    writeType: '',
+    ...changedData
+  })
+}
+
+function isConnectionLostError(error) {
+  if (!error) return false
+  if ([10000, 10001, 10006, 10013].includes(error.errCode)) return true
+
+  const message = String(error.errMsg || error.message || '').toLowerCase()
+  return message.includes('disconnect') || message.includes('not connected')
+}
+
+function isExpectedResponse(response, expected) {
+  if (response.functionCode === 0x01) {
+    return Array.isArray(response.dataBytes) && response.dataBytes.length >= Math.ceil(expected.quantity / 8)
+  }
+
+  if (response.functionCode === 0x03 || response.functionCode === 0x04) {
+    return Array.isArray(response.words) && response.words.length >= expected.quantity
+  }
+
+  if (response.functionCode === 0x10) {
+    return response.address === expected.address && response.quantityOrValue === expected.quantity
+  }
+
+  if (response.functionCode === 0x05 || response.functionCode === 0x06) {
+    if (response.address !== expected.address) return false
+    if (Number.isInteger(expected.value)) return response.quantityOrValue === expected.value
+
+    return true
+  }
+
+  return true
+}
+
+function getExpectedResponseLength(expected, responseFunctionCode, responseBytes) {
+  if (!expected) return 0
+
+  if (responseFunctionCode === (expected.functionCode | 0x80)) {
+    return 5
+  }
+
+  if (responseFunctionCode === 0x01) {
+    if (responseBytes.length < 3) return 0
+
+    return 3 + Number(responseBytes[2] || 0) + 2
+  }
+
+  if (responseFunctionCode === 0x03 || responseFunctionCode === 0x04) {
+    if (responseBytes.length < 3) return 0
+
+    return 3 + Number(responseBytes[2] || 0) + 2
+  }
+
+  if (responseFunctionCode === 0x05 || responseFunctionCode === 0x06 || responseFunctionCode === 0x10) {
+    return 8
+  }
+
+  return 0
+}
+
+function alignResponseBuffer(buffer, expected) {
+  if (!Array.isArray(buffer) || !buffer.length || !expected) return
+
+  const expectedFunctionCodes = [expected.functionCode, expected.functionCode | 0x80]
+  let matchIndex = -1
+
+  for (let index = 0; index < buffer.length - 1; index += 1) {
+    if (buffer[index] !== expected.slaveAddress) continue
+    if (!expectedFunctionCodes.includes(buffer[index + 1])) continue
+
+    matchIndex = index
+    break
+  }
+
+  if (matchIndex > 0) {
+    buffer.splice(0, matchIndex)
+  } else if (matchIndex < 0 && buffer.length > 2) {
+    buffer.splice(0, buffer.length - 1)
+  }
+}
+
+function finishPendingRequest(resolveValue) {
+  const pending = clearPendingRequest()
+
+  if (pending) {
+    pending.resolve(resolveValue)
+  }
+}
+
+function consumePendingResponseBuffer() {
+  const pending = pendingRequest
+  if (!pending || !Array.isArray(pending.responseBuffer)) return
+
+  const buffer = pending.responseBuffer
+  alignResponseBuffer(buffer, pending.expected)
+
+  if (buffer.length < 2) return
+
+  const responseFunctionCode = buffer[1]
+  const responseLength = getExpectedResponseLength(pending.expected, responseFunctionCode, buffer)
+
+  if (!responseLength) return
+
+  if (responseLength > MAX_MODBUS_DMA_BYTES) {
+    const content = `${pending.label} 返回帧长度 ${responseLength} 字节,超过 DMA 限制,已丢弃`
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
+    return
+  }
+
+  if (buffer.length < responseLength) return
+
+  const frameBytes = buffer.slice(0, responseLength)
+  const response = parseModbusResponse(frameBytes)
+  if (!response) {
+    const content = `${pending.label} 收到无效响应帧,已丢弃`
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
+    return
+  }
+
+  const responseCode = response.isException ? response.sourceFunctionCode : response.functionCode
+  if (response.slaveAddress !== pending.expected.slaveAddress || responseCode !== pending.expected.functionCode) {
+    buffer.shift()
+    consumePendingResponseBuffer()
+    return
+  }
+
+  if (response.isException) {
+    const exceptionText = getExceptionText(response.exceptionCode)
+    const content = `设备返回异常帧:功能码 0x${padHex(response.sourceFunctionCode, 2)},异常码 0x${padHex(response.exceptionCode, 2)}(${exceptionText})`
+
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('设备返回故障帧', content)
+    }
+    return
+  }
+
+  if (!isExpectedResponse(response, pending.expected)) {
+    const content = `${pending.label} 收到不匹配响应,已丢弃`
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
+    return
+  }
+
+  buffer.splice(0, responseLength)
+  finishPendingRequest(response)
+
+  if (buffer.length) {
+    consumePendingResponseBuffer()
+  }
+}
+
+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 pending = pendingRequest
+    const content = `${pending.label} 返回数据超过缓冲区,已丢弃`
+
+    addLog('SYS', content)
+    finishPendingRequest(false)
+    if (pending.showModal) {
+      showCommandAlert('通讯异常', content)
+    }
+    return
+  }
+
+  consumePendingResponseBuffer()
+}
+
+function createPendingRequest(label, expected, options = {}) {
+  return new Promise((resolve) => {
+    const timer = setTimeout(() => {
+      const pending = clearPendingRequest()
+      if (!pending) return
+
+      addLog('SYS', `${label} 超时`)
+      if (options.showModal !== false) {
+        showCommandAlert('通讯超时', `${label} 1秒内没有收到回复`)
+      }
+      resolve(false)
+    }, options.timeout || RESPONSE_TIMEOUT)
+
+    pendingRequest = {
+      expected,
+      label,
+      resolve,
+      timer,
+      showModal: options.showModal !== false,
+      responseBuffer: []
+    }
+    setState({
+      isAwaitingResponse: true
+    })
+  })
+}
+
+function init() {
+  if (initialized) return
+
+  wx.onBluetoothDeviceFound((res) => {
+    mergeDevices(res.devices || [])
+  })
+
+  wx.onBluetoothAdapterStateChange((res) => {
+    setState({
+      adapterAvailable: !!res.available,
+      isDiscovering: !!res.discovering
+    })
+
+    if (!res.available) {
+      clearScanTimer()
+      clearConnectedState({
+        adapterAvailable: false,
+        adapterOpened: false,
+        errorText: '请开启手机蓝牙后重新扫描',
+        isDiscovering: false,
+        sendQueueLength: 0
+      })
+    }
+  })
+
+  wx.onBLEConnectionStateChange((res) => {
+    const { connectedDevice } = state
+    if (!connectedDevice || connectedDevice.deviceId !== res.deviceId) return
+
+    if (!res.connected) {
+      addLog('SYS', '连接已断开')
+      clearConnectedState({
+        errorText: '',
+        sendQueueLength: 0
+      })
+    }
+  })
+
+  wx.onBLECharacteristicValueChange((res) => {
+    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'))
+      : ''
+
+    setState({
+      rxCount: state.rxCount + byteLength
+    })
+    addLog('RX', hex, crcState)
+    handleModbusResponse(rawBytes)
+  })
+
+  initialized = true
+}
+
+async function getAuthSetting() {
+  return callWx('getSetting')
+    .then((res) => res.authSetting || {})
+    .catch(() => ({}))
+}
+
+function showPermissionModal(title, content) {
+  return new Promise((resolve, reject) => {
+    wx.showModal({
+      title,
+      content,
+      confirmText: '去设置',
+      success: async (res) => {
+        if (!res.confirm) {
+          reject(new Error('用户取消授权'))
+          return
+        }
+
+        try {
+          await callWx('openSetting')
+          resolve()
+        } catch (error) {
+          reject(error)
+        }
+      },
+      fail: reject
+    })
+  })
+}
+
+async function ensureBluetoothAuthorized() {
+  const authSetting = await getAuthSetting()
+
+  if (authSetting['scope.bluetooth']) return
+
+  if (authSetting['scope.bluetooth'] === false) {
+    await showPermissionModal('需要蓝牙权限', '请在设置中允许使用蓝牙,用于扫描并连接附近设备。')
+    return
+  }
+
+  try {
+    await callWx('authorize', {
+      scope: 'scope.bluetooth'
+    })
+  } catch (error) {
+    await showPermissionModal('需要蓝牙权限', '请在设置中允许使用蓝牙,用于扫描并连接附近设备。')
+  }
+}
+
+async function ensureAndroidLocationAuthorized() {
+  const systemInfo = wx.getSystemInfoSync ? wx.getSystemInfoSync() : wx.getDeviceInfo()
+  if (systemInfo.platform !== 'android') return
+
+  const authSetting = await getAuthSetting()
+  if (authSetting['scope.userLocation']) return
+
+  setState({
+    systemTip: '安卓系统扫描 BLE 设备通常需要开启系统定位权限。'
+  })
+
+  if (authSetting['scope.userLocation'] === false) {
+    await showPermissionModal('需要定位权限', '安卓系统要求定位权限开启后才能搜索附近 BLE 设备。')
+    return
+  }
+
+  try {
+    await callWx('authorize', {
+      scope: 'scope.userLocation'
+    })
+  } catch (error) {
+    await showPermissionModal('需要定位权限', '安卓系统要求定位权限开启后才能搜索附近 BLE 设备。')
+  }
+}
+
+async function openAdapter() {
+  if (state.adapterOpened) {
+    try {
+      const adapterState = await callWx('getBluetoothAdapterState')
+
+      setState({
+        adapterAvailable: !!adapterState.available,
+        isDiscovering: !!adapterState.discovering
+      })
+
+      if (adapterState.available) return
+    } catch (error) {
+      setState({
+        adapterAvailable: false,
+        adapterOpened: false
+      })
+    }
+  }
+
+  try {
+    await callWx('openBluetoothAdapter', {
+      mode: 'central'
+    })
+
+    const adapterState = await callWx('getBluetoothAdapterState')
+
+    setState({
+      adapterAvailable: !!adapterState.available,
+      adapterOpened: true,
+      isDiscovering: !!adapterState.discovering
+    })
+
+    if (!adapterState.available) {
+      throw {
+        errCode: 10001,
+        errMsg: 'bluetooth adapter not available'
+      }
+    }
+  } catch (error) {
+    if (error.errCode === 10001) {
+      setState({
+        adapterOpened: true,
+        adapterAvailable: false
+      })
+    }
+
+    throw error
+  }
+}
+
+async function startDiscovery() {
+  try {
+    await callWx('startBluetoothDevicesDiscovery', {
+      allowDuplicatesKey: true,
+      interval: 600,
+      powerLevel: 'high'
+    })
+  } catch (error) {
+    await callWx('startBluetoothDevicesDiscovery', {
+      allowDuplicatesKey: true,
+      interval: 600
+    })
+  }
+}
+
+async function startScan() {
+  if (state.isConnecting) return
+
+  deviceMap = {}
+  deviceSequence = 0
+  setState({
+    devices: [],
+    errorText: ''
+  })
+
+  try {
+    init()
+    await ensureBluetoothAuthorized()
+    await ensureAndroidLocationAuthorized()
+    await openAdapter()
+    await startDiscovery()
+
+    setState({
+      isDiscovering: true
+    })
+
+    resetScanTimer()
+    addLog('SYS', '开始扫描 BLE 设备')
+  } catch (error) {
+    clearScanTimer()
+    setState({
+      isDiscovering: false,
+      errorText: formatBluetoothError(error)
+    })
+  }
+}
+
+function clearDevices() {
+  deviceMap = {}
+  deviceSequence = 0
+  setState({
+    devices: [],
+    errorText: ''
+  })
+}
+
+async function closeConnectedDevice(nextDeviceId, options = {}) {
+  const { connectedDevice } = state
+
+  if (!connectedDevice) {
+    resetSendRuntimeState()
+    return
+  }
+  if (connectedDevice.deviceId === nextDeviceId && !options.force) return
+
+  resetSendRuntimeState()
+
+  try {
+    await callWx('closeBLEConnection', {
+      deviceId: connectedDevice.deviceId
+    })
+  } catch (error) {
+    if (error.errCode !== 10006) throw error
+  }
+
+  clearConnectedState()
+}
+
+async function discoverCharacteristics(deviceId) {
+  const serviceResult = await callWx('getBLEDeviceServices', {
+    deviceId
+  })
+  const services = []
+  let writeServiceId = ''
+  let writeCharacteristicId = ''
+  let writeType = ''
+  let notifyServiceId = ''
+  let notifyCharacteristicId = ''
+
+  for (const service of serviceResult.services || []) {
+    const characteristicResult = await callWx('getBLEDeviceCharacteristics', {
+      deviceId,
+      serviceId: service.uuid
+    })
+    const characteristics = (characteristicResult.characteristics || []).map((item) => ({
+      uuid: item.uuid,
+      role: getCharacteristicRole(item.properties),
+      properties: item.properties || {}
+    }))
+
+    services.push({
+      uuid: service.uuid,
+      primary: service.isPrimary,
+      characteristics
+    })
+
+    characteristics.forEach((item) => {
+      const isPreferredService = isTargetUuid(service.uuid)
+      const isPreferredCharacteristic = isTargetUuid(item.uuid)
+      const canWrite = item.properties.write || item.properties.writeNoResponse
+      const canNotify = item.properties.notify || item.properties.indicate
+
+      if (isPreferredService && isPreferredCharacteristic && canWrite) {
+        writeServiceId = service.uuid
+        writeCharacteristicId = item.uuid
+        writeType = item.properties.write ? 'write' : 'writeNoResponse'
+      }
+
+      if (isPreferredService && isPreferredCharacteristic && canNotify) {
+        notifyServiceId = service.uuid
+        notifyCharacteristicId = item.uuid
+      }
+
+      if (!writeCharacteristicId && canWrite) {
+        writeServiceId = service.uuid
+        writeCharacteristicId = item.uuid
+        writeType = item.properties.write ? 'write' : 'writeNoResponse'
+      }
+
+      if (!notifyCharacteristicId && canNotify) {
+        notifyServiceId = service.uuid
+        notifyCharacteristicId = item.uuid
+      }
+    })
+  }
+
+  return {
+    services,
+    writeServiceId,
+    writeCharacteristicId,
+    writeType,
+    notifyServiceId,
+    notifyCharacteristicId
+  }
+}
+
+async function enableNotify(deviceId, serviceId, characteristicId) {
+  try {
+    await callWx('notifyBLECharacteristicValueChange', {
+      deviceId,
+      serviceId,
+      characteristicId,
+      state: true
+    })
+    addLog('SYS', `已开启通知 ${characteristicId}`)
+    return true
+  } catch (error) {
+    addLog('SYS', `开启通知失败:${formatBluetoothError(error)}`)
+    if (isConnectionLostError(error)) {
+      throw error
+    }
+    return false
+  }
+}
+
+async function connectDeviceById(deviceId) {
+  const device = deviceMap[deviceId]
+
+  if (!device || state.isConnecting) return
+
+  resetSendRuntimeState()
+  setState({
+    connectingDeviceId: deviceId,
+    errorText: '',
+    isConnecting: true
+  })
+
+  try {
+    await stopScan()
+    await closeConnectedDevice(deviceId, {
+      force: state.connectedDevice && state.connectedDevice.deviceId === deviceId
+    })
+    await openAdapter()
+    await callWx('createBLEConnection', {
+      deviceId,
+      timeout: CONNECT_TIMEOUT
+    })
+
+    const discovery = await discoverCharacteristics(deviceId)
+    const notifyEnabled = discovery.notifyServiceId && discovery.notifyCharacteristicId
+      ? await enableNotify(deviceId, discovery.notifyServiceId, discovery.notifyCharacteristicId)
+      : false
+    const isTargetDevice = hasTargetCharacteristic(discovery)
+    const connectedDevice = {
+      ...device,
+      isTargetDevice,
+      targetText: isTargetDevice ? '已发现目标特征' : device.targetText
+    }
+    deviceMap[deviceId] = connectedDevice
+    refreshDeviceList()
+
+    setState({
+      characteristicText: buildCharacteristicText(discovery.writeServiceId, discovery.writeCharacteristicId),
+      connectedDevice,
+      connectedServiceCount: discovery.services.length,
+      connectingDeviceId: '',
+      errorText: discovery.writeServiceId
+        ? (notifyEnabled ? '' : '已连接,但未成功开启通知,可能收不到设备回复')
+        : '已连接,但未找到可写特征值',
+      isConnecting: false,
+      writeCharacteristicId: discovery.writeCharacteristicId,
+      writeServiceId: discovery.writeServiceId,
+      writeType: discovery.writeType
+    })
+
+    addLog('SYS', `已连接 ${device.displayName}`)
+  } catch (error) {
+    resetSendRuntimeState()
+    setState({
+      connectingDeviceId: '',
+      isConnecting: false,
+      errorText: formatBluetoothError(error)
+    })
+  }
+}
+
+async function disconnectDevice() {
+  const { connectedDevice } = state
+  if (!connectedDevice) return
+
+  try {
+    await callWx('closeBLEConnection', {
+      deviceId: connectedDevice.deviceId
+    })
+  } catch (error) {
+    if (error.errCode !== 10006) {
+      setState({
+        errorText: formatBluetoothError(error)
+      })
+      return
+    }
+  }
+
+  addLog('SYS', '主动断开连接')
+  clearConnectedState({
+    errorText: '',
+    sendQueueLength: 0
+  })
+}
+
+async function refreshNativeConnectionState() {
+  if (!state.connectedDevice || typeof wx.getConnectedBluetoothDevices !== 'function') return true
+
+  try {
+    const services = state.writeServiceId ? [state.writeServiceId] : []
+    const result = await callWx('getConnectedBluetoothDevices', {
+      services
+    })
+    const isConnected = (result.devices || []).some((device) => device.deviceId === state.connectedDevice.deviceId)
+
+    if (isConnected) return true
+
+    addLog('SYS', '蓝牙连接状态已失效')
+    clearConnectedState({
+      errorText: '蓝牙连接已失效,请重新连接'
+    })
+    return false
+  } catch (error) {
+    if (isConnectionLostError(error)) {
+      clearConnectedState({
+        errorText: formatBluetoothError(error)
+      })
+      return false
+    }
+
+    return true
+  }
+}
+
+function handleAppHide() {
+  clearScanTimer()
+  resetSendRuntimeState()
+  if (state.isDiscovering) {
+    stopScan()
+  }
+}
+
+function handleAppShow() {
+  init()
+  refreshNativeConnectionState()
+}
+
+async function openSetting() {
+  try {
+    await callWx('openSetting')
+    setState({
+      errorText: ''
+    })
+  } catch (error) {
+    setState({
+      errorText: formatBluetoothError(error)
+    })
+  }
+}
+
+function setSendHex(sendHex) {
+  setState({
+    sendHex,
+    errorText: ''
+  })
+}
+
+function setCommandIndex(value) {
+  const commandIndex = Number(value)
+  const command = getCommand(commandIndex)
+  const commandValue = getDefaultCommandValue(command)
+  const nextState = {
+    commandIndex,
+    commandValue,
+    coilEnabled: true
+  }
+
+  setState({
+    ...nextState,
+    ...createProtocolState(
+      nextState.commandIndex,
+      state.slaveAddress,
+      state.registerAddress,
+      nextState.commandValue,
+      nextState.coilEnabled
+    )
+  })
+}
+
+function setProtocolInput(changedData) {
+  const nextState = {
+    commandIndex: state.commandIndex,
+    slaveAddress: state.slaveAddress,
+    registerAddress: state.registerAddress,
+    commandValue: state.commandValue,
+    coilEnabled: state.coilEnabled,
+    ...changedData
+  }
+
+  setState({
+    ...changedData,
+    ...createProtocolState(
+      nextState.commandIndex,
+      nextState.slaveAddress,
+      nextState.registerAddress,
+      nextState.commandValue,
+      nextState.coilEnabled
+    )
+  })
+}
+
+function buildGeneratedExpectedResponse() {
+  try {
+    const command = getCommand(state.commandIndex)
+    const address = parseHexNumber(state.registerAddress, '协议寄存器', 0xFFFF)
+    const slaveAddress = parseHexNumber(state.slaveAddress, '从站地址', 0xFF)
+    const quantity = command.inputMode === 'quantity'
+      ? parseHexNumber(state.commandValue, '读取数量', 0xFFFF)
+      : (command.inputMode === 'multiple' ? parseRegisterValues(state.commandValue).length : 1)
+    const value = command.inputMode === 'coil'
+      ? (state.coilEnabled ? 0xFF00 : 0x0000)
+      : (command.inputMode === 'single' ? parseHexNumber(state.commandValue, '写入值', 0xFFFF) : undefined)
+
+    return {
+      address,
+      functionCode: command.functionCode,
+      kind: 'manual-rtu',
+      quantity,
+      value,
+      slaveAddress
+    }
+  } catch (error) {
+    return null
+  }
+}
+
+function clearInput() {
+  setState({
+    sendHex: '',
+    errorText: ''
+  })
+}
+
+function clearLogs() {
+  setState({
+    logScrollTarget: '',
+    logs: [],
+    rxCount: 0,
+    txCount: 0
+  })
+}
+
+function enqueueSendFrame(hexFrame, source, options = {}) {
+  if (!state.connectedDevice) {
+    setState({
+      errorText: '请先连接蓝牙透传设备'
+    })
+    return Promise.resolve(false)
+  }
+
+  if (!state.writeServiceId || !state.writeCharacteristicId) {
+    setState({
+      errorText: '当前设备没有可写特征值'
+    })
+    return Promise.resolve(false)
+  }
+
+  const errorText = validateHex(hexFrame)
+
+  if (errorText) {
+    setState({
+      errorText
+    })
+    return Promise.resolve(false)
+  }
+
+  const buffer = hexToArrayBuffer(hexFrame)
+  const bytes = new Uint8Array(buffer)
+  const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
+
+  if (dmaFrameLengthError) {
+    setState({
+      errorText: dmaFrameLengthError
+    })
+    return Promise.resolve(false)
+  }
+
+  return new Promise((resolve) => {
+    sendJobSequence += 1
+    sendQueue.push({
+      id: sendJobSequence,
+      hexFrame,
+      options,
+      resolve,
+      source
+    })
+
+    setState({
+      sendQueueLength: sendQueue.length
+    })
+
+    processSendQueue()
+  })
+}
+
+async function processSendQueue() {
+  if (isProcessingSendQueue) return
+
+  const generation = sendQueueGeneration
+  isProcessingSendQueue = true
+
+  try {
+    while (sendQueue.length && generation === sendQueueGeneration) {
+      const job = sendQueue.shift()
+
+      setState({
+        sendQueueLength: sendQueue.length
+      })
+
+      let result = false
+      try {
+        result = await executeSendFrame(job.hexFrame, job.source, job.options)
+      } catch (error) {
+        cancelPendingRequest()
+        setState({
+          errorText: error.message || '发送失败'
+        })
+      }
+      job.resolve(result)
+
+      if (!state.connectedDevice) {
+        clearSendQueue()
+        break
+      }
+    }
+  } finally {
+    if (generation === sendQueueGeneration) {
+      isProcessingSendQueue = false
+    }
+  }
+}
+
+async function executeSendFrame(hexFrame, source, options = {}) {
+  const {
+    connectedDevice,
+    writeCharacteristicId,
+    writeServiceId,
+    writeType
+  } = state
+  const errorText = validateHex(hexFrame)
+
+  if (!connectedDevice) {
+    setState({
+      errorText: '请先连接蓝牙透传设备'
+    })
+    return false
+  }
+
+  if (!writeServiceId || !writeCharacteristicId) {
+    setState({
+      errorText: '当前设备没有可写特征值'
+    })
+    return false
+  }
+
+  if (errorText) {
+    setState({
+      errorText
+    })
+    return false
+  }
+
+  const buffer = hexToArrayBuffer(hexFrame)
+  const bytes = new Uint8Array(buffer)
+  const dmaFrameLengthError = validateDmaFrameLength(bytes, options.expected)
+
+  if (dmaFrameLengthError) {
+    setState({
+      errorText: dmaFrameLengthError
+    })
+    return false
+  }
+
+  const chunkSize = DEFAULT_PACKET_SIZE
+  const waitResponse = !!options.expected
+  const responsePromise = waitResponse
+    ? createPendingRequest(source, options.expected, options)
+    : null
+
+  setState({
+    isSending: true,
+    errorText: ''
+  })
+
+  try {
+    for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+      const chunk = bytes.slice(offset, offset + chunkSize)
+
+      await callWx('writeBLECharacteristicValue', {
+        deviceId: connectedDevice.deviceId,
+        serviceId: writeServiceId,
+        characteristicId: writeCharacteristicId,
+        value: chunk.buffer,
+        writeType
+      })
+    }
+
+    setState({
+      txCount: state.txCount + bytes.length
+    })
+    addLog('TX', arrayBufferToHex(buffer), source)
+
+    if (waitResponse) {
+      return responsePromise
+    }
+
+    return true
+  } catch (error) {
+    if (waitResponse) {
+      cancelPendingRequest()
+    }
+    if (isConnectionLostError(error)) {
+      clearConnectedState({
+        errorText: formatBluetoothError(error)
+      })
+    } else {
+      setState({
+        errorText: formatBluetoothError(error)
+      })
+    }
+    return false
+  } finally {
+    setState({
+      isSending: false
+    })
+  }
+}
+
+function sendManagedFrame(frameBytes, label, expected, options = {}) {
+  return enqueueSendFrame(formatHex(frameBytes), label, {
+    expected,
+    showModal: options.showModal !== false,
+    timeout: options.timeout || RESPONSE_TIMEOUT
+  })
+}
+
+function sendFrame(hexFrame, source, options = {}) {
+  return enqueueSendFrame(hexFrame, source, options)
+}
+
+function sendHexFrame() {
+  const errorText = validateHex(state.sendHex)
+  const expected = errorText ? null : parseModbusRequest(Array.prototype.slice.call(new Uint8Array(hexToArrayBuffer(state.sendHex))))
+
+  return enqueueSendFrame(state.sendHex, 'HEX', expected ? {
+    expected
+  } : {})
+}
+
+function sendGeneratedFrame() {
+  if (!state.generatedHex) return false
+
+  const expected = buildGeneratedExpectedResponse()
+
+  return enqueueSendFrame(state.generatedHex, 'RTU', expected ? {
+    expected
+  } : {})
+}
+
+setState(createProtocolState(
+  state.commandIndex,
+  state.slaveAddress,
+  state.registerAddress,
+  state.commandValue,
+  state.coilEnabled
+))
+
+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,
+  setCommandIndex,
+  setProtocolInput,
+  setSendHex,
+  showCommandAlert,
+  startScan,
+  stopScan,
+  subscribe,
+  validateHex
+}

+ 127 - 0
utils/calculation-context.js

@@ -0,0 +1,127 @@
+const SCALE_MAX = 32767
+const ATT_COEF = 0.85
+const TWO_PI = 2 * 3.1415926
+
+const DEFAULT_DRIVER_PARAMS = {
+  carrierFrequencyKHz: 16,
+  baseVoltage: 5.0,
+  opAmpGain: 4,
+  samplingResistorMohm: 100,
+  busVoltageDividerRatio: 996.8 / 6.8,
+  analogInputDividerRatio: 200 / 268
+}
+
+let currentDriverParams = {
+  ...DEFAULT_DRIVER_PARAMS
+}
+
+const sharedInputValues = {}
+
+const FAULT_CODE_MAP = {
+  0x01: '硬件过流',
+  0x02: '软件过流',
+  0x03: '风扇过流',
+  0x04: '电流偏置校准失败',
+  0x05: '缺相',
+  0x06: '驱动器上桥短路',
+  0x07: '驱动器下桥短路',
+  0x08: '相间短路',
+  0x09: '启动堵转',
+  0x0A: '运行堵转',
+  0x0B: '过功率',
+  0x0C: '过压',
+  0x0D: '欠压',
+  0x0E: '芯片欠压',
+  0x0F: 'NTC过温',
+  0x10: '电机过温',
+  0x11: 'IPM过温',
+  0x12: '芯片过温',
+  0x13: '串口丢失',
+  0x14: 'PWM丢失'
+}
+
+function toFiniteNumber(value, fallback = 0) {
+  if (typeof value === 'string') {
+    const text = value.trim()
+    const directValue = Number(text)
+    if (Number.isFinite(directValue)) return directValue
+
+    const match = text.match(/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i)
+    const textValue = match ? Number(match[0]) : NaN
+
+    return Number.isFinite(textValue) ? textValue : fallback
+  }
+
+  const numberValue = Number(value)
+
+  return Number.isFinite(numberValue) ? numberValue : fallback
+}
+
+function getSharedInputValues() {
+  return {
+    ...sharedInputValues
+  }
+}
+
+function getDriverParams() {
+  return {
+    ...currentDriverParams
+  }
+}
+
+function updateDriverParams(params = {}) {
+  currentDriverParams = {
+    ...currentDriverParams,
+    ...Object.keys(params).reduce((result, key) => {
+      const value = toFiniteNumber(params[key], NaN)
+      if (Number.isFinite(value) && value > 0) {
+        result[key] = value
+      }
+      return result
+    }, {})
+  }
+}
+
+function getSharedInputDefault() {
+  return 0
+}
+
+function setSharedInputValue(name, value, fallback = getSharedInputDefault(name)) {
+  sharedInputValues[name] = toFiniteNumber(value, fallback)
+}
+
+function setSharedInputValues(registers) {
+  registers.forEach((item) => {
+    setSharedInputValue(item.name, item.inputValue, getSharedInputDefault(item.name))
+  })
+}
+
+function mergeInputValues(registers = []) {
+  return registers.reduce((result, item) => {
+    const fallback = Object.prototype.hasOwnProperty.call(result, item.name) ? result[item.name] : getSharedInputDefault(item.name)
+    result[item.name] = toFiniteNumber(item.inputValue, fallback)
+    return result
+  }, getSharedInputValues())
+}
+
+function getFaultText(code) {
+  const numberValue = Number(code)
+  if (!Number.isFinite(numberValue) || numberValue === 0) return '无故障'
+
+  return FAULT_CODE_MAP[numberValue] || '未知故障'
+}
+
+module.exports = {
+  ATT_COEF,
+  DEFAULT_DRIVER_PARAMS,
+  SCALE_MAX,
+  TWO_PI,
+  getFaultText,
+  getDriverParams,
+  getSharedInputValues,
+  getSharedInputDefault,
+  mergeInputValues,
+  setSharedInputValues,
+  updateDriverParams,
+  toFiniteNumber
+}

+ 455 - 0
utils/control-page-state.js

@@ -0,0 +1,455 @@
+const {
+  controlButtonRegisters,
+  motorParameterInputRegisters,
+  readonlyParamRegisters,
+  speedCommandRegister,
+  statusRegisters
+} = require('./registers')
+const {
+  getSharedInputDefault,
+  mergeInputValues,
+  setSharedInputValues,
+  updateDriverParams,
+  toFiniteNumber
+} = require('./calculation-context')
+const {
+  calculateParameterInputWriteValue,
+  calculateSpeedCommandWriteValue,
+  SCALE_MAX,
+  formatFixedValue
+} = require('./conversions')
+const {
+  updateStatusRegisterWords
+} = require('./status-format')
+const {
+  floatToWords,
+  getRegisterWordCache,
+  toAddressKey,
+  wordsToFloat
+} = require('./register-value-utils')
+const {
+  appendInputUnit
+} = require('./input-value-utils')
+
+const AUTO_READ_MIN_INTERVAL = 100
+const AUTO_READ_MAX_INTERVAL = 3000
+const DEFAULT_AUTO_READ_INTERVAL = 1000
+const MOTOR_PARAM_START_ADDRESS = 0x60
+const MOTOR_PARAM_WORD_COUNT = 8
+const DRIVER_PARAM_START_ADDRESS = 0xA0
+const STATUS_START_ADDRESS = 0xC0
+
+function getRegisterSpanWordCount(registers, startAddress) {
+  const endAddress = registers.reduce((maxAddress, item) => {
+    const address = parseInt(item.address, 16)
+    if (!Number.isFinite(address)) return maxAddress
+
+    return Math.max(maxAddress, address + (item.registerCount || 1))
+  }, startAddress)
+
+  return endAddress - startAddress
+}
+
+const DRIVER_PARAM_WORD_COUNT = getRegisterSpanWordCount(readonlyParamRegisters, DRIVER_PARAM_START_ADDRESS)
+const STATUS_WORD_COUNT = getRegisterSpanWordCount(statusRegisters, STATUS_START_ADDRESS)
+
+function getInputValues(registers) {
+  return registers.reduce((result, item) => {
+    result[item.name] = toFiniteNumber(item.inputValue, getSharedInputDefault(item.name))
+    return result
+  }, {})
+}
+
+function updateMotorWriteValues(registers) {
+  const inputValues = getInputValues(registers)
+
+  return registers.map((item) => ({
+    ...item,
+    writeValue: calculateParameterInputWriteValue(item, item.inputValue, inputValues)
+  }))
+}
+
+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)
+  if (item.name === 'RS') return formatFixedValue(value, 4)
+  if (item.type === 'float') return formatFixedValue(value, 2)
+
+  return String(Math.round(value))
+}
+
+function formatHexWord(value) {
+  return `0x${(Number(value) & 0xFFFF).toString(16).toUpperCase().padStart(4, '0')}`
+}
+
+function wordsToAscii(words, startIndex, byteLength) {
+  const chars = []
+  const wordCount = Math.ceil(byteLength / 2)
+
+  for (let index = 0; index < wordCount; index += 1) {
+    const word = Number(words[startIndex + index])
+    if (!Number.isInteger(word)) break
+
+    const bytes = [(word >> 8) & 0xFF, word & 0xFF]
+
+    for (const byte of bytes) {
+      if (chars.length >= byteLength || byte === 0) {
+        return chars.join('').trim() || '--'
+      }
+
+      if (byte >= 0x20 && byte <= 0x7E) {
+        chars.push(String.fromCharCode(byte))
+      }
+    }
+  }
+
+  return chars.join('').trim() || '--'
+}
+
+function clampNumber(value, minValue, maxValue, fallback) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return fallback
+
+  return Math.min(Math.max(Math.round(numberValue), minValue), maxValue)
+}
+
+function cloneRegister(item) {
+  return {
+    ...item
+  }
+}
+
+function createInitialState() {
+  return {
+    autoReadInterval: DEFAULT_AUTO_READ_INTERVAL,
+    autoReadStatus: false,
+    connectedDevice: null,
+    controlActionButtons: controlButtonRegisters.filter((item) => item.momentary).map(cloneRegister),
+    controlButtons: controlButtonRegisters.filter((item) => !item.momentary).map(cloneRegister),
+    errorText: '',
+    isAwaitingResponse: false,
+    isReadingDriver: false,
+    isReadingMotor: false,
+    isSending: false,
+    isWritingMotor: false,
+    motorParameterInputRegisters,
+    readonlyParamRegisters,
+    speedCommand: speedCommandRegister,
+    systemTip: ''
+  }
+}
+
+function applyTransportState(data, transportState) {
+  const nextState = {
+    connectedDevice: transportState.connectedDevice,
+    errorText: transportState.errorText,
+    isAwaitingResponse: transportState.isAwaitingResponse,
+    isSending: transportState.isSending,
+    systemTip: transportState.systemTip
+  }
+
+  if (!transportState.connectedDevice && data.autoReadStatus) {
+    nextState.autoReadStatus = false
+  }
+
+  if (!transportState.connectedDevice) {
+    nextState.isReadingDriver = false
+    nextState.isReadingMotor = false
+    nextState.isWritingMotor = false
+  }
+
+  return nextState
+}
+
+function applyMotorParameterInput(data, index, value) {
+  const changedRegisters = data.motorParameterInputRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value
+    }
+  })
+  const nextRegisters = updateMotorWriteValues(changedRegisters)
+  const inputValues = mergeInputValues(nextRegisters)
+
+  setSharedInputValues(nextRegisters)
+
+  return {
+    motorParameterInputRegisters: nextRegisters,
+    speedCommand: {
+      ...data.speedCommand,
+      isDirty: true,
+      writeValue: calculateSpeedCommandWriteValue(data.speedCommand.inputValue, inputValues)
+    }
+  }
+}
+
+function applySpeedCommandInput(data, inputValue) {
+  const inputValues = mergeInputValues(data.motorParameterInputRegisters)
+
+  return {
+    speedCommand: {
+      ...data.speedCommand,
+      isDirty: true,
+      inputValue,
+      writeValue: calculateSpeedCommandWriteValue(inputValue, inputValues)
+    }
+  }
+}
+
+function applySpeedCommandReadValue(data, rawValue) {
+  const wordValue = Number(rawValue)
+  if (!Number.isInteger(wordValue)) return {}
+
+  const inputValues = mergeInputValues(data.motorParameterInputRegisters)
+  const speedBase = toFiniteNumber(inputValues['速度基准'])
+  const inputValue = speedBase > 0
+    ? appendInputUnit(data.speedCommand, formatFixedValue(wordValue / SCALE_MAX * speedBase, 2))
+    : data.speedCommand.inputValue
+
+  return {
+    speedCommand: {
+      ...data.speedCommand,
+      isDirty: false,
+      inputValue,
+      writeValue: String(wordValue & 0xFFFF)
+    }
+  }
+}
+
+function getControlButtonWriteValue(button) {
+  if (!button) return 0
+
+  return button.writeValue
+}
+
+function getNextControlButton(button) {
+  if (button.momentary) return button
+
+  return {
+    ...button,
+    name: button.nextName,
+    nextName: button.name,
+    nextWriteValue: button.writeValue,
+    writeValue: button.nextWriteValue
+  }
+}
+
+function applyControlSuccess(data, button) {
+  if (!button) return {}
+
+  if (button.momentary) {
+    return {
+      systemTip: `${button.name}已下发`
+    }
+  }
+
+  return {
+    controlButtons: data.controlButtons.map((item) => (
+      item.key === button.key ? getNextControlButton(item) : item
+    )),
+    systemTip: `${button.name}已下发`
+  }
+}
+
+function getControlButtonFromRead(button, value) {
+  if (!button || button.momentary) return button
+
+  const readValue = Number(value)
+  if (!Number.isFinite(readValue)) return button
+  if (Number(button.writeValue) === readValue) return getNextControlButton(button)
+  if (Number(button.nextWriteValue) === readValue) return button
+
+  return button
+}
+
+function applyControlReadValues(data, coilValues = {}) {
+  return {
+    controlButtons: data.controlButtons.map((item) => {
+      const value = coilValues[toAddressKey(item.address)]
+
+      return value === undefined ? item : getControlButtonFromRead(item, value)
+    })
+  }
+}
+
+function buildMotorMainWriteValues(data) {
+  const registerMap = data.motorParameterInputRegisters.reduce((result, item) => {
+    result[item.name] = item
+    return result
+  }, {})
+  const ldWords = floatToWords(registerMap.LD && registerMap.LD.inputValue)
+  const lqWords = floatToWords(registerMap.LQ && registerMap.LQ.inputValue)
+  const rsWords = floatToWords(registerMap.RS && registerMap.RS.inputValue)
+  const polePairsWord = toRegisterWord(registerMap['极对数'] && registerMap['极对数'].inputValue)
+  const speedBaseWord = toRegisterWord(registerMap['速度基准'] && registerMap['速度基准'].inputValue)
+
+  if (!ldWords || !lqWords || !rsWords || !Number.isInteger(polePairsWord) || !Number.isInteger(speedBaseWord)) {
+    return {
+      errorText: '请检查 LD、LQ、RS、极对数和速度基准的输入值',
+      values: null
+    }
+  }
+
+  return {
+    errorText: '',
+    values: ldWords.concat(lqWords, rsWords, [polePairsWord, speedBaseWord])
+  }
+}
+
+function applyMotorParameterReadValues(data, registerWordCache) {
+  const nextRegisters = data.motorParameterInputRegisters.map((item) => {
+    let readValue = null
+
+    if (item.name === 'LD' && registerWordCache[0x60] !== undefined && registerWordCache[0x61] !== undefined) {
+      readValue = wordsToFloat(registerWordCache[0x60], registerWordCache[0x61])
+    } else if (item.name === 'LQ' && registerWordCache[0x62] !== undefined && registerWordCache[0x63] !== undefined) {
+      readValue = wordsToFloat(registerWordCache[0x62], registerWordCache[0x63])
+    } else if (item.name === 'RS' && registerWordCache[0x64] !== undefined && registerWordCache[0x65] !== undefined) {
+      readValue = wordsToFloat(registerWordCache[0x64], registerWordCache[0x65])
+    } else if (item.name === '极对数' && registerWordCache[0x66] !== undefined) {
+      readValue = registerWordCache[0x66]
+    } else if (item.name === '速度基准' && registerWordCache[0x67] !== undefined) {
+      readValue = registerWordCache[0x67]
+    }
+
+    if (readValue === null) return item
+
+    return {
+      ...item,
+      isDirty: false,
+      inputValue: appendInputUnit(item, formatReadInputValue(item, readValue))
+    }
+  })
+  const nextWriteRegisters = updateMotorWriteValues(nextRegisters)
+  const inputValues = mergeInputValues(nextWriteRegisters)
+
+  setSharedInputValues(nextWriteRegisters)
+
+  return {
+    motorParameterInputRegisters: nextWriteRegisters,
+    speedCommand: {
+      ...data.speedCommand,
+      writeValue: calculateSpeedCommandWriteValue(data.speedCommand.inputValue, inputValues)
+    }
+  }
+}
+
+function clearMotorParameterDirty(data) {
+  return {
+    motorParameterInputRegisters: data.motorParameterInputRegisters.map((item) => ({
+      ...item,
+      isDirty: false
+    }))
+  }
+}
+
+function clearSpeedCommandDirty(data) {
+  return {
+    speedCommand: {
+      ...data.speedCommand,
+      isDirty: false
+    }
+  }
+}
+
+function applyMotorParameterBlur(data, index, value) {
+  const item = data.motorParameterInputRegisters[index]
+  if (!item) return {}
+
+  return applyMotorParameterInput(data, index, appendInputUnit(item, value === undefined ? item.inputValue : value))
+}
+
+function applySpeedCommandBlur(data, value) {
+  return applySpeedCommandInput(
+    data,
+    appendInputUnit(data.speedCommand, value === undefined ? data.speedCommand.inputValue : value)
+  )
+}
+
+function applyDriverParameterReadValues(data, words) {
+  if (!Array.isArray(words) || words.length < DRIVER_PARAM_WORD_COUNT) return {}
+
+  const carrierFrequencyKHz = (words[0] >> 8) & 0xFF
+  const baseVoltage = (words[0] & 0xFF) / 10
+  const opAmpGain = words[1] & 0xFFFF
+  const samplingResistorMohm = words[2] & 0xFFFF
+  const busVoltageDividerRatio = wordsToFloat(words[4], words[5])
+  const analogInputDividerRatio = wordsToFloat(words[6], words[7])
+  const displayValues = {
+    芯片型号: wordsToAscii(words, 8, 8),
+    型号: wordsToAscii(words, 12, 16),
+    载波频率: String(carrierFrequencyKHz),
+    基准电压: formatFixedValue(baseVoltage, 2),
+    运放倍数: String(opAmpGain),
+    采样电阻: String(samplingResistorMohm),
+    '全区 Flash 校验码': formatHexWord(words[3]),
+    母线电压分压比: formatFixedValue(busVoltageDividerRatio, 2),
+    模拟输入电压分压比: formatFixedValue(analogInputDividerRatio, 2)
+  }
+
+  updateDriverParams({
+    analogInputDividerRatio,
+    baseVoltage,
+    busVoltageDividerRatio,
+    carrierFrequencyKHz,
+    opAmpGain,
+    samplingResistorMohm
+  })
+
+  return {
+    readonlyParamRegisters: data.readonlyParamRegisters.map((item) => ({
+      ...item,
+      displayValue: displayValues[item.name] || item.displayValue || '--'
+    }))
+  }
+}
+
+function applyStatusReadValues(words, startAddress = STATUS_START_ADDRESS) {
+  if (!Array.isArray(words) || !words.length) return {}
+
+  updateStatusRegisterWords(statusRegisters, startAddress, words)
+
+  return {}
+}
+
+module.exports = {
+  AUTO_READ_MAX_INTERVAL,
+  AUTO_READ_MIN_INTERVAL,
+  DRIVER_PARAM_START_ADDRESS,
+  DRIVER_PARAM_WORD_COUNT,
+  MOTOR_PARAM_START_ADDRESS,
+  MOTOR_PARAM_WORD_COUNT,
+  STATUS_START_ADDRESS,
+  STATUS_WORD_COUNT,
+  applyControlReadValues,
+  applyControlSuccess,
+  applyDriverParameterReadValues,
+  applyMotorParameterBlur,
+  applyMotorParameterInput,
+  applyMotorParameterReadValues,
+  clearMotorParameterDirty,
+  clearSpeedCommandDirty,
+  applySpeedCommandBlur,
+  applySpeedCommandInput,
+  applySpeedCommandReadValue,
+  applyStatusReadValues,
+  applyTransportState,
+  buildMotorMainWriteValues,
+  clampNumber,
+  createInitialState,
+  getControlButtonWriteValue,
+  getRegisterWordCache,
+  setSharedInputValues
+}

+ 502 - 0
utils/control-service.js

@@ -0,0 +1,502 @@
+const {
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  buildWriteSingleRegisterFrame,
+  MAX_READ_REGISTER_QUANTITY
+} = require('./modbus-rtu')
+const controlState = require('./control-page-state')
+const transport = require('./ble-transport')
+const {
+  addCoilReadValues
+} = require('./register-value-utils')
+
+let state = controlState.createInitialState()
+let autoReadTimer = null
+let unsubscribeTransport = null
+const subscribers = []
+
+function getState() {
+  return {
+    ...state
+  }
+}
+
+function notify() {
+  const nextState = getState()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(nextState)
+  })
+}
+
+function setState(changedData) {
+  state = {
+    ...state,
+    ...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
+
+    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
+}
+
+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 (unsubscribeTransport) return
+
+  unsubscribeTransport = transport.subscribe((transportState) => {
+    const nextState = controlState.applyTransportState(state, transportState)
+
+    if (nextState.autoReadStatus === false) {
+      stopAutoReadStatus()
+    }
+
+    setState(nextState)
+  })
+}
+
+function syncSharedInputs() {
+  controlState.setSharedInputValues(state.motorParameterInputRegisters)
+}
+
+function applyControlReadValues(coilValues) {
+  setState(controlState.applyControlReadValues(state, coilValues))
+}
+
+function applyMotorReadWords(words, startAddress = controlState.MOTOR_PARAM_START_ADDRESS) {
+  const registerWordCache = controlState.getRegisterWordCache(startAddress, words)
+  const motorState = controlState.applyMotorParameterReadValues(state, registerWordCache)
+  const nextState = {
+    ...state,
+    ...motorState
+  }
+
+  setState({
+    ...motorState,
+    ...controlState.applySpeedCommandReadValue(nextState, registerWordCache[0x68])
+  })
+}
+
+function applyDriverReadWords(words) {
+  setState(controlState.applyDriverParameterReadValues(state, words))
+}
+
+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))
+}
+
+function updateMotorParameterBlur(index, value) {
+  setState(controlState.applyMotorParameterBlur(state, index, value))
+}
+
+function updateSpeedCommandInput(value) {
+  setState(controlState.applySpeedCommandInput(state, value))
+}
+
+function updateSpeedCommandBlur(value) {
+  setState(controlState.applySpeedCommandBlur(state, value))
+  sendSpeedCommand()
+}
+
+function getSpeedCommandWriteWord() {
+  const writeValue = Number(state.speedCommand.writeValue)
+  if (!Number.isFinite(writeValue)) return null
+
+  const word = Math.round(writeValue)
+
+  return word >= 0 && word <= 0xFFFF ? word : null
+}
+
+async function sendSpeedCommand() {
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return false
+
+  const writeWord = getSpeedCommandWriteWord()
+  if (writeWord === null) {
+    transport.showCommandAlert('转速命令错误', '请检查转速命令输入值')
+    return false
+  }
+
+  const address = parseInt(state.speedCommand.address, 16)
+  const response = await transport.sendManagedFrame(
+    buildWriteSingleRegisterFrame(slaveAddress, address, writeWord),
+    '转速命令',
+    {
+      address,
+      functionCode: 0x06,
+      kind: 'speed-command-write',
+      quantity: 1,
+      value: writeWord,
+      slaveAddress
+    }
+  )
+
+  if (response) {
+    setState({
+      ...controlState.clearSpeedCommandDirty(state),
+      systemTip: '转速命令已下发'
+    })
+    return true
+  }
+
+  return false
+}
+
+async function sendControlCommand(key) {
+  const button = state.controlButtons
+    .concat(state.controlActionButtons || [])
+    .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,
+    {
+      address,
+      functionCode: 0x05,
+      kind: 'control-write',
+      quantity: 1,
+      value: coilEnabled ? 0xFF00 : 0x0000,
+      slaveAddress
+    }
+  )
+
+  if (response) {
+    setState(controlState.applyControlSuccess(state, button))
+  }
+}
+
+async function readControlStatus() {
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return false
+
+  const startAddress = 0x00
+  const quantity = 3
+  const response = await transport.sendManagedFrame(
+    buildReadFrame(slaveAddress, 0x01, startAddress, quantity),
+    '控制状态读取',
+    {
+      address: startAddress,
+      functionCode: 0x01,
+      kind: 'control-status-read',
+      quantity,
+      slaveAddress
+    }
+  )
+
+  if (!response) return false
+
+  const readValues = {
+    coils: {}
+  }
+
+  addCoilReadValues(readValues, startAddress, quantity, response)
+  setState({
+    ...controlState.applyControlReadValues(state, readValues.coils),
+    systemTip: '控制状态读取完成'
+  })
+
+  return true
+}
+
+async function readMotorParameters() {
+  if (state.isReadingMotor) return
+
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return
+
+  setState({
+    errorText: '',
+    isReadingMotor: true,
+    systemTip: ''
+  })
+
+  try {
+    const words = await readRegisterChunks(
+      slaveAddress,
+      0x03,
+      controlState.MOTOR_PARAM_START_ADDRESS,
+      controlState.MOTOR_PARAM_WORD_COUNT,
+      '电机参数读取',
+      'motor-main-read',
+      { showModal: true }
+    )
+    if (!words) return
+
+    const registerWordCache = controlState.getRegisterWordCache(controlState.MOTOR_PARAM_START_ADDRESS, words)
+
+    setState({
+      ...controlState.applyMotorParameterReadValues(state, registerWordCache),
+      systemTip: '电机参数读取完成'
+    })
+  } finally {
+    setState({
+      isReadingMotor: false
+    })
+  }
+}
+
+async function writeMotorParameters() {
+  if (state.isWritingMotor) return
+
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return
+
+  const mainWrite = controlState.buildMotorMainWriteValues(state)
+  if (!mainWrite.values) {
+    transport.showCommandAlert('参数错误', mainWrite.errorText)
+    return
+  }
+
+  setState({
+    errorText: '',
+    isWritingMotor: true,
+    systemTip: ''
+  })
+
+  try {
+    const mainResponse = await transport.sendManagedFrame(
+      buildWriteMultipleRegistersFrame(
+        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
+      }
+    )
+    if (!mainResponse) return
+
+    setState({
+      ...controlState.clearMotorParameterDirty(state),
+      systemTip: '电机参数写入完成'
+    })
+  } finally {
+    setState({
+      isWritingMotor: false
+    })
+  }
+}
+
+async function readDriverParameters() {
+  if (state.isReadingDriver) return
+
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return
+
+  setState({
+    errorText: '',
+    isReadingDriver: true,
+    systemTip: ''
+  })
+
+  try {
+    const words = await readRegisterChunks(
+      slaveAddress,
+      0x04,
+      controlState.DRIVER_PARAM_START_ADDRESS,
+      controlState.DRIVER_PARAM_WORD_COUNT,
+      '驱动器硬件参数读取',
+      'driver-read',
+      { showModal: true }
+    )
+
+    if (words) {
+      setState({
+        ...controlState.applyDriverParameterReadValues(state, words),
+        systemTip: '驱动器硬件参数读取完成'
+      })
+    }
+  } finally {
+    setState({
+      isReadingDriver: false
+    })
+  }
+}
+
+function setAutoReadStatus(autoReadStatus) {
+  setState({
+    autoReadStatus
+  })
+
+  if (autoReadStatus) {
+    scheduleAutoReadStatus(0)
+    return
+  }
+
+  stopAutoReadStatus()
+}
+
+function setAutoReadInterval(value) {
+  const autoReadInterval = controlState.clampNumber(
+    value,
+    controlState.AUTO_READ_MIN_INTERVAL,
+    controlState.AUTO_READ_MAX_INTERVAL,
+    state.autoReadInterval
+  )
+
+  setState({
+    autoReadInterval
+  })
+
+  if (state.autoReadStatus) {
+    scheduleAutoReadStatus(autoReadInterval)
+  }
+}
+
+async function readStatus(options = {}) {
+  if (options.auto && !state.connectedDevice) return false
+
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return false
+
+  const words = await readRegisterChunks(
+    slaveAddress,
+    0x04,
+    controlState.STATUS_START_ADDRESS,
+    controlState.STATUS_WORD_COUNT,
+    '状态读取',
+    'status-read',
+    { showModal: !options.auto }
+  )
+
+  if (words) {
+    setState({
+      ...controlState.applyStatusReadValues(words, controlState.STATUS_START_ADDRESS),
+      systemTip: options.auto ? '' : '状态读取完成'
+    })
+  }
+
+  return words
+}
+
+function scheduleAutoReadStatus(delay) {
+  stopAutoReadStatus()
+
+  autoReadTimer = setTimeout(async () => {
+    if (!state.autoReadStatus) return
+
+    await readStatus({
+      auto: true
+    })
+    scheduleAutoReadStatus(state.autoReadInterval)
+  }, delay)
+}
+
+function stopAutoReadStatus() {
+  if (!autoReadTimer) return
+
+  clearTimeout(autoReadTimer)
+  autoReadTimer = null
+}
+
+module.exports = {
+  getState,
+  init,
+  applyControlReadValues,
+  applyDriverReadWords,
+  applyMotorReadWords,
+  applyStatusReadWords,
+  readControlStatus,
+  readDriverParameters,
+  readMotorParameters,
+  readStatus,
+  sendControlCommand,
+  sendSpeedCommand,
+  setAutoReadInterval,
+  setAutoReadStatus,
+  stopAutoReadStatus,
+  subscribe,
+  syncSharedInputs,
+  updateMotorParameterBlur,
+  updateMotorParameterInput,
+  updateSpeedCommandBlur,
+  updateSpeedCommandInput,
+  writeMotorParameters
+}

+ 268 - 0
utils/conversions.js

@@ -0,0 +1,268 @@
+const {
+  ATT_COEF,
+  DEFAULT_DRIVER_PARAMS,
+  SCALE_MAX,
+  TWO_PI,
+  getDriverParams,
+  getSharedInputValues,
+  toFiniteNumber
+} = require('./calculation-context')
+
+function getSpeedBase(inputValues = {}, driverParams = DEFAULT_DRIVER_PARAMS) {
+  const candidates = [
+    inputValues['速度基准'],
+    getSharedInputValues()['速度基准'],
+    driverParams.speedBase
+  ]
+
+  for (let index = 0; index < candidates.length; index += 1) {
+    const speedBase = toFiniteNumber(candidates[index])
+
+    if (speedBase > 0) return speedBase
+  }
+
+  return 0
+}
+
+function getSampleLimits(driverParams = getDriverParams()) {
+  const baseVoltage = driverParams.baseVoltage
+  const samplingResistorOhm = driverParams.samplingResistorMohm / 1000
+  const currentSampleMax = baseVoltage / 2 / samplingResistorOhm / driverParams.opAmpGain
+  const currentBase = currentSampleMax * 2
+  const voltageSampleMax = driverParams.busVoltageDividerRatio * baseVoltage
+
+  return {
+    analogInputDividerRatio: driverParams.analogInputDividerRatio,
+    baseVoltage,
+    busVoltageDividerRatio: driverParams.busVoltageDividerRatio,
+    carrierFrequencyKHz: driverParams.carrierFrequencyKHz,
+    currentBase,
+    currentSampleMax,
+    speedBase: getSpeedBase({}, driverParams),
+    voltageSampleMax
+  }
+}
+
+function formatWriteValue(value) {
+  if (!Number.isFinite(value)) return '--'
+  return String(Math.round(value))
+}
+
+function formatFixedValue(value, precision = 2) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  const text = numberValue.toFixed(precision)
+  return Number(text) === 0 ? (0).toFixed(precision) : text
+}
+
+function calculateSpeedSlope(inputValues, driverParams = getDriverParams()) {
+  const speedMin = toFiniteNumber(inputValues['速度最小值'])
+  const speedMax = toFiniteNumber(inputValues['速度最大值'])
+  const speedBase = getSpeedBase(inputValues, driverParams)
+  const minVoltage = toFiniteNumber(inputValues['调速最低电压'])
+  const maxVoltage = toFiniteNumber(inputValues['调速最高电压'])
+  const { baseVoltage, analogInputDividerRatio } = getSampleLimits(driverParams)
+
+  if (speedMax <= speedMin || maxVoltage <= minVoltage || !speedBase) return null
+
+  return (speedMax - speedMin) / speedBase / ((maxVoltage - minVoltage) * analogInputDividerRatio / baseVoltage)
+}
+
+function calculateSpeedCommandWriteValue(actualValue, inputValues = {}, driverParams = getDriverParams()) {
+  if (actualValue === '' || actualValue === undefined || actualValue === null) return '--'
+
+  const numberValue = toFiniteNumber(actualValue, NaN)
+  const speedBase = getSpeedBase(inputValues, driverParams)
+
+  if (!Number.isFinite(numberValue) || !speedBase) return '--'
+
+  return formatWriteValue(numberValue / speedBase * SCALE_MAX)
+}
+
+function calculateAtoGainWriteValues(atoBandwidth, inputValues = {}, driverParams = getDriverParams()) {
+  if (atoBandwidth === '' || atoBandwidth === undefined || atoBandwidth === null) {
+    return {
+      kpWriteValue: '--',
+      kiWriteValue: '--'
+    }
+  }
+
+  const bandwidth = toFiniteNumber(atoBandwidth, NaN)
+  const speedBase = getSpeedBase(inputValues, driverParams)
+  const polePairs = toFiniteNumber(inputValues['极对数'])
+  const sampleFrequency = driverParams.carrierFrequencyKHz * 1000
+  const tpwmValue = 1 / sampleFrequency
+  const baseFrequency = speedBase / 60 * polePairs
+
+  if (!Number.isFinite(bandwidth) || !speedBase || !polePairs || !baseFrequency) {
+    return {
+      kpWriteValue: '--',
+      kiWriteValue: '--'
+    }
+  }
+
+  return {
+    kpWriteValue: formatWriteValue(4095 * TWO_PI * ATT_COEF * bandwidth / baseFrequency),
+    kiWriteValue: formatWriteValue(SCALE_MAX * TWO_PI * bandwidth * bandwidth * tpwmValue / baseFrequency)
+  }
+}
+
+function calculateDqGainWriteValue(item, actualValue) {
+  if (actualValue === '' || actualValue === undefined || actualValue === null) return '--'
+
+  const numberValue = toFiniteNumber(actualValue, NaN)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  if (item.gainType === 'kp') return formatWriteValue(4095 * numberValue)
+  if (item.gainType === 'ki') return formatWriteValue(SCALE_MAX * numberValue)
+
+  return formatWriteValue(numberValue)
+}
+
+function getFloatPrecision(item) {
+  if (item.name === 'LD' || item.name === 'LQ') return 6
+  if (item.name === 'RS') return 4
+
+  return 2
+}
+
+function calculateParameterInputWriteValue(item, actualValue, inputValues = {}, driverParams = getDriverParams()) {
+  if (actualValue === '' || actualValue === undefined || actualValue === null) return '--'
+
+  const numberValue = toFiniteNumber(actualValue, NaN)
+  if (!Number.isFinite(numberValue)) return '--'
+
+  const { baseVoltage, analogInputDividerRatio } = getSampleLimits(driverParams)
+  const speedBase = getSpeedBase(inputValues, driverParams)
+
+  if (['开机电压', '关机电压', '调速最低电压', '调速最高电压'].includes(item.name)) {
+    return formatWriteValue(SCALE_MAX * numberValue / baseVoltage * analogInputDividerRatio)
+  }
+
+  if (['速度最小值', '速度最大值'].includes(item.name)) {
+    if (!speedBase) return '--'
+
+    return formatWriteValue(SCALE_MAX * numberValue / speedBase)
+  }
+
+  if (item.name === 'SOUT_MAX') {
+    const { currentBase } = getSampleLimits(driverParams)
+
+    return formatWriteValue(numberValue / currentBase * SCALE_MAX)
+  }
+
+  if (item.name === '上油转速') {
+    if (!speedBase) return '--'
+
+    return formatWriteValue(SCALE_MAX * numberValue / speedBase)
+  }
+
+  if (item.type === 'uint8_t') {
+    if (numberValue < 0 || numberValue > 0xFF) return '--'
+
+    return formatWriteValue(numberValue)
+  }
+
+  if (item.type === 'float') return formatFixedValue(numberValue, getFloatPrecision(item))
+
+  return formatWriteValue(numberValue)
+}
+
+function calculateProtectionWriteValue(item, actualValue, driverParams = getDriverParams()) {
+  if (!actualValue && actualValue !== 0) return '--'
+
+  const { currentBase, currentSampleMax, voltageSampleMax, speedBase } = getSampleLimits(driverParams)
+
+  if (['过压保护值', '欠压保护值', '过压恢复值', '欠压恢复值'].includes(item.name)) {
+    return formatWriteValue(actualValue / voltageSampleMax * SCALE_MAX)
+  }
+
+  if (item.name === '软件过流值') {
+    return formatWriteValue(actualValue / currentBase * SCALE_MAX)
+  }
+
+  if (['速度限制最大值', '速度限制最小值', '速度中间值'].includes(item.name)) {
+    return formatWriteValue(actualValue / speedBase * SCALE_MAX)
+  }
+
+  if (item.name === '功率保护值') {
+    return formatWriteValue(actualValue / currentSampleMax / voltageSampleMax * SCALE_MAX)
+  }
+
+  return item.type === 'float' ? formatFixedValue(Number(actualValue), 2) : formatWriteValue(actualValue)
+}
+
+function calculateParameterReadValue(item, rawValue, inputValues = {}, driverParams = getDriverParams()) {
+  const rawNumber = toFiniteNumber(rawValue, NaN)
+  if (!Number.isFinite(rawNumber)) return null
+
+  const ratio = rawNumber / SCALE_MAX
+  const {
+    analogInputDividerRatio,
+    baseVoltage,
+    currentBase,
+    currentSampleMax,
+    voltageSampleMax
+  } = getSampleLimits(driverParams)
+  const speedBase = getSpeedBase(inputValues, driverParams)
+
+  if (['开机电压', '关机电压', '调速最低电压', '调速最高电压'].includes(item.name)) {
+    return analogInputDividerRatio ? ratio * baseVoltage / analogInputDividerRatio : null
+  }
+
+  if (['过压保护值', '欠压保护值', '过压恢复值', '欠压恢复值'].includes(item.name)) {
+    return ratio * voltageSampleMax
+  }
+
+  if (['速度最小值', '速度最大值', '上油转速', '速度限制最大值', '速度限制最小值', '速度中间值'].includes(item.name)) {
+    return speedBase ? ratio * speedBase : null
+  }
+
+  if (item.name === '软件过流值' || item.name === 'SOUT_MAX') {
+    return ratio * currentBase
+  }
+
+  if (item.name === '功率保护值') {
+    return ratio * currentSampleMax * voltageSampleMax
+  }
+
+  return null
+}
+
+function calculateStatusValue(name, rawValue, driverParams = getDriverParams()) {
+  const rawNumber = toFiniteNumber(rawValue)
+  const ratio = rawNumber / SCALE_MAX
+  const {
+    analogInputDividerRatio,
+    baseVoltage,
+    busVoltageDividerRatio,
+    currentBase,
+    currentSampleMax,
+    voltageSampleMax,
+    speedBase
+  } = getSampleLimits(driverParams)
+
+  if (name === '母线电压') return ratio * baseVoltage * busVoltageDividerRatio
+  if (name === '母线电流') return ratio * currentBase
+  if (name === 'NTC 电压') return ratio * baseVoltage
+  if (name === '模拟输入电压') return analogInputDividerRatio ? ratio * baseVoltage / analogInputDividerRatio : 0
+  if (name === '估算速度') return ratio * speedBase
+  if (name === '估算功率') return ratio * voltageSampleMax * currentSampleMax
+  if (name === '频率' || name === '占空比') return rawNumber / 10
+  return rawNumber
+}
+
+module.exports = {
+  SCALE_MAX,
+  calculateAtoGainWriteValues,
+  calculateDqGainWriteValue,
+  calculateParameterInputWriteValue,
+  calculateParameterReadValue,
+  calculateProtectionWriteValue,
+  calculateSpeedCommandWriteValue,
+  calculateSpeedSlope,
+  calculateStatusValue,
+  formatFixedValue,
+  getSampleLimits
+}

+ 30 - 0
utils/input-value-utils.js

@@ -0,0 +1,30 @@
+const {
+  toFiniteNumber
+} = require('./calculation-context')
+
+function getInputTextWithoutUnit(item, value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!item || !item.unit || !text) return text
+
+  const lowerText = text.toLowerCase()
+  const lowerUnit = String(item.unit).toLowerCase()
+
+  return lowerText.endsWith(lowerUnit)
+    ? text.slice(0, -item.unit.length).trim()
+    : text
+}
+
+function appendInputUnit(item, value) {
+  const text = String(value === undefined || value === null ? '' : value).trim()
+  if (!item || !item.unit || !text) return text
+
+  const valueText = getInputTextWithoutUnit(item, text)
+  if (!Number.isFinite(toFiniteNumber(valueText, NaN))) return text
+
+  return `${valueText} ${item.unit}`
+}
+
+module.exports = {
+  appendInputUnit,
+  getInputTextWithoutUnit
+}

+ 181 - 0
utils/modbus-rtu.js

@@ -0,0 +1,181 @@
+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 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)
+
+function toByte(value, label) {
+  if (!Number.isInteger(value) || value < 0 || value > 0xFF) {
+    throw new Error(`${label}必须在 0x00 至 0xFF 之间`)
+  }
+
+  return value
+}
+
+function toWord(value, label) {
+  if (!Number.isInteger(value) || value < 0 || value > 0xFFFF) {
+    throw new Error(`${label}必须在 0x0000 至 0xFFFF 之间`)
+  }
+
+  return value
+}
+
+function splitWord(value) {
+  return [(value >> 8) & 0xFF, value & 0xFF]
+}
+
+function calculateCrc(bytes) {
+  let crc = 0xFFFF
+
+  bytes.forEach((byte) => {
+    crc = (crc >> 8) ^ CRC_TABLE[(crc ^ byte) & 0xFF]
+  })
+
+  return crc
+}
+
+function appendCrc(bytes) {
+  const crc = calculateCrc(bytes)
+
+  return bytes.concat([crc & 0xFF, (crc >> 8) & 0xFF])
+}
+
+function hasValidCrc(bytes) {
+  if (!bytes || bytes.length < 4) return false
+
+  const frame = bytes.slice(0, bytes.length - 2)
+  const expected = appendCrc(frame)
+
+  return expected[expected.length - 2] === bytes[bytes.length - 2]
+    && expected[expected.length - 1] === bytes[bytes.length - 1]
+}
+
+function buildReadFrame(slaveAddress, functionCode, address, quantity) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const command = toByte(functionCode, '功能码')
+  const startAddress = toWord(address, '寄存器地址')
+  const registerQuantity = toWord(quantity, '读取数量')
+
+  if (![0x01, 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 (command === 0x01 && registerQuantity > MAX_READ_COIL_QUANTITY) {
+    throw new Error(`单帧最多读取 ${MAX_READ_COIL_QUANTITY} 个线圈`)
+  }
+
+  return appendCrc([slave, command].concat(splitWord(startAddress), splitWord(registerQuantity)))
+}
+
+function buildWriteSingleCoilFrame(slaveAddress, address, checked) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '线圈地址')
+  const outputValue = checked ? 0xFF00 : 0x0000
+
+  return appendCrc([slave, 0x05].concat(splitWord(startAddress), splitWord(outputValue)))
+}
+
+function buildWriteSingleRegisterFrame(slaveAddress, address, value) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '寄存器地址')
+  const registerValue = toWord(value, '写入值')
+
+  return appendCrc([slave, 0x06].concat(splitWord(startAddress), splitWord(registerValue)))
+}
+
+function buildWriteMultipleRegistersFrame(slaveAddress, address, values) {
+  const slave = toByte(slaveAddress, '从站地址')
+  const startAddress = toWord(address, '寄存器地址')
+
+  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} 个寄存器`)
+  }
+
+  const registerBytes = values.reduce((result, value) => {
+    return result.concat(splitWord(toWord(value, '写入值')))
+  }, [])
+
+  return appendCrc(
+    [slave, 0x10]
+      .concat(splitWord(startAddress), splitWord(values.length), [registerBytes.length], registerBytes)
+  )
+}
+
+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 === 0x03 || functionCode === 0x04) return MODBUS_READ_RESPONSE_OVERHEAD + Number(quantity || 0) * 2
+
+  return 0
+}
+
+module.exports = {
+  MAX_MODBUS_DMA_BYTES,
+  MAX_READ_COIL_QUANTITY,
+  MAX_READ_REGISTER_QUANTITY,
+  MAX_WRITE_MULTIPLE_REGISTER_QUANTITY,
+  appendCrc,
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  buildWriteSingleRegisterFrame,
+  calculateCrc,
+  formatHex,
+  getMaxReadQuantity,
+  getReadResponseByteLength,
+  hasValidCrc
+}

+ 136 - 0
utils/page-toast.js

@@ -0,0 +1,136 @@
+const DEFAULT_DURATION = 1000
+const globalListeners = []
+
+function getToastFromState(state = {}) {
+  const errorText = String(state.errorText || '').trim()
+  const systemTip = String(state.systemTip || '').trim()
+
+  if (errorText) {
+    return {
+      text: errorText,
+      type: 'error'
+    }
+  }
+
+  if (systemTip) {
+    return {
+      text: systemTip,
+      type: 'info'
+    }
+  }
+
+  return {
+    text: '',
+    type: ''
+  }
+}
+
+function createPageToast(page, initialState = {}, duration = DEFAULT_DURATION) {
+  let active = true
+  let timer = null
+  let lastText = getToastFromState(initialState).text
+
+  function clearTimer() {
+    if (!timer) return
+
+    clearTimeout(timer)
+    timer = null
+  }
+
+  function clear() {
+    clearTimer()
+    page.setData({
+      toastText: '',
+      toastType: ''
+    })
+  }
+
+  function show(text, type) {
+    if (!active) return
+
+    clearTimer()
+    page.setData({
+      toastText: text,
+      toastType: type || 'info'
+    })
+
+    timer = setTimeout(() => {
+      timer = null
+      page.setData({
+        toastText: '',
+        toastType: ''
+      })
+    }, duration)
+  }
+
+  function showExternal(text, type) {
+    const toastText = String(text || '').trim()
+
+    if (!toastText) {
+      lastText = ''
+      return
+    }
+
+    if (!active) {
+      lastText = toastText
+      return
+    }
+
+    if (toastText === lastText) return
+
+    lastText = toastText
+    show(toastText, type)
+  }
+
+  function showFromState(state) {
+    const toast = getToastFromState(state)
+
+    if (!toast.text) {
+      lastText = ''
+      return
+    }
+
+    if (!active) {
+      lastText = toast.text
+      return
+    }
+
+    if (toast.text === lastText) return
+
+    lastText = toast.text
+    show(toast.text, toast.type)
+  }
+
+  function setActive(nextActive) {
+    active = !!nextActive
+
+    if (!active) clear()
+  }
+
+  function destroy() {
+    clear()
+    const index = globalListeners.indexOf(showExternal)
+    if (index >= 0) globalListeners.splice(index, 1)
+  }
+
+  globalListeners.push(showExternal)
+
+  return {
+    clear,
+    destroy,
+    show,
+    showFromState,
+    setActive
+  }
+}
+
+function notifyPageToast(text, type = 'info') {
+  globalListeners.slice().forEach((listener) => {
+    listener(text, type)
+  })
+}
+
+module.exports = {
+  createPageToast,
+  notifyPageToast
+}

+ 717 - 0
utils/params-page-state.js

@@ -0,0 +1,717 @@
+const {
+  atoBandwidthInputRegisters,
+  calculatedParameterRegisters,
+  dqGainInputRegisters,
+  oilParameterInputRegisters,
+  parameterInputRegisters,
+  prepositionParameterInputRegisters,
+  protectionRegisters,
+  protectionSwitchRegisters,
+  speedLoopExtraRegisters,
+  speedSlopeRegister,
+  tailwindSwitchRegisters,
+  getByteRegisterValue
+} = require('./registers')
+const {
+  getSharedInputValues,
+  mergeInputValues,
+  toFiniteNumber
+} = require('./calculation-context')
+const {
+  calculateAtoGainWriteValues,
+  calculateDqGainWriteValue,
+  calculateParameterInputWriteValue,
+  calculateParameterReadValue,
+  calculateProtectionWriteValue,
+  calculateSpeedSlope,
+  formatFixedValue
+} = require('./conversions')
+const {
+  toAddressKey,
+  wordsToFloat
+} = require('./register-value-utils')
+const {
+  appendInputUnit
+} = require('./input-value-utils')
+
+const VSP_CURVE_ORDER = [
+  '开机电压',
+  '关机电压',
+  '调速最低电压',
+  '调速最高电压'
+]
+
+const SPEED_LOOP_INPUT_ORDER = [
+  '速度最小值',
+  '速度最大值',
+  'SOUT_MAX'
+]
+
+const SPEED_LOOP_CALCULATED_NAMES = [
+]
+
+const TAILWIND_CALCULATED_NAMES = [
+  'SPEED_KLPF_TAILWIND',
+  'OBS_EA_KS_TAILWIND'
+]
+
+const ESTIMATOR_CALCULATED_PREFIXES = [
+  'OBS_E',
+  'OBS_FBASE',
+  'OBS_EA_KS',
+  'SPEED_KLPF',
+  'FOC_KFG'
+]
+
+function formatInputValue(item, value) {
+  if (value === '' || value === undefined || value === null) return '--'
+
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return value
+
+  if (item.type === 'uint8_t' && (numberValue < 0 || numberValue > 0xFF)) return '--'
+
+  if (item.type === 'float') return formatFixedValue(numberValue, 2)
+
+  return String(Math.round(numberValue))
+}
+
+function getInputValues(registers) {
+  return mergeInputValues(registers)
+}
+
+function getSharedParameterValues(registers, extraRegisters = []) {
+  return {
+    ...getSharedInputValues(),
+    ...getInputValues(registers.concat(extraRegisters))
+  }
+}
+
+function updateAtoBandwidthValues(registers, inputValues) {
+  return registers.map((item) => ({
+    ...item,
+    ...calculateAtoGainWriteValues(item.inputValue, inputValues)
+  }))
+}
+
+function updateInputWriteValues(registers) {
+  const inputValues = getInputValues(registers)
+
+  return registers.map((item) => ({
+    ...item,
+    writeValue: calculateParameterInputWriteValue(item, item.inputValue, inputValues)
+  }))
+}
+
+function updateSpeedLoopExtraValues(registers, inputValues) {
+  return registers.map((item) => {
+    const writeValue = calculateParameterInputWriteValue(item, item.inputValue, inputValues)
+
+    return {
+      ...item,
+      actualText: '',
+      writeValue
+    }
+  })
+}
+
+function updateOilParameterValues(registers, inputValues) {
+  return registers.map((item) => ({
+    ...item,
+    writeValue: calculateParameterInputWriteValue(item, item.inputValue, inputValues)
+  }))
+}
+
+function updateSpeedSlope(register, inputValues) {
+  const speedSlope = calculateSpeedSlope(inputValues)
+
+  return {
+    ...register,
+    writeValue: speedSlope === null ? '--' : formatFixedValue(speedSlope, 2)
+  }
+}
+
+function addSourceIndex(registers) {
+  return registers.map((item, index) => ({
+    ...item,
+    sourceIndex: index
+  }))
+}
+
+function isNameIn(names, item) {
+  return names.includes(item.name)
+}
+
+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
+
+  return ESTIMATOR_CALCULATED_PREFIXES.some((prefix) => item.name.startsWith(prefix))
+}
+
+function sortByNameOrder(registers, nameOrder) {
+  return registers
+    .filter((item) => nameOrder.includes(item.name))
+    .slice()
+    .sort((left, right) => nameOrder.indexOf(left.name) - nameOrder.indexOf(right.name))
+}
+
+function buildProtectionGroups(registers) {
+  return {
+    protectionDisplayRegisters: addSourceIndex(registers)
+  }
+}
+
+function getWord(readValues, address) {
+  return readValues.words[toAddressKey(address)]
+}
+
+function getRegisterReadValue(item, readValues) {
+  if (item.area && item.area.key === 'coil') {
+    const coilValue = readValues.coils[toAddressKey(item.address)]
+    return coilValue === undefined ? null : coilValue
+  }
+
+  const firstWord = getWord(readValues, item.address)
+  if (!Number.isInteger(firstWord)) return null
+
+  if (item.type === 'uint8_t' && item.bytePosition) {
+    return getByteRegisterValue(item, firstWord)
+  }
+
+  if (item.type === 'float') {
+    const nextAddress = (parseInt(item.address, 16) + 1).toString(16).toUpperCase()
+    return wordsToFloat(firstWord, getWord(readValues, nextAddress))
+  }
+
+  return firstWord
+}
+
+function formatReadValue(item, value) {
+  if (value === null || value === undefined || !Number.isFinite(Number(value))) return null
+
+  const numberValue = Number(value)
+
+  if (item.type === 'float') return formatFixedValue(numberValue, 2)
+
+  return String(Math.round(numberValue))
+}
+
+function getReadInputValue(item, readValue, readText, options = {}) {
+  let inputText = readText
+
+  if (options.useCalculatedInputValue) {
+    const calculatedValue = calculateParameterReadValue(item, readValue, options.inputValues || {})
+
+    if (calculatedValue !== null) {
+      inputText = formatFixedValue(calculatedValue, 2)
+    }
+  }
+
+  return appendInputUnit(item, inputText)
+}
+
+function getDqReadInputValue(item, rawText) {
+  const rawValue = Number(rawText)
+  if (!Number.isFinite(rawValue)) return ''
+
+  if (item.gainType === 'kp') return formatFixedValue(rawValue / 4095, 2)
+  if (item.gainType === 'ki') return formatFixedValue(rawValue / 32767, 2)
+
+  return rawText
+}
+
+function applyReadValuesToRegisters(registers, readValues, options = {}) {
+  return registers.map((item) => {
+    const readValue = getRegisterReadValue(item, readValues)
+    const readText = formatReadValue(item, readValue)
+
+    if (readText === null) return item
+
+    const nextItem = {
+      ...item,
+      isDirty: false,
+      writeValue: readText
+    }
+
+    if (Object.prototype.hasOwnProperty.call(item, 'value')) {
+      nextItem.value = Number(readValue) !== 0
+    }
+
+    if (options.updateInputValue) {
+      nextItem.inputValue = getReadInputValue(item, readValue, readText, options)
+    }
+
+    if (options.updateDqInputValue) {
+      nextItem.inputValue = getDqReadInputValue(item, readText)
+    }
+
+    return nextItem
+  })
+}
+
+function applyReadValuesToAtoRegisters(registers, readValues) {
+  return registers.map((item) => {
+    const kpWord = getWord(readValues, item.kpAddress)
+    const kiWord = getWord(readValues, item.kiAddress)
+
+    if (!Number.isInteger(kpWord) && !Number.isInteger(kiWord)) return item
+
+    return {
+      ...item,
+      isDirty: false,
+      kpWriteValue: Number.isInteger(kpWord) ? String(kpWord) : item.kpWriteValue,
+      kiWriteValue: Number.isInteger(kiWord) ? String(kiWord) : item.kiWriteValue
+    }
+  })
+}
+
+function clearDirty(registers = [], matcher = () => true) {
+  return registers.map((item) => (
+    matcher(item)
+      ? {
+        ...item,
+        isDirty: false
+      }
+      : item
+  ))
+}
+
+function clearGroupDirty(data, groupKey) {
+  const nextState = {
+    ...data
+  }
+
+  if (groupKey === 'estimator') {
+    nextState.atoBandwidthInputRegisters = clearDirty(data.atoBandwidthInputRegisters)
+    nextState.calculatedParameterRegisters = clearDirty(data.calculatedParameterRegisters, isEstimatorCalculatedRegister)
+  }
+  if (groupKey === 'dq') nextState.dqGainInputRegisters = clearDirty(data.dqGainInputRegisters)
+  if (groupKey === 'tailwind') {
+    nextState.tailwindSwitchRegisters = clearDirty(data.tailwindSwitchRegisters, (item) => item.name !== '预定位启用')
+    nextState.atoBandwidthInputRegisters = clearDirty(data.atoBandwidthInputRegisters, isTailwindAtoRegister)
+    nextState.calculatedParameterRegisters = clearDirty(data.calculatedParameterRegisters, isTailwindCalculatedRegister)
+  }
+  if (groupKey === 'preposition') {
+    nextState.tailwindSwitchRegisters = clearDirty(data.tailwindSwitchRegisters, (item) => item.name === '预定位启用')
+    nextState.prepositionParameterInputRegisters = clearDirty(data.prepositionParameterInputRegisters)
+  }
+  if (groupKey === 'speedLoop') {
+    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') {
+    nextState.parameterInputRegisters = clearDirty(data.parameterInputRegisters, (item) => VSP_CURVE_ORDER.includes(item.name))
+    nextState.speedSlopeRegister = {
+      ...data.speedSlopeRegister,
+      isDirty: false
+    }
+  }
+  if (groupKey === 'oil') nextState.oilParameterInputRegisters = clearDirty(data.oilParameterInputRegisters)
+  if (groupKey === 'protectionSwitch') nextState.protectionSwitchRegisters = clearDirty(data.protectionSwitchRegisters)
+  if (groupKey === 'protection') nextState.protectionRegisters = clearDirty(data.protectionRegisters)
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function clearRegisterDirty(registers = [], index) {
+  return registers.map((item, currentIndex) => (
+    currentIndex === index
+      ? {
+        ...item,
+        isDirty: false
+      }
+      : item
+  ))
+}
+
+function clearTailwindSwitchDirty(data, index) {
+  const nextState = {
+    ...data,
+    tailwindSwitchRegisters: clearRegisterDirty(data.tailwindSwitchRegisters, index)
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function clearProtectionSwitchDirty(data, index) {
+  return {
+    ...data,
+    protectionSwitchRegisters: clearRegisterDirty(data.protectionSwitchRegisters, index)
+  }
+}
+
+function buildViewState(state) {
+  const inputRegisters = addSourceIndex(state.parameterInputRegisters)
+  const atoRegisters = addSourceIndex(state.atoBandwidthInputRegisters)
+  const dqRegisters = addSourceIndex(state.dqGainInputRegisters)
+  const calculatedRegisters = addSourceIndex(state.calculatedParameterRegisters)
+  const speedLoopExtras = addSourceIndex(state.speedLoopExtraRegisters)
+  const tailwindSwitches = addSourceIndex(state.tailwindSwitchRegisters)
+
+  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),
+    tailwindCalculatedDisplayRegisters: calculatedRegisters.filter(isTailwindCalculatedRegister),
+    tailwindControlRegisters: tailwindSwitches.filter((item) => item.name !== '预定位启用'),
+    prepositionSwitchRegisters: tailwindSwitches.filter((item) => item.name === '预定位启用'),
+    prepositionParameterDisplayRegisters: addSourceIndex(state.prepositionParameterInputRegisters),
+    dqGainDisplayRegisters: dqRegisters,
+    estimatorCalculatedDisplayRegisters: calculatedRegisters.filter(isEstimatorCalculatedRegister),
+    ...buildProtectionGroups(state.protectionRegisters)
+  }
+}
+
+function createInitialState() {
+  const state = {
+    atoBandwidthInputRegisters,
+    atoBandwidthDisplayRegisters: [],
+    calculatedParameterRegisters,
+    dqGainInputRegisters,
+    dqGainDisplayRegisters: [],
+    estimatorCalculatedDisplayRegisters: [],
+    oilParameterInputRegisters,
+    parameterInputRegisters,
+    prepositionParameterDisplayRegisters: [],
+    prepositionParameterInputRegisters,
+    prepositionSwitchRegisters: [],
+    protectionDisplayRegisters: [],
+    protectionRegisters,
+    protectionSwitchRegisters,
+    speedLoopExtraRegisters,
+    speedLoopExtraDisplayRegisters: [],
+    speedLoopCalculatedDisplayRegisters: [],
+    speedLoopInputDisplayRegisters: [],
+    speedSlopeRegister,
+    tailwindAtoBandwidthDisplayRegisters: [],
+    tailwindCalculatedDisplayRegisters: [],
+    tailwindControlRegisters: [],
+    tailwindSwitchRegisters,
+    vspCurveRegisters: []
+  }
+
+  return {
+    ...state,
+    ...buildViewState(state)
+  }
+}
+
+function refreshState(data) {
+  const inputValues = getSharedParameterValues(data.parameterInputRegisters, data.speedLoopExtraRegisters)
+  const nextSpeedLoopExtraRegisters = updateSpeedLoopExtraValues(data.speedLoopExtraRegisters, inputValues)
+  const nextState = {
+    ...data,
+    atoBandwidthInputRegisters: updateAtoBandwidthValues(data.atoBandwidthInputRegisters, inputValues),
+    oilParameterInputRegisters: updateOilParameterValues(data.oilParameterInputRegisters, inputValues),
+    speedLoopExtraRegisters: nextSpeedLoopExtraRegisters,
+    speedSlopeRegister: updateSpeedSlope(data.speedSlopeRegister, inputValues)
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyReadValues(data, readValues) {
+  const inputValues = getSharedParameterValues(data.parameterInputRegisters, data.speedLoopExtraRegisters)
+  const nextState = {
+    ...data,
+    atoBandwidthInputRegisters: applyReadValuesToAtoRegisters(data.atoBandwidthInputRegisters, readValues),
+    calculatedParameterRegisters: applyReadValuesToRegisters(data.calculatedParameterRegisters, readValues),
+    dqGainInputRegisters: applyReadValuesToRegisters(data.dqGainInputRegisters, readValues, {
+      updateDqInputValue: true
+    }),
+    oilParameterInputRegisters: applyReadValuesToRegisters(data.oilParameterInputRegisters, readValues, {
+      inputValues,
+      updateInputValue: true,
+      useCalculatedInputValue: true
+    }),
+    parameterInputRegisters: applyReadValuesToRegisters(data.parameterInputRegisters, readValues, {
+      inputValues,
+      updateInputValue: true,
+      useCalculatedInputValue: true
+    }),
+    prepositionParameterInputRegisters: applyReadValuesToRegisters(data.prepositionParameterInputRegisters, readValues, {
+      updateInputValue: true
+    }),
+    protectionRegisters: applyReadValuesToRegisters(data.protectionRegisters, readValues, {
+      inputValues,
+      updateInputValue: true,
+      useCalculatedInputValue: true
+    }),
+    protectionSwitchRegisters: applyReadValuesToRegisters(data.protectionSwitchRegisters, readValues),
+    speedLoopExtraRegisters: applyReadValuesToRegisters(data.speedLoopExtraRegisters, readValues, {
+      updateInputValue: true
+    }),
+    speedSlopeRegister: applyReadValuesToRegisters([data.speedSlopeRegister], readValues)[0],
+    tailwindSwitchRegisters: applyReadValuesToRegisters(data.tailwindSwitchRegisters, readValues)
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyParameterInput(data, index, value) {
+  const changedRegisters = data.parameterInputRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value,
+      writeValue: formatInputValue(item, value)
+    }
+  })
+  const nextRegisters = updateInputWriteValues(changedRegisters)
+  const inputValues = getSharedParameterValues(nextRegisters, data.speedLoopExtraRegisters)
+  const nextState = {
+    ...data,
+    parameterInputRegisters: nextRegisters,
+    atoBandwidthInputRegisters: updateAtoBandwidthValues(data.atoBandwidthInputRegisters, inputValues),
+    speedLoopExtraRegisters: updateSpeedLoopExtraValues(data.speedLoopExtraRegisters, inputValues),
+    speedSlopeRegister: updateSpeedSlope(data.speedSlopeRegister, inputValues)
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyAtoBandwidthInput(data, index, value) {
+  const inputValues = getSharedParameterValues(data.parameterInputRegisters)
+  const nextRegisters = data.atoBandwidthInputRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value,
+      ...calculateAtoGainWriteValues(value, inputValues)
+    }
+  })
+  const nextState = {
+    ...data,
+    atoBandwidthInputRegisters: nextRegisters
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyDqGainInput(data, index, value) {
+  const nextRegisters = data.dqGainInputRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value,
+      writeValue: calculateDqGainWriteValue(item, value)
+    }
+  })
+  const nextState = {
+    ...data,
+    dqGainInputRegisters: nextRegisters
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applySpeedLoopExtraInput(data, index, value) {
+  const changedRegisters = data.speedLoopExtraRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value
+    }
+  })
+  const inputValues = getSharedParameterValues(data.parameterInputRegisters, changedRegisters)
+  const nextState = {
+    ...data,
+    speedLoopExtraRegisters: updateSpeedLoopExtraValues(changedRegisters, inputValues)
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyOilParameterInput(data, index, value) {
+  const changedRegisters = data.oilParameterInputRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value
+    }
+  })
+  const inputValues = getSharedParameterValues(data.parameterInputRegisters, data.speedLoopExtraRegisters)
+  const nextState = {
+    ...data,
+    oilParameterInputRegisters: updateOilParameterValues(changedRegisters, inputValues)
+  }
+
+  return nextState
+}
+
+function applyPrepositionParameterInput(data, index, value) {
+  const nextState = {
+    ...data,
+    prepositionParameterInputRegisters: data.prepositionParameterInputRegisters.map((item, currentIndex) => {
+      if (currentIndex !== index) return item
+
+      return {
+        ...item,
+        isDirty: true,
+        inputValue: value,
+        writeValue: formatInputValue(item, value)
+      }
+    })
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyTailwindSwitchChange(data, index, checked) {
+  const nextState = {
+    ...data,
+    tailwindSwitchRegisters: data.tailwindSwitchRegisters.map((item, currentIndex) => {
+      if (currentIndex !== index) return item
+
+      return {
+        ...item,
+        isDirty: true,
+        value: checked,
+        writeValue: checked ? 1 : 0
+      }
+    })
+  }
+
+  return {
+    ...nextState,
+    ...buildViewState(nextState)
+  }
+}
+
+function applyProtectionSwitchChange(data, index, checked) {
+  return {
+    ...data,
+    protectionSwitchRegisters: data.protectionSwitchRegisters.map((item, currentIndex) => {
+      if (currentIndex !== index) return item
+
+      return {
+        ...item,
+        isDirty: true,
+        value: checked,
+        writeValue: checked ? 1 : 0
+      }
+    })
+  }
+}
+
+function applyProtectionInput(data, index, value) {
+  const nextRegisters = data.protectionRegisters.map((item, currentIndex) => {
+    if (currentIndex !== index) return item
+
+    return {
+      ...item,
+      isDirty: true,
+      inputValue: value,
+      writeValue: value === '' ? '--' : calculateProtectionWriteValue(item, toFiniteNumber(value, NaN))
+    }
+  })
+
+  return {
+    ...data,
+    protectionRegisters: nextRegisters,
+    ...buildProtectionGroups(nextRegisters)
+  }
+}
+
+function getInputRegister(data, group, index) {
+  if (group === 'parameter') return data.parameterInputRegisters[index]
+  if (group === 'ato') return data.atoBandwidthInputRegisters[index]
+  if (group === 'dq') return data.dqGainInputRegisters[index]
+  if (group === 'speedLoopExtra') return data.speedLoopExtraRegisters[index]
+  if (group === 'oil') return data.oilParameterInputRegisters[index]
+  if (group === 'preposition') return data.prepositionParameterInputRegisters[index]
+  if (group === 'protection') return data.protectionRegisters[index]
+
+  return null
+}
+
+function applyInputBlur(data, group, index, value) {
+  const item = getInputRegister(data, group, index)
+  if (!item) return data
+
+  const inputValue = appendInputUnit(item, value === undefined ? item.inputValue : value)
+
+  if (group === 'parameter') return applyParameterInput(data, index, inputValue)
+  if (group === 'ato') return applyAtoBandwidthInput(data, index, inputValue)
+  if (group === 'dq') return applyDqGainInput(data, index, inputValue)
+  if (group === 'speedLoopExtra') return applySpeedLoopExtraInput(data, index, inputValue)
+  if (group === 'oil') return applyOilParameterInput(data, index, inputValue)
+  if (group === 'preposition') return applyPrepositionParameterInput(data, index, inputValue)
+  if (group === 'protection') return applyProtectionInput(data, index, inputValue)
+
+  return data
+}
+
+module.exports = {
+  applyAtoBandwidthInput,
+  clearGroupDirty,
+  clearProtectionSwitchDirty,
+  clearTailwindSwitchDirty,
+  applyDqGainInput,
+  applyInputBlur,
+  applyOilParameterInput,
+  applyParameterInput,
+  applyPrepositionParameterInput,
+  applyProtectionInput,
+  applyProtectionSwitchChange,
+  applyReadValues,
+  applySpeedLoopExtraInput,
+  applyTailwindSwitchChange,
+  createInitialState,
+  refreshState
+}

+ 389 - 0
utils/params-service.js

@@ -0,0 +1,389 @@
+const {
+  buildReadFrame,
+  buildWriteMultipleRegistersFrame,
+  buildWriteSingleCoilFrame,
+  MAX_READ_COIL_QUANTITY,
+  MAX_READ_REGISTER_QUANTITY
+} = require('./modbus-rtu')
+const paramsPageState = require('./params-page-state')
+const transport = require('./ble-transport')
+const {
+  addCoilReadValues,
+  addWordReadValues,
+  floatToWords
+} = 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 !== '--'
+}
+
+function toWriteNumber(value) {
+  if (!hasWriteValue(value)) return null
+
+  const numberValue = Number(value)
+  if (!Number.isFinite(numberValue)) return null
+
+  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()
+  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 readValues = {
+    coils: {},
+    words: {}
+  }
+  let sent = false
+
+  for (const span of splitReadSpans(makeReadSpans(coilItems), MAX_READ_COIL_QUANTITY)) {
+    sent = true
+    const response = await transport.sendManagedFrame(
+      buildReadFrame(slaveAddress, 0x01, span.address, span.quantity),
+      '参数读取',
+      {
+        address: span.address,
+        functionCode: 0x01,
+        kind: 'params-read',
+        quantity: span.quantity,
+        slaveAddress
+      }
+    )
+    addCoilReadValues(readValues, span.address, span.quantity, response)
+  }
+
+  for (const span of splitReadSpans(makeReadSpans(holdingItems), MAX_READ_REGISTER_QUANTITY)) {
+    sent = true
+    const response = await transport.sendManagedFrame(
+      buildReadFrame(slaveAddress, 0x03, span.address, span.quantity),
+      '参数读取',
+      {
+        address: span.address,
+        functionCode: 0x03,
+        kind: 'params-read',
+        quantity: span.quantity,
+        slaveAddress
+      }
+    )
+    addWordReadValues(readValues, span.address, response)
+  }
+
+  for (const span of splitReadSpans(makeReadSpans(inputItems), MAX_READ_REGISTER_QUANTITY)) {
+    sent = true
+    const response = await transport.sendManagedFrame(
+      buildReadFrame(slaveAddress, 0x04, span.address, span.quantity),
+      '参数读取',
+      {
+        address: span.address,
+        functionCode: 0x04,
+        kind: 'params-read',
+        quantity: span.quantity,
+        slaveAddress
+      }
+    )
+    addWordReadValues(readValues, span.address, response)
+  }
+
+  if (!sent) {
+    transport.showCommandAlert('参数读取', '当前分组没有可读取的寄存器')
+    return false
+  }
+
+  if (!Object.keys(readValues.coils).length && !Object.keys(readValues.words).length) {
+    return false
+  }
+
+  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 = {}
+
+  items.forEach((item) => {
+    if (getAreaKey(item) !== 'holding') return
+
+    if (item.type === 'uint8_t' && item.bytePosition) {
+      const address = parseAddress(item.address)
+      const group = byteGroups[address] || {
+        address,
+        high: null,
+        low: null
+      }
+      group[item.bytePosition] = item
+      byteGroups[address] = group
+      return
+    }
+
+    const writeNumber = toWriteNumber(item.writeValue)
+    if (writeNumber === null) return
+
+    const words = item.type === 'float'
+      ? floatToWords(writeNumber)
+      : [toWord(writeNumber)]
+
+    if (!words || words.some((word) => word === null)) return
+
+    normalEntries.push({
+      address: parseAddress(item.address),
+      label: item.name,
+      values: words
+    })
+  })
+
+  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
+
+    if (highValue === null && lowValue === null) continue
+
+    let baseWord = 0
+    if (highValue === null || lowValue === null) {
+      const readWord = await readSingleHoldingWord(slaveAddress, group.address)
+      if (!Number.isInteger(readWord)) continue
+
+      baseWord = readWord
+    }
+
+    const nextHigh = highValue === null ? ((baseWord >> 8) & 0xFF) : highValue
+    const nextLow = lowValue === null ? (baseWord & 0xFF) : lowValue
+
+    if (nextHigh > 0xFF || nextLow > 0xFF) continue
+
+    normalEntries.push({
+      address: group.address,
+      label: '8位参数',
+      values: [(nextHigh << 8) | nextLow]
+    })
+  }
+
+  return normalEntries.sort((left, right) => left.address - right.address)
+}
+
+async function writeGroup(data, groupKey) {
+  const slaveAddress = 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')
+  let sent = false
+
+  for (const item of coilItems) {
+    const checked = Number(item.writeValue) !== 0
+    const address = parseAddress(item.address)
+
+    sent = true
+    const response = await transport.sendManagedFrame(
+      buildWriteSingleCoilFrame(slaveAddress, address, checked),
+      item.name,
+      {
+        address,
+        functionCode: 0x05,
+        kind: 'params-coil-write',
+        quantity: 1,
+        value: checked ? 0xFF00 : 0x0000,
+        slaveAddress
+      }
+    )
+    if (!response) return false
+  }
+
+  const holdingEntries = await buildHoldingWriteEntries(slaveAddress, holdingItems)
+  for (const entry of holdingEntries) {
+    sent = true
+    const response = await transport.sendManagedFrame(
+      buildWriteMultipleRegistersFrame(slaveAddress, entry.address, entry.values),
+      entry.label,
+      {
+        address: entry.address,
+        functionCode: 0x10,
+        kind: 'params-holding-write',
+        quantity: entry.values.length,
+        slaveAddress
+      }
+    )
+    if (!response) return false
+  }
+
+  if (!sent) {
+    transport.showCommandAlert('参数写入', '当前分组没有可写入的参数')
+  }
+
+  return sent
+}
+
+async function writeSwitchRegister(item) {
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null || !item) return false
+
+  const address = parseAddress(item.address)
+  const checked = Number(item.writeValue) !== 0
+
+  return transport.sendManagedFrame(
+    buildWriteSingleCoilFrame(slaveAddress, address, checked),
+    item.name,
+    {
+      address,
+      functionCode: 0x05,
+      kind: 'params-switch-write',
+      quantity: 1,
+      value: checked ? 0xFF00 : 0x0000,
+      slaveAddress
+    }
+  )
+}
+
+module.exports = {
+  readGroup,
+  writeGroup,
+  writeSwitchRegister
+}

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

@@ -0,0 +1,86 @@
+const {
+  toFiniteNumber
+} = require('./calculation-context')
+
+function toAddressKey(address) {
+  if (typeof address === 'number' && Number.isFinite(address)) {
+    return Math.round(address).toString(16).toUpperCase()
+  }
+
+  const text = String(address || '').trim().toUpperCase()
+  if (!text) return ''
+
+  const hexText = text.startsWith('0X') ? text.slice(2) : text
+  if (/^[0-9A-F]+$/.test(hexText)) {
+    return parseInt(hexText, 16).toString(16).toUpperCase()
+  }
+
+  const numberValue = Number(text)
+  if (Number.isFinite(numberValue)) {
+    return Math.round(numberValue).toString(16).toUpperCase()
+  }
+
+  return text
+}
+
+function wordsToFloat(highWord, lowWord) {
+  const highValue = Number(highWord)
+  const lowValue = Number(lowWord)
+  if (!Number.isInteger(highValue) || !Number.isInteger(lowValue)) return null
+
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  view.setUint16(0, highValue & 0xFFFF, false)
+  view.setUint16(2, lowValue & 0xFFFF, false)
+
+  return view.getFloat32(0, false)
+}
+
+function floatToWords(value) {
+  const numberValue = toFiniteNumber(value, NaN)
+  if (!Number.isFinite(numberValue)) return null
+
+  const buffer = new ArrayBuffer(4)
+  const view = new DataView(buffer)
+
+  view.setFloat32(0, numberValue, false)
+
+  return [view.getUint16(0, false), view.getUint16(2, false)]
+}
+
+function addCoilReadValues(readValues, startAddress, quantity, response) {
+  if (!readValues || !readValues.coils || !response || !Array.isArray(response.dataBytes)) return
+
+  for (let offset = 0; offset < quantity; offset += 1) {
+    const byte = response.dataBytes[Math.floor(offset / 8)] || 0
+    const bit = (byte >> (offset % 8)) & 0x01
+    readValues.coils[toAddressKey(startAddress + offset)] = bit
+  }
+}
+
+function addWordReadValues(readValues, startAddress, response) {
+  if (!readValues || !readValues.words || !response || !Array.isArray(response.words)) return
+
+  response.words.forEach((word, index) => {
+    readValues.words[toAddressKey(startAddress + index)] = Number(word) & 0xFFFF
+  })
+}
+
+function getRegisterWordCache(startAddress, words) {
+  if (!Array.isArray(words)) return {}
+
+  return words.reduce((result, word, index) => {
+    result[startAddress + index] = Number(word) & 0xFFFF
+    return result
+  }, {})
+}
+
+module.exports = {
+  addCoilReadValues,
+  addWordReadValues,
+  floatToWords,
+  getRegisterWordCache,
+  toAddressKey,
+  wordsToFloat
+}

+ 440 - 0
utils/registers.js

@@ -0,0 +1,440 @@
+const MODBUS_AREAS = {
+  coil: {
+    key: 'coil',
+    label: '线圈',
+    readFunction: '01',
+    writeFunction: '05 / 0F',
+    access: '读写'
+  },
+  input: {
+    key: 'input',
+    label: '输入寄存器',
+    readFunction: '04',
+    writeFunction: '--',
+    access: '只读'
+  },
+  holding: {
+    key: 'holding',
+    label: '保持寄存器',
+    readFunction: '03',
+    writeFunction: '06 / 10',
+    access: '读写'
+  }
+}
+
+const BYTE_POSITIONS = {
+  high: {
+    label: '高8位',
+    shift: 8
+  },
+  low: {
+    label: '低8位',
+    shift: 0
+  }
+}
+
+function hex(address) {
+  return `0x${address}`
+}
+
+function getRegisterCount(item) {
+  return item.registerCount || (item.type === 'float' ? 2 : 1)
+}
+
+function getAddressDisplay(address, registerCount) {
+  if (registerCount <= 1) return hex(address)
+
+  const start = parseInt(address, 16)
+  const end = start + registerCount - 1
+
+  return `${hex(address)}-${hex(end.toString(16).toUpperCase())}`
+}
+
+function withArea(items, areaKey, category) {
+  return items.map((item) => ({
+    ...item,
+    ...(() => {
+      const byteMeta = item.bytePosition ? BYTE_POSITIONS[item.bytePosition] : null
+      const byteLabel = item.byteLabel || (byteMeta ? byteMeta.label : '')
+      const registerCount = getRegisterCount(item)
+      const addressDisplay = byteLabel
+        ? `${getAddressDisplay(item.address, registerCount)} ${byteLabel}`
+        : getAddressDisplay(item.address, registerCount)
+
+      return {
+        byteLabel,
+        byteShift: byteMeta ? byteMeta.shift : null,
+        category,
+        addressText: hex(item.address),
+        addressDisplay,
+        registerCount,
+        area: MODBUS_AREAS[areaKey]
+      }
+    })()
+  }))
+}
+
+const OBS_ADDRESSES = {
+  EK1: '30',
+  EK2: '31',
+  EK3: '32',
+  EK4: '33',
+  FOC_KFG: '34',
+  SPEED_KLPF: '35',
+  OBS_FBASE: '36',
+  OBS_EA_KS: '37',
+  OBS_KP_START: '38',
+  OBS_KI_START: '39',
+  OBS_KP_RUN1: '3A',
+  OBS_KI_RUN1: '3B',
+  OBS_KP_RUN2: '3C',
+  OBS_KI_RUN2: '3D',
+  OBS_KP_RUN3: '3E',
+  OBS_KI_RUN3: '3F',
+  OBS_KP_RUN4: '40',
+  OBS_KI_RUN4: '41',
+  DQ_KP_START: '42',
+  DQ_KI_START: '43',
+  DQ_KP_RUN: '44',
+  DQ_KI_RUN: '45',
+  TAILWIND_SPEED_KLPF: '46',
+  TAILWIND_OBS_EA_KS: '47',
+  TAILWIND_OBS_KP: '48',
+  TAILWIND_OBS_KI: '49',
+  ALIGN_ANGLE: '4A'
+}
+
+const CONFIG_ADDRESSES = {
+  LD: '60',
+  LQ: '62',
+  RS: '64',
+  POLE_PAIRS: '66',
+  SPEED_BASE: '67',
+  SPEED_CTRL: '68',
+  CURVE_MAX_SPEED: '69',
+  CURVE_MIN_SPEED: '6A',
+  SOUT_MAX: '6B',
+  START_RAMP_INC: '6C',
+  START_RAMP_DEC: '6E',
+  RUN_RAMP_INC: '70',
+  RUN_RAMP_DEC: '72',
+  START_VOLT: '74',
+  STOP_VOLT: '75',
+  CURVE_MAX_VOLT: '76',
+  CURVE_MIN_VOLT: '77',
+  SPEED_CURVE_K: '78',
+  OIL_SPEED: '7A',
+  OIL_TIME: '7B'
+}
+
+const controlButtonRegisters = withArea([
+  { key: 'protocol', address: '00', type: 'uint8_t', name: 'Modbus控制', writeValue: 1, nextName: 'VSP控制', nextWriteValue: 0 },
+  { key: 'direction', address: '02', type: 'uint8_t', name: '正转', writeValue: 0, nextName: '反转', nextWriteValue: 1 },
+  { key: 'power', address: '01', type: 'uint8_t', name: '开机', writeValue: 1, nextName: '关机', nextWriteValue: 0 },
+  { key: 'save', address: '03', type: 'uint8_t', name: '固化', writeValue: 1, momentary: true },
+  { key: 'reset', address: '04', type: 'uint8_t', name: '复位', writeValue: 1, momentary: true }
+], 'coil', '控制类寄存器')
+
+const speedCommandRegister = withArea([
+  { address: CONFIG_ADDRESSES.SPEED_CTRL, type: 'uint16_t', name: '转速命令', unit: 'RPM', inputValue: '', writeValue: '--' }
+], 'holding', '参数配置')[0]
+
+const tailwindSwitchRegisters = withArea([
+  { address: '05', type: 'uint8_t', name: '顺逆风启用', value: false, writeValue: 0 },
+  { address: '06', type: 'uint8_t', name: '预定位启用', value: false, writeValue: 0 }
+], 'coil', '顺逆风控制')
+
+const protectionSwitchRegisters = withArea([
+  { address: '07', type: 'uint8_t', name: '保护使能', value: false, writeValue: 0 },
+  { address: '08', type: 'uint8_t', name: '恢复使能', value: false, writeValue: 0 },
+  { address: '09', type: 'uint8_t', name: '电压保护使能', value: false, writeValue: 0 },
+  { address: '0A', type: 'uint8_t', name: '电流保护使能', value: false, writeValue: 0 },
+  { address: '0B', type: 'uint8_t', name: '堵转保护使能', value: false, writeValue: 0 },
+  { 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', '保护控制')
+
+const estimatorRegisters = withArea([
+  { address: OBS_ADDRESSES.EK1, type: 'uint16_t', name: 'OBS_E1K', conversion: '2047 * 3.0 / 125.0 * LQ / TPWM_VALUE * 电流基准 / 电压基准' },
+  { address: OBS_ADDRESSES.EK2, type: 'uint16_t', name: 'OBS_E2K', conversion: '2047 * 0.8 * RS * 电流基准 / 电压基准' },
+  { address: OBS_ADDRESSES.EK3, type: 'uint16_t', name: 'OBS_E3K', conversion: '255 * 2.5' },
+  { address: OBS_ADDRESSES.EK4, type: 'uint16_t', name: 'OBS_E4K', conversion: '((LD - LQ) * TPWM_VALUE * MAX_OMEGA_RAD_SEC) / (LD + RS * TPWM_VALUE)' },
+  { address: OBS_ADDRESSES.FOC_KFG, type: 'uint16_t', name: 'FOC_KFG', conversion: 'TIM4频率 / BASE_FREQ / FG_K * 极对数' },
+  { address: OBS_ADDRESSES.SPEED_KLPF, type: 'uint16_t', name: 'SPEED_KLPF', conversion: '32767 * 2PI * SPD_BW * TPWM_VALUE' },
+  { address: OBS_ADDRESSES.OBS_FBASE, type: 'uint16_t', name: 'OBS_FBASE', conversion: '32767 * TPWM_VALUE' },
+  { address: OBS_ADDRESSES.OBS_EA_KS, type: 'uint16_t', name: 'OBS_EA_KS', conversion: '32767 * 2 * SMOMIN * 2PI * BASE_FREQ * TPWM_VALUE / 速度基准' },
+  { address: OBS_ADDRESSES.OBS_KP_START, type: 'uint16_t', name: 'OBS_KP_START', conversion: '4095 * 2PI * ATT_COEF * ATO_BW / BASE_FREQ' },
+  { address: OBS_ADDRESSES.OBS_KI_START, type: 'uint16_t', name: 'OBS_KI_START', conversion: '32767 * 2PI * ATO_BW * ATO_BW * TPWM_VALUE / BASE_FREQ' },
+  { address: OBS_ADDRESSES.OBS_KP_RUN1, type: 'uint16_t', name: 'OBS_KP_RUN1' },
+  { address: OBS_ADDRESSES.OBS_KI_RUN1, type: 'uint16_t', name: 'OBS_KI_RUN1' },
+  { address: OBS_ADDRESSES.OBS_KP_RUN2, type: 'uint16_t', name: 'OBS_KP_RUN2' },
+  { address: OBS_ADDRESSES.OBS_KI_RUN2, type: 'uint16_t', name: 'OBS_KI_RUN2' },
+  { address: OBS_ADDRESSES.OBS_KP_RUN3, type: 'uint16_t', name: 'OBS_KP_RUN3' },
+  { address: OBS_ADDRESSES.OBS_KI_RUN3, type: 'uint16_t', name: 'OBS_KI_RUN3' },
+  { address: OBS_ADDRESSES.OBS_KP_RUN4, type: 'uint16_t', name: 'OBS_KP_RUN4' },
+  { address: OBS_ADDRESSES.OBS_KI_RUN4, type: 'uint16_t', name: 'OBS_KI_RUN4' },
+  { address: OBS_ADDRESSES.DQ_KP_START, type: 'uint16_t', name: 'DQ_KP_START' },
+  { address: OBS_ADDRESSES.DQ_KI_START, type: 'uint16_t', name: 'DQ_KI_START' },
+  { address: OBS_ADDRESSES.DQ_KP_RUN, type: 'uint16_t', name: 'DQ_KP_RUN' },
+  { address: OBS_ADDRESSES.DQ_KI_RUN, type: 'uint16_t', name: 'DQ_KI_RUN' },
+  { address: OBS_ADDRESSES.TAILWIND_SPEED_KLPF, type: 'uint16_t', name: 'SPEED_KLPF_TAILWIND' },
+  { address: OBS_ADDRESSES.TAILWIND_OBS_EA_KS, type: 'uint16_t', name: 'OBS_EA_KS_TAILWIND' },
+  { address: OBS_ADDRESSES.TAILWIND_OBS_KP, type: 'uint16_t', name: 'OBS_KP_TAILWIND' },
+  { address: OBS_ADDRESSES.TAILWIND_OBS_KI, type: 'uint16_t', name: 'OBS_KI_TAILWIND' },
+  { address: OBS_ADDRESSES.ALIGN_ANGLE, type: 'uint16_t', name: '预定位角度' }
+], 'holding', '估算器配置参数')
+
+const atoBandwidthInputRegisters = [
+  { suffix: 'START', name: 'ATO_BW_START', kpAddress: OBS_ADDRESSES.OBS_KP_START, kiAddress: OBS_ADDRESSES.OBS_KI_START, kpName: 'OBS_KP_START', kiName: 'OBS_KI_START' },
+  { suffix: 'RUN1', name: 'ATO_BW_RUN1', kpAddress: OBS_ADDRESSES.OBS_KP_RUN1, kiAddress: OBS_ADDRESSES.OBS_KI_RUN1, kpName: 'OBS_KP_RUN1', kiName: 'OBS_KI_RUN1' },
+  { suffix: 'RUN2', name: 'ATO_BW_RUN2', kpAddress: OBS_ADDRESSES.OBS_KP_RUN2, kiAddress: OBS_ADDRESSES.OBS_KI_RUN2, kpName: 'OBS_KP_RUN2', kiName: 'OBS_KI_RUN2' },
+  { suffix: 'RUN3', name: 'ATO_BW_RUN3', kpAddress: OBS_ADDRESSES.OBS_KP_RUN3, kiAddress: OBS_ADDRESSES.OBS_KI_RUN3, kpName: 'OBS_KP_RUN3', kiName: 'OBS_KI_RUN3' },
+  { suffix: 'RUN4', name: 'ATO_BW_RUN4', kpAddress: OBS_ADDRESSES.OBS_KP_RUN4, kiAddress: OBS_ADDRESSES.OBS_KI_RUN4, kpName: 'OBS_KP_RUN4', kiName: 'OBS_KI_RUN4' },
+  { suffix: 'TAILWIND', name: 'ATO_BW_TAILWIND', kpAddress: OBS_ADDRESSES.TAILWIND_OBS_KP, kiAddress: OBS_ADDRESSES.TAILWIND_OBS_KI, kpName: 'OBS_KP_TAILWIND', kiName: 'OBS_KI_TAILWIND' }
+].map((item) => ({
+  ...item,
+  type: 'float',
+  inputValue: '',
+  writeValue: '--',
+  kpWriteValue: '--',
+  kiWriteValue: '--',
+  category: '估算器配置参数',
+  addressDisplay: `${hex(item.kpAddress)}/${hex(item.kiAddress)}`,
+  area: MODBUS_AREAS.holding
+}))
+
+const dqGainInputRegisters = withArea([
+  { address: OBS_ADDRESSES.DQ_KP_START, type: 'uint16_t', name: 'DQ_KP_START', protocolName: 'DQ_KP_START', gainType: 'kp' },
+  { address: OBS_ADDRESSES.DQ_KI_START, type: 'uint16_t', name: 'DQ_KI_START', protocolName: 'DQ_KI_START', gainType: 'ki' },
+  { address: OBS_ADDRESSES.DQ_KP_RUN, type: 'uint16_t', name: 'DQ_KP_RUN', protocolName: 'DQ_KP_RUN', gainType: 'kp' },
+  { address: OBS_ADDRESSES.DQ_KI_RUN, type: 'uint16_t', name: 'DQ_KI_RUN', protocolName: 'DQ_KI_RUN', gainType: 'ki' }
+], 'holding', '估算器配置参数').map((item) => ({
+  ...item,
+  inputValue: '',
+  writeValue: '--'
+}))
+
+const parameterRegisters = withArea([
+  { address: CONFIG_ADDRESSES.LD, type: 'float', name: 'LD', unit: 'H', step: '0.000001' },
+  { address: CONFIG_ADDRESSES.LQ, type: 'float', name: 'LQ', unit: 'H', step: '0.000001' },
+  { address: CONFIG_ADDRESSES.RS, type: 'float', name: 'RS', unit: 'Ω', step: '0.0001' },
+  { address: CONFIG_ADDRESSES.POLE_PAIRS, type: 'uint16_t', name: '极对数' },
+  { address: CONFIG_ADDRESSES.SPEED_BASE, type: 'uint16_t', name: '速度基准', unit: 'RPM' },
+  { address: CONFIG_ADDRESSES.CURVE_MAX_SPEED, type: 'uint16_t', name: '速度最大值', unit: 'RPM' },
+  { address: CONFIG_ADDRESSES.CURVE_MIN_SPEED, type: 'uint16_t', name: '速度最小值', unit: 'RPM' },
+  { address: CONFIG_ADDRESSES.SOUT_MAX, type: 'uint16_t', name: 'SOUT_MAX', unit: 'A' },
+  { address: CONFIG_ADDRESSES.START_RAMP_INC, type: 'float', name: '启动加速加速度', unit: 'RPM/S' },
+  { address: CONFIG_ADDRESSES.START_RAMP_DEC, type: 'float', name: '启动减速加速度', unit: 'RPM/S' },
+  { address: CONFIG_ADDRESSES.RUN_RAMP_INC, type: 'float', name: '运行加速加速度', unit: 'RPM/S' },
+  { address: CONFIG_ADDRESSES.RUN_RAMP_DEC, type: 'float', name: '运行减速加速度', unit: 'RPM/S' },
+  { address: CONFIG_ADDRESSES.START_VOLT, type: 'uint16_t', name: '开机电压', unit: 'V' },
+  { address: CONFIG_ADDRESSES.STOP_VOLT, type: 'uint16_t', name: '关机电压', unit: 'V' },
+  { address: CONFIG_ADDRESSES.CURVE_MAX_VOLT, type: 'uint16_t', name: '调速最高电压', unit: 'V' },
+  { address: CONFIG_ADDRESSES.CURVE_MIN_VOLT, type: 'uint16_t', name: '调速最低电压', unit: 'V' },
+  { address: CONFIG_ADDRESSES.SPEED_CURVE_K, type: 'float', name: '调速曲线斜率' },
+  { address: CONFIG_ADDRESSES.OIL_SPEED, type: 'uint16_t', name: '上油转速', unit: 'RPM' },
+  { address: CONFIG_ADDRESSES.OIL_TIME, type: 'uint16_t', name: '上油时间', unit: 's' }
+], 'holding', '参数配置')
+
+const parameterInputNames = [
+  'LD',
+  'LQ',
+  'RS',
+  '极对数',
+  '速度基准'
+]
+
+const configParameterInputNames = [
+  '开机电压',
+  '关机电压',
+  '速度最小值',
+  '速度最大值',
+  'SOUT_MAX',
+  '调速最低电压',
+  '调速最高电压'
+]
+
+const speedLoopExtraInputNames = [
+  '启动加速加速度',
+  '启动减速加速度',
+  '运行加速加速度',
+  '运行减速加速度'
+]
+
+const oilParameterInputNames = [
+  '上油转速',
+  '上油时间'
+]
+
+const prepositionParameterInputNames = [
+  '预定位角度'
+]
+
+const parameterByName = parameterRegisters.reduce((result, item) => {
+  result[item.name] = item
+  return result
+}, {})
+const motorParameterInputRegisters = parameterInputNames
+  .map((name) => parameterByName[name])
+  .filter(Boolean)
+  .map((item) => ({
+    ...item,
+    inputValue: item.inputValue || '',
+    writeValue: item.writeValue || '--'
+  }))
+
+const parameterInputRegisters = configParameterInputNames
+  .map((name) => parameterByName[name])
+  .filter(Boolean)
+  .map((item) => ({
+    ...item,
+    inputValue: item.inputValue || '',
+    writeValue: item.writeValue || '--'
+  }))
+
+const speedSlopeRegister = {
+  ...parameterByName['调速曲线斜率'],
+  inputValue: '',
+  writeValue: '--'
+}
+
+const speedLoopExtraRegisters = speedLoopExtraInputNames
+  .map((name) => parameterByName[name])
+  .filter(Boolean)
+  .map((item) => ({
+    ...item,
+    inputValue: '',
+    writeValue: '--'
+  }))
+
+const oilParameterInputRegisters = oilParameterInputNames
+  .map((name) => parameterByName[name])
+  .filter(Boolean)
+  .map((item) => ({
+    ...item,
+    inputValue: '',
+    writeValue: '--'
+  }))
+
+const prepositionParameterInputRegisters = prepositionParameterInputNames
+  .map((name) => estimatorRegisters.find((item) => item.name === name))
+  .filter(Boolean)
+  .map((item) => ({
+    ...item,
+    inputValue: '',
+    writeValue: '--'
+  }))
+
+function isAtoGainRegister(item) {
+  return item.name.startsWith('OBS_KP_') || item.name.startsWith('OBS_KI_')
+}
+
+function isDqGainRegister(item) {
+  return item.name.startsWith('DQ_KP_') || item.name.startsWith('DQ_KI_')
+}
+
+const calculatedParameterRegisters = [
+  ...estimatorRegisters.filter((item) => !isAtoGainRegister(item) && !isDqGainRegister(item) && !prepositionParameterInputNames.includes(item.name)),
+  ...parameterRegisters.filter((item) => !parameterInputNames.includes(item.name)
+    && !configParameterInputNames.includes(item.name)
+    && !speedLoopExtraInputNames.includes(item.name)
+    && !oilParameterInputNames.includes(item.name)
+    && !prepositionParameterInputNames.includes(item.name)
+    && item.name !== '调速曲线斜率')
+].map((item) => ({
+  ...item,
+  inputValue: '',
+  writeValue: '--'
+}))
+
+const protectionRegisters = withArea([
+  { address: '7C', type: 'uint16_t', name: '硬件过流值', unit: 'A' },
+  { address: '7D', type: 'uint16_t', name: '软件过流值', unit: 'A', conversion: '32767 * 限制值 / 电流基准' },
+  { address: '7E', type: 'uint16_t', name: '过压保护值', unit: 'V', conversion: '32767 * 限制值 / 电压采样最大值' },
+  { address: '7F', type: 'uint16_t', name: '欠压保护值', unit: 'V' },
+  { address: '80', type: 'uint16_t', name: '过压恢复值', unit: 'V' },
+  { address: '81', type: 'uint16_t', name: '欠压恢复值', unit: 'V' },
+  { address: '82', type: 'uint16_t', name: '速度限制最大值', unit: 'RPM', conversion: '32767 * 限制值 / 速度基准' },
+  { address: '83', type: 'uint16_t', name: '速度限制最小值', unit: 'RPM' },
+  { address: '84', type: 'uint16_t', name: '反电动势低阈值' },
+  { 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: '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: '8D', type: 'uint16_t', name: '串口丢失检测时间', unit: 'ms' }
+], 'holding', '保护配置')
+
+const readonlyParamRegisters = withArea([
+  { address: 'A8', type: 'ascii', name: '芯片型号', displayValue: '--', registerCount: 4, hideMeta: true },
+  { address: 'AC', type: 'ascii', name: '型号', displayValue: '--', registerCount: 8, hideMeta: true },
+  { address: 'A0', type: 'uint8_t', name: '载波频率', unit: 'KHz', displayValue: '--', bytePosition: 'high' },
+  { address: 'A0', type: 'uint8_t', name: '基准电压', unit: 'V', displayValue: '--', bytePosition: 'low' },
+  { address: 'A1', type: 'uint16_t', name: '运放倍数', displayValue: '--' },
+  { address: 'A2', type: 'uint16_t', name: '采样电阻', unit: 'mΩ', displayValue: '--' },
+  { address: 'A3', type: 'uint16_t', name: '全区 Flash 校验码', displayValue: '--' },
+  { address: 'A4', type: 'float', name: '母线电压分压比', displayValue: '--' },
+  { address: 'A6', type: 'float', name: '模拟输入电压分压比', displayValue: '--' }
+], 'input', '只读参数寄存器')
+
+const userStatusRegisters = Array.from({ length: 10 }, (_, index) => ({
+  address: (0xD3 + index).toString(16).toUpperCase(),
+  type: 'uint16_t',
+  name: `用户状态字 ${index + 1}`
+}))
+
+const statusRegisters = withArea([
+  { address: 'C0', type: 'uint8_t', name: '状态机', bytePosition: 'high' },
+  { address: 'C0', type: 'uint8_t', name: '故障码', bytePosition: 'low' },
+  { address: 'C1', type: 'int16_t', name: 'UQ' },
+  { address: 'C2', type: 'int16_t', name: 'UD' },
+  { address: 'C3', type: 'int16_t', name: 'IQ' },
+  { address: 'C4', type: 'int16_t', name: 'ID' },
+  { address: 'C5', type: 'int16_t', name: 'A 相电流' },
+  { address: 'C6', type: 'int16_t', name: 'B 相电流' },
+  { address: 'C7', type: 'int16_t', name: 'C 相电流' },
+  { address: 'C8', type: 'uint16_t', name: '相电流最大值' },
+  { address: 'C9', type: 'uint16_t', name: '相电流最小值' },
+  { address: 'CA', type: 'int16_t', name: '估算速度', unit: 'RPM' },
+  { address: 'CB', type: 'uint16_t', name: '估算反电动势' },
+  { 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: '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: '%' },
+  ...userStatusRegisters
+], 'input', '状态类寄存器')
+
+function getByteRegisterValue(item, wordValue) {
+  if (!item || item.type !== 'uint8_t' || !item.bytePosition) return wordValue
+
+  return (Number(wordValue) >> item.byteShift) & 0xFF
+}
+
+module.exports = {
+  controlButtonRegisters,
+  speedCommandRegister,
+  atoBandwidthInputRegisters,
+  dqGainInputRegisters,
+  oilParameterInputRegisters,
+  prepositionParameterInputRegisters,
+  motorParameterInputRegisters,
+  parameterInputRegisters,
+  calculatedParameterRegisters,
+  protectionSwitchRegisters,
+  protectionRegisters,
+  readonlyParamRegisters,
+  speedLoopExtraRegisters,
+  speedSlopeRegister,
+  statusRegisters,
+  tailwindSwitchRegisters,
+  getByteRegisterValue
+}

+ 106 - 0
utils/status-format.js

@@ -0,0 +1,106 @@
+const {
+  getFaultText
+} = require('./calculation-context')
+const {
+  calculateStatusValue,
+  formatFixedValue
+} = require('./conversions')
+const {
+  getByteRegisterValue
+} = require('./registers')
+
+const statusWordValues = {}
+
+const statusRawValues = {}
+
+const SYS_STATE_TEXT = {
+  0: '就绪状态',
+  1: '初始化',
+  2: '检测偏置电压',
+  3: '预充电',
+  4: '风向检测',
+  5: '初始位置检测',
+  6: '预定位',
+  7: '电机启动',
+  8: '电机运行',
+  9: '电机停止',
+  10: '刹车',
+  11: '故障'
+}
+
+const FORMULA_STATUS_NAMES = [
+  '母线电压',
+  '模拟输入电压',
+  '母线电流',
+  'NTC 电压',
+  '估算速度',
+  '估算功率',
+  '频率',
+  '占空比'
+]
+
+function formatStatusRegister(item) {
+  const rawValue = item.type === 'uint8_t' && item.bytePosition && statusWordValues[item.address] !== undefined
+    ? getByteRegisterValue(item, statusWordValues[item.address])
+    : statusRawValues[item.name]
+  const hasFormula = FORMULA_STATUS_NAMES.includes(item.name)
+  let displayValue = rawValue === undefined ? '--' : String(rawValue)
+
+  if (item.name === '故障码' && rawValue !== undefined) {
+    displayValue = getFaultText(rawValue)
+  }
+
+  if (item.name === '状态机' && rawValue !== undefined) {
+    displayValue = SYS_STATE_TEXT[rawValue] || `未知状态 ${rawValue}`
+  }
+
+  if (hasFormula && rawValue !== undefined) {
+    const calculatedValue = calculateStatusValue(item.name, rawValue)
+    displayValue = item.name === '频率' || item.name === '占空比'
+      ? formatFixedValue(calculatedValue, 1)
+      : formatFixedValue(calculatedValue, 2)
+  }
+
+  return {
+    ...item,
+    rawValue: rawValue === undefined ? '--' : rawValue,
+    displayUnit: displayValue === '--' ? '' : item.displayUnit || item.unit || '',
+    displayValue
+  }
+}
+
+function formatStatusRegisters(registers) {
+  return registers.map(formatStatusRegister)
+}
+
+function toSigned16(value) {
+  const wordValue = Number(value) & 0xFFFF
+
+  return wordValue > 0x7FFF ? wordValue - 0x10000 : wordValue
+}
+
+function updateStatusRegisterWords(registers, startAddress, words) {
+  const start = Number(startAddress)
+
+  registers.forEach((item) => {
+    const offset = parseInt(item.address, 16) - start
+    if (offset < 0 || offset >= words.length) return
+
+    const wordValue = Number(words[offset]) & 0xFFFF
+    statusWordValues[item.address] = wordValue
+
+    if (item.type === 'uint8_t' && item.bytePosition) {
+      statusRawValues[item.name] = getByteRegisterValue(item, wordValue)
+      return
+    }
+
+    statusRawValues[item.name] = item.type === 'int16_t' ? toSigned16(wordValue) : wordValue
+  })
+
+  return formatStatusRegisters(registers)
+}
+
+module.exports = {
+  formatStatusRegisters,
+  updateStatusRegisterWords
+}

+ 16 - 0
utils/status-page-state.js

@@ -0,0 +1,16 @@
+const {
+  statusRegisters
+} = require('./registers')
+const {
+  formatStatusRegisters
+} = require('./status-format')
+
+function getStatusPageState() {
+  return {
+    statusRegisters: formatStatusRegisters(statusRegisters)
+  }
+}
+
+module.exports = {
+  getStatusPageState
+}

+ 211 - 0
utils/sync-service.js

@@ -0,0 +1,211 @@
+const {
+  buildReadFrame,
+  getMaxReadQuantity
+} = require('./modbus-rtu')
+const controlState = require('./control-page-state')
+const controlService = require('./control-service')
+const paramsPageState = require('./params-page-state')
+const transport = require('./ble-transport')
+const {
+  notifyPageToast
+} = require('./page-toast')
+const {
+  addCoilReadValues,
+  addWordReadValues
+} = require('./register-value-utils')
+
+const readValues = {
+  coils: {},
+  words: {}
+}
+
+let paramsSnapshot = paramsPageState.createInitialState()
+let paramsSnapshotVersion = 0
+
+const READ_STEPS = [
+  {
+    address: 0x00,
+    functionCode: 0x01,
+    label: '同步线圈 00-10',
+    quantity: 17,
+    onResponse(response, step) {
+      addCoilReadValues(readValues, step.address, step.quantity, response)
+      controlService.applyControlReadValues(readValues.coils)
+    }
+  },
+  {
+    address: 0x30,
+    functionCode: 0x03,
+    label: '同步估算器参数 30-4A',
+    quantity: 27,
+    onResponse(response, step) {
+      addWordReadValues(readValues, step.address, response)
+    }
+  },
+  {
+    address: 0x60,
+    functionCode: 0x03,
+    label: '同步参数配置 60-8D',
+    quantity: 46,
+    onResponse(response, step) {
+      addWordReadValues(readValues, step.address, response)
+      controlService.applyMotorReadWords(response.words || [], step.address)
+    }
+  },
+  {
+    address: controlState.DRIVER_PARAM_START_ADDRESS,
+    functionCode: 0x04,
+    label: '同步驱动器硬件参数 A0-B3',
+    quantity: controlState.DRIVER_PARAM_WORD_COUNT,
+    onResponse(response, step) {
+      addWordReadValues(readValues, step.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)
+    }
+  }
+]
+
+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 = []
+
+function getState() {
+  return {
+    isSyncing: syncing,
+    syncVersion: paramsSnapshotVersion
+  }
+}
+
+function notify() {
+  const state = getState()
+
+  subscribers.slice().forEach((subscriber) => {
+    subscriber(state)
+  })
+}
+
+function setSyncing(value) {
+  syncing = !!value
+  notify()
+}
+
+transport.subscribe((transportState) => {
+  if (!transportState.connectedDevice && syncing) {
+    setSyncing(false)
+  }
+})
+
+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 getSharedSlaveAddress() {
+  try {
+    return transport.getSlaveAddress()
+  } catch (error) {
+    transport.showCommandAlert('从机地址错误', error.message)
+    return null
+  }
+}
+
+function resetReadValues() {
+  readValues.coils = {}
+  readValues.words = {}
+}
+
+function getParamsSnapshot() {
+  return {
+    ...paramsSnapshot,
+    syncVersion: paramsSnapshotVersion
+  }
+}
+
+async function syncAllRegisters() {
+  if (syncing) return false
+
+  const transportState = transport.getState()
+  if (!transportState.connectedDevice) return false
+
+  const slaveAddress = getSharedSlaveAddress()
+  if (slaveAddress === null) return false
+
+  setSyncing(true)
+  resetReadValues()
+
+  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
+          }
+        )
+
+        if (!response) return false
+        if (typeof chunk.onResponse === 'function') {
+          chunk.onResponse(response, chunk)
+        }
+      }
+    }
+
+    paramsSnapshot = paramsPageState.applyReadValues(paramsSnapshot, readValues)
+    paramsSnapshotVersion += 1
+    notify()
+    notifyPageToast('同步完成')
+    return true
+  } finally {
+    setSyncing(false)
+  }
+}
+
+module.exports = {
+  getParamsSnapshot,
+  getState,
+  subscribe,
+  syncAllRegisters
+}