Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ Backends: `spi_flash.c` (fmc100), `spi_flash_hisfc350.c` (V1-era parts),
filename-based partition routing, plus temporary static-IP management and
U-Boot device discovery.
- **Firmware** (`src/defib/firmware.py`) — downloads OpenIPC releases from
GitHub, caches under `XDG_CACHE_HOME`.
GitHub, caches under `XDG_CACHE_HOME`. Most chips use one
`u-boot-<chip>-universal.bin`; the u-boot-xmedia SoCs in
`PER_FLASH_TYPE_UBOOT` (hi3516ev200/ev300, hi3518ev300, hi3516dv200,
gk7205v500/v510/v530) publish `u-boot-<chip>-nor.bin` and `-nand.bin`
instead, so callers pass `flash_type` (NOR when unknown). On NAND those SoCs
install the UBI-only layout (`NAND_UBI_LAYOUT` in `install/layout.py`: 768k
boot, 256k env, rest ubi), whose mtdparts/bootcmd/bootargs come from the
U-Boot default env; `NAND_LAYOUT` is the legacy split layout for other chips.
- **Capture** (`src/defib/capture/`) — record/replay UART sessions in `.dcap`.
- Loose modules worth knowing: `flashdump.py` (dump flash through a U-Boot
console), `ubi.py` (extract UBIFS volumes from raw UBI), `uboot_env.py`,
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ Requires root for TFTP port 69 and NIC IP assignment. Standard 8/16/32 MiB
NOR layouts are selected from U-Boot flash detection; `--nor-size` remains an
explicit override.

hi3516ev200, hi3516ev300, hi3518ev300, hi3516dv200 and gk7205v500/v510/v530
take their U-Boot from OpenIPC/u-boot-xmedia, published once per flash type as
`u-boot-<chip>-nor.bin` and `u-boot-<chip>-nand.bin`. `install` picks the build
from `--nand`; `burn` loads the NOR build unless `--nand` is given. On NAND
these SoCs use a UBI-only layout:

| Offset | Size | Contents |
|--------|------|----------|
| `0x000000` | 768K | boot: `u-boot-<chip>-nand.bin` |
| `0x0C0000` | 256K | env |
| `0x100000` | rest of chip | ubi: `rootfs.ubi.<board>` (volume `rootfs` with the kernel as `/boot/fitImage`, plus `rootfs_data`) |

```bash
defib install -c hi3516ev300 --nand \
--firmware openipc.hi3516ev300-nand-lite.tgz \
-p /dev/ttyUSB0 --power-cycle
```

The NAND U-Boot's default environment defines `mtdparts`, `bootcmd` and
`bootargs` for this layout, so the installer leaves them alone: it writes U-Boot,
erases the `ubi` partition (`nand erase.part ubi`) and writes the UBI image with
`nand write.trimffs`, then resets the environment to the U-Boot defaults
(`env default -a`), restores the camera's `ethaddr` and saves. The `kernel` and
`rootfs-data` stages are no-ops on this layout: both live inside the UBI image.
gk7205v510/v530 NAND packages are published under board `gk7205v500`.
`install --nand` on any other chip still uses the older split layout (raw
kernel partition, `mtdparts` and `bootcmd` set by the installer).

Targets that must bootstrap through a stock U-Boot use an explicit U-Boot
variant. For example, HiWatch DS-I203 uses:

Expand Down
42 changes: 33 additions & 9 deletions src/defib/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def burn(
chip: str = typer.Option(..., "-c", "--chip", help="Chip model name"),
file: str = typer.Option("", "-f", "--file", help="Firmware file (auto-downloads from OpenIPC if omitted)"),
port: str = typer.Option("/dev/ttyUSB0", "-p", "--port", help="Serial device (/dev/ttyUSB0), tcp://host:port, rfc2217://host:port, or socket:///path"),
nand: bool = typer.Option(False, "--nand", help="Auto-download the NAND U-Boot build (u-boot-<chip>-nand.bin) for SoCs published per flash type; the NOR build is the default"),
send_break: bool = typer.Option(False, "-b", "--break", help="Send Ctrl-C after upload"),
terminal: bool = typer.Option(False, "-t", "--terminal", help="Open serial terminal after upload"),
power_cycle: bool = typer.Option(False, "--power-cycle", help="Auto power-cycle via the controller selected by DEFIB_POWER_TYPE (default routeros, needs DEFIB_POE_* env vars)"),
Expand All @@ -55,14 +56,14 @@ def burn(
into MaskROM on its own at power-up, so --power-cycle is all it takes.
"""
import asyncio
asyncio.run(_burn_async(chip, file, port, send_break, terminal, power_cycle, poe_port_override, output, debug, ddr, usbplug, loader, wait, usb_path))
asyncio.run(_burn_async(chip, file, port, send_break, terminal, power_cycle, poe_port_override, output, debug, ddr, usbplug, loader, wait, usb_path, flash_type="nand" if nand else "nor"))


async def _burn_async(
chip: str, file: str, port: str, send_break: bool, terminal: bool,
power_cycle: bool, poe_port_override: str, output: str, debug: bool,
ddr: str = "", usbplug: str = "", loader: str = "", wait: float = 30.0,
usb_path: str = "",
usb_path: str = "", flash_type: str = "nor",
) -> None:
import json as json_mod
import logging
Expand Down Expand Up @@ -92,7 +93,7 @@ async def _burn_async(
if not firmware_path:
from defib.firmware import has_firmware, download_firmware, get_cached_path

if not has_firmware(chip):
if not has_firmware(chip, flash_type):
msg = (
f"No pre-built firmware for '{chip}' on OpenIPC. "
f"Specify a local file with -f/--file."
Expand All @@ -103,7 +104,7 @@ async def _burn_async(
console.print(f"[red]{msg}[/red]")
raise typer.Exit(1)

cached = get_cached_path(chip)
cached = get_cached_path(chip, flash_type)
if cached:
firmware_path = str(cached)
if output == "human":
Expand All @@ -121,7 +122,9 @@ def _dl_progress(done: int, total: int) -> None:
elif output == "json" and done == total:
print(json_mod.dumps({"event": "download_complete", "bytes": total}), flush=True)

path = download_firmware(chip, on_progress=_dl_progress)
path = download_firmware(
chip, on_progress=_dl_progress, flash_type=flash_type,
)
firmware_path = str(path)
if output == "human":
console.print(f"\n Saved: [cyan]{path.name}[/cyan] ({path.stat().st_size} bytes)")
Expand Down Expand Up @@ -2498,7 +2501,15 @@ def install(
0, "--nor-size",
help="NOR size override in MB; 0 auto-detects from U-Boot",
),
nand: bool = typer.Option(False, "--nand", help="Use NAND flash instead of NOR"),
nand: bool = typer.Option(
False, "--nand",
help=(
"Use NAND flash instead of NOR. hi3516ev200/ev300, hi3518ev300, "
"hi3516dv200 and gk7205v500/v510/v530 get the UBI layout (768k boot, "
"256k env, rest ubi): u-boot-<chip>-nand.bin plus the package's "
"rootfs.ubi.<board>, which carries the kernel."
),
),
wipe_env: bool = typer.Option(
False,
"--wipe-env",
Expand Down Expand Up @@ -2729,12 +2740,25 @@ async def _restore_async(
# --- Resolve U-Boot binary ---
if not uboot_path:
from defib.firmware import get_cached_path, download_firmware, has_firmware
if has_firmware(chip):
cached = get_cached_path(chip)
# SoCs with per-flash-type U-Boot builds need the NAND build to drive
# NAND; anything other than an explicit --flash-type nand gets NOR.
uboot_flash_type = "nand" if flash_type.lower() == "nand" else "nor"
from defib.firmware import uses_per_flash_type_uboot
if (
flash_type.lower() == "auto"
and uses_per_flash_type_uboot(chip)
and output == "human"
):
console.print(
f" [yellow]{chip} publishes separate NOR and NAND U-Boot builds; "
"using NOR. Pass --flash-type nand for a NAND camera.[/yellow]"
)
if has_firmware(chip, uboot_flash_type):
cached = get_cached_path(chip, uboot_flash_type)
if not cached:
if output == "human":
console.print(f" Downloading U-Boot for [cyan]{chip}[/cyan]...")
cached = download_firmware(chip)
cached = download_firmware(chip, flash_type=uboot_flash_type)
uboot_path = str(cached)
else:
console.print(f"[red]No U-Boot for '{chip}'. Specify --uboot.[/red]")
Expand Down
125 changes: 103 additions & 22 deletions src/defib/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

- Classic SoCs: ``u-boot-{chip}-universal.bin`` — a bare U-Boot image.
- Selected classic board variants may publish a dedicated U-Boot release asset.
- u-boot-xmedia SoCs (hi3516ev200/ev300, hi3518ev300, hi3516dv200,
gk7205v500/v510/v530): ``u-boot-{chip}-{nor|nand}.bin`` — one bare U-Boot per
flash type, because each build carries that flash type's partition layout.
- CV6xx SoCs: ``boot-{chip}[-{variant}]-nor.bin`` — a composite image
(GSL + DDR tables + U-Boot) that the bootrom expects as a single blob.

Expand Down Expand Up @@ -32,13 +35,28 @@
"gk7202v300", "gk7205v200", "gk7205v300", "gk7605v100",
"hi3516av100", "hi3516av200", "hi3516av300",
"hi3516cv100", "hi3516cv200", "hi3516cv300", "hi3516cv500",
"hi3516dv100", "hi3516dv200", "hi3516dv300",
"hi3516ev100", "hi3516ev200", "hi3516ev300",
"hi3518av100", "hi3518cv100", "hi3518ev100", "hi3518ev200", "hi3518ev300",
"hi3516dv100", "hi3516dv300",
"hi3516ev100",
"hi3518av100", "hi3518cv100", "hi3518ev100", "hi3518ev200",
"hi3519v101", "hi3520dv200", "hi3536cv100", "hi3536dv100",
"t40a", "t40n", "t40xp",
}

# SoCs whose OpenIPC U-Boot is built from OpenIPC/u-boot-xmedia and published
# once per flash type as u-boot-{chip}-nor.bin / u-boot-{chip}-nand.bin. The
# NAND build owns the UBI-only layout (768k boot, 256k env, rest ubi) in its
# default environment, so a NOR image must never be installed on NAND or vice
# versa. The old u-boot-{chip}-universal.bin of the HiSilicon members was the
# retired u-boot-hi3516ev200 build with the split NAND layout; it is never
# downloaded for these SoCs again.
PER_FLASH_TYPE_UBOOT: frozenset[str] = frozenset({
"hi3516ev200", "hi3516ev300", "hi3518ev300", "hi3516dv200",
"gk7205v500", "gk7205v510", "gk7205v530",
})

FLASH_TYPES: tuple[str, ...] = ("nor", "nand")
DEFAULT_FLASH_TYPE = "nor"

# CV6xx SoCs publish a composite boot image, not a bare U-Boot, and no
# u-boot-{chip}-universal.bin exists for any of them — the URL assumed by
# #112 always 404'd, so auto-download could never work and users had to
Expand Down Expand Up @@ -68,8 +86,7 @@
}

# Chip aliases: map chip names to the firmware download name
# e.g. hi3516ev300 profile resolves to hi3516ev200 internally,
# but the firmware binary is named u-boot-hi3516ev300-universal.bin
# e.g. hi3518ev201 has its own profile but boots the hi3518ev200 U-Boot.
CHIP_TO_FIRMWARE: dict[str, str] = {
"hi3518ev201": "hi3518ev200",
"hi3516dv100": "hi3516dv100",
Expand Down Expand Up @@ -109,14 +126,42 @@ def _strip_variant(chip: str) -> str:
return _split_variant(chip)[0]


def asset_name(chip: str) -> str | None:
def normalize_flash_type(flash_type: str | None) -> str:
"""Normalize an optional flash type; NOR when the caller does not know."""
if flash_type is None:
return DEFAULT_FLASH_TYPE
normalized = flash_type.strip().lower()
if normalized not in FLASH_TYPES:
raise ValueError(
f"unknown flash type {flash_type!r}; expected one of: "
+ ", ".join(FLASH_TYPES)
)
return normalized


def uses_per_flash_type_uboot(chip: str) -> bool:
"""True for SoCs whose U-Boot is published per flash type (nor/nand)."""
base = _strip_variant(chip).lower()
return CHIP_TO_FIRMWARE.get(base, base) in PER_FLASH_TYPE_UBOOT


def asset_name(chip: str, flash_type: str | None = None) -> str | None:
"""Published release filename for a chip, or None if there isn't one.

Returns None both for chips OpenIPC doesn't build and for a CV6xx chip
named without the variant needed to pick between several board images.

``flash_type`` (``"nor"``/``"nand"``) picks the build for SoCs listed in
PER_FLASH_TYPE_UBOOT and defaults to NOR there. Every other chip publishes
one image whatever its flash, so the argument does not change its name.
"""
kind = normalize_flash_type(flash_type)
base, variant = _split_variant(chip)
name = CHIP_TO_FIRMWARE.get(base, base)
name = CHIP_TO_FIRMWARE.get(base.lower(), base.lower())

if name in PER_FLASH_TYPE_UBOOT:
# Board variants (e.g. ``:emmc``) do not change the per-flash build.
return f"u-boot-{name}-{kind}.bin"

if name in CV6XX_BOOT_VARIANTS:
variants = CV6XX_BOOT_VARIANTS[name]
Expand All @@ -136,19 +181,22 @@ def asset_name(chip: str) -> str | None:
return None


def firmware_url(chip: str) -> str | None:
def firmware_url(chip: str, flash_type: str | None = None) -> str | None:
"""Get the OpenIPC download URL for a chip, or None if unavailable."""
name = asset_name(chip)
name = asset_name(chip, flash_type)
return f"{OPENIPC_BASE_URL}/{name}" if name else None


def has_firmware(chip: str) -> bool:
def has_firmware(chip: str, flash_type: str | None = None) -> bool:
"""Check if firmware can be obtained for this chip.

True when OpenIPC publishes an image *or* one is already cached — the
latter keeps hand-seeded blobs working for chips with no published build.
"""
return firmware_url(chip) is not None or get_cached_path(chip) is not None
return (
firmware_url(chip, flash_type) is not None
or get_cached_path(chip, flash_type) is not None
)


def _legacy_cache_name(chip: str) -> str:
Expand All @@ -159,23 +207,36 @@ def _legacy_cache_name(chip: str) -> str:
return f"u-boot-{CHIP_TO_FIRMWARE.get(base, base)}-universal.bin"


def get_cached_path(chip: str) -> Path | None:
def _cached_file(name: str) -> Path | None:
path = get_cache_dir() / name
if path.exists() and path.stat().st_size > 0:
return path
return None


def get_cached_path(chip: str, flash_type: str | None = None) -> Path | None:
"""Get the path to cached firmware, or None if not cached.

A registered classic board variant must never fall back to the chip-wide
universal cache entry: doing so could select incompatible DDR init data.

Per-flash-type SoCs report only their exact ``-nor``/``-nand`` entry, so a
universal image cached from the retired build cannot shadow the published
one. download_firmware() still reads that universal entry for NOR, as a
last resort when the download itself fails.
"""
cache_dir = get_cache_dir()
base, variant = _split_variant(chip)
name = CHIP_TO_FIRMWARE.get(base, base)
candidates: list[str | None] = [asset_name(chip)]
if not (variant is not None and name in CLASSIC_UBOOT_VARIANTS):
candidates: list[str | None] = [asset_name(chip, flash_type)]
if not uses_per_flash_type_uboot(chip) and not (
variant is not None and name in CLASSIC_UBOOT_VARIANTS
):
candidates.append(_legacy_cache_name(chip))
for candidate in candidates:
if not candidate:
continue
path = cache_dir / candidate
if path.exists() and path.stat().st_size > 0:
path = _cached_file(candidate)
if path is not None:
return path
return None

Expand Down Expand Up @@ -224,12 +285,15 @@ def _unavailable_message(chip: str) -> str:
def download_firmware(
chip: str,
on_progress: Callable[[int, int], None] | None = None,
flash_type: str | None = None,
) -> Path:
"""Download U-Boot firmware from OpenIPC, with caching.

Args:
chip: Chip name (e.g., "hi3516ev300").
on_progress: Optional callback(bytes_downloaded, total_bytes).
flash_type: "nor" or "nand" for SoCs that publish one U-Boot per
flash type (NOR when omitted); other chips ignore it.

Returns:
Path to the downloaded (or cached) firmware file.
Expand All @@ -238,26 +302,43 @@ def download_firmware(
ValueError: If no firmware is available for this chip.
ConnectionError: If download fails.
"""
url = firmware_url(chip)
url = firmware_url(chip, flash_type)
if url is None:
# Check the cache before giving up: a chip with no published build may
# still have a hand-seeded blob.
cached = get_cached_path(chip)
cached = get_cached_path(chip, flash_type)
if cached is not None:
logger.info("Using cached firmware: %s", cached)
return cached
raise ValueError(_unavailable_message(chip))

# Check cache
cached = get_cached_path(chip)
cached = get_cached_path(chip, flash_type)
if cached is not None:
logger.info("Using cached firmware: %s", cached)
return cached

# Download
name = asset_name(chip)
name = asset_name(chip, flash_type)
assert name is not None # firmware_url() is None otherwise
return _download(url, get_cache_dir() / name, on_progress)
try:
return _download(url, get_cache_dir() / name, on_progress)
except ConnectionError:
# A universal image cached before the per-flash-type split still boots
# a NOR board, so keep it usable offline. It is never downloaded, and
# never used for NAND, whose split layout it carries is retired.
if (
uses_per_flash_type_uboot(chip)
and normalize_flash_type(flash_type) == "nor"
):
legacy = _cached_file(_legacy_cache_name(chip))
if legacy is not None:
logger.warning(
"Download of %s failed; using cached legacy %s",
name, legacy.name,
)
return legacy
raise


def _download(
Expand Down
Loading
Loading