You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

521 line
20 KiB

  1. #!/usr/bin/env python3
  2. """Phase-based PLSR HSD/SFD persistence board acceptance tool.
  3. Normal phases never corrupt storage and issue at most one SFD SAVE per
  4. invocation. Power removal/reset is intentionally manual so VBAT retention and
  5. real reset behavior are tested rather than simulated by a software command.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import tempfile
  11. import time
  12. from pathlib import Path
  13. import serial
  14. from plsr_modbus_frequency_test import RtuClient, choose_port, signed_dword_words
  15. CONTROL_BASE = 1200
  16. CONTROL_WINDOW_WORDS = 338
  17. CALL_REQUEST = CONTROL_BASE + 8
  18. CALL_RESPONSE = CONTROL_BASE + 24
  19. COMMAND_REQUEST = CONTROL_BASE + 40
  20. COMMAND_RESPONSE = CONTROL_BASE + 48
  21. AXIS_STATUS_BASE = CONTROL_BASE + 64
  22. AXIS_STATUS_WORDS = 48
  23. PERSISTENCE_BASE = CONTROL_BASE + 270
  24. PERSISTENCE_WORDS = 30
  25. PERSISTENCE_REQUEST = CONTROL_BASE + 300
  26. PERSISTENCE_RESPONSE = CONTROL_BASE + 308
  27. PERSISTENCE_VERSION = 1
  28. PERFORMANCE_VERSION = 7
  29. S0_BASES = (1600, 1800, 2000, 2200)
  30. S1_BASES = (1700, 1900, 2100, 2300)
  31. KNOWN_POSITIONS = (123456, -234567, 345678, -456789)
  32. RESULT_OK = 0
  33. RESULT_QUEUED = 1
  34. RESULT_BUSY = 8
  35. RESULT_NOT_SUPPORTED = 10
  36. PERSISTENCE_OK = 0
  37. PERSISTENCE_NOT_IMPLEMENTED = 4
  38. STATE_IDLE = 1
  39. STATE_ACCEL = 2
  40. STATE_RUN = 3
  41. STATE_DECEL = 4
  42. STATE_STOPPED = 8
  43. CALL_COMMIT = 1
  44. CALL_START = 2
  45. CMD_STOP_IMMEDIATE = 2
  46. CMD_SET_POSITION = 5
  47. CMD_SAVE_CONFIG = 8
  48. DESTRUCTIVE_MAGIC_A = 0xDA7A
  49. DESTRUCTIVE_MAGIC_B = 0x51F0
  50. DESTRUCTIVE_ARM = 0xA55A
  51. DESTRUCTIVE_CONFIRM = "INVALIDATE-NEWEST-SLOT"
  52. def put_u32(words: list[int], offset: int, value: int) -> None:
  53. words[offset : offset + 2] = signed_dword_words(value)
  54. def put_u64(words: list[int], offset: int, value: int) -> None:
  55. raw = value & 0xFFFFFFFFFFFFFFFF
  56. words[offset : offset + 4] = [
  57. (raw >> shift) & 0xFFFF for shift in (0, 16, 32, 48)
  58. ]
  59. def get_u32(words: list[int], offset: int) -> int:
  60. return words[offset] | (words[offset + 1] << 16)
  61. def get_u64(words: list[int], offset: int, signed: bool = False) -> int:
  62. raw = sum(words[offset + index] << (16 * index) for index in range(4))
  63. if signed and raw & (1 << 63):
  64. return raw - (1 << 64)
  65. return raw
  66. def next_sequence() -> int:
  67. value = int(time.time_ns() // 1_000_000) & 0x7FFFFFFF
  68. return value if value != 0 else 1
  69. def wait_response(
  70. client: RtuClient, address: int, count: int, sequence: int, timeout: float = 3.0
  71. ) -> list[int]:
  72. deadline = time.monotonic() + timeout
  73. latest: list[int] | None = None
  74. while time.monotonic() < deadline:
  75. latest = client.read_holding(address, count)
  76. if get_u32(latest, 0) == sequence:
  77. return latest
  78. raise RuntimeError(f"等待序号 {sequence} 应答超时,最后应答={latest}")
  79. def send_command(
  80. client: RtuClient, sequence: int, axis: int, opcode: int, argument: int = 0
  81. ) -> list[int]:
  82. request = [0] * 8
  83. put_u32(request, 0, sequence)
  84. request[2] = opcode
  85. request[3] = axis
  86. put_u64(request, 4, argument)
  87. client.write_multiple(COMMAND_REQUEST, request)
  88. return wait_response(client, COMMAND_RESPONSE, 8, sequence)
  89. def read_axis_status(client: RtuClient, axis: int) -> dict[str, int]:
  90. words = client.read_holding(
  91. AXIS_STATUS_BASE + axis * AXIS_STATUS_WORDS, AXIS_STATUS_WORDS
  92. )
  93. generation_begin = get_u32(words, 0)
  94. generation_end = get_u32(words, 46)
  95. if generation_begin != generation_end or generation_begin & 1:
  96. raise RuntimeError(
  97. f"轴{axis}状态快照不一致:{generation_begin}/{generation_end}"
  98. )
  99. return {
  100. "state": words[2],
  101. "flags": get_u32(words, 3),
  102. "last_result": words[8],
  103. "last_sequence": get_u32(words, 10),
  104. "logical_position": get_u64(words, 16, signed=True),
  105. "task_pulses": get_u64(words, 20, signed=True),
  106. "current_frequency": get_u32(words, 38),
  107. }
  108. def wait_command_applied(
  109. client: RtuClient, axis: int, sequence: int, timeout: float = 5.0
  110. ) -> dict[str, int]:
  111. deadline = time.monotonic() + timeout
  112. latest: dict[str, int] | None = None
  113. while time.monotonic() < deadline:
  114. latest = read_axis_status(client, axis)
  115. if latest["last_sequence"] == sequence:
  116. return latest
  117. raise RuntimeError(f"等待命令 {sequence} 内核执行超时,最后状态={latest}")
  118. def read_persistence(client: RtuClient) -> dict[str, int]:
  119. latest: list[int] | None = None
  120. for _ in range(5):
  121. latest = client.read_holding(PERSISTENCE_BASE, PERSISTENCE_WORDS)
  122. generation_begin = get_u32(latest, 0)
  123. generation_end = get_u32(latest, 28)
  124. if generation_begin == generation_end and not generation_begin & 1:
  125. if latest[2] != PERSISTENCE_VERSION:
  126. raise RuntimeError(f"持久化诊断版本错误:{latest[2]}")
  127. hsd_newest = latest[4] & 0xFF
  128. sfd_newest = (latest[4] >> 8) & 0xFF
  129. hsd_generations = (get_u32(latest, 12), get_u32(latest, 14))
  130. sfd_generations = (get_u32(latest, 16), get_u32(latest, 18))
  131. return {
  132. "generation": generation_begin,
  133. "hsd_valid_mask": latest[3] & 0xFF,
  134. "sfd_valid_mask": (latest[3] >> 8) & 0xFF,
  135. "hsd_newest_mask": hsd_newest,
  136. "sfd_newest_mask": sfd_newest,
  137. "flags": latest[5],
  138. "last_hsd_load": latest[6],
  139. "last_sfd_load": latest[7],
  140. "last_hsd_save": latest[8],
  141. "last_sfd_save": latest[9],
  142. "last_sfd_erase": latest[10],
  143. "hsd_generation_a": hsd_generations[0],
  144. "hsd_generation_b": hsd_generations[1],
  145. "sfd_generation_a": sfd_generations[0],
  146. "sfd_generation_b": sfd_generations[1],
  147. "hsd_selected_generation": (
  148. hsd_generations[0]
  149. if hsd_newest == 1
  150. else hsd_generations[1] if hsd_newest == 2 else 0
  151. ),
  152. "sfd_selected_generation": (
  153. sfd_generations[0]
  154. if sfd_newest == 1
  155. else sfd_generations[1] if sfd_newest == 2 else 0
  156. ),
  157. "hsd_save_count": get_u32(latest, 20),
  158. "sfd_save_count": get_u32(latest, 22),
  159. "hsd_crc32": get_u32(latest, 24),
  160. "sfd_crc32": get_u32(latest, 26),
  161. }
  162. raise RuntimeError(f"持久化诊断快照连续不一致:{latest}")
  163. def wait_persistence_clean(client: RtuClient, timeout: float = 4.0) -> dict[str, int]:
  164. deadline = time.monotonic() + timeout
  165. latest: dict[str, int] | None = None
  166. while time.monotonic() < deadline:
  167. latest = read_persistence(client)
  168. if not latest["flags"] & 1 and latest["hsd_valid_mask"] != 0:
  169. return latest
  170. raise RuntimeError(f"HSD 检查点未在期限内完成:{latest}")
  171. def send_call(
  172. client: RtuClient, sequence: int, axis: int, operation: int
  173. ) -> list[int]:
  174. request = [0] * 16
  175. put_u32(request, 0, sequence)
  176. request[2] = 0
  177. put_u32(request, 3, S0_BASES[axis])
  178. request[5] = 0
  179. put_u32(request, 6, S1_BASES[axis])
  180. request[8] = 0
  181. put_u32(request, 10, 1)
  182. request[12] = axis
  183. request[13] = 0
  184. request[14] = operation
  185. client.write_multiple(CALL_REQUEST, request)
  186. return wait_response(client, CALL_RESPONSE, 12, sequence)
  187. def start_long_motion(client: RtuClient, sequence: int) -> int:
  188. s0 = [0] * 20
  189. put_u32(s0, 0, 1)
  190. put_u32(s0, 10, 100_000)
  191. put_u32(s0, 12, 50_000_000)
  192. client.write_multiple(S0_BASES[0], s0)
  193. client.write_multiple(S1_BASES[0], [0] * 4)
  194. response = send_call(client, sequence, 0, CALL_COMMIT)
  195. if response[3] != RESULT_OK or response[11] != 1:
  196. raise RuntimeError(f"长任务 COMMIT 失败:{response}")
  197. sequence += 1
  198. response = send_call(client, sequence, 0, CALL_START)
  199. if response[3] != RESULT_QUEUED:
  200. raise RuntimeError(f"长任务 START 失败:{response}")
  201. deadline = time.monotonic() + 5.0
  202. latest: dict[str, int] | None = None
  203. while time.monotonic() < deadline:
  204. latest = read_axis_status(client, 0)
  205. if (
  206. latest["last_sequence"] == sequence
  207. and latest["state"] in {STATE_ACCEL, STATE_RUN, STATE_DECEL}
  208. and latest["flags"] & (1 << 1)
  209. ):
  210. return sequence + 1
  211. raise RuntimeError(f"长任务未进入真实输出态:{latest}")
  212. def load_state(path: Path) -> dict[str, object]:
  213. if not path.exists():
  214. raise RuntimeError(f"阶段状态文件不存在:{path}")
  215. return json.loads(path.read_text(encoding="utf-8"))
  216. def save_state(path: Path, state: dict[str, object]) -> None:
  217. path.parent.mkdir(parents=True, exist_ok=True)
  218. path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
  219. def print_diagnostics(diag: dict[str, int]) -> None:
  220. print(
  221. "持久化诊断:"
  222. f"HSD valid=0x{diag['hsd_valid_mask']:02X}, "
  223. f"gen={diag['hsd_selected_generation']}, crc=0x{diag['hsd_crc32']:08X}, "
  224. f"boot saves={diag['hsd_save_count']}; "
  225. f"SFD valid=0x{diag['sfd_valid_mask']:02X}, "
  226. f"gen={diag['sfd_selected_generation']}, crc=0x{diag['sfd_crc32']:08X}, "
  227. f"boot saves={diag['sfd_save_count']}; flags=0x{diag['flags']:04X}"
  228. )
  229. def phase_hsd_prepare(client: RtuClient, state_path: Path) -> None:
  230. sequence = next_sequence()
  231. for axis, position in enumerate(KNOWN_POSITIONS):
  232. response = send_command(client, sequence, axis, CMD_SET_POSITION, position)
  233. if response[4] != RESULT_QUEUED:
  234. raise RuntimeError(f"轴{axis} SET_POSITION 未排队:{response}")
  235. status = wait_command_applied(client, axis, sequence)
  236. if status["last_result"] != RESULT_OK or status["logical_position"] != position:
  237. raise RuntimeError(f"轴{axis}位置检查失败:{status}")
  238. sequence += 1
  239. diag = wait_persistence_clean(client)
  240. if diag["last_hsd_save"] != PERSISTENCE_OK:
  241. raise RuntimeError(f"HSD 保存结果不是 OK:{diag}")
  242. save_state(state_path, {"positions": list(KNOWN_POSITIONS)})
  243. print_diagnostics(diag)
  244. print("HSD prepare PASS。现在关闭主电源(保持 VBAT),再执行 --phase hsd-verify。")
  245. def phase_hsd_verify(client: RtuClient, state_path: Path) -> None:
  246. expected = [int(value) for value in load_state(state_path)["positions"]]
  247. for axis, position in enumerate(expected):
  248. status = read_axis_status(client, axis)
  249. if status["logical_position"] != position or not status["flags"] & (1 << 5):
  250. raise RuntimeError(f"轴{axis} HSD 恢复失败:期望{position},实测{status}")
  251. if status["flags"] & (1 << 1):
  252. raise RuntimeError(f"轴{axis} 上电后意外输出:{status}")
  253. diag = read_persistence(client)
  254. if diag["last_hsd_load"] != PERSISTENCE_OK:
  255. raise RuntimeError(f"HSD 上电加载结果不是 OK:{diag}")
  256. if not diag["flags"] & (1 << 2) or diag["flags"] & (1 << 3):
  257. raise RuntimeError(f"HSD restored flags 不符合正常停机:{diag}")
  258. print_diagnostics(diag)
  259. print("HSD VBAT 掉主电恢复 PASS。")
  260. def phase_busy_prepare(client: RtuClient) -> None:
  261. sequence = start_long_motion(client, next_sequence())
  262. diag = wait_persistence_clean(client)
  263. print_diagnostics(diag)
  264. print(
  265. f"运行中掉电已就绪(下一序号{sequence})。现在直接关闭主电源或硬复位,"
  266. "不要先发 STOP;重启后执行 --phase busy-verify。"
  267. )
  268. def phase_busy_verify(client: RtuClient) -> None:
  269. for axis in range(4):
  270. status = read_axis_status(client, axis)
  271. if status["flags"] & (1 << 1):
  272. raise RuntimeError(f"轴{axis} 重启后仍有输出:{status}")
  273. if status["flags"] & (1 << 5):
  274. raise RuntimeError(f"轴{axis} 运行中复位后 position_valid 未清除:{status}")
  275. if status["state"] != STATE_IDLE:
  276. raise RuntimeError(f"轴{axis} 重启后不是 IDLE:{status}")
  277. diag = read_persistence(client)
  278. if not diag["flags"] & (1 << 3) or diag["flags"] & (1 << 2):
  279. raise RuntimeError(f"busy-reset restored flags 错误:{diag}")
  280. print_diagnostics(diag)
  281. print("运行中掉电/复位 PASS:未自动续跑,所有轴 position_valid=0。")
  282. def phase_sfd_save(client: RtuClient, state_path: Path) -> None:
  283. before = read_persistence(client)
  284. sequence = next_sequence()
  285. response = send_command(client, sequence, 0, CMD_SAVE_CONFIG)
  286. if response[4] != RESULT_QUEUED:
  287. raise RuntimeError(f"SAVE_CONFIG 未排队:{response}")
  288. status = wait_command_applied(client, 0, sequence, timeout=10.0)
  289. if status["last_result"] != RESULT_OK:
  290. raise RuntimeError(f"SAVE_CONFIG 内核执行失败:{status}")
  291. time.sleep(0.1)
  292. after = read_persistence(client)
  293. was_dirty = bool(before["flags"] & (1 << 1))
  294. if was_dirty:
  295. if after["sfd_save_count"] != before["sfd_save_count"] + 1:
  296. raise RuntimeError(f"SFD 实际保存次数不正确:before={before}, after={after}")
  297. if after["last_sfd_save"] != PERSISTENCE_OK:
  298. raise RuntimeError(f"SFD 保存结果不是 OK:{after}")
  299. if after["sfd_selected_generation"] == before["sfd_selected_generation"]:
  300. raise RuntimeError(f"SFD generation 未前进:before={before}, after={after}")
  301. elif after["sfd_save_count"] != before["sfd_save_count"]:
  302. raise RuntimeError("SFD clean no-op 不应擦写 Flash")
  303. if after["sfd_valid_mask"] == 0:
  304. raise RuntimeError(f"SFD 没有可验证的有效槽:{after}")
  305. state = load_state(state_path) if state_path.exists() else {}
  306. state["sfd_generation"] = after["sfd_selected_generation"]
  307. state["sfd_crc32"] = after["sfd_crc32"]
  308. save_state(state_path, state)
  309. print_diagnostics(after)
  310. if not was_dirty:
  311. print("SFD 当前为 clean,本次 SAVE 正确地没有重复擦写;验证现有提交记录。")
  312. print("SFD save PASS。现在硬复位/掉电重启,再执行 --phase sfd-verify。")
  313. def phase_sfd_verify(client: RtuClient, state_path: Path) -> None:
  314. state = load_state(state_path)
  315. expected_generation = int(state["sfd_generation"])
  316. expected_crc = int(state["sfd_crc32"])
  317. diag = read_persistence(client)
  318. if diag["last_sfd_load"] != PERSISTENCE_OK:
  319. raise RuntimeError(f"SFD 上电加载结果不是 OK:{diag}")
  320. if (
  321. diag["sfd_selected_generation"] != expected_generation
  322. or diag["sfd_crc32"] != expected_crc
  323. ):
  324. raise RuntimeError(
  325. "SFD 上电记录不一致:"
  326. f"期望 gen={expected_generation}, crc=0x{expected_crc:08X};实测={diag}"
  327. )
  328. print_diagnostics(diag)
  329. print("SFD A/B + CRC 上电加载 PASS。")
  330. def phase_motion_save_busy(client: RtuClient) -> None:
  331. sequence = start_long_motion(client, next_sequence())
  332. before = read_persistence(client)
  333. response = send_command(client, sequence, 0, CMD_SAVE_CONFIG)
  334. if response[4] != RESULT_QUEUED:
  335. raise RuntimeError(f"运行中 SAVE_CONFIG 未进入命令队列:{response}")
  336. status = wait_command_applied(client, 0, sequence)
  337. if status["last_result"] != RESULT_BUSY:
  338. raise RuntimeError(f"运行中 SAVE_CONFIG 未返回 BUSY:{status}")
  339. after = read_persistence(client)
  340. if after["sfd_save_count"] != before["sfd_save_count"]:
  341. raise RuntimeError("运行中 SAVE_CONFIG 意外擦写了 Flash")
  342. sequence += 1
  343. response = send_command(client, sequence, 0, CMD_STOP_IMMEDIATE)
  344. if response[4] != RESULT_QUEUED:
  345. raise RuntimeError(f"清理 STOP_IMMEDIATE 未排队:{response}")
  346. status = wait_command_applied(client, 0, sequence)
  347. if status["last_result"] != RESULT_OK or status["state"] != STATE_STOPPED:
  348. raise RuntimeError(f"清理停止失败:{status}")
  349. print_diagnostics(after)
  350. print("运行中禁止 SFD Flash 擦写 PASS:SAVE_CONFIG=BUSY,save_count 未增加。")
  351. def phase_invalidate(
  352. client: RtuClient, target: str, allow: bool, confirmation: str | None
  353. ) -> None:
  354. if not allow or confirmation != DESTRUCTIVE_CONFIRM:
  355. raise RuntimeError(
  356. "破坏性诊断未授权;必须同时使用 --allow-destructive "
  357. f"--confirm {DESTRUCTIVE_CONFIRM}"
  358. )
  359. before = read_persistence(client)
  360. if not before["flags"] & (1 << 4):
  361. raise RuntimeError("固件未显式启用 PLSR_ENABLE_DESTRUCTIVE_PERSISTENCE_DIAG")
  362. mask_key = "hsd_valid_mask" if target == "hsd" else "sfd_valid_mask"
  363. if before[mask_key] != 3:
  364. raise RuntimeError(f"必须先有两个有效槽,当前诊断={before}")
  365. sequence = next_sequence()
  366. inverse = (~sequence) & 0xFFFFFFFF
  367. request = [DESTRUCTIVE_MAGIC_A, DESTRUCTIVE_MAGIC_B]
  368. request += signed_dword_words(sequence)
  369. request += signed_dword_words(inverse)
  370. request += [1 if target == "hsd" else 2, DESTRUCTIVE_ARM]
  371. client.write_multiple(PERSISTENCE_REQUEST, request)
  372. response = wait_response(client, PERSISTENCE_RESPONSE, 8, sequence)
  373. if response[3] == RESULT_NOT_SUPPORTED:
  374. raise RuntimeError("破坏性诊断被固件拒绝(normal build)")
  375. if response[3] != RESULT_OK:
  376. raise RuntimeError(f"失效 newest {target.upper()} 槽失败:{response}")
  377. after = read_persistence(client)
  378. if after[mask_key] not in {1, 2}:
  379. raise RuntimeError(f"失效后应只剩一个有效槽:{after}")
  380. print_diagnostics(after)
  381. print(f"受控失效 newest {target.upper()} 槽 PASS;请立即执行对应 LOAD/重启回退验证。")
  382. def main() -> int:
  383. parser = argparse.ArgumentParser(description="PLSR HSD/SFD 真机掉电与 Flash 验收")
  384. parser.add_argument(
  385. "--phase",
  386. required=True,
  387. choices=(
  388. "diagnostics",
  389. "hsd-prepare",
  390. "hsd-verify",
  391. "busy-prepare",
  392. "busy-verify",
  393. "sfd-save",
  394. "sfd-verify",
  395. "motion-save-busy",
  396. "invalidate-hsd",
  397. "invalidate-sfd",
  398. ),
  399. )
  400. parser.add_argument("--port", default="COM5")
  401. parser.add_argument("--baud", type=int, default=9600)
  402. parser.add_argument("--slave", type=int, default=1)
  403. parser.add_argument(
  404. "--state-file",
  405. type=Path,
  406. default=Path(tempfile.gettempdir()) / "plsr_persistence_board_state.json",
  407. )
  408. parser.add_argument("--allow-destructive", action="store_true")
  409. parser.add_argument("--confirm")
  410. args = parser.parse_args()
  411. with serial.Serial(
  412. port=choose_port(args.port),
  413. baudrate=args.baud,
  414. bytesize=serial.EIGHTBITS,
  415. parity=serial.PARITY_EVEN,
  416. stopbits=serial.STOPBITS_ONE,
  417. timeout=1.0,
  418. write_timeout=1.0,
  419. ) as uart:
  420. client = RtuClient(uart, args.slave)
  421. header = client.read_holding(CONTROL_BASE, 8)
  422. if header[:5] != [0x504C, 0x5352, 0x0100, CONTROL_WINDOW_WORDS, 0x0007]:
  423. raise RuntimeError(f"控制窗口未就绪或固件过旧:{header}")
  424. if header[7] != PERFORMANCE_VERSION:
  425. raise RuntimeError(f"需要诊断版本 V{PERFORMANCE_VERSION},当前 V{header[7]}")
  426. print(f"控制窗口 D1200~D1537 就绪;阶段状态文件:{args.state_file}")
  427. if args.phase == "diagnostics":
  428. print_diagnostics(read_persistence(client))
  429. elif args.phase == "hsd-prepare":
  430. phase_hsd_prepare(client, args.state_file)
  431. elif args.phase == "hsd-verify":
  432. phase_hsd_verify(client, args.state_file)
  433. elif args.phase == "busy-prepare":
  434. phase_busy_prepare(client)
  435. elif args.phase == "busy-verify":
  436. phase_busy_verify(client)
  437. elif args.phase == "sfd-save":
  438. phase_sfd_save(client, args.state_file)
  439. elif args.phase == "sfd-verify":
  440. phase_sfd_verify(client, args.state_file)
  441. elif args.phase == "motion-save-busy":
  442. phase_motion_save_busy(client)
  443. elif args.phase == "invalidate-hsd":
  444. phase_invalidate(client, "hsd", args.allow_destructive, args.confirm)
  445. else:
  446. phase_invalidate(client, "sfd", args.allow_destructive, args.confirm)
  447. return 0
  448. if __name__ == "__main__":
  449. try:
  450. raise SystemExit(main())
  451. except (RuntimeError, serial.SerialException, KeyError, ValueError) as error:
  452. print(f"测试失败:{error}")
  453. raise SystemExit(1)