Jelajahi Sumber

重构线架注册为地址段UID绑定扫描,新增运行数据备份恢复

- 读卡帧(0x0002)携带线架UID(请求20B/响应161B),uid=0探测实现新线架
  自动注册;旧固件149B响应兼容解析但不可用于绑定/接管
- 轮询改为按配置地址段顺序扫描(默认1-50,DTU配置页可改):已绑定带
  UID读卡兼作保活,缺失地址改探测以支持换架接管/原架恢复,移除广播
  自动发现循环
- 换架自动接管:新线架绑定原地址并继承panel_id与端口映射,期望卡号
  自动下发到硬件,端口状态静默对齐避免误报事件
- 新增「线架管理」页(绑定列表/解绑/扫描新线架),「发现终端」按钮改
  走地址段扫描接口
- 新增config_backup模块:写配置前快照+原子写,整机备份导出/导入(固
  化UID前缀保证换机卡号一致),配置损坏自动回退快照,新增「数据备份」页
- 补充线架注册与备份快照单元测试;同步更新AGENTS.md与MQTT协议文档
wenhongquan 18 jam lalu
induk
melakukan
fa6d57ef5e
30 mengubah file dengan 2631 tambahan dan 329 penghapusan
  1. 2 1
      .commandcode/taste/taste.md
  2. 3 0
      .gitignore
  3. 70 1
      AGENTS.md
  4. TEMPAT SAMPAH
      backend/__pycache__/app.cpython-310.pyc
  5. TEMPAT SAMPAH
      backend/__pycache__/config.cpython-310.pyc
  6. 859 194
      backend/app.py
  7. 6 0
      backend/config.py
  8. TEMPAT SAMPAH
      backend/modules/__pycache__/config_backup.cpython-310.pyc
  9. TEMPAT SAMPAH
      backend/modules/__pycache__/dht11_sensor.cpython-310.pyc
  10. TEMPAT SAMPAH
      backend/modules/__pycache__/modbus_rtu.cpython-310.pyc
  11. TEMPAT SAMPAH
      backend/modules/__pycache__/mqtt_client.cpython-310.pyc
  12. TEMPAT SAMPAH
      backend/modules/__pycache__/network_config.cpython-310.pyc
  13. TEMPAT SAMPAH
      backend/modules/__pycache__/serial_port.cpython-310.pyc
  14. 315 0
      backend/modules/config_backup.py
  15. 70 28
      backend/modules/modbus_rtu.py
  16. TEMPAT SAMPAH
      backend/tests/__pycache__/test_config_backup.cpython-310-pytest-9.0.2.pyc
  17. TEMPAT SAMPAH
      backend/tests/__pycache__/test_modbus_rtu.cpython-310-pytest-9.0.2.pyc
  18. TEMPAT SAMPAH
      backend/tests/__pycache__/test_panel_registration.cpython-310-pytest-9.0.2.pyc
  19. 173 0
      backend/tests/test_config_backup.py
  20. 76 11
      backend/tests/test_modbus_rtu.py
  21. 209 0
      backend/tests/test_panel_registration.py
  22. 6 0
      frontend/src/App.vue
  23. 50 1
      frontend/src/api/apiService.js
  24. 46 2
      frontend/src/components/DTUConfig.vue
  25. 19 6
      frontend/src/components/SerialConfig.vue
  26. 12 0
      frontend/src/router/index.js
  27. 401 0
      frontend/src/views/BackupRestore.vue
  28. 254 0
      frontend/src/views/PanelManage.vue
  29. 23 10
      frontend/src/views/RealTimeStatus.vue
  30. 37 75
      线架系统功能需求与MQTT协议设计.md

+ 2 - 1
.commandcode/taste/taste.md

@@ -1,7 +1,8 @@
 # Taste
 
 ## Communication
-- Communicates in Chinese and expects replies in Chinese. Confidence: 0.6
+- Communicates in Chinese and expects replies in Chinese. Confidence: 0.7
+- States feature requests as terse, numbered requirement lists (in Chinese), describing the desired outcome/user benefit rather than implementation details. Confidence: 0.5
 
 ## Workflow
 - Expects the agent to read and follow the project's AGENTS.md / agent instructions before acting (reminds the agent to check it). Confidence: 0.7

+ 3 - 0
.gitignore

@@ -7,3 +7,6 @@ frontend/dist
 # Local worktrees
 .worktrees/
 worktrees/
+
+# Runtime auto snapshots (dzxj_dtu config backups)
+backend/backups/

+ 70 - 1
AGENTS.md

@@ -194,9 +194,20 @@ journalctl -u dzxj_dtu | grep -E 'DHT11|环境传感器'
 | ----- | ------------ | -------------------------------------- |
 | 0x0000| 网络通断     | **保留不用**(已去除)                 |
 | 0x0001| 24 个 RGB 灯 | **保留不用**(灯由配线架自己控制)     |
-| 0x0002| 批量卡号读   | 0x03 读 72 寄存器 = 144 字节,24 路×6 字节 |
+| 0x0002| 批量卡号读   | 0x03 读 72 寄存器 = 144 字节,24 路×6 字节(**2026-10 起帧内携带线架 UID**,见下) |
 | 0x0003| 设置卡号     | 0x10 写 144 字节,24 路×6 字节         |
 
+**带 UID 批量读卡帧(2026-10-13 新增,注册模型基础,需线架固件同步支持!)**:
+
+- 请求 20B:`[addr][0x03][0x00][0x02][0x00][0x48][12字节线架UID][CRC_L][CRC_H]`
+  - UID = 该地址已绑定的线架 UID;未绑定时为 12 字节全 0(探测)
+- 响应 161B:`[addr][0x03][0x90][144字节卡数据][12字节线架UID][CRC_L][CRC_H]`
+  - UID = 线架自身 UID(探测响应时用于绑定"地址↔UID")
+- 未配置(新)线架收到 uid=0 探测帧后会**采纳该地址**并应答自身 UID(不再依赖 00 40/00 42 广播分配流程)
+- 实现:`modbus_rtu.py` 的 `build_read_cards_request(addr, uid)` 与 `read_all_antenna_cards(addr, uid=None)`;常量 `READ_WITH_UID_RESPONSE_LEN=161`
+- 兼容:旧固件 149B 响应仍可解析(`panel_uid=None`),但**不能用于绑定/换架接管**,仅是过渡期诊断
+- 旧广播流程(`00 40` 发现 / `00 42` 分配)仅在手动调试接口 `/api/modbus/broadcast_query`、`/api/modbus/auto_configure` 保留,自动流程已不使用
+
 **设置卡号协议(非标准 MODBUS 0x10,重点!)**:
 
 - 帧格式:`[addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]`
@@ -220,6 +231,57 @@ journalctl -u dzxj_dtu | grep -E 'DHT11|环境传感器'
 - 原「LED 调试」页面已改为**「卡号设置测试」**页面(路由 `/led-debug`,组件 `LedDebug.vue`)
 - 页面功能:选终端 + 选端口 + 输入 12 位 hex 卡号 + 设置/清除 + 读卡验证
 
+## 线架注册模型(2026-10-13 重构,重点)
+
+**模型**:DTU 按配置的**地址段**顺序轮询(默认 1-50,DTU 配置页可改,存 `dtu_config.json` 的 `address_range`),读卡帧带线架 UID;未绑定地址用 uid=0 探测,设备响应后绑定"地址↔UID"(绑定表仍是 `devices.json`,结构 `{uid: addr}` 不变)。
+
+**扫描状态机**(`app.py` 的 `_sweep_panel_addresses()`,一轮按地址升序):
+
+1. **已绑定且正常**:带绑定 UID 读卡(兼作线架保活)→ 成功走既有端口事件/告警逻辑;失败累计 miss
+2. **已绑定且失败过**(miss≥1,含离线/缺失):改用 **uid=0 探测**该地址 →
+   - 回的 UID 相同 → 原线架恢复在线
+   - 回的 UID 不同 → **换架自动接管**(见下)
+   - 无响应 → 继续缺失;连续失败 ≥3(`PANEL_MISS_THRESHOLD`)标记离线并发布离线状态
+3. **未绑定地址**:每轮轮转抽查 5 个(`DEFAULT_UNBOUND_PROBE_BATCH`,游标 `_unbound_probe_cursor`)→ 有响应则注册绑定;**若任一面板处于缺失/接管状态,本轮暂停未绑定探测**(防止新线架抢占非目标地址;旧线架拆走后要清幽灵绑定请用管理页删除)
+
+**换架自动接管**(`_takeover_panel()`)——更换单台线架无需重新匹配数据:
+
+- 新线架装到原地址后,DTU 探测到新 UID → 重绑地址↔新UID、**panel_id 不变**(继承 `port_mappings` 全部期望数据)
+- 自动把期望卡号 **下发到新线架硬件**(`set_card_mapping`),端口状态静默对齐(不误报事件)
+- 更新 `panel_config.panel_uid`,上报注册+面板状态(payload 含 `panel_uid`)
+- 自定义 `panel_id_mapping`(按 UID 映射)会自动补新 UID → 原 panel_id 的映射
+
+**管理页面/API**:
+
+- 新页面「线架管理」`/panel-manage`(`PanelManage.vue`):已注册线架表格(地址/UID/状态/最后响应/端口)+ 删除绑定 + 「扫描新线架」
+- `GET /api/modbus/stored_devices`:返回 `panels[]`(含 panel_id/status=online|missing|offline/last_seen/端口统计)+ `address_range` + `scan` 状态
+- `POST /api/panel/unbind {address|panel_id}`:解除绑定(**仅解除**;线架仍在总线上时下轮扫描会重新绑定)
+- `POST /api/panel/scan` / `GET /api/panel/scan_status`:手动全量扫描地址段内未绑定地址
+- 「实时状态」页「发现终端」按钮已改为调用新扫描接口
+- 已删除自动广播发现(`auto_discover_loop` / `run_discovery_sync` / `check_assigned_devices_online`);`confirm_loop`(0x44 保活,10s)**保留**(线架开机 30s 窗口需要)
+
+**注意事项**:
+
+- ⚠️ **多台全新线架同时上电可能总线冲突**(都抢第一个探测地址);首次部署建议逐台上电,或等固件加随机退避
+- ⚠️ 需线架固件支持"uid=0 探测自采纳地址 + 161B 响应";旧固件只能过渡诊断(149B 响应)
+- 绑定之外的地址(超地址段)如有绑定仍会轮询,但不再探测新地址
+- 相关常量:`config.py` 的 `DEFAULT_ADDRESS_RANGE` / `DEFAULT_UNBOUND_PROBE_BATCH` / `PANEL_MISS_THRESHOLD` / `PANEL_PROBE_TIMEOUT`
+
+## 运行数据备份 / 恢复(2026-10-13)
+
+**目的**:更换新 DTU 设备时一键还原全部运行数据,无需重新设置。
+
+- **整机备份包**(`GET /api/backup/export`,`?download=1` 下载)包含:
+  - `dtu_config.json`(身份/端口映射/地址段/`uid_prefix` 等)、`serial_config.json`、`mqtt_config.json`、`/root/dzxj_dtu/devices.json`(线架绑定表)
+  - `/etc/default/dzxj_dtu` 环境变量文件(可选,DHT11/串口屏等;**恢复后需重启服务生效**)
+  - `effective_uid_prefix`:导入时若配置无显式 `uid_prefix` 会**固化**它(换机后 MAC 变化不影响 jumper_uid 前缀,保证平台卡号一致)
+- **导入恢复**(`POST /api/backup/import`,支持上传文件/JSON,`dry_run=1` 预览)→ 写文件 + 内存生效 + 串口/MQTT 自动重连 + 绑定表重建 + 注册上报
+- **设备内自动快照**:每次写配置前快照到 `backend/backups/`(内容去重、30s 节流、每类保留 20 份);接口 `GET /api/backup/list`、`POST /api/backup/restore_snapshot`、`GET /api/backup/download?name=`、`POST /api/backup/delete`;配置文件损坏时加载会自动回退最新快照
+- **页面**:`/backup-restore`(菜单「数据备份」,`BackupRestore.vue`):导出下载 / 导入恢复(预览确认)/ 快照列表(恢复/下载/删除)
+- `POST /api/system/restart_service`:重启 `dzxj_dtu` 服务(恢复环境变量后用)
+- **换机流程**:旧机「数据备份」页导出下载 → 新机(同版本代码)导入恢复 → 若含环境变量文件则重启服务 → 线架地址保持不变,扫描自动重新绑定
+- 实现:`backend/modules/config_backup.py`(快照/原子写/bundle 校验)+ `app.py` 的 4 个 save 函数全部走"快照→原子写"
+
 ## 部署与更新
 
 - 代码在 `/root/dzxj_dtu/`,后端在 `backend/`,前端构建产物在 `frontend/dist/`。
