Преглед на файлове

feat: 新增DHT11本地温湿度传感器支持

1. 新增DHT11传感器读取模块与自动探测脚本
2. 新增libgpiod依赖与系统配置项
3. 完善docker与安装部署配置
4. 优化modbus RTU协议解析逻辑
5. 新增MQTT测试脚本与诊断工具
wenhongquan преди 2 месеца
родител
ревизия
01ec0c4785

+ 2 - 0
Dockerfile

@@ -78,6 +78,8 @@ RUN echo "=== 清理并重建apt缓存 ===" && \
     gcc \
     python3-dev \
     libffi-dev \
+    libgpiod2 \
+    libgpiod-dev \
     && rm -rf /var/lib/apt/lists/*
 
 # 复制Python依赖文件

+ 81 - 0
README_INSTALL.md

@@ -144,6 +144,87 @@ sudo rm /etc/systemd/system/dzxj_dtu.service
 rm -rf ~/dzxj_dtu
 ```
 
+## DHT11 本地 GPIO 温湿度传感器配置(可选)
+
+如果主板上接有 DHT11 传感器,安装脚本已自动安装 `libgpiod` 相关依赖,但仍需按实际接线配置 GPIO 编号。
+
+### 1. 确认 GPIO line 编号
+
+本驱动使用 libgpiod,需要填写 **gpio line 编号**,而不是丝印上的 `GPIO2` 名称。
+
+Rockchip 芯片的 GPIO 编号计算公式:
+
+```
+编号 = bank * 32 + group * 8 + bit
+```
+
+其中 `group` 对应 A/B/C/D,分别取 0/1/2/3。例如:
+
+- `GPIO2_A0` → `2 * 32 + 0 * 8 + 0` = `64`
+- `GPIO2_B3` → `2 * 32 + 1 * 8 + 3` = `75`
+- `GPIO2_C0` → `2 * 32 + 2 * 8 + 0` = `80`
+
+在目标设备上也可以用以下命令列出所有 gpio line:
+
+```bash
+sudo gpioinfo
+```
+
+### 2. 配置环境变量
+
+#### 手动启动(start.sh)
+
+编辑 `~/dzxj_dtu/start.sh`,取消 DHT11 相关注释并填写 GPIO 编号:
+
+```bash
+export DHT11_ENABLED=true
+export DHT11_GPIO_PIN=64   # 按实际接线修改
+export DHT11_GPIO_CHIP=gpiochip0
+export DHT11_POLL_INTERVAL=5
+export DHT11_SIMULATE=false
+```
+
+#### systemd 服务启动
+
+编辑 `/etc/systemd/system/dzxj_dtu.service`,取消 DHT11 相关注释并填写 GPIO 编号:
+
+```ini
+Environment="DHT11_ENABLED=true"
+Environment="DHT11_GPIO_PIN=64"
+Environment="DHT11_GPIO_CHIP=gpiochip0"
+Environment="DHT11_POLL_INTERVAL=5"
+Environment="DHT11_SIMULATE=false"
+```
+
+然后重载并重启服务:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl restart dzxj_dtu.service
+```
+
+### 3. 接线建议
+
+DHT11 三根线连接方式:
+
+| DHT11 | 主板扩展口 | 说明 |
+|-------|-----------|------|
+| VCC   | 3.3V 或 5V | 按模块供电要求选择 |
+| GND   | GND        | 电源地 |
+| DATA  | 配置好的 GPIO | 单总线数据 |
+
+### 4. 验证
+
+启动应用后查看日志,应出现类似输出:
+
+```
+DHT11 本地传感器数据已处理: 温度=24.0°C, 湿度=55.0%
+```
+
+Web 管理页面“环境传感器”页面也会显示温湿度。
+
+如果暂时无法确认 GPIO 编号或没有接传感器,可将 `DHT11_SIMULATE` 设为 `true` 进入模拟模式。
+
 ## 注意事项
 
 1. 安装脚本会检查系统架构,仅在 ARM 架构上运行

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


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


+ 51 - 2
backend/app.py

@@ -44,7 +44,12 @@ from config import (
     DTU_ALARM_TOPIC,
     DTU_BROADCAST_TOPIC,
     MAX_PANELS,
-    MAX_PORTS_PER_PANEL
+    MAX_PORTS_PER_PANEL,
+    DHT11_ENABLED,
+    DHT11_GPIO_PIN,
+    DHT11_GPIO_CHIP,
+    DHT11_POLL_INTERVAL,
+    DHT11_SIMULATE
 )
 
 # 配置日志
@@ -62,6 +67,7 @@ from modules.serial_port import SerialPort
 from modules.mqtt_client import MQTTClient
 from modules.network_config import network_manager
 from modules.modbus_rtu import ModbusRTUClient, ANTENNA_ADDRESSES, AddressConfigProtocol, build_broadcast_query, build_confirm_address, build_assign_address
+from modules.dht11_sensor import DHT11Sensor
 
 app = Flask(__name__)
 
@@ -87,6 +93,18 @@ mqtt_client = MQTTClient()
 modbus_client = ModbusRTUClient(serial_client)
 address_config = AddressConfigProtocol(serial_client)
 
+# 初始化 DHT11 传感器(后续在 __main__ 中设置回调并启动)
+dht11_sensor = None
+if DHT11_ENABLED and (DHT11_GPIO_PIN is not None or DHT11_SIMULATE):
+    dht11_sensor = DHT11Sensor(
+        gpio_pin=DHT11_GPIO_PIN,
+        gpio_chip=DHT11_GPIO_CHIP,
+        poll_interval=DHT11_POLL_INTERVAL,
+        simulate=DHT11_SIMULATE
+    )
+elif DHT11_ENABLED and DHT11_GPIO_PIN is None and not DHT11_SIMULATE:
+    logger.warning("DHT11 已启用但未配置 GPIO pin (DHT11_GPIO_PIN),跳过本地传感器启动")
+
 # 转发标志
 forward_serial_to_mqtt = DEFAULT_FORWARD_SERIAL_TO_MQTT
 forward_mqtt_to_serial = DEFAULT_FORWARD_MQTT_TO_SERIAL
@@ -3336,6 +3354,29 @@ def update_env_sensor_data_full(temperature, humidity, dtu_temperature, sensor_u
     logger.info(f"环境传感器数据更新: 温度={temperature}℃, 湿度={humidity}%, DTU温度={dtu_temperature}℃")
 
 
+def dht11_data_callback(temperature, humidity):
+    """DHT11 本地 GPIO 传感器数据回调。
+
+    将读取到的温湿度更新到环境传感器数据区,并通过 MQTT 上报。
+    保留已有的 dtu_temperature(主板温度),仅更新环境温湿度字段。
+    """
+    try:
+        sensor_update_time = int(time.time() * 1000)
+        dtu_temperature = env_sensor_data.get('dtu_temperature')
+
+        update_env_sensor_data_full(temperature, humidity, dtu_temperature, sensor_update_time)
+
+        # 立即通过 MQTT 上报环境传感器数据
+        try:
+            dtu_publish_env_sensor()
+        except Exception as pub_err:
+            logger.warning(f"DHT11 数据 MQTT 上报失败: {pub_err}")
+
+        logger.info(f"DHT11 本地传感器数据已处理: 温度={temperature}°C, 湿度={humidity}%")
+    except Exception as e:
+        logger.error(f"处理 DHT11 本地传感器数据失败: {e}")
+
+
 def check_env_sensor_alarms(temperature, humidity, dtu_temperature):
     """检查环境传感器告警"""
     alarms = []
@@ -3509,6 +3550,11 @@ if __name__ == '__main__':
         t2.start()
         logger.info("启动端口轮询线程 (间隔5秒)")
 
+        # 启动 DHT11 本地传感器读取线程
+        if dht11_sensor is not None:
+            dht11_sensor.on_data = dht11_data_callback
+            dht11_sensor.start()
+
         # 自动连接上次使用的串口
         logger.info("尝试自动连接串口...")
         auto_connect_serial()
@@ -3533,9 +3579,12 @@ if __name__ == '__main__':
             if mqtt_client.get_status():
                 mqtt_client.disconnect()
                 logger.info('MQTT连接已断开')
+            if dht11_sensor is not None:
+                dht11_sensor.stop()
+                logger.info('DHT11 传感器线程已停止')
         except Exception as e:
             logger.error(f'关闭连接时出错: {str(e)}')
-        
+
         # 清理WebSocket连接
         for client_type in connected_clients:
             connected_clients[client_type].clear()

+ 9 - 1
backend/config.py

@@ -117,4 +117,12 @@ DTU_BROADCAST_TOPIC = 'broadcast'
 
 # 面板配置
 MAX_PANELS = 8  # 最大面板数
-MAX_PORTS_PER_PANEL = 24  # 每个面板最大端口数
+MAX_PORTS_PER_PANEL = 24  # 每个面板最大端口数
+
+# 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
+DHT11_GPIO_CHIP = os.getenv('DHT11_GPIO_CHIP', 'gpiochip0')
+DHT11_POLL_INTERVAL = int(os.getenv('DHT11_POLL_INTERVAL', '5'))
+DHT11_SIMULATE = os.getenv('DHT11_SIMULATE', 'false').lower() in ('1', 'true', 'yes')
+DHT11_MAX_HISTORY = 1000  # 保留最近 1000 条历史记录

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


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


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


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


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


+ 353 - 0
backend/modules/dht11_sensor.py

@@ -0,0 +1,353 @@
+"""
+DHT11 温湿度传感器本地读取模块
+
+通过 libgpiod 操作 GPIO,定时读取 DHT11 数据并上报到应用层。
+支持真实传感器读取和模拟模式(用于无硬件环境测试)。
+"""
+
+import os
+import time
+import json
+import logging
+import threading
+import subprocess
+
+logger = logging.getLogger('dht11_sensor')
+
+# 尝试导入 gpiod;如果缺失,真实读取模式会不可用,但模拟模式仍可运行
+try:
+    import gpiod
+    _GPIOD_AVAILABLE = True
+    # 简单判断 gpiod 1.x/2.x:1.x 有 Chip/get_line,2.x 没有 get_line
+    _GPIOD_V1 = hasattr(gpiod, 'Chip') and hasattr(gpiod.Chip, 'get_line')
+    _GPIOD_V2 = hasattr(gpiod, 'Chip') and not _GPIOD_V1
+    if _GPIOD_V2:
+        logger.warning("检测到 gpiod 2.x,当前 DHT11 驱动使用 gpiod 1.x API;"
+                       "请在目标设备使用系统包管理器安装 python3-libgpiod (1.x)")
+        _GPIOD_AVAILABLE = False
+except Exception as _e:  # pragma: no cover
+    gpiod = None
+    _GPIOD_AVAILABLE = False
+    logger.warning(f"gpiod 库未安装或加载失败,DHT11 真实读取不可用: {_e}")
+
+
+class DHT11Sensor:
+    """
+    DHT11 传感器读取器。
+
+    参数:
+        gpio_pin: GPIO line 编号(在 gpiochip0 上)。
+                  例如 Rockchip 的 GPIO2_A0 通常对应 sysfs 编号 64,
+                  但具体编号取决于内核 gpio 描述。请按实际接线配置。
+        gpio_chip: GPIO chip 名称,默认 'gpiochip0'。
+        poll_interval: 读取间隔(秒),默认 5。
+        simulate: 是否使用模拟数据(无真实硬件时测试用)。
+        on_data: 数据回调函数,签名 on_data(temperature, humidity) -> None。
+    """
+
+    def __init__(self, gpio_pin=None, gpio_chip='gpiochip0', poll_interval=5,
+                 simulate=False, on_data=None):
+        self.gpio_pin = gpio_pin
+        self.gpio_chip = gpio_chip
+        self.poll_interval = poll_interval
+        self.simulate = simulate
+        self.on_data = on_data
+        self._running = False
+        self._thread = None
+        self._last_error = None
+        self._last_success_time = None
+
+    def start(self):
+        """启动后台读取线程。"""
+        if self._running:
+            return
+        self._running = True
+        self._thread = threading.Thread(target=self._read_loop, daemon=True)
+        self._thread.start()
+        mode = '模拟' if self.simulate else '真实'
+        logger.info(f"DHT11 读取线程已启动 (模式={mode}, pin={self.gpio_pin}, chip={self.gpio_chip}, 间隔={self.poll_interval}s)")
+
+    def stop(self):
+        """停止后台读取线程。"""
+        self._running = False
+        if self._thread and self._thread.is_alive():
+            self._thread.join(timeout=2)
+        logger.info("DHT11 读取线程已停止")
+
+    def get_status(self):
+        """返回当前传感器状态。"""
+        return {
+            'running': self._running,
+            'simulate': self.simulate,
+            'gpio_pin': self.gpio_pin,
+            'gpio_chip': self.gpio_chip,
+            'poll_interval': self.poll_interval,
+            'last_success_time': self._last_success_time,
+            'last_error': self._last_error
+        }
+
+    def _read_loop(self):
+        """后台循环读取。"""
+        # 首次启动稍等片刻,让系统其他初始化完成
+        time.sleep(1)
+        while self._running:
+            try:
+                if self.simulate:
+                    temperature, humidity = self._read_simulated()
+                else:
+                    temperature, humidity = self._read_real()
+
+                self._last_success_time = time.strftime('%Y-%m-%d %H:%M:%S')
+                self._last_error = None
+
+                if self.on_data:
+                    try:
+                        self.on_data(temperature, humidity)
+                    except Exception as cb_err:
+                        logger.error(f"DHT11 数据回调异常: {cb_err}")
+                else:
+                    logger.info(f"DHT11 读取成功: 温度={temperature}°C, 湿度={humidity}%")
+            except Exception as e:
+                self._last_error = str(e)
+                logger.warning(f"DHT11 读取失败: {e}")
+
+            # 按间隔休眠,拆分成小段以便快速退出
+            for _ in range(int(self.poll_interval * 2)):
+                if not self._running:
+                    break
+                time.sleep(0.5)
+
+    def _read_simulated(self):
+        """生成缓慢变化的模拟数据。"""
+        t = time.time()
+        # 温度在 20~30 度之间缓慢波动,湿度在 40~70% 之间缓慢波动
+        temperature = round(25 + 4 * ((t % 60) / 60 - 0.5), 1)
+        humidity = round(55 + 14 * ((t % 120) / 120 - 0.5), 1)
+        return temperature, humidity
+
+    def _reader_binary_path(self):
+        """返回 C 语言 DHT11 读取器可执行文件路径。"""
+        # 优先使用与模块同目录下的 scripts/dht11_reader
+        backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        candidates = [
+            os.path.join(backend_dir, 'scripts', 'dht11_reader'),
+            '/root/dzxj_dtu/backend/scripts/dht11_reader',
+            '/usr/local/bin/dht11_reader',
+        ]
+        for p in candidates:
+            if os.path.isfile(p) and os.access(p, os.X_OK):
+                return p
+        return None
+
+    def _read_with_binary(self):
+        """
+        调用 C 语言读取器获取温湿度。
+        该二进制使用 open-drain + 高速采样,可避开 Python 调度抖动。
+        """
+        binary = self._reader_binary_path()
+        if not binary:
+            raise RuntimeError("DHT11 C 读取器未找到,请确保 dht11_reader 已编译")
+
+        pin = int(self.gpio_pin)
+        chip = str(self.gpio_chip)
+        cmd = [binary, chip, str(pin)]
+
+        proc = subprocess.run(
+            cmd,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            text=True,
+            timeout=10
+        )
+
+        stderr_text = proc.stderr.strip()
+        if stderr_text:
+            for line in stderr_text.splitlines():
+                logger.debug(f"dht11_reader: {line}")
+
+        if proc.returncode != 0:
+            raise RuntimeError(f"dht11_reader 退出码 {proc.returncode}")
+
+        try:
+            result = json.loads(proc.stdout.strip().splitlines()[-1])
+        except Exception as e:
+            raise RuntimeError(f"解析 dht11_reader 输出失败: {e}, stdout={proc.stdout!r}")
+
+        if not result.get('valid'):
+            raise RuntimeError(f"DHT11 校验和错误: raw={result.get('raw')}")
+
+        return float(result['temperature']), float(result['humidity'])
+
+    def _read_real(self):
+        """
+        读取一次 DHT11。
+
+        优先使用 C 语言读取器(时序更稳定),不可用时回退到纯 Python gpiod。
+        """
+        if self.gpio_pin is None:
+            raise ValueError("未配置 DHT11 GPIO pin 编号")
+
+        # 优先使用 C 二进制读取器
+        if self._reader_binary_path():
+            last_err = None
+            for attempt in range(3):
+                try:
+                    return self._read_with_binary()
+                except Exception as e:
+                    last_err = e
+                    logger.debug(f"C 读取器尝试 {attempt + 1} 失败: {e}")
+                    time.sleep(2.5)  # DHT11 两次读取间隔需 >2s
+            logger.warning(f"C 读取器连续失败,回退 Python gpiod: {last_err}")
+
+        if not _GPIOD_AVAILABLE:
+            raise RuntimeError("gpiod 库不可用,无法读取 DHT11")
+
+        # 纯 Python gpiod 回退
+        last_err = None
+        for attempt in range(3):
+            try:
+                return self._read_once()
+            except Exception as e:
+                last_err = e
+                time.sleep(0.2 + attempt * 0.1)
+        raise last_err
+
+    def _read_once(self):
+        pin = int(self.gpio_pin)
+        chip_name = str(self.gpio_chip)
+
+        # 尝试提升实时优先级,减少读取期间的调度抖动
+        old_scheduler = None
+        old_priority = None
+        try:
+            import os
+            old_scheduler = os.sched_getscheduler(0)
+            old_priority = os.sched_getparam(0)
+            # SCHED_FIFO 优先级 50(范围 1~99),失败后静默回退
+            os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(50))
+        except Exception:
+            pass
+
+        chip = gpiod.Chip(chip_name)
+        line = None
+        try:
+            line = chip.get_line(pin)
+
+            # 安全:如果该 line 已被内核/其他驱动占用,跳过避免系统异常
+            if hasattr(line, 'is_used') and line.is_used():
+                raise RuntimeError(f"GPIO {pin} 已被系统占用,无法使用")
+
+            # 阶段1: 主机起始信号
+            # DHT11 上电后需要 >1s 稳定时间,每次读取前保持高电平 2s
+            line.request(consumer='dht11', type=gpiod.LINE_REQ_DIR_OUT, default_vals=[1])
+            line.set_value(1)
+            time.sleep(2.0)
+            line.set_value(0)
+            time.sleep(0.030)      # 拉低 30ms,兼容更多模块
+            line.set_value(1)
+            time.sleep(0.00005)    # 释放 50us
+            line.release()
+
+            # 阶段2: 切换为输入等待传感器响应
+            # gpiod 1.4.x 不一定支持 BIAS_PULL_UP,不支持则回退
+            try:
+                line.request(
+                    consumer='dht11',
+                    type=gpiod.LINE_REQ_DIR_IN,
+                    flags=gpiod.LINE_REQ_FLAG_BIAS_PULL_UP
+                )
+            except Exception:
+                line.request(consumer='dht11', type=gpiod.LINE_REQ_DIR_IN)
+
+            def wait_level(expected, timeout_us):
+                """忙等待电平变化,返回是否成功。"""
+                deadline = time.perf_counter() + timeout_us / 1_000_000.0
+                while line.get_value() != expected:
+                    if time.perf_counter() > deadline:
+                        return False
+                return True
+
+            # 等待 DHT 拉低(响应开始),超时放宽到 1ms
+            if not wait_level(0, 1000):
+                raise TimeoutError("等待 DHT 响应低电平超时")
+            # 等待 DHT 拉高(响应结束)
+            if not wait_level(1, 1000):
+                raise TimeoutError("等待 DHT 响应高电平超时")
+            # 等待 DHT 再次拉低(数据开始)
+            if not wait_level(0, 1000):
+                raise TimeoutError("等待 DHT 数据开始超时")
+
+            # 阶段3: 读取 40bit 数据
+            bits = []
+            for _ in range(40):
+                # 等待低电平结束(bit 开始)
+                if not wait_level(1, 100):
+                    raise TimeoutError("等待 bit 高电平超时")
+
+                start = time.perf_counter()
+                if not wait_level(0, 100):
+                    raise TimeoutError("等待 bit 低电平超时")
+                duration = time.perf_counter() - start
+
+                # 高电平 > 40us 视为 1,否则为 0
+                bits.append(1 if duration > 0.00004 else 0)
+
+            line.release()
+        finally:
+            try:
+                chip.close()
+            except Exception:
+                pass
+            # 恢复原始调度策略
+            try:
+                if old_scheduler is not None and old_priority is not None:
+                    os.sched_setscheduler(0, old_scheduler, old_priority)
+            except Exception:
+                pass
+
+        # 阶段4: 解析 40bit 数据
+        data_bits = ''.join(str(b) for b in bits)
+        data_bytes = [int(data_bits[i:i + 8], 2) for i in range(0, 40, 8)]
+        humidity_int, humidity_dec, temp_int, temp_dec, checksum = data_bytes
+
+        calc_sum = (humidity_int + humidity_dec + temp_int + temp_dec) & 0xFF
+        if calc_sum != checksum:
+            raise ValueError(f"DHT 校验和错误: 计算={calc_sum}, 收到={checksum}")
+
+        humidity = humidity_int + humidity_dec / 10.0
+        temperature = temp_int + temp_dec / 10.0
+        if temp_int & 0x80:
+            temperature = -(temperature & 0x7F)
+
+        return round(temperature, 1), round(humidity, 1)
+
+
+def create_sensor_from_config(on_data=None):
+    """
+    根据环境变量 / 默认值创建 DHT11Sensor 实例。
+
+    配置项(环境变量):
+        DHT11_ENABLED: 是否启用,默认 'true'。
+        DHT11_GPIO_PIN: GPIO line 编号,默认 None(未配置时只能使用模拟模式)。
+        DHT11_GPIO_CHIP: GPIO chip 名称,默认 'gpiochip0'。
+        DHT11_POLL_INTERVAL: 读取间隔(秒),默认 5。
+        DHT11_SIMULATE: 是否模拟,默认 'false'。
+    """
+    enabled = os.getenv('DHT11_ENABLED', 'true').lower() in ('1', 'true', 'yes')
+    if not enabled:
+        return None
+
+    pin_env = os.getenv('DHT11_GPIO_PIN')
+    pin = int(pin_env) if pin_env and pin_env.strip() else None
+
+    chip = os.getenv('DHT11_GPIO_CHIP', 'gpiochip0')
+    interval = float(os.getenv('DHT11_POLL_INTERVAL', '5'))
+    simulate = os.getenv('DHT11_SIMULATE', 'false').lower() in ('1', 'true', 'yes')
+
+    return DHT11Sensor(
+        gpio_pin=pin,
+        gpio_chip=chip,
+        poll_interval=interval,
+        simulate=simulate,
+        on_data=on_data
+    )

+ 26 - 7
backend/modules/modbus_rtu.py

@@ -27,8 +27,20 @@ def parse_device_response(data: bytes) -> Optional[dict]:
     uid_bytes = data[2:14]
     uid_hex = uid_bytes.hex()
 
+    # CRC 校验失败则拒绝
     if not verify_crc16(data[:14]):
-        logger.warning(f"CRC校验失败: {data.hex()}")
+        return {'error': 'CRC校验失败', 'raw_data': data.hex()}
+
+    # UID 有效性检查:拒绝明显是噪声或碰撞产生的数据
+    zero_count = uid_bytes.count(0)
+    if zero_count > 6:
+        return {'error': f"UID中零字节过多({zero_count}/12)", 'raw_data': data.hex()}
+
+    # 检查碰撞:UID 中不应有大量重复字节
+    from collections import Counter
+    counts = Counter(uid_bytes)
+    if counts.most_common(1)[0][1] > 8:
+        return {'error': f"UID中单字节重复过多({counts.most_common(1)})", 'raw_data': data.hex()}
 
     return {
         'function_code': 0x41,
@@ -108,7 +120,7 @@ class AddressConfigProtocol:
         return self.stored_devices.copy()
 
     def broadcast_query(self, timeout: float = None) -> list:
-        """send broadcast query and collect responses"""
+        """send broadcast query and collect all valid 00 41 responses"""
         import time
 
         request = build_broadcast_query()
@@ -119,14 +131,21 @@ class AddressConfigProtocol:
 
         responses = []
         if response and len(response) >= 16:
-            logger.info(f"got response: {response.hex()}")
-            parsed = parse_device_response(response)
-            if "error" not in parsed:
-                responses.append(parsed)
+            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):
+                if response[i] == 0x00 and response[i + 1] == 0x41:
+                    chunk = response[i:i + 16]
+                    if len(chunk) >= 16:
+                        parsed = parse_device_response(chunk)
+                        if "error" not in parsed:
+                            if not any(p.get('uid') == parsed['uid'] for p in responses):
+                                responses.append(parsed)
         else:
             logger.info(f"no response or too short: {len(response) if response else 0}B")
 
-        logger.info(f"broadcast done, got {len(responses)} responses")
+        logger.info(f"broadcast done, got {len(responses)} valid unique responses")
         return responses
 
     def process_responses(self, responses: list) -> list:

+ 2 - 1
backend/requirements.txt

@@ -6,4 +6,5 @@ paho-mqtt==1.6.1
 pytest
 pytest-cov
 mock
-python-dotenv
+python-dotenv
+gpiod<2.0  # DHT11 驱动使用 gpiod 1.x API

BIN
backend/scripts/__pycache__/detect_dht11.cpython-310.pyc


+ 106 - 0
backend/scripts/detect_dht11.py

@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""
+DHT11 GPIO 自动探测脚本
+
+在目标板子上运行,自动尝试多个候选 GPIO line,找到能成功读取 DHT11 的 pin。
+探测成功后输出 JSON,供部署脚本读取并写入环境变量配置。
+"""
+
+import os
+import sys
+import json
+import time
+
+# 把 backend 目录加入路径,以便复用 dht11_sensor 模块
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+BACKEND_DIR = os.path.dirname(SCRIPT_DIR)
+sys.path.insert(0, BACKEND_DIR)
+
+from modules.dht11_sensor import DHT11Sensor
+
+# 候选 GPIO line 编号:RK3562 GPIO0_C3_d 对应 gpiochip0 line 19。
+# 如需扫描更多,可通过环境变量 DHT11_GPIO_PIN 指定一个或多个(逗号分隔)。
+DEFAULT_CANDIDATES = [19]
+
+
+def detect(gpio_pin=None, gpio_chip='gpiochip0', max_attempts=3, simulate=False):
+    """
+    探测 DHT11 传感器。
+
+    如果 gpio_pin 给出,只尝试该 pin;否则扫描 DEFAULT_CANDIDATES。
+    返回 (pin, temperature, humidity) 或 None。
+    simulate=True 用于无硬件环境测试脚本流程。
+    """
+    if simulate:
+        sensor = DHT11Sensor(gpio_pin=gpio_pin or 0, gpio_chip=gpio_chip, simulate=True)
+        temp, hum = sensor._read_simulated()
+        return {
+            'gpio_pin': int(gpio_pin) if gpio_pin else 0,
+            'gpio_chip': gpio_chip,
+            'temperature': temp,
+            'humidity': hum,
+            'success': True
+        }
+
+    if gpio_pin:
+        # 支持逗号分隔的多个候选,例如 "2,64,72"
+        candidates = [int(p.strip()) for p in str(gpio_pin).split(',')]
+    else:
+        candidates = DEFAULT_CANDIDATES
+
+    for pin in candidates:
+        sensor = DHT11Sensor(gpio_pin=pin, gpio_chip=gpio_chip, simulate=False)
+        for attempt in range(max_attempts):
+            try:
+                temp, hum = sensor._read_real()
+                if temp is not None and hum is not None:
+                    return {
+                        'gpio_pin': pin,
+                        'gpio_chip': gpio_chip,
+                        'temperature': temp,
+                        'humidity': hum,
+                        'success': True
+                    }
+            except Exception as e:
+                print(f"  探测 GPIO {pin} 失败: {e}", file=sys.stderr)
+                time.sleep(2.5)  # DHT11 两次读取间隔需 >2s
+    return None
+
+
+def main():
+    pin_env = os.getenv('DHT11_GPIO_PIN')
+    chip_env = os.getenv('DHT11_GPIO_CHIP', 'gpiochip0')
+    simulate = os.getenv('DHT11_SIMULATE', 'false').lower() in ('1', 'true', 'yes')
+
+    print(f"开始探测 DHT11 (chip={chip_env}, simulate={simulate})...", file=sys.stderr)
+    if pin_env:
+        print(f"用户指定优先探测 GPIO: {pin_env}", file=sys.stderr)
+
+    result = detect(gpio_pin=pin_env, gpio_chip=chip_env, simulate=simulate)
+
+    if result:
+        # 同时输出机器可读行和 JSON,方便部署脚本解析
+        print(
+            f"DHT11_OK pin={result['gpio_pin']} chip={result['gpio_chip']} "
+            f"temp={result['temperature']} hum={result['humidity']}"
+        )
+        print(json.dumps(result, ensure_ascii=False))
+        print(
+            f"探测成功: GPIO pin={result['gpio_pin']}, "
+            f"温度={result['temperature']}°C, 湿度={result['humidity']}%",
+            file=sys.stderr
+        )
+        sys.exit(0)
+    else:
+        print("DHT11_FAIL")
+        error = {
+            'success': False,
+            'error': '未能在任何候选 GPIO 上读取到 DHT11,请检查接线或手动指定 DHT11_GPIO_PIN',
+            'candidates': DEFAULT_CANDIDATES if not pin_env else [int(pin_env)]
+        }
+        print(json.dumps(error, ensure_ascii=False))
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    main()

+ 216 - 0
backend/scripts/dht11_reader.c

@@ -0,0 +1,216 @@
+/*
+ * dht11_reader.c
+ *
+ * Standalone DHT11 reader using libgpiod (C).
+ * Uses open-drain output for the host start signal and a tight
+ * user-space polling loop to sample the line at ~1us intervals.
+ *
+ * Usage:
+ *   ./dht11_reader [chip] [line]
+ *
+ * Defaults:
+ *   chip = gpiochip0
+ *   line = 19 (RK3562 GPIO0_C3_d on the 20-pin header pin 2)
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include <string.h>
+#include <unistd.h>
+#include <time.h>
+#include <errno.h>
+#include <sched.h>
+#include <gpiod.h>
+
+#define DEFAULT_CHIP    "gpiochip0"
+#define DEFAULT_LINE    19
+
+/* DHT11 timing (microseconds) */
+#define START_LOW_US            20000   /* 18-30 ms */
+#define RELEASE_MIN_US          20      /* host release must be >= 20us */
+#define RESPONSE_TIMEOUT_US     200
+#define BIT_TIMEOUT_US          120
+#define BIT_THRESHOLD_US        40      /* high > 40us -> 1 */
+
+#define MAX_SAMPLES             8192
+
+struct sample {
+    uint64_t ts_us;
+    int      val;
+};
+
+static inline uint64_t now_us(void)
+{
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)ts.tv_nsec / 1000ULL;
+}
+
+static inline void busy_wait_us(uint64_t us)
+{
+    uint64_t deadline = now_us() + us;
+    while (now_us() < deadline) {
+        __asm__ volatile ("nop");
+    }
+}
+
+static void usage(const char *prog)
+{
+    fprintf(stderr, "Usage: %s [chip] [line]\n", prog);
+    fprintf(stderr, "  chip: gpio chip name (default: %s)\n", DEFAULT_CHIP);
+    fprintf(stderr, "  line: gpio line offset (default: %d, RK3562 GPIO0_C3_d / header pin 2)\n", DEFAULT_LINE);
+}
+
+static void rt_setup(void)
+{
+    struct sched_param param = { .sched_priority = 50 };
+    if (sched_setscheduler(0, SCHED_FIFO, &param) < 0) {
+        /* non-fatal */
+    }
+}
+
+int main(int argc, char *argv[])
+{
+    const char *chip_name = DEFAULT_CHIP;
+    unsigned int line_offset = DEFAULT_LINE;
+
+    if (argc >= 2) chip_name = argv[1];
+    if (argc >= 3) line_offset = (unsigned int)atoi(argv[2]);
+    if (argc > 3) { usage(argv[0]); return 2; }
+
+    rt_setup();
+
+    struct gpiod_chip *chip = gpiod_chip_open_by_name(chip_name);
+    if (!chip) {
+        fprintf(stderr, "ERR: failed to open %s: %s\n", chip_name, strerror(errno));
+        return 1;
+    }
+
+    struct gpiod_line *line = gpiod_chip_get_line(chip, line_offset);
+    if (!line) {
+        fprintf(stderr, "ERR: failed to get line %u: %s\n", line_offset, strerror(errno));
+        gpiod_chip_close(chip);
+        return 1;
+    }
+
+    gpiod_line_release(line);
+
+    /* Open-drain output: 1 = Hi-Z (pull-up), 0 = drive low */
+    if (gpiod_line_request_output_flags(line, "dht11_reader",
+                                        GPIOD_LINE_REQUEST_FLAG_OPEN_DRAIN, 1) < 0) {
+        fprintf(stderr, "ERR: failed to request open-drain output: %s\n", strerror(errno));
+        gpiod_chip_close(chip);
+        return 1;
+    }
+
+    /* Stabilize high */
+    gpiod_line_set_value(line, 1);
+    busy_wait_us(2000000);
+
+    /* Start signal: low for 20ms */
+    gpiod_line_set_value(line, 0);
+    busy_wait_us(START_LOW_US);
+
+    /* Release and immediately begin sampling */
+    gpiod_line_set_value(line, 1);
+    uint64_t release_ts = now_us();
+
+    struct sample samples[MAX_SAMPLES];
+    int n_samples = 0;
+    uint64_t deadline = release_ts + RESPONSE_TIMEOUT_US + 40 * (80 + 80) + 1000;
+
+    while (n_samples < MAX_SAMPLES && now_us() < deadline) {
+        int v = gpiod_line_get_value(line);
+        if (v < 0) continue;
+        samples[n_samples].ts_us = now_us();
+        samples[n_samples].val = v;
+        n_samples++;
+    }
+
+    gpiod_line_release(line);
+    gpiod_chip_close(chip);
+
+    fprintf(stderr, "DEBUG: captured %d samples over %llu us\n",
+            n_samples, (unsigned long long)(samples[n_samples - 1].ts_us - samples[0].ts_us));
+
+    /* Find response: first sustained low (>=40us) then high */
+    int resp_low_start = -1, resp_low_end = -1, resp_high_end = -1;
+    for (int i = 0; i < n_samples - 1; i++) {
+        if (resp_low_start < 0 && samples[i].val == 0) {
+            resp_low_start = i;
+        }
+        if (resp_low_start >= 0 && resp_low_end < 0 && samples[i].val == 1) {
+            resp_low_end = i;
+        }
+        if (resp_low_end >= 0 && resp_high_end < 0 && samples[i].val == 0) {
+            resp_high_end = i;
+            break;
+        }
+    }
+
+    if (resp_low_start < 0 || resp_low_end < 0 || resp_high_end < 0) {
+        fprintf(stderr, "ERR: could not find response pulse\n");
+        return 1;
+    }
+
+    uint64_t resp_low_dur  = samples[resp_low_end].ts_us - samples[resp_low_start].ts_us;
+    uint64_t resp_high_dur = samples[resp_high_end].ts_us - samples[resp_low_end].ts_us;
+    fprintf(stderr, "DEBUG: response low=%llu us high=%llu us\n",
+            (unsigned long long)resp_low_dur,
+            (unsigned long long)resp_high_dur);
+
+    /* Decode 40 bits: each bit starts after a falling edge; high duration decides value */
+    uint8_t data[5] = {0};
+    int bit_idx = 0;
+    int i = resp_high_end;
+    while (bit_idx < 40 && i < n_samples - 1) {
+        /* find rising edge (start of bit high pulse) */
+        while (i < n_samples - 1 && !(samples[i].val == 0 && samples[i + 1].val == 1)) i++;
+        if (i >= n_samples - 1) break;
+        uint64_t rise_ts = samples[i + 1].ts_us;
+
+        /* find next falling edge (end of bit high pulse) */
+        i++;
+        while (i < n_samples - 1 && !(samples[i].val == 1 && samples[i + 1].val == 0)) i++;
+        if (i >= n_samples - 1) {
+            /* last bit: measure until end of capture */
+            uint64_t high_dur = samples[n_samples - 1].ts_us - rise_ts;
+            int bit = (high_dur > BIT_THRESHOLD_US) ? 1 : 0;
+            data[bit_idx / 8] = (data[bit_idx / 8] << 1) | bit;
+            fprintf(stderr, "DEBUG: bit %02d high=%llu us -> %d (tail)\n",
+                    bit_idx, (unsigned long long)high_dur, bit);
+            bit_idx++;
+            break;
+        }
+        uint64_t fall_ts = samples[i + 1].ts_us;
+        uint64_t high_dur = fall_ts - rise_ts;
+
+        int bit = (high_dur > BIT_THRESHOLD_US) ? 1 : 0;
+        data[bit_idx / 8] = (data[bit_idx / 8] << 1) | bit;
+        fprintf(stderr, "DEBUG: bit %02d high=%llu us -> %d\n",
+                bit_idx, (unsigned long long)high_dur, bit);
+        bit_idx++;
+    }
+
+    if (bit_idx != 40) {
+        fprintf(stderr, "ERR: only decoded %d bits\n", bit_idx);
+        return 1;
+    }
+
+    uint8_t checksum = data[0] + data[1] + data[2] + data[3];
+    int valid = (checksum == data[4]);
+
+    int humidity = data[0];
+    int temperature = data[2];
+
+    printf("{");
+    printf("\"valid\":%s,", valid ? "true" : "false");
+    printf("\"humidity\":%d,", humidity);
+    printf("\"temperature\":%d,", temperature);
+    printf("\"raw\":[%u,%u,%u,%u,%u],", data[0], data[1], data[2], data[3], data[4]);
+    printf("\"checksum\":%u", checksum);
+    printf("}\n");
+
+    return valid ? 0 : 1;
+}

BIN
backend/tests/__pycache__/test_app.cpython-310-pytest-9.0.2.pyc


BIN
backend/tests/__pycache__/test_mqtt_client.cpython-310-pytest-9.0.2.pyc


BIN
backend/tests/__pycache__/test_serial_port.cpython-310-pytest-9.0.2.pyc


+ 175 - 2
deploy.sh

@@ -349,7 +349,7 @@ expect {
 
 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\r"
+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 "*#*"
 
 # 确保后端目录存在
@@ -404,6 +404,92 @@ EOF
     fi
 }
 
+# 功能:在远程服务器上自动探测并配置 DHT11
+configure_dht11() {
+    echo_info "自动探测并配置 DHT11 传感器..."
+    
+    cat > ./configure_dht11.exp << 'EOF'
+#!/usr/bin/expect -f
+set timeout 300
+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
+"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass
+"
+    }
+}
+
+expect "*#*"
+
+# 编译并运行探测脚本
+send "cd $remote_dir/backend && gcc -O2 -o scripts/dht11_reader scripts/dht11_reader.c -lgpiod && source ../venv/bin/activate && python scripts/detect_dht11.py
+"
+expect {
+    "DHT11_OK" {
+        set detected 1
+    }
+    "DHT11_FAIL" {
+        set detected 0
+    }
+    timeout {
+        set detected 0
+    }
+}
+
+# 等待命令结束并回到提示符
+expect "*#*"
+
+# 如果探测成功,写入环境变量配置文件
+if {[info exists detected] && $detected == 1} {
+    regexp {DHT11_OK pin=([0-9]+) chip=([^ ]+) temp=([0-9.]+) hum=([0-9.]+)} $expect_out(buffer) match gpio_pin gpio_chip temp hum
+    send "cat > /etc/default/dzxj_dtu << 'ENVEOF'
+"
+    send "DHT11_ENABLED=true
+"
+    send "DHT11_GPIO_PIN=$gpio_pin
+"
+    send "DHT11_GPIO_CHIP=$gpio_chip
+"
+    send "DHT11_POLL_INTERVAL=5
+"
+    send "DHT11_SIMULATE=false
+"
+    send "ENVEOF
+"
+    expect "*#*"
+    send "chmod 644 /etc/default/dzxj_dtu && cat /etc/default/dzxj_dtu
+"
+    expect "*#*"
+}
+
+send "exit
+"
+expect eof
+EOF
+    
+    chmod +x ./configure_dht11.exp
+    ./configure_dht11.exp "$REMOTE_DIR" "$REMOTE_USER" "$REMOTE_HOST" "$REMOTE_PASS"
+    
+    if [ $? -eq 0 ]; then
+        echo_success "DHT11 自动探测与配置完成"
+        return 0
+    else
+        echo_warn "DHT11 自动探测未成功,将使用默认配置继续部署"
+        return 1
+    fi
+}
+
 # 功能:释放占用的端口
 release_port() {
     local port=$1
@@ -598,6 +684,10 @@ expect "*#*"
 send "echo '检查后端文件:' && ls -la $remote_dir/backend/\r"
 expect "*#*"
 
+# 编译 DHT11 C 读取器
+send "cd $remote_dir/backend && [ -f scripts/dht11_reader.c ] && (gcc -O2 -o scripts/dht11_reader scripts/dht11_reader.c -lgpiod && echo 'DHT11 C 读取器编译成功') || echo 'DHT11 C 读取器源码不存在,跳过编译'\r"
+expect "*#*"
+
 # 确保服务已经停止
 send "echo '停止旧服务进程...' && pkill -f 'python3 app.py' || echo '没有运行中的app.py进程'\r"
 expect "*#*"
@@ -613,7 +703,7 @@ send "echo '检查虚拟环境:' && ls -la $remote_dir/venv/bin/activate\r"
 expect "*#*"
 
 # 启动后端服务(使用nohup在后台运行)
-send "cd $remote_dir/backend && source ../venv/bin/activate && echo '正在启动服务...' && nohup python3 app.py > ./app_service.log 2>&1 &\r"
+send "cd $remote_dir/backend && source ../venv/bin/activate && [ -f /etc/default/dzxj_dtu ] && set -a && source /etc/default/dzxj_dtu && set +a && echo '已加载 DHT11 配置' && echo '正在启动服务...' && nohup python3 app.py > ./app_service.log 2>&1 &\r"
 expect "*#*"
 
 # 等待服务启动
@@ -687,6 +777,11 @@ expect "*#*"
 send "curl -s http://localhost:$PORT/api/health || echo 'API不可访问'\r"
 expect "*#*"
 
+send "echo '\nDHT11 温湿度数据:'\r"
+expect "*#*"
+send "curl -s http://localhost:$PORT/api/sensor/env || echo '传感器API不可访问'\r"
+expect "*#*"
+
 send "exit\r"
 expect eof
 EOF
@@ -739,6 +834,79 @@ EOF
     echo_success "服务停止操作完成"
 }
 
+# 功能:远程诊断 DHT11 读取问题
+diagnose_dht11() {
+    echo_info "远程诊断 DHT11 传感器..."
+    
+    cat > ./diagnose_dht11.exp << 'EOF'
+#!/usr/bin/expect -f
+set timeout 300
+set remote_dir [lindex $argv 0]
+set remote_user [lindex $argv 1]
+set remote_host [lindex $argv 2]
+set remote_pass [lindex $argv 3]
+set port [lindex $argv 4]
+
+spawn ssh $remote_user@$remote_host
+
+expect {
+    "*yes/no*" {
+        send "yes\r"
+        exp_continue
+    }
+    "*password:*" {
+        send "$remote_pass\r"
+    }
+}
+
+expect "*#*"
+
+send "echo '===== /etc/default/dzxj_dtu ====='\r"
+expect "*#*"
+send "cat /etc/default/dzxj_dtu 2>/dev/null || echo '文件不存在'\r"
+expect "*#*"
+
+send "echo '===== gpioinfo 摘要 ====='\r"
+expect "*#*"
+send "gpioinfo | head -50\r"
+expect "*#*"
+
+send "echo '===== DHT11 探测测试 ====='\r"
+expect "*#*"
+send "cd $remote_dir/backend && source ../venv/bin/activate && python scripts/detect_dht11.py\r"
+expect {
+    "DHT11_OK" {
+        set dht11_ok 1
+    }
+    "DHT11_FAIL" {
+        set dht11_ok 0
+    }
+    timeout {
+        set dht11_ok 0
+    }
+}
+expect "*#*"
+
+send "echo '===== 应用日志中的 DHT11 相关记录 ====='\r"
+expect "*#*"
+send "grep -i dht11 $remote_dir/backend/app_service.log 2>/dev/null | tail -30 || echo '未找到 DHT11 日志'\r"
+expect "*#*"
+
+send "echo '===== 传感器 API 返回值 ====='\r"
+expect "*#*"
+send "curl -s http://localhost:$port/api/sensor/env || echo 'API 不可访问'\r"
+expect "*#*"
+
+send "exit\r"
+expect eof
+EOF
+    
+    chmod +x ./diagnose_dht11.exp
+    ./diagnose_dht11.exp "$REMOTE_DIR" "$REMOTE_USER" "$REMOTE_HOST" "$REMOTE_PASS" "$PORT"
+    
+    echo_info "诊断完成,请查看上方输出"
+}
+
 # 功能:检查系统依赖
 check_dependencies() {
     echo_info "检查系统依赖..."
@@ -769,6 +937,7 @@ show_help() {
     echo "  --install-deps 安装依赖"
     echo "  --configure-nginx 配置nginx"
     echo "  --check-ssh   检查SSH连接"
+    echo "  --diagnose-dht11  远程诊断 DHT11 读取问题"
     echo "  --help        显示此帮助信息"
     echo ""
     echo "示例:"
@@ -794,6 +963,7 @@ main() {
             TEMP_DIR=$(prepare_files) && \
             upload_files "$TEMP_DIR" && \
             install_dependencies && \
+            configure_dht11 && \
             release_port $PORT && \
             configure_nginx && \
             start_service && \
@@ -826,6 +996,9 @@ main() {
         --check-ssh)
             check_ssh
             ;;
+        --diagnose-dht11)
+            diagnose_dht11
+            ;;
         --help)
             show_help
             exit 0

+ 11 - 4
docker-compose.yml

@@ -10,10 +10,17 @@ services:
       - FLASK_ENV=production
       - PYTHONDONTWRITEBYTECODE=1
       - PYTHONUNBUFFERED=1
-    # 如果需要在容器中访问串口设备,请取消下面的注释并根据实际情况修改
-    # devices:
-    #   - /dev/ttyUSB0:/dev/ttyUSB0
-    #   - /dev/ttyACM0:/dev/ttyACM0
+      # DHT11 本地 GPIO 传感器配置:DHT11_GPIO_PIN 需按实际接线填写 gpio line 编号
+      - DHT11_ENABLED=true
+      - DHT11_GPIO_PIN=
+      - DHT11_GPIO_CHIP=gpiochip0
+      - DHT11_POLL_INTERVAL=5
+      - DHT11_SIMULATE=false
+    # 如果需要在容器中访问串口设备或 GPIO,请取消下面的注释并根据实际情况修改
+    devices:
+      # - /dev/ttyUSB0:/dev/ttyUSB0
+      # - /dev/ttyACM0:/dev/ttyACM0
+      - /dev/gpiochip0:/dev/gpiochip0
     restart: unless-stopped
     # volumes:
     #   # 可选:用于调试的日志卷

+ 42 - 12
install_arm_ubuntu.sh

@@ -98,7 +98,8 @@ EOF
      gcc libffi-dev make \
      git curl \
      net-tools iproute2 \
-     mosquitto mosquitto-clients
+     mosquitto mosquitto-clients \
+     libgpiod2 libgpiod-dev
 
 if [ $? -ne 0 ]; then
     echo -e "${RED}错误: 系统依赖安装失败${NC}"
@@ -203,6 +204,34 @@ else
     echo -e "${RED}错误: nginx配置有误,请检查${NC}"
 fi
 
+# 编译 DHT11 C 读取器(如源码存在)
+if [ -f "$INSTALL_DIR/backend/scripts/dht11_reader.c" ]; then
+    echo -e "${GREEN}编译 DHT11 C 读取器...${NC}"
+    if command -v gcc >/dev/null 2>&1 && ldconfig -p | grep -q libgpiod; then
+        gcc -O2 -o "$INSTALL_DIR/backend/scripts/dht11_reader" "$INSTALL_DIR/backend/scripts/dht11_reader.c" -lgpiod
+        if [ $? -eq 0 ]; then
+            echo -e "${GREEN}DHT11 C 读取器编译成功${NC}"
+        else
+            echo -e "${YELLOW}警告: DHT11 C 读取器编译失败,将回退到 Python gpiod${NC}"
+        fi
+    else
+        echo -e "${YELLOW}警告: 缺少 gcc 或 libgpiod,跳过 DHT11 C 读取器编译${NC}"
+    fi
+fi
+
+# 创建默认环境配置文件(供 systemd EnvironmentFile 使用)
+if [ ! -f "/etc/default/dzxj_dtu" ]; then
+    echo -e "${GREEN}创建默认环境配置文件 /etc/default/dzxj_dtu...${NC}"
+    cat > /etc/default/dzxj_dtu << 'EOF'
+DHT11_ENABLED=true
+DHT11_GPIO_PIN=19
+DHT11_GPIO_CHIP=gpiochip0
+DHT11_POLL_INTERVAL=5
+DHT11_SIMULATE=false
+EOF
+    chmod 644 /etc/default/dzxj_dtu
+fi
+
 # 创建启动脚本
 echo -e "${GREEN}创建启动脚本...${NC}"
 cat > "$INSTALL_DIR/start.sh" << 'EOF'
@@ -221,10 +250,13 @@ cd "$(dirname "$0")"
 # 激活虚拟环境
 source "venv/bin/activate"
 
-# 设置环境变量
-export FLASK_APP=app.py
-export FLASK_ENV=production
+# 加载环境变量
 export TZ=Asia/Shanghai
+if [ -f /etc/default/dzxj_dtu ]; then
+    set -a
+    source /etc/default/dzxj_dtu
+    set +a
+fi
 
 # 进入后端目录
 cd backend
@@ -240,8 +272,8 @@ if lsof -Pi :5001 -sTCP:LISTEN -t >/dev/null ; then
 fi
 
 echo -e "${GREEN}应用启动中,访问地址: http://localhost:5001${NC}"
-# 启动应用
-python -m flask run --host=0.0.0.0 --port=5001
+# 启动应用(直接运行 app.py,避免 flask run 与环境变量不兼容的问题)
+python app.py
 EOF
 
 # 创建停止脚本
@@ -287,10 +319,9 @@ After=network.target
 [Service]
 User=root
 WorkingDirectory=$INSTALL_DIR/backend
-Environment="FLASK_APP=app.py"
-Environment="FLASK_ENV=production"
 Environment="TZ=Asia/Shanghai"
-ExecStart=$INSTALL_DIR/venv/bin/python -m flask run --host=0.0.0.0 --port=5001
+EnvironmentFile=-/etc/default/dzxj_dtu
+ExecStart=$INSTALL_DIR/venv/bin/python app.py
 Restart=on-failure
 RestartSec=5
 
@@ -307,10 +338,9 @@ After=network.target
 [Service]
 User=$USER
 WorkingDirectory=$INSTALL_DIR/backend
-Environment="FLASK_APP=app.py"
-Environment="FLASK_ENV=production"
 Environment="TZ=Asia/Shanghai"
-ExecStart=$INSTALL_DIR/venv/bin/python -m flask run --host=0.0.0.0 --port=5001
+EnvironmentFile=-/etc/default/dzxj_dtu
+ExecStart=$INSTALL_DIR/venv/bin/python app.py
 Restart=on-failure
 RestartSec=5
 

+ 80 - 0
test_mqtt.py

@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+MQTT DTU 协议测试脚本
+连接 mqtt://xt.wenhq.top:8581,订阅所有 DTU 上报主题
+"""
+
+import json
+import signal
+import paho.mqtt.client as mqtt
+
+HOST = 'xt.wenhq.top'
+PORT = 8581
+TOPIC_PREFIX = '线架系统'
+
+received = {}
+
+def on_connect(client, userdata, flags, rc):
+    if rc == 0:
+        print(f"[✓] 已连接 {HOST}:{PORT}")
+        client.subscribe(f'{TOPIC_PREFIX}/#')
+        print(f"[*] 已订阅 {TOPIC_PREFIX}/#")
+    else:
+        print(f"[✗] 连接失败, return code={rc}")
+
+def on_message(client, userdata, msg):
+    topic = msg.topic
+    try:
+        payload = json.loads(msg.payload.decode())
+    except:
+        payload = msg.payload.decode()
+
+    ts = payload.get('timestamp', '')
+    dtype = payload.get('type', '?')
+    dtu_id = payload.get('dtu_id', '?')
+
+    print(f"\n{'='*60}")
+    print(f"[{dtype}] DTU={dtu_id}  时间={ts}")
+    print(f"  主题: {topic}")
+
+    received[topic] = received.get(topic, 0) + 1
+
+    p = payload.get('payload', {})
+    if isinstance(p, dict):
+        for k, v in p.items():
+            if isinstance(v, list) and len(v) > 3:
+                print(f"  {k}: [{len(v)} items]")
+                for item in v[:3]:
+                    print(f"    - {json.dumps(item, ensure_ascii=False)}")
+                if len(v) > 3:
+                    print(f"    ... 共 {len(v)} 项")
+            elif isinstance(v, dict):
+                print(f"  {k}: {json.dumps(v, ensure_ascii=False)}")
+            else:
+                print(f"  {k}: {v}")
+    else:
+        print(f"  payload: {json.dumps(p, ensure_ascii=False)}")
+
+def on_disconnect(client, userdata, rc):
+    print(f"\n[!] 已断开 (rc={rc})")
+
+def signal_handler(sig, frame):
+    print(f"\n\n{'='*60}")
+    print("接收统计:")
+    for topic, count in sorted(received.items(), key=lambda x: -x[1]):
+        short = topic.replace(f'{TOPIC_PREFIX}/', '', 1)
+        print(f"  {short}: {count} 条")
+    print(f"\n共收到 {sum(received.values())} 条消息")
+    client.disconnect()
+    exit(0)
+
+signal.signal(signal.SIGINT, signal_handler)
+
+client = mqtt.Client(client_id='dtu_test_script', protocol=mqtt.MQTTv311)
+client.on_connect = on_connect
+client.on_message = on_message
+client.on_disconnect = on_disconnect
+
+print(f"正在连接 {HOST}:{PORT} ...")
+client.connect(HOST, PORT, keepalive=60)
+client.loop_forever()