tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,626 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan monitor` -- open a serial console to the attached board.
3
+
4
+ Port of `scripts/alp_cli/monitor.py` (73 lines): a thin front door over
5
+ pyserial's `miniterm`. Port comes from `--port`; baud from `--baud` (default
6
+ 115200, the SDK-wide console default).
7
+
8
+ There is no safe cross-platform guess for the port itself (COMx vs
9
+ `/dev/ttyUSBx` vs `/dev/cu.*`), so when no port is given -- or the requested
10
+ one does not exist -- this command lists every serial port pyserial can see
11
+ and refuses instead of hanging on a wrong device.
12
+
13
+ "Does not exist" is the stated rule, and until tan-cli#569 the code was
14
+ stricter than it: the gate tested membership in `comports()`, which refuses
15
+ every port pyserial can OPEN but does not ENUMERATE. `_port_is_usable` is now
16
+ the gate, with three accepting arms -- enumerated (plus the `\\\\.\\` alias of
17
+ tan-cli#701), a character device on this host (`/dev/serial/by-id/...`
18
+ symlinks, the case #569 was filed on), or one of pyserial's own URL schemes
19
+ (`socket://`, `rfc2217://`, ... , which have no local device path at all).
20
+
21
+ Board-context port resolution -- filling in `--port` from the current project
22
+ instead of asking for it -- is deliberately NOT implemented here, and it is
23
+ not simply unstarted (tan-cli#255): the build-plan already carries a
24
+ `slices[].debug.console` selector per slice (`build-plan-v1.schema.json`,
25
+ issue #610 §4; computed here too, at `tan/planner/buildplan.py::_slice_debug`,
26
+ and independently in alp-sdk's own `scripts/alp_orchestrate/buildplan.py`),
27
+ resolving to `"uart"` / `"ram"` / `"linux"` / `null`. That is a console
28
+ BACKEND CLASS, not a port: it says a slice's console is a UART (as opposed to
29
+ a RAM console read over SWD, or a Linux tty), never which host-visible device
30
+ that UART shows up as. Nothing in `board.yaml` or the build-plan carries a
31
+ VID:PID, serial number, or platform-specific device path for a board's
32
+ console UART, so `debug.console == "uart"` still leaves every USB-serial
33
+ adapter on the bench indistinguishable to this host OS -- reading it would not
34
+ let this command fill in `--port`. Teach this verb to read a real per-board
35
+ physical-port fact once metadata carries one; `debug.console` alone is not
36
+ that fact.
37
+
38
+ **No alp-sdk checkout required, unlike `model`.** The oracle's `monitor.py`
39
+ imports nothing from alp-sdk beyond `alp_cli._workspace.python_exe`, itself
40
+ just `sys.executable` -- the running interpreter. This port does NOT read
41
+ `sys.executable` directly, though: under PyInstaller `sys.executable` IS
42
+ `tan` itself, so spawning it would just re-enter this CLI instead of
43
+ launching miniterm -- the same reasoning `tan.core.sdk_discovery`'s `_planner_python`
44
+ and `generate_cmd.py` already carry, spelled out there so it need not be
45
+ re-argued per call site. This port reuses that same function, a PATH name
46
+ (`python`/`python3`) never `sys.executable`, when frozen or when
47
+ `sys.executable` is empty (an embedded interpreter can report ""); the
48
+ running interpreter is still preferred otherwise, since it is guaranteed to
49
+ have `serial` importable already. Either way no SDK root is resolved, so
50
+ `tan monitor` no longer requires a resolvable alp-sdk checkout the way the
51
+ retired Rust forwarder did (`crates/tan-cli/src/commands/sdk_cli.rs` resolves
52
+ one unconditionally for every forward, `monitor` included, purely as an
53
+ artifact of sharing one function with `model`/`new-som`/`faultdecode`) -- a
54
+ deliberate, documented improvement, not a regression: `monitor` never read
55
+ anything an SDK root would supply.
56
+
57
+ **Exit code on a failed miniterm run is `RuntimeFailure` (1) regardless of the
58
+ child's own exit code** -- mirroring the shipped Rust forwarder
59
+ (`sdk_cli::run`'s `s.code().unwrap_or(1)` branch always maps to
60
+ `ExitCode::RuntimeFailure`), which is the customer-facing contract today, NOT
61
+ the oracle's literal `raise SystemExit(rc)` passthrough of whatever code
62
+ miniterm returned. The actual child code still reaches the issue message.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import json
68
+ import os
69
+ import stat
70
+ import subprocess
71
+ import sys
72
+ import tempfile
73
+ from enum import Enum
74
+ from pathlib import Path
75
+
76
+ import typer
77
+
78
+ from tan.core.sdk_discovery import _planner_python
79
+ from tan.core import console_filter as console_filter_mod
80
+ from tan.core import serial_url
81
+ from tan.core.subprocess_env import spawn_env
82
+ from tan.envelope import Envelope, Issue, Project, emit
83
+ from tan.exit_codes import ExitCode
84
+ from tan.output_format import FORMAT_HELP, OutputFormat
85
+
86
+ #: The SDK-wide console default, matching `monitor.py::DEFAULT_BAUD`.
87
+ DEFAULT_BAUD = 115200
88
+
89
+ #: `data.schemaVersion` for this command's payload.
90
+ DATA_SCHEMA_VERSION = "1"
91
+
92
+
93
+ class ConsoleFilter(str, Enum):
94
+ """Console output filters. `colors` (tan's own, default) keeps SGR colour
95
+ sequences and neutralises every other escape/control byte; `default`,
96
+ `nocontrol`, `printable` are miniterm's own stripping filters. `direct`
97
+ passes the device's bytes to the terminal unmodified and is UNSAFE for an
98
+ untrusted target (OSC 52 clipboard writes, title changes, screen games)."""
99
+
100
+ COLORS = "colors"
101
+ DIRECT = "direct"
102
+ DEFAULT = "default"
103
+ NOCONTROL = "nocontrol"
104
+ PRINTABLE = "printable"
105
+
106
+
107
+ class MonitorError(Exception):
108
+ """A refusal whose issue code and exit code are already decided."""
109
+
110
+ def __init__(self, code: str, message: str, exit_code: ExitCode, data: dict) -> None:
111
+ super().__init__(message)
112
+ self.code = code
113
+ self.message = message
114
+ self.exit_code = exit_code
115
+ self.data = data
116
+
117
+
118
+ def _pyserial_missing() -> MonitorError:
119
+ """The one spelling of "pyserial is not installed".
120
+
121
+ The hint names the EXTRA rather than the bare distribution because that is
122
+ the supported way to get it: pyserial is declared in
123
+ `[project.optional-dependencies] monitor`, not in `dependencies`. A frozen
124
+ `--onefile` build resolves it at BUILD time, so a customer holding a binary
125
+ built without the extra cannot pip-install their way out -- hence the second
126
+ sentence, which is the only actionable thing to tell them.
127
+ """
128
+ return MonitorError(
129
+ "monitor.pyserial-missing",
130
+ "pyserial is required for `tan monitor`. Install it with "
131
+ '`pip install "./python[monitor]"` from a tan-cli checkout (tan-cli is '
132
+ "not on PyPI), or `pip install pyserial`. A frozen `tan` binary bundles "
133
+ "it at build time, so a binary built without that extra cannot gain it here.",
134
+ ExitCode.RUNTIME_FAILURE,
135
+ {"schemaVersion": DATA_SCHEMA_VERSION},
136
+ )
137
+
138
+
139
+ #: Contract-harness seam ONLY (tan-cli#1165) -- never a documented,
140
+ #: `--help`-visible flag. `_available_ports()`'s real source, pyserial's own
141
+ #: `list_ports.comports()`, enumerates whatever serial hardware happens to be
142
+ #: physically attached to the host running it, so no golden envelope can pin a
143
+ #: non-empty, deterministic `data.availablePorts` (the field
144
+ #: `contract/envelopes/monitor-no-port` exists to freeze) without depending on
145
+ #: the recording machine's own hardware -- and on a CI runner with nothing
146
+ #: plugged in, `data.availablePorts` would record as `[]` forever, pinning
147
+ #: nothing the issue asked for. Set to a JSON-encoded `[[device, description],
148
+ #: ...]` array, this REPLACES the pyserial enumeration outright, in the exact
149
+ #: `[(device, description)]` shape every regular CALLER (`_refuse_listing_ports`,
150
+ #: `_port_is_usable`) already expects, so none of THOSE need to know the seam
151
+ #: exists. `_run_monitor` is the one exception: it also checks this variable
152
+ #: directly, to skip its own "pyserial is importable" precheck (see that
153
+ #: function's comment) -- without that second check this golden would depend
154
+ #: on whether pyserial happens to be installed in whatever environment replays
155
+ #: it, the exact host-dependence this seam exists to remove. See
156
+ #: `contract/envelopes/monitor-no-port/PROVENANCE.txt` for how a golden arms
157
+ #: it via `env.json`.
158
+ _TEST_PORTS_ENV = "TAN_MONITOR_TEST_PORTS_JSON"
159
+
160
+
161
+ def _available_ports() -> list[tuple[str, str]]:
162
+ """`[(device, description)]` for every serial port pyserial can see.
163
+
164
+ The import is guarded HERE, not only at the caller, because this is the one
165
+ choke point every port-listing path routes through -- and because
166
+ `_run_monitor`'s precheck is deliberately skipped on a FROZEN build (there
167
+ is no `sys.executable` worth validating there). On a `--onefile` binary
168
+ built without the `monitor` extra this line is therefore the FIRST place
169
+ pyserial is touched, and it is reached IN-PROCESS before any child is
170
+ spawned. Left unguarded the ImportError escaped as an unexpected exception
171
+ and surfaced as `monitor.internal-failure` at exit 5 -- "tan has a bug" --
172
+ for what is simply an optional dependency the customer never installed.
173
+
174
+ `_TEST_PORTS_ENV`, when set, short-circuits all of the above -- see its own
175
+ comment. A malformed value (bad JSON, the wrong shape) is a harness/fixture
176
+ bug, not a customer-facing one -- the same "do not let this become
177
+ `monitor.internal-failure`" reasoning the ImportError guard above states
178
+ for itself -- so it is swallowed and falls through to the real enumeration
179
+ below rather than escaping as an unexpected exception.
180
+ """
181
+ fake = os.environ.get(_TEST_PORTS_ENV)
182
+ if fake is not None:
183
+ try:
184
+ return [(str(device), str(description)) for device, description in json.loads(fake)]
185
+ except (ValueError, TypeError):
186
+ pass
187
+
188
+ try:
189
+ from serial.tools import list_ports # noqa: PLC0415 (optional at runtime)
190
+ except ImportError as err:
191
+ raise _pyserial_missing() from err
192
+
193
+ return [(p.device, p.description or "") for p in list_ports.comports()]
194
+
195
+
196
+ #: Windows' documented device-namespace prefix. `\\.\COM38` is the spelling
197
+ #: Microsoft documents for COM10 and above, and pyserial's win32 backend opens
198
+ #: it -- `serial/serialwin32.py` hands any string that does not
199
+ #: `startswith('COM')` straight to `CreateFile`. What it is NOT is a spelling
200
+ #: `comports()` ever REPORTS: `serial/tools/list_ports_windows.py` builds each
201
+ #: `ListPortInfo` from the registry `PortName`, which is the bare `COM38`. So a
202
+ #: membership test against `comports()` refuses a port pyserial would have
203
+ #: opened, while listing that same port in its own "not found" message
204
+ #: (tan-cli#701).
205
+ _WINDOWS_DEVICE_PREFIX = "\\\\.\\"
206
+
207
+
208
+ def _port_aliases(port: str) -> set[str]:
209
+ """Every spelling of `port` that `comports()` might report it under.
210
+
211
+ Deliberately narrow: this resolves a spelling pyserial itself accepts back
212
+ to the bare device name, rather than widening the gate. A port that is
213
+ genuinely absent is still refused -- see
214
+ `test_a_unc_port_whose_bare_form_is_absent_is_still_refused`.
215
+ """
216
+ aliases = {port}
217
+ if port.startswith(_WINDOWS_DEVICE_PREFIX):
218
+ aliases.add(port[len(_WINDOWS_DEVICE_PREFIX):])
219
+ return aliases
220
+
221
+
222
+ def _url_handler_prefixes() -> frozenset[str]:
223
+ """pyserial's own URL schemes, read from `serial.urlhandler` rather than
224
+ hardcoded here (tan-cli#569).
225
+
226
+ `serial.serial_for_url` dispatches on these, and miniterm -- which this
227
+ command spawns -- goes through it. `comports()` enumerates none of them, so
228
+ a membership test refuses every one, and for `socket://`/`rfc2217://` there
229
+ is no device path to fall back on: the port does not exist locally at all.
230
+
231
+ Derived by listing `serial/urlhandler/protocol_*.py`, measured on pyserial
232
+ 3.5 as `alt://`, `cp2110://`, `hwgrep://`, `loop://`, `rfc2217://`,
233
+ `socket://`, `spy://`. Read from the installed package so a pyserial that
234
+ adds or drops one moves this set with it. An empty result (no such package)
235
+ yields an empty set, which leaves the gate exactly as strict as before.
236
+ """
237
+ try:
238
+ import pkgutil # noqa: PLC0415
239
+ import serial.urlhandler # noqa: PLC0415
240
+ except ImportError:
241
+ return frozenset()
242
+ return frozenset(
243
+ f"{module.name[len('protocol_'):]}://"
244
+ for module in pkgutil.iter_modules(serial.urlhandler.__path__)
245
+ if module.name.startswith("protocol_")
246
+ )
247
+
248
+
249
+ def _is_openable_device(port: str) -> bool:
250
+ """Whether `port` is a character device on this host.
251
+
252
+ THE case tan-cli#569 was filed on: `/dev/serial/by-id/usb-..._-if00`
253
+ symlinks. `serial.tools.list_ports` reports raw nodes (`/dev/ttyACM0`) and
254
+ never the by-id path, while `serial.Serial()` opens the symlink fine --
255
+ measured on this host against a real Artery AT32 adapter. On a bench with
256
+ two identical adapters whose `ttyACM` ordering swaps across reboots, the
257
+ by-id path is the only stable name for the intended board, so refusing it
258
+ forces the operator onto the unstable one.
259
+
260
+ `os.stat` follows symlinks, which is the point. Any `OSError` -- absent
261
+ path, permission, an embedded NUL -- reads as "not a device", so a
262
+ genuinely missing port is still refused rather than becoming a traceback.
263
+ Windows reaches this too and answers False for `COM7` (no stat-able
264
+ character device there), which is correct: `_port_aliases` is what covers
265
+ Windows, and this must not quietly widen that platform's gate.
266
+ """
267
+ try:
268
+ return stat.S_ISCHR(os.stat(port).st_mode)
269
+ except (OSError, ValueError):
270
+ return False
271
+
272
+
273
+ def _port_is_usable(port: str, enumerated: set[str]) -> bool:
274
+ """The tan-cli#569 gate: openable, not merely enumerated.
275
+
276
+ Three accepting arms -- enumerated (including the `\\\\.\\` alias of
277
+ tan-cli#701), a character device on this host, or one of pyserial's own URL
278
+ schemes. A name matching none of the three is still refused, which is the
279
+ only reason this function is worth having over `True`.
280
+ """
281
+ if _port_aliases(port) & enumerated:
282
+ return True
283
+ if _is_openable_device(port):
284
+ return True
285
+ return any(port.startswith(scheme) for scheme in _url_handler_prefixes())
286
+
287
+
288
+ def _ports_data(ports: list[tuple[str, str]]) -> list[dict[str, str]]:
289
+ return [{"device": device, "description": description} for device, description in ports]
290
+
291
+
292
+ def _refuse_listing_ports(reason: str) -> MonitorError:
293
+ """Port of `monitor.py::_die_listing_ports`: the reason plus every serial
294
+ port pyserial can see, folded into one issue message so `--format json`
295
+ carries the same information the oracle prints line-by-line to stderr."""
296
+ ports = _available_ports()
297
+ if ports:
298
+ listing = "; ".join(f"{d} {desc}".rstrip() for d, desc in ports)
299
+ message = f"{reason} -- available serial ports: {listing}"
300
+ else:
301
+ message = f"{reason} -- no serial ports detected on this host."
302
+ return MonitorError(
303
+ "monitor.no-port",
304
+ message,
305
+ ExitCode.RUNTIME_FAILURE,
306
+ {"schemaVersion": DATA_SCHEMA_VERSION, "availablePorts": _ports_data(ports)},
307
+ )
308
+
309
+
310
+ def _child_stdout(json_mode: bool):
311
+ """What miniterm's stdout is wired to (tan-cli#491 defect 6).
312
+
313
+ TEXT mode: `None`, i.e. inherit -- board traffic on tan's stdout is the
314
+ whole point of an interactive console, and redirecting it would break
315
+ `tan monitor > board.log`.
316
+
317
+ `--format json`: the board's bytes must NOT land on stdout. pyserial's
318
+ miniterm writes every received byte through `Console.write` to its own
319
+ `sys.stdout`, so an inherited stdout puts board traffic AHEAD of the
320
+ envelope and a consumer's whole-stdout `JSON.parse` fails on an `ok: true`,
321
+ exit-0 run -- reproduced end to end against a real pty. Same rule
322
+ `flash_cmd` states for itself: nothing but the single JSON envelope may
323
+ reach stdout under `--format json`. The traffic is kept, on stderr, where
324
+ this command's own `monitor: <port> @ <baud>` banner and miniterm's own
325
+ banner already go.
326
+
327
+ `sys.__stderr__`, not `sys.stderr`: under `--format json` `cli.main` binds
328
+ `sys.stderr` to a `_TeeStderr`, which implements only `write`/`flush`/
329
+ `getvalue` -- it has no `fileno()`, and `subprocess` needs a real one to
330
+ hand the child. `sys.__stderr__` is the interpreter's original stream and
331
+ is unaffected by that rebinding. It can still be `None` (pythonw, an
332
+ embedded interpreter) or closed, hence the guard; `DEVNULL` is the last
333
+ resort, because dropping the board's bytes is bad and putting them on
334
+ stdout is the defect.
335
+ """
336
+ if not json_mode:
337
+ return None
338
+ stream = sys.__stderr__
339
+ try:
340
+ if stream is not None and stream.fileno() >= 0:
341
+ return stream
342
+ except (AttributeError, OSError, ValueError):
343
+ pass
344
+ return subprocess.DEVNULL
345
+
346
+
347
+ def _spawn_console(python: str, port: str, baud: int, console_filter: str, json_mode: bool) -> int:
348
+ """Run the plain console child (`python -c <bootstrap>`), returning its exit code."""
349
+ try:
350
+ # Empty cwd: `-c` puts the cwd on sys.path, so a `serial/` planted in the
351
+ # project dir would be imported instead of pyserial (tan-cli#1317). It
352
+ # also neutralises empty/relative PYTHONPATH entries (they resolve
353
+ # against this empty directory).
354
+ with tempfile.TemporaryDirectory(prefix="tan-monitor-") as empty:
355
+ return subprocess.run(
356
+ [
357
+ python,
358
+ "-c",
359
+ console_filter_mod.BOOTSTRAP,
360
+ "--filter",
361
+ console_filter,
362
+ # The spawn runs from an empty cwd, so a relative device path
363
+ # must be made absolute first.
364
+ os.path.abspath(port) if os.path.exists(port) else port,
365
+ str(baud),
366
+ ],
367
+ stdout=_child_stdout(json_mode),
368
+ env=spawn_env(),
369
+ cwd=empty,
370
+ ).returncode
371
+ except OSError as err:
372
+ raise MonitorError(
373
+ "monitor.launch-failed",
374
+ f"failed to launch `{python} -c <miniterm bootstrap>`: {err}",
375
+ ExitCode.RUNTIME_FAILURE,
376
+ {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud},
377
+ ) from err
378
+
379
+
380
+ def _run_monitor(
381
+ port: str | None,
382
+ baud: int,
383
+ json_mode: bool,
384
+ break_opts: tuple[bytes, bytes, float] | None = None,
385
+ non_interactive: bool = False,
386
+ console_filter: str = "colors",
387
+ capture_opts: tuple | None = None,
388
+ action_spec: object | None = None,
389
+ ) -> tuple[dict, list[Issue], ExitCode]:
390
+ # Frozen (PyInstaller) or an embedded interpreter with no reportable
391
+ # `sys.executable`: fall back to a PATH name, mirroring
392
+ # `tan.core.sdk_discovery._planner_python` -- NOT `sys.executable`, which under a
393
+ # PyInstaller freeze IS `tan` itself and would just re-enter this CLI.
394
+ using_this_interpreter = not getattr(sys, "frozen", False) and bool(sys.executable)
395
+ python = (
396
+ sys.executable
397
+ if using_this_interpreter
398
+ else _planner_python(str(Path.cwd()), None)
399
+ )
400
+
401
+ # `_TEST_PORTS_ENV` set means `_available_ports()` never touches real
402
+ # pyserial for this run (see its own comment), so this precheck -- whose
403
+ # only job is proving pyserial resolves in THIS interpreter -- would just
404
+ # make a contract golden depend on whether pyserial happens to be
405
+ # installed in whatever environment ran it, the exact host-dependence the
406
+ # seam exists to remove.
407
+ if using_this_interpreter and os.environ.get(_TEST_PORTS_ENV) is None:
408
+ # This precheck only proves the interpreter about to be spawned --
409
+ # THIS one -- has pyserial. It says nothing about a PATH `python`
410
+ # resolved via `_planner_python()`, so skip it there; a missing
411
+ # pyserial in the child surfaces as the child's own reported failure.
412
+ try:
413
+ import serial # noqa: F401, PLC0415 (validates pyserial is installed)
414
+ except ImportError as err:
415
+ raise _pyserial_missing() from err
416
+
417
+ if port is None:
418
+ raise _refuse_listing_ports("no --port given")
419
+ problem = serial_url.port_url_problem(port)
420
+ if problem is not None:
421
+ raise MonitorError(
422
+ "monitor.bad-port",
423
+ f"bad --port: {problem}",
424
+ ExitCode.VALIDATION_FAILURE,
425
+ {"schemaVersion": DATA_SCHEMA_VERSION, "port": port},
426
+ )
427
+ if not _port_is_usable(port, {device for device, _ in _available_ports()}):
428
+ raise _refuse_listing_ports(f"port '{port}' not found")
429
+
430
+ if capture_opts is not None:
431
+ from tan.commands import monitor_session # noqa: PLC0415 (only on --capture)
432
+
433
+ return monitor_session.run_capture(port, baud, capture_opts, break_opts, action_spec)
434
+
435
+ if break_opts is not None:
436
+ from tan.commands import monitor_session # noqa: PLC0415 (only on --break-uboot)
437
+
438
+ return monitor_session.run(
439
+ port, baud, json_mode, break_opts, non_interactive, console_filter
440
+ )
441
+
442
+ print(f"monitor: {port} @ {baud} (Ctrl+] to quit)", file=sys.stderr)
443
+ rc = _spawn_console(python, port, baud, console_filter, json_mode)
444
+
445
+ data = {"schemaVersion": DATA_SCHEMA_VERSION, "port": port, "baud": baud}
446
+ if rc != 0:
447
+ return (
448
+ data,
449
+ [
450
+ Issue(
451
+ "monitor.failed",
452
+ "error",
453
+ f"`tan monitor` exited with code {rc} (see log above).",
454
+ )
455
+ ],
456
+ ExitCode.RUNTIME_FAILURE,
457
+ )
458
+ return data, [], ExitCode.SUCCESS
459
+
460
+
461
+ def monitor(
462
+ port: str = typer.Option(
463
+ None,
464
+ "--port",
465
+ help="Serial port (COM7, /dev/ttyUSB0, /dev/cu.usbmodem...).",
466
+ ),
467
+ baud: int = typer.Option(
468
+ DEFAULT_BAUD, "--baud", show_default=True, help="Baud rate."
469
+ ),
470
+ break_uboot: bool = typer.Option(
471
+ False,
472
+ "--break-uboot",
473
+ help="After opening the port, send the autoboot interrupt key repeatedly "
474
+ "until the U-Boot prompt appears or --break-timeout passes, then continue "
475
+ "in the interactive console on the same open port. With --non-interactive "
476
+ "it stops after the break-in instead (exit 0 if caught); the console "
477
+ "itself needs a terminal. --non-interactive is a shared flag (see "
478
+ "its own help line). Power-cycle the board yourself; works over "
479
+ "rfc2217:// and socket:// URLs. Use rfc2217:// against ser2net: socket:// "
480
+ "to a telnet/RFC2217 port delivers IAC negotiation bytes as data and "
481
+ "adds about 4 s.",
482
+ ),
483
+ break_key: str = typer.Option(
484
+ None,
485
+ "--break-key",
486
+ help="With --break-uboot: key that interrupts autoboot (default: a space; "
487
+ "escapes \\xNN \\r \\n \\t \\\\ allowed).",
488
+ ),
489
+ prompt: str = typer.Option(
490
+ None, "--prompt", help="With --break-uboot: prompt that ends it (default: '=> ')."
491
+ ),
492
+ break_timeout: float = typer.Option(
493
+ None,
494
+ "--break-timeout",
495
+ help="With --break-uboot: seconds to keep sending the key (default: 30).",
496
+ ),
497
+ capture: bool = typer.Option(
498
+ False,
499
+ "--capture",
500
+ help="Headless capture instead of an interactive console: no TTY needed, "
501
+ "works over pyserial URLs, stores raw bytes. Needs --duration and/or "
502
+ "--until; combine with --break-uboot to break in first. Never prompts "
503
+ "(--non-interactive is accepted and has no further effect).",
504
+ ),
505
+ duration: float = typer.Option(
506
+ None, "--duration", help="With --capture: seconds to read (default 30 with --until)."
507
+ ),
508
+ until: str = typer.Option(
509
+ None,
510
+ "--until",
511
+ help="With --capture: stop at the first line (or unterminated partial line, "
512
+ "e.g. a prompt) matching this regex; the envelope carries it. A line "
513
+ "longer than 4 KiB is searched on its last 4 KiB (complete or partial). A "
514
+ "regex cannot be interrupted inside one search. No match in time is an error.",
515
+ ),
516
+ log: str = typer.Option(None, "--log", help="With --capture: write the raw bytes to this file."),
517
+ send: list[str] = typer.Option(
518
+ None, "--send", metavar="TEXT",
519
+ help="With --capture: write TEXT to the console (repeatable, in order; escapes "
520
+ "\\xNN \\r \\n \\t \\\\, no newline added). All items go out as one burst, at the "
521
+ "start unless --send-after or --send-on says when.",
522
+ ),
523
+ send_after: float = typer.Option(
524
+ None, "--send-after", metavar="SECONDS", help="With --send: send the burst this long after the start."
525
+ ),
526
+ send_on: str = typer.Option(
527
+ None, "--send-on", metavar="REGEX",
528
+ help="With --send: send the burst on the first line (or partial line, e.g. a prompt) "
529
+ "matching this regex. Excludes --send-after.",
530
+ ),
531
+ send_gap: float = typer.Option(
532
+ None, "--send-gap", metavar="SECONDS", help="With --send: pause between items (default 0.2)."
533
+ ),
534
+ reopen_at: int = typer.Option(
535
+ None, "--reopen-at", metavar="BAUD",
536
+ help="With --on: change the baud (in place, else close and reopen) on the first complete "
537
+ "line matching --on, then keep capturing. Works over rfc2217:// and local serial.",
538
+ ),
539
+ on: str = typer.Option(None, "--on", metavar="REGEX", help="With --reopen-at: the trigger regex."),
540
+ console_filter: ConsoleFilter = typer.Option(
541
+ None,
542
+ "--filter",
543
+ help="Console output filter (default: colors): colors (colours render, every other escape "
544
+ "is neutralised), default/nocontrol/printable (strip control codes), "
545
+ "direct (raw bytes to the terminal: unsafe for untrusted targets). "
546
+ "colors cannot stop same-colour (invisible) text or \\b/\\r overdrawing.",
547
+ ),
548
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
549
+ project: str = typer.Option(None, "--project", hidden=True),
550
+ board_yaml: str = typer.Option(None, "--board-yaml", hidden=True),
551
+ sdk_root: str = typer.Option(None, "--sdk-root", hidden=True),
552
+ target: str = typer.Option(None, "--target", hidden=True),
553
+ all_targets: bool = typer.Option(False, "--all", hidden=True),
554
+ verbose: bool = typer.Option(False, "--verbose", hidden=True),
555
+ quiet: bool = typer.Option(False, "--quiet", hidden=True),
556
+ no_color: bool = typer.Option(False, "--no-color", hidden=True),
557
+ non_interactive: bool = typer.Option(
558
+ False, "--non-interactive", help="With --break-uboot: stop after the break-in."
559
+ ),
560
+ ci: bool = typer.Option(False, "--ci", hidden=True),
561
+ ) -> None:
562
+ """Open a serial console to the board."""
563
+ # The ten options above are clap's `GlobalArgs` members (`global = true`)
564
+ # that the oracle accepts on EVERY verb, `monitor` included, and never
565
+ # reads for this one -- confirmed live (`tan.exe monitor --non-interactive
566
+ # --ci --target zephyr-conf --all --project . --board-yaml x --sdk-root x
567
+ # --port COM7` reaches the identical "port not found" failure a bare
568
+ # `tan.exe monitor --port COM7` does). Declared here purely so the argv
569
+ # SURFACE matches: `tan monitor --sdk-root <path> --port COM7` exited 2 as
570
+ # a Click "No such option" usage error without this, breaking any caller
571
+ # (or saved script) forwarding the global set unconditionally -- unlike
572
+ # `model`/`new-som`/`faultdecode`, `monitor` never resolves an SDK root at
573
+ # all (see the module docstring), so `--project`/`--board-yaml`/
574
+ # `--sdk-root` are genuinely unread here too, not merely deferred. Hidden
575
+ # from `--help` because they do nothing. Same port-wide gap as
576
+ # `clean_cmd.clean`/`new_som_cmd.new_som`.
577
+ del project, board_yaml, sdk_root, target, all_targets
578
+ del verbose, quiet, no_color, ci
579
+ json_mode = output_format == "json"
580
+
581
+ def finish(data: dict, issues: list[Issue], exit_code: ExitCode) -> None:
582
+ if json_mode:
583
+ emit(
584
+ Envelope(
585
+ "monitor", Project(root=None, board_yaml=None), data, issues, exit_code
586
+ )
587
+ )
588
+ else:
589
+ for issue in issues:
590
+ print(f"monitor: {issue.message}", file=sys.stderr)
591
+ raise typer.Exit(int(exit_code))
592
+
593
+ try:
594
+ from tan.commands import monitor_session # noqa: PLC0415 (validation only)
595
+
596
+ opts = monitor_session.break_opts(break_uboot, break_key, prompt, break_timeout)
597
+ cap = monitor_session.capture_opts(
598
+ capture, duration, until, log, console_filter is not None
599
+ )
600
+ from tan.commands import monitor_actions # noqa: PLC0415 (validation only)
601
+
602
+ spec = monitor_actions.action_opts(
603
+ capture, send, send_after, send_on, send_gap, reopen_at, on
604
+ )
605
+ data, issues, exit_code = _run_monitor(
606
+ port, baud, json_mode, opts, non_interactive,
607
+ console_filter.value if console_filter else "colors", cap, spec
608
+ )
609
+ except MonitorError as err:
610
+ finish(err.data, [Issue(err.code, "error", err.message)], err.exit_code)
611
+ return
612
+ except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
613
+ finish(
614
+ {"schemaVersion": DATA_SCHEMA_VERSION},
615
+ [
616
+ Issue(
617
+ "monitor.internal-failure",
618
+ "error",
619
+ f"monitor failed unexpectedly: {type(err).__name__}: {err}",
620
+ )
621
+ ],
622
+ ExitCode.INTERNAL_FAILURE,
623
+ )
624
+ return
625
+
626
+ finish(data, issues, exit_code)