@@ -248,6 +310,9 @@ journalctl -u dzxj_dtu | grep -E 'DHT11|环境传感器'
 | 温湿度读到 0.0 或报 gpiod 错 | 确认`dht11_reader` 二进制存在且可执行;`dht11_reader gpiochip0 19` 手测                                                         |
 | easytier 组网后虚拟 IP 不通  | `--no-tun` 模式限制,虚拟 IP 不进路由表,只能走 port-forward                                                                      |
 | MQTT 重启后没自动连          | 确认`/root/dzxj_dtu/backend/mqtt_config.json` 存在;存在则看 `auto_connect_mqtt` 日志                                           |
+| 新线架扫描不到               | 检查「DTU 配置」地址段是否覆盖其地址、串口是否连接;若「线架管理」中有"缺失待接管"的线架,未绑定探测会被暂停(删除其绑定或等接管完成) |
+| 换架后未自动接管             | 看日志是否"收到旧格式(149B)响应"——线架固件需支持带 UID 读卡帧(161B 响应);再查地址段是否包含该地址                                |
+| 配置被改坏/丢失              | 「数据备份」页从自动快照恢复(`backend/backups/`,每类保留 20 份)                                                                  |
 
 ## 注意事项(重要)
 
@@ -257,3 +322,7 @@ journalctl -u dzxj_dtu | grep -E 'DHT11|环境传感器'
 - 设备的 root 密码是 `123456`(本机/测试环境)。
 - 改 `app.py` 后必须重启 `dzxj_dtu` 才生效。
 - Web 管理页面端口 `5001`(Flask),前端由 nginx 提供 80 端口。
+- **线架注册/换架依赖固件**:需支持"uid=0 探测自采纳地址 + 161B 响应",旧固件只能过渡诊断。
+- **多台全新线架不要同时上电**(可能总线冲突抢地址),首次部署逐台上电。
+- **换机流程**:旧机导出备份 → 新机导入恢复(含环境变量时重启服务),详见「运行数据备份 / 恢复」。
+- **删除绑定 ≠ 注销线架**:线架仍在总线上会被重新绑定;要停用某台请同时断电/拆线。

TEMPAT SAMPAH
backend/__pycache__/app.cpython-310.pyc


TEMPAT SAMPAH
backend/__pycache__/config.cpython-310.pyc


File diff ditekan karena terlalu besar
+ 859 - 194
backend/app.py


+ 6 - 0
backend/config.py

@@ -119,6 +119,12 @@ DTU_BROADCAST_TOPIC = 'broadcast'
 MAX_PANELS = 8  # 最大面板数
 MAX_PORTS_PER_PANEL = 24  # 每个面板最大端口数
 
+# 线架地址段轮询 / UID 绑定(注册模型)
+DEFAULT_ADDRESS_RANGE = {'start': 1, 'end': 50}  # 默认轮询地址段
+DEFAULT_UNBOUND_PROBE_BATCH = 5                  # 每轮抽查的未绑定地址数量(轮转)
+PANEL_MISS_THRESHOLD = 3                         # 连续失败达阈值后改用 uid=0 探测(换架接管/原架恢复)
+PANEL_PROBE_TIMEOUT = 0.3                        # uid=0 探测超时(秒)
+
 # DHT11 环境传感器配置(本地 GPIO 读取)
 DHT11_ENABLED = os.getenv('DHT11_ENABLED', 'true').lower() in ('1', 'true', 'yes')
 DHT11_GPIO_PIN = int(os.getenv('DHT11_GPIO_PIN')) if os.getenv('DHT11_GPIO_PIN') else None

TEMPAT SAMPAH
backend/modules/__pycache__/config_backup.cpython-310.pyc


TEMPAT SAMPAH
backend/modules/__pycache__/dht11_sensor.cpython-310.pyc


TEMPAT SAMPAH
backend/modules/__pycache__/modbus_rtu.cpython-310.pyc


TEMPAT SAMPAH
backend/modules/__pycache__/mqtt_client.cpython-310.pyc


TEMPAT SAMPAH
backend/modules/__pycache__/network_config.cpython-310.pyc


TEMPAT SAMPAH
backend/modules/__pycache__/serial_port.cpython-310.pyc


+ 315 - 0
backend/modules/config_backup.py

@@ -0,0 +1,315 @@
+"""DTU 运行数据备份/快照工具。
+
+功能:
+- 原子写 JSON 配置(write_json_atomic)
+- 写前自动快照(snapshot_before_write): 内容变化才快照, 每类保留最近 N 份, 节流去重
+- 快照列表/读取/删除(list_snapshots/read_snapshot/delete_snapshot/find_latest_snapshot)
+- 整机备份包构建与校验(build_bundle/validate_bundle)
+
+整机备份包(bundle)结构:
+{
+  "format": "dzxj_dtu_backup",
+  "format_version": 1,
+  "created_at": "2026-10-13T14:00:00+0800",
+  "device": {"dtu_id": ..., "customer_id": ..., ...},
+  "effective_uid_prefix": "A1B2",       # 导入时固化, 保证换机后 jumper_uid 前缀不变
+  "files": {
+    "dtu_config": {...}, "serial_config": {...}, "mqtt_config": {...},
+    "devices": {...}, "system_env": "..."
+  }
+}
+"""
+import json
+import logging
+import os
+import re
+import time
+
+logger = logging.getLogger('serial_mqtt_gateway')
+
+BUNDLE_FORMAT = 'dzxj_dtu_backup'
+BUNDLE_FORMAT_VERSION = 1
+MAX_BUNDLE_BYTES = 2 * 1024 * 1024
+DEFAULT_KEEP = 20
+DEFAULT_MIN_INTERVAL_S = 30
+
+# backend/backups(模块位于 backend/modules/ 下)
+BACKUP_DIR = os.path.join(
+    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'backups'
+)
+
+_SNAPSHOT_RE = re.compile(r'^(.+)_(\d{8}_\d{6})(?:_(\d+))?\.json$')
+_UID_RE = re.compile(r'^[0-9a-fA-F]{24}$')
+_PREFIX_RE = re.compile(r'^[0-9a-fA-F]{4}$')
+
+BUNDLE_FILES = ('dtu_config', 'serial_config', 'mqtt_config', 'devices', 'system_env')
+
+
+def ensure_backup_dir(backup_dir=BACKUP_DIR):
+    """确保备份目录存在"""
+    try:
+        os.makedirs(backup_dir, exist_ok=True)
+    except OSError as e:
+        logger.error(f"创建备份目录失败: {e}")
+    return backup_dir
+
+
+def write_json_atomic(path, data):
+    """原子写 JSON 文件(tmp + os.replace), 避免写一半损坏"""
+    tmp_path = f"{path}.tmp"
+    with open(tmp_path, 'w', encoding='utf-8') as f:
+        json.dump(data, f, indent=2, ensure_ascii=False)
+    os.replace(tmp_path, path)
+
+
+def read_json_file(path):
+    """读取 JSON 文件, 失败抛出异常"""
+    with open(path, 'r', encoding='utf-8') as f:
+        return json.load(f)
+
+
+def _snapshot_name(raw_name):
+    """校验快照文件名, 防路径穿越; 非法返回 None"""
+    if not raw_name or os.path.basename(raw_name) != raw_name:
+        return None
+    return raw_name if _SNAPSHOT_RE.match(raw_name) else None
+
+
+def snapshot_stem(name):
+    """从快照文件名提取原始配置文件 stem, 非法返回 None"""
+    safe = _snapshot_name(name)
+    if not safe:
+        return None
+    return _SNAPSHOT_RE.match(safe).group(1)
+
+
+def list_snapshots(backup_dir=BACKUP_DIR, stem=None):
+    """列出快照, 按修改时间倒序(最新在前)
+
+    返回: [{'name','stem','size','mtime'}, ...]
+    """
+    result = []
+    if not os.path.isdir(backup_dir):
+        return result
+    for name in os.listdir(backup_dir):
+        m = _SNAPSHOT_RE.match(name)
+        if not m:
+            continue
+        if stem and m.group(1) != stem:
+            continue
+        path = os.path.join(backup_dir, name)
+        try:
+            st = os.stat(path)
+        except OSError:
+            continue
+        result.append({
+            'name': name,
+            'stem': m.group(1),
+            'size': st.st_size,
+            'mtime': st.st_mtime,
+        })
+    result.sort(key=lambda x: x['mtime'], reverse=True)
+    return result
+
+
+def find_latest_snapshot(backup_dir=BACKUP_DIR, stem=None):
+    """返回最新一份快照信息, 无则 None"""
+    snaps = list_snapshots(backup_dir, stem=stem)
+    return snaps[0] if snaps else None
+
+
+def read_snapshot(name, backup_dir=BACKUP_DIR):
+    """读取快照内容(JSON), 返回 (data, error)"""
+    safe = _snapshot_name(name)
+    if not safe:
+        return None, '非法快照文件名'
+    path = os.path.join(backup_dir, safe)
+    if not os.path.exists(path):
+        return None, '快照不存在'
+    try:
+        with open(path, 'r', encoding='utf-8') as f:
+            return json.load(f), None
+    except Exception as e:
+        return None, f'快照解析失败: {e}'
+
+
+def delete_snapshot(name, backup_dir=BACKUP_DIR):
+    """删除快照, 返回 (ok, message)"""
+    safe = _snapshot_name(name)
+    if not safe:
+        return False, '非法快照文件名'
+    path = os.path.join(backup_dir, safe)
+    if not os.path.exists(path):
+        return False, '快照不存在'
+    try:
+        os.remove(path)
+        return True, '已删除'
+    except OSError as e:
+        return False, f'删除失败: {e}'
+
+
+def _rotate_snapshots(backup_dir, stem, keep):
+    """每类快照只保留最近 keep 份"""
+    snaps = list_snapshots(backup_dir, stem=stem)  # 倒序
+    for item in snaps[keep:]:
+        try:
+            os.remove(os.path.join(backup_dir, item['name']))
+            logger.info(f"清理旧配置快照: {item['name']}")
+        except OSError:
+            pass
+
+
+def snapshot_before_write(path, backup_dir=BACKUP_DIR, keep=DEFAULT_KEEP,
+                          min_interval_s=DEFAULT_MIN_INTERVAL_S, now=None):
+    """写文件前对现有内容做快照。
+
+    规则:
+    - 目标文件不存在或为空 -> 不快照
+    - 内容与最新快照相同 -> 跳过(去重)
+    - 距最新快照不足 min_interval_s -> 跳过(节流)
+    返回快照文件名, 未创建返回 None
+    """
+    now = time.time() if now is None else now
+    try:
+        if not os.path.exists(path) or os.path.getsize(path) == 0:
+            return None
+        with open(path, 'rb') as f:
+            current = f.read()
+        stem = os.path.splitext(os.path.basename(path))[0]
+        latest = find_latest_snapshot(backup_dir, stem=stem)
+        if latest:
+            try:
+                with open(os.path.join(backup_dir, latest['name']), 'rb') as f:
+                    if f.read() == current:
+                        return None
+            except OSError:
+                pass
+            if now - latest['mtime'] < min_interval_s:
+                return None
+        ensure_backup_dir(backup_dir)
+        base = os.path.join(
+            backup_dir,
+            f"{stem}_{time.strftime('%Y%m%d_%H%M%S', time.localtime(now))}.json"
+        )
+        target = base
+        counter = 1
+        while os.path.exists(target):
+            target = base[:-5] + f"_{counter}.json"
+            counter += 1
+        with open(target, 'wb') as f:
+            f.write(current)
+        try:
+            os.utime(target, (now, now))  # 统一 mtime 与逻辑时间, 保证节流/轮转排序一致
+        except OSError:
+            pass
+        _rotate_snapshots(backup_dir, stem, keep)
+        logger.info(f"配置快照已保存: {os.path.basename(target)}")
+        return os.path.basename(target)
+    except Exception as e:
+        logger.error(f"配置快照失败({path}): {e}")
+        return None
+
+
+def latest_snapshot_content(path, backup_dir=BACKUP_DIR):
+    """取某配置文件最新快照的内容(用于损坏恢复), 返回 (data, name) 或 (None, None)"""
+    stem = os.path.splitext(os.path.basename(path))[0]
+    latest = find_latest_snapshot(backup_dir, stem=stem)
+    if not latest:
+        return None, None
+    data, err = read_snapshot(latest['name'], backup_dir=backup_dir)
+    if err:
+        logger.warning(f"读取快照 {latest['name']} 失败: {err}")
+        return None, latest['name']
+    return data, latest['name']
+
+
+def build_bundle(files, device_meta=None, uid_prefix=None, now=None):
+    """构建整机备份包"""
+    now = time.time() if now is None else now
+    prefix = (uid_prefix or '').strip().upper()
+    return {
+        'format': BUNDLE_FORMAT,
+        'format_version': BUNDLE_FORMAT_VERSION,
+        'created_at': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(now)),
+        'device': device_meta or {},
+        'effective_uid_prefix': prefix if _PREFIX_RE.match(prefix) else None,
+        'files': files,
+    }
+
+
+def _validate_address_range(value):
+    if not isinstance(value, dict):
+        return 'address_range 必须是对象'
+    start = value.get('start')
+    end = value.get('end')
+    if not isinstance(start, int) or not isinstance(end, int):
+        return 'address_range.start/end 必须是整数'
+    if not (1 <= start <= 247) or not (1 <= end <= 247) or start > end:
+        return f'address_range 非法: {start}-{end}(需 1-247 且 start<=end)'
+    return None
+
+
+def validate_bundle(obj):
+    """校验整机备份包, 返回 (ok, message)"""
+    if not isinstance(obj, dict):
+        return False, '备份内容不是 JSON 对象'
+    if obj.get('format') != BUNDLE_FORMAT:
+        return False, '不是本系统的备份文件(format 不匹配)'
+    fv = obj.get('format_version')
+    if not isinstance(fv, int) or not (1 <= fv <= BUNDLE_FORMAT_VERSION):
+        return False, f'备份格式版本不支持: {fv}'
+    files = obj.get('files')
+    if not isinstance(files, dict) or not files:
+        return False, '缺少 files 字段或为空'
+    unknown = set(files) - set(BUNDLE_FILES)
+    if unknown:
+        return False, f'包含未知文件项: {sorted(unknown)}'
+    if not any(v not in (None, {} , '') for v in files.values()):
+        return False, '备份包内没有任何有效文件'
+
+    for key, val in files.items():
+        if val is None:
+            continue
+        if key == 'system_env':
+            if not isinstance(val, str):
+                return False, 'system_env 必须是文本内容'
+            continue
+        if not isinstance(val, dict):
+            return False, f'{key} 必须是 JSON 对象'
+
+    dtu = files.get('dtu_config')
+    if isinstance(dtu, dict):
+        if 'address_range' in dtu and dtu['address_range'] is not None:
+            err = _validate_address_range(dtu['address_range'])
+            if err:
+                return False, err
+        if 'uid_prefix' in dtu and dtu['uid_prefix'] is not None:
+            if not _PREFIX_RE.match(str(dtu['uid_prefix'])):
+                return False, f"uid_prefix 非法: {dtu['uid_prefix']}"
+        pm = dtu.get('port_mappings')
+        if pm is not None and not isinstance(pm, dict):
+            return False, 'port_mappings 必须是对象'
+
+    dev = files.get('devices')
+    if isinstance(dev, dict):
+        for uid, addr in dev.items():
+            if not _UID_RE.match(str(uid)):
+                return False, f'devices 中存在非法线架 UID: {uid}'
+            if not isinstance(addr, int) or not (1 <= addr <= 247):
+                return False, f'devices 中地址非法: {uid} -> {addr}'
+
+    sc = files.get('serial_config')
+    if isinstance(sc, dict) and 'port' in sc and not isinstance(sc['port'], str):
+        return False, 'serial_config.port 必须是字符串'
+
+    mc = files.get('mqtt_config')
+    if isinstance(mc, dict) and 'broker' in mc and not isinstance(mc['broker'], str):
+        return False, 'mqtt_config.broker 必须是字符串'
+
+    try:
+        size = len(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
+    except (TypeError, ValueError):
+        return False, '备份内容无法序列化'
+    if size > MAX_BUNDLE_BYTES:
+        return False, f'备份内容过大({size} 字节, 上限 {MAX_BUNDLE_BYTES})'
+    return True, 'ok'

+ 70 - 28
backend/modules/modbus_rtu.py

@@ -334,6 +334,16 @@ SET_CARD_DATA_BYTES = 144       # 24 路 × 6 字节
 SET_CARD_REQUEST_LEN = 152      # addr(1)+fc(1)+start(2)+qty(2)+data(144)+crc(2)
 SET_CARD_RESPONSE_LEN = 8       # addr(1)+fc(1)+start(2)+qty(2)+crc(2)
 
+# 带 UID 读卡协议(地址段轮询 / 线架绑定)
+# 请求 20B: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
+#   UID = 该地址已绑定的线架 UID;未绑定时为 12 字节全 0(uid=None 探测),
+#   未配置线架收到探测帧后采纳该地址并应答自身 UID,DTU 据此绑定"地址↔UID"。
+# 响应 161B: [addr][0x03][0x90][144 字节卡数据][12 字节 UID][CRC_L][CRC_H]
+#   UID = 线架自身 UID(探测响应时用于绑定)。
+READ_WITH_UID_REQUEST_LEN = 20
+READ_WITH_UID_RESPONSE_LEN = 161
+ZERO_UID = b'\x00' * 12
+
 # 天线地址映射表(天线编号 -> Modbus寄存器地址)
 # V4: 所有天线共享寄存器 0x0002,由 read_all_antenna_cards 一次批量读出
 ANTENNA_ADDRESSES = {i: CARD_REG_ADDR for i in range(1, 25)}
@@ -366,6 +376,20 @@ def verify_crc16(data: bytes) -> bool:
     return received_crc == calculated_crc
 
 
+def build_read_cards_request(device_address: int, uid: Optional[bytes] = None) -> bytes:
+    """构建带 UID 的 V4 批量读卡请求(20 字节)。
+
+    格式: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
+    uid=None 表示 12 字节全 0 探测(未绑定地址;未配置线架会采纳该地址)。
+    """
+    if uid is None:
+        uid = ZERO_UID
+    if len(uid) != 12:
+        raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
+    data = bytes([device_address, 0x03]) + struct.pack('>HH', CARD_REG_ADDR, CARD_REG_QUANTITY) + bytes(uid)
+    return data + calculate_crc16(data)
+
+
 class ModbusRTUClient:
     """Modbus RTU 客户端"""
 
@@ -373,6 +397,7 @@ class ModbusRTUClient:
         self.serial = serial_port
         self.default_timeout = 2.0
         self.test_cards = None  # 测试注入:非 None 时 read_all_antenna_cards 返回构造数据
+        self.test_panel_uids = None  # 测试注入:{addr: 'hex24'} 模拟线架自身 UID
 
     def _build_request(self, device_address: int, function_code: int, data: bytes = b'') -> bytes:
         """构建Modbus请求帧"""
@@ -403,21 +428,26 @@ class ModbusRTUClient:
             "raw_data": response.hex()
         }
 
