返回教程正文

配套源码

gateway.py

can-gateway/gateway.py
zdt-x57s-can-test:封装单电机 Brick 并组合验证四台电机can-gateway/gateway.py
Python630 行
  1. #!/usr/bin/env python3
  2. """
  3. @description : 向App Lab Custom Brick提供受鉴权的Linux SocketCAN网关
  4. @param : 无
  5. @return : 无
  6. """
  7. import argparse
  8. import hmac
  9. import json
  10. import os
  11. import socketserver
  12. import subprocess
  13. import threading
  14. import time
  15. from socketcan_transport import SocketCanTransport
  16. from zdt_x57s_driver import ZdtX57S
  17. PROTOCOL_VERSION = 1
  18. MOTION_CONFIRMATION = "RUN_ZDT_X57S_V1_0"
  19. MIN_MOTOR_ID = 1
  20. MAX_MOTOR_ID = 255
  21. MAX_REQUEST_BYTES = 16384
  22. class GatewayRequestError(RuntimeError):
  23. """
  24. @description : 表示网关请求字段、鉴权或方法不合法
  25. @param code : 稳定错误码
  26. @param message : 错误说明
  27. @return : GatewayRequestError实例
  28. """
  29. def __init__(self, code, message):
  30. """
  31. @description : 初始化网关请求异常
  32. @param code : 稳定错误码
  33. @param message : 错误说明
  34. @return : 无
  35. """
  36. super().__init__(message)
  37. self.code = str(code)
  38. class MotorGateway:
  39. """
  40. @description : 串行化CAN操作并执行电机参数与安全边界校验
  41. @param interface : SocketCAN接口名称
  42. @param token : Brick和网关共享的随机鉴权令牌
  43. @param max_rpm : 允许的最大绝对转速
  44. @param reply_timeout_s: 单帧电机应答超时时间
  45. @param command_timeout_s: 非零速度命令看门狗超时时间
  46. @return : MotorGateway实例
  47. """
  48. def __init__(
  49. self,
  50. interface,
  51. token,
  52. max_rpm,
  53. reply_timeout_s,
  54. command_timeout_s=0.5,
  55. ):
  56. """
  57. @description : 初始化网关配置和CAN互斥锁
  58. @param interface : SocketCAN接口名称
  59. @param token : 共享鉴权令牌
  60. @param max_rpm : 允许的最大绝对转速
  61. @param reply_timeout_s: 单帧电机应答超时时间
  62. @param command_timeout_s: 非零速度命令看门狗超时时间
  63. @return : 无
  64. """
  65. if not token:
  66. raise ValueError("gateway token must not be empty")
  67. if max_rpm < 1 or max_rpm > 3000:
  68. raise ValueError("max_rpm must be in range 1-3000")
  69. if reply_timeout_s <= 0:
  70. raise ValueError("reply_timeout_s must be greater than zero")
  71. if command_timeout_s < 0.1 or command_timeout_s > 5.0:
  72. raise ValueError("command_timeout_s must be in range 0.1-5.0")
  73. self.interface = str(interface)
  74. self.max_rpm = int(max_rpm)
  75. self.reply_timeout_s = float(reply_timeout_s)
  76. self.command_timeout_s = float(command_timeout_s)
  77. self._token = str(token)
  78. self._can_lock = threading.Lock()
  79. self._watchdog_lock = threading.Lock()
  80. self._watchdogs = {}
  81. self._started_at = time.monotonic()
  82. def dispatch(self, request):
  83. """
  84. @description : 校验请求并分发到允许的CAN网关方法
  85. @param request : 反序列化后的请求字典
  86. @return : 方法执行结果字典
  87. """
  88. if not isinstance(request, dict):
  89. raise GatewayRequestError("invalid_request", "request must be an object")
  90. if request.get("version") != PROTOCOL_VERSION:
  91. raise GatewayRequestError(
  92. "version_mismatch", "unsupported protocol version"
  93. )
  94. supplied_token = str(request.get("token", ""))
  95. if not hmac.compare_digest(supplied_token, self._token):
  96. raise GatewayRequestError("unauthorized", "invalid gateway token")
  97. params = request.get("params", {})
  98. if not isinstance(params, dict):
  99. raise GatewayRequestError("invalid_params", "params must be an object")
  100. methods = {
  101. "status": self.status,
  102. "read_speed": self.read_speed,
  103. "enable": self.enable,
  104. "disable": self.disable,
  105. "set_speed": self.set_speed,
  106. "stop": self.stop,
  107. "timed_speed_test": self.timed_speed_test,
  108. }
  109. method_name = request.get("method")
  110. method = methods.get(method_name)
  111. if method is None:
  112. raise GatewayRequestError("unknown_method", "method is not allowed")
  113. return method(params)
  114. def status(self, params):
  115. """
  116. @description : 返回网关和can0状态且不发送CAN报文
  117. @param params : 空参数字典
  118. @return : 网关状态字典
  119. """
  120. del params
  121. return {
  122. "interface": self.interface,
  123. "interface_state": self._read_interface_state(),
  124. "max_rpm": self.max_rpm,
  125. "command_timeout_ms": int(self.command_timeout_s * 1000),
  126. "motor_id_range": [MIN_MOTOR_ID, MAX_MOTOR_ID],
  127. "uptime_s": round(time.monotonic() - self._started_at, 3),
  128. }
  129. def read_speed(self, params):
  130. """
  131. @description : 查询请求中唯一一台电机的实时转速
  132. @param params : 包含motor_id的参数字典
  133. @return : 当前电机地址和实时RPM字典
  134. """
  135. motor_id = self._validate_motor_id(params.get("motor_id"))
  136. def operation(transport):
  137. """
  138. @description : 在SocketCAN会话中读取当前唯一一台电机速度
  139. @param transport : 已打开的SocketCanTransport实例
  140. @return : 带符号实时RPM
  141. """
  142. motor = ZdtX57S(transport, motor_id, self.reply_timeout_s)
  143. return motor.read_speed()
  144. return {
  145. "motor_id": motor_id,
  146. "speed_rpm": self._with_can(operation),
  147. }
  148. def enable(self, params):
  149. """
  150. @description : 校验运动确认后使能单台电机
  151. @param params : 包含motor_id和confirmation的字典
  152. @return : 成功状态字典
  153. """
  154. self._require_motion_confirmation(params)
  155. motor_id = self._validate_motor_id(params.get("motor_id"))
  156. return self._single_motor_command(
  157. motor_id, lambda motor: motor.enable(True)
  158. )
  159. def disable(self, params):
  160. """
  161. @description : 失能单台电机
  162. @param params : 包含motor_id的字典
  163. @return : 成功状态字典
  164. """
  165. motor_id = self._validate_motor_id(params.get("motor_id"))
  166. self._cancel_watchdog(motor_id)
  167. return self._single_motor_command(
  168. motor_id, lambda motor: motor.enable(False)
  169. )
  170. def set_speed(self, params):
  171. """
  172. @description : 校验运动确认、转速和加速度后控制单台电机
  173. @param params : 包含motor_id、rpm、acceleration_level和confirmation
  174. @return : 成功状态字典
  175. """
  176. self._require_motion_confirmation(params)
  177. motor_id = self._validate_motor_id(params.get("motor_id"))
  178. rpm = self._validate_rpm(params.get("rpm"))
  179. acceleration = self._validate_acceleration(
  180. params.get("acceleration_level")
  181. )
  182. result = self._single_motor_command(
  183. motor_id,
  184. lambda motor: motor.set_speed(rpm, acceleration),
  185. )
  186. if rpm == 0:
  187. self._cancel_watchdog(motor_id)
  188. else:
  189. self._arm_watchdog(motor_id)
  190. result["watchdog_timeout_ms"] = (
  191. 0 if rpm == 0 else int(self.command_timeout_s * 1000)
  192. )
  193. return result
  194. def stop(self, params):
  195. """
  196. @description : 对单台电机发送零速并尽力执行停止和失能
  197. @param params : 包含motor_id的字典
  198. @return : 三条安全命令的独立结果
  199. """
  200. motor_id = self._validate_motor_id(params.get("motor_id"))
  201. self._cancel_watchdog(motor_id)
  202. return self._with_can(
  203. lambda transport: self._safe_stop_motor(transport, motor_id)
  204. )
  205. def timed_speed_test(self, params):
  206. """
  207. @description : 运行最多5秒的单电机测试并在finally阶段发送零速停车
  208. @param params : 电机地址、转速、加速度、时长和确认口令
  209. @return : 实时速度采样及停车结果
  210. """
  211. self._require_motion_confirmation(params)
  212. motor_id = self._validate_motor_id(params.get("motor_id"))
  213. self._cancel_watchdog(motor_id)
  214. rpm = self._validate_rpm(params.get("rpm"), allow_zero=False)
  215. acceleration = self._validate_acceleration(
  216. params.get("acceleration_level")
  217. )
  218. try:
  219. duration_s = float(params.get("duration_s"))
  220. except (TypeError, ValueError) as error:
  221. raise GatewayRequestError(
  222. "invalid_params", "duration_s must be a number"
  223. ) from error
  224. if duration_s < 0.2 or duration_s > 5.0:
  225. raise GatewayRequestError(
  226. "invalid_params", "duration_s must be in range 0.2-5.0"
  227. )
  228. def operation(transport):
  229. """
  230. @description : 执行使能、限时速度运行、反馈采样和finally安全停车
  231. @param transport : 已打开的SocketCanTransport实例
  232. @return : 测试结果字典
  233. """
  234. motor = ZdtX57S(transport, motor_id, self.reply_timeout_s)
  235. samples = []
  236. test_error = None
  237. shutdown = None
  238. try:
  239. motor.enable(True)
  240. motor.set_speed(rpm, acceleration)
  241. deadline = time.monotonic() + duration_s
  242. while time.monotonic() < deadline:
  243. samples.append(motor.read_speed())
  244. time.sleep(0.2)
  245. except Exception as error:
  246. test_error = str(error)
  247. finally:
  248. shutdown = self._safe_stop_motor(transport, motor_id)
  249. if test_error is not None:
  250. raise GatewayRequestError("motor_test_failed", test_error)
  251. return {
  252. "motor_id": motor_id,
  253. "target_rpm": rpm,
  254. "samples_rpm": samples,
  255. "shutdown": shutdown,
  256. }
  257. return self._with_can(operation)
  258. def _arm_watchdog(self, motor_id):
  259. """
  260. @description : 为非零速度命令创建一次性通信超时安全停车定时器
  261. @param motor_id : 已校验的电机地址
  262. @return : 无
  263. """
  264. generation = object()
  265. timer = threading.Timer(
  266. self.command_timeout_s,
  267. self._watchdog_expired,
  268. args=(motor_id, generation),
  269. )
  270. timer.daemon = True
  271. with self._watchdog_lock:
  272. previous = self._watchdogs.pop(motor_id, None)
  273. if previous is not None:
  274. previous[0].cancel()
  275. self._watchdogs[motor_id] = (timer, generation)
  276. timer.start()
  277. def _cancel_watchdog(self, motor_id):
  278. """
  279. @description : 取消指定电机尚未触发的速度命令看门狗
  280. @param motor_id : 已校验的电机地址
  281. @return : 无
  282. """
  283. with self._watchdog_lock:
  284. current = self._watchdogs.pop(motor_id, None)
  285. if current is not None:
  286. current[0].cancel()
  287. def _watchdog_expired(self, motor_id, generation):
  288. """
  289. @description : 速度命令超时后验证定时器代次并执行安全停车
  290. @param motor_id : 超时电机地址
  291. @param generation : 防止旧定时器误停新命令的代次对象
  292. @return : 无
  293. """
  294. with self._watchdog_lock:
  295. current = self._watchdogs.get(motor_id)
  296. if current is None or current[1] is not generation:
  297. return
  298. self._watchdogs.pop(motor_id, None)
  299. try:
  300. result = self._with_can(
  301. lambda transport: self._safe_stop_motor(
  302. transport, motor_id
  303. )
  304. )
  305. print(
  306. f"motor {motor_id} command watchdog expired: {result}",
  307. flush=True,
  308. )
  309. except Exception as error:
  310. print(
  311. f"motor {motor_id} watchdog stop failed: {error}",
  312. flush=True,
  313. )
  314. def _single_motor_command(self, motor_id, command):
  315. """
  316. @description : 在独占CAN会话中执行单台电机命令
  317. @param motor_id : 已校验的电机地址
  318. @param command : 接受ZdtX57S实例的可调用对象
  319. @return : 成功状态字典
  320. """
  321. def operation(transport):
  322. """
  323. @description : 创建电机对象并执行传入命令
  324. @param transport : 已打开的SocketCanTransport实例
  325. @return : 成功状态字典
  326. """
  327. motor = ZdtX57S(transport, motor_id, self.reply_timeout_s)
  328. command(motor)
  329. return {"motor_id": motor_id, "accepted": True}
  330. return self._with_can(operation)
  331. def _safe_stop_motor(self, transport, motor_id):
  332. """
  333. @description : 以F6零速为主路径并继续尝试FE停止和F3失能
  334. @param transport : 已打开的SocketCanTransport实例
  335. @param motor_id : 已校验的电机地址
  336. @return : 每条命令的成功状态或错误文本
  337. """
  338. motor = ZdtX57S(transport, motor_id, self.reply_timeout_s)
  339. result = {}
  340. actions = (
  341. ("zero_speed", lambda: motor.set_speed(0, 0)),
  342. ("stop", lambda: motor.stop(False)),
  343. ("disable", lambda: motor.enable(False)),
  344. )
  345. for name, action in actions:
  346. try:
  347. action()
  348. result[name] = {"ok": True}
  349. except Exception as error:
  350. result[name] = {"ok": False, "error": str(error)}
  351. return result
  352. def _with_can(self, operation):
  353. """
  354. @description : 串行打开can0、执行操作并可靠关闭SocketCAN
  355. @param operation : 接受SocketCanTransport实例的可调用对象
  356. @return : 操作返回值
  357. """
  358. with self._can_lock:
  359. try:
  360. with SocketCanTransport(self.interface) as transport:
  361. return operation(transport)
  362. except GatewayRequestError:
  363. raise
  364. except Exception as error:
  365. raise GatewayRequestError("can_error", str(error)) from error
  366. def _validate_motor_id(self, motor_id):
  367. """
  368. @description : 校验第二代FW_Emm协议支持的单电机地址
  369. @param motor_id : 待校验地址
  370. @return : 合法整数地址
  371. """
  372. if isinstance(motor_id, bool) or not isinstance(motor_id, int):
  373. raise GatewayRequestError(
  374. "invalid_params", "motor_id must be an integer"
  375. )
  376. if motor_id < MIN_MOTOR_ID or motor_id > MAX_MOTOR_ID:
  377. raise GatewayRequestError(
  378. "invalid_params", "motor_id must be in range 1-255"
  379. )
  380. return motor_id
  381. def _validate_rpm(self, rpm, allow_zero=True):
  382. """
  383. @description : 将目标转速限制在网关配置的安全范围
  384. @param rpm : 待校验带符号转速
  385. @param allow_zero : 是否允许零转速
  386. @return : 合法整数RPM
  387. """
  388. try:
  389. normalized_rpm = int(rpm)
  390. except (TypeError, ValueError) as error:
  391. raise GatewayRequestError(
  392. "invalid_params", "rpm must be an integer"
  393. ) from error
  394. if abs(normalized_rpm) > self.max_rpm:
  395. raise GatewayRequestError(
  396. "invalid_params",
  397. f"absolute rpm must not exceed {self.max_rpm}",
  398. )
  399. if not allow_zero and normalized_rpm == 0:
  400. raise GatewayRequestError(
  401. "invalid_params", "rpm must not be zero for a motion test"
  402. )
  403. return normalized_rpm
  404. def _validate_acceleration(self, acceleration_level):
  405. """
  406. @description : 校验FW_Emm加减速档位
  407. @param acceleration_level: 待校验档位
  408. @return : 合法整数档位
  409. """
  410. try:
  411. normalized_acceleration = int(acceleration_level)
  412. except (TypeError, ValueError) as error:
  413. raise GatewayRequestError(
  414. "invalid_params", "acceleration_level must be an integer"
  415. ) from error
  416. if normalized_acceleration < 0 or normalized_acceleration > 255:
  417. raise GatewayRequestError(
  418. "invalid_params",
  419. "acceleration_level must be in range 0-255",
  420. )
  421. return normalized_acceleration
  422. def _require_motion_confirmation(self, params):
  423. """
  424. @description : 要求运动请求携带固定人工确认口令
  425. @param params : 网关方法参数字典
  426. @return : 校验成功无返回值
  427. """
  428. if params.get("confirmation") != MOTION_CONFIRMATION:
  429. raise GatewayRequestError(
  430. "confirmation_required",
  431. "motion confirmation must be " + MOTION_CONFIRMATION,
  432. )
  433. def _read_interface_state(self):
  434. """
  435. @description : 使用ip工具只读获取SocketCAN接口摘要
  436. @param : 无
  437. @return : 接口状态文本或错误说明
  438. """
  439. try:
  440. result = subprocess.run(
  441. ["ip", "-brief", "link", "show", self.interface],
  442. check=False,
  443. capture_output=True,
  444. text=True,
  445. timeout=1.0,
  446. )
  447. except (OSError, subprocess.TimeoutExpired) as error:
  448. return "unavailable: " + str(error)
  449. output = (result.stdout or result.stderr).strip()
  450. return output or "not found"
  451. class GatewayTcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
  452. """
  453. @description : 支持并发连接且进程退出时关闭工作线程的TCP服务器
  454. @param server_address: 监听地址和端口
  455. @param handler_class : 请求处理器类型
  456. @return : GatewayTcpServer实例
  457. """
  458. allow_reuse_address = True
  459. daemon_threads = True
  460. class GatewayRequestHandler(socketserver.StreamRequestHandler):
  461. """
  462. @description : 读取单条JSON请求并返回单条JSON响应
  463. @param : 由socketserver创建
  464. @return : 无
  465. """
  466. def handle(self):
  467. """
  468. @description : 限长读取、解析、分发并编码网关响应
  469. @param : 无
  470. @return : 无
  471. """
  472. request_id = None
  473. try:
  474. raw_request = self.rfile.readline(MAX_REQUEST_BYTES + 1)
  475. if not raw_request or len(raw_request) > MAX_REQUEST_BYTES:
  476. raise GatewayRequestError(
  477. "invalid_request", "request is empty or too large"
  478. )
  479. request = json.loads(raw_request.decode("utf-8"))
  480. if isinstance(request, dict):
  481. request_id = request.get("request_id")
  482. result = self.server.gateway.dispatch(request)
  483. response = {
  484. "version": PROTOCOL_VERSION,
  485. "request_id": request_id,
  486. "ok": True,
  487. "result": result,
  488. }
  489. except GatewayRequestError as error:
  490. response = self._error_response(request_id, error.code, str(error))
  491. except (UnicodeDecodeError, json.JSONDecodeError) as error:
  492. response = self._error_response(
  493. request_id, "invalid_json", str(error)
  494. )
  495. except Exception as error:
  496. response = self._error_response(
  497. request_id, "internal_error", str(error)
  498. )
  499. self.wfile.write(
  500. (
  501. json.dumps(response, separators=(",", ":")) + "\n"
  502. ).encode("utf-8")
  503. )
  504. def _error_response(self, request_id, code, message):
  505. """
  506. @description : 构造统一失败响应
  507. @param request_id : 请求关联ID或None
  508. @param code : 稳定错误码
  509. @param message : 错误说明
  510. @return : 失败响应字典
  511. """
  512. return {
  513. "version": PROTOCOL_VERSION,
  514. "request_id": request_id,
  515. "ok": False,
  516. "error": {"code": str(code), "message": str(message)},
  517. }
  518. def read_token(token_file):
  519. """
  520. @description : 从仅当前用户可读的文件加载网关鉴权令牌
  521. @param token_file : 令牌文件路径
  522. @return : 非空令牌字符串
  523. """
  524. with open(token_file, "r", encoding="utf-8") as token_stream:
  525. token = token_stream.read().strip()
  526. if not token:
  527. raise ValueError("gateway token file is empty")
  528. return token
  529. def parse_args():
  530. """
  531. @description : 解析Linux原生CAN网关启动参数
  532. @param : 无
  533. @return : argparse.Namespace参数对象
  534. """
  535. parser = argparse.ArgumentParser(
  536. description="Native SocketCAN gateway for the ZDT X57S CAN Brick."
  537. )
  538. parser.add_argument("--interface", default="can0")
  539. parser.add_argument("--bind", default="172.17.0.1")
  540. parser.add_argument("--port", type=int, default=8766)
  541. parser.add_argument("--max-rpm", type=int, default=60)
  542. parser.add_argument("--reply-timeout", type=float, default=0.5)
  543. parser.add_argument("--command-timeout", type=float, default=0.5)
  544. parser.add_argument(
  545. "--token-file",
  546. default=os.path.join(os.path.dirname(__file__), ".gateway-token"),
  547. )
  548. return parser.parse_args()
  549. def main():
  550. """
  551. @description : 创建MotorGateway并持续处理Brick请求
  552. @param : 无
  553. @return : 正常退出返回0
  554. """
  555. args = parse_args()
  556. gateway = MotorGateway(
  557. interface=args.interface,
  558. token=read_token(args.token_file),
  559. max_rpm=args.max_rpm,
  560. reply_timeout_s=args.reply_timeout,
  561. command_timeout_s=args.command_timeout,
  562. )
  563. with GatewayTcpServer(
  564. (args.bind, args.port), GatewayRequestHandler
  565. ) as server:
  566. server.gateway = gateway
  567. print(
  568. f"ZDT X57S CAN gateway listening on {args.bind}:{args.port}; "
  569. f"interface={args.interface}; max_rpm={args.max_rpm}",
  570. flush=True,
  571. )
  572. try:
  573. server.serve_forever(poll_interval=0.2)
  574. except KeyboardInterrupt:
  575. print("ZDT X57S CAN gateway stopped", flush=True)
  576. return 0
  577. if __name__ == "__main__":
  578. raise SystemExit(main())