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
tan/core/size.py ADDED
@@ -0,0 +1,610 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure footprint model for `tan size` -- port of `crates/tan-core/src/size.rs`.
3
+
4
+ Measurement-source selection, budget resolution, classification and rendering
5
+ are all IO-free here; the subprocess + filesystem wiring lives in
6
+ `tan.commands.size_cmd`.
7
+
8
+ FLASH = text+data (everything in the flashed image); RAM = data+bss (everything
9
+ live at runtime) -- the Berkeley `size` model, where binutils folds `.rodata`
10
+ into `text` and `.noinit` (NOBITS) into `bss`.
11
+
12
+ Nothing here knows a hardware fact. `resolve_budget` is handed numbers a caller
13
+ read out of alp-sdk `metadata/`; no SKU, address or bank name is spelled in this
14
+ file (I-26).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import re
22
+ import struct
23
+ from dataclasses import dataclass, field
24
+ from collections.abc import Sequence
25
+ from typing import Any
26
+
27
+ from tan.core.pending import is_pending_placeholder
28
+ from tan.core.ram_run import SRAM0_BASE
29
+
30
+ #: ELF `sh_flags` bits used to classify a section.
31
+ SHF_WRITE = 0x1
32
+ SHF_ALLOC = 0x2
33
+
34
+ #: ELF `sh_type` for a section that occupies memory but not the image.
35
+ SHT_NOBITS = 8
36
+
37
+ #: A slice at/above this fraction of its budget is `warn` even though it still
38
+ #: fits -- a pre-flight "you're close" nudge.
39
+ WARN_FRACTION = 0.90
40
+
41
+ #: `u64::MAX`. A corrupt/adversarial `sh_size` saturates here rather than
42
+ #: reporting an arbitrarily large Python int the oracle could never emit.
43
+ _U64_MAX = 2**64 - 1
44
+
45
+ # colorama Fore/Style literals, so the color path matches the retired command's
46
+ # bytes.
47
+ _GREEN = "\x1b[32m"
48
+ _YELLOW = "\x1b[33m"
49
+ _RED = "\x1b[31m"
50
+ _CYAN = "\x1b[36m"
51
+ _RESET = "\x1b[0m"
52
+
53
+ #: Rust's `str::parse::<u64>()`: optional `+`, digits only. Python's `int()` is
54
+ #: laxer (it takes `-5`, `1_000`, surrounding whitespace, unicode digits), so a
55
+ #: `size` output row the oracle skips as noise must be skipped here too.
56
+ _U64_RE = re.compile(r"\+?[0-9]+\Z")
57
+
58
+
59
+ def _u64(token: str) -> int | None:
60
+ if not _U64_RE.match(token):
61
+ return None
62
+ value = int(token)
63
+ return value if value <= _U64_MAX else None
64
+
65
+
66
+ def parse_berkeley_size(text: str) -> tuple[int, int] | None:
67
+ """Parse Berkeley-format `size` output into `(flash, ram)` bytes. Columns are
68
+ `text data bss dec hex filename`; the first row whose first three
69
+ whitespace-split fields all parse as u64 wins (header/noise rows skipped).
70
+ `None` when no data row is found."""
71
+ for line in text.splitlines():
72
+ parts = line.split()
73
+ if len(parts) < 3:
74
+ continue
75
+ text_b, data_b, bss_b = (_u64(p) for p in parts[:3])
76
+ if text_b is None or data_b is None or bss_b is None:
77
+ continue # header row ("text data bss ...") or noise
78
+ return text_b + data_b, data_b + bss_b
79
+ return None
80
+
81
+
82
+ def sizes_from_elf_sections(
83
+ elf_bytes: bytes, off_budget_spans: Sequence[tuple[int, int]] = ()
84
+ ) -> tuple[int, int] | None:
85
+ """Sum ELF section sizes into `(flash, ram)` bytes with Berkeley-`size`
86
+ semantics, straight from the section headers -- the middle rung between the
87
+ external size tool and the `rom/ram.json` fallback, so a present elf is
88
+ measured even with no `arm-zephyr-eabi-size` on PATH.
89
+
90
+ FLASH = every `SHF_ALLOC` section that occupies the image (not NOBITS):
91
+ `.text` + `.rodata` + `.data`, i.e. binutils' `text`+`data` columns.
92
+ RAM = every `SHF_ALLOC | SHF_WRITE` section, NOBITS included: `.data` +
93
+ `.bss` + `.noinit`, i.e. binutils' `data`+`bss` columns.
94
+
95
+ *off_budget_spans* (`(base, size)`, tan-cli#1402): a writable section whose
96
+ `sh_addr` falls inside one is placed in a different region than the RAM
97
+ budget (e.g. SRAM0 vs the core's DTCM), so it is left out of RAM. FLASH is
98
+ unchanged -- an initialised `.data` there is still loaded from the image.
99
+
100
+ `None` -- so the caller falls through to the next rung instead of reporting
101
+ a fake 0-byte size -- when the bytes are not ELF (a PE/Mach-O/wasm container
102
+ parses in other readers and used to yield `(0, 0)` in the oracle), when the
103
+ header table does not fit in the file, or when NO allocated section exists
104
+ (a relocatable `.o`, a stripped partial link). Handles ELF32/ELF64 and
105
+ either endianness, whatever the SoM core emits. Pure.
106
+ """
107
+ if len(elf_bytes) < 64 or elf_bytes[:4] != b"\x7fELF":
108
+ return None
109
+ ei_class, ei_data = elf_bytes[4], elf_bytes[5]
110
+ if ei_class not in (1, 2) or ei_data not in (1, 2):
111
+ return None
112
+ endian = "<" if ei_data == 1 else ">"
113
+ if ei_class == 2: # ELF64
114
+ e_shoff = struct.unpack_from(endian + "Q", elf_bytes, 0x28)[0]
115
+ e_shentsize, e_shnum = struct.unpack_from(endian + "HH", elf_bytes, 0x3A)
116
+ shdr = endian + "IIQQQQIIQQ"
117
+ type_at, flags_at, addr_at, size_at = 1, 2, 3, 5
118
+ else: # ELF32
119
+ e_shoff = struct.unpack_from(endian + "I", elf_bytes, 0x20)[0]
120
+ e_shentsize, e_shnum = struct.unpack_from(endian + "HH", elf_bytes, 0x2E)
121
+ shdr = endian + "IIIIIIIIII"
122
+ type_at, flags_at, addr_at, size_at = 1, 2, 3, 5
123
+ entry_size = struct.calcsize(shdr)
124
+ if e_shentsize < entry_size or e_shnum == 0:
125
+ return None
126
+ # The header TABLE is bounds-checked (a truncated/adversarial e_shoff/e_shnum
127
+ # must not read past the buffer); an individual `sh_size` is NOT -- it is an
128
+ # unvalidated field, and its bytes are never read here, only its declared
129
+ # length, so it is saturated instead.
130
+ if e_shoff + e_shnum * e_shentsize > len(elf_bytes):
131
+ return None
132
+
133
+ flash = 0
134
+ ram = 0
135
+ saw_alloc = False
136
+ for index in range(e_shnum):
137
+ fields = struct.unpack_from(shdr, elf_bytes, e_shoff + index * e_shentsize)
138
+ sh_type, sh_flags, sh_addr, sh_size = (
139
+ fields[type_at],
140
+ fields[flags_at],
141
+ fields[addr_at],
142
+ fields[size_at],
143
+ )
144
+ if not sh_flags & SHF_ALLOC:
145
+ continue # .symtab, .debug_*, .comment -- neither region
146
+ saw_alloc = True
147
+ if sh_type != SHT_NOBITS:
148
+ flash = min(flash + sh_size, _U64_MAX)
149
+ if sh_flags & SHF_WRITE and not any(
150
+ base <= sh_addr < base + span for base, span in off_budget_spans
151
+ ):
152
+ ram = min(ram + sh_size, _U64_MAX)
153
+ return (flash, ram) if saw_alloc else None
154
+
155
+
156
+ def footprint_total(json_text: str) -> int | None:
157
+ """Total bytes from a Zephyr `rom.json` / `ram.json` footprint document. The
158
+ footprint root is `{"symbols": {"size": <total>}}`; older layouts put the
159
+ total at the top level. `None` when the JSON is malformed or neither shape
160
+ yields a non-negative integer.
161
+
162
+ `bool` is excluded explicitly: it is an `int` subclass in Python, and
163
+ serde_json's `as_u64()` never accepts a JSON boolean.
164
+ """
165
+ try:
166
+ data = json.loads(json_text)
167
+ except (ValueError, RecursionError):
168
+ return None
169
+ if not isinstance(data, dict):
170
+ return None
171
+ symbols = data.get("symbols")
172
+ if isinstance(symbols, dict):
173
+ size = _as_u64(symbols.get("size"))
174
+ if size is not None:
175
+ return size
176
+ return _as_u64(data.get("size"))
177
+
178
+
179
+ def _as_u64(value: Any) -> int | None:
180
+ if isinstance(value, bool) or not isinstance(value, int):
181
+ return None
182
+ return value if 0 <= value <= _U64_MAX else None
183
+
184
+
185
+ def round1(x: float) -> float:
186
+ """Round to 1 decimal the way both CPython's `round` and Rust's `{:.1}` do:
187
+ on the TRUE binary value, so the X.X5 tie cases agree -- 0.35 is actually
188
+ 0.34999... and gives 0.3, not the 0.4 a `(x*10).round()/10` multiply
189
+ yields. Formatting-then-parsing rather than `round()` keeps this identical
190
+ to the text-cell path, which already format-rounds."""
191
+ try:
192
+ return float(f"{x:.1f}")
193
+ except (OverflowError, ValueError):
194
+ return x
195
+
196
+
197
+ def region_json(used: int | None, total: int | None) -> dict[str, Any]:
198
+ """A `{used, total, pct}` region object. `pct` is `null` unless `used` is
199
+ present and `total` is present AND non-zero (no div-by-zero, no guess)."""
200
+ pct = None
201
+ if used is not None and total:
202
+ pct = round1(used / total * 100.0)
203
+ return {"used": used, "total": total, "pct": pct}
204
+
205
+
206
+ def core_token(core_id: str) -> str:
207
+ """`m55_hp` -> `M55_HP` -- the token embedded in `sram_banks_kb` keys."""
208
+ return core_id.upper()
209
+
210
+
211
+ def classify(
212
+ flash_used: int | None,
213
+ flash_total: int | None,
214
+ ram_used: int | None,
215
+ ram_total: int | None,
216
+ ) -> str:
217
+ """`ok` / `warn` / `over` from the worst of the two regions. A region with a
218
+ missing `used`, or a `total` that is missing or zero, is IGNORED -- no
219
+ div-by-zero and no guess. That silent skip is why `budget_fully_known`
220
+ exists: it is the only thing that surfaces a half-resolved budget."""
221
+ worst = "ok"
222
+ for used, total in ((flash_used, flash_total), (ram_used, ram_total)):
223
+ if used is None or not total:
224
+ continue
225
+ frac = used / total
226
+ if frac > 1.0:
227
+ return "over"
228
+ if frac >= WARN_FRACTION:
229
+ worst = "warn"
230
+ return worst
231
+
232
+
233
+ @dataclass
234
+ class MemoryBudget:
235
+ """A resolved per-slice FLASH/RAM budget in bytes, plus a human note when a
236
+ coarser fallback source was used."""
237
+
238
+ flash_total: int | None = None
239
+ ram_total: int | None = None
240
+ note: str | None = None
241
+ #: `(base, size)` apertures of SRAM banks that are NOT the RAM budget's region
242
+ #: (tan-cli#1402); a section linked inside one is not counted against it.
243
+ ram_off_budget_spans: tuple[tuple[int, int], ...] = ()
244
+
245
+
246
+ def budget_note_only(note: str) -> MemoryBudget:
247
+ return MemoryBudget(None, None, note)
248
+
249
+
250
+ def _saturating_u64(value: float) -> int:
251
+ """Rust's `as u64` cast for a FINITE input -- truncate toward zero, 0 at or
252
+ below zero, `u64::MAX` above it -- and 0, never a number, for a NON-FINITE
253
+ one.
254
+
255
+ That cast used to sit outside its caller's `try` (tan-cli#499 defect 8), so
256
+ an infinite `mram_mb` / `soc_flash_mb` / `tcm_kb` / `sram_banks_kb` entry --
257
+ which `json.loads` produces for an `Infinity` literal or an overflowing
258
+ `1e400` in a SoC JSON -- collapsed the whole `tan size` run into
259
+ `size.internal-failure` at exit 5, with `data.slices` EMPTY (every other
260
+ slice's measurement discarded) and `project` null. Python's `int()` refuses
261
+ `inf` (`OverflowError: cannot convert float infinity to integer`) where
262
+ Rust's cast does not, so the two cases have to be separated by hand.
263
+
264
+ **Where the boundary sits, and why it is NOT "saturate everything"
265
+ (tan-cli#499 REVIEW).** The first version of this fix answered `+inf` with
266
+ `u64::MAX`, arguing continuity across the f64 range. Measured, that
267
+ manufactures a confidently WRONG budget at `ok:true`, which is the exact
268
+ failure class this issue exists to close:
269
+
270
+ # SoC JSON with "mram_mb": 1e400
271
+ this branch, before: exit 0 ok:true flash.total 18446744073709551615
272
+ tan 0.4.1: exit 0 ok:true flash.total null,
273
+ budget_note "unreadable SoM preset for E1M-AEN801"
274
+
275
+ 16 EiB of flash, stated as fact, on a manifest a customer could then read as
276
+ "plenty of room". So the saturation is BOUNDED to the inputs where the
277
+ oracle demonstrably saturates -- the finite ones:
278
+
279
+ # SoC JSON with "mram_mb": 1e300 (finite; serde_json reads it)
280
+ this branch: exit 0 flash.total 18446744073709551615
281
+ tan 0.4.1: exit 0 flash.total 18446744073709551615 <- byte-identical
282
+
283
+ A NON-finite value is not a point on that curve: serde_json refuses `1e400`
284
+ / `Infinity` / `NaN` outright, so there is no oracle answer to be continuous
285
+ WITH. Those join the negative/NaN arm at 0 -- this module's own "unresolved"
286
+ value (`_region_resolved` treats 0 as unresolved, `classify` skips it,
287
+ `budget_fully_known()` is False for it), so the envelope reports `pct: null`
288
+ and no budget rather than a fabricated maximum. A residual divergence from
289
+ the oracle stays (`total: 0` vs `total: null` plus a note); it is upstream
290
+ of this file and NOT fixed here -- see the PR's "Not fixed" section.
291
+ """
292
+ if value != value or value <= 0: # NaN, -inf, and every non-positive
293
+ return 0
294
+ if value == float("inf"):
295
+ # NOT `_U64_MAX`: see the docstring. An input the oracle's own reader
296
+ # refuses is answered "no budget", not "the largest budget there is".
297
+ return 0
298
+ return min(int(value), _U64_MAX)
299
+
300
+
301
+ def _mb_to_bytes(mb: float) -> int:
302
+ """Megabytes -> bytes with Rust's `as u64` cast semantics: truncate toward
303
+ zero, and saturate a negative or NaN input to 0. A `Some(0)` total counts as
304
+ unresolved everywhere downstream (`region_resolved`), so a nonsense
305
+ `mram_mb` degrades to "no budget" rather than to "fits easily"."""
306
+ try:
307
+ value = float(mb) * 1024.0 * 1024.0
308
+ except (TypeError, ValueError, OverflowError):
309
+ return 0
310
+ return _saturating_u64(value)
311
+
312
+
313
+ def _kib_to_bytes(kib: float) -> int:
314
+ try:
315
+ value = float(kib) * 1024.0
316
+ except (TypeError, ValueError, OverflowError):
317
+ return 0
318
+ return _saturating_u64(value)
319
+
320
+
321
+ def sram_banks(variant: dict) -> list[tuple[str, float]]:
322
+ """A SoC-JSON variant's `sram_banks_kb` as ordered `(name, kib)` pairs,
323
+ dropping non-numeric and unrepresentable entries. Insertion order is the
324
+ wire order, so the first-matching-bank rule in [`resolve_budget`] is
325
+ deterministic.
326
+
327
+ A JSON integer too large for an f64 is DROPPED, not crashed on
328
+ (tan-cli#499 defect 8): `float(10**400)` raises `OverflowError: int too
329
+ large to convert to float`, which took the whole `tan size` run to
330
+ `size.internal-failure` at exit 5. Dropping matches what `_mb_to_bytes`
331
+ already does with the identical value -- its own `float()` raises inside a
332
+ `try` and it returns the unresolved 0 -- so both regions answer "no budget"
333
+ for an unrepresentable integer rather than one crashing and one not. A
334
+ non-finite FLOAT is passed through and reaches the same answer one level
335
+ down: [`_saturating_u64`] resolves it to 0, not to `u64::MAX`."""
336
+ raw = variant.get("sram_banks_kb")
337
+ if not isinstance(raw, dict):
338
+ return []
339
+ out: list[tuple[str, float]] = []
340
+ for name, value in raw.items():
341
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
342
+ continue
343
+ try:
344
+ kib = float(value)
345
+ except OverflowError:
346
+ continue
347
+ out.append((str(name), kib))
348
+ return out
349
+
350
+
351
+ def resolve_variant(
352
+ silicon_variant: str | None, sku: str | None, variants: list[dict]
353
+ ) -> dict | None:
354
+ """Resolve a SoM preset to its matching SoC-JSON variant: forward via
355
+ `silicon_variant == order_code` (skipping empty / `TBD`, trimmed -- #276),
356
+ then reverse via `sku in alp_module_skus`. `None` when neither path
357
+ resolves. A forward value that names no variant falls THROUGH to the
358
+ reverse lookup."""
359
+ if silicon_variant and not is_pending_placeholder(silicon_variant):
360
+ for variant in variants:
361
+ if variant.get("order_code") == silicon_variant:
362
+ return variant
363
+ if sku is None:
364
+ return None
365
+ for variant in variants:
366
+ skus = variant.get("alp_module_skus")
367
+ if isinstance(skus, list) and any(s == sku for s in skus):
368
+ return variant
369
+ return None
370
+
371
+
372
+ def slot0_bytes_for_core(
373
+ core_id: str, memory_map: list[dict] | None
374
+ ) -> int | None:
375
+ """This core's OWN slot0 window from the SoM preset's `memory_map`, in
376
+ bytes -- `None` when the preset declares none for it (tan-cli#747).
377
+
378
+ Since alp-sdk#1445/#1069 a dual-M55 AEN SoM gives each core a disjoint
379
+ slot0 (`he_slot0` at 0x80010000, `hp_slot0` at 0x802b0000, 2688 KiB
380
+ each), and only that window is linkable by that core. The whole-MRAM
381
+ figure is the sum of every partition INCLUDING the other core's, so it
382
+ is nobody's budget.
383
+
384
+ Matched on the `<role>_slot0` name, role being the core id's last
385
+ segment -- the same `core_id.split("_")[-1]` the SDK's own
386
+ `gen_zephyr_board` uses to pick a role's window, not a second spelling
387
+ that could drift from it. `accessible_from`, when the entry declares
388
+ one, must name this core: a window some other core owns is not a budget
389
+ for this one, and silently accepting it would reintroduce exactly the
390
+ cross-core confusion #1069 exists to prevent.
391
+ """
392
+ role = core_id.rsplit("_", 1)[-1]
393
+ if not role:
394
+ return None
395
+ want = f"{role}_slot0"
396
+ for entry in memory_map or []:
397
+ if not isinstance(entry, dict) or entry.get("name") != want:
398
+ continue
399
+ accessible = entry.get("accessible_from")
400
+ if isinstance(accessible, list) and accessible and core_id not in accessible:
401
+ continue
402
+ size_kib = entry.get("size_kib")
403
+ # bool is an int in Python; a `size_kib: true` is malformed, not 1 KiB.
404
+ if isinstance(size_kib, bool) or not isinstance(size_kib, (int, float)):
405
+ continue
406
+ if size_kib <= 0 or not math.isfinite(float(size_kib)):
407
+ continue
408
+ return int(size_kib) * 1024
409
+ return None
410
+
411
+
412
+ # Only SRAM0 (SRAM0_BASE) is excluded from the RAM budget. Limitation: SRAM1
413
+ # (0x08000000) placements are still charged against the DTCM budget.
414
+ def resolve_budget(
415
+ core_id: str,
416
+ mram_mb: float | None,
417
+ soc_flash_mb: float | None,
418
+ sram_banks_kb: list[tuple[str, float]],
419
+ soc_cores: list[tuple[str, float | None]],
420
+ memory_map: list[dict] | None = None,
421
+ ) -> MemoryBudget:
422
+ """The FLASH/RAM budget for one core, from values a caller already read out
423
+ of the SoM preset + SoC JSON. FLASH: this core's own `<role>_slot0` window
424
+ from the SoM `memory_map`, else variant `mram_mb`, else SoC
425
+ `soc_flash_mb` (with a note). RAM: the `*_DTCM` bank whose name contains the
426
+ core token, else the core's `tcm_kb` (with a note). Anything unresolvable
427
+ stays `None` -- the caller renders `unknown`, never a guessed number.
428
+
429
+ FLASH prefers the per-core window for the reason the RAM half below has
430
+ always resolved per core: a shared total is not a budget. Measured before
431
+ the fix, `E1M-AEN801` reported BOTH M55s against 5767168 B (the whole
432
+ 5.5 MB MRAM) while each image links into 2752512 B, so utilisation read
433
+ 2.0% where the linker said 4.22%, and `over_budget` could never fire --
434
+ an image would have to exceed the whole part, which it cannot, because
435
+ mcuboot + both slot0s + reserved + storage + atoc already fill it
436
+ (tan-cli#747).
437
+
438
+ `memory_map` defaults to `None` so every existing caller keeps today's
439
+ behaviour exactly; a SoM that declares no per-role window (single-M55
440
+ parts, non-AEN families, presets predating alp-sdk#1445) still falls
441
+ through to `mram_mb` with no note, byte-identical to before."""
442
+ notes: list[str] = []
443
+
444
+ slot0_total = slot0_bytes_for_core(core_id, memory_map)
445
+ if slot0_total is not None:
446
+ flash_total: int | None = slot0_total
447
+ elif mram_mb is not None:
448
+ flash_total = _mb_to_bytes(mram_mb)
449
+ elif soc_flash_mb is not None:
450
+ notes.append("flash=soc_flash_mb")
451
+ flash_total = _mb_to_bytes(soc_flash_mb)
452
+ else:
453
+ flash_total = None
454
+
455
+ token = core_token(core_id)
456
+ ram_total: int | None = None
457
+ for name, kib in sram_banks_kb:
458
+ if token in name and "DTCM" in name:
459
+ ram_total = _kib_to_bytes(kib)
460
+ break
461
+ if ram_total is None:
462
+ for core, tcm_kb in soc_cores:
463
+ if core == core_id:
464
+ if tcm_kb is not None:
465
+ ram_total = _kib_to_bytes(tcm_kb)
466
+ notes.append("ram=core tcm_kb (ITCM+DTCM)")
467
+ break
468
+
469
+ off_budget: tuple[tuple[int, int], ...] = ()
470
+ if ram_total is not None:
471
+ for name, kib in sram_banks_kb:
472
+ if name.upper() == "SRAM0":
473
+ off_budget = ((SRAM0_BASE, _kib_to_bytes(kib)),)
474
+ break
475
+ return MemoryBudget(flash_total, ram_total, "; ".join(notes) or None, off_budget)
476
+
477
+
478
+ @dataclass
479
+ class SliceSize:
480
+ """One slice's measured footprint vs its resolved budget. `status` is one of
481
+ `ok`/`over`/`warn`/`not-built`/`n/a`/`no-budget`."""
482
+
483
+ core_id: str
484
+ os: str
485
+ status: str
486
+ flash_used: int | None = None
487
+ flash_total: int | None = None
488
+ ram_used: int | None = None
489
+ ram_total: int | None = None
490
+ source: str | None = None
491
+ note: str | None = None
492
+ notes: list[str] = field(default_factory=list)
493
+
494
+ def budget_fully_known(self) -> bool:
495
+ """True only when BOTH regions resolved to a nonzero total.
496
+
497
+ Deliberately an AND, not an OR: `classify` silently skips whichever
498
+ region has no usable `total` (correct in isolation), so with an OR a
499
+ half-resolved budget reported as `ok` and never surfaced -- neither
500
+ `--fail-over-budget` nor `summary.unknown_budget` named the slice.
501
+ A total of exactly `0` counts as unresolved for the same reason
502
+ `classify` already skips it.
503
+ """
504
+ return _region_resolved(self.flash_total) and _region_resolved(self.ram_total)
505
+
506
+ def over_budget(self) -> bool:
507
+ return self.status == "over"
508
+
509
+ def to_json_entry(self) -> dict[str, Any]:
510
+ """This slice's `alp-size/1` JSON entry. Key order IS the wire order:
511
+ core_id, os, status, flash, ram, source, then optional budget_note +
512
+ notes."""
513
+ entry: dict[str, Any] = {
514
+ "core_id": self.core_id,
515
+ "os": self.os,
516
+ "status": self.status,
517
+ "flash": region_json(self.flash_used, self.flash_total),
518
+ "ram": region_json(self.ram_used, self.ram_total),
519
+ "source": self.source,
520
+ }
521
+ if self.note:
522
+ entry["budget_note"] = self.note
523
+ if self.notes:
524
+ entry["notes"] = list(self.notes)
525
+ return entry
526
+
527
+
528
+ def _region_resolved(total: int | None) -> bool:
529
+ return total is not None and total != 0
530
+
531
+
532
+ def build_size_report(rows: list[SliceSize]) -> dict[str, Any]:
533
+ """The full `alp-size/1` payload wrapped verbatim in the tan Envelope's
534
+ `data`: `{schema, slices, summary{over_budget, unknown_budget}}`."""
535
+ return {
536
+ "schema": "alp-size/1",
537
+ "slices": [row.to_json_entry() for row in rows],
538
+ "summary": {
539
+ "over_budget": sorted(r.core_id for r in rows if r.over_budget()),
540
+ "unknown_budget": sorted(r.core_id for r in unknown_budget_rows(rows)),
541
+ },
542
+ }
543
+
544
+
545
+ def over_budget_rows(rows: list[SliceSize]) -> list[SliceSize]:
546
+ return [r for r in rows if r.over_budget()]
547
+
548
+
549
+ def unknown_budget_rows(rows: list[SliceSize]) -> list[SliceSize]:
550
+ """Zephyr slices that were measured but whose budget could not be fully
551
+ resolved -- the ones `--fail-over-budget` skips. Keyed on the resolved
552
+ totals (`budget_fully_known`) rather than on `status == "no-budget"`, which
553
+ the caller only sets when BOTH regions are unresolved."""
554
+ return [
555
+ r
556
+ for r in rows
557
+ if r.os == "zephyr" and r.status != "not-built" and not r.budget_fully_known()
558
+ ]
559
+
560
+
561
+ def human_bytes(n: int | None) -> str:
562
+ """Bytes -> a compact KiB/MiB string, or `?` for unknown."""
563
+ if n is None:
564
+ return "?"
565
+ if n >= 1024 * 1024:
566
+ return f"{n / (1024.0 * 1024.0):.2f}M"
567
+ if n >= 1024:
568
+ return f"{n / 1024.0:.1f}K"
569
+ return f"{n}B"
570
+
571
+
572
+ def region_cell(used: int | None, total: int | None) -> str:
573
+ """A `used/total pct%` table cell, exactly 24 columns wide for the values
574
+ this command produces."""
575
+ pct = f"{used / total * 100.0:5.1f}%" if used is not None and total else " - "
576
+ return f"{human_bytes(used):>8}/{human_bytes(total):<8} {pct}"
577
+
578
+
579
+ def _status_hue(status: str) -> tuple[str, str]:
580
+ """Status hue + label. An unknown status renders plain, with its raw text."""
581
+ return {
582
+ "ok": (_GREEN, "OK"),
583
+ "warn": (_YELLOW, "WARN"),
584
+ "over": (_RED, "OVER"),
585
+ "not-built": (_YELLOW, "not built"),
586
+ "n/a": (_CYAN, "n/a"),
587
+ "no-budget": (_CYAN, "no budget"),
588
+ }.get(status, ("", status))
589
+
590
+
591
+ def render_table_lines(rows: list[SliceSize], use_color: bool) -> list[str]:
592
+ """The per-slice table: header, separator, one row per slice, and a
593
+ `-> <detail>` continuation for any slice carrying a note."""
594
+ head = (
595
+ f"{'CORE':<14} {'OS':<10} {'FLASH used/total':<24} "
596
+ f"{'RAM used/total':<24} STATUS"
597
+ )
598
+ lines = [head, "-" * len(head)]
599
+ for row in rows:
600
+ hue, label = _status_hue(row.status)
601
+ status = f"{hue}{label}{_RESET}" if use_color and hue else label
602
+ lines.append(
603
+ f"{row.core_id:<14} {row.os:<10} "
604
+ f"{region_cell(row.flash_used, row.flash_total):<24} "
605
+ f"{region_cell(row.ram_used, row.ram_total):<24} {status}"
606
+ )
607
+ detail = row.note or (row.notes[0] if row.notes else None)
608
+ if detail is not None:
609
+ lines.append(f"{'':<14} {'':<10} -> {detail}")
610
+ return lines