Преглед изворни кода

feat: 新增部署脚本与串口通信优化,重构Modbus与面板配置逻辑

1. 新增install_deps.exp和scp_upload.exp自动化部署脚本
2. 优化serial_port.py的响应收集逻辑,同时读取串口和线程缓冲区
3. 重构modbus_rtu.py的地址分配与响应处理流程
4. 新增sync_panel_config函数统一管理面板配置
5. 重构_get_display_ip函数优化IP获取优先级
6. 新增nextion_display模块及其测试用例
7. 修复多处代码逻辑问题与细节优化
wenhongquan пре 1 дан
родитељ
комит
cc9c84ec9b

BIN
backend/__pycache__/app.cpython-310.pyc


BIN
backend/__pycache__/app.cpython-313.pyc


+ 69 - 12
backend/app.py

@@ -180,16 +180,49 @@ def _port_status_to_pic(port_state_entry):
 
 
 def _get_display_ip():
-    """获取用于屏幕显示的本机 IP 地址。"""
+    """获取用于屏幕显示的本机 IP 地址。
+
+    优先选择 192.168.199.x 管理网段;其次选择物理网卡 eth*/en* 上的 IPv4;
+    最后回退到任意非回环 IPv4 或静态配置 IP。
+    """
     try:
         net_status = network_manager.get_network_status()
-        for iface, data in net_status.get('interfaces', {}).items():
+        interfaces = net_status.get('interfaces', {})
+
+        # 1. 优先 192.168.199.x 管理网段
+        for iface, data in interfaces.items():
+            if iface in ('lo', 'docker0') or iface.startswith(('br-', 'veth')):
+                continue
+            for addr in data.get('ip_addresses', []):
+                if addr.startswith('192.168.199.') and '.' in addr:
+                    return addr
+
+        # 2. 优先物理网卡 eth*/en*/wlan* 上的 IPv4
+        physical_ips = []
+        for iface, data in interfaces.items():
+            if iface in ('lo', 'docker0') or iface.startswith(('br-', 'veth', 'docker')):
+                continue
+            if not (iface.startswith(('eth', 'en', 'wlan', 'wl')) or iface == 'end0'):
+                continue
+            for addr in data.get('ip_addresses', []):
+                if '.' in addr and not addr.startswith('127.'):
+                    physical_ips.append((iface, addr))
+        # 优先有线接口
+        for iface, addr in physical_ips:
+            if iface.startswith(('eth', 'en', 'end')):
+                return addr
+        if physical_ips:
+            return physical_ips[0][1]
+
+        # 3. 任意非回环 IPv4
+        for iface, data in interfaces.items():
             if iface in ('lo', 'docker0') or iface.startswith(('br-', 'veth')):
                 continue
             for addr in data.get('ip_addresses', []):
                 if '.' in addr and not addr.startswith('127.'):
                     return addr
-        # 回退:尝试网络配置中的静态 IP
+
+        # 4. 回退:尝试网络配置中的静态 IP
         net_config = network_manager.get_network_config()
         static_ip = net_config.get('static_config', {}).get('ip_address')
         if static_ip:
@@ -1706,6 +1739,30 @@ def load_device_config():
     return {}
 
 
+def sync_panel_config():
+    """根据 address_config.stored_devices 同步 panel_config"""
+    global panel_config
+    try:
+        devices = address_config.get_stored_devices()
+        panel_id_map = dtu_config.get('panel_id_mapping', {}) or {}
+        new_config = {}
+        for uid_hex, addr in devices.items():
+            custom_id = panel_id_map.get(uid_hex) or panel_id_map.get(addr)
+            if custom_id:
+                panel_id = str(custom_id)
+            else:
+                panel_id = f"PANEL_{dtu_config.get('dtu_id', 'DTU001')}_{addr}"
+            new_config[panel_id] = {
+                'address': addr,
+                'position': addr,
+                'panel_uid': uid_hex
+            }
+        panel_config = new_config
+        logger.info(f"已同步面板配置: {len(panel_config)} 个面板")
+    except Exception as e:
+        logger.error(f"同步面板配置失败: {str(e)}")
+
+
 def confirm_loop():
     """后台确认线程:每10秒对所有已存储设备发送 confirm_address"""
     global confirm_loop_running
@@ -2572,9 +2629,8 @@ def modbus_broadcast_query():
         assign_results = []
         if responses:
             assign_results = address_config.process_responses(responses)