-    def read_all_antenna_cards(self, device_address: int, timeout: float = None) -> dict:
-        """V4: 一次批量读取全部 24 路天线卡号。
+    def read_all_antenna_cards(self, device_address: int, uid: Optional[bytes] = None, timeout: float = None) -> dict:
+        """V4+: 带 UID 的批量读取全部 24 路天线卡号。
 
-    寄存器 0x0002,数量 0x48(72 寄存器 = 144 字节)。
-    响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节。
-    每张卡 6 字节(硬件卡号,12 位十六进制);无卡标记 = 6×0xFF。
-    MQTT 侧的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成。
+        寄存器 0x0002,数量 0x48(72 寄存器 = 144 字节)。
+        请求 20B: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
+          uid=None 表示 12 字节全 0 探测(未绑定地址探测/换架接管用)。
+        响应 161B: [addr][0x03][0x90][144 数据字节][12 字节 UID][CRC_L][CRC_H]
+          响应 UID = 线架自身 UID(未绑定/换架时用于绑定)。
+        每张卡 6 字节(硬件卡号,12 位十六进制);无卡标记 = 6×0xFF。
+        MQTT 侧的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成。
 
-    返回:
-        {'success': True, 'device_address', 'function_code': 0x03,
-         'cards': [{'antenna', 'card_str', 'present', 'uid'}, ...24],
-         'raw_data'}
-    """
+        过渡期兼容: 旧固件 149B 响应仍可解析(panel_uid=None 仅作诊断),不能用于绑定。
+
+        返回:
+            {'success': True, 'device_address', 'function_code': 0x03,
+             'cards': [{'antenna', 'card_str', 'present', 'uid'}, ...24],
+             'panel_uid': '24位hex'|None, 'raw_data'}
+        """
         # 测试注入: test_cards = {addr: {port_id: 'hex12'|None, ...}, ...}
-        # 命中时绕过串口,返回构造数据(触发告警/同步流程测试)
+        #           test_panel_uids = {addr: 'hex24'}(可选, 模拟线架自身 UID)
         if self.test_cards is not None:
             panel_map = self.test_cards.get(device_address)
             if panel_map is not None:
@@ -442,6 +472,7 @@ class ModbusRTUClient:
                     "device_address": device_address,
                     "function_code": 0x03,
                     "cards": cards,
+                    "panel_uid": (self.test_panel_uids or {}).get(device_address),
                     "raw_data": "TEST_INJECTED"
                 }
 
@@ -450,13 +481,14 @@ class ModbusRTUClient:
         if not self.serial.ser:
             return {"error": "serial not connected"}
 
-        request_data = struct.pack('>HH', CARD_REG_ADDR, CARD_REG_QUANTITY)
-        request = self._build_request(device_address, 0x03, request_data)
+        try:
+            request = build_read_cards_request(device_address, uid)
+        except ValueError as e:
+            return {"error": str(e)}
 
-        # 直调 send_and_wait 并要求完整 149 字节;不走 _send_receive
-        # (其 data 字段会含 0x90 字节计数字节,且默认 min=5 会截断 149 字节帧)
+        # 直调 send_and_wait 并要求完整 161 字节(或旧格式 149 字节)
         response = self.serial.send_and_wait(
-            request, timeout=timeout, min_response_bytes=BULK_RESPONSE_LEN
+            request, timeout=timeout, min_response_bytes=READ_WITH_UID_RESPONSE_LEN
         )
 
         if not response or len(response) < 5:
@@ -468,19 +500,28 @@ class ModbusRTUClient:
             return {"error": f"Modbus exception: {response[2]:#x}",
                     "raw_data": response.hex()}
 
