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.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/flash_plan.py
ADDED
|
@@ -0,0 +1,2501 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pure planning for ``tan flash`` -- the decision + argv-building half.
|
|
3
|
+
|
|
4
|
+
Port of ``crates/tan-core/src/flash/`` (``mod.rs`` / ``args.rs`` /
|
|
5
|
+
``builders.rs`` / ``registry.rs`` / ``storage.rs``) plus the manifest reader in
|
|
6
|
+
``crates/tan-core/src/system_manifest.rs``. Every string, argv, filter and
|
|
7
|
+
per-backend command shape lives here with NO IO; the subprocess / filesystem /
|
|
8
|
+
temp-file half is ``tan.commands.flash_cmd``.
|
|
9
|
+
|
|
10
|
+
The flow mirrors ``alp_flash.dispatch`` + ``_flash_entry``: walk the manifest's
|
|
11
|
+
``boot_order`` (or the sorted slice ``core_id``s when empty), map each step to
|
|
12
|
+
its slice, append the helper MCUs after, then dispatch each entry's
|
|
13
|
+
``flash_method`` to a backend plan-builder.
|
|
14
|
+
|
|
15
|
+
**Strict ``flash_args`` reading.** A whole ``flash_args`` that is not a mapping
|
|
16
|
+
(the AEN701 helper's ``flash_args: TBD`` string) reads as an empty map -- but a
|
|
17
|
+
sub-key that IS present is read STRICTLY: every behaviour-affecting bool/int
|
|
18
|
+
(``erase``, ``use_openocd``, ``reset``, ``base``, ``baud``, ...) goes through a
|
|
19
|
+
``_checked`` accessor that hard-errors on a wrong-type scalar rather than
|
|
20
|
+
silently defaulting, since a wrong flash is worse than a refused one. Do not
|
|
21
|
+
reintroduce a tolerant bool/int reader here.
|
|
22
|
+
|
|
23
|
+
**No hardware facts (I-26 / ADR-0017).** Nothing in this module names a SKU, an
|
|
24
|
+
address, a pin, an I2C address, a probe serial or a vendor branch. Every such
|
|
25
|
+
value arrives in ``flash_args``, passed through from alp-sdk ``metadata/``.
|
|
26
|
+
tan-cli#732 removed the module's last two exceptions along with the
|
|
27
|
+
``swd_probe`` backend they existed for -- ``_DEFAULT_JLINK_DEVICE`` (a part
|
|
28
|
+
number) and ``_DEFAULT_BASE`` (a flash base address), both inherited verbatim
|
|
29
|
+
from the Rust oracle and reachable only from ``plan_swd_probe`` when a
|
|
30
|
+
manifest omitted the corresponding ``flash_args`` key. Neither default has any
|
|
31
|
+
other caller. If a future backend needs one, restate the count here rather
|
|
32
|
+
than adding it silently -- a docstring that undercounts its own debt is how
|
|
33
|
+
the next one gets added without argument.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import posixpath
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from dataclasses import dataclass, replace
|
|
41
|
+
from typing import Any, Callable
|
|
42
|
+
|
|
43
|
+
from tan.core.pending import PENDING_PLACEHOLDER as PENDING_SENTINEL, is_pending_placeholder
|
|
44
|
+
|
|
45
|
+
#: The system-manifest schema major this command consumes. A different value is
|
|
46
|
+
#: REFUSED rather than read as if it were v1 -- mirrors
|
|
47
|
+
#: `system_manifest.rs::SYSTEM_MANIFEST_SCHEMA_VERSION`.
|
|
48
|
+
SYSTEM_MANIFEST_SCHEMA_VERSION = 1
|
|
49
|
+
|
|
50
|
+
_DEFAULT_JLINK_SPEED = 4000
|
|
51
|
+
_JLINK_BINARIES = ("JLinkExe", "JLink")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ManifestError(Exception):
|
|
55
|
+
"""`build/system-manifest.yaml` could not be consumed. `message` is the
|
|
56
|
+
human text; the caller pairs it with `flash.manifest-invalid`."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FlashPlanError(Exception):
|
|
60
|
+
"""A backend refused to build a plan -- the `Err(String)` arm of every
|
|
61
|
+
`plan_*` builder in `builders.rs`/`storage.rs`. The message is reported
|
|
62
|
+
verbatim as the entry's `message`."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── manifest reading ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Slice:
|
|
70
|
+
"""One per-core image from the manifest's `slices[]`. Tolerant reader: only
|
|
71
|
+
the fields `tan flash` consumes are modeled and unknown additive-v1 keys are
|
|
72
|
+
ignored, per the stability policy in `system_manifest.rs`."""
|
|
73
|
+
|
|
74
|
+
core_id: str
|
|
75
|
+
os: str
|
|
76
|
+
status: str = ""
|
|
77
|
+
output_artefact: str | None = None
|
|
78
|
+
flash_method: str | None = None
|
|
79
|
+
flash_args: Any = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class HelperMcu:
|
|
84
|
+
"""One on-module helper MCU from `helper_mcus[]`."""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
firmware_path: str | None = None
|
|
88
|
+
flash_method: str | None = None
|
|
89
|
+
flash_args: Any = None
|
|
90
|
+
update_channel: str | None = None
|
|
91
|
+
#: tan-cli#611. WHO may invoke `flash_method`, and WHEN -- the fact neither
|
|
92
|
+
#: `update_channel` (how a device is updated in the FIELD) nor
|
|
93
|
+
#: `flash_method` (by what transport) carries. See [`FLASH_POLICY_FACTORY`]
|
|
94
|
+
#: for the whole argument; `None` means the SoM preset declared nothing and
|
|
95
|
+
#: every gate below behaves exactly as it did before this field existed.
|
|
96
|
+
flash_policy: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Manifest:
|
|
101
|
+
sku: str = ""
|
|
102
|
+
slices: tuple[Slice, ...] = ()
|
|
103
|
+
helper_mcus: tuple[HelperMcu, ...] = ()
|
|
104
|
+
boot_order: tuple[Any, ...] = ()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _opt_str(raw: Any) -> str | None:
|
|
108
|
+
"""A manifest string field, or `None`. A non-string scalar reads as absent
|
|
109
|
+
rather than being coerced: `serde` would have failed the whole document, and
|
|
110
|
+
`str(4)` here would silently invent a path/method name."""
|
|
111
|
+
return raw if isinstance(raw, str) else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_system_manifest(text: str) -> Manifest:
|
|
115
|
+
"""Parse + version-guard a `system-manifest.yaml` document.
|
|
116
|
+
|
|
117
|
+
Raises `ManifestError` for: PyYAML unavailable, malformed YAML, a non-mapping
|
|
118
|
+
document, a `schema_version` that is not 1, or a `slices[]`/`helper_mcus[]`
|
|
119
|
+
entry missing a field the Rust struct declares non-`Option` (`core_id`/`os`
|
|
120
|
+
for a slice, `name`/`chip` for a helper) -- serde fails the ENTIRE parse in
|
|
121
|
+
that last case, so a partial read here would flash against a manifest the
|
|
122
|
+
oracle rejects.
|
|
123
|
+
|
|
124
|
+
tan ships no YAML dependency of its own (`python/pyproject.toml`), so PyYAML
|
|
125
|
+
is imported lazily. Its absence is FATAL here, unlike in `debug-config`
|
|
126
|
+
where the manifest is a best-effort enrichment: `flash` cannot pick a target
|
|
127
|
+
or an artefact without it, and silently flashing nothing would be the worse
|
|
128
|
+
outcome.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
import yaml # noqa: PLC0415 (optional at runtime, by design)
|
|
132
|
+
except ImportError as err:
|
|
133
|
+
raise ManifestError(
|
|
134
|
+
"reading a system-manifest needs PyYAML, which is not importable "
|
|
135
|
+
f"({err}); install it (`pip install pyyaml`) or run tan from a "
|
|
136
|
+
"bootstrapped workspace"
|
|
137
|
+
) from err
|
|
138
|
+
try:
|
|
139
|
+
doc = yaml.safe_load(text)
|
|
140
|
+
except Exception as err: # noqa: BLE001 -- the SDK's output, not ours
|
|
141
|
+
raise ManifestError(f"system-manifest is not valid YAML: {err}") from err
|
|
142
|
+
if doc is None or not isinstance(doc, dict):
|
|
143
|
+
raise ManifestError(
|
|
144
|
+
"system-manifest is not valid YAML: expected a mapping at the "
|
|
145
|
+
f"document root, got {type(doc).__name__}"
|
|
146
|
+
)
|
|
147
|
+
version = doc.get("schema_version")
|
|
148
|
+
if version != SYSTEM_MANIFEST_SCHEMA_VERSION:
|
|
149
|
+
raise ManifestError(
|
|
150
|
+
f"unsupported system-manifest schema_version {version} (this CLI "
|
|
151
|
+
f"consumes v{SYSTEM_MANIFEST_SCHEMA_VERSION}); upgrade the CLI or "
|
|
152
|
+
"the SDK so the versions match"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
hw_info = doc.get("hw_info")
|
|
156
|
+
sku = ""
|
|
157
|
+
if isinstance(hw_info, dict) and isinstance(hw_info.get("sku"), str):
|
|
158
|
+
sku = hw_info["sku"]
|
|
159
|
+
|
|
160
|
+
slices: list[Slice] = []
|
|
161
|
+
for raw in _seq(doc.get("slices")):
|
|
162
|
+
if not isinstance(raw, dict):
|
|
163
|
+
raise ManifestError("system-manifest is not valid YAML: slices[] entry is not a mapping")
|
|
164
|
+
core_id, os_name = raw.get("core_id"), raw.get("os")
|
|
165
|
+
if not isinstance(core_id, str) or not isinstance(os_name, str):
|
|
166
|
+
raise ManifestError(
|
|
167
|
+
"system-manifest is not valid YAML: every slices[] entry needs a "
|
|
168
|
+
"string `core_id` and `os`"
|
|
169
|
+
)
|
|
170
|
+
slices.append(
|
|
171
|
+
Slice(
|
|
172
|
+
core_id=core_id,
|
|
173
|
+
os=os_name,
|
|
174
|
+
status=raw["status"] if isinstance(raw.get("status"), str) else "",
|
|
175
|
+
output_artefact=_opt_str(raw.get("output_artefact")),
|
|
176
|
+
flash_method=_opt_str(raw.get("flash_method")),
|
|
177
|
+
flash_args=raw.get("flash_args"),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
helpers: list[HelperMcu] = []
|
|
182
|
+
for raw in _seq(doc.get("helper_mcus")):
|
|
183
|
+
if not isinstance(raw, dict):
|
|
184
|
+
raise ManifestError(
|
|
185
|
+
"system-manifest is not valid YAML: helper_mcus[] entry is not a mapping"
|
|
186
|
+
)
|
|
187
|
+
name, chip = raw.get("name"), raw.get("chip")
|
|
188
|
+
if not isinstance(name, str) or not isinstance(chip, str):
|
|
189
|
+
raise ManifestError(
|
|
190
|
+
"system-manifest is not valid YAML: every helper_mcus[] entry needs "
|
|
191
|
+
"a string `name` and `chip`"
|
|
192
|
+
)
|
|
193
|
+
helpers.append(
|
|
194
|
+
HelperMcu(
|
|
195
|
+
name=name,
|
|
196
|
+
firmware_path=_opt_str(raw.get("firmware_path")),
|
|
197
|
+
flash_method=_opt_str(raw.get("flash_method")),
|
|
198
|
+
flash_args=raw.get("flash_args"),
|
|
199
|
+
update_channel=_opt_str(raw.get("update_channel")),
|
|
200
|
+
flash_policy=_opt_str(raw.get("flash_policy")),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return Manifest(
|
|
205
|
+
sku=sku,
|
|
206
|
+
slices=tuple(slices),
|
|
207
|
+
helper_mcus=tuple(helpers),
|
|
208
|
+
boot_order=tuple(_seq(doc.get("boot_order"))),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _seq(raw: Any) -> list[Any]:
|
|
213
|
+
"""A manifest list field. `#[serde(default)]` means a missing key is an
|
|
214
|
+
empty list; a key present with a NON-list value is a shape error serde would
|
|
215
|
+
reject, so it is not silently treated as empty here either -- `[]` is
|
|
216
|
+
returned only for genuinely absent/null."""
|
|
217
|
+
if raw is None:
|
|
218
|
+
return []
|
|
219
|
+
if not isinstance(raw, list):
|
|
220
|
+
raise ManifestError(
|
|
221
|
+
f"system-manifest is not valid YAML: expected a sequence, got {type(raw).__name__}"
|
|
222
|
+
)
|
|
223
|
+
return raw
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── target selection ────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
SLICE = "slice"
|
|
229
|
+
HELPER = "helper"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class FlashTarget:
|
|
234
|
+
"""One manifest entry selected for flashing, in dispatch order."""
|
|
235
|
+
|
|
236
|
+
kind: str
|
|
237
|
+
id: str
|
|
238
|
+
flash_method: str | None
|
|
239
|
+
flash_args: Any
|
|
240
|
+
output_artefact: str | None = None
|
|
241
|
+
firmware_path: str | None = None
|
|
242
|
+
update_channel: str | None = None
|
|
243
|
+
#: tan-cli#611 -- carried from `HelperMcu.flash_policy`; always `None` for a
|
|
244
|
+
#: slice, which is an application core and a genuine customer flash target.
|
|
245
|
+
flash_policy: str | None = None
|
|
246
|
+
#: tan-cli#1509 -- further `alif_mram_jlink` slices of the same board folded into
|
|
247
|
+
#: this one write, so ONE ATOC names them all (see `merge_flow_d_targets`).
|
|
248
|
+
co_slices: tuple["FlashTarget", ...] = ()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _signable_flow_d(t: FlashTarget) -> bool:
|
|
252
|
+
"""A slice `tan flash` would sign for itself via Flow D: no `atoc`/`atoc_map`/
|
|
253
|
+
`atoc_address` of the customer's own, and no unresolved `TBD`."""
|
|
254
|
+
if t.kind != SLICE or select_flash_method(t) != FLOW_D_METHOD:
|
|
255
|
+
return False
|
|
256
|
+
fa = t.flash_args
|
|
257
|
+
if flash_args_has_tbd(fa) or not t.output_artefact:
|
|
258
|
+
return False
|
|
259
|
+
try:
|
|
260
|
+
return all(fa_str_checked(fa, k, True) is None for k in ("atoc", "atoc_map", "atoc_address"))
|
|
261
|
+
except FlashPlanError:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
#: The keys that say WHICH board a Flow D slice targets; slices that differ on any are
|
|
266
|
+
#: separate writes (separate ATOCs), never one.
|
|
267
|
+
_BOARD_KEYS = ("jlink_flash_device", "jlink_serial", "jlink_device", "expect_dpidr")
|
|
268
|
+
|
|
269
|
+
#: Keys read from the LEAD slice only (the combined entry has one preflight, one confirm
|
|
270
|
+
#: gate, one device config, one ATOC acknowledgement). A co-slice that disagrees is
|
|
271
|
+
#: refused rather than silently ignored.
|
|
272
|
+
_LEAD_ONLY_KEYS = (
|
|
273
|
+
"confirm", "resident_atoc_entries", "setools_device_config", "atoc_unqueryable",
|
|
274
|
+
"setools_dir",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def check_co_slice_args(lead_id: str, lead_args: Any, co_id: str, co_args: Any) -> None:
|
|
279
|
+
"""tan-cli#1509: refuse a co-slice whose `_LEAD_ONLY_KEYS` differ from the lead's."""
|
|
280
|
+
|
|
281
|
+
def _get(fa: Any, key: str) -> Any:
|
|
282
|
+
value = fa.get(key) if hasattr(fa, "get") else None
|
|
283
|
+
return bool(value) if key in ("confirm", "atoc_unqueryable") else value
|
|
284
|
+
|
|
285
|
+
for key in _LEAD_ONLY_KEYS:
|
|
286
|
+
if _get(lead_args, key) != _get(co_args, key):
|
|
287
|
+
raise FlashPlanError(
|
|
288
|
+
f"{FLOW_D_METHOD}: slices '{lead_id}' and '{co_id}' are written in ONE "
|
|
289
|
+
f"ATOC, but flash_args.{key} differs between them "
|
|
290
|
+
f"({_get(lead_args, key)!r} vs {_get(co_args, key)!r}); only '{lead_id}''s "
|
|
291
|
+
f"would be honoured. Set flash_args.{key} identically on both."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def merge_flow_d_targets(targets: Sequence[FlashTarget]) -> tuple[FlashTarget, ...]:
|
|
296
|
+
"""tan-cli#1509: fold every signable `alif_mram_jlink` slice that shares one
|
|
297
|
+
`jlink_flash_device` into the FIRST one's `co_slices`, so a single ATOC names
|
|
298
|
+
them all. A second ATOC at the same package address would delist the first
|
|
299
|
+
core (the loader replaces the whole table). Anything else, and a lone slice,
|
|
300
|
+
passes through untouched."""
|
|
301
|
+
groups: dict[tuple[str | None, ...], list[FlashTarget]] = {}
|
|
302
|
+
for t in targets:
|
|
303
|
+
if _signable_flow_d(t):
|
|
304
|
+
try:
|
|
305
|
+
# One board = one device profile, one probe, one expected SW-DP ID.
|
|
306
|
+
key = tuple(
|
|
307
|
+
fa_str_checked(t.flash_args, k, k == "expect_dpidr")
|
|
308
|
+
for k in _BOARD_KEYS
|
|
309
|
+
)
|
|
310
|
+
except FlashPlanError:
|
|
311
|
+
continue
|
|
312
|
+
groups.setdefault(key, []).append(t)
|
|
313
|
+
folded = {id(c) for g in groups.values() if len(g) > 1 for c in g[1:]}
|
|
314
|
+
heads = {id(g[0]): g[1:] for g in groups.values() if len(g) > 1}
|
|
315
|
+
return tuple(
|
|
316
|
+
replace(t, co_slices=tuple(heads[id(t)])) if id(t) in heads else t
|
|
317
|
+
for t in targets
|
|
318
|
+
if id(t) not in folded
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def left_out_flow_d(manifest: Manifest, targets: Sequence[FlashTarget]) -> tuple[str, ...]:
|
|
323
|
+
"""Ids of the manifest's `alif_mram_jlink` slices that THIS run does not flash
|
|
324
|
+
(a `--core` filter) -- the ones the lone ATOC will delist. Only reported when
|
|
325
|
+
the run has exactly one Flow D write (a combined one names them already)."""
|
|
326
|
+
ours = [t for t in targets if t.kind == SLICE and select_flash_method(t) == FLOW_D_METHOD]
|
|
327
|
+
if len(ours) != 1 or ours[0].co_slices:
|
|
328
|
+
return ()
|
|
329
|
+
ran = {ours[0].id}
|
|
330
|
+
return tuple(
|
|
331
|
+
s.core_id
|
|
332
|
+
for s in manifest.slices
|
|
333
|
+
if s.core_id and s.core_id not in ran
|
|
334
|
+
and select_flash_method(
|
|
335
|
+
FlashTarget(SLICE, s.core_id, s.flash_method, s.flash_args)
|
|
336
|
+
) == FLOW_D_METHOD
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass(frozen=True)
|
|
341
|
+
class TargetPlan:
|
|
342
|
+
targets: tuple[FlashTarget, ...]
|
|
343
|
+
warnings: tuple[str, ...]
|
|
344
|
+
refused: tuple[str, ...]
|
|
345
|
+
#: The subset of "status not ok" refusals that are explained away rather
|
|
346
|
+
#: than a real build problem: a slice `status: "skipped"` -- i.e. `tan
|
|
347
|
+
#: build` itself declined to build this slice under `executionPolicy.
|
|
348
|
+
#: missingTool`/`.nullCommand` (a host with no `bitbake`, say) -- or a
|
|
349
|
+
#: slice declared `os: "off"` in `board.yaml` (tan-cli#699), which `tan
|
|
350
|
+
#: build`'s `iter_buildable_slices` never touches at all, so its manifest
|
|
351
|
+
#: entry never advances off whatever status the plan-time emit left it at
|
|
352
|
+
#: (`pending`, today). Both were decisions already made before `tan
|
|
353
|
+
#: flash` ever ran; refusing to flash a never-built
|
|
354
|
+
#: artefact is still correct (there is nothing to flash), but it must not
|
|
355
|
+
#: ALSO read as a flash failure for a slice the manifest already
|
|
356
|
+
#: explained away. `refused` (a `"failed"`/`"pending"`/other status on a
|
|
357
|
+
#: slice that is NOT `os: "off"`) is the opposite: `tan build` tried and
|
|
358
|
+
#: the slice is broken or was never reconciled, which must keep failing
|
|
359
|
+
#: `tan flash`. Callers surface this bucket as a WARNING and must not
|
|
360
|
+
#: fold it into a failure count -- see `refused` for the error-severity,
|
|
361
|
+
#: exit-code-affecting bucket.
|
|
362
|
+
#:
|
|
363
|
+
#: **DIVERGED from the Rust oracle, before the oracle was retired.**
|
|
364
|
+
#: `crates/tan-core/src/flash/mod.rs`'s `plan_flash_targets` had no
|
|
365
|
+
#: `refused_skipped` bucket at all -- a `status: skipped` slice/helper
|
|
366
|
+
#: landed in the ONE `refused` list alongside `failed`/`pending`/anything
|
|
367
|
+
#: else non-`ok`, and the CLI seeded `failed` from `refused.len()` before
|
|
368
|
+
#: the dispatch loop even ran, so the oracle FAILED the run on a
|
|
369
|
+
#: `status: skipped` slice exactly like any other bad status -- and had no
|
|
370
|
+
#: `os: "off"` carve-out either, so it also failed on that shape. This
|
|
371
|
+
#: split (and the caller's
|
|
372
|
+
#: warning-only, exit-0 treatment when something else DID flash) was a
|
|
373
|
+
#: deliberate product improvement on top of the port, not a porting bug --
|
|
374
|
+
#: but the caller (`tan.commands.flash_cmd.flash`) MUST still fail the run
|
|
375
|
+
#: when every match was a `refused_skipped` entry and nothing flashed
|
|
376
|
+
#: (`flash.nothing-flashed`), or this bucket reintroduces the exact
|
|
377
|
+
#: silent-success class `refused` exists to prevent, just inverted.
|
|
378
|
+
#: `crates/` and `tests/parity/test_flash_oracle_parity.py` (which
|
|
379
|
+
#: deliberately carried no `status: skipped` nor `os: "off"` case, for the
|
|
380
|
+
#: reasons above) were both deleted in 2883cdf -- there is no longer a
|
|
381
|
+
#: second implementation to diff against.
|
|
382
|
+
refused_skipped: tuple[str, ...] = ()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def plan_flash_targets(
|
|
387
|
+
manifest: Manifest, core: str | None = None, helper: str | None = None
|
|
388
|
+
) -> TargetPlan:
|
|
389
|
+
"""Build the ordered flash target list + any `boot_order` warnings/refusals.
|
|
390
|
+
|
|
391
|
+
- Empty `boot_order`: one step per slice `core_id`, sorted ascending.
|
|
392
|
+
- Non-empty `boot_order`: walked in order; a step naming a `core_id` not in
|
|
393
|
+
`slices` is dropped and surfaced as a warning.
|
|
394
|
+
- A slice whose `status` is not `ok` is REFUSED, not flashed and not silently
|
|
395
|
+
dropped: `overlay_run_results` PRESERVES the plan-time `output_artefact`
|
|
396
|
+
when a later run has no artefact for that core, so a run-1 success followed
|
|
397
|
+
by a run-2 failure/skip leaves run-1's elf on disk under a manifest
|
|
398
|
+
reporting a broken slice. Flashing that stale elf and silently dropping the
|
|
399
|
+
slice are the same silent-failure class. A `status: skipped` refusal, or
|
|
400
|
+
one for a slice declared `os: "off"` in `board.yaml` (tan-cli#699), is
|
|
401
|
+
split into `refused_skipped` rather than `refused`: `tan build` already
|
|
402
|
+
decided (via `executionPolicy`, or by design for an `off` core) that
|
|
403
|
+
this slice was never going to build -- e.g. no `bitbake` on an MCU-only
|
|
404
|
+
checkout, or a core that is deliberately off -- and that is not a flash
|
|
405
|
+
failure, it is `tan flash` agreeing with a decision already made.
|
|
406
|
+
`os: "off"` is checked on the slice's `os` field, not its
|
|
407
|
+
`status`: an `off` core's manifest entry never advances off whatever
|
|
408
|
+
status the plan-time emit left it at, so it must be recognised
|
|
409
|
+
regardless of that value. A genuinely broken or unbuilt slice
|
|
410
|
+
(`status: failed`, `status: pending` on an `os` that is NOT `"off"`, or
|
|
411
|
+
any other non-`ok`/non-`skipped` value) stays in `refused`.
|
|
412
|
+
- Helpers always come AFTER all slices.
|
|
413
|
+
- `core` flashes only that slice and skips every helper; `helper` skips every
|
|
414
|
+
slice and flashes only that helper.
|
|
415
|
+
|
|
416
|
+
Callers MUST surface both `refused` and `refused_skipped`: those entries
|
|
417
|
+
never enter `targets`, so a caller that only reports `targets`/`warnings`
|
|
418
|
+
would show a clean run while a stale/never-built artefact stayed unflashed.
|
|
419
|
+
Only `refused` (not `refused_skipped`) may fail the overall run -- see
|
|
420
|
+
`TargetPlan.refused_skipped`.
|
|
421
|
+
"""
|
|
422
|
+
targets: list[FlashTarget] = []
|
|
423
|
+
warnings: list[str] = []
|
|
424
|
+
refused: list[str] = []
|
|
425
|
+
refused_skipped: list[str] = []
|
|
426
|
+
|
|
427
|
+
def find_slice(cid: str) -> Slice | None:
|
|
428
|
+
# Non-empty core_id only, matching the Python dict-comprehension guard
|
|
429
|
+
# `alp_flash` used and the `!s.core_id.is_empty()` filter in Rust.
|
|
430
|
+
for s in manifest.slices:
|
|
431
|
+
if s.core_id and s.core_id == cid:
|
|
432
|
+
return s
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
if not manifest.boot_order:
|
|
436
|
+
steps = sorted(s.core_id for s in manifest.slices if s.core_id)
|
|
437
|
+
else:
|
|
438
|
+
steps = []
|
|
439
|
+
for step in manifest.boot_order:
|
|
440
|
+
if not isinstance(step, dict):
|
|
441
|
+
continue
|
|
442
|
+
named = step.get("core")
|
|
443
|
+
if isinstance(named, str) and named:
|
|
444
|
+
steps.append(named)
|
|
445
|
+
|
|
446
|
+
# A slice present in `slices` but never named by a `boot_order` step used to
|
|
447
|
+
# be dropped with NO warning at all -- a heterogeneous system silently
|
|
448
|
+
# flashed a strict subset of its cores and reported success. Only warn on the
|
|
449
|
+
# unfiltered default run: `--core` deliberately narrows the slice set and
|
|
450
|
+
# `--helper` deliberately suppresses every slice.
|
|
451
|
+
if manifest.boot_order and helper is None and core is None:
|
|
452
|
+
for s in manifest.slices:
|
|
453
|
+
if s.core_id and s.core_id not in steps:
|
|
454
|
+
warnings.append(f"flash: slice '{s.core_id}' has no boot_order entry; not flashed")
|
|
455
|
+
|
|
456
|
+
if helper is None:
|
|
457
|
+
for cid in steps:
|
|
458
|
+
if core is not None and cid != core:
|
|
459
|
+
continue
|
|
460
|
+
found = find_slice(cid)
|
|
461
|
+
if found is None:
|
|
462
|
+
warnings.append(
|
|
463
|
+
f"flash: boot_order references core '{cid}' not in slices; skipping"
|
|
464
|
+
)
|
|
465
|
+
continue
|
|
466
|
+
if not slice_should_flash(found.status):
|
|
467
|
+
# `found.os == "off"` is checked BEFORE `found.status ==
|
|
468
|
+
# "skipped"` below on purpose: an off core never receives a
|
|
469
|
+
# `SliceRunResult` (see the comment inside this branch), so
|
|
470
|
+
# its `status` can only ever be the plan-time `pending`
|
|
471
|
+
# default -- never `failed` -- making this ordering safe
|
|
472
|
+
# today. If that ever changed and an off core's manifest
|
|
473
|
+
# entry could carry `status: failed`, checking `os` first
|
|
474
|
+
# would silently downgrade a real failure to a warning; the
|
|
475
|
+
# `os` check would need to move after the `status` checks
|
|
476
|
+
# (or gain its own `status == "failed"` guard) at that point.
|
|
477
|
+
if found.os == "off":
|
|
478
|
+
# tan-cli#699: `board.yaml` declares this core `os: "off"`
|
|
479
|
+
# -- `tan build`'s `iter_buildable_slices` (`tan/planner/
|
|
480
|
+
# orchestrator.py:36`) correctly skips it, so it never
|
|
481
|
+
# receives a `SliceRunResult` and the manifest's plan-time
|
|
482
|
+
# `status: pending` default is never overlaid to anything
|
|
483
|
+
# else. That is NOT a policy skip (`executionPolicy` never
|
|
484
|
+
# ran for this core) and NOT an incomplete/failed build --
|
|
485
|
+
# it is the manifest correctly recording a core that is
|
|
486
|
+
# declared off, so `tan build` never builds it. (The
|
|
487
|
+
# manifest can still name an `app:`/`board:`/`toolchain:`
|
|
488
|
+
# for an off core -- `board.yaml` may declare those
|
|
489
|
+
# fields per-core independent of `os:` -- so this must
|
|
490
|
+
# not be read as "no app, no board".)
|
|
491
|
+
# Checked on `found.os`, not `found.status`: unlike the
|
|
492
|
+
# `skipped` branch below, this must catch the slice
|
|
493
|
+
# regardless of which never-advanced status the emitter
|
|
494
|
+
# happens to have left behind.
|
|
495
|
+
refused_skipped.append(
|
|
496
|
+
f"flash: slice '{found.core_id}' is declared `os: \"off\"` "
|
|
497
|
+
"in board.yaml -- tan build never builds a core "
|
|
498
|
+
"declared off, by design. There is nothing to flash; "
|
|
499
|
+
"this is expected, not an error."
|
|
500
|
+
)
|
|
501
|
+
elif found.status == "skipped":
|
|
502
|
+
# A policy decision `tan build` already made and reported
|
|
503
|
+
# (`executionPolicy.missingTool`/`.nullCommand`), not a
|
|
504
|
+
# broken build -- "stale, rebuild it" is wrong on both
|
|
505
|
+
# counts: nothing was ever built, so nothing is stale, and
|
|
506
|
+
# rebuilding ON THIS HOST hits the same policy skip again.
|
|
507
|
+
refused_skipped.append(
|
|
508
|
+
f"flash: slice '{found.core_id}' build status is 'skipped' -- "
|
|
509
|
+
"tan build already declined to build it under executionPolicy "
|
|
510
|
+
"(a missing tool or a null command on this host); there is "
|
|
511
|
+
"nothing to flash. Rebuilding on this same host will skip it "
|
|
512
|
+
"again -- it needs a host where that tool resolves."
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
refused.append(
|
|
516
|
+
f"flash: slice '{found.core_id}' build status is "
|
|
517
|
+
f"'{found.status}' (not 'ok'); refusing to flash its artefact "
|
|
518
|
+
"-- it may be stale from a previous successful build. "
|
|
519
|
+
"Rebuild it first."
|
|
520
|
+
)
|
|
521
|
+
continue
|
|
522
|
+
targets.append(
|
|
523
|
+
FlashTarget(
|
|
524
|
+
kind=SLICE,
|
|
525
|
+
id=found.core_id,
|
|
526
|
+
flash_method=found.flash_method,
|
|
527
|
+
flash_args=found.flash_args,
|
|
528
|
+
output_artefact=found.output_artefact,
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if core is None:
|
|
533
|
+
for h in manifest.helper_mcus:
|
|
534
|
+
if not h.name:
|
|
535
|
+
continue
|
|
536
|
+
if helper is not None and h.name != helper:
|
|
537
|
+
continue
|
|
538
|
+
targets.append(
|
|
539
|
+
FlashTarget(
|
|
540
|
+
kind=HELPER,
|
|
541
|
+
id=h.name,
|
|
542
|
+
flash_method=h.flash_method,
|
|
543
|
+
flash_args=h.flash_args,
|
|
544
|
+
firmware_path=h.firmware_path,
|
|
545
|
+
update_channel=h.update_channel,
|
|
546
|
+
flash_policy=h.flash_policy,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
return TargetPlan(
|
|
552
|
+
tuple(targets), tuple(warnings), tuple(refused), tuple(refused_skipped)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ── who may flash a helper, and when (tan-cli#611) ──────────────────────────
|
|
557
|
+
|
|
558
|
+
#: A genuine customer flash target. Explicit rather than merely-absent so a SoM
|
|
559
|
+
#: preset can SAY so; absent is treated the same way (see [`helper_flash_gate`]),
|
|
560
|
+
#: because every preset written before this field existed means exactly that.
|
|
561
|
+
FLASH_POLICY_CUSTOMER = "customer"
|
|
562
|
+
|
|
563
|
+
#: Programmed by Alp Lab in production. Never a customer flash target, and there
|
|
564
|
+
#: is no recovery path declared for it.
|
|
565
|
+
#:
|
|
566
|
+
#: This is the fact tan-cli#611 found nothing carried. `update_channel` answers
|
|
567
|
+
#: how a device is updated in the FIELD; `flash_method` answers by what
|
|
568
|
+
#: TRANSPORT it is written. Neither answers WHO may write it, so `flash` was
|
|
569
|
+
#: inferring the first from the second: it honoured `update_channel` only for a
|
|
570
|
+
#: helper that declared no `flash_method` at all, which mirrored an alp-sdk
|
|
571
|
+
#: schema rule making the two mutually exclusive. That XOR is the modelling
|
|
572
|
+
#: defect -- a helper can legitimately have BOTH an OTA channel for the normal
|
|
573
|
+
#: case and a flash method for the abnormal one -- and this field is what
|
|
574
|
+
#: replaces the inference.
|
|
575
|
+
FLASH_POLICY_FACTORY = "factory"
|
|
576
|
+
|
|
577
|
+
#: Programmed by Alp Lab in production, and customer-flashable ONLY to recover a
|
|
578
|
+
#: bricked device, with Alp Lab-supplied binaries.
|
|
579
|
+
#:
|
|
580
|
+
#: An unconditional skip would be WRONG for this value: it would remove the one
|
|
581
|
+
#: path that matters at the one moment it matters. So the gate declines it on an
|
|
582
|
+
#: ordinary run and keeps it reachable through a DELIBERATE action -- see
|
|
583
|
+
#: [`helper_flash_gate`]'s `recovery_armed`/`helper_filter` pair, which together
|
|
584
|
+
#: mean a recovery write cannot be reached by a bare `tan flash`.
|
|
585
|
+
FLASH_POLICY_RECOVERY_ONLY = "recovery_only"
|
|
586
|
+
|
|
587
|
+
#: Every policy this CLI understands, in the order the diagnostics list them.
|
|
588
|
+
FLASH_POLICIES = (
|
|
589
|
+
FLASH_POLICY_CUSTOMER,
|
|
590
|
+
FLASH_POLICY_FACTORY,
|
|
591
|
+
FLASH_POLICY_RECOVERY_ONLY,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
#: How a policy-declined helper is named in the human transcript.
|
|
595
|
+
_PRODUCTION_PROGRAMMED = "is programmed by Alp Lab in production"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _channel_clause(channel: str) -> str:
|
|
599
|
+
"""The `update_channel` sentence, or nothing.
|
|
600
|
+
|
|
601
|
+
"Mention the update channel only where one exists" is the point: the old
|
|
602
|
+
message asserted an OTA mechanism as the REASON a helper was skipped, which
|
|
603
|
+
is only ever true where a channel is declared -- and is not the reason even
|
|
604
|
+
then. The reason is who programs it; the channel is a separate fact about
|
|
605
|
+
its life afterwards."""
|
|
606
|
+
return f" Field updates arrive over update_channel: {channel}." if channel else ""
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _under_declared_skip_message(kind: str, entry_id: str, method: str, channel: str) -> str:
|
|
610
|
+
"""A helper carrying BOTH halves and no `flash_policy`.
|
|
611
|
+
|
|
612
|
+
Legal only once the upstream XOR is relaxed, and under-declared when it
|
|
613
|
+
happens: the preset author gave the entry both an update channel and a flash
|
|
614
|
+
method, and said nothing about who may write it. Declining is fail-safe AND
|
|
615
|
+
visible -- silently flashing it would be the tan-cli#611 defect one field
|
|
616
|
+
over."""
|
|
617
|
+
return (
|
|
618
|
+
f"flash: {kind} '{entry_id}' declares both flash_method '{method}' and "
|
|
619
|
+
f"update_channel '{channel}' but no flash_policy, so nothing says who may "
|
|
620
|
+
f"flash it; skipping. Add flash_policy ({' / '.join(FLASH_POLICIES)}) to "
|
|
621
|
+
"the helper_firmware entry in the SoM preset."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _recovery_only_skip_message(
|
|
626
|
+
kind: str, entry_id: str, channel: str, *, recovery_armed: bool
|
|
627
|
+
) -> str:
|
|
628
|
+
"""The decline for a `recovery_only` helper this run may not write.
|
|
629
|
+
|
|
630
|
+
The tail names the EXACT re-run rather than the flag alone, and distinguishes
|
|
631
|
+
the two ways to be here: `--recover` not given at all, versus given on a run
|
|
632
|
+
that was never narrowed to this entry. An operator whose device is bricked
|
|
633
|
+
has no second channel to work anything out from."""
|
|
634
|
+
if recovery_armed:
|
|
635
|
+
tail = (
|
|
636
|
+
" --recover was given but this run is not narrowed to it; a recovery "
|
|
637
|
+
"flash must name its single target. Re-run with "
|
|
638
|
+
f"`--helper {entry_id} --recover`."
|
|
639
|
+
)
|
|
640
|
+
else:
|
|
641
|
+
tail = (
|
|
642
|
+
" To recover a bricked device deliberately, re-run with "
|
|
643
|
+
f"`--helper {entry_id} --recover`."
|
|
644
|
+
)
|
|
645
|
+
return (
|
|
646
|
+
f"flash: {kind} '{entry_id}' {_PRODUCTION_PROGRAMMED} and is "
|
|
647
|
+
"customer-flashable only to recover a bricked device, with Alp "
|
|
648
|
+
f"Lab-supplied binaries; skipping.{_channel_clause(channel)}{tail}"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def helper_flash_gate(
|
|
653
|
+
target: FlashTarget,
|
|
654
|
+
*,
|
|
655
|
+
recovery_armed: bool = False,
|
|
656
|
+
helper_filter: str | None = None,
|
|
657
|
+
) -> str | None:
|
|
658
|
+
"""The `flash_policy` decision for one target: a skip message, or `None` to
|
|
659
|
+
let dispatch continue. Pure -- no IO, no `flash_args`, no PATH.
|
|
660
|
+
|
|
661
|
+
Called ABOVE `_flash_entry`'s `if not raw_method:` guard, which is the whole
|
|
662
|
+
of tan-cli#611's tan-side half: the old code consulted the only non-customer
|
|
663
|
+
declaration there was (`update_channel`) exclusively INSIDE that guard, so a
|
|
664
|
+
helper declaring both it and a `flash_method` had the declaration silently
|
|
665
|
+
dropped and was flashed like any other target.
|
|
666
|
+
|
|
667
|
+
`recovery_armed` (`--recover`) and `helper_filter` (`--helper NAME`) are BOTH
|
|
668
|
+
required to reach a `recovery_only` write: the flag alone would let a
|
|
669
|
+
whole-manifest run sweep a recovery helper in. An UNRECOGNISED policy skips
|
|
670
|
+
-- a restriction this build does not understand must not become permission."""
|
|
671
|
+
policy = (target.flash_policy or "").strip()
|
|
672
|
+
channel = (target.update_channel or "").strip()
|
|
673
|
+
method = (target.flash_method or "").strip()
|
|
674
|
+
kind, entry_id = target.kind, target.id
|
|
675
|
+
|
|
676
|
+
if not policy:
|
|
677
|
+
if method and channel:
|
|
678
|
+
return _under_declared_skip_message(kind, entry_id, method, channel)
|
|
679
|
+
# Every shape that predates this field, unchanged -- including the
|
|
680
|
+
# `update_channel`-without-`flash_method` skip, still worded by
|
|
681
|
+
# `_flash_entry` exactly as before.
|
|
682
|
+
return None
|
|
683
|
+
if policy == FLASH_POLICY_CUSTOMER:
|
|
684
|
+
return None
|
|
685
|
+
if policy == FLASH_POLICY_FACTORY:
|
|
686
|
+
return (
|
|
687
|
+
f"flash: {kind} '{entry_id}' {_PRODUCTION_PROGRAMMED}, not a customer "
|
|
688
|
+
f"flash target; skipping.{_channel_clause(channel)}"
|
|
689
|
+
)
|
|
690
|
+
if policy == FLASH_POLICY_RECOVERY_ONLY:
|
|
691
|
+
if recovery_armed and helper_filter == entry_id:
|
|
692
|
+
return None
|
|
693
|
+
return _recovery_only_skip_message(
|
|
694
|
+
kind, entry_id, channel, recovery_armed=recovery_armed
|
|
695
|
+
)
|
|
696
|
+
return (
|
|
697
|
+
f"flash: {kind} '{entry_id}' declares flash_policy '{policy}', which this "
|
|
698
|
+
f"tan does not recognise (known: {', '.join(FLASH_POLICIES)}); refusing to "
|
|
699
|
+
"treat an unrecognised restriction as permission; skipping. Upgrade tan, "
|
|
700
|
+
"or correct the SoM preset."
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def slice_should_flash(status: str) -> bool:
|
|
705
|
+
"""A slice is flashed iff it built successfully. `image_bundle.rs::
|
|
706
|
+
slice_should_bundle` -- the same one-line predicate, shared on purpose so
|
|
707
|
+
`flash` and `image` can never disagree about which artefacts are real."""
|
|
708
|
+
return status == "ok"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ── path helpers ────────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def is_rust_absolute(path: str) -> bool:
|
|
715
|
+
"""`Path::is_absolute()` semantics, NOT `os.path.isabs`.
|
|
716
|
+
|
|
717
|
+
On Windows Rust requires BOTH a prefix (drive/UNC) and a root, so a
|
|
718
|
+
rooted-but-driveless `/dev/sdb` or `\\x` is RELATIVE and `base.join(p)`
|
|
719
|
+
discards part of `base`. `os.path.isabs("/dev/sdb")` answered True on
|
|
720
|
+
Windows until Python 3.13 and False from 3.13 on -- so reaching for it would
|
|
721
|
+
make artefact resolution differ from the oracle AND differ between two
|
|
722
|
+
supported interpreters on the same host.
|
|
723
|
+
"""
|
|
724
|
+
if os.name == "nt":
|
|
725
|
+
drive, rest = os.path.splitdrive(path)
|
|
726
|
+
return bool(drive) and rest[:1] in ("\\", "/")
|
|
727
|
+
return path.startswith("/")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def resolve_artefact_path(
|
|
731
|
+
artefact: str,
|
|
732
|
+
build_root: str,
|
|
733
|
+
sdk_root: str | None,
|
|
734
|
+
is_file: Callable[[str], bool],
|
|
735
|
+
) -> str:
|
|
736
|
+
"""Resolve a manifest artefact string to a path. Absolute strings pass
|
|
737
|
+
through; a relative string tries `build_root/artefact`, then
|
|
738
|
+
`sdk_root/artefact`, then west's NESTED `build_root/build/artefact`, and
|
|
739
|
+
falls back to the `build_root` candidate. `is_file` is injected to keep this
|
|
740
|
+
pure.
|
|
741
|
+
|
|
742
|
+
The first two candidates and the fallback are `flash/mod.rs::
|
|
743
|
+
resolve_artefact_path` verbatim. The third is the consumer half of **I-18**:
|
|
744
|
+
the planner emits `west build` with NO `-d`, so west's tree lands at
|
|
745
|
+
`<buildDir>/build/`. Before tan-cli#560 (alp-sdk d00dbdc1) the plan's
|
|
746
|
+
`artifacts` block still reported the un-nested `<buildDir>/zephyr/
|
|
747
|
+
zephyr.elf`; as of that pin the planner's own in-process output already
|
|
748
|
+
carries the nested path, but an older cached plan, the stale-SDK alp-sdk
|
|
749
|
+
subprocess fallback, or a hand-authored manifest can still arrive
|
|
750
|
+
un-nested -- this candidate still catches those. Rust reconciles the
|
|
751
|
+
nested case at manifest-WRITE time (`build/execute/manifest.rs::
|
|
752
|
+
resolve_zephyr_artefact`, tan's only writer of `output_artefact`, which
|
|
753
|
+
stores the nested ABSOLUTE path); this port's `build` does not write the
|
|
754
|
+
manifest yet, so an un-nested artefact string would resolve to nothing
|
|
755
|
+
without this candidate. Probed LAST, only when the oracle's own
|
|
756
|
+
candidates all miss a real file, so it never changes a resolution the
|
|
757
|
+
oracle already makes -- an absolute artefact never reaches it at all.
|
|
758
|
+
"""
|
|
759
|
+
if is_rust_absolute(artefact):
|
|
760
|
+
return artefact
|
|
761
|
+
cand_build = os.path.join(build_root, artefact)
|
|
762
|
+
if sdk_root is None:
|
|
763
|
+
return cand_build
|
|
764
|
+
if is_file(cand_build):
|
|
765
|
+
return cand_build
|
|
766
|
+
cand_sdk = os.path.join(sdk_root, artefact)
|
|
767
|
+
if is_file(cand_sdk):
|
|
768
|
+
return cand_sdk
|
|
769
|
+
cand_nested = os.path.join(build_root, "build", artefact)
|
|
770
|
+
if is_file(cand_nested):
|
|
771
|
+
return cand_nested
|
|
772
|
+
return cand_build
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ── flash_args accessors ────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _fa_get(value: Any, key: str) -> Any:
|
|
779
|
+
"""A `flash_args` sub-key, or `None` when `flash_args` is not a mapping.
|
|
780
|
+
Mirrors `args.rs::fa_get`'s `v.as_mapping()?`: the AEN701 helper's
|
|
781
|
+
`flash_args: TBD` string reads as an empty map, not an error."""
|
|
782
|
+
if not isinstance(value, dict):
|
|
783
|
+
return None
|
|
784
|
+
return value.get(key)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _fa_has_key(value: Any, key: str) -> bool:
|
|
788
|
+
"""Whether `flash_args` is a mapping that carries `key` AT ALL --
|
|
789
|
+
independent of what it resolves to. `_fa_get`/`fa_str_checked` collapse a
|
|
790
|
+
present-but-null value and a genuinely-absent key to the same `None`,
|
|
791
|
+
which is right for every OPTIONAL field but wrong for one that must
|
|
792
|
+
distinguish "not selected" from "selected with a malformed value" (see
|
|
793
|
+
`slot0_load_address` in `plan_alif_mram_jlink`, and `expect_dpidr` /
|
|
794
|
+
`jlink_device` in `flow_d_preflight_script`)."""
|
|
795
|
+
return isinstance(value, dict) and key in value
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _yaml_debug(value: Any) -> str:
|
|
799
|
+
"""`serde_yaml::Value`'s `{:?}` rendering, so the strict accessors' refusal
|
|
800
|
+
messages match the oracle byte for byte (`String("true")`, `Number(1)`,
|
|
801
|
+
`Bool(true)`, `Sequence [Number(1), Number(2)]`). Verified against the
|
|
802
|
+
shipped binary; the messages ship to the customer and to the extension's
|
|
803
|
+
issue list, and a diff harness that has to special-case them stops being
|
|
804
|
+
able to prove anything about the rest of the envelope."""
|
|
805
|
+
if value is None:
|
|
806
|
+
return "Null"
|
|
807
|
+
if isinstance(value, bool):
|
|
808
|
+
return f"Bool({'true' if value else 'false'})"
|
|
809
|
+
if isinstance(value, str):
|
|
810
|
+
return f'String("{value}")'
|
|
811
|
+
if isinstance(value, (int, float)):
|
|
812
|
+
return f"Number({value})"
|
|
813
|
+
if isinstance(value, list):
|
|
814
|
+
return "Sequence [" + ", ".join(_yaml_debug(v) for v in value) + "]"
|
|
815
|
+
if isinstance(value, dict):
|
|
816
|
+
body = ", ".join(f"{_yaml_debug(k)}: {_yaml_debug(v)}" for k, v in value.items())
|
|
817
|
+
return "Mapping {" + body + "}"
|
|
818
|
+
return f"String(\"{value}\")"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def fa_str(value: Any, key: str) -> str | None:
|
|
822
|
+
"""A non-empty string sub-key; `None` when absent, empty, or non-string."""
|
|
823
|
+
raw = _fa_get(value, key)
|
|
824
|
+
if isinstance(raw, str) and raw:
|
|
825
|
+
return raw
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def fa_bool_checked(value: Any, key: str) -> bool | None:
|
|
830
|
+
"""Strict bool accessor for every behaviour-affecting `flash_args` bool
|
|
831
|
+
(`reset`, `erase`, `use_openocd`, `use_pyocd`, `confirm`, ...).
|
|
832
|
+
|
|
833
|
+
A quoted `"false"` is NOT a bool, and a tolerant reader would read it as
|
|
834
|
+
absent, apply the caller's default and program the OPPOSITE of what was
|
|
835
|
+
written. `None` only for genuinely absent/null; any other shape raises."""
|
|
836
|
+
raw = _fa_get(value, key)
|
|
837
|
+
if raw is None:
|
|
838
|
+
return None
|
|
839
|
+
if isinstance(raw, bool):
|
|
840
|
+
return raw
|
|
841
|
+
raise FlashPlanError(
|
|
842
|
+
f"flash_args.{key} must be a bare boolean (true/false, unquoted; got "
|
|
843
|
+
f"{_yaml_debug(raw)}) -- refusing to silently fall back to a default -- "
|
|
844
|
+
"this plans a real flash write."
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def fa_int_checked(value: Any, key: str) -> int | None:
|
|
849
|
+
"""Strict int accessor (`jlink_speed`, `baud`, `jobs`, `speed`).
|
|
850
|
+
|
|
851
|
+
`0`-means-absent semantics are preserved from the oracle: an explicit `0`
|
|
852
|
+
yields `None`, i.e. "use the default". `bool` is checked BEFORE `int` --
|
|
853
|
+
Python's `True` IS an `int`, so an unguarded `isinstance(raw, int)` would
|
|
854
|
+
accept `jobs: true` and emit `-j 1`."""
|
|
855
|
+
raw = _fa_get(value, key)
|
|
856
|
+
if raw is None:
|
|
857
|
+
return None
|
|
858
|
+
if isinstance(raw, bool):
|
|
859
|
+
raise FlashPlanError(_int_refusal(key, raw))
|
|
860
|
+
if isinstance(raw, int):
|
|
861
|
+
return raw if raw != 0 else None
|
|
862
|
+
raise FlashPlanError(_int_refusal(key, raw))
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _int_refusal(key: str, raw: Any) -> str:
|
|
866
|
+
return (
|
|
867
|
+
f"flash_args.{key} must be a bare number (unquoted; got {_yaml_debug(raw)}) "
|
|
868
|
+
"-- refusing to silently fall back to a default -- this plans a real "
|
|
869
|
+
"flash write."
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def fa_str_checked(value: Any, key: str, as_hex_address: bool) -> str | None:
|
|
874
|
+
"""Strict string accessor for fields where falling back to a baked-in default
|
|
875
|
+
is dangerous -- a flash base address, an OpenOCD interface/target name that
|
|
876
|
+
gets interpolated into a spawned command.
|
|
877
|
+
|
|
878
|
+
`fa_str` treats ANY non-string value -- including the bare YAML integer an
|
|
879
|
+
unquoted `base: 0x08000000` resolves to -- as "absent", so the caller
|
|
880
|
+
silently substitutes the default and programs real silicon at the wrong
|
|
881
|
+
address with no warning. This returns `None` only for genuinely
|
|
882
|
+
absent/null/empty, round-trips a bare non-negative number back into a string
|
|
883
|
+
(hex for an address field, decimal otherwise), and refuses every other shape.
|
|
884
|
+
|
|
885
|
+
A NEGATIVE number is refused outright rather than formatted: Rust's
|
|
886
|
+
`n as u64` sign-extends `-8` into `0xFFFFFFFFFFFFFFF8`, which
|
|
887
|
+
`validate_address` (a pure charset check) then ACCEPTS as a plausible
|
|
888
|
+
address and the J-Link/OpenOCD command interpolates verbatim.
|
|
889
|
+
"""
|
|
890
|
+
raw = _fa_get(value, key)
|
|
891
|
+
if raw is None:
|
|
892
|
+
return None
|
|
893
|
+
if isinstance(raw, bool):
|
|
894
|
+
# Guarded before the int arm for the same reason as `fa_int_checked`:
|
|
895
|
+
# `True` is an `int`, and `base: true` must not resolve to `0x00000001`.
|
|
896
|
+
raise FlashPlanError(_str_refusal(key, raw))
|
|
897
|
+
if isinstance(raw, str):
|
|
898
|
+
return raw or None
|
|
899
|
+
if isinstance(raw, int):
|
|
900
|
+
if raw < 0:
|
|
901
|
+
raise FlashPlanError(
|
|
902
|
+
f"flash_args.{key} = {raw} is negative; refusing to interpret it as "
|
|
903
|
+
"an address/count -- this plans a real flash write."
|
|
904
|
+
)
|
|
905
|
+
return f"0x{raw:08X}" if as_hex_address else str(raw)
|
|
906
|
+
raise FlashPlanError(_str_refusal(key, raw))
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _str_refusal(key: str, raw: Any) -> str:
|
|
910
|
+
return (
|
|
911
|
+
f"flash_args.{key} must be a quoted string (got {_yaml_debug(raw)}); "
|
|
912
|
+
"refusing to silently fall back to a default -- this plans a real flash write."
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def is_pending(value: Any) -> bool:
|
|
917
|
+
"""Whether a manifest SCALAR is the SDK's unfilled-field sentinel.
|
|
918
|
+
|
|
919
|
+
**The one definition for the whole flash path (#222).** Every guard in this
|
|
920
|
+
area used to test for EMPTY, and empty is the one thing a `TBD` placeholder
|
|
921
|
+
is not -- so an unfilled field behaved exactly like a filled one, and
|
|
922
|
+
whether that ended in a loud refusal or a spawned flasher came down to
|
|
923
|
+
whether the particular consumer happened to validate against a closed set.
|
|
924
|
+
`flash_method: TBD` hit the backend registry and failed safely;
|
|
925
|
+
`output_artefact`/`firmware_path: TBD` hit nothing at all, resolved to
|
|
926
|
+
`<build_root>/TBD` and reached a real J-Link write. Route every new
|
|
927
|
+
manifest-derived field through THIS, never through a fresh `== "TBD"`.
|
|
928
|
+
|
|
929
|
+
Trimmed before comparing -- a YAML `device: " TBD "` is the same unfilled
|
|
930
|
+
field -- but deliberately NOT case-folded and NOT a substring test:
|
|
931
|
+
`TBD-1234-XYZ` is a plausible part number and `flash_args.build_dir:
|
|
932
|
+
/opt/TBDtool/x` a plausible path, and refusing either would block a
|
|
933
|
+
legitimate flash. `tbd` lowercase is not the sentinel alp-sdk emits;
|
|
934
|
+
widening to it means widening the SDK's convention first, in one place,
|
|
935
|
+
not here.
|
|
936
|
+
|
|
937
|
+
The comparison is the single `tan.core.pending.is_pending_placeholder`
|
|
938
|
+
definition (#276): the neutral module with no flash- or image-bundle
|
|
939
|
+
machinery behind it, so `tan.core.size` (and `pinmux`, once ported) can
|
|
940
|
+
read the same rule without pulling flash internals in. `PENDING_SENTINEL`
|
|
941
|
+
stays the name this module exports -- `flash_cmd` and the flash tests
|
|
942
|
+
already spell it that way -- but it is now an alias for
|
|
943
|
+
`pending.PENDING_PLACEHOLDER`, not a second definition. `tan image`'s own
|
|
944
|
+
`image_bundle.PENDING_SENTINEL` is still a separate `"TBD"` literal;
|
|
945
|
+
pointing it at the same module too is a follow-up outside flash_plan.py.
|
|
946
|
+
"""
|
|
947
|
+
return is_pending_placeholder(value)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def flash_args_has_tbd(value: Any) -> bool:
|
|
951
|
+
"""Whether `flash_args` carries an unresolved `TBD` ANYWHERE -- a bare `TBD`
|
|
952
|
+
scalar, or a mapping/sequence value that trims to `TBD`.
|
|
953
|
+
|
|
954
|
+
Deliberately broader than a single-key check: a `TBD` anywhere means the
|
|
955
|
+
entry is not finalised yet under the SDK's pending-placeholder convention.
|
|
956
|
+
Do not narrow this back to a set of known keys. Recurses into mapping VALUES
|
|
957
|
+
and sequence elements, not mapping keys: every accessor here reads by a
|
|
958
|
+
known key name, so a key literally named `TBD` selects nothing and cannot
|
|
959
|
+
reach an argv.
|
|
960
|
+
|
|
961
|
+
This covers `flash_args` ONLY. The sibling artefact fields
|
|
962
|
+
(`output_artefact`/`firmware_path`) are NOT part of `flash_args` and are
|
|
963
|
+
guarded separately at the point of use -- see `is_pending`.
|
|
964
|
+
"""
|
|
965
|
+
if isinstance(value, str):
|
|
966
|
+
return is_pending(value)
|
|
967
|
+
if isinstance(value, dict):
|
|
968
|
+
return any(flash_args_has_tbd(v) for v in value.values())
|
|
969
|
+
if isinstance(value, list):
|
|
970
|
+
return any(flash_args_has_tbd(v) for v in value)
|
|
971
|
+
return False
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ── validators ──────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def validate_identifier(
|
|
978
|
+
text: str, field_name: str, *, destination: str = "a spawned command / OpenOCD Tcl script"
|
|
979
|
+
) -> None:
|
|
980
|
+
"""Reject anything that is not a plain identifier, or a `/`-separated path
|
|
981
|
+
of plain identifier segments.
|
|
982
|
+
|
|
983
|
+
`interface`/`target` are interpolated verbatim into an OpenOCD
|
|
984
|
+
`-f <name>.cfg` path and a `-c` Tcl command string, so an unrestricted value
|
|
985
|
+
is a path-traversal + Tcl-injection primitive into a process routinely run
|
|
986
|
+
with device-flashing privileges. Multi-segment is allowed because OpenOCD
|
|
987
|
+
ships interface configs in subdirectories (`ftdi/olimex-arm-usb-ocd-h`).
|
|
988
|
+
|
|
989
|
+
Rust composes `path_guard::is_plain_relative` with a per-segment charset
|
|
990
|
+
check. The charset alone is EQUIVALENT here and is what is implemented: the
|
|
991
|
+
only shapes `is_plain_relative` adds are absolute/rooted/drive-prefixed and
|
|
992
|
+
`.`/`..`, and every one of those carries a character (`/` leading -> an empty
|
|
993
|
+
segment, `:`, `\\`, `.`) the charset already rejects. Cross-checked against
|
|
994
|
+
the oracle on `a;b`, `../x`, `/x`, `\\x`, `C:/x`, `a//b`, `.`.
|
|
995
|
+
|
|
996
|
+
`destination` lets a caller whose value never goes near OpenOCD say so.
|
|
997
|
+
The default names OpenOCD because `interface`/`target` -- the callers
|
|
998
|
+
that shaped this message originally -- legitimately reach its `-c` Tcl
|
|
999
|
+
string; `jlink_serial` (tan-cli#486 review) does not, and reusing the
|
|
1000
|
+
default gave a captured refusal envelope the sentence "refusing to
|
|
1001
|
+
interpolate it into a spawned command / OpenOCD Tcl script" for a value
|
|
1002
|
+
that only ever reaches a J-Link Commander `SelectEmuBySN` line.
|
|
1003
|
+
"""
|
|
1004
|
+
segments = text.split("/")
|
|
1005
|
+
ok = bool(text) and all(
|
|
1006
|
+
seg and all(c.isascii() and (c.isalnum() or c in "-_") for c in seg) for seg in segments
|
|
1007
|
+
)
|
|
1008
|
+
if not ok:
|
|
1009
|
+
raise FlashPlanError(
|
|
1010
|
+
f"flash_args.{field_name} = {_quoted(text)} is not a plain identifier or "
|
|
1011
|
+
"'/'-separated path of plain identifiers (letters, digits, '-', '_' per "
|
|
1012
|
+
f"segment) -- refusing to interpolate it into {destination}."
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def validate_address(text: str, field_name: str) -> None:
|
|
1017
|
+
"""A flash base address must be purely hex digits, with an optional `0x`/`0X`.
|
|
1018
|
+
|
|
1019
|
+
`base` is interpolated verbatim into a J-Link Commander script LINE and an
|
|
1020
|
+
OpenOCD `-c` Tcl command string -- both line/command-oriented interpreters,
|
|
1021
|
+
so a newline (or `;`, `[`, `]`) inside `base` runs arbitrary extra commands
|
|
1022
|
+
against whatever silicon is attached.
|
|
1023
|
+
"""
|
|
1024
|
+
digits = text
|
|
1025
|
+
for prefix in ("0x", "0X"):
|
|
1026
|
+
if digits.startswith(prefix):
|
|
1027
|
+
digits = digits[len(prefix) :]
|
|
1028
|
+
break
|
|
1029
|
+
if not digits or not all(c in "0123456789abcdefABCDEF" for c in digits):
|
|
1030
|
+
raise FlashPlanError(
|
|
1031
|
+
f"flash_args.{field_name} = {_quoted(text)} is not a plain hex/decimal "
|
|
1032
|
+
"address -- refusing to interpolate it into a J-Link/OpenOCD command."
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def validate_commander_path(text: str, label: str) -> None:
|
|
1037
|
+
"""Reject a control character (`\\x00`-`\\x1f`, or DEL `\\x7f`) or a literal
|
|
1038
|
+
`"` in a value bound for a J-Link Commander script LINE -- `loadbin`/
|
|
1039
|
+
`loadfile`/`verifybin`'s path argument, or `SelectEmuBySN`'s serial
|
|
1040
|
+
(tan-cli#486).
|
|
1041
|
+
|
|
1042
|
+
Deliberately narrower than `validate_identifier`: this guards a real
|
|
1043
|
+
filesystem path (`atoc`, the flash artefact), which legitimately carries
|
|
1044
|
+
spaces, `:`, `\\`, and drive letters -- rejecting those would turn a real
|
|
1045
|
+
Windows-style path into a refusal. What must never reach the script is a
|
|
1046
|
+
literal newline/CR: `commander_path`'s conditional quoting only stops
|
|
1047
|
+
SEGGER's whitespace tokeniser from splitting a spaced path into two
|
|
1048
|
+
tokens, it does nothing to stop an embedded newline from ending the
|
|
1049
|
+
quoted string's own Commander LINE and starting a new, attacker-chosen
|
|
1050
|
+
one -- quoting controls tokenisation within a line, not where lines end.
|
|
1051
|
+
A tab/formfeed/vertical-tab is technically a control character too but is
|
|
1052
|
+
already forced through the quoting path by `commander_path`'s `c.isspace()`
|
|
1053
|
+
check; rejecting the narrower "line-ending" set here (which still catches
|
|
1054
|
+
every one of them, since `isspace()` control chars are also `< 0x20`)
|
|
1055
|
+
keeps this guard doing ONE job instead of duplicating that quoting
|
|
1056
|
+
decision.
|
|
1057
|
+
|
|
1058
|
+
`"` is rejected too (tan-cli#486 review): a value carrying BOTH a `"` and
|
|
1059
|
+
a space defeats `commander_path`'s own conditional quoting from the
|
|
1060
|
+
inside -- `/b/a" halt "z.bin` renders `loadbin "/b/a" halt "z.bin", 0x8000`,
|
|
1061
|
+
where the embedded quote closes the wrapper early and `halt` reads back
|
|
1062
|
+
as a bare token mid-line, exactly what this function's own claim to
|
|
1063
|
+
"control tokenisation within a line" promises will not happen. `"` is a
|
|
1064
|
+
reserved character in a Windows filename and vanishingly rare on POSIX,
|
|
1065
|
+
so rejecting it costs a real path nothing.
|
|
1066
|
+
"""
|
|
1067
|
+
if any(ord(c) < 0x20 or ord(c) == 0x7F or c == '"' for c in text):
|
|
1068
|
+
raise FlashPlanError(
|
|
1069
|
+
f"{label} = {_quoted(text)} contains a control character or a "
|
|
1070
|
+
'literal \'"\' -- refusing to interpolate it into a J-Link '
|
|
1071
|
+
"Commander script line, where either would let it end the "
|
|
1072
|
+
"current line/quoted token and start a new, unintended one "
|
|
1073
|
+
"regardless of quoting."
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
#: `char::escape_debug`'s named escapes, which is what Rust's `{:?}` for a
|
|
1078
|
+
#: `&str` emits. Applied in ONE pass -- escaping `\\` up front and then
|
|
1079
|
+
#: re-scanning would revisit the backslashes it just added.
|
|
1080
|
+
_DEBUG_ESCAPES = {
|
|
1081
|
+
"\\": "\\\\",
|
|
1082
|
+
'"': '\\"',
|
|
1083
|
+
"\t": "\\t",
|
|
1084
|
+
"\r": "\\r",
|
|
1085
|
+
"\n": "\\n",
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _quoted(text: str) -> str:
|
|
1090
|
+
"""Rust's `{s:?}` for a `&str`.
|
|
1091
|
+
|
|
1092
|
+
Not just `"` and `\\`: Rust escapes control characters too, so a `base`
|
|
1093
|
+
containing a real newline renders as `"0x8000\\n r"` -- ONE line -- and not
|
|
1094
|
+
as a refusal message split across two. These messages are exactly the ones
|
|
1095
|
+
reporting an injection attempt (`validate_address`/`validate_identifier`
|
|
1096
|
+
exist to catch a newline smuggled into a J-Link Commander script line), so a
|
|
1097
|
+
diagnostic that itself breaks across lines is the worst possible rendering:
|
|
1098
|
+
a reader sees a truncated message and the offending bytes on their own line.
|
|
1099
|
+
Caught by the oracle diff, not by review.
|
|
1100
|
+
"""
|
|
1101
|
+
rendered = [
|
|
1102
|
+
_DEBUG_ESCAPES.get(char)
|
|
1103
|
+
or (char if char.isprintable() else f"\\u{{{ord(char):x}}}")
|
|
1104
|
+
for char in text
|
|
1105
|
+
]
|
|
1106
|
+
return '"' + "".join(rendered) + '"'
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def is_raw_bin(artefact: str) -> bool:
|
|
1110
|
+
"""Whether an artefact is a raw binary (needs an explicit load address), as
|
|
1111
|
+
opposed to ELF/HEX which carry their own. Passing a load offset for a
|
|
1112
|
+
non-`.bin` artefact shifts every section by that offset and writes outside
|
|
1113
|
+
the intended flash region."""
|
|
1114
|
+
return os.path.splitext(artefact)[1].lower() == ".bin"
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# ── the plan + backend registry ─────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@dataclass(frozen=True)
|
|
1121
|
+
class FlashPlan:
|
|
1122
|
+
"""A built flash plan: the argv, the success message, whether it is
|
|
1123
|
+
planning-only (never spawns real device IO), and -- for the J-Link path --
|
|
1124
|
+
the Commander script the caller must materialise to a temp file."""
|
|
1125
|
+
|
|
1126
|
+
argv: tuple[str, ...]
|
|
1127
|
+
ok_message: str
|
|
1128
|
+
planning_only: bool = False
|
|
1129
|
+
jlink_script: str | None = None
|
|
1130
|
+
#: tan-cli#520: a backend's own already-resolved WRITE device, carried
|
|
1131
|
+
#: through so the caller's read-only DPIDR preflight can reuse it as its
|
|
1132
|
+
#: own connect device rather than re-reading a second `flash_args` key
|
|
1133
|
+
#: with a different meaning. `None` for every backend/arm that has no such
|
|
1134
|
+
#: device to offer -- today that is every registered backend; the field
|
|
1135
|
+
#: exists for a future J-Link-arm backend that needs it (`swd_probe`, the
|
|
1136
|
+
#: original setter, was removed by tan-cli#732).
|
|
1137
|
+
preflight_device: str | None = None
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
@dataclass(frozen=True)
|
|
1141
|
+
class BackendMeta:
|
|
1142
|
+
"""A registered backend: the tool-gate `requires` list + its plan-builder."""
|
|
1143
|
+
|
|
1144
|
+
requires: tuple[str, ...]
|
|
1145
|
+
build: Callable[["FlashInputs", Callable[[str], bool]], FlashPlan]
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@dataclass(frozen=True)
|
|
1149
|
+
class FlashInputs:
|
|
1150
|
+
"""Everything a backend plan-builder consumes. Injected by the CLI layer."""
|
|
1151
|
+
|
|
1152
|
+
artefact: str
|
|
1153
|
+
flash_args: Any
|
|
1154
|
+
core_id: str
|
|
1155
|
+
sku: str
|
|
1156
|
+
dry_run: bool = False
|
|
1157
|
+
#: The env half of the confirm gate (`ALP_FLASH_FORCE=1`). The per-entry
|
|
1158
|
+
#: `flash_args.confirm` is OR-ed in by the gated builders, so the effective
|
|
1159
|
+
#: gate is `flash_args.confirm OR ALP_FLASH_FORCE=1`.
|
|
1160
|
+
force_confirm: bool = False
|
|
1161
|
+
#: tan-cli#1509: `(slice id, resolved .bin, slot0 address)` of the other slices a
|
|
1162
|
+
#: combined Flow D write also loads, ahead of the single ATOC. Empty = today's plan.
|
|
1163
|
+
extra_apps: tuple[tuple[str, str, str], ...] = ()
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
#: The one place the confirm gate's remedy is written (tan-cli#719). Three
|
|
1167
|
+
#: sites used to compose their own "not run" note and only ONE of them named
|
|
1168
|
+
#: `ALP_FLASH_FORCE=1` -- the mechanism that actually works -- so the other two
|
|
1169
|
+
#: pointed the reader at a manifest key and stayed silent about the env var.
|
|
1170
|
+
#: Most-specific-first, matching the SETOOLS resolution message's shape.
|
|
1171
|
+
def confirm_remedy(action: str = "actually flash") -> str:
|
|
1172
|
+
"""The remedy sentence, with `action` naming what the gate holds back
|
|
1173
|
+
(`tan model run --device` passes "run on the device", tan-cli#1417)."""
|
|
1174
|
+
return (
|
|
1175
|
+
f"to {action}, most-specific first: `--confirm` on the command line, "
|
|
1176
|
+
"`ALP_FLASH_FORCE=1` in the environment, or `flash_args.confirm: true` in "
|
|
1177
|
+
"the manifest"
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
CONFIRM_REMEDY = confirm_remedy()
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def confirm_gate_note(why: str, action: str = "actually flash") -> str:
|
|
1185
|
+
"""The parenthetical every confirm-gated preview appends.
|
|
1186
|
+
|
|
1187
|
+
`why` is `dry-run` (an explicit preview, nothing to remedy) or the
|
|
1188
|
+
confirm-gate reason, which carries `CONFIRM_REMEDY`.
|
|
1189
|
+
"""
|
|
1190
|
+
if why == "dry-run":
|
|
1191
|
+
return why
|
|
1192
|
+
return f"{why} -- {confirm_remedy(action)}"
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def backend_for(method: str) -> BackendMeta | None:
|
|
1196
|
+
"""Resolve a `flash_method` string to its backend metadata, or `None`."""
|
|
1197
|
+
return _REGISTRY.get(method)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def registry_keys() -> list[str]:
|
|
1201
|
+
"""The registered method names, sorted -- for the "Available: ..." error."""
|
|
1202
|
+
return sorted(_REGISTRY)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def registry_keys_debug() -> str:
|
|
1206
|
+
"""`{:?}` of a `Vec<&str>`, for the unknown-method message."""
|
|
1207
|
+
return _str_list_debug(registry_keys())
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _str_list_debug(items) -> str:
|
|
1211
|
+
return "[" + ", ".join(_quoted(i) for i in items) + "]"
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
# ── J-Link Commander script quoting (shared by Flow D) ──────────────────────
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def commander_path(path: str) -> str:
|
|
1218
|
+
"""A path as it should be interpolated into a J-Link Commander script
|
|
1219
|
+
line -- quoted when it CONTAINS whitespace, unchanged otherwise
|
|
1220
|
+
(tan-cli#369). SEGGER's Commander splits an unquoted line on whitespace,
|
|
1221
|
+
so an unquoted `loadbin C:\\Program Files\\alif\\setools\\build\\
|
|
1222
|
+
AppTocPackage.bin, <address>` silently truncates to `C:\\Program` --
|
|
1223
|
+
`-ExitOnError 1` turns that into a loud SEGGER parse error rather than a
|
|
1224
|
+
mis-write, which is the only reason it was not a blocker. tan generates
|
|
1225
|
+
every Commander script now, so tan owns making its own filenames parse
|
|
1226
|
+
back correctly. Quoting is CONDITIONAL, not unconditional, so the
|
|
1227
|
+
overwhelmingly common no-space path -- every already-measured
|
|
1228
|
+
oracle/bench script -- renders byte-identical to before this fix.
|
|
1229
|
+
"""
|
|
1230
|
+
return f'"{path}"' if any(c.isspace() for c in path) else path
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _default(value, fallback):
|
|
1234
|
+
"""`Option::unwrap_or`. Spelled out because `value or fallback` is WRONG for
|
|
1235
|
+
every falsy-but-present value this module reads -- `reset: false`,
|
|
1236
|
+
`jlink_speed` legitimately absent-as-0, `interface: ""`."""
|
|
1237
|
+
return fallback if value is None else value
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
# ── zephyr_west_flash / baremetal_cmake_flash ───────────────────────────────
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def zephyr_build_dir(artefact: str) -> str:
|
|
1244
|
+
"""The Zephyr build dir derived from the artefact: `parent.parent` when the
|
|
1245
|
+
artefact sits directly in a `zephyr/` subdirectory, else `parent`.
|
|
1246
|
+
|
|
1247
|
+
Checks the PARENT DIRECTORY NAME, never the artefact's basename: an
|
|
1248
|
+
MCUboot-signed (`zephyr.signed.hex`) or sysbuild (`merged.hex`) output still
|
|
1249
|
+
lands in `<build_dir>/zephyr/` under a different name, and a basename
|
|
1250
|
+
allowlist sent those one directory too deep -- `west flash --build-dir
|
|
1251
|
+
<that>` then failed with no CMakeCache.txt there.
|
|
1252
|
+
|
|
1253
|
+
`os.path.dirname`, not `Path.parent`: it slices the string and preserves
|
|
1254
|
+
whatever separators the joined path already mixes (a native `build_root` +
|
|
1255
|
+
a `/`-authored manifest artefact), exactly as Rust's `Path::parent` does.
|
|
1256
|
+
"""
|
|
1257
|
+
parent = os.path.dirname(artefact)
|
|
1258
|
+
if os.path.basename(parent).lower() == "zephyr":
|
|
1259
|
+
return os.path.dirname(parent)
|
|
1260
|
+
return parent
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def zephyr_west_build_dir(flash_args: Any, artefact: str) -> str:
|
|
1264
|
+
"""The `--build-dir` `plan_zephyr_west_flash` hands `west flash`:
|
|
1265
|
+
`flash_args.build_dir`, else derived from the artefact. One definition,
|
|
1266
|
+
shared with `tan.commands.flash_atoc_guard`, which reads the runner's
|
|
1267
|
+
verdict out of this same directory (tan-cli#1267)."""
|
|
1268
|
+
return _default(fa_str(flash_args, "build_dir"), zephyr_build_dir(artefact))
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def plan_zephyr_west_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
|
|
1272
|
+
"""`zephyr_west_flash`: `west flash --build-dir <d> [--runner <r>] [--erase]
|
|
1273
|
+
[--hex-file <h>]`.
|
|
1274
|
+
|
|
1275
|
+
`runner` is OPTIONAL -- when absent, `--runner` is omitted and `west flash`
|
|
1276
|
+
falls back to the board.cmake default runner (on an AEN board that is
|
|
1277
|
+
`alif_flash`, i.e. Flow A over the SE-UART). `--replace-atoc` is NOT added
|
|
1278
|
+
here: whether it applies depends on the build's runner and the bound SDK's
|
|
1279
|
+
runner source, both IO, so the caller appends it (tan-cli#1267,
|
|
1280
|
+
`tan.core.atoc_guard`).
|
|
1281
|
+
"""
|
|
1282
|
+
del which # this backend probes nothing
|
|
1283
|
+
fa = inp.flash_args
|
|
1284
|
+
runner = fa_str(fa, "runner")
|
|
1285
|
+
build_dir = zephyr_west_build_dir(fa, inp.artefact)
|
|
1286
|
+
argv = ["west", "flash", "--build-dir", build_dir]
|
|
1287
|
+
if runner is not None:
|
|
1288
|
+
argv += ["--runner", runner]
|
|
1289
|
+
if _default(fa_bool_checked(fa, "erase"), False):
|
|
1290
|
+
argv.append("--erase")
|
|
1291
|
+
hex_file = fa_str(fa, "hex_file")
|
|
1292
|
+
if hex_file is not None:
|
|
1293
|
+
argv += ["--hex-file", hex_file]
|
|
1294
|
+
return FlashPlan(
|
|
1295
|
+
argv=tuple(argv),
|
|
1296
|
+
ok_message=(
|
|
1297
|
+
f"zephyr_west_flash[{inp.core_id}]: programmed via "
|
|
1298
|
+
f"{runner if runner is not None else 'board-default runner'}"
|
|
1299
|
+
),
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def plan_baremetal_cmake_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
|
|
1304
|
+
"""`baremetal_cmake_flash`: `cmake --build <d> --target <t> [--config <c>] [-j N]`."""
|
|
1305
|
+
del which
|
|
1306
|
+
fa = inp.flash_args
|
|
1307
|
+
build_dir = _default(fa_str(fa, "build_dir"), os.path.dirname(inp.artefact))
|
|
1308
|
+
target = _default(fa_str(fa, "target"), "flash")
|
|
1309
|
+
argv = ["cmake", "--build", build_dir, "--target", target]
|
|
1310
|
+
config = fa_str(fa, "config")
|
|
1311
|
+
if config is not None:
|
|
1312
|
+
argv += ["--config", config]
|
|
1313
|
+
jobs = fa_int_checked(fa, "jobs")
|
|
1314
|
+
if jobs is not None:
|
|
1315
|
+
argv += ["-j", str(jobs)]
|
|
1316
|
+
return FlashPlan(
|
|
1317
|
+
argv=tuple(argv),
|
|
1318
|
+
ok_message=f"baremetal_cmake_flash[{inp.core_id}]: target `{target}` ok",
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
# ── storage backends ────────────────────────────────────────────────────────
|
|
1323
|
+
|
|
1324
|
+
PIPE = "|"
|
|
1325
|
+
|
|
1326
|
+
#: The directory a `yocto_wic` flash target must resolve beneath (tan-cli#487,
|
|
1327
|
+
#: porting alp-sdk's `_DEV_ROOT` -- security fix 3aa65cd7 / #1112, the half of
|
|
1328
|
+
#: that commit tan-cli#486's sibling-hardening pass dropped). A module
|
|
1329
|
+
#: constant, not a literal in the check, so a test can exercise the real
|
|
1330
|
+
#: resolution logic without touching a host's actual `/dev`.
|
|
1331
|
+
_DEV_ROOT = "/dev"
|
|
1332
|
+
|
|
1333
|
+
#: The two registry keys `plan_yocto_wic` answers for -- exported so
|
|
1334
|
+
#: `tan.commands.flash_cmd` can scope its write-time block-device gate
|
|
1335
|
+
#: (tan-cli#487, see that module's `_yocto_wic_block_device_refusal`) to
|
|
1336
|
+
#: exactly this backend without re-typing the strings.
|
|
1337
|
+
YOCTO_WIC_METHODS = ("yocto_wic_to_sd_or_emmc", "yocto_wic")
|
|
1338
|
+
|
|
1339
|
+
#: Suffixes `plan_yocto_wic` RECOGNISES as a real compression codec it
|
|
1340
|
+
#: cannot decompress (tan-cli#487, defect 2, shape 2) -- named verbatim from
|
|
1341
|
+
#: the issue's own examples. Deliberately NOT "every suffix that isn't gz/
|
|
1342
|
+
#: xz/wic": an unrecognised suffix might be a customer's own naming
|
|
1343
|
+
#: convention for a genuinely uncompressed artefact, and refusing THAT would
|
|
1344
|
+
#: trade a silent raw-dd for a false-positive refusal on a legitimate plain
|
|
1345
|
+
#: `.wic`.
|
|
1346
|
+
_KNOWN_UNSUPPORTED_COMPRESSION_SUFFIXES = ("zst", "bz2", "lzo")
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _resolve_dev_root(target: str) -> str | None:
|
|
1350
|
+
"""LEXICAL canonicalization of `target`: `posixpath.normpath` on a
|
|
1351
|
+
POSIX-normalized copy of the string -- collapses a `..` traversal
|
|
1352
|
+
(`/dev/../home/u/x`) on every host, including Windows, where
|
|
1353
|
+
`pathlib`/`os.path` would instead reinterpret the string as an NT path.
|
|
1354
|
+
Pure -- no filesystem access -- so safe to run unconditionally, including
|
|
1355
|
+
for a `--dry-run` preview of a target that is not plugged in yet.
|
|
1356
|
+
|
|
1357
|
+
Returns the normalized path when it resolves at or beneath `_DEV_ROOT`,
|
|
1358
|
+
else `None`. The caller keeps using the ORIGINAL `target` string for the
|
|
1359
|
+
refusal message and for the argv actually spawned -- never this return
|
|
1360
|
+
value -- so a target that is itself a symlink (`/dev/by-id/mmc-foo`)
|
|
1361
|
+
still reaches `dd`/`bmaptool` under the name the caller asked for.
|
|
1362
|
+
|
|
1363
|
+
Ported from alp-sdk's `_resolve_dev_root` (`scripts/flash_backends/
|
|
1364
|
+
yocto_wic.py`) MINUS its real-filesystem symlink-chase layer: this
|
|
1365
|
+
module is pure by convention (see its own docstring's "NO IO"), so that
|
|
1366
|
+
half lives in `tan.commands.flash_cmd`'s IO side instead, as a SEPARATE,
|
|
1367
|
+
write-time-only block-device `stat` gate
|
|
1368
|
+
(`_yocto_wic_block_device_refusal`) -- see that function's docstring for
|
|
1369
|
+
why, and for the one shape this lexical-only layer alone cannot catch (a
|
|
1370
|
+
real, existing regular file lexically living under a real `/dev/`
|
|
1371
|
+
subtree, e.g. `/dev/shm/<name>`)."""
|
|
1372
|
+
normalized = posixpath.normpath(target.replace(os.sep, "/") if os.sep != "/" else target)
|
|
1373
|
+
root = _DEV_ROOT.rstrip("/")
|
|
1374
|
+
# Strictly BENEATH the root, not equal to it -- `_DEV_ROOT` itself is not
|
|
1375
|
+
# a device (matches the old bare `startswith("/dev/")` check, which also
|
|
1376
|
+
# rejected the bare string `"/dev"`: it has no trailing slash to match).
|
|
1377
|
+
if normalized.startswith(root + "/"):
|
|
1378
|
+
return normalized
|
|
1379
|
+
return None
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def plan_yocto_wic(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
|
|
1383
|
+
"""`yocto_wic_to_sd_or_emmc` / `yocto_wic`: bmaptool (preferred) or dd to a
|
|
1384
|
+
raw `/dev/` block device. Compressed images pipe `gunzip`/`xz` into `dd`.
|
|
1385
|
+
Planning-only unless the confirm gate is armed."""
|
|
1386
|
+
fa = inp.flash_args
|
|
1387
|
+
target = fa_str(fa, "target")
|
|
1388
|
+
if target is None:
|
|
1389
|
+
raise FlashPlanError("yocto_wic: flash_args.target is required (e.g. /dev/sdb)")
|
|
1390
|
+
if _resolve_dev_root(target) is None:
|
|
1391
|
+
raise FlashPlanError(
|
|
1392
|
+
f"yocto_wic: refusing target '{target}' -- must start with /dev/ to avoid "
|
|
1393
|
+
"clobbering a regular file. Set flash_args.target to a real block device."
|
|
1394
|
+
)
|
|
1395
|
+
artefact = inp.artefact
|
|
1396
|
+
compress = fa_str(fa, "compress")
|
|
1397
|
+
suffix = os.path.splitext(artefact)[1].lstrip(".").lower()
|
|
1398
|
+
confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
|
|
1399
|
+
planning_only = inp.dry_run or not confirm
|
|
1400
|
+
|
|
1401
|
+
bmaptool = which("bmaptool")
|
|
1402
|
+
dd = which("dd")
|
|
1403
|
+
if bmaptool or (planning_only and not dd):
|
|
1404
|
+
# tan-cli#487 review finding 2: `compress` is a `dd`-fallback-only
|
|
1405
|
+
# concern -- bmaptool decompresses natively and never reads it -- so
|
|
1406
|
+
# the vocabulary refusals below must not fire here. Before this fix
|
|
1407
|
+
# they ran unconditionally, ahead of tool selection, so a stock Yocto
|
|
1408
|
+
# `IMAGE_FSTYPES` artefact like `core-image.wic.zst` hard-refused a
|
|
1409
|
+
# `bmaptool copy core-image.wic.zst /dev/sdb` even on a bmap-tools
|
|
1410
|
+
# host -- a live regression on the arm this module's own docstring
|
|
1411
|
+
# calls "preferred".
|
|
1412
|
+
argv: tuple[str, ...] = ("bmaptool", "copy", artefact, target)
|
|
1413
|
+
elif dd:
|
|
1414
|
+
# `compress` is validated HERE, not before tool selection -- see the
|
|
1415
|
+
# bmaptool arm above. The suffix-refusal message below does NOT
|
|
1416
|
+
# suggest `flash_args.compress` as a fix for an UNSUPPORTED codec:
|
|
1417
|
+
# that used to send the operator straight into the compress-value
|
|
1418
|
+
# refusal a few lines down (the exact same codec, now explicit,
|
|
1419
|
+
# is still not "gz"|"xz") -- a dead-end loop tan-cli#487's review
|
|
1420
|
+
# caught.
|
|
1421
|
+
if compress is None:
|
|
1422
|
+
if suffix in ("gz", "xz"):
|
|
1423
|
+
compress = suffix
|
|
1424
|
+
elif suffix in _KNOWN_UNSUPPORTED_COMPRESSION_SUFFIXES:
|
|
1425
|
+
# tan-cli#487, defect 2, shape 2: no `compress` key at all,
|
|
1426
|
+
# but a suffix (`.wic.zst`, `.wic.bz2`, `.wic.lzo`) this
|
|
1427
|
+
# backend RECOGNISES as a real codec it just cannot
|
|
1428
|
+
# decompress via `dd` -- the old auto-detect missed it (only
|
|
1429
|
+
# "gz"/"xz" are recognised), so `compress` silently resolved
|
|
1430
|
+
# to `None`, which reads as "genuinely uncompressed" and
|
|
1431
|
+
# raw-`dd`s the compressed stream. An UNRECOGNISED suffix
|
|
1432
|
+
# (anything else) is left alone -- there is no way to tell
|
|
1433
|
+
# "genuinely uncompressed" from "a codec this list simply
|
|
1434
|
+
# does not know about" for those, and guessing wrong in THAT
|
|
1435
|
+
# direction would refuse a legitimate plain `.wic`.
|
|
1436
|
+
raise FlashPlanError(
|
|
1437
|
+
f"yocto_wic: artefact suffix '.{suffix}' looks compressed but "
|
|
1438
|
+
'is not supported by the dd fallback -- the vocabulary is '
|
|
1439
|
+
'"gz" | "xz". Decompress the artefact first, or install '
|
|
1440
|
+
"bmaptool (preferred -- it decompresses natively)."
|
|
1441
|
+
)
|
|
1442
|
+
# else: no `compress` key and an unrecognised/absent suffix --
|
|
1443
|
+
# genuinely uncompressed, `compress` stays `None`.
|
|
1444
|
+
elif compress not in ("gz", "xz"):
|
|
1445
|
+
# tan-cli#487, defect 2, shape 1 (an explicit out-of-vocabulary
|
|
1446
|
+
# `compress`) and shape 3 (that same bad explicit value SHADOWING
|
|
1447
|
+
# a suffix that would have auto-detected correctly -- a genuinely
|
|
1448
|
+
# `.wic.gz` artefact with `compress: zst` never even reaches the
|
|
1449
|
+
# `if compress is None` branch above, so the bad explicit value
|
|
1450
|
+
# wins and the auto-detect that WOULD have been right never
|
|
1451
|
+
# runs). Both used to fall through to the `else` branch below and
|
|
1452
|
+
# raw-`dd` the still-compressed stream onto the block device --
|
|
1453
|
+
# silently, `ok:true`, exit 0, and the board then silently does
|
|
1454
|
+
# not boot. That `else` branch is the CORRECT path for a
|
|
1455
|
+
# genuinely uncompressed `.wic`; it must never be reached for a
|
|
1456
|
+
# value the manifest actually SET to something this backend
|
|
1457
|
+
# cannot decompress. The documented vocabulary is "gz" | "xz" |
|
|
1458
|
+
# None (alp-sdk `scripts/flash_backends/yocto_wic.py:46`).
|
|
1459
|
+
raise FlashPlanError(
|
|
1460
|
+
f"yocto_wic: flash_args.compress '{compress}' is not supported -- the "
|
|
1461
|
+
'vocabulary is "gz" | "xz" (omit the key to auto-detect from the '
|
|
1462
|
+
"artefact suffix instead)."
|
|
1463
|
+
)
|
|
1464
|
+
bs = _default(fa_str(fa, "bs"), "4M")
|
|
1465
|
+
dd_cmd = ["dd", f"of={target}", f"bs={bs}", "conv=fsync", "status=progress"]
|
|
1466
|
+
if compress == "gz":
|
|
1467
|
+
if which("gunzip"):
|
|
1468
|
+
dcmp = ["gunzip", "-c", artefact]
|
|
1469
|
+
elif which("gzip"):
|
|
1470
|
+
dcmp = ["gzip", "-dc", artefact]
|
|
1471
|
+
else:
|
|
1472
|
+
raise FlashPlanError(
|
|
1473
|
+
"yocto_wic: compressed .wic.gz fallback needs `gunzip` or `gzip` on PATH."
|
|
1474
|
+
)
|
|
1475
|
+
argv = tuple([*dcmp, PIPE, *dd_cmd])
|
|
1476
|
+
elif compress == "xz":
|
|
1477
|
+
if not which("xz"):
|
|
1478
|
+
raise FlashPlanError(
|
|
1479
|
+
"yocto_wic: compressed .wic.xz fallback needs `xz` on PATH."
|
|
1480
|
+
)
|
|
1481
|
+
argv = tuple(["xz", "-dc", artefact, PIPE, *dd_cmd])
|
|
1482
|
+
else:
|
|
1483
|
+
argv = (
|
|
1484
|
+
"dd", f"if={artefact}", f"of={target}", f"bs={bs}",
|
|
1485
|
+
"conv=fsync", "status=progress",
|
|
1486
|
+
)
|
|
1487
|
+
else:
|
|
1488
|
+
raise FlashPlanError(
|
|
1489
|
+
"yocto_wic: neither `bmaptool` nor `dd` is on PATH; install bmaptool "
|
|
1490
|
+
"(preferred -- sparse aware) via `apt install bmap-tools` or run on a "
|
|
1491
|
+
"host with coreutils."
|
|
1492
|
+
)
|
|
1493
|
+
return FlashPlan(
|
|
1494
|
+
argv=argv,
|
|
1495
|
+
ok_message=f"yocto_wic[{inp.core_id}]: programmed {target}",
|
|
1496
|
+
planning_only=planning_only,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def plan_xspi_flashwriter(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
|
|
1501
|
+
"""`xspi_flashwriter`: Renesas Flash Writer over SCIF. Planning-only unless
|
|
1502
|
+
confirmed; the confirmed real write is HW-gated and fails today."""
|
|
1503
|
+
del which
|
|
1504
|
+
fa = inp.flash_args
|
|
1505
|
+
partition = _default(fa_str(fa, "flash_partition"), "")
|
|
1506
|
+
if partition not in ("mtd0", "mtd1"):
|
|
1507
|
+
raise FlashPlanError(
|
|
1508
|
+
"xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)"
|
|
1509
|
+
)
|
|
1510
|
+
port = _default(fa_str(fa, "port"), "<port>")
|
|
1511
|
+
writer = _default(fa_str(fa, "flash_writer"), "<flash_writer.mot>")
|
|
1512
|
+
baud = _default(fa_int_checked(fa, "baud"), 115200)
|
|
1513
|
+
artefact_name = os.path.basename(inp.artefact)
|
|
1514
|
+
argv = (
|
|
1515
|
+
"flash-writer-scif", f"port={port}", f"writer={writer}", f"baud={baud}",
|
|
1516
|
+
f"partition={partition}", f"artefact={artefact_name}",
|
|
1517
|
+
)
|
|
1518
|
+
confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
|
|
1519
|
+
if inp.dry_run or not confirm:
|
|
1520
|
+
why = "dry-run" if inp.dry_run else "flash_args.confirm is false"
|
|
1521
|
+
return FlashPlan(
|
|
1522
|
+
argv=argv,
|
|
1523
|
+
ok_message=(
|
|
1524
|
+
f"xspi_flashwriter[{inp.core_id}]: would write {artefact_name} -> xSPI "
|
|
1525
|
+
f"{partition} via Flash Writer on {port} ({confirm_gate_note(why)})"
|
|
1526
|
+
),
|
|
1527
|
+
planning_only=True,
|
|
1528
|
+
)
|
|
1529
|
+
raise FlashPlanError(
|
|
1530
|
+
"xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on "
|
|
1531
|
+
"silicon (bench shelved). Run with --dry-run; see docs/provisioning.md."
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
# ── Flow D: J-Link direct MRAM write ────────────────────────────────────────
|
|
1536
|
+
|
|
1537
|
+
#: The `flash_args` key that ARMS Flow D. Only the part-number device profile
|
|
1538
|
+
#: is required: without it J-Link has no MRAM loader at all, so its presence
|
|
1539
|
+
#: alone is metadata's statement that this silicon has one. `slot0_load_address`
|
|
1540
|
+
#: is NOT an arming key -- `tan/planner/orchestrator.py`/`loader.py` DO emit it
|
|
1541
|
+
#: today (alp-sdk#1374/tan-cli#353) for AEN slot0-XIP cores, but even where
|
|
1542
|
+
#: present it only ever selects the two-blob mramxip SHAPE, an ITCM-overflow
|
|
1543
|
+
#: exception, not whether Flow D applies at all. Requiring it here would leave
|
|
1544
|
+
#: Flow D permanently unarmed for every real AEN entry, which is the bug this
|
|
1545
|
+
#: comment replaces.
|
|
1546
|
+
FLOW_D_KEYS = ("jlink_flash_device",)
|
|
1547
|
+
FLOW_D_METHOD = "alif_mram_jlink"
|
|
1548
|
+
|
|
1549
|
+
#: `jlink_serial`'s `validate_identifier(..., destination=...)` override
|
|
1550
|
+
#: (tan-cli#486 review): it is interpolated into a J-Link Commander
|
|
1551
|
+
#: `SelectEmuBySN` line, never an OpenOCD Tcl script -- the default
|
|
1552
|
+
#: `destination` names the wrong interpreter for this one field.
|
|
1553
|
+
_JLINK_SERIAL_DESTINATION = "a J-Link Commander script line (SelectEmuBySN)"
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def flow_d_available(flash_args: Any) -> bool:
|
|
1557
|
+
"""Whether the manifest armed Flow D for this entry, i.e. supplied every
|
|
1558
|
+
key in `FLOW_D_KEYS`. Purely a data question -- see `select_flash_method`.
|
|
1559
|
+
|
|
1560
|
+
KEY PRESENCE, deliberately -- not "resolves to a non-null/non-empty
|
|
1561
|
+
string": an `is not None` check collapses a present-but-null
|
|
1562
|
+
`jlink_flash_device:` (bare YAML null) to "absent" and SILENTLY routes the
|
|
1563
|
+
entry to Flow A over the SE-UART instead, with no diagnostic at all.
|
|
1564
|
+
Transport must never be decided by a quoting detail. Using `_fa_has_key`
|
|
1565
|
+
arms Flow D on presence alone, so a present-but-null/malformed value still
|
|
1566
|
+
reaches `plan_alif_mram_jlink`, which turns it into the loud refusal it
|
|
1567
|
+
already produces for every other malformed Flow D field -- not a silent
|
|
1568
|
+
Flow A fallback. `fa_str_checked` itself only raises on a genuinely
|
|
1569
|
+
malformed (wrong-type) value; for present-but-null it quietly returns
|
|
1570
|
+
`None` same as for absent, so it is `plan_alif_mram_jlink`'s own explicit
|
|
1571
|
+
`_fa_has_key` re-check on that `None` (distinguishing "present but
|
|
1572
|
+
null/empty" from "absent") that decides the present-but-null case, not
|
|
1573
|
+
`fa_str_checked`'s own check.
|
|
1574
|
+
"""
|
|
1575
|
+
return all(_fa_has_key(flash_args, key) for key in FLOW_D_KEYS)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def select_flash_method(target: FlashTarget) -> str | None:
|
|
1579
|
+
"""The `flash_method` actually dispatched for `target` -- **Flow D by
|
|
1580
|
+
default, Flow A as the fallback.**
|
|
1581
|
+
|
|
1582
|
+
Two host paths put a signed image into MRAM on an Alif Ensemble part. Both
|
|
1583
|
+
need the SETOOLS `app-gen-toc` step to sign the ATOC; they differ only in
|
|
1584
|
+
TRANSPORT, and the transport is the part tan owns:
|
|
1585
|
+
|
|
1586
|
+
* **Flow A** -- `zephyr_west_flash` with no runner, so `west flash` picks the
|
|
1587
|
+
board.cmake default (`alif_flash`) and burns over the SE-UART. Needs a
|
|
1588
|
+
dedicated 1.8 V-capable USB-UART, which the bench runbook calls the #1
|
|
1589
|
+
trap.
|
|
1590
|
+
* **Flow D** -- `alif_mram_jlink`: J-Link straight over SWD, no SE-UART. Same
|
|
1591
|
+
blob(s), same addresses, ~0.16 s, and the bench's day-to-day default
|
|
1592
|
+
(`docs/aen-bench-bringup.md`: "Flow D is the day-to-day default now").
|
|
1593
|
+
|
|
1594
|
+
The switch is made **entirely from data**, never from silicon knowledge: a
|
|
1595
|
+
`zephyr_west_flash` entry whose `flash_args` carries `FLOW_D_KEYS` is
|
|
1596
|
+
dispatched as Flow D instead. tan cannot ask "is this an AEN MRAM part?" --
|
|
1597
|
+
that would put a SKU or an address in tan, which ADR-0017 / I-26 forbid and
|
|
1598
|
+
no gate would catch. What it CAN ask is "does this slice's SoC variant
|
|
1599
|
+
publish a part-number J-Link profile in its `debug:` block?", because
|
|
1600
|
+
that arriving at all IS metadata's statement that this silicon has a
|
|
1601
|
+
J-Link MRAM loader.
|
|
1602
|
+
|
|
1603
|
+
Consequence, stated plainly: `tan/planner/loader.py` already resolves `jlink_flash_device`
|
|
1604
|
+
(and, where present, the paired `expect_dpidr`/`jlink_device` preflight) from the SoC variant's
|
|
1605
|
+
`debug:` block -- selected via the SoM preset's `silicon_variant` -- and
|
|
1606
|
+
`tan/planner/orchestrator.py::_slice_flash_recipe` copies them onto the emitted `flash_args`.
|
|
1607
|
+
`slot0_load_address` rides alongside them in the same `args` dict but is sourced differently on
|
|
1608
|
+
purpose: `loader.py::_resolve_slot0_load_address` reads it from the SoM preset's `memory_map:`,
|
|
1609
|
+
NOT from that `debug:` block -- it is SDK/module build POLICY, not a silicon fact
|
|
1610
|
+
(alp-sdk#1069), so two SoMs on the same part can pick different slot0 windows. So a Zephyr
|
|
1611
|
+
slice on a SoC variant that publishes a part-number J-Link profile already carries
|
|
1612
|
+
`FLOW_D_KEYS` on emit and dispatches to Flow D, not Flow A. A slice on a variant with no such
|
|
1613
|
+
profile still emits `args={}` and stays on Flow A, which is correct: that silicon has no J-Link
|
|
1614
|
+
MRAM loader for tan to arm. That used to be most of today's shipped Alif metadata; it no longer
|
|
1615
|
+
is. Measured against alp-sdk `origin/dev` on 2026-09-13: all 10 published Ensemble variants
|
|
1616
|
+
(`metadata/socs/alif/ensemble/e3..e8.json`) carry `jlink_flash_device`, and 1 of those also
|
|
1617
|
+
carries `expect_dpidr` -- a fact about what's published today, not an invariant of the emitter
|
|
1618
|
+
itself. It is also the fact that sizes the gap `docs/setools.md` records under tan-cli#1252:
|
|
1619
|
+
a planner-emitted AEN manifest dispatches Flow D and is covered by the whole-ATOC
|
|
1620
|
+
acknowledgement, so what reaches an ATOC over Flow A is a hand-written or legacy manifest --
|
|
1621
|
+
not the published catalogue, and guarded by the runner's own check (`tan.core.atoc_guard`).
|
|
1622
|
+
"""
|
|
1623
|
+
method = target.flash_method or None
|
|
1624
|
+
if method == "zephyr_west_flash" and flow_d_available(target.flash_args):
|
|
1625
|
+
return FLOW_D_METHOD
|
|
1626
|
+
return method
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def parse_atoc_start_address(text: str) -> str | None:
|
|
1630
|
+
"""The ATOC package's MRAM placement out of an `app-gen-toc`
|
|
1631
|
+
`app-package-map.txt` report -- the LAST `APP Package Start Address:`
|
|
1632
|
+
line's last field, mirroring every bench script's own
|
|
1633
|
+
``awk '/APP Package Start Address:/{print $NF}' app-package-map.txt | tail
|
|
1634
|
+
-1`` byte for byte (last match wins: a re-signed re-run APPENDS a fresh
|
|
1635
|
+
block rather than truncating the file, per
|
|
1636
|
+
`scripts/bench/aen/flash-jlink.sh`/`flash-jlink-mramxip.sh`/
|
|
1637
|
+
`flash-update-log-dual.sh`). `None` when the marker never appears -- an
|
|
1638
|
+
empty, foreign or not-yet-signed file, not a malformed one; the caller
|
|
1639
|
+
decides what that means.
|
|
1640
|
+
|
|
1641
|
+
**tan-cli#373.** `tan.core.setools.sign_slot0` -- the one place this repo
|
|
1642
|
+
WRITES that report -- never deletes it for exactly this reason: an
|
|
1643
|
+
earlier version did, which destroyed every prior entry (this SETOOLS
|
|
1644
|
+
install's whole accumulated sign history) the moment a soft-failing
|
|
1645
|
+
re-sign recreated the file holding only its own block.
|
|
1646
|
+
|
|
1647
|
+
**This is a BUILD-TIME output, never plan-time metadata.** `app-gen-toc`
|
|
1648
|
+
writes the address fresh at signing time and the runbook says outright it
|
|
1649
|
+
SHIFTS per build/config -- no field under `metadata/**` can express it, so
|
|
1650
|
+
parsing this report is the only correct source. See `plan_alif_mram_jlink`
|
|
1651
|
+
for the required/optional split this feeds; the actual file read happens
|
|
1652
|
+
in `tan.commands.flash_cmd` (IO), never here.
|
|
1653
|
+
"""
|
|
1654
|
+
address: str | None = None
|
|
1655
|
+
for line in text.splitlines():
|
|
1656
|
+
if "APP Package Start Address:" not in line:
|
|
1657
|
+
continue
|
|
1658
|
+
fields = line.split()
|
|
1659
|
+
if fields:
|
|
1660
|
+
address = fields[-1]
|
|
1661
|
+
return address
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def is_elf_artefact(artefact: str) -> bool:
|
|
1665
|
+
"""Extension-based, mirroring `is_raw_bin`'s own convention: no extension,
|
|
1666
|
+
`.elf`, or `.out` (case-insensitive) -- the three "plausibly ELF" shapes
|
|
1667
|
+
#367(a) named and #353 agreed are safe to resolve automatically to a
|
|
1668
|
+
same-stem sibling `.bin`. Covers the Zephyr build's own known-good
|
|
1669
|
+
`zephyr.elf`/`zephyr.bin` pair AND a toolchain output named bare (`app`)
|
|
1670
|
+
or `.out` with no `.elf` suffix. Every other shape (a `.hex` carries its
|
|
1671
|
+
own load addresses) is NOT, even when a same-stem `.bin` happens to sit
|
|
1672
|
+
beside it: that could be an unrelated image, and resolving it silently
|
|
1673
|
+
would flash something the manifest never named.
|
|
1674
|
+
|
|
1675
|
+
**tan-cli#373.** A prior version of this function accepted `.elf` only --
|
|
1676
|
+
a narrowing of #367(a)'s own three-shape decision that nothing flagged,
|
|
1677
|
+
since no test exercised the other two.
|
|
1678
|
+
"""
|
|
1679
|
+
return os.path.splitext(artefact)[1].lower() in ("", ".elf", ".out")
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def resolve_slot0_binary(artefact: str, is_file: Callable[[str], bool]) -> str | None:
|
|
1683
|
+
"""The ONE definition of "what raw `.bin` does this slot0 write/sign
|
|
1684
|
+
actually mean" -- shared by [`plan_alif_mram_jlink`] (the `loadbin`/
|
|
1685
|
+
`verifybin` pair) and `tan.commands.flash_cmd
|
|
1686
|
+
._resolve_flow_d_atoc_via_setools` (the SETOOLS `app-gen-toc` sign
|
|
1687
|
+
input), via [`validate_flow_d_shape`], so the two can never resolve
|
|
1688
|
+
DIFFERENT files for the same entry. **#367's root cause**: they used to
|
|
1689
|
+
each carry their own copy of this logic, and neither actually applied the
|
|
1690
|
+
ELF-only restriction its own comment/tests claimed -- a `.hex` resolved
|
|
1691
|
+
to a same-stem sibling `.bin` exactly like an ELF did, silently flashing
|
|
1692
|
+
a different artefact than the manifest named.
|
|
1693
|
+
|
|
1694
|
+
Already a raw `.bin` -> returned unchanged. A plausibly-ELF artefact
|
|
1695
|
+
([`is_elf_artefact`]: no extension, `.elf`, `.out`) with a real
|
|
1696
|
+
same-directory, same-stem `.bin` -> that sibling. Every other shape -- a
|
|
1697
|
+
`.hex`, a plausibly-ELF artefact with no sibling, anything else -- ->
|
|
1698
|
+
`None`; the caller decides how to phrase the refusal.
|
|
1699
|
+
"""
|
|
1700
|
+
if is_raw_bin(artefact):
|
|
1701
|
+
return artefact
|
|
1702
|
+
if not is_elf_artefact(artefact):
|
|
1703
|
+
return None
|
|
1704
|
+
sibling = os.path.splitext(artefact)[0] + ".bin"
|
|
1705
|
+
return sibling if is_file(sibling) else None
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
#: The manifest spelling of the whole-ATOC acknowledgement (tan-cli#1252).
|
|
1709
|
+
#: Deliberately the SAME word upstream's bench scripts use for the flag
|
|
1710
|
+
#: (`--atoc-unqueryable`), so an operator who has read either one recognises
|
|
1711
|
+
#: the other.
|
|
1712
|
+
ATOC_ACK_KEY = "atoc_unqueryable"
|
|
1713
|
+
|
|
1714
|
+
#: The command-line spelling of the same acknowledgement. EXACTLY two spellings
|
|
1715
|
+
#: exist -- this flag and `flash_args.atoc_unqueryable` -- and see
|
|
1716
|
+
#: [`atoc_replacement_acknowledged`] for why there is deliberately no third.
|
|
1717
|
+
ATOC_ACK_FLAG = "--atoc-unqueryable"
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def atoc_replacement_acknowledged(fa: Any, cli_flag: bool) -> bool:
|
|
1721
|
+
"""Has the operator accepted that this Flow D write REPLACES the whole
|
|
1722
|
+
ATOC (tan-cli#1252, porting alp-sdk#2025)? Pure.
|
|
1723
|
+
|
|
1724
|
+
Two spellings, OR-ed: `--atoc-unqueryable` on the command line (`cli_flag`)
|
|
1725
|
+
and `flash_args.atoc_unqueryable: true` in the manifest.
|
|
1726
|
+
|
|
1727
|
+
**Deliberately NO environment variable**, unlike `ALP_FLASH_FORCE` /
|
|
1728
|
+
`ALP_FLASH_REQUIRE_DPIDR` next to it in `flash_cmd`, whose obvious shape
|
|
1729
|
+
invites a third one for symmetry. An env var is exported ONCE into a shell
|
|
1730
|
+
profile or a CI job and then silences this guard forever, on every
|
|
1731
|
+
subsequent run, including the runs nobody is watching -- which is exactly
|
|
1732
|
+
the habit alp-sdk#2025's own header warns about when it says the Flow D
|
|
1733
|
+
flag must never be merged or aliased with Flow A's `--replace-atoc`. The
|
|
1734
|
+
acknowledgement is a statement about THIS write's ATOC, not a property of
|
|
1735
|
+
the host, so it has to be re-stated per invocation (the flag) or recorded
|
|
1736
|
+
against the specific manifest that knows its own boot layout (the key).
|
|
1737
|
+
That is the opposite trade from `ALP_FLASH_REQUIRE_DPIDR`, which IS a
|
|
1738
|
+
property of the host and is therefore an env var on purpose -- see
|
|
1739
|
+
`flash_cmd._require_dpidr_refusal`.
|
|
1740
|
+
|
|
1741
|
+
**Not the confirm gate, and never aliased to it.** `--confirm` means "yes,
|
|
1742
|
+
write"; this means "yes, I accept that the entire ATOC is replaced". A run
|
|
1743
|
+
can legitimately be confirmed and still refuse here.
|
|
1744
|
+
|
|
1745
|
+
`_default(fa_bool_checked(...), False)` -- the same shape
|
|
1746
|
+
`plan_alif_mram_jlink` reads `confirm` with -- makes an ABSENT key and a
|
|
1747
|
+
present-but-null one both read `False`, i.e. both REFUSE. Fail-safe by
|
|
1748
|
+
construction: no manifest shape can accidentally acknowledge. (A
|
|
1749
|
+
present-but-null value is additionally called out as malformed at plan
|
|
1750
|
+
time by `validate_flow_d_shape`, so it never reaches here silently.)
|
|
1751
|
+
"""
|
|
1752
|
+
return cli_flag or _default(fa_bool_checked(fa, ATOC_ACK_KEY), False)
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def atoc_replacement_refusal(method: str, entry_id: str) -> str:
|
|
1756
|
+
"""The refusal text for a confirmed Flow D write that has not acknowledged
|
|
1757
|
+
the whole-ATOC replacement. Pure.
|
|
1758
|
+
|
|
1759
|
+
Carries upstream's facts (alp-sdk#2025, which made
|
|
1760
|
+
`scripts/bench/aen/flash-jlink.sh`, `flash-jlink-hp.sh` and
|
|
1761
|
+
`flash-jlink-mramxip.sh` refuse with exit 8 for the same reason) in tan's
|
|
1762
|
+
own voice, then the two remedies."""
|
|
1763
|
+
return (
|
|
1764
|
+
f"{method}[{entry_id}]: this write REPLACES the ENTIRE ATOC, and Flow D has no "
|
|
1765
|
+
"SE-UART channel to enumerate what is resident first -- so any boot entry already "
|
|
1766
|
+
"in MRAM that this ATOC does not name (an A32 boot chain, an HP app, a diagnostic "
|
|
1767
|
+
"image) is silently DELISTED, and the SES still prints \"[SES] ATOC ok\" "
|
|
1768
|
+
"afterwards. Refusing until that is acknowledged: pass "
|
|
1769
|
+
f"{ATOC_ACK_FLAG} on the command line, or set flash_args.{ATOC_ACK_KEY}: true in "
|
|
1770
|
+
"the manifest. --confirm does NOT acknowledge this -- it only means \"yes, write\" "
|
|
1771
|
+
"(alp-sdk#2025, tan-cli#1252)."
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
#: Appended to every Flow D PREVIEW message (tan-cli#1252). A preview must stay
|
|
1776
|
+
#: a preview -- the refusal above fires only on a write that would really
|
|
1777
|
+
#: proceed -- but the operator has to learn what arming the write means BEFORE
|
|
1778
|
+
#: they arm it, and a preview is the one moment they are reading.
|
|
1779
|
+
ATOC_REPLACEMENT_PREVIEW_NOTE = (
|
|
1780
|
+
"NOTE: a real write REPLACES the ENTIRE ATOC -- Flow D cannot enumerate what is "
|
|
1781
|
+
"resident, so any boot entry this ATOC does not name is silently delisted and the SES "
|
|
1782
|
+
f"still reports \"[SES] ATOC ok\". Arming the write therefore also needs {ATOC_ACK_FLAG} "
|
|
1783
|
+
f"(or flash_args.{ATOC_ACK_KEY}: true) alongside the confirm gate "
|
|
1784
|
+
"(alp-sdk#2025, tan-cli#1252)."
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
@dataclass(frozen=True)
|
|
1789
|
+
class FlowDShape:
|
|
1790
|
+
"""Everything about a Flow D entry that is knowable WITHOUT `atoc`/
|
|
1791
|
+
`atoc_address` -- i.e. before SETOOLS may need to sign one. `artefact` is
|
|
1792
|
+
already resolved via [`resolve_slot0_binary`] when `app_address` is set;
|
|
1793
|
+
callers must use THIS value, not the raw input artefact, for both the
|
|
1794
|
+
SETOOLS sign input and the final loadbin/verifybin.
|
|
1795
|
+
"""
|
|
1796
|
+
|
|
1797
|
+
device: str
|
|
1798
|
+
app_address: str | None
|
|
1799
|
+
artefact: str
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def validate_flow_d_shape(fa: Any, artefact: str, is_file: Callable[[str], bool]) -> FlowDShape:
|
|
1803
|
+
"""Validate + resolve every Flow D input that does NOT depend on `atoc`/
|
|
1804
|
+
`atoc_address` -- the part-number device profile and, when
|
|
1805
|
+
`slot0_load_address` selects the mramxip shape, the artefact itself.
|
|
1806
|
+
|
|
1807
|
+
**The single source `tan.commands.flash_cmd._flash_entry` now calls
|
|
1808
|
+
BEFORE ever considering a SETOOLS auto-sign or a `--dry-run` preview
|
|
1809
|
+
(#366).** `atoc`/`atoc_address` are legitimately still absent at that
|
|
1810
|
+
point -- SETOOLS is what is about to produce them -- so this cannot
|
|
1811
|
+
validate the WHOLE entry; splitting out exactly the half that CAN be
|
|
1812
|
+
checked early means a manifest that would fail here can no longer report
|
|
1813
|
+
`ok:true` on a SETOOLS `--dry-run` preview just because the failure used
|
|
1814
|
+
to live only in the (until-then unreached) second half of
|
|
1815
|
+
`plan_alif_mram_jlink`.
|
|
1816
|
+
|
|
1817
|
+
Also called BY `plan_alif_mram_jlink` itself -- there remains exactly ONE
|
|
1818
|
+
implementation of these checks; calling it twice on the real-write path
|
|
1819
|
+
is pure/side-effect-free, so the repeat costs nothing.
|
|
1820
|
+
"""
|
|
1821
|
+
device = fa_str_checked(fa, "jlink_flash_device", False)
|
|
1822
|
+
if device is None:
|
|
1823
|
+
if _fa_has_key(fa, "jlink_flash_device"):
|
|
1824
|
+
raise FlashPlanError(
|
|
1825
|
+
f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is present but "
|
|
1826
|
+
"null/empty -- refusing to write MRAM with no part-number J-Link "
|
|
1827
|
+
"device profile; the generic profile has none. It is a per-variant "
|
|
1828
|
+
"metadata fact (socs/**/*.json `variants[].debug.jlink_flash_device`); "
|
|
1829
|
+
"tan does not guess a part number."
|
|
1830
|
+
)
|
|
1831
|
+
raise FlashPlanError(
|
|
1832
|
+
f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is required -- only the "
|
|
1833
|
+
"part-number J-Link device profile unlocks the MRAM loader, and the "
|
|
1834
|
+
"generic profile has none. It is a per-variant metadata fact "
|
|
1835
|
+
"(socs/**/*.json `variants[].debug.jlink_flash_device`); tan does not "
|
|
1836
|
+
"guess a part number."
|
|
1837
|
+
)
|
|
1838
|
+
validate_identifier(device, "jlink_flash_device")
|
|
1839
|
+
# OPTIONAL -- selects the mramxip two-blob shape when present; the default
|
|
1840
|
+
# single-ATOC-blob shape (flash-jlink.sh) needs no app-address write at
|
|
1841
|
+
# all, since the ATOC embeds the app. `None` only for genuinely absent; a
|
|
1842
|
+
# present-but-malformed value still raises below, never silently reverts
|
|
1843
|
+
# to the default shape.
|
|
1844
|
+
#
|
|
1845
|
+
# `fa_str_checked` alone cannot tell "key absent" from "key present with a
|
|
1846
|
+
# null/empty-string value" -- both collapse to `None` (`raw or None` at
|
|
1847
|
+
# line ~571). A key that IS present must still refuse when it resolves to
|
|
1848
|
+
# `None`: a `slot0_load_address: ""` or a bare `slot0_load_address:` (YAML
|
|
1849
|
+
# null) must never silently pick the default shape, exactly like any other
|
|
1850
|
+
# malformed value.
|
|
1851
|
+
app_address = fa_str_checked(fa, "slot0_load_address", True)
|
|
1852
|
+
if app_address is None and _fa_has_key(fa, "slot0_load_address"):
|
|
1853
|
+
raise FlashPlanError(
|
|
1854
|
+
f"{FLOW_D_METHOD}: flash_args.slot0_load_address is present but "
|
|
1855
|
+
"null/empty -- refusing to silently select the default "
|
|
1856
|
+
"single-ATOC-blob shape. Remove the key entirely to use the default "
|
|
1857
|
+
"shape, or supply the app's real MRAM address to select the mramxip "
|
|
1858
|
+
"two-blob shape."
|
|
1859
|
+
)
|
|
1860
|
+
resolved_artefact = artefact
|
|
1861
|
+
if app_address is not None:
|
|
1862
|
+
validate_address(app_address, "slot0_load_address")
|
|
1863
|
+
# The mramxip shape `loadbin`s the app blob at an explicit MRAM
|
|
1864
|
+
# address -- correct ONLY for a raw `.bin`. `loadbin`ing anything else
|
|
1865
|
+
# (e.g. `zephyr.elf`) at that address writes the artefact's own
|
|
1866
|
+
# headers into MRAM instead of the app image (tan-cli#311). There is no
|
|
1867
|
+
# ELF/HEX fallback to `loadfile` here: it ignores `slot0_load_address` entirely,
|
|
1868
|
+
# which would silently place the app wherever the ELF's own load
|
|
1869
|
+
# addresses say rather than where this flow demands -- a refusal is
|
|
1870
|
+
# the safer failure. tan-cli#353 resolves a real ELF/sibling-`.bin`
|
|
1871
|
+
# pair rather than refusing over something resolvable;
|
|
1872
|
+
# [`resolve_slot0_binary`] is the ONE place that decides which shapes
|
|
1873
|
+
# qualify (#367).
|
|
1874
|
+
resolved = resolve_slot0_binary(artefact, is_file)
|
|
1875
|
+
if resolved is None:
|
|
1876
|
+
if is_elf_artefact(artefact):
|
|
1877
|
+
detail = (
|
|
1878
|
+
"No sibling "
|
|
1879
|
+
f"{os.path.basename(os.path.splitext(artefact)[0] + '.bin')} "
|
|
1880
|
+
"was found beside it either."
|
|
1881
|
+
)
|
|
1882
|
+
else:
|
|
1883
|
+
detail = (
|
|
1884
|
+
"Only a plausibly-ELF artefact's (no extension, .elf, .out) "
|
|
1885
|
+
"same-stem sibling .bin is resolved automatically -- a .hex "
|
|
1886
|
+
"(or any other shape) is not, even with a same-stem .bin "
|
|
1887
|
+
"beside it, since that could be an unrelated image."
|
|
1888
|
+
)
|
|
1889
|
+
raise FlashPlanError(
|
|
1890
|
+
f"{FLOW_D_METHOD}: flash_args.slot0_load_address is set but the "
|
|
1891
|
+
f"artefact {artefact} is not a raw .bin -- refusing to loadbin "
|
|
1892
|
+
"it at slot0_load_address, which would write the artefact's own "
|
|
1893
|
+
f"headers into MRAM instead of the app image. {detail} Point the "
|
|
1894
|
+
"build's output_artefact at the slot0-linked zephyr.bin for the "
|
|
1895
|
+
"mramxip shape."
|
|
1896
|
+
)
|
|
1897
|
+
resolved_artefact = resolved
|
|
1898
|
+
# tan-cli#373: `jlink_speed`/`confirm` do not depend on `atoc`/`atoc_address`
|
|
1899
|
+
# either, so they belong in the "everything checkable early" half this
|
|
1900
|
+
# function IS -- but #366 only moved `jlink_flash_device`/
|
|
1901
|
+
# `slot0_load_address` here, leaving these two still validated only deep
|
|
1902
|
+
# inside `plan_alif_mram_jlink`. That left #366's own fix narrowed, not
|
|
1903
|
+
# closed: `_resolve_flow_d_atoc_via_setools`'s `--dry-run` preview short-
|
|
1904
|
+
# circuits BEFORE `plan_alif_mram_jlink` ever runs (measured: a manifest
|
|
1905
|
+
# with `jlink_speed: "fast"` and SETOOLS resolving reported `ok:true`
|
|
1906
|
+
# under `--dry-run`), and on a REAL (confirmed) run the SETOOLS auto-sign
|
|
1907
|
+
# itself -- spawning `app-gen-toc`, writing into the customer's install --
|
|
1908
|
+
# happens before `plan_alif_mram_jlink` ever gets a chance to refuse.
|
|
1909
|
+
# Validating (and discarding the result -- `plan_alif_mram_jlink` still
|
|
1910
|
+
# applies its own default) here closes that gap regardless of which path
|
|
1911
|
+
# the entry takes afterward.
|
|
1912
|
+
fa_int_checked(fa, "jlink_speed")
|
|
1913
|
+
fa_bool_checked(fa, "confirm")
|
|
1914
|
+
# tan-cli#1252: the whole-ATOC acknowledgement is the same class of key as
|
|
1915
|
+
# the two above -- it does not depend on `atoc`/`atoc_address` either -- so
|
|
1916
|
+
# its SHAPE is validated (and the result discarded; the guard reads it
|
|
1917
|
+
# again at the one place that decides) here, for the reason the #373
|
|
1918
|
+
# comment above gives: otherwise `atoc_unqueryable: "yes"` would report
|
|
1919
|
+
# `ok:true` from `tan flash --dry-run` and, on a real run, only be refused
|
|
1920
|
+
# AFTER the SETOOLS auto-sign had already written into the customer's
|
|
1921
|
+
# install. `fa_bool_checked` raises for every non-bool shape.
|
|
1922
|
+
#
|
|
1923
|
+
# The present-but-NULL case needs the explicit `_fa_has_key` pairing, the
|
|
1924
|
+
# same way `slot0_load_address` above does: `fa_bool_checked` collapses a
|
|
1925
|
+
# bare `atoc_unqueryable:` and an absent key to the same `None`. Both
|
|
1926
|
+
# REFUSE the write either way -- `atoc_replacement_acknowledged` is
|
|
1927
|
+
# fail-safe -- but an operator who TRIED to acknowledge and mistyped it
|
|
1928
|
+
# deserves to be told the value is malformed, at plan time, rather than
|
|
1929
|
+
# reading a refusal that says they never acknowledged at all.
|
|
1930
|
+
if _fa_has_key(fa, ATOC_ACK_KEY) and fa_bool_checked(fa, ATOC_ACK_KEY) is None:
|
|
1931
|
+
raise FlashPlanError(
|
|
1932
|
+
f"{FLOW_D_METHOD}: flash_args.{ATOC_ACK_KEY} is present but null/empty -- "
|
|
1933
|
+
"refusing to read that as a whole-ATOC acknowledgement, and refusing to "
|
|
1934
|
+
f"read it as an absent one. Write `{ATOC_ACK_KEY}: true` (a bare, unquoted "
|
|
1935
|
+
"boolean) to acknowledge that this write replaces the entire ATOC, or "
|
|
1936
|
+
"remove the key entirely (tan-cli#1252)."
|
|
1937
|
+
)
|
|
1938
|
+
# tan-cli#486 review: `jlink_serial` is the same class of "checkable
|
|
1939
|
+
# early" field as `jlink_speed`/`confirm` just above -- it does not
|
|
1940
|
+
# depend on `atoc`/`atoc_address` either -- but was left validated only
|
|
1941
|
+
# deep inside `plan_alif_mram_jlink`/`flow_d_preflight_script`, which is
|
|
1942
|
+
# exactly the #373 gap those two comments describe: a hostile
|
|
1943
|
+
# `jlink_serial` (e.g. embedded newlines forming extra Commander
|
|
1944
|
+
# commands) reported `ok:true` from `tan flash --dry-run`, and on a real
|
|
1945
|
+
# confirmed run reached the customer's SETOOLS install via
|
|
1946
|
+
# `_resolve_flow_d_atoc_via_setools` -- which runs BEFORE this
|
|
1947
|
+
# function -- before ever being refused. Validating (and discarding the
|
|
1948
|
+
# result, like the two lines above) here closes that gap regardless of
|
|
1949
|
+
# which path the entry takes afterward; the late call sites keep their
|
|
1950
|
+
# own check too, defensively, since it is pure and costs nothing to repeat.
|
|
1951
|
+
serial = fa_str_checked(fa, "jlink_serial", False)
|
|
1952
|
+
if serial is not None:
|
|
1953
|
+
validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
|
|
1954
|
+
return FlowDShape(device=device, app_address=app_address, artefact=resolved_artefact)
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def plan_alif_mram_jlink(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
|
|
1958
|
+
"""Flow D: burn the signed ATOC into MRAM over SWD with J-Link's built-in
|
|
1959
|
+
Alif MRAM loader, verify it, then PIN-reset so the Secure Enclave boot ROM
|
|
1960
|
+
boots the image -- the same blob(s) at the same addresses SETOOLS writes
|
|
1961
|
+
over the SE-UART, so no re-signing and no keys.
|
|
1962
|
+
|
|
1963
|
+
**Two shapes, selected from data, matching the two bench scripts they
|
|
1964
|
+
port** (`scripts/bench/aen/flash-jlink.sh` / `flash-jlink-mramxip.sh`):
|
|
1965
|
+
|
|
1966
|
+
* **Default -- single ATOC blob.** The day-to-day flow
|
|
1967
|
+
(`flash-jlink.sh`): the ATOC is self-contained (an ITCM-load package,
|
|
1968
|
+
its own embedded load address set by `app-gen-toc`), so ONE
|
|
1969
|
+
`loadbin`/`verifybin` of `atoc` at `atoc_address` is the whole write.
|
|
1970
|
+
This is what runs whenever `flash_args` omits `slot0_load_address`.
|
|
1971
|
+
* **mramxip -- two blobs.** The ITCM-overflow exception
|
|
1972
|
+
(`flash-jlink-mramxip.sh`), for an app LINKED into MRAM slot0 (built
|
|
1973
|
+
with `CONFIG_USE_DT_CODE_PARTITION=y`, a per-app-build opt-in tan does
|
|
1974
|
+
not set): the app blob itself also needs writing, to `slot0_load_address`,
|
|
1975
|
+
ahead of the ATOC. This activates only when `flash_args.slot0_load_address`
|
|
1976
|
+
is present -- tan cannot detect the Kconfig opt-in from here, so a
|
|
1977
|
+
manifest that arms the mramxip shape must supply the address that
|
|
1978
|
+
proves it was built that way.
|
|
1979
|
+
|
|
1980
|
+
**Every identifier is read from `flash_args`; none is baked in.** Required
|
|
1981
|
+
in both shapes:
|
|
1982
|
+
|
|
1983
|
+
* `jlink_flash_device` -- the PART-NUMBER device profile. Only this unlocks
|
|
1984
|
+
the loader; with a generic `Cortex-M55` profile there is no loader and
|
|
1985
|
+
`loadbin` to MRAM does nothing useful. It is also the wrong profile for
|
|
1986
|
+
attaching to a live core, which is why it is a distinct metadata key
|
|
1987
|
+
(`jlink_flash_device`, not `jlink_device`) on the SoC spec.
|
|
1988
|
+
* `atoc` + `atoc_address` -- the signed ATOC blob and its MRAM placement.
|
|
1989
|
+
The address SHIFTS per build/config and the runbook says outright not to
|
|
1990
|
+
hardcode it -- it is a BUILD-TIME output of the signing step, never a
|
|
1991
|
+
metadata fact, so this function still requires it as a plain
|
|
1992
|
+
`flash_args` value and REFUSES when it is absent. tan does NOT run
|
|
1993
|
+
`app-gen-toc` here either way: signing is common to both flows and
|
|
1994
|
+
belongs to whatever produced the ATOC. What changed is only WHO fills
|
|
1995
|
+
`atoc_address` in before this function runs -- `tan.commands.flash_cmd`
|
|
1996
|
+
resolves it from `flash_args.atoc_map` (an `app-package-map.txt` path)
|
|
1997
|
+
via `parse_atoc_start_address` when the manifest supplies that instead
|
|
1998
|
+
of a baked-in address, so a customer's manifest never has to hardcode a
|
|
1999
|
+
value that changes every build. `atoc` itself is read here VERBATIM --
|
|
2000
|
+
this module has no filesystem access to resolve it against -- so
|
|
2001
|
+
`tan.commands.flash_cmd` also anchors it on `build_root`/`sdk_root`
|
|
2002
|
+
(`resolve_artefact_path`, the same resolver `output_artefact`/
|
|
2003
|
+
`atoc_map` use) before this function ever sees it; a caller that skips
|
|
2004
|
+
that step hands this function a path relative to WHATEVER the eventual
|
|
2005
|
+
spawn's cwd turns out to be, not the build root.
|
|
2006
|
+
|
|
2007
|
+
Optional, mramxip-only:
|
|
2008
|
+
|
|
2009
|
+
* `slot0_load_address` -- where the slot0-linked app itself sits, so the SE boots
|
|
2010
|
+
it in place rather than loading it out of the ATOC. Present but
|
|
2011
|
+
malformed is still a loud refusal, never a silent fall-back to the
|
|
2012
|
+
default shape -- a quoting detail must never decide which shape burns.
|
|
2013
|
+
|
|
2014
|
+
Absent a required identifier this REFUSES. There is no default to fall
|
|
2015
|
+
back to: a guessed MRAM address is a write to the wrong place on a part
|
|
2016
|
+
whose Secure Enclave then boots whatever is there.
|
|
2017
|
+
|
|
2018
|
+
Confirm-gated (`flash_args.confirm` OR `ALP_FLASH_FORCE=1`) like the other
|
|
2019
|
+
two persistent-device backends -- see the `planning_only` note below.
|
|
2020
|
+
"""
|
|
2021
|
+
fa = inp.flash_args
|
|
2022
|
+
shape = validate_flow_d_shape(fa, inp.artefact, os.path.isfile)
|
|
2023
|
+
device, app_address, artefact = shape.device, shape.app_address, shape.artefact
|
|
2024
|
+
|
|
2025
|
+
atoc = fa_str(fa, "atoc")
|
|
2026
|
+
atoc_address = fa_str_checked(fa, "atoc_address", True)
|
|
2027
|
+
if atoc is None or atoc_address is None:
|
|
2028
|
+
raise FlashPlanError(
|
|
2029
|
+
f"{FLOW_D_METHOD}: flash_args.atoc (the signed ATOC blob) and "
|
|
2030
|
+
"flash_args.atoc_address are both required. Both flows burn the SAME "
|
|
2031
|
+
"signed ATOC -- sign it with the SETOOLS `app-gen-toc` step and pass the "
|
|
2032
|
+
"blob plus the placement its own report prints; the addresses shift per "
|
|
2033
|
+
"build and must not be hardcoded."
|
|
2034
|
+
)
|
|
2035
|
+
validate_address(atoc_address, "atoc_address")
|
|
2036
|
+
|
|
2037
|
+
speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED)
|
|
2038
|
+
# Probe serial: the ONLY disambiguator when a bench carries more than one
|
|
2039
|
+
# J-Link. No default -- a bench-wide serial can be shared by two probes that
|
|
2040
|
+
# differ only by USB path, and a silent default can select the wrong board.
|
|
2041
|
+
# `fa_str_checked`, not the tolerant `fa_str` (tan-cli#486): a NUMERIC
|
|
2042
|
+
# serial -- the canonical SEGGER spelling -- is a bare YAML integer, which
|
|
2043
|
+
# `fa_str` treats as "absent" and silently drops the SelectEmuBySN line
|
|
2044
|
+
# on a bench with more than one probe attached. `validate_identifier`
|
|
2045
|
+
# then closes the same newline-injection hole `jlink_flash_device` above
|
|
2046
|
+
# is already guarded against: `serial` is interpolated verbatim into
|
|
2047
|
+
# `SelectEmuBySN {serial}`, a J-Link Commander script LINE. Also already
|
|
2048
|
+
# validated by `validate_flow_d_shape` above (tan-cli#486 review, #373
|
|
2049
|
+
# gap) -- repeated here defensively; the check is pure and this is the
|
|
2050
|
+
# value actually used below.
|
|
2051
|
+
serial = fa_str_checked(fa, "jlink_serial", False)
|
|
2052
|
+
if serial is not None:
|
|
2053
|
+
validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
|
|
2054
|
+
# The expected SW-DP IDR. When the manifest supplies one, the Commander
|
|
2055
|
+
# script connects with the READ profile first and the caller ABORTS unless
|
|
2056
|
+
# that ID appears -- writing MRAM on the wrong attached board is the one
|
|
2057
|
+
# unrecoverable mistake this path can make. A hardware value, so it comes
|
|
2058
|
+
# from data: tan neither knows nor invents an IDR.
|
|
2059
|
+
# tan-cli#795: `as_hex_address=True` -- an unquoted YAML `expect_dpidr:
|
|
2060
|
+
# 0x0BE12477` must round-trip back to `0x0BE12477`, the way `base` /
|
|
2061
|
+
# `atoc_address` / `slot0_load_address` already do, not decay to the
|
|
2062
|
+
# decimal string `199304311` and refuse the CORRECT board.
|
|
2063
|
+
expect_dpidr = fa_str_checked(fa, "expect_dpidr", True)
|
|
2064
|
+
if expect_dpidr is not None:
|
|
2065
|
+
validate_address(expect_dpidr, "expect_dpidr")
|
|
2066
|
+
|
|
2067
|
+
jlink = _JLINK_BINARIES[0] if inp.dry_run else next(
|
|
2068
|
+
(n for n in _JLINK_BINARIES if which(n)), None
|
|
2069
|
+
)
|
|
2070
|
+
if jlink is None:
|
|
2071
|
+
raise FlashPlanError(
|
|
2072
|
+
f"{FLOW_D_METHOD}: needs SEGGER J-Link on PATH (JLinkExe/JLink), on a "
|
|
2073
|
+
"V9.46+ DLL with the probe on matched firmware -- the built-in Alif MRAM "
|
|
2074
|
+
"loader ships with the DLL and older ones cannot connect with the "
|
|
2075
|
+
"part-number device profile."
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
# Two-blob mramxip shape only when `slot0_load_address` armed it; otherwise the
|
|
2079
|
+
# default single-ATOC-blob shape (flash-jlink.sh) writes nothing for the
|
|
2080
|
+
# app -- the ATOC already embeds it. See the docstring's "two shapes" note.
|
|
2081
|
+
lines: list[str] = []
|
|
2082
|
+
if serial is not None:
|
|
2083
|
+
lines.append(f"SelectEmuBySN {serial}")
|
|
2084
|
+
else:
|
|
2085
|
+
# tan-cli#353: no serial pinned, so this script selects no probe. Fine
|
|
2086
|
+
# on a single-probe host; on a bench with several J-Links JLinkExe
|
|
2087
|
+
# cannot choose and answers "Connecting to J-Link ...FAILED: Cannot
|
|
2088
|
+
# connect to the probe/programmer." -- measured on the AEN bench, which
|
|
2089
|
+
# carries three. Recorded here so the failure diagnosis can SAY that
|
|
2090
|
+
# instead of leaving the user with SEGGER's bare sentence; the plan
|
|
2091
|
+
# itself is unchanged, because refusing would break every correct
|
|
2092
|
+
# single-probe host.
|
|
2093
|
+
pass
|
|
2094
|
+
# tan-cli#486: quoting (below) is not escaping -- a newline embedded in
|
|
2095
|
+
# either path still ends the quoted string's own Commander LINE and
|
|
2096
|
+
# starts a new, attacker-chosen one. Validated on the UNQUOTED value,
|
|
2097
|
+
# same as `jlink_commander_script`'s own artefact guard.
|
|
2098
|
+
validate_commander_path(artefact, "the flash artefact path")
|
|
2099
|
+
validate_commander_path(atoc, "flash_args.atoc")
|
|
2100
|
+
# Quoted (tan-cli#369) ONLY for these Commander-script lines, when either
|
|
2101
|
+
# actually contains whitespace -- `artefact`/`atoc` themselves are left
|
|
2102
|
+
# unquoted for `ok_message` and every other use above.
|
|
2103
|
+
commander_artefact = commander_path(artefact)
|
|
2104
|
+
commander_atoc = commander_path(atoc)
|
|
2105
|
+
apps: list[tuple[str, str]] = []
|
|
2106
|
+
if app_address is not None:
|
|
2107
|
+
# `artefact`, not `inp.artefact`: the tan-cli#353 sibling resolution
|
|
2108
|
+
# above may have swapped an ELF for its real raw `.bin`, and the
|
|
2109
|
+
# write must use what was RESOLVED or the guard would be decorative.
|
|
2110
|
+
apps.append((commander_artefact, app_address))
|
|
2111
|
+
for _extra_id, extra_bin, extra_address in inp.extra_apps:
|
|
2112
|
+
validate_commander_path(extra_bin, "the flash artefact path")
|
|
2113
|
+
validate_address(extra_address, "slot0_load_address")
|
|
2114
|
+
apps.append((commander_path(extra_bin), extra_address))
|
|
2115
|
+
lines += ["si SWD", f"speed {speed}", f"device {device}", "connect"]
|
|
2116
|
+
lines += [f"loadbin {path} {addr}" for path, addr in apps]
|
|
2117
|
+
lines.append(f"loadbin {commander_atoc} {atoc_address}")
|
|
2118
|
+
lines += [f"verifybin {path} {addr}" for path, addr in apps]
|
|
2119
|
+
lines += [
|
|
2120
|
+
f"verifybin {commander_atoc} {atoc_address}",
|
|
2121
|
+
# PIN reset (RSetType 2), then run: the Secure Enclave boot ROM re-reads
|
|
2122
|
+
# and boots the ATOC, exactly as it does after an SE-UART burn. A core
|
|
2123
|
+
# reset would leave the SE out of the loop.
|
|
2124
|
+
"RSetType 2",
|
|
2125
|
+
"r",
|
|
2126
|
+
"g",
|
|
2127
|
+
"exit",
|
|
2128
|
+
]
|
|
2129
|
+
argv = (
|
|
2130
|
+
jlink, "-device", device, "-if", "SWD", "-speed", str(speed),
|
|
2131
|
+
"-ExitOnError", "1", "-NoGui", "1", "-CommanderScript",
|
|
2132
|
+
)
|
|
2133
|
+
confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
|
|
2134
|
+
ok_message = (
|
|
2135
|
+
f"{FLOW_D_METHOD}[{inp.core_id}]: app -> {app_address}"
|
|
2136
|
+
+ "".join(f", {i} app -> {a}" for i, _b, a in inp.extra_apps)
|
|
2137
|
+
+ f", signed ATOC -> {atoc_address} via J-Link ({device}); cache-verified and PIN-reset"
|
|
2138
|
+
if app_address is not None
|
|
2139
|
+
else (
|
|
2140
|
+
f"{FLOW_D_METHOD}[{inp.core_id}]: signed ATOC (app embedded) -> "
|
|
2141
|
+
f"{atoc_address} via J-Link ({device}); cache-verified and PIN-reset"
|
|
2142
|
+
)
|
|
2143
|
+
)
|
|
2144
|
+
return FlashPlan(
|
|
2145
|
+
argv=argv,
|
|
2146
|
+
ok_message=ok_message,
|
|
2147
|
+
# `planning_only` -- and therefore the `planned` status + the
|
|
2148
|
+
# `flash.confirm-required` warning -- for an UNCONFIRMED run, matching
|
|
2149
|
+
# `yocto_wic`/`xspi_flashwriter`. This is a NEW backend, so nothing in
|
|
2150
|
+
# the oracle is being diverged from, and it is the only backend in the
|
|
2151
|
+
# registry that persistently programs on-die MRAM: a `tan flash` in a
|
|
2152
|
+
# fresh customer's checkout must not silently reprogram an attached
|
|
2153
|
+
# module. A backend targeting an external helper MCU's own flash
|
|
2154
|
+
# rather than persistent on-die storage would not need this gate.
|
|
2155
|
+
planning_only=inp.dry_run or not confirm,
|
|
2156
|
+
jlink_script="\n".join(lines) + "\n",
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def validate_flow_d_preflight_args(
|
|
2161
|
+
flash_args: Any, method: str = FLOW_D_METHOD, *, require_device_key: bool = True
|
|
2162
|
+
) -> tuple[str | None, str | None]:
|
|
2163
|
+
"""The presence/pairing/shape checks for the read-only DPIDR preflight,
|
|
2164
|
+
returning `(expect_dpidr, jlink_device)` -- both `None` (opted out) or
|
|
2165
|
+
both set (validated), UNLESS `require_device_key` is `False` (see below),
|
|
2166
|
+
in which case the second slot is always `None`. Raises `FlashPlanError`
|
|
2167
|
+
for every half-armed or malformed shape; never touches a J-Link binary or
|
|
2168
|
+
builds the Commander script, so the CALLER decides when to run it.
|
|
2169
|
+
`flow_d_preflight_script` (write-path) runs it then builds the script
|
|
2170
|
+
from the same values; `tan.commands.flash_cmd` also runs it PLAN-TIME,
|
|
2171
|
+
before the confirm/dry-run gate, so a half-armed or malformed manifest
|
|
2172
|
+
surfaces as a `flash.entry-failed` issue in the planned envelope too --
|
|
2173
|
+
not only at real-write time.
|
|
2174
|
+
|
|
2175
|
+
Written to be shared by more than one backend, not one copy per backend
|
|
2176
|
+
(tan-cli#520): the caller is Flow D (`alif_mram_jlink`) today, with
|
|
2177
|
+
`method` interpolated ONLY into the refusal text -- it names which
|
|
2178
|
+
backend's `flash_args` a customer needs to fix, since a manifest could
|
|
2179
|
+
otherwise not tell which entry a bare "flash_args.expect_dpidr" refusal
|
|
2180
|
+
was about. Defaults to `FLOW_D_METHOD` so every existing Flow D call site
|
|
2181
|
+
keeps its exact wording unchanged. `swd_probe`'s J-Link arm was the second
|
|
2182
|
+
caller (`method="swd_probe"`, `require_device_key=False`) until tan-cli#732
|
|
2183
|
+
removed the backend; the parameter stays for a future J-Link-arm backend
|
|
2184
|
+
with the same shape.
|
|
2185
|
+
|
|
2186
|
+
`require_device_key` (default `True`, Flow D's own shape, unchanged):
|
|
2187
|
+
whether `expect_dpidr` must be PAIRED with a `flash_args.jlink_device`
|
|
2188
|
+
naming the live-core read attach profile, the way Flow D needs it
|
|
2189
|
+
(Flow D's `jlink_device` is DISTINCT from its write-time flash-algorithm
|
|
2190
|
+
profile, `jlink_flash_device`). A backend whose `flash_args.jlink_device`
|
|
2191
|
+
already means the write's own `-device` profile -- `swd_probe` was one --
|
|
2192
|
+
passes `False`: pairing that field with `expect_dpidr` the way Flow D's
|
|
2193
|
+
DIFFERENT field is paired would retroactively demand a preflight of every
|
|
2194
|
+
manifest that only ever set the write device. With
|
|
2195
|
+
`require_device_key=False`, `expect_dpidr` ALONE arms the preflight and
|
|
2196
|
+
the second returned slot is always `None`; the caller supplies its own
|
|
2197
|
+
read device via `FlashPlan.preflight_device`.
|
|
2198
|
+
|
|
2199
|
+
`None`/`None` (or `expect_dpidr`/`None` under `require_device_key=False`)
|
|
2200
|
+
only for a genuinely ABSENT `expect_dpidr` -- the documented, test-pinned
|
|
2201
|
+
way to opt out of the preflight entirely. A key that IS present must
|
|
2202
|
+
still refuse when it resolves to `None` (`fa_str_checked` alone cannot
|
|
2203
|
+
tell "absent" from "present but null/empty"; see `_fa_has_key`'s
|
|
2204
|
+
docstring): silently treating it as absent would drop the SW-DP IDR check
|
|
2205
|
+
-- the one guard standing between a wrong-board attach and an MRAM/GD32
|
|
2206
|
+
write -- with no diagnostic at all.
|
|
2207
|
+
"""
|
|
2208
|
+
fa = flash_args
|
|
2209
|
+
# tan-cli#795: `as_hex_address=True` -- same round-trip fix as
|
|
2210
|
+
# `plan_alif_mram_jlink` above; a bare YAML integer must not decay to a
|
|
2211
|
+
# decimal string that then fails to match the probe's hex-formatted banner
|
|
2212
|
+
# on the CORRECT board.
|
|
2213
|
+
expected = fa_str_checked(fa, "expect_dpidr", True)
|
|
2214
|
+
if expected is None and _fa_has_key(fa, "expect_dpidr"):
|
|
2215
|
+
raise FlashPlanError(
|
|
2216
|
+
f"{method}: flash_args.expect_dpidr is present but null/empty -- "
|
|
2217
|
+
"refusing to silently skip the pre-write SW-DP IDR check. Remove the key "
|
|
2218
|
+
"entirely to skip the preflight, or supply the board's real expected ID."
|
|
2219
|
+
)
|
|
2220
|
+
if not require_device_key:
|
|
2221
|
+
if expected is None:
|
|
2222
|
+
return None, None
|
|
2223
|
+
validate_address(expected, "expect_dpidr")
|
|
2224
|
+
_validate_expect_dpidr_width(expected, method)
|
|
2225
|
+
return expected, None
|
|
2226
|
+
read_device = fa_str_checked(fa, "jlink_device", False)
|
|
2227
|
+
if read_device is None and _fa_has_key(fa, "jlink_device"):
|
|
2228
|
+
raise FlashPlanError(
|
|
2229
|
+
f"{method}: flash_args.jlink_device is present but null/empty -- "
|
|
2230
|
+
"refusing to silently skip the pre-write SW-DP IDR check. Remove the key "
|
|
2231
|
+
"entirely to skip the preflight, or supply the live-core read device "
|
|
2232
|
+
"profile."
|
|
2233
|
+
)
|
|
2234
|
+
# Half-armed by a genuinely ABSENT partner key (not a null one -- that is
|
|
2235
|
+
# the two checks above): supplying `expect_dpidr` is the manifest's
|
|
2236
|
+
# unambiguous statement that it wanted the wrong-board guard armed, and
|
|
2237
|
+
# the reverse holds for `jlink_device`. Silently returning `None` here
|
|
2238
|
+
# would drop the SW-DP IDR check with no diagnostic at all, immediately
|
|
2239
|
+
# before the one write this backend's own docstring calls unrecoverable.
|
|
2240
|
+
if (expected is None) != (read_device is None):
|
|
2241
|
+
present_key, absent_key = (
|
|
2242
|
+
("expect_dpidr", "jlink_device")
|
|
2243
|
+
if expected is not None
|
|
2244
|
+
else ("jlink_device", "expect_dpidr")
|
|
2245
|
+
)
|
|
2246
|
+
raise FlashPlanError(
|
|
2247
|
+
f"{method}: flash_args.{present_key} is present but flash_args."
|
|
2248
|
+
f"{absent_key} is not -- refusing to silently skip the pre-write SW-DP "
|
|
2249
|
+
"IDR check. Supply both flash_args.expect_dpidr and flash_args."
|
|
2250
|
+
"jlink_device to arm the preflight, or remove both to skip it entirely."
|
|
2251
|
+
)
|
|
2252
|
+
if expected is None or read_device is None:
|
|
2253
|
+
return None, None
|
|
2254
|
+
validate_address(expected, "expect_dpidr")
|
|
2255
|
+
_validate_expect_dpidr_width(expected, method)
|
|
2256
|
+
validate_identifier(read_device, "jlink_device")
|
|
2257
|
+
return expected, read_device
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def _validate_expect_dpidr_width(expected: str, method: str) -> None:
|
|
2261
|
+
"""Refuse an `expect_dpidr` that is not a full 32-bit / 8-hex-digit SW-DP
|
|
2262
|
+
ID (tan-cli#795). `validate_address` (above) stays a generic charset check
|
|
2263
|
+
of any length -- `base` and friends share it -- so this width rule lives
|
|
2264
|
+
here instead, narrowly scoped to the one field this preflight arms off of,
|
|
2265
|
+
and runs at the SAME PLAN-TIME call site as `validate_address` itself:
|
|
2266
|
+
Flow D calls `validate_flow_d_preflight_args` before any write, via
|
|
2267
|
+
`tan.commands.flash_cmd`, so a truncated value surfaces under `--dry-run`
|
|
2268
|
+
too, not only when a real J-Link probe reads a banner.
|
|
2269
|
+
|
|
2270
|
+
Without this, a truncated value like `0x2477`, or `0x477` (ARM's own
|
|
2271
|
+
JEP106 designer field, shared by every ARM SW-DP), would match any ARM
|
|
2272
|
+
board's banner and silently disarm the wrong-board guard.
|
|
2273
|
+
"""
|
|
2274
|
+
digits = expected[2:] if expected[:2] in ("0x", "0X") else expected
|
|
2275
|
+
if len(digits) == 8:
|
|
2276
|
+
return
|
|
2277
|
+
raise FlashPlanError(
|
|
2278
|
+
f"{method}: flash_args.expect_dpidr = {_quoted(expected)} is not a full "
|
|
2279
|
+
"32-bit SW-DP ID (8 hex digits) -- refusing to arm the wrong-board "
|
|
2280
|
+
"guard with a value short enough to match more than one board (ARM's "
|
|
2281
|
+
"own JEP106 designer field, 0x477, is shared by every ARM SW-DP)."
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def flow_d_preflight_script(
|
|
2286
|
+
inp: FlashInputs, method: str = FLOW_D_METHOD, *, read_device: str | None = None
|
|
2287
|
+
) -> tuple[str, str] | None:
|
|
2288
|
+
"""The read-only DPIDR preflight script for a Flow D J-Link plan (tan-cli
|
|
2289
|
+
#520 generalised this out of a Flow D-only shape so a second backend
|
|
2290
|
+
could reuse it -- see `read_device` below): `(script, expected_id)`, or
|
|
2291
|
+
`None` when the preflight is not armed at all -- see
|
|
2292
|
+
`validate_flow_d_preflight_args` for every refusal shape, which this
|
|
2293
|
+
delegates to (passing `method` through unchanged) before building the
|
|
2294
|
+
script.
|
|
2295
|
+
|
|
2296
|
+
`read_device` (tan-cli#520, default `None`): for a CALLER that already has
|
|
2297
|
+
a connect device in hand via `FlashPlan.preflight_device` -- the same
|
|
2298
|
+
device its own write already resolved -- pass it here rather than
|
|
2299
|
+
requiring a second `flash_args` key. This flips
|
|
2300
|
+
`validate_flow_d_preflight_args`'s `require_device_key` to `False`, so
|
|
2301
|
+
`expect_dpidr` ALONE arms the preflight on that path. `None` (the default)
|
|
2302
|
+
keeps Flow D's own shape exactly: `jlink_device` PAIRED with
|
|
2303
|
+
`expect_dpidr` in `flash_args`, as it always has been. `swd_probe`'s
|
|
2304
|
+
J-Link arm was the one caller that passed a `read_device` until tan-cli
|
|
2305
|
+
#732 removed the backend; the parameter stays for the next one.
|
|
2306
|
+
|
|
2307
|
+
Run BEFORE any write, with a live-core attach profile (which the
|
|
2308
|
+
part-number one is not), so the caller can abort on the wrong board while
|
|
2309
|
+
the session is still read-only. The expected ID always comes from
|
|
2310
|
+
`flash_args.expect_dpidr`; the device name comes from either
|
|
2311
|
+
`flash_args.jlink_device` (Flow D shape) or the caller-supplied
|
|
2312
|
+
`read_device`, depending on which was armed.
|
|
2313
|
+
"""
|
|
2314
|
+
expected, paired_device = validate_flow_d_preflight_args(
|
|
2315
|
+
inp.flash_args, method, require_device_key=read_device is None
|
|
2316
|
+
)
|
|
2317
|
+
if expected is None:
|
|
2318
|
+
return None
|
|
2319
|
+
read_device = read_device if read_device is not None else paired_device
|
|
2320
|
+
if read_device is None:
|
|
2321
|
+
return None
|
|
2322
|
+
# tan-cli#520 REVIEW, BLOCKER 1: `read_device` -- whichever source it came
|
|
2323
|
+
# from -- is charset-validated HERE, unconditionally, before it reaches
|
|
2324
|
+
# the script line below. A CALLER-supplied `read_device` (`swd_probe`'s
|
|
2325
|
+
# `FlashPlan.preflight_device`, i.e. `_resolve_jlink_device`'s return
|
|
2326
|
+
# value) was NEVER validated anywhere: `_resolve_jlink_device` returns
|
|
2327
|
+
# `flash_args.jlink_device` verbatim, which was safe while it only ever
|
|
2328
|
+
# reached ARGV (a list element, newline-inert) via the write -- this
|
|
2329
|
+
# preflight instead interpolates it into a Commander script LINE
|
|
2330
|
+
# (`device {read_device}`, a few lines down), the exact injection surface
|
|
2331
|
+
# `validate_identifier` already closes for Flow D's own PAIRED
|
|
2332
|
+
# `jlink_device` inside `validate_flow_d_preflight_args`'s
|
|
2333
|
+
# `require_device_key=True` branch. Without this, an embedded newline in
|
|
2334
|
+
# `flash_args.jlink_device` could splice EXTRA Commander lines (a
|
|
2335
|
+
# `loadfile`/`r`/`g` ahead of `connect`) into this READ-ONLY preflight,
|
|
2336
|
+
# running BEFORE the mismatch abort this function exists to run first --
|
|
2337
|
+
# the same class of hole #486 closed for `jlink_serial` on Flow D, now
|
|
2338
|
+
# closed here for the device name on both callers (re-validating Flow
|
|
2339
|
+
# D's own already-checked `paired_device` here too is redundant but
|
|
2340
|
+
# harmless -- `validate_identifier` is pure).
|
|
2341
|
+
validate_identifier(read_device, "jlink_device")
|
|
2342
|
+
fa = inp.flash_args
|
|
2343
|
+
speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED)
|
|
2344
|
+
lines = []
|
|
2345
|
+
# `fa_str_checked` + `validate_identifier`, not the tolerant `fa_str`
|
|
2346
|
+
# (tan-cli#486) -- same fix as `plan_alif_mram_jlink`'s write script, and
|
|
2347
|
+
# more load-bearing HERE: an injected line prefixed onto this READ-ONLY
|
|
2348
|
+
# preflight (see the module docstring's "the identity is confirmed while
|
|
2349
|
+
# the session is still read-only" comment in `flash_cmd`) would run
|
|
2350
|
+
# before the wrong-board abort ever gets a chance to fire. Also already
|
|
2351
|
+
# validated by `validate_flow_d_shape`, which `_flash_entry` runs before
|
|
2352
|
+
# this preflight (tan-cli#486 review) -- repeated here defensively.
|
|
2353
|
+
serial = fa_str_checked(fa, "jlink_serial", False)
|
|
2354
|
+
if serial is not None:
|
|
2355
|
+
validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
|
|
2356
|
+
lines.append(f"SelectEmuBySN {serial}")
|
|
2357
|
+
lines += ["si SWD", f"speed {speed}", f"device {read_device}", "connect", "exit"]
|
|
2358
|
+
return "\n".join(lines) + "\n", expected
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
_REGISTRY: dict[str, BackendMeta] = {
|
|
2362
|
+
"zephyr_west_flash": BackendMeta(("west",), plan_zephyr_west_flash),
|
|
2363
|
+
"baremetal_cmake_flash": BackendMeta(("cmake",), plan_baremetal_cmake_flash),
|
|
2364
|
+
"yocto_wic_to_sd_or_emmc": BackendMeta(("bmaptool", "dd"), plan_yocto_wic),
|
|
2365
|
+
"yocto_wic": BackendMeta(("bmaptool", "dd"), plan_yocto_wic),
|
|
2366
|
+
"xspi_flashwriter": BackendMeta((), plan_xspi_flashwriter),
|
|
2367
|
+
FLOW_D_METHOD: BackendMeta(("JLinkExe", "JLink"), plan_alif_mram_jlink),
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
# ── tan-cli#609: which methods the wrong-board SW-DP ID guard covers ────────
|
|
2372
|
+
|
|
2373
|
+
#: Every registered `flash_method`, mapped to whether **tan itself composes
|
|
2374
|
+
#: the J-Link Commander session that performs the write** and can therefore
|
|
2375
|
+
#: run the read-only SW-DP IDR preflight (`flow_d_preflight_script`) ahead of
|
|
2376
|
+
#: it. `True` is the guard's coverage set.
|
|
2377
|
+
#:
|
|
2378
|
+
#: A TABLE rather than an inline `method == ...` test at the one call site,
|
|
2379
|
+
#: because tan-cli#609 measured what the inline form costs. `flash_cmd` used to
|
|
2380
|
+
#: gate the unarmed-guard ADVISORY on a single hardcoded method; the AEN
|
|
2381
|
+
#: dispatches Flow D (`alif_mram_jlink`), so a real MRAM write on
|
|
2382
|
+
#: `e1m-aen-evk-01` (2026-08-10, `origin/dev` `a9062ea`) emitted `ISSUES = []`
|
|
2383
|
+
#: -- no guard AND no signal -- on a bench where one J-Link serial is
|
|
2384
|
+
#: OEM-cloned across two probes and `JLinkExe` selects by serial alone. The
|
|
2385
|
+
#: advisory tracked whichever method someone had last wired it to, not the
|
|
2386
|
+
#: methods that can actually write.
|
|
2387
|
+
#:
|
|
2388
|
+
#: `False` is NOT a safety claim about a method. It says only that
|
|
2389
|
+
#: `flash_args.expect_dpidr` would have nothing to arm there, because tan does
|
|
2390
|
+
#: not build that method's probe session: `zephyr_west_flash` hands the job to
|
|
2391
|
+
#: `west flash`, whose runner composes its own command line (a J-Link runner
|
|
2392
|
+
#: among them); `baremetal_cmake_flash` hands it to a CMake target; the
|
|
2393
|
+
#: `yocto_wic*` pair and `xspi_flashwriter` address a block device and a
|
|
2394
|
+
#: serial port, neither probe-selected. An advisory on any of those would tell
|
|
2395
|
+
#: an operator to set a key that does nothing.
|
|
2396
|
+
#:
|
|
2397
|
+
#: `tests/gates/test_dpidr_guard_coverage.py` pins these keys to `_REGISTRY`'s,
|
|
2398
|
+
#: so a NEW backend cannot dispatch until someone has decided which side it is
|
|
2399
|
+
#: on. That pin is what makes the `.get(..., False)` default in
|
|
2400
|
+
#: `dpidr_preflight_possible` a closed question rather than a silent omission.
|
|
2401
|
+
DPIDR_GUARD_COVERAGE: dict[str, bool] = {
|
|
2402
|
+
FLOW_D_METHOD: True,
|
|
2403
|
+
"zephyr_west_flash": False,
|
|
2404
|
+
"baremetal_cmake_flash": False,
|
|
2405
|
+
"yocto_wic": False,
|
|
2406
|
+
"yocto_wic_to_sd_or_emmc": False,
|
|
2407
|
+
"xspi_flashwriter": False,
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
|
|
2411
|
+
def dpidr_preflight_possible(method: str, preflight_device: str | None) -> bool:
|
|
2412
|
+
"""Whether tan CAN run its read-only SW-DP IDR preflight for this entry --
|
|
2413
|
+
i.e. whether `flash_args.expect_dpidr` would arm a real check here. Pure.
|
|
2414
|
+
|
|
2415
|
+
The method being on the `True` side of `DPIDR_GUARD_COVERAGE` is the whole
|
|
2416
|
+
test today. `preflight_device` stays a parameter for call-site symmetry
|
|
2417
|
+
with `dpidr_preflight_unarmed`, which threads the same `FlashPlan` field
|
|
2418
|
+
through -- tan-cli#732 removed `swd_probe`, the one backend whose plan
|
|
2419
|
+
builder could take an arm where a preflight is impossible even though the
|
|
2420
|
+
METHOD is covered (`preflight_device is None` on its openocd/pyocd arm).
|
|
2421
|
+
Flow D has no such split (it is J-Link by construction) and always passes
|
|
2422
|
+
`preflight_device=None`; a future backend with its own split arm would
|
|
2423
|
+
restate that second condition here rather than inventing a new predicate.
|
|
2424
|
+
"""
|
|
2425
|
+
return DPIDR_GUARD_COVERAGE.get(method, False)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def dpidr_preflight_unarmed(
|
|
2429
|
+
method: str, flash_args: Any, preflight_device: str | None
|
|
2430
|
+
) -> bool:
|
|
2431
|
+
"""Whether this write went ahead with a wrong-board guard that COULD have
|
|
2432
|
+
run and was not armed -- the condition `flash.dpidr-preflight-unarmed`
|
|
2433
|
+
reports. Pure.
|
|
2434
|
+
|
|
2435
|
+
`expect_dpidr` PRESENT is the whole test, via `_fa_has_key` rather than
|
|
2436
|
+
`fa_str_checked`: a present-but-null key is a refusal
|
|
2437
|
+
(`validate_flow_d_preflight_args`), never a quiet opt-out, so treating it
|
|
2438
|
+
as absent here would report the wrong thing about a manifest that already
|
|
2439
|
+
cannot flash. Flow D additionally requires `expect_dpidr` to be PAIRED
|
|
2440
|
+
with `jlink_device`, but a half-armed pair refuses before any write, so by
|
|
2441
|
+
the time an entry reaches a caller of this function "present" and "armed"
|
|
2442
|
+
coincide on both covered methods.
|
|
2443
|
+
|
|
2444
|
+
Ask this only of a REAL, CONFIRMED write. It says nothing about a
|
|
2445
|
+
`--dry-run` or an unconfirmed preview, neither of which writes anything
|
|
2446
|
+
for a wrong-board guard to have protected.
|
|
2447
|
+
"""
|
|
2448
|
+
return dpidr_preflight_possible(method, preflight_device) and not _fa_has_key(
|
|
2449
|
+
flash_args, "expect_dpidr"
|
|
2450
|
+
)
|
|
2451
|
+
|
|
2452
|
+
|
|
2453
|
+
# ── the required-tool gate ──────────────────────────────────────────────────
|
|
2454
|
+
|
|
2455
|
+
PROCEED = "proceed"
|
|
2456
|
+
SKIP = "skip"
|
|
2457
|
+
FAIL = "fail"
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
@dataclass(frozen=True)
|
|
2461
|
+
class ToolGate:
|
|
2462
|
+
outcome: str
|
|
2463
|
+
message: str = ""
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def tool_gate(
|
|
2467
|
+
requires,
|
|
2468
|
+
dry_run: bool,
|
|
2469
|
+
skip_missing: bool,
|
|
2470
|
+
kind: str,
|
|
2471
|
+
entry_id: str,
|
|
2472
|
+
method: str,
|
|
2473
|
+
which: Callable[[str], bool],
|
|
2474
|
+
) -> ToolGate:
|
|
2475
|
+
"""A backend is usable when AT LEAST ONE of `requires` is on PATH. Bypassed
|
|
2476
|
+
entirely under `--dry-run`, and for a backend with an empty `requires`."""
|
|
2477
|
+
if dry_run or not requires:
|
|
2478
|
+
return ToolGate(PROCEED)
|
|
2479
|
+
if any(which(t) for t in requires):
|
|
2480
|
+
return ToolGate(PROCEED)
|
|
2481
|
+
msg = (
|
|
2482
|
+
f"flash: {kind} '{entry_id}' backend '{method}' needs one of "
|
|
2483
|
+
f"{_str_list_debug(requires)} on PATH; none found."
|
|
2484
|
+
)
|
|
2485
|
+
if skip_missing:
|
|
2486
|
+
return ToolGate(SKIP, f"{msg} (skipped via --skip-missing-tools)")
|
|
2487
|
+
return ToolGate(FAIL, msg)
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
# ── argv display ────────────────────────────────────────────────────────────
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
def display_argv(plan: FlashPlan) -> str:
|
|
2494
|
+
"""The would-run display string; a J-Link plan shows a `<generated.jlink>`
|
|
2495
|
+
placeholder for the temp Commander script (which does not exist yet, and
|
|
2496
|
+
whose name carries a pid + nanosecond stamp that must never reach a
|
|
2497
|
+
golden)."""
|
|
2498
|
+
parts = list(plan.argv)
|
|
2499
|
+
if plan.jlink_script is not None:
|
|
2500
|
+
parts.append("<generated.jlink>")
|
|
2501
|
+
return " ".join(parts)
|