物联网 · 盛世宏博 · 协议驱动 · RS485 · Modbus RTU · 档案监控平台
关键词:档案环境监控平台、恒湿消毒净化一体机、RS485协议驱动、Modbus RTU、数据模型归一化、多厂家适配、驱动开发 标签:#物联网 #Modbus #RS485 #TCP/IP #POE供电 #腾讯云 #Python #以太网温湿度传感器 #网口温湿度变送器 #机房监控

档案监控平台不是通用SCADA,它面对的是特定行业设备+特定业务语义。通用Modbus驱动只能解决"读寄存器",但解决不了:
协议驱动 = 通用通信能力 + 设备厂商方言 + 业务数据模型
┌─────────────────────────────────────────────────────────┐
│ 档案环境监控平台(业务层) │
│ 设备管理 · 数据点模型 · 联动引擎 · 告警 · 报表 │
├─────────────────────────────────────────────────────────┤
│ 协议驱动层(本文核心) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ 驱动管理器│ │ 设备适配器│ │ 寄存器映射引擎 │ │
│ │ 生命周期 │ │ 厂家A/B/C│ │ 地址偏移·缩放·字节序│ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 通信层(通用能力) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ ModbusRTU│ │ ModbusTCP│ │ 串口管理 · 超时重试 │ │
│ │ 主站栈 │ │ 客户端 │ │ 帧间隔 · 并发控制 │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 硬件接口层 │
│ 串口服务器 / USB-RS485 / 边缘网关 / 直接串口 │
└─────────────────────────────────────────────────────────┘# driver_core.py - 驱动核心抽象
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Union
from enum import Enum
class DataType(Enum):
U16 = "uint16"
S16 = "int16"
U32 = "uint32"
S32 = "int32"
F32 = "float32"
BIT = "bit"
class AccessMode(Enum):
RO = "readonly"
WO = "writeonly"
RW = "readwrite"
@dataclass
class RegisterMap:
"""寄存器映射定义"""
name: str # 统一数据点名
display_name: str # 显示名称
function_code: int # Modbus功能码 (3=保持寄存器, 4=输入寄存器)
address: int # 寄存器地址(厂家原始地址)
data_type: DataType
scale: float = 1.0 # 缩放因子
offset: float = 0.0 # 偏移量
unit: str = ""
access: AccessMode = AccessMode.RO
bit_index: Optional[int] = None # 位标志时指定位号
description: str = ""
@dataclass
class DeviceProfile:
"""设备厂商档案"""
vendor: str # 厂家名称
model: str # 设备型号
protocol: str # 协议类型
address_mode: str # "0-based" 或 "1-based"
baudrate: int = 9600
bytesize: int = 8
parity: str = "N"
stopbits: int = 1
slave_id_range: tuple = (1, 247)
registers: List[RegisterMap] = field(default_factory=list)
read_groups: List[Dict] = field(default_factory=list) # 连续读取分组# adapter_vendor_a.py
VENDOR_A_PROFILE = DeviceProfile(
vendor="厂家甲",
model="HSP-A系列",
protocol="Modbus RTU",
address_mode="0-based",
baudrate=9600,
registers=[
RegisterMap(
name="env.temp.return",
display_name="回风温度",
function_code=4, # 输入寄存器
address=0x0001,
data_type=DataType.U16,
scale=0.1,
unit="℃",
access=AccessMode.RO
),
RegisterMap(
name="env.humidity.return",
display_name="回风湿度",
function_code=4,
address=0x0002,
data_type=DataType.U16,
scale=0.1,
unit="%RH",
access=AccessMode.RO
),
RegisterMap(
name="ctrl.hum_setpoint",
display_name="设定湿度",
function_code=3, # 保持寄存器
address=0x0064,
data_type=DataType.U16,
scale=0.1,
unit="%RH",
access=AccessMode.RW
),
RegisterMap(
name="dev.run_status",
display_name="运行状态",
function_code=3,
address=0x0000,
data_type=DataType.U16,
access=AccessMode.RO
),
RegisterMap(
name="dev.fault_code",
display_name="故障码",
function_code=3,
address=0x000F,
data_type=DataType.U16,
access=AccessMode.RO
),
# 消毒使能(干接点外挂,无RS485接口)
# 通过串口服务器DO模拟按键
],
read_groups=[
# 连续读取分组:减少帧数
{"start": 0x0000, "count": 3, "function_code": 4}, # 状态+温湿度
{"start": 0x0000, "count": 1, "function_code": 3}, # 运行模式
{"start": 0x000F, "count": 1, "function_code": 3}, # 故障码
]
)# adapter_vendor_b.py
VENDOR_B_PROFILE = DeviceProfile(
vendor="厂家乙",
model="HSP-B系列",
protocol="Modbus RTU",
address_mode="1-based", # 关键差异!
baudrate=9600,
registers=[
RegisterMap(
name="env.temp.return",
display_name="回风温度",
function_code=4,
address=40002, # 1-based,实际对应0x0001
data_type=DataType.U16,
scale=0.1,
unit="℃",
access=AccessMode.RO
),
RegisterMap(
name="env.humidity.return",
display_name="回风湿度",
function_code=4,
address=40003, # 1-based,实际对应0x0002
data_type=DataType.U16,
scale=0.1,
unit="%RH",
access=AccessMode.RO
),
RegisterMap(
name="ctrl.hum_setpoint",
display_name="设定湿度",
function_code=3,
address=40101, # 1-based
data_type=DataType.U16,
scale=0.1,
unit="%RH",
access=AccessMode.RW
),
RegisterMap(
name="dev.run_status",
display_name="运行状态",
function_code=3,
address=40001,
data_type=DataType.U16,
access=AccessMode.RO
),
RegisterMap(
name="dev.fault_code",
display_name="故障码",
function_code=3,
address=40016,
data_type=DataType.U16,
access=AccessMode.RO
),
RegisterMap(
name="disinfect.enable",
display_name="消毒使能",
function_code=3,
address=40120,
data_type=DataType.U16,
access=AccessMode.RW
),
],
read_groups=[
{"start": 40001, "count": 3, "function_code": 4}, # 状态+温湿度
{"start": 40001, "count": 1, "function_code": 3}, # 运行模式
{"start": 40016, "count": 1, "function_code": 3}, # 故障码
]
)# address_translator.py
class AddressTranslator:
"""处理0-based和1-based地址差异"""
@staticmethod
def to_wire_address(profile: DeviceProfile, logical_address: int) -> int:
"""
将逻辑地址转为实际通信地址
0-based厂家:直接用
1-based厂家:减1(因为Modbus协议本身是0-based的)
"""
if profile.address_mode == "0-based":
return logical_address
elif profile.address_mode == "1-based":
# 1-based地址40001 → 协议地址0x0000
if 30001 <= logical_address <= 39999:
# 输入寄存器
return logical_address - 30001
elif 40001 <= logical_address <= 49999:
# 保持寄存器
return logical_address - 40001
else:
return logical_address
else:
raise ValueError(f"Unknown address mode: {profile.address_mode}")
@staticmethod
def to_logical_address(profile: DeviceProfile, wire_address: int, register_type: str) -> int:
"""将协议地址转为逻辑地址"""
if profile.address_mode == "0-based":
return wire_address
elif profile.address_mode == "1-based":
if register_type == "input":
return wire_address + 30001
elif register_type == "holding":
return wire_address + 40001
else:
return wire_address
else:
raise ValueError(f"Unknown address mode: {profile.address_mode}")# driver_manager.py
import time
import threading
from queue import Queue, Empty
from typing import Callable, Dict, List
class DeviceDriver:
"""单设备驱动实例"""
def __init__(self, device_id: str, slave_id: int,
profile: DeviceProfile, comm_channel):
self.device_id = device_id
self.slave_id = slave_id
self.profile = profile
self.comm = comm_channel # 串口/网关通信句柄
self.translator = AddressTranslator()
self._last_read: Dict[str, float] = {}
self._last_write: Dict[str, float] = {}
self._fail_count: Dict[str, int] = {}
self._online = True
self._lock = threading.Lock()
def read_point(self, point_name: str) -> Optional[float]:
"""读取单个数据点"""
reg = self._find_register(point_name)
if not reg:
return None
wire_addr = self.translator.to_wire_address(self.profile, reg.address)
with self._lock:
try:
raw = self.comm.read_registers(
slave=self.slave_id,
function_code=reg.function_code,
address=wire_addr,
count=self._get_data_length(reg.data_type)
)
if raw is None:
self._fail_count[point_name] = self._fail_count.get(point_name, 0) + 1
return None
value = self._decode(raw, reg)
self._last_read[point_name] = value
self._fail_count[point_name] = 0
return value
except Exception as e:
self._fail_count[point_name] = self._fail_count.get(point_name, 0) + 1
return None
def write_point(self, point_name: str, value: float) -> bool:
"""写入单个数据点"""
reg = self._find_register(point_name)
if not reg or reg.access == AccessMode.RO:
return False
wire_addr = self.translator.to_wire_address(self.profile, reg.address)
encoded = self._encode(value, reg)
with self._lock:
try:
result = self.comm.write_registers(
slave=self.slave_id,
address=wire_addr,
values=encoded
)
if result:
self._last_write[point_name] = value
# 写后回读确认
time.sleep(0.05)
readback = self.read_point(point_name)
if readback is not None and abs(readback - value) < 0.01:
return True
return False
except Exception:
return False
def read_group(self, group: Dict) -> Dict[str, float]:
"""按分组连续读取(减少帧数)"""
results = {}
start_addr = group["start"]
count = group["count"]
fc = group["function_code"]
wire_addr = self.translator.to_wire_address(self.profile, start_addr)
with self._lock:
try:
raw = self.comm.read_registers(
slave=self.slave_id,
function_code=fc,
address=wire_addr,
count=count
)
if raw is None:
return results
# 解析分组内各寄存器
for reg in self.profile.registers:
reg_wire = self.translator.to_wire_address(self.profile, reg.address)
if fc == reg.function_code:
offset = reg_wire - wire_addr
if 0 <= offset < len(raw):
# 单寄存器值提取
val = raw[offset]
decoded = self._apply_scale(val, reg)
results[reg.name] = decoded
except Exception:
pass
return results
def _decode(self, raw: List[int], reg: RegisterMap) -> float:
"""原始数据解码"""
if reg.data_type in (DataType.U16, DataType.S16):
val = raw[0]
if reg.data_type == DataType.S16 and val > 32767:
val -= 65536
elif reg.data_type == DataType.U32:
val = (raw[0] << 16) | raw[1]
elif reg.data_type == DataType.F32:
import struct
packed = struct.pack('>HH', raw[0], raw[1])
val = struct.unpack('>f', packed)[0]
else:
val = raw[0]
return self._apply_scale(val, reg)
def _apply_scale(self, val: float, reg: RegisterMap) -> float:
return val * reg.scale + reg.offset
def _encode(self, value: float, reg: RegisterMap) -> List[int]:
"""编码写入值"""
raw_val = int((value - reg.offset) / reg.scale)
if reg.data_type in (DataType.U16, DataType.S16):
return [raw_val & 0xFFFF]
elif reg.data_type == DataType.U32:
return [(raw_val >> 16) & 0xFFFF, raw_val & 0xFFFF]
else:
return [raw_val & 0xFFFF]
def _get_data_length(self, data_type: DataType) -> int:
if data_type in (DataType.U16, DataType.S16, DataType.BIT):
return 1
elif data_type in (DataType.U32, DataType.S32, DataType.F32):
return 2
return 1
def _find_register(self, point_name: str) -> Optional[RegisterMap]:
for reg in self.profile.registers:
if reg.name == point_name:
return reg
return None
@property
def online(self) -> bool:
return self._online
def set_online(self, status: bool):
self._online = status
class DriverManager:
"""驱动管理器:管理所有设备驱动实例"""
def __init__(self, poll_interval=5.0, offline_poll=30.0):
self.drivers: Dict[str, DeviceDriver] = {}
self.poll_interval = poll_interval
self.offline_poll = offline_poll
self._running = False
self._poll_thread = None
self._data_callback: Optional[Callable] = None
self._alarm_callback: Optional[Callable] = None
def register_device(self, device_id: str, slave_id: int,
profile: DeviceProfile, comm_channel) -> DeviceDriver:
"""注册设备"""
driver = DeviceDriver(device_id, slave_id, profile, comm_channel)
self.drivers[device_id] = driver
return driver
def unregister_device(self, device_id: str):
"""注销设备"""
if device_id in self.drivers:
del self.drivers[device_id]
def start(self):
"""启动轮询"""
self._running = True
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start()
def stop(self):
"""停止轮询"""
self._running = False
if self._poll_thread:
self._poll_thread.join(timeout=5)
def set_data_callback(self, callback: Callable):
self._data_callback = callback
def set_alarm_callback(self, callback: Callable):
self._alarm_callback = callback
def _poll_loop(self):
"""主轮询循环"""
while self._running:
for device_id, driver in self.drivers.items():
try:
if driver.online:
# 在线设备:按分组读取
for group in driver.profile.read_groups:
results = driver.read_group(group)
if results and self._data_callback:
self._data_callback(device_id, results)
time.sleep(0.02) # 帧间间隔
else:
# 离线设备:低频探活
if self._should_probe(driver):
result = driver.read_point("dev.run_status")
if result is not None:
driver.set_online(True)
if self._alarm_callback:
self._alarm_callback(device_id, "RECOVERED", {})
except Exception as e:
pass
time.sleep(self.poll_interval)
def _should_probe(self, driver: DeviceDriver) -> bool:
"""判断是否该探活(简化:按时间)"""
return True # 实际应记录上次探活时间
def write_control(self, device_id: str, point_name: str, value: float) -> bool:
"""平台下发控制指令"""
if device_id not in self.drivers:
return False
driver = self.drivers[device_id]
return driver.write_point(point_name, value)# platform_integration.py
class PlatformBridge:
"""驱动层与平台业务层的桥梁"""
def __init__(self, driver_manager: DriverManager):
self.dm = driver_manager
self.dm.set_data_callback(self._on_data)
self.dm.set_alarm_callback(self._on_alarm)
def _on_data(self, device_id: str, data: Dict):
"""数据回调:推送到平台时序库"""
# 实际实现中写入InfluxDB/TDengine
for point_name, value in data.items():
record = {
"device_id": device_id,
"point": point_name,
"value": value,
"timestamp": time.time()
}
# push_to_timeseries(record)
pass
def _on_alarm(self, device_id: str, alarm_type: str, detail: Dict):
"""告警回调"""
# 推送告警到平台
pass
def get_unified_model(self, device_id: str) -> Dict:
"""获取设备的统一数据模型视图"""
if device_id not in self.dm.drivers:
return {}
driver = self.dm.drivers[device_id]
model = {}
for reg in driver.profile.registers:
value = driver.read_point(reg.name)
if value is not None:
model[reg.name] = {
"value": value,
"unit": reg.unit,
"display": reg.display_name,
"timestamp": time.time()
}
return model
def control_device(self, device_id: str, action: str, params: Dict) -> bool:
"""平台控制接口"""
mapping = {
"set_humidity": "ctrl.hum_setpoint",
"set_mode": "dev.run_status",
"start_disinfect": "disinfect.enable",
}
point_name = mapping.get(action)
if not point_name:
return False
value = params.get("value", 0)
return self.dm.write_control(device_id, point_name, value)# devices.yaml - 设备配置
global:
poll_interval: 5
offline_poll: 30
comm_timeout: 0.4
retry_count: 2
inter_frame_delay: 0.02
channels:
- id: "uart_bus_1"
type: "serial"
port: "/dev/ttyUSB0"
baudrate: 9600
bytesize: 8
parity: "N"
stopbits: 1
- id: "tcp_gateway_a"
type: "tcp"
host: "192.168.1.101"
port: 502
unit_id: 1
devices:
- id: "HSP-A01"
name: "一体机-01(A库)"
vendor: "厂家甲"
model: "HSP-A系列"
channel: "uart_bus_1"
slave_id: 1
zone: "A库"
profile: "vendor_a"
- id: "HSP-B01"
name: "一体机-02(A库)"
vendor: "厂家乙"
model: "HSP-B系列"
channel: "uart_bus_1"
slave_id: 2
zone: "A库"
profile: "vendor_b"
- id: "HSP-A02"
name: "一体机-03(B库)"
vendor: "厂家甲"
model: "HSP-A系列"
channel: "uart_bus_1"
slave_id: 3
zone: "B库"
profile: "vendor_a"# main.py - 驱动启动入口
import yaml
from driver_manager import DriverManager
from adapter_vendor_a import VENDOR_A_PROFILE
from adapter_vendor_b import VENDOR_B_PROFILE
from platform_integration import PlatformBridge
PROFILES = {
"vendor_a": VENDOR_A_PROFILE,
"vendor_b": VENDOR_B_PROFILE,
}
def load_config(path="devices.yaml"):
with open(path, 'r') as f:
return yaml.safe_load(f)
def main():
config = load_config()
dm = DriverManager(
poll_interval=config["global"]["poll_interval"],
offline_poll=config["global"]["offline_poll"]
)
# 初始化通信通道
channels = {}
for ch_cfg in config["channels"]:
if ch_cfg["type"] == "serial":
# 实际实现中初始化串口
channels[ch_cfg["id"]] = init_serial(ch_cfg)
elif ch_cfg["type"] == "tcp":
channels[ch_cfg["id"]] = init_tcp(ch_cfg)
# 注册设备
for dev_cfg in config["devices"]:
profile = PROFILES[dev_cfg["profile"]]
channel = channels[dev_cfg["channel"]]
dm.register_device(
device_id=dev_cfg["id"],
slave_id=dev_cfg["slave_id"],
profile=profile,
comm_channel=channel
)
# 平台桥接
bridge = PlatformBridge(dm)
# 启动
dm.start()
# 主循环(平台业务逻辑)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
dm.stop()
if __name__ == "__main__":
main()恒湿消毒净化一体机的RS485协议驱动开发,核心不是"怎么读Modbus",而是怎么把多厂家、多方言的设备归一化为平台能理解的统一定义。做好三件事:
驱动层做好了,平台业务层就只需要关心"env.humidity.return是多少"和"ctrl.hum_setpoint设成多少",不用管底下是厂家甲还是厂家乙、地址是0x0002还是40003。这才是档案环境监控平台该有的架构。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。