-        if len(response) < BULK_RESPONSE_LEN:
-            return {"error": f"response too short ({len(response)}/{BULK_RESPONSE_LEN})",
+        panel_uid = None
+        if len(response) >= READ_WITH_UID_RESPONSE_LEN:
+            frame = response[:READ_WITH_UID_RESPONSE_LEN]
+            raw_uid = frame[3 + CARD_DATA_BYTES:3 + CARD_DATA_BYTES + 12]
+            panel_uid = raw_uid.hex() if raw_uid != ZERO_UID else None
+        elif len(response) >= BULK_RESPONSE_LEN:
+            # 过渡期兼容:旧固件 149B 响应(无 UID 字段)
+            logger.warning("收到旧格式(149B)批量读卡响应,无 UID 字段,无法用于线架绑定")
+            frame = response[:BULK_RESPONSE_LEN]
+        else:
+            return {"error": f"response too short ({len(response)}/{READ_WITH_UID_RESPONSE_LEN})",
                     "raw_data": response.hex()}
 
-        if not verify_crc16(response):
-            logger.warning(f"CRC check failed: {response.hex()}")
-            return {"error": "CRC check failed", "raw_data": response.hex()}
+        if not verify_crc16(frame):
+            logger.warning(f"CRC check failed: {frame.hex()}")
+            return {"error": "CRC check failed", "raw_data": frame.hex()}
 
-        if response[1] != 0x03 or response[2] != BULK_BYTE_COUNT:
-            return {"error": f"unexpected response: fc={response[1]:#x} count={response[2]:#x}",
-                    "raw_data": response.hex()}
+        if frame[1] != 0x03 or frame[2] != BULK_BYTE_COUNT:
+            return {"error": f"unexpected response: fc={frame[1]:#x} count={frame[2]:#x}",
+                    "raw_data": frame.hex()}
 
-        tag_data = response[3:3 + CARD_DATA_BYTES]  # 144 字节
+        tag_data = frame[3:3 + CARD_DATA_BYTES]  # 144 字节
 
         cards = []
         for i in range(ANTENNA_COUNT):
@@ -495,10 +536,11 @@ class ModbusRTUClient:
 
         return {
             "success": True,
-            "device_address": response[0],
+            "device_address": frame[0],
             "function_code": 0x03,
             "cards": cards,
-            "raw_data": response.hex()
+            "panel_uid": panel_uid,
+            "raw_data": frame.hex()
         }
 
     def read_antenna_card(self, device_address: int, antenna_num: int, timeout: float = None) -> dict:

TEMPAT SAMPAH
backend/tests/__pycache__/test_config_backup.cpython-310-pytest-9.0.2.pyc


TEMPAT SAMPAH
backend/tests/__pycache__/test_modbus_rtu.cpython-310-pytest-9.0.2.pyc


TEMPAT SAMPAH
backend/tests/__pycache__/test_panel_registration.cpython-310-pytest-9.0.2.pyc


+ 173 - 0
backend/tests/test_config_backup.py

@@ -0,0 +1,173 @@
+"""运行数据备份/快照工具 (config_backup) 单元测试。"""
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from modules import config_backup  # noqa: E402
+
+
+@pytest.fixture()
+def workdir(tmp_path):
+    cfg = tmp_path / 'config'
+    back = tmp_path / 'backups'
+    cfg.mkdir()
+    return cfg, back
+
+
+def write_text(path, text):
+    with open(path, 'w', encoding='utf-8') as f:
+        f.write(text)
+
+
+class TestWriteJsonAtomic:
+    def test_write_and_read(self, workdir):
+        cfg, _ = workdir
+        path = str(cfg / 'demo.json')
+        config_backup.write_json_atomic(path, {'a': 1, '中文': 'ok'})
+        with open(path) as f:
+            assert json.load(f) == {'a': 1, '中文': 'ok'}
+        assert not os.path.exists(path + '.tmp')
+
+    def test_read_json_file_missing_raises(self, workdir):
+        cfg, _ = workdir
+        with pytest.raises(FileNotFoundError):
+            config_backup.read_json_file(str(cfg / 'nope.json'))
+
+
+class TestSnapshot:
+    def test_snapshot_created_and_deduped(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'dtu_config.json')
+        write_text(path, '{"a": 1}')
+
+        name = config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
+        assert name and os.path.exists(os.path.join(str(back), name))
+        # 内容未变化 -> 去重
+        assert config_backup.snapshot_before_write(path, str(back), min_interval_s=0) is None
+
+    def test_snapshot_missing_file(self, workdir):
+        cfg, back = workdir
+        assert config_backup.snapshot_before_write(str(cfg / 'none.json'), str(back)) is None
+
+    def test_snapshot_throttled(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'dtu_config.json')
+        write_text(path, '{"a": 1}')
+        assert config_backup.snapshot_before_write(path, str(back), min_interval_s=3600) is not None
+        write_text(path, '{"a": 2}')
+        # 距上一份快照不足节流时间 -> 跳过
+        assert config_backup.snapshot_before_write(path, str(back), min_interval_s=3600) is None
+
+    def test_snapshot_rotation(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'devices.json')
+        for i in range(5):
+            write_text(path, json.dumps({'round': i}))
+            config_backup.snapshot_before_write(path, str(back), keep=2, min_interval_s=0)
+        snaps = config_backup.list_snapshots(str(back), stem='devices')
+        assert len(snaps) == 2
+
+    def test_snapshot_stem_and_filter(self, workdir):
+        cfg, back = workdir
+        p1 = str(cfg / 'dtu_config.json')
+        p2 = str(cfg / 'mqtt_config.json')
+        write_text(p1, '{"a": 1}')
+        write_text(p2, '{"b": 2}')
+        n1 = config_backup.snapshot_before_write(p1, str(back), min_interval_s=0)
+        n2 = config_backup.snapshot_before_write(p2, str(back), min_interval_s=0)
+        assert config_backup.snapshot_stem(n1) == 'dtu_config'
+        assert config_backup.snapshot_stem(n2) == 'mqtt_config'
+        assert len(config_backup.list_snapshots(str(back), stem='dtu_config')) == 1
+
+    def test_same_second_collision_suffix(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'dtu_config.json')
+        write_text(path, '{"a": 1}')
+        n1 = config_backup.snapshot_before_write(path, str(back), min_interval_s=0, now=1000.0)
+        write_text(path, '{"a": 2}')
+        n2 = config_backup.snapshot_before_write(path, str(back), min_interval_s=0, now=1000.0)
+        assert n1 != n2
+        assert config_backup.snapshot_stem(n2) == 'dtu_config'
+
+    def test_read_and_delete_snapshot(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'serial_config.json')
+        write_text(path, '{"port": "COM1"}')
+        name = config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
+        data, err = config_backup.read_snapshot(name, str(back))
+        assert err is None and data == {'port': 'COM1'}
+        ok, _ = config_backup.delete_snapshot(name, str(back))
+        assert ok and not os.path.exists(os.path.join(str(back), name))
+
+    def test_reject_path_traversal(self, workdir):
+        _, back = workdir
+        for bad in ('../etc/passwd', 'a/b.json', '..{}', 'x.json'):
+            assert config_backup.read_snapshot(bad, str(back))[1] is not None
+            assert config_backup.delete_snapshot(bad, str(back))[0] is False
+
+    def test_latest_snapshot_content(self, workdir):
+        cfg, back = workdir
+        path = str(cfg / 'devices.json')
+        write_text(path, '{"aa": 1}')
+        config_backup.snapshot_before_write(path, str(back), min_interval_s=0)
+        data, name = config_backup.latest_snapshot_content(path, str(back))
+        assert data == {'aa': 1} and name
+
+
+class TestBundle:
+    def _bundle(self):
+        return config_backup.build_bundle(
+            {
+                'dtu_config': {'dtu_id': 'dtu_001', 'port_mappings': {'PANEL_dtu_001_1': {'1': 'AABBCCDDEEFF'}}},
+                'serial_config': {'port': '/dev/ttyS8', 'baudrate': 9600},
+                'mqtt_config': {'broker': 'xt.wenhq.top', 'port': 8581},
+                'devices': {'18' + '00' * 10 + '59': 1},
+                'system_env': 'DHT11_ENABLED=true\n',
+            },
+            device_meta={'dtu_id': 'dtu_001'},
+            uid_prefix='a1b2',
+        )
+
+    def test_build_and_validate_ok(self):
+        bundle = self._bundle()
+        assert bundle['format'] == config_backup.BUNDLE_FORMAT
+        assert bundle['effective_uid_prefix'] == 'A1B2'
+        ok, msg = config_backup.validate_bundle(bundle)
+        assert ok, msg
+
+    def test_validate_rejects_wrong_format(self):
+        assert config_backup.validate_bundle({'format': 'other'})[0] is False
+        assert config_backup.validate_bundle([])[0] is False
+
+    def test_validate_rejects_unknown_file_key(self):
+        bundle = self._bundle()
+        bundle['files']['shadow'] = {'x': 1}
+        ok, msg = config_backup.validate_bundle(bundle)
+        assert not ok and '未知文件项' in msg
+
+    def test_validate_rejects_bad_devices(self):
+        bundle = self._bundle()
+        bundle['files']['devices'] = {'not-a-uid': 1}
+        assert config_backup.validate_bundle(bundle)[0] is False
+        bundle = self._bundle()
+        bundle['files']['devices'] = {'a' * 24: 999}
+        assert config_backup.validate_bundle(bundle)[0] is False
+
+    def test_validate_rejects_bad_address_range(self):
+        bundle = self._bundle()
+        bundle['files']['dtu_config']['address_range'] = {'start': 50, 'end': 1}
+        assert config_backup.validate_bundle(bundle)[0] is False
+        bundle['files']['dtu_config']['address_range'] = {'start': 1, 'end': 50}
+        assert config_backup.validate_bundle(bundle)[0] is True
+
+    def test_validate_accepts_partial_files(self):
+        bundle = config_backup.build_bundle({'devices': {'a' * 24: 2}})
+        assert config_backup.validate_bundle(bundle)[0] is True
+
+    def test_validate_rejects_empty_files(self):
+        bundle = config_backup.build_bundle({'dtu_config': None, 'devices': {}, 'system_env': ''})
+        assert config_backup.validate_bundle(bundle)[0] is False

+ 76 - 11
backend/tests/test_modbus_rtu.py

@@ -1,10 +1,14 @@
-"""V4 批量读卡协议 (modbus_rtu) 单元测试。
+"""V4+ 带 UID 批量读卡协议 (modbus_rtu) 单元测试。
 
-协议 V4: 一次性批量读 24 路卡号。
-  请求: [addr][0x03][0x00][0x02][0x00][0x48][CRC_L][CRC_H]  -> 01 03 00 02 00 48 E4 3C
-  响应: [addr][0x03][0x90][144 数据字节][CRC_L][CRC_H] = 149 字节
+协议 V4+ (地址段轮询/线架绑定):
+  请求 20B: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
+    UID = 已绑定线架 UID;uid=None 时为 12 字节全 0(探测未绑定地址)
+  响应 161B: [addr][0x03][0x90][144 数据字节][12 字节 UID][CRC_L][CRC_H]
+    UID = 线架自身 UID(用于绑定)
   每张卡 6 字节(硬件卡号,12 位十六进制); 无卡标记 = FF FF FF FF FF FF
 
+过渡期兼容: 旧固件 149B 响应(无 UID 字段)可解析但 panel_uid=None。
+
 注: 硬件通信固定 6 字节卡号;对 MQTT 的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成
     (前 4 位 DTU 号 + 后 12 位卡号),本测试只覆盖硬件通信层。
 """
@@ -20,6 +24,7 @@ from modules.modbus_rtu import (  # noqa: E402
     ModbusRTUClient,
     calculate_crc16,
     verify_crc16,
+    build_read_cards_request,
     CARD_REG_ADDR,
     CARD_REG_QUANTITY,
     CARD_DATA_BYTES,
@@ -29,12 +34,26 @@ from modules.modbus_rtu import (  # noqa: E402
     BULK_RESPONSE_LEN,
     BULK_BYTE_COUNT,
     ANTENNA_ADDRESSES,
+    READ_WITH_UID_REQUEST_LEN,
+    READ_WITH_UID_RESPONSE_LEN,
+    ZERO_UID,
 )
 
+# 协议文档示例 UID: 18 00 40 00 14 00 00 59 59 54 30 56
+PANEL_UID = bytes([0x18, 0x00, 0x40, 0x00, 0x14, 0x00, 0x00, 0x59, 0x59, 0x54, 0x30, 0x56])
+
 
-def build_bulk_response(addr: int, tag_data: bytes) -> bytes:
-    """构造合法的 149 字节 V4 批量读响应帧。"""
+def build_bulk_response(addr: int, tag_data: bytes, uid: bytes = PANEL_UID) -> bytes:
+    """构造合法的 161 字节 V4+ 批量读响应帧(带线架 UID)。"""
     assert len(tag_data) == CARD_DATA_BYTES, f"tag_data 必须 {CARD_DATA_BYTES} 字节"
+    assert len(uid) == 12, "uid 必须 12 字节"
+    frame = bytes([addr, 0x03, BULK_BYTE_COUNT]) + tag_data + uid
+    return frame + calculate_crc16(frame)
+
+
+def build_legacy_bulk_response(addr: int, tag_data: bytes) -> bytes:
+    """构造旧固件 149 字节响应帧(无 UID 字段)。"""
+    assert len(tag_data) == CARD_DATA_BYTES
     frame = bytes([addr, 0x03, BULK_BYTE_COUNT]) + tag_data
     return frame + calculate_crc16(frame)
 
@@ -55,21 +74,67 @@ class TestReadAllAntennaCards:
         self.client.serial.ser = MagicMock()
         self.client.serial.send_and_wait.return_value = response_bytes
 
-    def test_request_frame_matches_doc(self):
-        """请求帧必须等于文档示例 01 03 00 02 00 48 E4 3C。"""
+    def test_request_frame_with_zero_uid(self):
+        """uid=None(探测): 20 字节请求,UID 段全 0。"""
         self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
         self.client.read_all_antenna_cards(0x01)
 
         sent = self.client.serial.send_and_wait.call_args[0][0]
-        assert sent == bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48, 0xE4, 0x3C])
+        assert len(sent) == READ_WITH_UID_REQUEST_LEN
+        assert sent[:6] == bytes([0x01, 0x03, 0x00, 0x02, 0x00, 0x48])
+        assert sent[6:18] == ZERO_UID
+        assert sent[18:] == calculate_crc16(sent[:18])
+
+    def test_request_frame_with_bound_uid(self):
+        """传入绑定 UID: 请求帧尾部携带该 UID。"""
+        self._mock_response(build_bulk_response(0x02, NO_TAG_BYTES * ANTENNA_COUNT))
+        self.client.read_all_antenna_cards(0x02, uid=PANEL_UID)
+
+        sent = self.client.serial.send_and_wait.call_args[0][0]
+        assert len(sent) == READ_WITH_UID_REQUEST_LEN
+        assert sent[6:18] == PANEL_UID
+        assert sent[18:] == calculate_crc16(sent[:18])
+
+    def test_build_read_cards_request_helper(self):
+        assert build_read_cards_request(0x01) == build_read_cards_request(0x01, ZERO_UID)
+        with pytest.raises(ValueError):
+            build_read_cards_request(0x01, b'\x00' * 11)
 
     def test_min_response_bytes_is_full_frame(self):
-        """必须要求完整 149 字节,避免截断。"""
+        """必须要求完整 161 字节,避免截断。"""
         self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
         self.client.read_all_antenna_cards(0x01)
 
         kwargs = self.client.serial.send_and_wait.call_args.kwargs
-        assert kwargs.get('min_response_bytes') == BULK_RESPONSE_LEN
+        assert kwargs.get('min_response_bytes') == READ_WITH_UID_RESPONSE_LEN
+
+    def test_panel_uid_extracted(self):
+        """161B 响应中的线架自身 UID 必须被提取(用于绑定)。"""
+        self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT, uid=PANEL_UID))
+
+        result = self.client.read_all_antenna_cards(0x01)
+
+        assert 'error' not in result
+        assert result['panel_uid'] == PANEL_UID.hex()
+
+    def test_zero_uid_response_gives_none(self):
+        """响应的 UID 段全 0(无效) -> panel_uid=None。"""
+        self._mock_response(build_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT, uid=ZERO_UID))
+
+        result = self.client.read_all_antenna_cards(0x01)
+
+        assert 'error' not in result
+        assert result['panel_uid'] is None
+
+    def test_legacy_149_response_parsed_without_uid(self):
+        """过渡期: 旧固件 149B 响应可解析,但 panel_uid=None。"""
+        self._mock_response(build_legacy_bulk_response(0x01, NO_TAG_BYTES * ANTENNA_COUNT))
+
+        result = self.client.read_all_antenna_cards(0x01)
+
+        assert 'error' not in result
+        assert result['panel_uid'] is None
+        assert len(result['cards']) == ANTENNA_COUNT
 
     def test_no_tag_response(self):
         """24 路全无卡 (FF*144)。"""

+ 209 - 0
backend/tests/test_panel_registration.py

@@ -0,0 +1,209 @@
+"""线架注册模型(地址段扫描 / UID 绑定 / 换架自动接管)单元测试。"""
+import sys
+import os
+from unittest.mock import patch
+
+import pytest
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import app as app_module  # noqa: E402
+from app import app  # noqa: E402
+
+
+def make_cards(port_map):
+    """构造 24 路读卡结果;port_map: {port_id: 'hex12'}"""
+    cards = []
+    for ant in range(1, 25):
+        hex_str = (port_map.get(ant) or '').lower()
+        cards.append({
+            'antenna': ant,
+            'card_str': hex_str,
+            'present': bool(hex_str),
+            'uid': '' if not hex_str else ':'.join(hex_str[i:i + 2] for i in range(0, 12, 2)),
+        })
+    return cards
+
+
+def read_result(cards, panel_uid=None):
+    return {
+        'success': True, 'device_address': 1, 'function_code': 3,
+        'cards': cards, 'panel_uid': panel_uid, 'raw_data': 'TEST',
+    }
+
+
+class TestPanelRegistration:
+    def setup_method(self):
+        self._saved = {
+            'stored': dict(app_module.address_config.stored_devices),
+            'panel_config': dict(app_module.panel_config),
+            'port_state': dict(app_module.port_state),
+            'fail': dict(app_module.panel_fail_count),
+            'address_range': app_module.dtu_config.get('address_range'),
+            'port_mappings': app_module.dtu_config.get('port_mappings'),
+            'last_seen': dict(app_module.device_last_seen),
+        }
+        self.mock_modbus = patch.object(app_module, 'modbus_client').start()
+        self.mock_save_dev = patch.object(app_module, 'save_device_config').start()
+        self.mock_save_dtu = patch.object(app_module, 'save_dtu_config').start()
+        self.mock_register = patch.object(app_module, 'dtu_register').start()
+        self.mock_panel_status = patch.object(app_module, 'dtu_publish_panel_status').start()
+
+        app_module.address_config.stored_devices.clear()
+        app_module.panel_config.clear()
+        app_module.port_state.clear()
+        app_module.panel_fail_count.clear()
+        app_module.device_last_seen.clear()
+
+    def teardown_method(self):
+        patch.stopall()
+        app_module.address_config.stored_devices.clear()
+        app_module.address_config.stored_devices.update(self._saved['stored'])
+        app_module.panel_config.clear()
+        app_module.panel_config.update(self._saved['panel_config'])
+        app_module.port_state.clear()
+        app_module.port_state.update(self._saved['port_state'])
+        app_module.panel_fail_count.clear()
+        app_module.panel_fail_count.update(self._saved['fail'])
+        app_module.device_last_seen.clear()
+        app_module.device_last_seen.update(self._saved['last_seen'])
+        if self._saved['address_range'] is None:
+            app_module.dtu_config.pop('address_range', None)
+        else:
+            app_module.dtu_config['address_range'] = self._saved['address_range']
+        if self._saved['port_mappings'] is None:
+            app_module.dtu_config.pop('port_mappings', None)
+        else:
+            app_module.dtu_config['port_mappings'] = self._saved['port_mappings']
+
+    def test_takeover_rebinds_uid_and_inherits_mappings(self):
+        """换架: 原地址探测到新 UID -> 重绑地址、保留 panel_id/映射、自动下发卡号。"""
+        old_uid = 'a1' * 12
+        new_uid = 'b2' * 12
+        pid = 'PANEL_dtu_001_5'
+        app_module.dtu_config['address_range'] = {'start': 5, 'end': 5}
+        app_module.dtu_config['port_mappings'] = {pid: {'3': 'AABBCCDDEEFF'}}
+        app_module.address_config.stored_devices[old_uid] = 5
+        app_module.panel_config[pid] = {'address': 5, 'position': 5, 'panel_uid': old_uid, 'status': 'offline'}
+        app_module.panel_fail_count[pid] = 3
+
+        self.mock_modbus.read_all_antenna_cards.return_value = read_result(
+            make_cards({3: 'aabbccddeeff'}), panel_uid=new_uid)
+        self.mock_modbus.set_card_mapping.return_value = {'success': True}
+
+        app_module._sweep_panel_addresses()
+
+        devices = app_module.address_config.stored_devices
+        assert devices.get(new_uid) == 5
+        assert old_uid not in devices
+        assert app_module.panel_config[pid]['panel_uid'] == new_uid
+        assert app_module.panel_config[pid]['status'] == 'online'
+        assert pid not in app_module.panel_fail_count
+        # 期望卡号已下发到硬件(继承原面板数据)
+        args, _ = self.mock_modbus.set_card_mapping.call_args
+        assert args[0] == 5
+        assert args[1].get(3) == 'AABBCCDDEEFF'
+        assert self.mock_save_dev.called
+        assert self.mock_register.called
+
+    def test_probe_same_uid_marks_back_online(self):
+        """缺失探测回应原 UID -> 恢复在线,不改变绑定。"""
+        uid = 'c3' * 12
+        pid = 'PANEL_dtu_001_2'
+        app_module.dtu_config['address_range'] = {'start': 2, 'end': 2}
+        app_module.address_config.stored_devices[uid] = 2
+        app_module.panel_config[pid] = {'address': 2, 'position': 2, 'panel_uid': uid, 'status': 'offline'}
+        app_module.panel_fail_count[pid] = 3
+
+        self.mock_modbus.read_all_antenna_cards.return_value = read_result(
+            make_cards({}), panel_uid=uid)
+
+        app_module._sweep_panel_addresses()
+
+        assert app_module.address_config.stored_devices == {uid: 2}
+        assert app_module.panel_config[pid]['status'] == 'online'
+        assert pid not in app_module.panel_fail_count
+
+    def test_unbound_probe_registers_new_panel(self):
+        """未绑定地址探测到新线架 -> 绑定地址↔UID。"""
+        new_uid = 'd4' * 12
+        app_module.dtu_config['address_range'] = {'start': 4, 'end': 4}
+        self.mock_modbus.read_all_antenna_cards.return_value = read_result(
+            make_cards({}), panel_uid=new_uid)
+
+        app_module._sweep_panel_addresses()
+
+        assert app_module.address_config.stored_devices.get(new_uid) == 4
+        assert 'PANEL_dtu_001_4' in app_module.panel_config
+        assert self.mock_save_dev.called
+
+    def test_missing_panel_suspends_unbound_probe(self):
+        """有面板处于缺失/接管状态时,本轮不抽查未绑定地址。"""
+        old_uid = 'e5' * 12
+        pid = 'PANEL_dtu_001_2'
+        app_module.dtu_config['address_range'] = {'start': 2, 'end': 3}
+        app_module.address_config.stored_devices[old_uid] = 2
+        app_module.panel_config[pid] = {'address': 2, 'position': 2, 'panel_uid': old_uid, 'status': 'offline'}
+        app_module.panel_fail_count[pid] = 1
+        self.mock_modbus.read_all_antenna_cards.return_value = {'error': 'timeout'}
+
+        app_module._sweep_panel_addresses()
+
+        probed = [c.args[0] for c in self.mock_modbus.read_all_antenna_cards.call_args_list]
+        assert probed == [2]
+
+    def test_read_failure_threshold_marks_offline(self):
+        """连续失败达到阈值 -> 标记离线(并发布离线状态)。"""
+        uid = 'f6' * 12
+        pid = 'PANEL_dtu_001_3'
+        app_module.dtu_config['address_range'] = {'start': 3, 'end': 3}
+        app_module.address_config.stored_devices[uid] = 3
+        app_module.panel_config[pid] = {'address': 3, 'position': 3, 'panel_uid': uid, 'status': 'online'}
+        self.mock_modbus.read_all_antenna_cards.return_value = {'error': 'timeout'}
+
+        for _ in range(app_module.PANEL_MISS_THRESHOLD):
+            app_module._sweep_panel_addresses()
+
+        assert app_module.panel_config[pid]['status'] == 'offline'
+        assert app_module.panel_fail_count[pid] == app_module.PANEL_MISS_THRESHOLD
+        self.mock_panel_status.assert_any_call(pid, 3, None, online=False)
+
+    def test_panel_unbind_api(self):
+        """解绑 API: 移除绑定与面板配置。"""
+        client = app.test_client()
+        uid = '99' * 12
+        pid = 'PANEL_dtu_001_7'
+        app_module.address_config.stored_devices[uid] = 7
+        app_module.panel_config[pid] = {'address': 7, 'position': 7, 'panel_uid': uid, 'status': 'online'}
+
+        resp = client.post('/api/panel/unbind', json={'address': 7})
+        data = resp.get_json()
+
+        assert resp.status_code == 200 and data['success']
+        assert uid not in app_module.address_config.stored_devices
+        assert pid not in app_module.panel_config
+        assert self.mock_save_dev.called
+
+    def test_update_dtu_config_address_range(self):
+        """DTU 配置 API: 地址段写入并校验。"""
+        client = app.test_client()
+        resp = client.post('/api/dtu/config', json={'address_range': {'start': 5, 'end': 60}})
+        assert resp.status_code == 200
+        assert app_module.dtu_config['address_range'] == {'start': 5, 'end': 60}
+        assert self.mock_save_dtu.called
+
+        resp = client.post('/api/dtu/config', json={'address_range': {'start': 70, 'end': 10}})
+        assert resp.status_code == 400
+        assert app_module.dtu_config['address_range'] == {'start': 5, 'end': 60}
+
+    def test_address_range_fallback(self):
+        app_module.dtu_config['address_range'] = {'start': 60, 'end': 10}
+        assert app_module._address_range() == (1, 50)
+        app_module.dtu_config['address_range'] = {'start': 3, 'end': 9}
+        assert app_module._address_range() == (3, 9)
+        app_module.dtu_config['address_range'] = None
+        assert app_module._address_range() == (1, 50)
+
+
+if __name__ == '__main__':
+    pytest.main([__file__, '-v'])

+ 6 - 0
frontend/src/App.vue

@@ -62,6 +62,9 @@
             <a-menu-item key="/panel-status" icon="<appstore />">
               <router-link to="/panel-status">面板状态</router-link>
             </a-menu-item>
+            <a-menu-item key="/panel-manage" icon="<cluster />">
+              <router-link to="/panel-manage">线架管理</router-link>
+            </a-menu-item>
             <a-menu-item key="/alarm-status" icon="<alert />">
               <router-link to="/alarm-status">告警显示</router-link>
             </a-menu-item>
@@ -77,6 +80,9 @@
             <a-menu-item key="/network-config" icon="<wifi />">
               <router-link to="/network-config">网络配置</router-link>
             </a-menu-item>
+            <a-menu-item key="/backup-restore" icon="<database />">
+              <router-link to="/backup-restore">数据备份</router-link>
+            </a-menu-item>
             <a-menu-item key="/about" icon="<info-circle />">
               <router-link to="/about">关于</router-link>
             </a-menu-item>

+ 50 - 1
frontend/src/api/apiService.js

@@ -134,7 +134,56 @@ const apiService = {
   // 面板状态API
   panel: {
     // 获取所有面板状态
-    getStatus: () => apiClient.get('/panel/status')
+    getStatus: () => apiClient.get('/panel/status'),
+
+    // 获取已注册线架(含绑定与状态)
+    getBindings: () => apiClient.get('/modbus/stored_devices'),
+
+    // 解除线架绑定(仅解除;线架仍在总线时下轮扫描会重新绑定)
+    unbind: (params) => apiClient.post('/panel/unbind', params),
+
+    // 扫描地址段内未绑定地址,发现新线架
+    scan: () => apiClient.post('/panel/scan'),
+
+    // 查询扫描进度/结果
+    getScanStatus: () => apiClient.get('/panel/scan_status')
+  },
+
+  // 运行数据备份/恢复API
+  backup: {
+    // 导出备份包(bundle JSON)
+    exportBundle: () => apiClient.get('/backup/export'),
+
+    // 备份文件下载地址(浏览器直接下载)
+    exportDownloadUrl: () => '/api/backup/export?download=1',
+
+    // 导入备份包(dryRun=true 仅预览)
+    importBundle: (bundle, dryRun = false) => apiClient.post(`/backup/import?dry_run=${dryRun ? 1 : 0}`, { bundle }),
+
+    // 上传备份文件导入
+    importFile: (file, dryRun = false) => {
+      const form = new FormData()
+      form.append('file', file)
+      return apiClient.post(`/backup/import?dry_run=${dryRun ? 1 : 0}`, form)
+    },
+
+    // 设备内自动快照列表
+    list: () => apiClient.get('/backup/list'),
+
+    // 从快照恢复单个配置文件
+    restoreSnapshot: (name) => apiClient.post('/backup/restore_snapshot', { name }),
+
+    // 删除快照
+    deleteSnapshot: (name) => apiClient.post('/backup/delete', { name }),
+
+    // 快照下载地址
+    downloadUrl: (name) => `/api/backup/download?name=${encodeURIComponent(name)}`
+  },
+
+  // 系统API
+  system: {
+    // 重启 dzxj_dtu 服务(恢复环境变量后生效)
+    restartService: () => apiClient.post('/system/restart_service')
   },
 
   // 传感器API

+ 46 - 2
frontend/src/components/DTUConfig.vue

@@ -111,6 +111,38 @@
           </div>
         </a-form-item>
 
+        <a-divider orientation="left">线架扫描地址段</a-divider>
+
+        <a-row :gutter="16">
+          <a-col :span="8">
+            <a-form-item label="起始地址" help="范围 1-247">
+              <a-input-number
+                v-model:value="formData.address_range.start"
+                :min="1"
+                :max="247"
+                style="width: 100%"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="8">
+            <a-form-item label="结束地址" help="按地址升序顺序轮询">
+              <a-input-number
+                v-model:value="formData.address_range.end"
+                :min="1"
+                :max="247"
+                style="width: 100%"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="8">
+            <a-form-item label="说明">
+              <span style="font-size: 12px; color: #8c8c8c">
+                DTU 顺序轮询该地址段:已绑定线架带 UID 查询;未绑定地址自动探测,新线架接入后自动绑定注册。
+              </span>
+            </a-form-item>
+          </a-col>
+        </a-row>
+
         <a-divider orientation="left">状态信息</a-divider>
 
         <a-descriptions :column="2" size="small" bordered>
@@ -259,7 +291,8 @@ const formData = ref({
   dtu_id: '',
   firmware_version: '1.0.0',
   heartbeat_interval: 30,
-  enabled: true
+  enabled: true,
+  address_range: { start: 1, end: 50 }
 })
 
 const broadcastConfig = ref({
@@ -298,6 +331,13 @@ const saveConfig = async () => {
     message.error('请输入根主题前缀')
     return
   }
+  if (!formData.value.address_range ||
+      formData.value.address_range.start == null ||
+      formData.value.address_range.end == null ||
+      formData.value.address_range.start > formData.value.address_range.end) {
+    message.error('线架扫描地址段无效:需满足 1 <= 起始 <= 结束 <= 247')
+    return
+  }
 
   try {
     isSaving.value = true
@@ -353,7 +393,11 @@ const refreshStatus = async () => {
 
     if (configResponse.data.success) {
       dataStore.setDTUConfig(configResponse.data.data)
-      formData.value = { ...configResponse.data.data }
+      const cfg = { ...configResponse.data.data }
+      if (!cfg.address_range) {
+        cfg.address_range = { start: 1, end: 50 }
+      }
+      formData.value = cfg
     }
 
     if (statusResponse.data.success) {

+ 19 - 6
frontend/src/components/SerialConfig.vue

@@ -182,15 +182,28 @@ const refreshSerialPorts = async () => {
   }
 }
 
-// 发现终端
+// 发现终端(新注册模型:扫描地址段内未绑定地址,新线架自动绑定)
 const discoverTerminals = async () => {
   isDiscovering.value = true
   try {
-    const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
-    if (res.data.success && res.data.responses) {
-      showStatus(`发现 ${res.data.responses.length} 个终端`, 'success')
-    } else {
-      showStatus(res.data.message || '未发现终端', 'info')
+    const res = await apiService.panel.scan()
+    if (!res.data.success) {
+      showStatus(res.data.message || '扫描请求失败', 'warning')
+      return
+    }
+    showStatus('已开始扫描地址段内未绑定地址,请稍候…', 'info')
+    for (let i = 0; i < 30; i++) {
+      await new Promise((resolve) => setTimeout(resolve, 2000))
+      const st = await apiService.panel.getScanStatus()
+      const scan = st.data.scan || {}
+      if (!scan.running) {
+        const found = scan.found || []
+        showStatus(
+          found.length ? `扫描完成,发现 ${found.length} 台新线架` : '扫描完成,未发现新线架',
+          found.length ? 'success' : 'info'
+        )
+        break
+      }
     }
   } catch (e) {
     showStatus('发现终端失败: ' + (e.response?.data?.message || e.message), 'error')

+ 12 - 0
frontend/src/router/index.js

@@ -25,6 +25,12 @@ const routes = [
     meta: { title: '网络配置' }
   },
   {
+    path: '/backup-restore',
+    name: 'BackupRestore',
+    component: () => import('../views/BackupRestore.vue'),
+    meta: { title: '数据备份' }
+  },
+  {
     path: '/led-debug',
     name: 'LedDebug',
     component: () => import('../views/LedDebug.vue'),
@@ -43,6 +49,12 @@ const routes = [
     meta: { title: '面板状态' }
   },
   {
+    path: '/panel-manage',
+    name: 'PanelManage',
+    component: () => import('../views/PanelManage.vue'),
+    meta: { title: '线架管理' }
+  },
+  {
     path: '/alarm-status',
     name: 'AlarmStatus',
     component: () => import('../views/AlarmStatus.vue'),

+ 401 - 0
frontend/src/views/BackupRestore.vue

@@ -0,0 +1,401 @@
+<template>
+  <div class="backup-restore">
+    <a-row :gutter="[24, 24]">
+      <!-- 整机备份导出 -->
+      <a-col :span="24">
+        <a-card title="整机运行数据备份">
+          <a-alert
+            type="info"
+            show-icon
+            style="margin-bottom: 16px"
+          >
+            <template #message>更换新 DTU 设备时使用</template>
+            <template #description>
+              导出本备份并在新设备的本页导入恢复,即可完整还原:DTU 身份与端口映射(卡号数据)、串口/MQTT 连接配置、线架绑定表、设备环境变量,
+              无需重新设置。备份文件同时包含 UID 前缀固化信息,保证换机后平台侧卡号数据一致。
+            </template>
+          </a-alert>
+          <a-space wrap>
+            <a-button type="primary" @click="downloadBackup">
+              <download-outlined /> 导出并下载备份
+            </a-button>
+            <a-button :loading="exportPreviewLoading" @click="loadExportPreview">
+              <file-search-outlined /> 查看当前数据一览
+            </a-button>
+          </a-space>
+          <a-descriptions
+            v-if="exportPreview"
+            size="small"
+            bordered
+            :column="{ xs: 1, sm: 2, md: 3 }"
+            style="margin-top: 16px"
+          >
+            <a-descriptions-item label="DTU 编号">{{ exportPreview.dtu_id || '--' }}</a-descriptions-item>
+            <a-descriptions-item label="客户编号">{{ exportPreview.customer_id || '--' }}</a-descriptions-item>
+            <a-descriptions-item label="UID 前缀">{{ exportPreview.uid_prefix || '自动(MAC 派生)' }}</a-descriptions-item>
+            <a-descriptions-item label="线架绑定数">{{ exportPreview.devices_count }} 台</a-descriptions-item>
+            <a-descriptions-item label="端口映射面板数">{{ exportPreview.port_mapping_panels }} 台</a-descriptions-item>
+            <a-descriptions-item label="环境变量文件">{{ exportPreview.has_system_env ? '包含' : '无' }}</a-descriptions-item>
+          </a-descriptions>
+        </a-card>
+      </a-col>
+
+      <!-- 导入恢复 -->
+      <a-col :span="24">
+        <a-card title="导入恢复">
+          <a-upload
+            v-model:file-list="fileList"
+            :before-upload="handleBeforeUpload"
+            :max-count="1"
+            accept=".json"
+          >
+            <a-button>
+              <upload-outlined /> 选择备份文件(.json)
+            </a-button>
+          </a-upload>
+          <div style="margin-top: 12px">
+            <a-space>
+              <a-button :disabled="!selectedFile" :loading="previewing" @click="previewSelectedFile">
+                预览备份内容
+              </a-button>
+              <a-button
+                type="primary"
+                danger
+                :disabled="!selectedFile"
+                :loading="importing"
+                @click="applySelectedFile"
+              >
+                确认恢复
+              </a-button>
+            </a-space>
+            <span v-if="selectedFile" class="file-tip">已选择: {{ selectedFile.name }}</span>
+          </div>
+
+          <a-alert
+            v-if="lastImportResult"
+            :type="lastImportResult.errors && lastImportResult.errors.length ? 'warning' : 'success'"
+            show-icon
+            style="margin-top: 16px"
+          >
+            <template #message>恢复完成</template>
+            <template #description>
+              <div>已应用: {{ (lastImportResult.applied || []).join('、') || '--' }}</div>
+              <div v-if="lastImportResult.errors && lastImportResult.errors.length" class="import-errors">
+                部分失败: {{ lastImportResult.errors.join(';') }}
+              </div>
+              <div v-if="lastImportResult.restart_required" style="margin-top: 8px">
+                环境变量文件已恢复,需要重启服务后生效。
+                <a-popconfirm title="确定重启 dzxj_dtu 服务?" @confirm="restartService">
+                  <a-button size="small" type="primary" danger style="margin-left: 8px">重启服务</a-button>
+                </a-popconfirm>
+              </div>
+            </template>
+          </a-alert>
+        </a-card>
+      </a-col>
+
+      <!-- 设备内自动快照 -->
+      <a-col :span="24">
+        <a-card>
+          <template #title>
+            设备内自动快照
+            <a-tooltip title="每次修改配置前自动快照(内容去重、30 秒节流),每类文件保留最近 20 份,可随时回滚">
+              <question-circle-outlined class="title-tip" />
+            </a-tooltip>
+          </template>
+          <template #extra>
+            <a-button size="small" :loading="loadingSnapshots" @click="loadSnapshots">
+              <reload-outlined /> 刷新
+            </a-button>
+          </template>
+          <a-table
+            :data-source="snapshots"
+            :columns="snapshotColumns"
+            row-key="name"
+            size="small"
+            :pagination="{ pageSize: 10, showSizeChanger: false }"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'type'">
+                <a-tag color="blue">{{ stemLabel(record.stem) }}</a-tag>
+              </template>
+              <template v-else-if="column.key === 'time'">
+                {{ formatTime(record.mtime) }}
+              </template>
+              <template v-else-if="column.key === 'size'">
+                {{ formatSize(record.size) }}
+              </template>
+              <template v-else-if="column.key === 'action'">
+                <a-space>
+                  <a-popconfirm
+                    title="确定用该快照覆盖当前配置?"
+                    :description="`将恢复 ${stemLabel(record.stem)}(${formatTime(record.mtime)})`"
+                    @confirm="restoreSnapshot(record)"
+                  >
+                    <a-button type="link" size="small">恢复</a-button>
+                  </a-popconfirm>
+                  <a-button type="link" size="small" @click="downloadSnapshot(record)">下载</a-button>
+                  <a-popconfirm title="确定删除该快照?" @confirm="deleteSnapshot(record)">
+                    <a-button type="link" size="small" danger>删除</a-button>
+                  </a-popconfirm>
+                </a-space>
+              </template>
+            </template>
+          </a-table>
+        </a-card>
+      </a-col>
+    </a-row>
+
+    <!-- 导入预览弹窗 -->
+    <a-modal
+      v-model:open="previewOpen"
+      title="备份内容预览"
+      :confirm-loading="importing"
+      ok-text="确认恢复"
+      cancel-text="取消"
+      @ok="applySelectedFile"
+    >
+      <a-descriptions v-if="importPreview" size="small" bordered :column="1">
+        <a-descriptions-item label="备份时间">{{ importPreview.created_at || '--' }}</a-descriptions-item>
+        <a-descriptions-item label="备份设备">
+          {{ (importPreview.device && (importPreview.device.hostname || importPreview.device.dtu_id)) || '--' }}
+          <span v-if="importPreview.backup_dtu_id">(DTU: {{ importPreview.backup_dtu_id }})</span>
+        </a-descriptions-item>
+        <a-descriptions-item label="包含文件">
+          <a-tag v-for="(val, key) in (importPreview.files || {})" :key="key">{{ stemLabel(key) }} {{ val }}</a-tag>
+          <span v-if="!Object.keys(importPreview.files || {}).length">--</span>
+        </a-descriptions-item>
+        <a-descriptions-item label="线架绑定数">{{ importPreview.devices_count }} 台</a-descriptions-item>
+        <a-descriptions-item label="端口映射面板数">{{ importPreview.port_mapping_panels }} 台</a-descriptions-item>
+        <a-descriptions-item v-if="importPreview.will_fix_uid_prefix" label="UID 前缀固化">
+          将固化 UID 前缀 {{ importPreview.effective_uid_prefix }}(保证换机后卡号上报一致)
+        </a-descriptions-item>
+        <a-descriptions-item v-if="importPreview.current_dtu_id !== importPreview.backup_dtu_id" label="DTU 编号变化">
+          {{ importPreview.current_dtu_id }} → {{ importPreview.backup_dtu_id }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="importPreview.restart_required" label="重启提示">
+          包含环境变量文件,恢复后需重启服务生效
+        </a-descriptions-item>
+      </a-descriptions>
+      <a-alert
+        type="warning"
+        show-icon
+        message="恢复会覆盖当前设备上的对应配置,并自动重连串口/MQTT。"
+        style="margin-top: 12px"
+      />
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { message } from 'ant-design-vue'
+import {
+  DownloadOutlined,
+  UploadOutlined,
+  ReloadOutlined,
+  FileSearchOutlined,
+  QuestionCircleOutlined
+} from '@ant-design/icons-vue'
+import apiService from '../api/apiService'
+
+const exportPreview = ref(null)
+const exportPreviewLoading = ref(false)
+
+const fileList = ref([])
+const selectedFile = ref(null)
+const previewing = ref(false)
+const importing = ref(false)
+const previewOpen = ref(false)
+const importPreview = ref(null)
+const lastImportResult = ref(null)
+
+const snapshots = ref([])
+const loadingSnapshots = ref(false)
+
+const snapshotColumns = [
+  { title: '时间', key: 'time', width: 220 },
+  { title: '类型', key: 'type', width: 160 },
+  { title: '大小', key: 'size', width: 120 },
+  { title: '操作', key: 'action', width: 220 }
+]
+
+const STEM_LABELS = {
+  dtu_config: 'DTU 配置',
+  serial_config: '串口配置',
+  mqtt_config: 'MQTT 配置',
+  devices: '线架绑定表',
+  system_env: '环境变量'
+}
+
+function stemLabel(stem) {
+  return STEM_LABELS[stem] || stem
+}
+
+function formatTime(mtime) {
+  if (!mtime) return '--'
+  return new Date(mtime * 1000).toLocaleString()
+}
+
+function formatSize(size) {
+  if (size == null) return '--'
+  if (size < 1024) return `${size} B`
+  return `${(size / 1024).toFixed(1)} KB`
+}
+
+async function loadExportPreview() {
+  exportPreviewLoading.value = true
+  try {
+    const res = await apiService.backup.exportBundle()
+    const bundle = res.data.bundle || {}
+    const files = bundle.files || {}
+    const dtu = files.dtu_config || {}
+    exportPreview.value = {
+      dtu_id: (bundle.device || {}).dtu_id || dtu.dtu_id,
+      customer_id: (bundle.device || {}).customer_id || dtu.customer_id,
+      uid_prefix: bundle.effective_uid_prefix || dtu.uid_prefix,
+      devices_count: Object.keys(files.devices || {}).length,
+      port_mapping_panels: Object.keys(dtu.port_mappings || {}).length,
+      has_system_env: !!files.system_env
+    }
+  } catch (e) {
+    message.error('读取备份内容失败')
+  } finally {
+    exportPreviewLoading.value = false
+  }
+}
+
+function downloadBackup() {
+  window.open(apiService.backup.exportDownloadUrl(), '_blank')
+}
+
+function handleBeforeUpload(file) {
+  if (!file.name.endsWith('.json')) {
+    message.warning('请选择 .json 格式的备份文件')
+    return false
+  }
+  selectedFile.value = file
+  lastImportResult.value = null
+  return false
+}
+
+function handleRemoveFile() {
+  selectedFile.value = null
+  return true
+}
+
+async function previewSelectedFile() {
+  if (!selectedFile.value) return
+  previewing.value = true
+  try {
+    const res = await apiService.backup.importFile(selectedFile.value, true)
+    if (res.data.success) {
+      importPreview.value = res.data.preview
+      previewOpen.value = true
+    } else {
+      message.error(res.data.message || '备份文件校验失败')
+    }
+  } catch (e) {
+    message.error((e.response && e.response.data && e.response.data.message) || '备份文件校验失败')
+  } finally {
+    previewing.value = false
+  }
+}
+
+async function applySelectedFile() {
+  if (!selectedFile.value) return
+  importing.value = true
+  try {
+    const res = await apiService.backup.importFile(selectedFile.value, false)
+    if (res.data.success) {
+      message.success('备份已恢复')
+      lastImportResult.value = res.data
+      previewOpen.value = false
+      fileList.value = []
+      selectedFile.value = null
+      loadSnapshots()
+    } else {
+      message.error(res.data.message || '恢复失败')
+    }
+  } catch (e) {
+    message.error((e.response && e.response.data && e.response.data.message) || '恢复失败')
+  } finally {
+    importing.value = false
+  }
+}
+
+async function loadSnapshots() {
+  loadingSnapshots.value = true
+  try {
+    const res = await apiService.backup.list()
+    if (res.data.success) {
+      snapshots.value = res.data.backups || []
+    }
+  } catch (e) {
+    message.error('加载快照列表失败')
+  } finally {
+    loadingSnapshots.value = false
+  }
+}
+
+async function restoreSnapshot(record) {
+  try {
+    const res = await apiService.backup.restoreSnapshot(record.name)
+    if (res.data.success) {
+      message.success(res.data.message || '已恢复')
+      loadSnapshots()
+    } else {
+      message.error(res.data.message || '恢复失败')
+    }
+  } catch (e) {
+    message.error((e.response && e.response.data && e.response.data.message) || '恢复失败')
+  }
+}
+
+function downloadSnapshot(record) {
+  window.open(apiService.backup.downloadUrl(record.name), '_blank')
+}
+
+async function deleteSnapshot(record) {
+  try {
+    const res = await apiService.backup.deleteSnapshot(record.name)
+    if (res.data.success) {
+      message.success('快照已删除')
+      loadSnapshots()
+    } else {
+      message.error(res.data.message || '删除失败')
+    }
+  } catch (e) {
+    message.error('删除失败')
+  }
+}
+
+async function restartService() {
+  try {
+    const res = await apiService.system.restartService()
+    message.success(res.data.message || '服务将在 1 秒后重启')
+  } catch (e) {
+    message.error('重启服务请求失败')
+  }
+}
+
+onMounted(() => {
+  loadSnapshots()
+})
+</script>
+
+<style scoped>
+.file-tip {
+  margin-left: 12px;
+  color: #666;
+  font-size: 13px;
+}
+.title-tip {
+  color: #999;
+  margin-left: 4px;
+}
+.import-errors {
+  color: #d46b08;
+  margin-top: 4px;
+}
+</style>

+ 254 - 0
frontend/src/views/PanelManage.vue

@@ -0,0 +1,254 @@
+<template>
+  <div class="panel-manage">
+    <a-row :gutter="[24, 24]">
+      <!-- 概览 -->
+      <a-col :span="24">
+        <a-card>
+          <div class="overview-bar">
+            <div class="overview-item">
+              <span class="overview-label">已注册线架</span>
+              <a-tag color="blue">{{ panels.length }} 台</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">在线</span>
+              <a-tag color="green">{{ onlineCount }} 台</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">缺失待接管</span>
+              <a-tag color="orange">{{ missingCount }} 台</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">离线</span>
+              <a-tag color="default">{{ offlineCount }} 台</a-tag>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">地址段</span>
+              <a-tag color="purple">{{ addressRange.start }} - {{ addressRange.end }}</a-tag>
+              <a-tooltip title="地址段在「系统配置 → DTU配置」中修改">
+                <question-circle-outlined class="tip-icon" />
+              </a-tooltip>
+            </div>
+            <div class="overview-item">
+              <span class="overview-label">最近更新</span>
+              <span class="overview-value">{{ lastUpdate || '--' }}</span>
+            </div>
+            <div class="overview-item" style="margin-left: auto">
+              <a-space>
+                <a-button size="small" :loading="loading" @click="refreshData">
+                  <reload-outlined /> 刷新
+                </a-button>
+                <a-button size="small" type="primary" :loading="scanning" @click="scanNewPanels">
+                  <search-outlined /> 扫描新线架
+                </a-button>
+              </a-space>
+            </div>
+          </div>
+          <a-alert
+            type="info"
+            show-icon
+            style="margin-top: 12px"
+            message="线架注册说明"
+            description="DTU 顺序轮询地址段内的地址:已绑定线架带 UID 查询;未绑定地址自动探测,新线架接入后会采纳该地址并自动绑定。更换单台线架时,新线架自动接管原地址并继承全部端口映射数据,无需重新匹配。删除绑定仅解除注册;线架若仍在总线上,下一轮扫描会重新绑定。"
+          />
+        </a-card>
+      </a-col>
+
+      <!-- 线架列表 -->
+      <a-col :span="24">
+        <a-card title="已注册线架">
+          <a-table
+            :data-source="panels"
+            :columns="columns"
+            row-key="address"
+            size="middle"
+            :loading="loading"
+            :pagination="{ pageSize: 10, showSizeChanger: false }"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'address'">
+                <a-tag>{{ record.address }}</a-tag>
+              </template>
+              <template v-else-if="column.key === 'uid'">
+                <span class="mono">{{ formatUid(record.uid) }}</span>
+              </template>
+              <template v-else-if="column.key === 'status'">
+                <a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
+              </template>
+              <template v-else-if="column.key === 'last_seen'">
+                {{ formatTime(record.last_seen) }}
+              </template>
+              <template v-else-if="column.key === 'ports'">
+                <a-space>
+                  <span>{{ record.connected_count }}/{{ record.port_count }}</span>
+                  <a-tag v-if="record.alarm_count > 0" color="red">告警 {{ record.alarm_count }}</a-tag>
+                </a-space>
+              </template>
+              <template v-else-if="column.key === 'action'">
+                <a-popconfirm
+                  title="确定解除该线架的注册绑定?"
+                  :description="`地址 ${record.address}(UID ${formatUid(record.uid)})。若线架仍在总线上,下一轮扫描会自动重新绑定。`"
+                  @confirm="unbindPanel(record)"
+                >
+                  <a-button type="link" size="small" danger>删除</a-button>
+                </a-popconfirm>
+              </template>
+            </template>
+          </a-table>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { message } from 'ant-design-vue'
+import { ReloadOutlined, SearchOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
+import apiService from '../api/apiService'
+
+const loading = ref(false)
+const scanning = ref(false)
+const panels = ref([])
+const addressRange = ref({ start: 1, end: 50 })
+const lastUpdate = ref('')
+let pollTimer = null
+
+const columns = [
+  { title: '地址', key: 'address', width: 80 },
+  { title: '面板ID', dataIndex: 'panel_id', key: 'panel_id', width: 200 },
+  { title: '线架 UID', key: 'uid', width: 260 },
+  { title: '状态', key: 'status', width: 130 },
+  { title: '最后响应', key: 'last_seen', width: 180 },
+  { title: '端口(已连/总)', key: 'ports', width: 160 },
+  { title: '操作', key: 'action', width: 100 }
+]
+
+const onlineCount = computed(() => panels.value.filter(p => p.status === 'online').length)
+const missingCount = computed(() => panels.value.filter(p => p.status === 'missing').length)
+const offlineCount = computed(() => panels.value.filter(p => p.status === 'offline').length)
+
+function statusLabel(status) {
+  return { online: '在线', missing: '缺失待接管', offline: '离线' }[status] || status
+}
+
+function statusColor(status) {
+  return { online: 'green', missing: 'orange', offline: 'default' }[status] || 'default'
+}
+
+function formatUid(uid) {
+  if (!uid) return '--'
+  return String(uid).toUpperCase().replace(/[:\s-]/g, '')
+}
+
+function formatTime(ts) {
+  if (!ts) return '--'
+  return new Date(ts * 1000).toLocaleString()
+}
+
+async function loadPanels() {
+  try {
+    const res = await apiService.panel.getBindings()
+    if (res.data.success) {
+      panels.value = res.data.panels || []
+      if (res.data.address_range) addressRange.value = res.data.address_range
+    }
+  } catch (e) {
+    console.error('load panels error:', e)
+  }
+}
+
+async function refreshData() {
+  loading.value = true
+  try {
+    await loadPanels()
+    lastUpdate.value = new Date().toLocaleTimeString()
+  } finally {
+    loading.value = false
+  }
+}
+
+async function scanNewPanels() {
+  scanning.value = true
+  try {
+    const res = await apiService.panel.scan()
+    if (!res.data.success) {
+      message.warning(res.data.message || '扫描请求失败')
+      return
+    }
+    message.info('已开始扫描地址段内未绑定地址')
+    // 每 2 秒查询一次进度,最多 60 秒
+    for (let i = 0; i < 30; i++) {
+      await new Promise((resolve) => setTimeout(resolve, 2000))
+      const st = await apiService.panel.getScanStatus()
+      const scan = st.data.scan || {}
+      if (!scan.running) {
+        const found = scan.found || []
+        if (found.length) {
+          message.success(`扫描完成,发现 ${found.length} 台新线架: ${found.map(f => `地址${f.address}`).join('、')}`)
+        } else {
+          message.info('扫描完成,未发现新线架')
+        }
+        break
+      }
+    }
+    await refreshData()
+  } catch (e) {
+    console.error('scan error:', e)
+    message.error('扫描失败,请检查串口连接')
+  } finally {
+    scanning.value = false
+  }
+}
+
+async function unbindPanel(record) {
+  try {
+    const res = await apiService.panel.unbind({ address: record.address })
+    if (res.data.success) {
+      message.success(res.data.message || '已解除绑定')
+      await refreshData()
+    } else {
+      message.error(res.data.message || '解除绑定失败')
+    }
+  } catch (e) {
+    message.error((e.response && e.response.data && e.response.data.message) || '解除绑定失败')
+  }
+}
+
+onMounted(async () => {
+  await refreshData()
+  pollTimer = setInterval(loadPanels, 5000)
+})
+
+onUnmounted(() => {
+  if (pollTimer) clearInterval(pollTimer)
+})
+</script>
+
+<style scoped>
+.overview-bar {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.overview-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.overview-label {
+  font-size: 14px;
+  color: #666;
+}
+.overview-value {
+  font-size: 14px;
+  color: #333;
+}
+.tip-icon {
+  color: #999;
+}
+.mono {
+  font-family: monospace;
+  font-size: 12px;
+}
+</style>

+ 23 - 10
frontend/src/views/RealTimeStatus.vue

@@ -123,6 +123,7 @@
 
 <script setup>
 import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
+import { message } from 'ant-design-vue'
 import apiService from '../api/apiService'
 import { useDataStore } from '../stores/dataStore'
 import SerialDataDisplay from '../components/SerialDataDisplay.vue'
@@ -179,19 +180,31 @@ const portStates = computed(() => {
 async function discoverTerminals() {
   discovering.value = true
   try {
-    const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
-    if (res.data.success && res.data.responses) {
-      // 优先使用已保存的地址映射,避免应答顺序导致地址错乱
-      const storedRes = await apiService.modbus.getStoredDevices()
-      const storedDevices = storedRes.data.success ? (storedRes.data.devices || {}) : {}
-      terminals.value = res.data.responses.map((r) => ({
-        uid: r.uid,
-        uid_readable: r.uid_readable,
-        address: storedDevices[r.uid] || r.address || 1
-      })).filter(t => t.address)
+    // 新注册模型:扫描配置的地址段内未绑定地址,发现新线架并自动绑定
+    const res = await apiService.panel.scan()
+    if (!res.data.success) {
+      message.warning(res.data.message || '扫描请求失败')
+      return
     }
+    // 轮询扫描状态(每 2 秒,最多 60 秒)
+    for (let i = 0; i < 30; i++) {
+      await new Promise((resolve) => setTimeout(resolve, 2000))
+      const st = await apiService.panel.getScanStatus()
+      const scan = st.data.scan || {}
+      if (!scan.running) {
+        const found = scan.found || []
+        if (found.length) {
+          message.success(`扫描完成,发现 ${found.length} 台新线架`)
+        } else {
+          message.info('扫描完成,未发现新线架')
+        }
+        break
+      }
+    }
+    await loadStoredDevices()
   } catch (e) {
     console.error('discover error:', e)
+    message.error('扫描失败,请检查串口连接')
   } finally {
     discovering.value = false
     lastUpdate.value = new Date().toLocaleTimeString()

+ 37 - 75
线架系统功能需求与MQTT协议设计.md

@@ -371,101 +371,60 @@ LED 控制使用**单个 16 位寄存器**,格式为:
 
 > 将 8 字节转换为大写十六进制字符串:`32E59506090104E0`。
 
-### 6.7 地址配置流程
+### 6.7 线架注册与地址绑定流程(2026-10 重构)
 
-#### 6.7.1 阶段划分
+> **新的注册模型**:DTU 按**配置的地址段**(默认 1-50,可在 DTU 配置页修改)顺序轮询,读卡帧携带线架 UID;
+> 未绑定地址以 uid=0 探测,设备响应后绑定"地址 ↔ UID"。
+> 原 `00 40` 广播发现 / `00 42` 分配地址流程不再作为自动注册手段(仅保留在手动调试接口)。
 
-| 阶段           | 配线架行为                   | DTU 行为                 |
-| -------------- | ---------------------------- | ------------------------ |
-| 启动 30 秒等待 | 监听总线上的确认/广播        | 发送确认指令给已保存设备 |
-| 运行期         | 使用已分配地址响应 0x03/0x06 | 正常轮询                 |
-| 配置期         | 未配置设备响应广播           | 发现新设备并分配地址     |
+#### 6.7.1 带 UID 的批量读卡帧
 
-#### 6.7.2 运行期地址确认(保活)
+请求(20 字节):
 
-DTU 每隔 10 秒对已保存的 `address↔panel_uid` 映射发送确认指令:
-
-```
-请求:add + 0x44 + 0x00 + 12 字节 panel_uid + CRC_L + CRC_H
 ```
-
-示例:
-
+[addr][0x03][0x00][0x02][0x00][0x48][12 字节线架 UID][CRC_L][CRC_H]
 ```
-01 44 00 18 00 40 00 14 00 00 59 59 54 30 56 3C 8F
-```
-
-- `01`:已分配地址
-- `44`:自定义功能码
-- `00`:子命令
-- `18 00 40 00 14 00 00 59 59 54 30 56`:12 字节 panel_uid
-- `3C 8F`:CRC16
-
-面板收到后校验地址与 panel_uid 是否匹配,匹配则进入/保持运行状态。
 
-#### 6.7.3 新设备发现与地址分配
+- `12 字节 UID`:该地址已绑定的线架 UID;**未绑定时为 12 字节全 0**(探测帧)
+- 未配置(新)线架收到 uid=0 的探测帧后**采纳该地址**,并在应答中携带自身 UID
 
-当 DTU 需要发现未配置面板时,发送广播查询:
+响应(161 字节):
 
 ```
-请求:00 40 00 40 00
+[addr][0x03][0x90][144 字节卡数据][12 字节线架 UID][CRC_L][CRC_H]
 ```
 
-- `00`:广播地址
-- `40`:自定义功能码
-- `00 40 00`:固定字段
-- 无 CRC(固定 5 字节)
+- `12 字节 UID`:线架自身 UID(DTU 据此完成"地址 ↔ UID"绑定 / 换架自动接管)
+- 过渡期兼容:旧固件 149 字节响应(无 UID 字段)仍可解析,但不能用于绑定/接管
 
-未配置面板应答:
+#### 6.7.2 地址段扫描状态机(DTU 侧)
 
-```
-00 41 12 字节 panel_uid CRC_L CRC_H
-```
+每轮按地址升序扫描配置的地址段:
 
-示例:
+| 地址状态                        | DTU 行为                                                                                                            |
+| ------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| 已绑定且正常                    | 带绑定 UID 读卡(兼作线架保活);失败累计 miss                                                                        |
+| 已绑定且 miss≥1(缺失/待接管)  | 改用 uid=0 探测:UID 相同→原线架恢复在线;UID 不同→**换架自动接管**;无响应→继续缺失,连续失败≥3 次标记离线并上报     |
+| 未绑定                          | 每轮轮转抽查若干地址(默认 5 个,uid=0 探测)→ 有响应则绑定注册;若任一绑定处于缺失/接管状态,本轮暂停未绑定探测      |
 
-```
-00 41 18 00 40 00 14 00 00 59 59 54 30 56 AE 34
-```
+> - 所有总线帧仍通过串口发送队列顺序下发(定时按地址顺序查询)。
+> - `0x44` 确认帧保活(DTU 每 10 秒发送)继续保留——线架开机 30 秒内需要收到"地址+UID 一致"的帧才会进入运行程序(见 V4 协议第 6 节)。
+> - 暂停规则用于防止新线架在换架期间抢占非目标地址;旧线架拆除后残留的绑定可在「线架管理」页删除。
 
-> **重要:多设备同时应答会产生总线冲突。** 首次部署或新增面板时,须单台上电、逐台完成地址配置;禁止多台未配置面板同时上电。DTU 在配置窗口期内仅处理第一个有效应答,后续设备须断电重上电后再次进入配置流程。
+#### 6.7.3 换架自动接管(无需重新匹配数据)
 
-DTU 分配地址:
-
-```
-00 42 12 字节 panel_uid add CRC_L CRC_H
-```
+更换单台线架时(同一地址上出现了不同的 UID):
 
-示例:
-
-```
-00 42 18 00 40 00 14 00 00 59 59 54 30 56 01 77 7F
-```
-
-- `01`:分配给该面板的 Modbus 地址
-
-面板应答:
-
-```
-add 43 00 CRC_L CRC_H
-```
-
-示例:
-
-```
-01 43 00 11 30
-```
+1. DTU 在原地址的 uid=0 探测中发现新线架应答
+2. 解除旧 UID 绑定,绑定"新 UID → 原地址"(`panel_id` 不变,端口期望映射全部继承)
+3. DTU 自动把期望卡号下发到新线架硬件(0x10 设置卡号),端口状态静默对齐(不误报事件)
+4. 注册消息与配线架状态(含 `panel_uid`)上报平台,平台侧数据无需重新匹配
 
 #### 6.7.4 position 的确定
 
-`position` 不是 Modbus 地址,而是配线架在 RS485 级联链路中的**物理顺序**。确定方式:
-
-1. DTU 第一次成功为某 panel_uid 分配 Modbus 地址时,按**分配成功的先后顺序**赋予 position。
-2. 第一台分配成功的面板 `position = 1`,第二台 `position = 2`,依次递增。
-3. DTU 将 `panel_uid → address → position` 持久化到本地配置。
-4. 业务系统通过 DTU 注册消息中的 `panels` 数组获取 `position`,用于 UI 展示和物理定位。
+`position` 表示配线架在 RS485 级联链路中的**物理顺序**(从 1 开始)。当前实现按 Modbus 地址取值(`position == address`),DTU 将 `panel_uid → address` 持久化到本地配置(`devices.json`),业务系统通过注册消息中的 `panels` 数组获取 `position`,用于 UI 展示和物理定位。
 
-> 若后期在链路中间插入新面板,后续面板的 position 顺延;已保存的 `position` 不自动重排。业务系统须通过 `SYNC_ALL_MAPPING` 或人工配置重新同步 position 与 panel_id 的映射关系。
+> 若后期在链路中间插入新面板,或更换线架导致地址变化,业务系统可通过 `SYNC_ALL_MAPPING` 或人工配置重新同步 position 与 panel_id 的映射关系;换架接管场景下 panel_id 保持不变,无需重新匹配。
 
 ---
 
@@ -565,9 +524,9 @@ add 43 00 CRC_L CRC_H
     "signal_strength": null,
     "uptime": 3600000,
     "panels": [
-      {"panel_id": "PANEL_001", "address": 1, "position": 1},
-      {"panel_id": "PANEL_002", "address": 2, "position": 2},
-      {"panel_id": "PANEL_003", "address": 3, "position": 3}
+      {"panel_id": "PANEL_001", "address": 1, "position": 1, "panel_uid": "180040001400005959543056"},
+      {"panel_id": "PANEL_002", "address": 2, "position": 2, "panel_uid": "1800400014000059595430AB"},
+      {"panel_id": "PANEL_003", "address": 3, "position": 3, "panel_uid": "1800400014000059595430CD"}
     ]
   }
 }
@@ -587,6 +546,7 @@ add 43 00 CRC_L CRC_H
 | `panels[].panel_id` | string   | 是   | 业务系统侧为该面板分配的逻辑 ID                       |
 | `panels[].address`  | int      | 是   | Modbus 从机地址                                       |
 | `panels[].position` | int      | 是   | 物理级联顺序,从 1 开始                               |
+| `panels[].panel_uid`| string   | 否   | 线架硬件唯一 UID(24 位十六进制);换架后该值变化      |
 
 > `panel_id` 由 DTU 在地址配置阶段生成,默认规则为 `PANEL_{dtu_id}_{address}`(例如 `PANEL_DTU001_1`)。平台可通过 DTU Web 管理界面或本地配置文件预先指定 `panel_uid → panel_id` 映射;未指定时采用默认规则。`panel_id` 须持久化到本地,并在注册消息中上报。
 
@@ -750,6 +710,7 @@ add 43 00 CRC_L CRC_H
     "panel_id": "PANEL_001",
     "position": 1,
     "address": 1,
+    "panel_uid": "180040001400005959543056",
     "online": true,
     "last_poll_time": 1718092795000,
     "ports": [
@@ -769,6 +730,7 @@ add 43 00 CRC_L CRC_H
 | `panel_id`           | string      | 是   | 配线架逻辑 ID                                                |
 | `position`           | int         | 是   | 物理级联顺序                                                 |
 | `address`            | int         | 是   | Modbus 地址                                                  |
+| `panel_uid`          | string/null | 否   | 线架硬件唯一 UID(24 位十六进制);换架接管后变为新线架 UID   |
 | `online`             | bool        | 是   | 最近一次轮询是否成功                                         |
 | `last_poll_time`     | long        | 是   | 最近一次成功轮询的时间戳                                     |
 | `ports`              | array       | 是   | 各端口当前状态                                               |

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini