物联网 · 盛世宏博 · 工业传感开发 · Python · Modbus TCP · 以太网温湿度传感器
关键词:pymodbus、ModbusClient、读保持寄存器、INT16补码、FLOAT32字节序、连接池、指数退避、质量位、断网缓存、生产级采集 标签:#物联网 #Modbus #TCP/IP #POE供电 #腾讯云 #Python #InfluxDB #以太网温湿度传感器 #网口温湿度变送器 #机房监控

网上“十行 pymodbus 读温湿度”的帖子很多,但离生产还差五件事:
本篇从一个能跑的 Demo,递进到能上线、能排错、能扛断网的生产级采集器。
# demo_smoke.py
from pymodbus.client import ModbusTcpClient
IP = "192.168.110.11"
UNIT = 1
with ModbusTcpClient(IP, port=502, timeout=2) as c:
if not c.connect():
print("connect fail")
exit(1)
# 40001 温度 / 40002 湿度 → 0-based 地址 0,读 2 个寄存器
rr = c.read_holding_registers(0, 2, slave=UNIT)
if rr.isError():
print("modbus error:", rr)
exit(1)
t, h = rr.registers
if t > 32767: # INT16 补码
t -= 65536
print(f"T={t*0.1:.1f}℃ H={h*0.1:.1f}%RH raw={[hex(x) for x in rr.registers]}")跑通这一版,确认:Ping 通、502 通、地址基对、Unit ID 对、缩放对。
跑不通就别往下封装——先回到上一篇的抓包定位法。
from dataclasses import dataclass
from typing import Optional
@dataclass
class Sample:
node_id: str
ip: str
ts: float # 采集时刻(不是发送时刻)
temp: Optional[float]
hum: Optional[float]
dew: Optional[float]
quality: str # GOOD / STALE / TIMEOUT / BAD_VALUE / PROTO_ERR
rtt_ms: float
raw: str # 原始寄存器十六进制,排错用# collector_node.py
import time, struct, socket
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
class NodeCollector:
def __init__(self, node_id, ip, unit=1, timeout=2.0):
self.node_id = node_id
self.ip = ip
self.unit = unit
self.timeout = timeout
self._client = None
def _ensure(self):
if self._client is None or not self._client.is_socket_open():
self._client = ModbusTcpClient(self.ip, port=502, timeout=self.timeout)
self._client.connect()
return self._client
def poll(self) -> Sample:
t0 = time.perf_counter()
try:
c = self._ensure()
# 40001~40004:温度/湿度/状态/露点(浮点2寄存器)
rr = c.read_holding_registers(0, 4, slave=self.unit)
rtt = (time.perf_counter() - t0) * 1000
if rr.isError():
if isinstance(rr, ExceptionResponse):
return Sample(self.node_id, self.ip, time.time(),
None, None, None, "PROTO_ERR", rtt, f"0x{rr.exception_code:02X}")
raise ModbusException("error response")
raw = rr.registers
t = raw[0]
if t > 32767:
t -= 65536
temp = t * 0.1
hum = raw[1] * 0.1
# FLOAT32 露点:按设备点表确认 ABCD / CDAB
dew_bytes = struct.pack('>HH', raw[3], raw[2]) # CDAB 示例
dew = struct.unpack('>f', dew_bytes)[0]
if not (-40 <= temp <= 85) or not (0 <= hum <= 100):
return Sample(self.node_id, self.ip, time.time(),
None, None, None, "BAD_VALUE", rtt, f"{raw}")
return Sample(self.node_id, self.ip, time.time(),
temp, hum, dew, "GOOD", rtt, f"{[hex(x) for x in raw]}")
except (socket.timeout, ConnectionRefusedError):
return Sample(self.node_id, self.ip, time.time(),
None, None, None, "TIMEOUT", 0, "")
except Exception as e:
return Sample(self.node_id, self.ip, time.time(),
None, None, None, "PROTO_ERR", 0, str(e))关键点:0x83 要能区分出来(ExceptionResponse),它是“设备活着但拒绝”,别当网络故障。
# conn_pool.py
import time, socket
from threading import Lock
class TcpPool:
def __init__(self, max_idle=30, max_life=300):
self._pool = {} # ip -> (sock, last, created)
self._lock = Lock()
self.max_idle = max_idle
self.max_life = max_life
def get(self, ip, port, timeout):
with self._lock:
now = time.time()
if ip in self._pool:
s, last, created = self._pool[ip]
if (now - last < self.max_idle) and (now - created < self.max_life):
return s
self._safe_close(s)
del self._pool[ip]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((ip, port))
self._pool[ip] = (s, now, now)
return s
def release(self, ip, s, close=False):
with self._lock:
if close:
self._safe_close(s)
self._pool.pop(ip, None)
else:
self._pool[ip] = (s, time.time(), self._pool[ip][2])
@staticmethod
def _safe_close(s):
try: s.close()
except: pass# batch.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
BACKOFF = [2, 4, 8, 15, 30] # 秒,上限 30
class BatchCollector:
def __init__(self, nodes, pool, max_workers=8, cycle=10.0):
self.nodes = nodes
self.pool = pool
self.max_workers = max_workers
self.cycle = cycle
self._bo = {n.node_id: 0 for n in nodes}
self._last_good = {n.node_id: 0.0 for n in nodes}
def poll_one(self, n):
# 退避:失败次数越高,跳过的轮次越多
if self._bo[n.node_id] and (time.time() - self._last_good[n.node_id]) < BACKOFF[min(self._bo[n.node_id], len(BACKOFF)-1)]:
return None
col = NodeCollector(n.node_id, n.ip, n.unit)
s = col.poll()
if s and s.quality == "GOOD":
self._bo[n.node_id] = 0
self._last_good[n.node_id] = time.time()
elif s:
self._bo[n.node_id] += 1
return s
def cycle_once(self):
t0 = time.time()
with ThreadPoolExecutor(max_workers=self.max_workers) as ex:
futs = [ex.submit(self.poll_one, n) for n in self.nodes]
for f in as_completed(futs, timeout=self.cycle):
s = f.result()
if s:
yield s
elapsed = time.time() - t0
time.sleep(max(0, self.cycle - elapsed))退避很重要:传感器断网时如果还每秒打一次,502 端口和 TCP 连接池会被打满,恢复也更慢。
# store.py
import sqlite3, json, time
DB = "env_cache.db"
def init_db():
with sqlite3.connect(DB) as c:
c.execute("""CREATE TABLE IF NOT EXISTS samples(
node_id TEXT, ts REAL, payload TEXT,
PRIMARY KEY(node_id, ts))""")
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
def save(s: Sample):
if s.quality != "GOOD":
return
with sqlite3.connect(DB) as c:
c.execute("INSERT OR IGNORE INTO samples VALUES(?,?,?)",
(s.node_id, s.ts, json.dumps({
"t": s.temp, "h": s.hum, "d": s.dew, "rtt": s.rtt_ms})))
def flush_to_platform(post_fn, since_ts=0):
"""补传:按采集时间戳顺序,失败回滚"""
with sqlite3.connect(DB) as c:
rows = c.execute("SELECT node_id,ts,payload FROM samples WHERE ts>? ORDER BY ts",
(since_ts,)).fetchall()
for node_id, ts, payload in rows:
if not post_fn(node_id, ts, payload):
return ts # 断点,下次从此继续
c.execute("DELETE FROM samples WHERE node_id=? AND ts=?", (node_id, ts))
return None两个不能省的原则:
(node_id, ts),平台侧 INSERT ... ON CONFLICT DO NOTHING,天然去重 ts 是采集时刻,断网 5 分钟后补传,曲线也不会出现“竖线” def set_threshold(col: NodeCollector, reg_addr, value, scale=0.1):
c = col._ensure()
c.write_register(reg_addr, int(value / scale), slave=col.unit)
time.sleep(0.05) # 设备处理延时
rb = c.read_holding_registers(reg_addr, 1, slave=col.unit)
if rb.isError() or rb.registers[0] != int(value / scale):
raise RuntimeError(f"{col.node_id} 回读不一致")生产里所有写操作:记录操作人、源 IP、改前、改后、毫秒时间戳,进审计表。
from influxdb_client import InfluxDBClient, Point, WriteOptions
def to_point(s: Sample) -> Point:
p = Point("env").tag("node", s.node_id).tag("ip", s.ip)
if s.quality == "GOOD":
p = p.field("temp", s.temp).field("hum", s.hum).field("dew", s.dew)
return p.field("quality", s.quality).field("rtt_ms", s.rtt_ms)Grafana 里:WHERE quality='GOOD',STALE/OFFLINE 不画图、只标状态,避免“平台上一根平直线”的假象。
项目 | 要求 |
|---|---|
pymodbus 版本 | 3.x(3.0 后 API 有变化,别混用教程) |
超时 | 2s,别默认无限 |
并发 | ≤8,别超传感器连接池 |
采集周期 | 机房 5~10s,库房 30s |
日志 | 存原始 hex + 质量位,不存密码 |
断网缓存 | SQLite WAL ≥24h |
系统服务 | systemd 托管,崩溃自动拉起 |
防火墙 | 只放网关 IP → 传感器:502 |
监控 | 采集器自身 /metrics(Prometheus)暴露在线节点数、错误率 |
Python 对接 Modbus TCP 温湿度传感器,门槛在“能读”,水平在“能稳、能查、能扛断网”:
做到这四点,这个采集器才从“脚本”变成机房里能跑三年的工业组件。
别在 Demo 里庆祝“读到 23.5℃”; 把网线拔掉再插上,曲线没断、数据没丢、日志里有原始 hex——那才叫对接完成。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。