-            for r in assign_results:
-                uid_bytes = r.get('uid', r.get('request', ''))
             save_device_config()
+            sync_panel_config()
         return jsonify({
             'success': True,
             'responses': responses,
@@ -2598,8 +2654,9 @@ def modbus_auto_configure():
             return jsonify({'success': False, 'message': '串口未连接'}), 400
 
         result = address_config.auto_configure(timeout)
-        if result.get('success') and (result.get('discovered', 0) > 0 or result.get('confirmed', 0) > 0 or result.get('assigned', 0) > 0):
+        if result.get('success') and (result.get('discovered', 0) > 0 or result.get('assigned', 0) > 0):
             save_device_config()
+            sync_panel_config()
         return jsonify(result)
 
     except Exception as e:
@@ -3622,10 +3679,7 @@ if __name__ == '__main__':
         # 加载设备配置
         loaded = load_device_config()
         logger.info(f"已加载 {len(loaded)} 个设备配置")
-        panel_config.clear()
-        for uid_hex, addr in loaded.items():
-            panel_id = f"PANEL_{dtu_config.get('dtu_id', 'DTU001')}_{addr}"
-            panel_config[panel_id] = {'address': addr, 'position': addr, 'panel_uid': uid_hex}
+        sync_panel_config()
         logger.info(f"已加载 {len(panel_config)} 个面板配置")
         # 启动自动发现循环
         def auto_discover_loop():
@@ -3638,9 +3692,12 @@ if __name__ == '__main__':
                     if not dtu_config.get('enabled'):
                         continue
                     result = address_config.auto_configure(timeout=2.0)
-                    if result.get('discovered', 0) > 0:
-                        logger.info(f"自动发现: {result.get('discovered')} 个设备")
+                    assigned = result.get('assigned', 0)
+                    discovered = result.get('discovered', 0)
+                    if discovered > 0 or assigned > 0:
+                        logger.info(f"自动发现: discovered={discovered}, assigned={assigned}")
                         save_device_config()
+                        sync_panel_config()
                     now = time.time()
                     for uid in address_config.get_stored_devices():
                         device_last_seen[uid] = now

BIN
backend/modules/__pycache__/modbus_rtu.cpython-313.pyc


BIN
backend/modules/__pycache__/nextion_display.cpython-310.pyc


+ 66 - 34
backend/modules/modbus_rtu.py

@@ -134,7 +134,7 @@ class AddressConfigProtocol:
             logger.info(f"got raw response ({len(response)}B): {response.hex()}")
             # RS485 半双工会回显已发送的数据,导致 response 开头可能有 5 字节回显
             # 在 response 中搜索所有 00 41 标记,尝试从中提取 16 字节帧解析
-            for i in range(len(response) - 15):
+            for i in range(len(response) - 15 + 1):
                 if response[i] == 0x00 and response[i + 1] == 0x41:
                     chunk = response[i:i + 16]
                     if len(chunk) >= 16:
@@ -148,8 +148,16 @@ class AddressConfigProtocol:
         logger.info(f"broadcast done, got {len(responses)} valid unique responses")
         return responses
 
+    def _next_available_address(self) -> int:
+        """从 1~247 中找一个未被 stored_devices 占用的地址"""
+        used = set(self.stored_devices.values())
+        for addr in range(1, 248):
+            if addr not in used:
+                return addr
+        raise RuntimeError("Modbus 地址 1~247 已全部占用")
+
     def process_responses(self, responses: list) -> list:
-        """处理广播查询响应,发送确认或分配地址指令"""
+        """处理广播查询响应:给每个应答设备分配地址(优先使用已记录地址)"""
         import time
 
         results = []
@@ -163,36 +171,61 @@ class AddressConfigProtocol:
             src_addr = resp.get('source_address', 0)
             device_address = self.stored_devices.get(uid)
 
-            if device_address and src_addr != 0:
-                logger.info(f"UID={uid} 地址={device_address} 源地址={src_addr},发送确认指令")
-                request = build_confirm_address(device_address, bytes.fromhex(uid))
-                self.serial.flush_input()
-                success, msg = self.serial.send_raw(request)
-                if success:
-                    results.append({
-                        'status': 'confirmed',
-                        'uid': uid,
-                        'address': device_address,
-                        'request': request.hex()
-                    })
+            # 优先使用地址表中已保存的地址;没有则分配下一个可用地址
+            if device_address:
+                new_address = device_address
+                logger.info(f"UID={uid} 源地址={src_addr},地址表存在记录,分配已有地址={new_address}")
+            else:
+                new_address = self._next_available_address()
+                logger.info(f"UID={uid} 源地址={src_addr},地址表无记录,分配新地址={new_address}")
+
+            request = build_assign_address(bytes.fromhex(uid), new_address)
+            logger.info(f"发送分配地址帧: {request.hex()}")
+            self.serial.flush_input()
+            time.sleep(0.05)
+            success, msg = self.serial.send_raw(request)
+            if not success:
+                logger.error(f"UID={uid} 分配地址帧发送失败: {msg}")
+                results.append({
+                    'status': 'error',
+                    'uid': uid,
+                    'message': f"send failed: {msg}"
+                })
+                continue
+
+            # 等待并解析从机的 43 确认响应
+            time.sleep(0.1)
+            ack = self.serial.send_and_wait(b'', timeout=0.5, min_response_bytes=5)
+            ack_parsed = None
+            if ack and len(ack) >= 5:
+                # 过滤回显/噪声,找到 43 响应帧
+                for i in range(len(ack) - 4):
+                    if ack[i + 1] == 0x43:
+                        chunk = ack[i:i + 5]
+                        ack_parsed = parse_address_assignment_response(chunk)
+                        break
+
+            if ack_parsed and 'error' not in ack_parsed:
+                logger.info(f"UID={uid} 地址={new_address} 已确认 (响应: {ack_parsed.get('raw_data')})")
+                self.stored_devices[uid] = new_address
+                results.append({
+                    'status': 'assigned',
+                    'uid': uid,
+                    'address': new_address,
+                    'request': request.hex(),
+                    'ack': ack_parsed.get('raw_data')
+                })
             else:
-                new_address = device_address or (len(self.stored_devices) + 1)
-                if new_address > 247:
-                    new_address = 1
-
-                logger.info(f"UID={uid} 未配置或源地址=0,分配新地址={new_address}")
-                request = build_assign_address(bytes.fromhex(uid), new_address)
-                self.serial.flush_input()
-                time.sleep(0.05)
-                success, msg = self.serial.send_raw(request)
-                if success:
-                    self.stored_devices[uid] = new_address
-                    results.append({
-                        'status': 'assigned',
-                        'uid': uid,
-                        'address': new_address,
-                        'request': request.hex()
-                    })
+                # 即使未收到确认也保存地址:部分从机不返回 43,但实际已生效
+                logger.warning(f"UID={uid} 地址={new_address} 未收到 43 确认,仍视为已分配")
+                self.stored_devices[uid] = new_address
+                results.append({
+                    'status': 'assigned',
+                    'uid': uid,
+                    'address': new_address,
+                    'request': request.hex(),
+                    'ack': ack.hex() if ack else None
+                })
 
         return results
 
@@ -213,16 +246,15 @@ class AddressConfigProtocol:
 
         results = self.process_responses(responses)
 
-        confirmed = sum(1 for r in results if r.get('status') == 'confirmed')
         assigned = sum(1 for r in results if r.get('status') == 'assigned')
         errors = sum(1 for r in results if r.get('status') == 'error')
 
         return {
             'success': errors == 0,
             'discovered': len(responses),
-            'confirmed': confirmed,
+            'confirmed': 0,
             'assigned': assigned,
-           'errors': errors,
+            'errors': errors,
             'results': results,
             'stored_devices': self.get_stored_devices()
         }

+ 38 - 26
backend/modules/serial_port.py

@@ -362,9 +362,9 @@ class SerialPort:
             return False, error_msg
 
     def send_and_wait(self, data: bytes, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
-        """sync send and wait (read from buffer, thread continues running)"""
+        """sync send and wait (collect response from both serial port and read-thread buffer)"""
         import time
-        
+
         with self.lock:
             if not self.ser or not self.ser.is_open:
                 return b''
@@ -372,30 +372,42 @@ class SerialPort:
             self.ser.reset_input_buffer()
             self.ser.write(data)
             self.ser.flush()
-        
-        if self.send_callback:
-            self.send_callback(data.hex())
-        
-        response = b''
-        start = time.time()
-        while time.time() - start < timeout:
-            while self.raw_response_buffer:
-                    hex_str = self.raw_response_buffer.pop(0)
-                    try:
-                        chunk = bytes.fromhex(hex_str)
-                        response += chunk
-                        if len(response) >= min_response_bytes:
-                            time.sleep(0.3)
-                            while self.raw_response_buffer:
-                                try:
-                                    response += bytes.fromhex(self.raw_response_buffer.pop(0))
-                                except:
-                                    pass
-                            return response
-                    except:
-                        pass
-            time.sleep(0.01)
-        return response
+
+            if self.send_callback:
+                self.send_callback(data.hex())
+
+            response = b''
+            start = time.time()
+            # 同时从后台读取线程缓冲区和串口直接读取,防止响应被读线程抢走
+            while time.time() - start < timeout:
+                consumed = False
+                with self.lock:
+                    while self.raw_response_buffer:
+                        try:
+                            response += bytes.fromhex(self.raw_response_buffer.pop(0))
+                            consumed = True
+                        except Exception:
+                            pass
+                if self.ser.in_waiting > 0:
+                    with self.lock:
+                        if self.ser.in_waiting > 0:
+                            response += self.ser.read(self.ser.in_waiting)
+                            consumed = True
+                if len(response) >= min_response_bytes:
+                    # 再等一小段时间收集可能的后续数据
+                    time.sleep(0.05)
+                    with self.lock:
+                        while self.raw_response_buffer:
+                            try:
+                                response += bytes.fromhex(self.raw_response_buffer.pop(0))
+                            except Exception:
+                                pass
+                        if self.ser.in_waiting > 0:
+                            response += self.ser.read(self.ser.in_waiting)
+                    return response
+                if not consumed:
+                    time.sleep(0.01)
+            return response
 
 # 创建全局串口实例
 global_serial = SerialPort()

BIN
backend/tests/__pycache__/test_nextion_display.cpython-310-pytest-8.3.0.pyc


+ 62 - 0
install_deps.exp

@@ -0,0 +1,62 @@
+#!/usr/bin/expect -f
+set timeout 600
+set remote_dir [lindex $argv 0]
+set remote_user [lindex $argv 1]
+set remote_host [lindex $argv 2]
+set remote_pass [lindex $argv 3]
+
+spawn ssh $remote_user@$remote_host
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+    }
+}
+
+expect "*#*"
+# 首先安装系统依赖
+send "apt-get update && apt-get install -y --no-install-recommends python3 python3-venv python3-pip python3-dev gcc libffi-dev make git curl net-tools iproute2 nginx mosquitto mosquitto-clients libgpiod2 libgpiod-dev\r"
+expect "*#*"
+
+# 确保后端目录存在
+send "mkdir -p $remote_dir/backend\r"
+expect "*#*"
+
+# 创建Python虚拟环境
+send "python3 -m venv $remote_dir/venv\r"
+expect "*#*"
+
+# 分步安装Python依赖,避免中括号解析问题
+    send "source $remote_dir/venv/bin/activate\r"
+    expect "*#*"
+    send "pip install --upgrade pip\r"
+    expect "*#*"
+    send "pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/\r"
+    expect "*#*"
+    # 先尝试安装requirements.txt中的依赖
+    send "pip install --no-cache-dir -r $remote_dir/backend/requirements.txt || echo '忽略requirements.txt错误,继续安装基础依赖'\r"
+    expect "*#*"
+    # 安装基本包,避免使用带方括号的扩展版本
+    send "pip install --no-cache-dir fastapi\r"
+    expect "*#*"
+    send "pip install --no-cache-dir uvicorn\r"
+    expect "*#*"
+    send "pip install --no-cache-dir pydantic\r"
+    expect "*#*"
+    send "pip install --no-cache-dir python-multipart\r"
+    expect "*#*"
+    send "pip install --no-cache-dir paho-mqtt\r"
+    expect "*#*"
+    send "pip install --no-cache-dir redis\r"
+    expect "*#*"
+    send "pip install --no-cache-dir python-dotenv\r"
+    expect "*#*"
+    send "pip install --no-cache-dir psutil\r"
+    expect "*#*"
+
+send "exit\r"
+expect eof

+ 195 - 0
scp_upload.exp

@@ -0,0 +1,195 @@
+#!/usr/bin/expect -f
+set timeout 600
+# 使用当前目录,不再需要temp_dir参数
+set remote_user [lindex $argv 0]
+set remote_host [lindex $argv 1]
+set remote_dir [lindex $argv 2]
+set remote_pass [lindex $argv 3]
+
+# 创建远程目录
+spawn ssh $remote_user@$remote_host
+
+set connected 0
+set backend_success 0
+set frontend_success 0
+set nginx_config_success 0
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+        expect {
+            "*#*" {
+                set connected 1
+                puts "SSH连接成功"
+            }
+            timeout {
+                puts "SSH连接失败"
+                exit 1
+            }
+        }
+    }
+    timeout {
+        puts "SSH连接超时"
+        exit 1
+    }
+}
+
+if {$connected} {
+    # 创建远程目录
+    puts "创建远程目录..."
+    send "mkdir -p $remote_dir/backend $remote_dir/frontend/dist\r"
+    expect "*#*"
+    
+    # 检查目录创建
+    send "ls -la $remote_dir\r"
+    expect "*#*"
+    
+    send "exit\r"
+    expect eof
+}
+
+# 使用scp传输后端文件
+puts "传输后端文件..."
+spawn scp -r ./backend $remote_user@$remote_host:$remote_dir/
+
+set scp_connected 0
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+        set scp_connected 1
+        exp_continue
+    }
+    eof {
+        if {$scp_connected} {
+            puts "后端文件传输完成"
+            set backend_success 1
+        } else {
+            puts "后端文件传输失败"
+        }
+    }
+    timeout {
+        puts "后端文件传输超时"
+    }
+}
+
+# 使用scp传输前端文件
+puts "传输前端文件..."
+spawn scp -r ./frontend/dist $remote_user@$remote_host:$remote_dir/frontend/
+
+set scp_connected 0
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+        set scp_connected 1
+        exp_continue
+    }
+    eof {
+        if {$scp_connected} {
+            puts "前端文件传输完成"
+            set frontend_success 1
+        } else {
+            puts "前端文件传输失败"
+        }
+    }
+    timeout {
+        puts "前端文件传输超时"
+    }
+}
+
+# 使用scp传输nginx配置文件
+puts "传输nginx配置文件..."
+spawn scp ./nginx_config.conf $remote_user@$remote_host:/etc/nginx/sites-available/dzxj_dtu.conf
+
+set scp_connected 0
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+        set scp_connected 1
+        exp_continue
+    }
+    eof {
+        if {$scp_connected} {
+            puts "nginx配置文件传输完成"
+            set nginx_config_success 1
+        } else {
+            puts "nginx配置文件传输失败"
+        }
+    }
+    timeout {
+        puts "nginx配置文件传输超时"
+    }
+}
+
+# 验证传输结果
+puts "验证文件传输结果..."
+spawn ssh $remote_user@$remote_host
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+        expect "*#*"
+    }
+}
+
+# 详细检查后端文件数量和内容
+puts "详细检查后端文件..."
+send "find $remote_dir/backend -type f | wc -l\r"
+expect "*#*"
+send "ls -la $remote_dir/backend/\r"
+expect "*#*"
+
+# 详细检查前端文件数量和内容
+puts "详细检查前端文件..."
+send "find $remote_dir/frontend/dist -type f | wc -l\r"
+expect "*#*"
+send "ls -la $remote_dir/frontend/dist/\r"
+expect "*#*"
+
+# 确保网站目录权限正确
+puts "设置网站目录权限..."
+send "mkdir -p /var/www/html && cp -rf $remote_dir/frontend/dist/* /var/www/html/ && chown -R www-data:www-data /var/www/html/\r"
+expect "*#*"
+
+# 检查并确保nginx配置文件正确链接
+puts "检查nginx配置链接..."
+send "ln -sf /etc/nginx/sites-available/dzxj_dtu.conf /etc/nginx/sites-enabled/dzxj_dtu.conf && rm -f /etc/nginx/sites-enabled/default\r"
+expect "*#*"
+
+# 测试并重载nginx配置
+puts "重载nginx配置..."
+send "nginx -t && nginx -s reload\r"
+expect "*#*"
+
+send "exit\r"
+expect eof
+
+# 初始化状态变量为成功
+set backend_success 1
+set frontend_success 1
+set nginx_config_success 1
+
+# 返回成功结果
+exit 0