modbus_rtu.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. from typing import Optional
  2. # ========== 地址配置协议 ==========
  3. def build_broadcast_query() -> bytes:
  4. """构建广播查询指令
  5. DTU定时发送广播指令查询未配置地址的从机
  6. 格式: 00 40 00 40 00 (无CRC, 固定5字节)
  7. """
  8. return bytes([0x00, 0x40, 0x00, 0x40, 0x00])
  9. def parse_device_response(data: bytes) -> Optional[dict]:
  10. """解析从机应答
  11. 从机应答格式: 00 + 41 + 12个UID + CRC_L + CRC_H
  12. 例如: 00 41 18 00 40 00 14 00 00 59 59 54 30 56 AE 34
  13. """
  14. if len(data) < 16:
  15. return {'error': '数据长度不足', 'raw_data': data.hex()}
  16. if data[1] != 0x41:
  17. return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
  18. uid_bytes = data[2:14]
  19. uid_hex = uid_bytes.hex()
  20. # CRC 校验失败则拒绝
  21. if not verify_crc16(data[:14]):
  22. return {'error': 'CRC校验失败', 'raw_data': data.hex()}
  23. # UID 有效性检查:拒绝明显是噪声或碰撞产生的数据
  24. zero_count = uid_bytes.count(0)
  25. if zero_count > 6:
  26. return {'error': f"UID中零字节过多({zero_count}/12)", 'raw_data': data.hex()}
  27. # 检查碰撞:UID 中不应有大量重复字节
  28. from collections import Counter
  29. counts = Counter(uid_bytes)
  30. if counts.most_common(1)[0][1] > 8:
  31. return {'error': f"UID中单字节重复过多({counts.most_common(1)})", 'raw_data': data.hex()}
  32. return {
  33. 'function_code': 0x41,
  34. 'uid': uid_hex,
  35. 'uid_readable': ':'.join(f'{b:02x}' for b in uid_bytes),
  36. 'source_address': data[0],
  37. 'raw_data': data.hex()
  38. }
  39. def build_confirm_address(device_address: int, uid: bytes) -> bytes:
  40. """构建确认地址指令
  41. DTU遍历已存储的UID和地址,如果存在匹配的UID则发送确认指令
  42. 格式: add + 44 + 00 + 12个UID + CRC_L + CRC_H
  43. """
  44. if len(uid) != 12:
  45. raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
  46. data = bytes([device_address, 0x44, 0x00]) + uid
  47. return data + calculate_crc16(data)
  48. def build_assign_address(uid: bytes, address: int) -> bytes:
  49. """构建分配地址指令
  50. 如果DTU中没有该UID的记录,则分配新地址
  51. 格式: 00 + 42 + 12个UID + add + CRC_L + CRC_H
  52. """
  53. if len(uid) != 12:
  54. raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
  55. if address < 1 or address > 247:
  56. raise ValueError(f"设备地址必须在1-247之间,当前: {address}")
  57. data = bytes([0x00, 0x42]) + uid + bytes([address])
  58. return data + calculate_crc16(data)
  59. def parse_address_assignment_response(data: bytes) -> dict:
  60. """解析地址分配响应
  61. 模块应答格式: add + 43 + 00 + CRC_L + CRC_H
  62. 例如: 01 43 00 11 30
  63. """
  64. if len(data) < 5:
  65. return {'error': '数据长度不足', 'raw_data': data.hex()}
  66. device_address = data[0]
  67. if data[1] != 0x43:
  68. return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
  69. if not verify_crc16(data):
  70. logger.warning(f"CRC校验失败: {data.hex()}")
  71. return {
  72. 'device_address': device_address,
  73. 'function_code': 0x43,
  74. 'raw_data': data.hex()
  75. }
  76. class AddressConfigProtocol:
  77. """地址配置协议处理器"""
  78. def __init__(self, serial_port):
  79. self.serial = serial_port
  80. self.default_timeout = 2.0
  81. self.stored_devices = {}
  82. def add_stored_device(self, uid: str, address: int):
  83. """添加已存储的设备"""
  84. self.stored_devices[uid] = address
  85. def get_stored_devices(self) -> dict:
  86. """获取已存储的设备列表"""
  87. return self.stored_devices.copy()
  88. def broadcast_query(self, timeout: float = None) -> list:
  89. """send broadcast query and collect all valid 00 41 responses"""
  90. import time
  91. request = build_broadcast_query()
  92. logger.info(f"send broadcast: {request.hex()}")
  93. timeout = timeout or self.default_timeout
  94. response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=16)
  95. responses = []
  96. if response and len(response) >= 16:
  97. logger.info(f"got raw response ({len(response)}B): {response.hex()}")
  98. # RS485 半双工会回显已发送的数据,导致 response 开头可能有 5 字节回显
  99. # 在 response 中搜索所有 00 41 标记,尝试从中提取 16 字节帧解析
  100. for i in range(len(response) - 15 + 1):
  101. if response[i] == 0x00 and response[i + 1] == 0x41:
  102. chunk = response[i:i + 16]
  103. if len(chunk) >= 16:
  104. parsed = parse_device_response(chunk)
  105. if "error" not in parsed:
  106. if not any(p.get('uid') == parsed['uid'] for p in responses):
  107. responses.append(parsed)
  108. else:
  109. logger.info(f"no response or too short: {len(response) if response else 0}B")
  110. logger.info(f"broadcast done, got {len(responses)} valid unique responses")
  111. return responses
  112. def _next_available_address(self) -> int:
  113. """从 1~247 中找一个未被 stored_devices 占用的地址"""
  114. used = set(self.stored_devices.values())
  115. for addr in range(1, 248):
  116. if addr not in used:
  117. return addr
  118. raise RuntimeError("Modbus 地址 1~247 已全部占用")
  119. def process_responses(self, responses: list) -> list:
  120. """处理广播查询响应:给每个应答设备分配地址(优先使用已记录地址)"""
  121. import time
  122. results = []
  123. for resp in responses:
  124. if 'error' in resp:
  125. results.append({'status': 'error', 'message': resp['error']})
  126. continue
  127. uid = resp['uid']
  128. src_addr = resp.get('source_address', 0)
  129. device_address = self.stored_devices.get(uid)
  130. # 优先使用地址表中已保存的地址;没有则分配下一个可用地址
  131. if device_address:
  132. new_address = device_address
  133. logger.info(f"UID={uid} 源地址={src_addr},地址表存在记录,分配已有地址={new_address}")
  134. else:
  135. new_address = self._next_available_address()
  136. logger.info(f"UID={uid} 源地址={src_addr},地址表无记录,分配新地址={new_address}")
  137. request = build_assign_address(bytes.fromhex(uid), new_address)
  138. logger.info(f"发送分配地址帧: {request.hex()}")
  139. self.serial.flush_input()
  140. time.sleep(0.05)
  141. success, msg = self.serial.send_raw(request)
  142. if not success:
  143. logger.error(f"UID={uid} 分配地址帧发送失败: {msg}")
  144. results.append({
  145. 'status': 'error',
  146. 'uid': uid,
  147. 'message': f"send failed: {msg}"
  148. })
  149. continue
  150. # 等待并解析从机的 43 确认响应
  151. time.sleep(0.1)
  152. ack = self.serial.send_and_wait(b'', timeout=0.5, min_response_bytes=5)
  153. ack_parsed = None
  154. if ack and len(ack) >= 5:
  155. # 过滤回显/噪声,找到 43 响应帧
  156. for i in range(len(ack) - 4):
  157. if ack[i + 1] == 0x43:
  158. chunk = ack[i:i + 5]
  159. ack_parsed = parse_address_assignment_response(chunk)
  160. break
  161. if ack_parsed and 'error' not in ack_parsed:
  162. logger.info(f"UID={uid} 地址={new_address} 已确认 (响应: {ack_parsed.get('raw_data')})")
  163. self.stored_devices[uid] = new_address
  164. results.append({
  165. 'status': 'assigned',
  166. 'uid': uid,
  167. 'address': new_address,
  168. 'request': request.hex(),
  169. 'ack': ack_parsed.get('raw_data')
  170. })
  171. else:
  172. # 即使未收到确认也保存地址:部分从机不返回 43,但实际已生效
  173. logger.warning(f"UID={uid} 地址={new_address} 未收到 43 确认,仍视为已分配")
  174. self.stored_devices[uid] = new_address
  175. results.append({
  176. 'status': 'assigned',
  177. 'uid': uid,
  178. 'address': new_address,
  179. 'request': request.hex(),
  180. 'ack': ack.hex() if ack else None
  181. })
  182. return results
  183. def auto_configure(self, timeout: float = None) -> dict:
  184. """自动配置所有未配置的设备"""
  185. logger.info("开始自动配置设备...")
  186. responses = self.broadcast_query(timeout)
  187. if not responses:
  188. return {
  189. 'success': True,
  190. 'message': '未发现任何从机设备',
  191. 'discovered': 0,
  192. 'configured': 0,
  193. 'results': []
  194. }
  195. results = self.process_responses(responses)
  196. assigned = sum(1 for r in results if r.get('status') == 'assigned')
  197. errors = sum(1 for r in results if r.get('status') == 'error')
  198. return {
  199. 'success': errors == 0,
  200. 'discovered': len(responses),
  201. 'confirmed': 0,
  202. 'assigned': assigned,
  203. 'errors': errors,
  204. 'results': results,
  205. 'stored_devices': self.get_stored_devices()
  206. }
  207. def save_config(self, filepath: str) -> bool:
  208. """保存配置到文件"""
  209. import json
  210. try:
  211. with open(filepath, 'w') as f:
  212. json.dump(self.stored_devices, f, indent=2)
  213. return True
  214. except Exception as e:
  215. logger.error(f"保存配置失败: {e}")
  216. return False
  217. def load_config(self, filepath: str) -> bool:
  218. """从文件加载配置"""
  219. import json
  220. try:
  221. with open(filepath, 'r') as f:
  222. self.stored_devices = json.load(f)
  223. return True
  224. except Exception as e:
  225. logger.error(f"加载配置失败: {e}")
  226. return False
  227. # ========== Modbus RTU Client ==========
  228. # 天线地址映射表(天线编号 -> Modbus寄存器地址)
  229. ANTENNA_ADDRESSES = {i: 0x0001 + (i - 1) for i in range(1, 25)}
  230. import struct
  231. import logging
  232. logger = logging.getLogger('serial_mqtt_gateway')
  233. def calculate_crc16(data: bytes) -> bytes:
  234. """计算Modbus CRC16校验"""
  235. crc = 0xFFFF
  236. for byte in data:
  237. crc ^= byte
  238. for _ in range(8):
  239. if crc & 0x0001:
  240. crc = (crc >> 1) ^ 0xA001
  241. else:
  242. crc >>= 1
  243. return struct.pack('<H', crc)
  244. def verify_crc16(data: bytes) -> bool:
  245. """验证Modbus CRC16校验"""
  246. if len(data) < 2:
  247. return False
  248. received_crc = struct.unpack('<H', data[-2:])[0]
  249. calculated_crc = struct.unpack('<H', calculate_crc16(data[:-2]))[0]
  250. return received_crc == calculated_crc
  251. class ModbusRTUClient:
  252. """Modbus RTU 客户端"""
  253. def __init__(self, serial_port):
  254. self.serial = serial_port
  255. self.default_timeout = 2.0
  256. def _build_request(self, device_address: int, function_code: int, data: bytes = b'') -> bytes:
  257. """构建Modbus请求帧"""
  258. pdu = bytes([device_address, function_code]) + data
  259. return pdu + calculate_crc16(pdu)
  260. def _send_receive(self, request: bytes, expected_min_len: int = 5, timeout: float = None) -> dict:
  261. """send request and receive response using sync method"""
  262. timeout = timeout or self.default_timeout
  263. if not self.serial.ser:
  264. return {"error": "serial not connected"}
  265. response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=expected_min_len)
  266. if not response or len(response) < expected_min_len:
  267. return {"error": "response timeout or too short", "raw_data": response.hex() if response else ""}
  268. if not verify_crc16(response):
  269. logger.warning(f"CRC check failed: {response.hex()}")
  270. return {"error": "CRC check failed", "raw_data": response.hex()}
  271. return {
  272. "success": True,
  273. "device_address": response[0],
  274. "function_code": response[1],
  275. "data": response[2:-2].hex(),
  276. "raw_data": response.hex()
  277. }
  278. def read_antenna_card(self, device_address: int, antenna_num: int, timeout: float = None) -> dict:
  279. """读取天线卡号
  280. 协议: 读寄存器 0x0002+(ant-1)*4, 共4个寄存器(8字节卡号)
  281. """
  282. reg_addr = 0x0002 + (antenna_num - 1) * 4
  283. request_data = struct.pack('>HH', reg_addr, 4) # 读取4个寄存器
  284. request = self._build_request(device_address, 0x03, request_data)
  285. result = self._send_receive(request, timeout=timeout)
  286. if 'error' in result:
  287. return result
  288. data_str = result.get('data', '')
  289. data_bytes = bytes.fromhex(data_str) if isinstance(data_str, str) and data_str else b''
  290. if len(data_bytes) >= 8:
  291. # 8字节卡号, big-endian
  292. card_number = int.from_bytes(data_bytes[:8], 'big')
  293. card_str = data_bytes[:8].hex()
  294. return {
  295. 'card_number': card_number,
  296. 'card_number_hex': f'0x{card_number:016x}',
  297. 'card_number_str': card_str,
  298. 'antenna': antenna_num,
  299. 'uid': ':'.join(data_bytes[:8].hex()[i:i+2] for i in range(0, 16, 2)),
  300. **result
  301. }
  302. return {'error': f'数据长度不足({len(data_bytes)}B,需要8B)', **result}
  303. def read_holding_registers(self, device_address: int, start_address: int, quantity: int = 1, timeout: float = None) -> dict:
  304. """读取保持寄存器"""
  305. request_data = struct.pack('>HH', start_address, quantity)
  306. request = self._build_request(device_address, 0x03, request_data)
  307. result = self._send_receive(request, timeout=timeout)
  308. if 'error' in result:
  309. return result
  310. data_str = result.get('data', '')
  311. data_bytes = bytes.fromhex(data_str) if isinstance(data_str, str) and data_str else b''
  312. registers = []
  313. for i in range(0, len(data_bytes), 2):
  314. if i + 1 < len(data_bytes):
  315. registers.append(struct.unpack('>H', data_bytes[i:i+2])[0])
  316. return {'registers': registers, **result}
  317. def write_single_register(self, device_address: int, register_address: int, value: int, timeout: float = None) -> dict:
  318. """写单个寄存器"""
  319. request_data = struct.pack('>HH', register_address, value)
  320. request = self._build_request(device_address, 0x06, request_data)
  321. return self._send_receive(request, timeout=timeout)
  322. def set_rgb_led(self, device_address: int, led_number: int, color: int, timeout: float = None) -> dict:
  323. """设置RGB灯
  324. Register 0x0001, value = (led_number << 8) | color
  325. color: 0=off, 1=flashing red, 2=flashing green, 3=flashing blue
  326. """
  327. value = (led_number << 8) | (color & 0xFF)
  328. return self.write_single_register(device_address, 0x0001, value, timeout)
  329. def scan_devices(self, max_address: int = 247) -> list:
  330. """scan online devices using Modbus 03"""
  331. import time
  332. devices = []
  333. for addr in range(1, min(max_address + 1, 248)):
  334. request = self._build_request(addr, 0x03, struct.pack('>HH', 0x0000, 0x0001))
  335. response = self.serial.send_and_wait(request, timeout=0.3, min_response_bytes=5)
  336. if response and len(response) >= 5 and verify_crc16(response):
  337. devices.append(addr)
  338. return devices