From b688efadb9997bef5a55067dd12fcf875cb527e9 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin Date: Tue, 12 May 2026 07:37:49 +0300 Subject: [PATCH] install: route through pod-hosted TFTP when power=rack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `--tftp-via=auto|pod|host` flag (default: auto) and wires `_install_async` to pick the TFTP backend without forking the U-Boot driving logic: - `pod`: stage U-Boot + kernel + rootfs in the rack pod's PSRAM via `RackController.tftp_put`; set `setenv serverip 192.168.1.1` and let the camera fetch directly over the local LAN. The pod TFTP server is already on the camera's gateway IP — no host TFTP, no NIC IP plumbing, no sudo, no port-69 conflict. Files cleared at the end via `tftp_clear`. - `host`: existing path. `temporary_ip` adds a NIC IP alias and `start_tftp_server` binds UDP/69 on the host. - `auto`: pod when power=rack, host otherwise (preserves the old default for non-rack setups). Implementation: a small `AsyncExitStack` swap. Both branches end up exposing the same two locals to the rest of the install code: `serverip` (used in `setenv serverip`) and `replace_in_tftp(name, data)` (the async hook that the UBI rootfs path needs to swap files mid-flow). The 200+ lines of U-Boot driving / tftp+sf-write / ethaddr-rescue / saveenv stay untouched. End-to-end verification on rack pod 10.216.128.69 + hi3516ev300 (nor-neo install): $ DEFIB_POWER_TYPE=rack DEFIB_RACK_HOST=10.216.128.69 \ defib install -c hi3516ev300 \ --firmware openipc.hi3516ev300-nor-neo.tgz \ -p rack://10.216.128.69 \ --power-cycle \ --nor-size 16 ... Phase 1: Burning U-Boot to RAM Pod-side fastboot in progress… U-Boot loaded in 25940ms Phase 2: Flash via TFTP Staging 6535 KB in pod PSRAM via POST /tftp/... Pod TFTP ready on 192.168.1.1:69 Flashing U-Boot → 0x0 (262144 bytes) TFTP CRC verified: FA8B2667 Flash verified: FA8B2667 U-Boot OK Flashing kernel → 0x50000 (2055676 bytes) TFTP CRC verified: BF160C6A Flash verified: BF160C6A kernel OK Flashing rootfs → 0x350000 (4374528 bytes) TFTP CRC verified: 7D598D15 Flash verified: 7D598D15 rootfs OK ethaddr preserved Environment saved Resetting device... Install complete! $ # camera reaches `openipc-hi3516ev300 login:` cleanly The CLI now drives a complete OpenIPC install from a single command, zero host-side TFTP setup — the whole UART + Eth path through the rack pod is the only network plane involved. Suite: 486 passed / 2 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/defib/cli/app.py | 135 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/src/defib/cli/app.py b/src/defib/cli/app.py index cdc0830..07d0bd4 100644 --- a/src/defib/cli/app.py +++ b/src/defib/cli/app.py @@ -1727,6 +1727,14 @@ def install( "default is to preserve env so MACs aren't reset to the OpenIPC " "u-boot default 00:00:23:34:45:66).", ), + tftp_via: str = typer.Option( + "auto", "--tftp-via", + help="Where the camera fetches firmware from: " + "'host' (defib starts a host-side TFTP server — needs sudo/port-69), " + "'pod' (stage files in the rack pod's PSRAM and serve from the pod's " + "192.168.1.1 — no host setup, requires DEFIB_POWER_TYPE=rack), " + "'auto' (pod when power=rack, else host).", + ), output: str = typer.Option("human", "--output", help="Output mode: human, json"), debug: bool = typer.Option(False, "-d", "--debug", help="Enable debug logging"), ) -> None: @@ -1739,7 +1747,7 @@ def install( import asyncio asyncio.run(_install_async( chip, firmware, port, power_cycle, poe_port_override, nic, host_ip, device_ip, - tftp_port, nor_size, nand, wipe_env, output, debug, + tftp_port, nor_size, nand, wipe_env, tftp_via, output, debug, )) @@ -1813,6 +1821,7 @@ async def _install_async( nor_size: int, nand: bool, wipe_env: bool, + tftp_via: str, output: str, debug: bool, ) -> None: @@ -2135,18 +2144,28 @@ async def _cmd(cmd: str, timeout: float = 60.0, **kw: object) -> str: if output == "human": console.print(" [green]SPI flash detected[/green]") - # --- Step 5: Start TFTP server + configure U-Boot networking --- - if not nic: - interfaces = list_interfaces() - if interfaces: - nic = interfaces[0] - else: - console.print("[red]No network interfaces found. Specify --nic.[/red]") - await transport.close() - raise typer.Exit(1) - - if output == "human": - console.print(f" NIC: [cyan]{nic}[/cyan], Host IP: [cyan]{host_ip}[/cyan]") + # --- Step 5: Pick a TFTP backend, stage / start, then drive U-Boot --- + # + # Two paths: + # * pod — stage firmware bytes in the rack pod's PSRAM via + # RackController.tftp_put; camera fetches from the pod's + # W5500 IP (192.168.1.1). Zero host setup; the pod is + # already on the camera's local LAN. + # * host — defib starts an embedded TFTP server on the host's + # `nic` at `host_ip`. Needs sudo / port-69 / NIC plumbing. + # + # `--tftp-via auto` picks pod when power=rack, host otherwise. + use_pod_tftp = ( + tftp_via == "pod" + or (tftp_via == "auto" and isinstance(power_controller, RackController)) + ) + if tftp_via == "pod" and not isinstance(power_controller, RackController): + console.print( + "[red]--tftp-via pod requires DEFIB_POWER_TYPE=rack[/red] " + "(no rack pod to host TFTP)." + ) + await transport.close() + raise typer.Exit(1) # TFTP files: U-Boot, kernel, rootfs tftp_files = { @@ -2155,24 +2174,81 @@ async def _cmd(cmd: str, timeout: float = 60.0, **kw: object) -> str: rootfs_name: rootfs_data, } - async with temporary_ip(nic, host_ip, "255.255.255.0"): + if not use_pod_tftp: + # Host TFTP needs a NIC + host_ip; pod path needs neither. + if not nic: + interfaces = list_interfaces() + if interfaces: + nic = interfaces[0] + else: + console.print("[red]No network interfaces found. Specify --nic.[/red]") + await transport.close() + raise typer.Exit(1) if output == "human": - console.print(" [green]IP assigned[/green]") + console.print(f" NIC: [cyan]{nic}[/cyan], Host IP: [cyan]{host_ip}[/cyan]") + + from contextlib import AsyncExitStack + async with AsyncExitStack() as stack: + # Set up the TFTP backend. Both branches end up with: + # serverip — U-Boot's `setenv serverip` value + # replace_in_tftp() — async hook to swap a file mid-flow + # (used by the UBI rootfs path below) + tftp_protocol = None # only used by host path's UBI replace + if use_pod_tftp: + assert isinstance(power_controller, RackController) + if output == "human": + console.print( + f" [cyan]Staging {sum(len(d) for d in tftp_files.values()) // 1024} KB " + f"in pod PSRAM via POST /tftp/...[/cyan]" + ) + for name, data in tftp_files.items(): + await power_controller.tftp_put(name, data, timeout=180.0) + serverip = "192.168.1.1" + tftp_pod = power_controller - tftp_transport, tftp_protocol = await start_tftp_server( - files=tftp_files, - bind_addr=host_ip, - port=tftp_port, - done_count=3, # U-Boot + kernel + rootfs - ) + async def _aclose_pod_tftp() -> None: + try: + await tftp_pod.tftp_clear() + except Exception: + pass - if output == "human": - console.print(f" [green]TFTP server started on {host_ip}:{tftp_port}[/green]") + stack.push_async_callback(_aclose_pod_tftp) + + async def replace_in_tftp(name: str, data: bytes) -> None: + await tftp_pod.tftp_put(name, data, timeout=180.0) + + if output == "human": + console.print(f" [green]Pod TFTP ready on {serverip}:69[/green]") + else: + await stack.enter_async_context( + temporary_ip(nic, host_ip, "255.255.255.0") + ) + if output == "human": + console.print(" [green]IP assigned[/green]") + + tftp_transport, tftp_protocol = await start_tftp_server( + files=tftp_files, + bind_addr=host_ip, + port=tftp_port, + done_count=3, # U-Boot + kernel + rootfs + ) + stack.callback(tftp_transport.close) + serverip = host_ip + async def replace_in_tftp(name: str, data: bytes) -> None: + tftp_protocol._files[name] = data + + if output == "human": + console.print( + f" [green]TFTP server started on {host_ip}:{tftp_port}[/green]" + ) + + # ── U-Boot console drive (identical for both backends, only + # `serverip` and `replace_in_tftp` differ) ───────────────── try: # Configure U-Boot networking await _cmd(f"setenv ipaddr {device_ip}", timeout=3.0) - await _cmd(f"setenv serverip {host_ip}", timeout=3.0) + await _cmd(f"setenv serverip {serverip}", timeout=3.0) if output == "human": console.print(f" Device IP: [cyan]{device_ip}[/cyan]") @@ -2281,8 +2357,9 @@ async def tftp_and_flash( f" from {len(rootfs_data)} byte UBI image" ) - # Replace TFTP file with extracted UBIFS - tftp_protocol._files[rootfs_name] = ubifs_data + # Replace TFTP file with extracted UBIFS — works for + # both host (dict reassignment) and pod (HTTP repost). + await replace_in_tftp(rootfs_name, ubifs_data) try: resp = await _tftp_to_ram(rootfs_name, timeout=120.0) @@ -2395,8 +2472,10 @@ async def tftp_and_flash( console.print("\n [bold]Resetting device...[/bold]") await _cmd("reset", timeout=3.0) - finally: - tftp_transport.close() + except Exception: + raise + # The AsyncExitStack handles closing the host TFTP transport and + # clearing pod TFTP state — no per-branch finally needed here. await transport.close() if power_controller: