modbus_rtu.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. from typing import Optional
  2. import threading
  3. # ========== 地址配置协议 ==========
  4. def build_broadcast_query() -> bytes:
  5. """构建广播查询指令
  6. DTU定时发送广播指令查询未配置地址的从机
  7. 格式: 00 40 00 40 00 (无CRC, 固定5字节)
  8. """
  9. return bytes([0x00, 0x40, 0x00, 0x40, 0x00])
  10. def parse_device_response(data: bytes, fix_crc: bool = True) -> Optional[dict]:
  11. """解析从机应答
  12. 从机应答格式: 00 + 41 + 12个UID + CRC_L + CRC_H
  13. 例如: 00 41 18 00 40 00 14 00 00 59 59 54 30 56 AE 34
  14. fix_crc=True: 当 CRC 不通过但帧头 + UID 有效时,重新计算正确 CRC 补帧,
  15. 而不是直接拒绝。用于兼容部分从机固件 CRC 计算 bug。
  16. """
  17. if len(data) < 16:
  18. return {'error': '数据长度不足', 'raw_data': data.hex()}
  19. if data[1] != 0x41:
  20. return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
  21. uid_bytes = data[2:14]
  22. uid_hex = uid_bytes.hex()
  23. # UID 有效性检查(CRC 之前先做,以便 CRC 失败时也能判断是否补帧)
  24. from collections import Counter
  25. zero_count = uid_bytes.count(0)
  26. counts = Counter(uid_bytes)
  27. most_common_count = counts.most_common(1)[0][1]
  28. uid_looks_valid = (zero_count <= 6) and (most_common_count <= 8)
  29. crc_ok = verify_crc16(data)
  30. if not crc_ok and fix_crc and uid_looks_valid:
  31. # 补帧:重新计算正确 CRC 替换从机发出的错误 CRC
  32. original_crc = data[14:16]
  33. corrected_crc = calculate_crc16(data[:14])
  34. data = data[:14] + corrected_crc
  35. logger.warning(
  36. f"广播响应 CRC 错误,已补帧修正: 原CRC={original_crc.hex()} "
  37. f"→ 正确CRC={corrected_crc.hex()} (数据={data[:14].hex()})"
  38. )
  39. elif not crc_ok:
  40. return {'error': 'CRC校验失败', 'raw_data': data.hex()}
  41. if not uid_looks_valid:
  42. if zero_count > 6:
  43. return {'error': f"UID中零字节过多({zero_count}/12)", 'raw_data': data.hex()}
  44. else:
  45. return {'error': f"UID中单字节重复过多({counts.most_common(1)})", 'raw_data': data.hex()}
  46. return {
  47. 'function_code': 0x41,
  48. 'uid': uid_hex,
  49. 'uid_readable': ':'.join(f'{b:02x}' for b in uid_bytes),
  50. 'source_address': data[0],
  51. 'raw_data': data.hex()
  52. }
  53. def build_confirm_address(device_address: int, uid: bytes) -> bytes:
  54. """构建确认地址指令
  55. DTU遍历已存储的UID和地址,如果存在匹配的UID则发送确认指令
  56. 格式: add + 44 + 00 + 12个UID + CRC_L + CRC_H
  57. """
  58. if len(uid) != 12:
  59. raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
  60. data = bytes([device_address, 0x44, 0x00]) + uid
  61. return data + calculate_crc16(data)
  62. def build_assign_address(uid: bytes, address: int) -> bytes:
  63. """构建分配地址指令
  64. 如果DTU中没有该UID的记录,则分配新地址
  65. 格式: 00 + 42 + 12个UID + add + CRC_L + CRC_H
  66. """
  67. if len(uid) != 12:
  68. raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
  69. if address < 1 or address > 247:
  70. raise ValueError(f"设备地址必须在1-247之间,当前: {address}")
  71. data = bytes([0x00, 0x42]) + uid + bytes([address])
  72. return data + calculate_crc16(data)
  73. def parse_address_assignment_response(data: bytes) -> dict:
  74. """解析地址分配响应
  75. 模块应答格式: add + 43 + 00 + CRC_L + CRC_H
  76. 例如: 01 43 00 11 30
  77. """
  78. if len(data) < 5:
  79. return {'error': '数据长度不足', 'raw_data': data.hex()}
  80. device_address = data[0]
  81. if data[1] != 0x43:
  82. return {'error': f"非预期功能码: {data[1]:#x}", 'raw_data': data.hex()}
  83. if not verify_crc16(data):
  84. logger.warning(f"CRC校验失败: {data.hex()}")
  85. return {
  86. 'device_address': device_address,
  87. 'function_code': 0x43,
  88. 'raw_data': data.hex()
  89. }
  90. class AddressConfigProtocol:
  91. """地址配置协议处理器"""
  92. def __init__(self, serial_port):
  93. self.serial = serial_port
  94. self.default_timeout = 5.0 # 设备广播响应可能较慢,给足时间
  95. self.stored_devices = {}
  96. self.config_lock = threading.RLock() # 地址配置期间占用,避免与轮询冲突
  97. def add_stored_device(self, uid: str, address: int):
  98. """添加已存储的设备"""
  99. self.stored_devices[uid] = address
  100. def get_stored_devices(self) -> dict:
  101. """获取已存储的设备列表"""
  102. return self.stored_devices.copy()
  103. def broadcast_query(self, timeout: float = None) -> list:
  104. """send broadcast query and collect all valid 00 41 responses"""
  105. import time
  106. with self.config_lock:
  107. request = build_broadcast_query()
  108. logger.info(f"send broadcast: {request.hex()}")
  109. timeout = timeout or self.default_timeout
  110. # 先清空输入缓冲区,避免残留数据干扰
  111. self.serial.flush_input()
  112. time.sleep(0.05)
  113. # 发送广播并等待响应;所有串口操作统一走发送队列,
  114. # 这里不再额外直接读串口,避免在收集窗口内把后续队列命令的响应抢走。
  115. response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=16)
  116. responses = []
  117. if response and len(response) >= 16:
  118. logger.info(f"got raw response ({len(response)}B): {response.hex()}")
  119. # RS485 半双工会回显已发送的数据,导致 response 开头可能有 5 字节回显
  120. # 在 response 中搜索所有 00 41 标记,尝试从中提取 16 字节帧解析
  121. for i in range(len(response) - 15 + 1):
  122. if response[i] == 0x00 and response[i + 1] == 0x41:
  123. chunk = response[i:i + 16]
  124. if len(chunk) >= 16:
  125. parsed = parse_device_response(chunk)
  126. if "error" not in parsed:
  127. if not any(p.get('uid') == parsed['uid'] for p in responses):
  128. responses.append(parsed)
  129. else:
  130. logger.info(f"no response or too short: {len(response) if response else 0}B")
  131. logger.info(f"broadcast done, got {len(responses)} valid unique responses")
  132. return responses
  133. def _next_available_address(self) -> int:
  134. """从 1~247 中找一个未被 stored_devices 占用的地址"""
  135. used = set(self.stored_devices.values())
  136. for addr in range(1, 248):
  137. if addr not in used:
  138. return addr
  139. raise RuntimeError("Modbus 地址 1~247 已全部占用")
  140. def process_responses(self, responses: list) -> list:
  141. """处理广播查询响应:给每个应答设备分配地址(优先使用已记录地址)"""
  142. import time
  143. with self.config_lock:
  144. results = []
  145. for resp in responses:
  146. if 'error' in resp:
  147. results.append({'status': 'error', 'message': resp['error']})
  148. continue
  149. uid = resp['uid']
  150. src_addr = resp.get('source_address', 0)
  151. device_address = self.stored_devices.get(uid)
  152. # 优先使用地址表中已保存的地址;没有则分配下一个可用地址
  153. if device_address:
  154. new_address = device_address
  155. logger.info(f"UID={uid} 源地址={src_addr},地址表存在记录,分配已有地址={new_address}")
  156. else:
  157. new_address = self._next_available_address()
  158. logger.info(f"UID={uid} 源地址={src_addr},地址表无记录,分配新地址={new_address}")
  159. request = build_assign_address(bytes.fromhex(uid), new_address)
  160. logger.info(f"发送分配地址帧: {request.hex()}")
  161. self.serial.flush_input()
  162. time.sleep(0.05)
  163. success, msg = self.serial.send_raw(request)
  164. if not success:
  165. logger.error(f"UID={uid} 分配地址帧发送失败: {msg}")
  166. results.append({
  167. 'status': 'error',
  168. 'uid': uid,
  169. 'message': f"send failed: {msg}"
  170. })
  171. continue
  172. # 等待并解析从机的 43 确认响应
  173. # 用 wait_response 只读等待:不发空数据(避免前端空JSON)、不清空接收缓冲(避免冲掉43)
  174. time.sleep(0.1)
  175. ack = self.serial.wait_response(timeout=0.8, min_response_bytes=5)
  176. ack_parsed = None
  177. if ack and len(ack) >= 5:
  178. # 过滤回显/噪声,找到 43 响应帧
  179. for i in range(len(ack) - 4):
  180. if ack[i + 1] == 0x43:
  181. chunk = ack[i:i + 5]
  182. ack_parsed = parse_address_assignment_response(chunk)
  183. break
  184. if ack_parsed and 'error' not in ack_parsed:
  185. logger.info(f"UID={uid} 地址={new_address} 已确认 (响应: {ack_parsed.get('raw_data')})")
  186. self.stored_devices[uid] = new_address
  187. results.append({
  188. 'status': 'assigned',
  189. 'uid': uid,
  190. 'address': new_address,
  191. 'request': request.hex(),
  192. 'ack': ack_parsed.get('raw_data')
  193. })
  194. else:
  195. # 即使未收到确认也保存地址:部分从机不返回 43,但实际已生效
  196. logger.warning(f"UID={uid} 地址={new_address} 未收到 43 确认,仍视为已分配")
  197. self.stored_devices[uid] = new_address
  198. results.append({
  199. 'status': 'assigned',
  200. 'uid': uid,
  201. 'address': new_address,
  202. 'request': request.hex(),
  203. 'ack': ack.hex() if ack else None
  204. })
  205. return results
  206. def auto_configure(self, timeout: float = None) -> dict:
  207. """自动配置所有未配置的设备"""
  208. with self.config_lock:
  209. logger.info("开始自动配置设备...")
  210. responses = self.broadcast_query(timeout)
  211. if not responses:
  212. return {
  213. 'success': True,
  214. 'message': '未发现任何从机设备',
  215. 'discovered': 0,
  216. 'configured': 0,
  217. 'results': []
  218. }
  219. results = self.process_responses(responses)
  220. assigned = sum(1 for r in results if r.get('status') == 'assigned')
  221. errors = sum(1 for r in results if r.get('status') == 'error')
  222. return {
  223. 'success': errors == 0,
  224. 'discovered': len(responses),
  225. 'confirmed': 0,
  226. 'assigned': assigned,
  227. 'errors': errors,
  228. 'results': results,
  229. 'stored_devices': self.get_stored_devices()
  230. }
  231. def save_config(self, filepath: str) -> bool:
  232. """保存配置到文件"""
  233. import json
  234. try:
  235. with open(filepath, 'w') as f:
  236. json.dump(self.stored_devices, f, indent=2)
  237. return True
  238. except Exception as e:
  239. logger.error(f"保存配置失败: {e}")
  240. return False
  241. def load_config(self, filepath: str) -> bool:
  242. """从文件加载配置"""
  243. import json
  244. try:
  245. with open(filepath, 'r') as f:
  246. self.stored_devices = json.load(f)
  247. return True
  248. except Exception as e:
  249. logger.error(f"加载配置失败: {e}")
  250. return False
  251. # ========== Modbus RTU Client ==========
  252. # ========== V4 批量读卡协议常量 ==========
  253. # V4: 一次性批量读 24 路卡号;每张卡 6 字节;无卡标记 6×0xFF。
  254. # 注: 硬件通信固定 6 字节卡号;对 MQTT 的 8 字节(16 位)适配在 app.py 边界完成
  255. # (前 4 位 DTU 号 + 后 12 位卡号)。
  256. CARD_REG_ADDR = 0x0002 # 批量卡号读起始寄存器
  257. CARD_REG_QUANTITY = 0x48 # 72 寄存器 = 144 字节
  258. CARD_DATA_BYTES = 144 # 24 天线 × 6 字节
  259. BYTES_PER_TAG = 6
  260. ANTENNA_COUNT = 24
  261. NO_TAG_BYTES = b'\xff' * 6 # V4 无卡标记
  262. NO_TAG_HEX = 'ffffffffffff'
  263. BULK_BYTE_COUNT = 0x90 # 144,Modbus 响应字节计数字段
  264. BULK_RESPONSE_LEN = 149 # addr(1)+fc(1)+bytecount(1)+data(144)+crc(2)
  265. # V4 设置卡号协议常量(非标准 MODBUS 0x10)
  266. # 协议帧: [addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]
  267. # 注意: 与标准 MODBUS 0x10 不同,此处省略 byte count 字段,数量字段 0x0090 直接表示字节数(144)
  268. SET_CARD_REG_ADDR = 0x0003 # 设置卡号起始寄存器
  269. SET_CARD_QUANTITY = 0x0090 # 数量字段 = 144(字节数,非寄存器数)
  270. SET_CARD_DATA_BYTES = 144 # 24 路 × 6 字节
  271. SET_CARD_REQUEST_LEN = 152 # addr(1)+fc(1)+start(2)+qty(2)+data(144)+crc(2)
  272. SET_CARD_RESPONSE_LEN = 8 # addr(1)+fc(1)+start(2)+qty(2)+crc(2)
  273. # 带 UID 读卡协议(地址段轮询 / 线架绑定)
  274. # 请求 20B: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
  275. # UID = 该地址已绑定的线架 UID;未绑定时为 12 字节全 0(uid=None 探测),
  276. # 未配置线架收到探测帧后采纳该地址并应答自身 UID,DTU 据此绑定"地址↔UID"。
  277. # 响应 161B: [addr][0x03][0x90][144 字节卡数据][12 字节 UID][CRC_L][CRC_H]
  278. # UID = 线架自身 UID(探测响应时用于绑定)。
  279. READ_WITH_UID_REQUEST_LEN = 20
  280. READ_WITH_UID_RESPONSE_LEN = 161
  281. ZERO_UID = b'\x00' * 12
  282. # 天线地址映射表(天线编号 -> Modbus寄存器地址)
  283. # V4: 所有天线共享寄存器 0x0002,由 read_all_antenna_cards 一次批量读出
  284. ANTENNA_ADDRESSES = {i: CARD_REG_ADDR for i in range(1, 25)}
  285. import struct
  286. import logging
  287. logger = logging.getLogger('serial_mqtt_gateway')
  288. def calculate_crc16(data: bytes) -> bytes:
  289. """计算Modbus CRC16校验"""
  290. crc = 0xFFFF
  291. for byte in data:
  292. crc ^= byte
  293. for _ in range(8):
  294. if crc & 0x0001:
  295. crc = (crc >> 1) ^ 0xA001
  296. else:
  297. crc >>= 1
  298. return struct.pack('<H', crc)
  299. def verify_crc16(data: bytes) -> bool:
  300. """验证Modbus CRC16校验"""
  301. if len(data) < 2:
  302. return False
  303. received_crc = struct.unpack('<H', data[-2:])[0]
  304. calculated_crc = struct.unpack('<H', calculate_crc16(data[:-2]))[0]
  305. return received_crc == calculated_crc
  306. def build_read_cards_request(device_address: int, uid: Optional[bytes] = None) -> bytes:
  307. """构建带 UID 的 V4 批量读卡请求(20 字节)。
  308. 格式: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
  309. uid=None 表示 12 字节全 0 探测(未绑定地址;未配置线架会采纳该地址)。
  310. """
  311. if uid is None:
  312. uid = ZERO_UID
  313. if len(uid) != 12:
  314. raise ValueError(f"UID长度必须是12字节,当前: {len(uid)}")
  315. data = bytes([device_address, 0x03]) + struct.pack('>HH', CARD_REG_ADDR, CARD_REG_QUANTITY) + bytes(uid)
  316. return data + calculate_crc16(data)
  317. class ModbusRTUClient:
  318. """Modbus RTU 客户端"""
  319. def __init__(self, serial_port):
  320. self.serial = serial_port
  321. self.default_timeout = 2.0
  322. self.test_cards = None # 测试注入:非 None 时 read_all_antenna_cards 返回构造数据
  323. self.test_panel_uids = None # 测试注入:{addr: 'hex24'} 模拟线架自身 UID
  324. def _build_request(self, device_address: int, function_code: int, data: bytes = b'') -> bytes:
  325. """构建Modbus请求帧"""
  326. pdu = bytes([device_address, function_code]) + data
  327. return pdu + calculate_crc16(pdu)
  328. def _send_receive(self, request: bytes, expected_min_len: int = 5, timeout: float = None) -> dict:
  329. """send request and receive response using sync method"""
  330. timeout = timeout or self.default_timeout
  331. if not self.serial.ser:
  332. return {"error": "serial not connected"}
  333. response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=expected_min_len)
  334. if not response or len(response) < expected_min_len:
  335. return {"error": "response timeout or too short", "raw_data": response.hex() if response else ""}
  336. if not verify_crc16(response):
  337. logger.warning(f"CRC check failed: {response.hex()}")
  338. return {"error": "CRC check failed", "raw_data": response.hex()}
  339. return {
  340. "success": True,
  341. "device_address": response[0],
  342. "function_code": response[1],
  343. "data": response[2:-2].hex(),
  344. "raw_data": response.hex()
  345. }
  346. def read_all_antenna_cards(self, device_address: int, uid: Optional[bytes] = None, timeout: float = None) -> dict:
  347. """V4+: 带 UID 的批量读取全部 24 路天线卡号。
  348. 寄存器 0x0002,数量 0x48(72 寄存器 = 144 字节)。
  349. 请求 20B: [addr][0x03][0x00][0x02][0x00][0x48][12 字节 UID][CRC_L][CRC_H]
  350. uid=None 表示 12 字节全 0 探测(未绑定地址探测/换架接管用)。
  351. 响应 161B: [addr][0x03][0x90][144 数据字节][12 字节 UID][CRC_L][CRC_H]
  352. 响应 UID = 线架自身 UID(未绑定/换架时用于绑定)。
  353. 每张卡 6 字节(硬件卡号,12 位十六进制);无卡标记 = 6×0xFF。
  354. MQTT 侧的 8 字节(16 位) jumper_uid 适配在 app.py 边界完成。
  355. 过渡期兼容: 旧固件 149B 响应仍可解析(panel_uid=None 仅作诊断),不能用于绑定。
  356. 返回:
  357. {'success': True, 'device_address', 'function_code': 0x03,
  358. 'cards': [{'antenna', 'card_str', 'present', 'uid'}, ...24],
  359. 'panel_uid': '24位hex'|None, 'raw_data'}
  360. """
  361. # 测试注入: test_cards = {addr: {port_id: 'hex12'|None, ...}, ...}
  362. # test_panel_uids = {addr: 'hex24'}(可选, 模拟线架自身 UID)
  363. if self.test_cards is not None:
  364. panel_map = self.test_cards.get(device_address)
  365. if panel_map is not None:
  366. cards = []
  367. for i in range(ANTENNA_COUNT):
  368. ant = i + 1
  369. hex_str = panel_map.get(ant)
  370. if hex_str:
  371. tag = bytes.fromhex(hex_str)
  372. cards.append({
  373. 'antenna': ant,
  374. 'card_str': tag.hex(),
  375. 'present': True,
  376. 'uid': ':'.join(f'{b:02x}' for b in tag)
  377. })
  378. else:
  379. cards.append({
  380. 'antenna': ant, 'card_str': '', 'present': False, 'uid': ''
  381. })
  382. return {
  383. "success": True,
  384. "device_address": device_address,
  385. "function_code": 0x03,
  386. "cards": cards,
  387. "panel_uid": (self.test_panel_uids or {}).get(device_address),
  388. "raw_data": "TEST_INJECTED"
  389. }
  390. timeout = timeout or self.default_timeout
  391. if not self.serial.ser:
  392. return {"error": "serial not connected"}
  393. try:
  394. request = build_read_cards_request(device_address, uid)
  395. except ValueError as e:
  396. return {"error": str(e)}
  397. # 直调 send_and_wait 并要求完整 161 字节(或旧格式 149 字节)
  398. response = self.serial.send_and_wait(
  399. request, timeout=timeout, min_response_bytes=READ_WITH_UID_RESPONSE_LEN
  400. )
  401. if not response or len(response) < 5:
  402. return {"error": "response timeout or too short",
  403. "raw_data": response.hex() if response else ""}
  404. # Modbus 异常响应: addr + 0x83 + exception_code + CRC
  405. if response[1] & 0x80:
  406. return {"error": f"Modbus exception: {response[2]:#x}",
  407. "raw_data": response.hex()}
  408. panel_uid = None
  409. if len(response) >= READ_WITH_UID_RESPONSE_LEN:
  410. frame = response[:READ_WITH_UID_RESPONSE_LEN]
  411. raw_uid = frame[3 + CARD_DATA_BYTES:3 + CARD_DATA_BYTES + 12]
  412. panel_uid = raw_uid.hex() if raw_uid != ZERO_UID else None
  413. elif len(response) >= BULK_RESPONSE_LEN:
  414. # 过渡期兼容:旧固件 149B 响应(无 UID 字段)
  415. logger.warning("收到旧格式(149B)批量读卡响应,无 UID 字段,无法用于线架绑定")
  416. frame = response[:BULK_RESPONSE_LEN]
  417. else:
  418. return {"error": f"response too short ({len(response)}/{READ_WITH_UID_RESPONSE_LEN})",
  419. "raw_data": response.hex()}
  420. if not verify_crc16(frame):
  421. logger.warning(f"CRC check failed: {frame.hex()}")
  422. return {"error": "CRC check failed", "raw_data": frame.hex()}
  423. if frame[1] != 0x03 or frame[2] != BULK_BYTE_COUNT:
  424. return {"error": f"unexpected response: fc={frame[1]:#x} count={frame[2]:#x}",
  425. "raw_data": frame.hex()}
  426. tag_data = frame[3:3 + CARD_DATA_BYTES] # 144 字节
  427. cards = []
  428. for i in range(ANTENNA_COUNT):
  429. tag = tag_data[i * BYTES_PER_TAG:(i + 1) * BYTES_PER_TAG] # 6 字节
  430. present = tag != NO_TAG_BYTES
  431. cards.append({
  432. 'antenna': i + 1,
  433. 'card_str': tag.hex() if present else '',
  434. 'present': present,
  435. 'uid': ':'.join(f'{b:02x}' for b in tag) if present else ''
  436. })
  437. return {
  438. "success": True,
  439. "device_address": frame[0],
  440. "function_code": 0x03,
  441. "cards": cards,
  442. "panel_uid": panel_uid,
  443. "raw_data": frame.hex()
  444. }
  445. def read_antenna_card(self, device_address: int, antenna_num: int, timeout: float = None) -> dict:
  446. """读取单根天线卡号(V4: 由批量读提取,不再发 V2 逐天线帧)。
  447. 调用 read_all_antenna_cards 后提取指定天线,返回兼容 schema。
  448. 无卡时 card_number_str 为空串(V2 是 '0000000000000000')。
  449. """
  450. if antenna_num < 1 or antenna_num > ANTENNA_COUNT:
  451. return {"error": f"invalid antenna number: {antenna_num}, must be 1-{ANTENNA_COUNT}"}
  452. result = self.read_all_antenna_cards(device_address, timeout=timeout)
  453. if 'error' in result:
  454. return result
  455. card = next((c for c in result.get('cards', []) if c['antenna'] == antenna_num), None)
  456. if card is None:
  457. return {"error": f"antenna {antenna_num} not found in bulk response",
  458. "raw_data": result.get('raw_data', '')}
  459. base = {k: v for k, v in result.items() if k != 'cards'}
  460. if card['present']:
  461. tag_bytes = bytes.fromhex(card['card_str']) # 6 字节
  462. card_number = int.from_bytes(tag_bytes, 'big')
  463. return {
  464. 'card_number': card_number,
  465. 'card_number_hex': f'0x{card_number:012x}',
  466. 'card_number_str': card['card_str'],
  467. 'antenna': antenna_num,
  468. 'uid': card['uid'],
  469. **base
  470. }
  471. return {
  472. 'card_number': 0,
  473. 'card_number_hex': '0x000000000000',
  474. 'card_number_str': '',
  475. 'antenna': antenna_num,
  476. 'uid': '',
  477. **base
  478. }
  479. def set_card_mapping(self, device_address: int, card_mappings: dict, timeout: float = None) -> dict:
  480. """V4: 设置24路天线卡号(0x10 写 0x0003,144字节,非标准MODBUS无bytecount)。
  481. 协议帧(发送): [addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]
  482. 协议帧(响应): [addr][0x10][0x00][0x03][0x00][0x90][CRC_L][CRC_H]
  483. Args:
  484. device_address: 从机地址
  485. card_mappings: {port_id(1-24): 'hex12' or None}
  486. 未提供或 None 的端口填 0xFF*6(无卡标记)
  487. Returns:
  488. {'success': True, 'device_address', 'raw_data', 'cards_written'} 或
  489. {'error': ...}
  490. """
  491. timeout = timeout or self.default_timeout
  492. if not self.serial.ser:
  493. return {"error": "serial not connected"}
  494. # 构建 24 路 × 6 字节 = 144 字节数据
  495. data_bytes = b''
  496. written = {}
  497. for ant in range(1, ANTENNA_COUNT + 1):
  498. hex_str = card_mappings.get(ant)
  499. if hex_str:
  500. hex_str = str(hex_str).strip().lower().replace(':', '').replace(' ', '')
  501. try:
  502. tag = bytes.fromhex(hex_str)
  503. except ValueError:
  504. return {"error": f"port {ant} 卡号十六进制非法: {hex_str!r}"}
  505. if len(tag) != BYTES_PER_TAG:
  506. return {"error": f"port {ant} 卡号必须 {BYTES_PER_TAG} 字节,当前 {len(tag)} 字节: {hex_str!r}"}
  507. data_bytes += tag
  508. written[ant] = hex_str
  509. else:
  510. data_bytes += NO_TAG_BYTES
  511. written[ant] = None
  512. if len(data_bytes) != SET_CARD_DATA_BYTES:
  513. return {"error": f"数据长度异常: {len(data_bytes)} != {SET_CARD_DATA_BYTES}"}
  514. # 构建非标准 0x10 请求帧(无 byte count 字段)
  515. header = bytes([device_address, 0x10]) + struct.pack('>HH', SET_CARD_REG_ADDR, SET_CARD_QUANTITY)
  516. request = header + data_bytes + calculate_crc16(header + data_bytes)
  517. if len(request) != SET_CARD_REQUEST_LEN:
  518. return {"error": f"请求帧长度异常: {len(request)} != {SET_CARD_REQUEST_LEN}"}
  519. logger.info(f"设置卡号帧({len(request)}B): {request.hex()}")
  520. response = self.serial.send_and_wait(
  521. request, timeout=timeout, min_response_bytes=SET_CARD_RESPONSE_LEN
  522. )
  523. if not response or len(response) < 5:
  524. return {"error": "response timeout or too short",
  525. "raw_data": response.hex() if response else ""}
  526. # Modbus 异常响应: addr + 0x90 + exception_code + CRC
  527. if response[1] & 0x80:
  528. return {"error": f"Modbus exception: {response[2]:#x}",
  529. "raw_data": response.hex()}
  530. if len(response) < SET_CARD_RESPONSE_LEN:
  531. return {"error": f"response too short ({len(response)}/{SET_CARD_RESPONSE_LEN})",
  532. "raw_data": response.hex()}
  533. if not verify_crc16(response):
  534. logger.warning(f"设置卡号响应 CRC 失败: {response.hex()}")
  535. return {"error": "CRC check failed", "raw_data": response.hex()}
  536. if response[1] != 0x10:
  537. return {"error": f"非预期功能码: {response[1]:#x}", "raw_data": response.hex()}
  538. resp_start = struct.unpack('>H', response[2:4])[0]
  539. resp_qty = struct.unpack('>H', response[4:6])[0]
  540. if resp_start != SET_CARD_REG_ADDR or resp_qty != SET_CARD_QUANTITY:
  541. return {"error": f"响应地址/数量不匹配: start={resp_start:#x} qty={resp_qty:#x}",
  542. "raw_data": response.hex()}
  543. return {
  544. "success": True,
  545. "device_address": response[0],
  546. "function_code": 0x10,
  547. "cards_written": written,
  548. "raw_data": response.hex()
  549. }
  550. def read_holding_registers(self, device_address: int, start_address: int, quantity: int = 1, timeout: float = None) -> dict:
  551. """读取保持寄存器"""
  552. request_data = struct.pack('>HH', start_address, quantity)
  553. request = self._build_request(device_address, 0x03, request_data)
  554. result = self._send_receive(request, timeout=timeout)
  555. if 'error' in result:
  556. return result
  557. data_str = result.get('data', '')
  558. data_bytes = bytes.fromhex(data_str) if isinstance(data_str, str) and data_str else b''
  559. registers = []
  560. for i in range(0, len(data_bytes), 2):
  561. if i + 1 < len(data_bytes):
  562. registers.append(struct.unpack('>H', data_bytes[i:i+2])[0])
  563. return {'registers': registers, **result}
  564. def write_single_register(self, device_address: int, register_address: int, value: int, timeout: float = None) -> dict:
  565. """写单个寄存器"""
  566. request_data = struct.pack('>HH', register_address, value)
  567. request = self._build_request(device_address, 0x06, request_data)
  568. return self._send_receive(request, timeout=timeout)
  569. def set_rgb_led(self, device_address: int, led_number: int, color: int, timeout: float = None) -> dict:
  570. """设置RGB灯
  571. Register 0x0001, value = (led_number << 8) | color
  572. color: 0=off, 1=flashing red, 2=flashing green, 3=flashing blue
  573. """
  574. value = (led_number << 8) | (color & 0xFF)
  575. return self.write_single_register(device_address, 0x0001, value, timeout)
  576. def scan_devices(self, max_address: int = 247) -> list:
  577. """scan online devices using Modbus 03"""
  578. import time
  579. devices = []
  580. for addr in range(1, min(max_address + 1, 248)):
  581. request = self._build_request(addr, 0x03, struct.pack('>HH', 0x0000, 0x0001))
  582. response = self.serial.send_and_wait(request, timeout=0.3, min_response_bytes=5)
  583. if response and len(response) >= 5 and verify_crc16(response):
  584. devices.append(addr)
  585. return devices