
关键词:RJ45温湿度记录仪、Modbus TCP、SNMP、双协议接入、机房监控大屏、Grafana、Telegraf、设备侧代理、数据融合、告警联动 标签:#物联网 #Modbus #TCP/IP #POE供电 #InfluxDB #以太网温湿度传感器 #网口温湿度变送器 #机房监控 #边缘计算 #SNMP
前几篇把采集面拆开讲过:Modbus TCP 轮询、UDP 主动上报、双模式切换、断网补传。本篇落到交付现场——把 RJ45 PoE 温湿度记录仪接进机房服务器监控大屏,运营和运维要在同一面板上看到服务器指标和环境量。
现场约束决定了协议分工:
别把两件事混为一谈:Modbus TCP 是设备南向协议,SNMP 是北向/监控面集成协议。双协议不是设备同时跑两套采集,而是采集层做桥接——一端轮询设备 Modbus TCP,另一端以 SNMP agent 或 SNMP 可轮询代理暴露给大屏侧 NMS;或反向,大屏侧直接轮询设备 SNMP 子代理。
形态A:边缘桥接代理(推荐)
RJ45记录仪 ──Modbus TCP──▶ 边缘采集服务 ──本地状态库──▶ SNMP子代理 ──SNMP GET──▶ NMS/Grafana+Telegraf
│
└──HTTP/InfluxDB直推(并行)形态B:设备侧原生SNMP(少数设备支持)
记录仪 ──SNMP agent内置──▶ NMS直接轮询
└─Modbus TCP保留给配置/补传本文按形态A展开,工程上更通用,不改设备固件即可交付。
记录仪常见寄存器布局(需以厂家点表为准,下面为典型示例):
寄存器(0-based) | 类型 | 含义 | 缩放 |
|---|---|---|---|
0x0000 | RO holding | 温度×10, int16 | /10 |
0x0001 | RO holding | 湿度×10, int16 | /10 |
0x0002 | RO holding | 露点×10, int16 | /10 |
0x0003 | RO holding | 状态字 uint16 | bit0=传感器OK,bit1=PoE供电OK,bit2=存储写满,bit3=UDP/TCP模式 |
0x0004 | RO holding | 最后采样时标 epoch sec uint32(或分高低字) | — |
0x0010 | RW holding | 设定温度×10 | /10 |
0x0011 | RW holding | 设定湿度×10 | /10 |
0x0012 | RW holding | 上报周期 s | — |
0x0080 | RW coil | 远程复位 | — |
注意:保持寄存器 vs 输入寄存器、字序、IEEE754 32bit 时高低字顺序、符号扩展。上线前用 mbpoll 验证。
# 轮询单台验证
mbpoll -a -t 3 -r 1 -c 4 -P 502 -u 1 10.20.1.51
# -t 3 = holding registers, -r 1 注意mbpoll是1-based,映射到0-based偏移"""
env_bridge.py - 边缘采集 + SNMP子代理状态供给
"""
import asyncio, logging, time
from pymodbus.client import AsyncModbusTcpClient
from dataclasses import dataclass, asdict
import json
DEVICES = {
"A01": {"host": "10.20.1.51", "unit": 1},
"A02": {"host": "10.20.1.52", "unit": 1},
}
POLL_PERIOD = 5.0
STATE_TTL = 30.0 # 状态库老化阈值
@dataclass
class EnvSample:
dev: str
temp: float
hum: float
dewpoint: float
status: int
ts: float
reachable: bool = True
class Bridge:
def __init__(self):
self.clients = {}
self.state = {} # dev -> EnvSample
self._stop = False
async def ensure_client(self, dev):
cfg = DEVICES[dev]
c = self.clients.get(dev)
if c is None or not c.connected:
c = AsyncModbusTcpClient(cfg["host"], port=502)
await c.connect()
self.clients[dev] = c
return c
async def poll_one(self, dev):
cfg = DEVICES[dev]
try:
c = await self.ensure_client(dev)
rr = await c.read_holding_registers(0, 5, slave=cfg["unit"])
if rr.isError():
raise RuntimeError(str(rr))
v = rr.registers
temp = v[0]/10.0 if v[0] < 0x8000 else (v[0]-0x10000)/10.0
hum = v[1]/10.0
dp = v[2]/10.0 if v[2] < 0x8000 else (v[2]-0x10000)/10.0
status = v[3]
ts = time.time()
self.state[dev] = EnvSample(dev, temp, hum, dp, status, ts)
except Exception as e:
logging.warning("poll %s failed: %s", dev, e)
old = self.state.get(dev)
if old:
old.reachable = False
else:
self.state[dev] = EnvSample(dev, float('nan'), float('nan'), float('nan'), 0, time.time(), False)
async def poll_loop(self):
while not self.stop:
await asyncio.gather(*(self.poll_one(d) for d in DEVICES))
await asyncio.sleep(POLL_PERIOD)
# ---- SNMP子代理侧拉取接口 ----
def snapshot_json(self) -> str:
out = {}
now = time.time()
for dev, s in self.state.items():
stale = (now - s.ts) > STATE_TTL
out[dev] = {
"temp": s.temp, "hum": s.hum, "dewpoint": s.dewpoint,
"status": s.status, "reachable": s.reachable and not stale,
"age_s": round(now - s.ts, 1)
}
return json.dumps(out)
if __name__ == "__main__":
b = Bridge()
loop = asyncio.get_event_loop()
loop.create_task(b.poll_loop())
try:
loop.run_forever()
except KeyboardInterrupt:
b.stop = True生产环境补:连接池复用、超时(asyncio.wait_for 3s)、重试退避、多设备并发限制、状态库落盘/WAL。

下面给 net-snmp pass_persist 方案,落地快。
私有子树,挂在企业OID下,例如 .1.3.6.1.4.1.4711.1.1.x:
envKitEntry ::= SEQUENCE {
envKitIndex INTEGER, -- x.1.<idx>
envKitId OCTET STRING, -- x.2.<idx>
envKitTemp Gauge32, -- x.3.<idx> ×10 后返回,或返回原生
envKitHum Gauge32, -- x.4.<idx>
envKitDewpoint INTEGER, -- x.5.<idx>
envKitStatus INTEGER, -- x.6.<idx>
envKitReachable INTEGER, -- x.7.<idx> 1=up 2=down
envKitAge Gauge32 -- x.8.<idx> s
}注意 SNMPv2c/3 传输:NMS 轮询 UDP/161。pass_persist 通过 STDIN/STDOUT 与 snmpd 通信,snmpd 代发。
#!/usr/bin/env python3
"""
snmp_pass_persist.py - net-snmp pass_persist handler
从env_bridge拉取JSON状态,映射为SNMP叶子
"""
import sys, json, time
BRIDGE_STATE_PATH = "/run/env_bridge/state.json" # 或由本地HTTP拉取
OID_BASE = ".1.3.6.1.4.1.4711.1.1"
INDEX_MAP = {} # dev -> idx
def load_state():
try:
with open(BRDIGE_STATE_PATH) as f:
return json.load(f)
except Exception:
return {}
def build_rows(state):
rows = []
idx = 1
INDEX_MAP.clear()
for dev, m in state.items():
INDEX_MAP[dev] = idx
prefix = f"{OID_BASE}.3.{idx}"
rows.append((f"{OID_BASE}.1.{idx}", "INTEGER", str(idx)))
rows.append((f"{OID_BASE}.2.{idx}", "OCTETSTR", dev.encode().hex()))
rows.append((f"{OID_BASE}.3.{idx}", "GAUGE", str(int(round(m.get('temp',0)*10)))))
rows.append((f"{OID_BASE}.4.{idx}", "GAUGE", str(int(round(m.get('hum',0)*10)))))
rows.append((f"{OID_BASE}.5.{idx}", "INTEGER", str(int(round(m.get('dewpoint',0)*10)))))
rows.append((f"{OID_BASE}.6.{idx}", "INTEGER", str(m.get('status',0))))
rows.append((f"{OID_BASE}.7.{idx}", "INTEGER", "1" if m.get('reachable') else "2"))
rows.append((f"{OID_BASE}.8.{idx}", "GAUGE", str(int(m.get('age_s',0)))))
idx += 1
return rows
def handle_pdu():
# pass_persist协议:snmpd发送 "ping\nPONG\n" 或 "get\n<oid>\n" / "getnext\n<oid>\n"
while True:
line = sys.stdin.readline()
if not line:
break
cmd = line.strip()
if cmd == "ping":
sys.stdout.write("PONG\n")
sys.stdout.flush()
continue
if cmd in ("get", "getnext", "set"):
oid = sys.stdin.readline().strip()
state = load_state()
rows = build_rows(state)
keymap = {o:(t,v) for (o,t,v) in rows}
if cmd == "getnext":
# 简化:返回下一个>=oid的叶子;生产用完备字典序
cand = sorted(keymap.keys())
hit = next((k for k in cand if k >= oid), None)
if hit:
t,v = keymap[hit]
sys.stdout.write(f"{t}\n{hit}\n{v}\n")
else:
sys.stdout.write("NONE\n\n")
else:
if oid in keymap:
t,v = keymap[oid]
sys.stdout.write(f"{t}\n{oid}\n{v}\n")
else:
sys.stdout.write("NONE\n\n")
sys.stdout.flush()
continue
if __name__ == "__main__":
handle_pdu()更稳妥的做法:桥接服务暴露本地 HTTP/unix socket,pass_persist 脚本拉取后缓存;snmpd 配置:
pass_persist .1.3.6.1.4.1.4711.1.1 /usr/local/bin/snmp_pass_persist.py重启 snmpd,验证:
snmpwalk -v2c -c public localhost .1.3.6.1.4.1.4711.1.1
snmpget -v2c -c public localhost .1.3.6.1.4.1.4711.1.1.3.1注意 SELinux/AppArmor 放行、snmpd 社区字符串、UDP 防火墙、pass_persist 超时(缺省够用,状态拉取失败要降级)。
很多现场大屏已用 Grafana+Telegraf+InfluxDB。两条并行管道:
modbus input → InfluxDB → Grafana 面板。 snmp input 采交换机/PDU/UPS → 同一 InfluxDB → 同一仪表盘。 交付上这就够了,但用户点名双协议对接大屏,重点在让大屏侧 NMS 通过 SNMP 拿到环境量。所以保留桥接代理;同时把数据推一份进 InfluxDB,Grafana 直接渲染,避免 NMS 轮询延迟。
env。 [[inputs.snmp.table]]
oid = ".1.3.6.1.4.1.4711.1.1"
name = "envkit"
[[inputs.snmp.table.field]]
oid = ".1.3.6.1.4.1.4711.1.1.2.1"
name = "dev_id"
is_tag = true
[[inputs.snmp.table.field]]
oid = ".1.3.6.1.4.1.4711.1.1.3.1"
name = "temp_x10"
[[inputs.snmp.table.field]]
oid = ".1.3.6.1.4.1.4711.1.1.4.1"
name = "hum_x10"
[[inputs.snmp.table.field]]
oid = ".1.3.6.1.4.1.4711.1.1.7.1"
name = "reachable"更实际:Telegraf snmp 采整个表,用 agent_host tag 区分。InfluxDB 中字段 temp_x10/10.0 在面板表达式处理,或在 Telegraf processor 转换。
示例面板变量:
$room, $rack_row, $sensor
FROM env WHERE room=$room写回示例(桥接服务暴露 HTTP/gRPC,或直接在采集服务内):
async def set_setpoint(dev, t_sp, h_sp):
cfg = DEVICES[dev]
c = await ensure_client(dev)
await c.write_registers(0x10, [int(t_sp*10), int(h_sp*10)], slave=cfg["unit"])snmpd -f -Le 前台跑。 
双协议对接的本质是控制面与监控面解耦:南向 Modbus TCP 管设备,北向 SNMP 融进既有监控大屏体系。工程落地关键在边缘桥接层——把设备状态归一化后,既供 SNMP 轮询,又推时序库直渲。别让 NMS 直接戳设备私有协议,也别为大屏另起一套采集栈;复用管道、明确边界、老化判定到位,交付就能闭环。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。