diff --git a/.github/workflows/.gitignore b/.github/workflows/.gitignore new file mode 100644 index 0000000..a3e4b41 --- /dev/null +++ b/.github/workflows/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +dl/ +out/ +workdir/ +*.pkg diff --git a/.github/workflows/_camhi2oipc.sh b/.github/workflows/_camhi2oipc.sh new file mode 100755 index 0000000..b8e36fe --- /dev/null +++ b/.github/workflows/_camhi2oipc.sh @@ -0,0 +1,138 @@ +#!/bin/bash + +##### +## Build a PIHC-format firmware that the camhi (Hi3516CV610) vendor's +## `upgrade.cgi` admin endpoint accepts as a normal firmware update. +## +## STATUS: UNTESTED ON HARDWARE. The container format is reverse-engineered +## and the packer is unit-tested, but no end-to-end flash has been performed. +## See camhi-NOTES.md for what is verified vs assumed. +## +## Inputs (env vars): +## SOC — OpenIPC SoC tag (default hi3516cv6xx — the only Hi3516CV610 +## build OpenIPC currently publishes). +## RELEASE — OpenIPC flavor (default ultimate; lite is not published for +## this SoC, and may be required if rootfs does not fit — see the +## size check below). +## HARDWARE — Hardware name stamped into u-boot env (cosmetic). +## OSMEM — kernel "mem=" value (default 46400KB — the vendor's own value). +## TOTALMEM — totalmem env (default 64M). +## +## Dependencies: u-boot-tools (mkenvimage) OR the bundled uboot_env.py fallback; +## python3 (>= 3.8); tar. +##### + +set -euo pipefail + +SOC="${SOC:-hi3516cv6xx}" +RELEASE="${RELEASE:-ultimate}" +HARDWARE="${HARDWARE:-CAMHI_CV610}" +OSMEM="${OSMEM:-46400KB}" +TOTALMEM="${TOTALMEM:-64M}" + +WORKDIR="${WORKDIR:-workdir}" +OUTPUTDIR="${OUTPUTDIR:-..}" +TARBALL="${TARBALL:-openipc.${SOC}-nor-${RELEASE}.tgz}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +# Partition geometry for the Hi3516CV610 camhi reference device. Verified from +# the live device on 2026-06-06 (/proc/mtd + /proc/cmdline): +# mtdparts=sfc:192K(boot),64K(env),2112K(kernel),3456K(rootfs),10560K(ipc) +# 16 MB SPI NOR, 64 KB erase blocks. +ADDR_KERNEL_START="0x00040000" +ADDR_ROOTFS_START="0x00250000" +ENV_SIZE="0x10000" # 64 KB env partition (mtd1) +KERNEL_PART_SIZE=$((0x250000 - 0x40000)) # 2,162,688 bytes (mtd2) +ROOTFS_PART_SIZE=$((0x5B0000 - 0x250000)) # 3,538,944 bytes (mtd3) + +mkdir -p "${WORKDIR}" "${OUTPUTDIR}" + +tar -xvz -f "${TARBALL}" -C "${WORKDIR}/" --exclude "*.md5sum" || { + echo "Error: cannot untar ${TARBALL}." >&2 + exit 1 +} + +KERNEL_SRC=$(ls "${WORKDIR}"/uImage* 2>/dev/null | head -n1 || true) +ROOTFS_SRC=$(ls "${WORKDIR}"/rootfs.squashfs* 2>/dev/null | head -n1 || true) +[[ -z "${KERNEL_SRC}" || -z "${ROOTFS_SRC}" ]] && { + echo "Error: ${TARBALL} missing uImage* or rootfs.squashfs*." >&2 + exit 1 +} + +# Size guard. The reference vendor rootfs already fills the 3456 KB partition +# (df reported /dev/root 3.3M used 3.3M 100%), so OpenIPC's squashfs has very +# little headroom here. If the ultimate build overflows, a lite build or a +# repartition is required — fail loudly rather than produce a brick. +KERNEL_SIZE=$(wc -c <"${KERNEL_SRC}") +ROOTFS_SIZE=$(wc -c <"${ROOTFS_SRC}") +if (( KERNEL_SIZE > KERNEL_PART_SIZE )); then + echo "Error: kernel ${KERNEL_SIZE} > kernel partition ${KERNEL_PART_SIZE}." >&2 + exit 1 +fi +if (( ROOTFS_SIZE > ROOTFS_PART_SIZE )); then + echo "Error: rootfs ${ROOTFS_SIZE} > rootfs partition ${ROOTFS_PART_SIZE}." >&2 + echo " Try RELEASE=lite, or repartition (out of scope for this build)." >&2 + exit 1 +fi + +# u-boot env (flashed to mtd1). Two deliberate changes from the vendor env +# (verified vendor /proc/cmdline shown for reference): +# 1. init=/bin/sh -> init=/init (OpenIPC runs /init as PID 1) +# 2. last partition renamed "ipc" -> "rootfs_data" so OpenIPC's overlay +# mechanism finds a writable data partition where it expects one. The +# offset/size are unchanged; only the mtdparts label differs. OpenIPC's +# `firstboot` is expected to format this overlay on first boot. UNTESTED. +# Everything else (mem, console, mtdparts geometry) is kept as the vendor set +# it, to stay in a configuration the SoC is known to boot. +cat >"${WORKDIR}/u-boot.env.txt" </dev/null 2>&1; then + mkenvimage -s "${ENV_SIZE}" -o "${WORKDIR}/bootarg.img" "${WORKDIR}/u-boot.env.txt" +else + python3 "${HERE}/uboot_env.py" -s "${ENV_SIZE}" -o "${WORKDIR}/bootarg.img" "${WORKDIR}/u-boot.env.txt" +fi + +# Pad kernel/rootfs to their partition sizes with 0xFF (NOR erased state). +# LC_ALL=C forces tr into byte mode; without it the macOS BSD tr treats 0xFF +# as a multibyte UTF-8 sequence and doubles the output. +pad_to() { + local src="$1" dst="$2" size="$3" cur need + cur=$(wc -c <"${src}") + cp "${src}" "${dst}" + if (( cur < size )); then + need=$((size - cur)) + dd if=/dev/zero bs=1 count="${need}" 2>/dev/null | LC_ALL=C tr '\000' '\377' >>"${dst}" + fi +} +pad_to "${KERNEL_SRC}" "${WORKDIR}/kernel.img" "${KERNEL_PART_SIZE}" +pad_to "${ROOTFS_SRC}" "${WORKDIR}/rootfs.img" "${ROOTFS_PART_SIZE}" + +# boot.img is intentionally omitted: leaving the vendor u-boot in place keeps +# TFTP recovery available if the conversion goes wrong. +# ipc.img is intentionally omitted: the vendor app store (mtd4) is not written; +# OpenIPC reformats that space as its overlay on first boot. +# upgrade.zip stays empty (pihc_pack.py supplies a 22-byte EOCD-only ZIP, +# below the 1024-byte Hichip-cipher threshold). + +OUTPUT="${OUTPUTDIR}/openipc.${SOC}.${HARDWARE}.pkg" +python3 "${HERE}/pihc_pack.py" \ + --bootarg "${WORKDIR}/bootarg.img" \ + --kernel "${WORKDIR}/kernel.img" \ + --rootfs "${WORKDIR}/rootfs.img" \ + --filename "openipc.pkg" \ + -o "${OUTPUT}" + +echo "Built ${OUTPUT}" +ls -la "${OUTPUT}" diff --git a/.github/workflows/camhi-NOTES.md b/.github/workflows/camhi-NOTES.md new file mode 100644 index 0000000..d72a86c --- /dev/null +++ b/.github/workflows/camhi-NOTES.md @@ -0,0 +1,71 @@ +# camhi (Hi3516CV610) — engineering notes + +> **Status: UNTESTED on hardware.** The container format and partition +> geometry below are reverse-engineered from the vendor `ipc_server` binary +> and read from one live reference device. The packer is unit-tested. **No +> end-to-end conversion flash has been performed.** Treat the produced `.pkg` +> as a candidate to validate on a spare unit with UART, not a finished +> product. + +## Verified (vendor binary + live device, 2026-06-06) + +Reference device: CamHi-app camera, web banner `Server: Hipcam`, +`getsysinfo.cgi` returns `devid="IPCAM"`, CGI prefix `/cgi-bin/hi3510/`. + +* **SoC:** Hisilicon **Hi3516CV610**. Vendor MPP banner + `HI3516CV610_MPP_V1.0.1.0 B040 Release`; loader script `load3516cv610`. +* **Kernel:** Linux 5.10.221, musl, dual ARMv7 Cortex-A7. +* **Flash:** 16 MB SPI NOR (`sfc`), 64 KB erase blocks. From `/proc/mtd` and + `/proc/cmdline`: + + | mtd | name | size | offset | + |-----|--------|-----------------|-------------| + | 0 | boot | 0x030000 (192K) | 0x000000 | + | 1 | env | 0x010000 (64K) | 0x030000 | + | 2 | kernel | 0x210000 (2112K)| 0x040000 | + | 3 | rootfs | 0x360000 (3456K)| 0x250000 | + | 4 | ipc | 0xA50000 (10560K)| 0x5B0000 | + +* **Vendor bootargs:** `... root=/dev/mtdblock3 rootfstype=squashfs + mtdparts=sfc:192K(boot),64K(env),2112K(kernel),3456K(rootfs),10560K(ipc) + init=/bin/sh`. The `init=/bin/sh` is why OpenIPC needs an env override to + `init=/init`. +* **Root filesystem:** squashfs on mtd3, mounted read-only; `df` shows it + **100% full at 3.3 M** — i.e. almost no headroom in the 3456 K partition. +* **Upload path:** `upgrade.cgi` (admin auth) accepts the PIHC `.pkg`. The web + realm default is `admin`/`admin` (confirmed: `getsysinfo.cgi` answered with + those). The vendor flow is `upgrade.cgi` → in-binary decrypt+stage + (`SysUpdateEx`) → `/mnt/mtd/ipc/upgrade` (a 9484-byte ARM ELF, not a script) + → `/mnt/mtd/ipc/flash_upg.sh boot.img bootarg.img kernel.img rootfs.img + ipc.img upgrade.zip`. +* **PIHC container format:** 512-byte header (`PIHC` magic 0x43484950, type + 4098, six component lengths, six `MD5(component||"IPCAM")` hex strings), + then concatenated component bytes. Integrity is MD5-only, no signature. + Encoded in `pihc_pack.py` with unit tests. + +## Assumed / NOT verified — validate before trusting + +* **That the `.pkg` flashes and boots OpenIPC.** No hardware flash done. +* **`flash_upg.sh` exact behaviour.** It is 416 bytes; we did not capture its + contents (telnet on the reference unit was unstable). The component→mtd + mapping (boot→mtd0 … ipc→mtd4) is inferred from the argument order and the + staged filenames, not confirmed by reading the script. +* **Overlay partition.** The build relabels mtd4 `ipc` → `rootfs_data` in the + new env so OpenIPC's overlay finds a writable area. Whether OpenIPC's + `firstboot`/overlay actually adopts it (and formats the old jffs2) is + untested. +* **rootfs fits.** OpenIPC's `hi3516cv6xx` ultimate squashfs may exceed the + 3456 K rootfs partition (the vendor's own rootfs already fills it). The + build script aborts if it overflows; a lite build or repartition may be + needed. OpenIPC currently publishes only the ultimate flavor for this SoC. +* **Sensor.** Unknown — `sensor.conf` on the reference unit was not read. + No sensor is baked into the build; set it post-flash with + `fw_setenv sensor `. +* **Root shell password.** Not publicly known and not a fixed vendor default; + obtaining a shell on a stock unit needs UART or a u-boot env override. + +## Recovery + +The build omits the `boot.img` component, so the vendor u-boot is preserved +and TFTP recovery via UART remains available. This is the intended safety net +while the conversion is still unverified. diff --git a/.github/workflows/camhi.yml b/.github/workflows/camhi.yml new file mode 100644 index 0000000..f67fea0 --- /dev/null +++ b/.github/workflows/camhi.yml @@ -0,0 +1,57 @@ +name: camhi to OpenIPC + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: setenv + run: | + TAG_NAME="camhi" + RELEASE_NAME="OpenIPC Firmware" + PRERELEASE=true + echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV + echo "RELEASE_NAME=$RELEASE_NAME" >> $GITHUB_ENV + echo "PRERELEASE=$PRERELEASE" >> $GITHUB_ENV + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y u-boot-tools python3 zip + + - name: Checkout + uses: actions/checkout@v4 + + - name: Self-test PIHC packer + run: | + cd "$GITHUB_WORKSPACE" + python3 tests/test_pihc_pack.py + + - name: Fetch OpenIPC release + run: | + mkdir -p "$GITHUB_WORKSPACE/.github/workflows/dl" && cd "$_" + # Hi3516CV610 is published under the cv6xx tag in OpenIPC/firmware's + # `latest` release. Only the `ultimate` flavor is currently built. + wget -q https://github.com/OpenIPC/firmware/releases/download/latest/openipc.hi3516cv6xx-nor-ultimate.tgz + + - name: Build firmware + run: | + cd "$GITHUB_WORKSPACE/.github/workflows/dl" + export OUTPUTDIR="$GITHUB_WORKSPACE/out" + mkdir -p "$OUTPUTDIR" + # A single generic build. Sensor identity is left to OpenIPC's + # boot-time autodetection or a manual post-flash `fw_setenv sensor`. + SOC=hi3516cv6xx RELEASE=ultimate HARDWARE=CAMHI_CV610 \ + bash "$GITHUB_WORKSPACE/.github/workflows/_camhi2oipc.sh" + + - name: Upload Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ env.TAG_NAME }} + name: ${{ env.RELEASE_NAME }} + prerelease: ${{ env.PRERELEASE }} + files: out/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pihc_pack.py b/.github/workflows/pihc_pack.py new file mode 100755 index 0000000..5d4532b --- /dev/null +++ b/.github/workflows/pihc_pack.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +PIHC firmware container packer (camhi / Hi3516CV610 family). + +Builds the "PIHC" `.pkg` container that the vendor's `upgrade.cgi` admin +endpoint accepts as a firmware update. The format was reverse-engineered from +the vendor `ipc_server` binary (functions `sub_37EA4` / `sub_36A70` / +`sub_36D64`) on a CamHi-app Hi3516CV610 reference device. + +WHAT IS VERIFIED vs ASSUMED +--------------------------- +Verified by reading the vendor binary and the live device: + * the 512-byte header layout (magic, type, lengths, MD5+"IPCAM" strings), + * the per-component MD5+"IPCAM" integrity check (`sub_1CA00`), + * the empty-zip / no-cipher code path (see below), + * the component->partition mapping (boot/bootarg/kernel/rootfs/ipc). +NOT verified — no end-to-end flash has been performed: + * that a packed `.pkg` actually flashes and boots OpenIPC on hardware. + +THE "ZIP" COMPONENT CIPHER +-------------------------- +The final "zip" component is wrapped in a CUSTOM Hichip block cipher +(`sub_5A2C0` / `sub_59F18`). It is NOT AES — the binary contains no AES +S-box / inverse S-box / Rcon tables (checked). The cipher has not been +reversed. + +It does not need to be: the decoder (`sub_37EA4` lines 47049-47132) only +invokes the cipher on chunks at index 1, 17, 33, ... (every 16th 1024-byte +chunk, starting at index 1). Chunk 0 is never decrypted. For a "zip" blob +<= 1024 bytes the cipher is never touched — only a PK-signature mangle is +applied. The OpenIPC conversion keeps the zip slot to a 22-byte empty ZIP, +so the packer never needs the cipher. `hichip_encrypt_chunk` is therefore a +stub that raises NotImplementedError; reversing the cipher (to support +larger payloads) is a separate task. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import struct +import sys +from dataclasses import dataclass +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants extracted from `ipc_server` (Hi3516CV610 vendor firmware). + +PIHC_MAGIC = 0x43484950 # bytes "PIHC" little-endian +TYPE_RESTORE = 4097 # restore.cgi mode — final blob = config_restore.bin +TYPE_UPGRADE = 4098 # upgrade.cgi mode — final blob = upgrade.zip + +# 512-byte header field offsets (validator: sub_36A70, ipc_server.c:45587). +OFF_MAGIC = 0 +OFF_TYPE = 4 +OFF_LEN_BOOT = 8 +OFF_LEN_BOOTARG = 12 +OFF_LEN_KERNEL = 16 +OFF_LEN_ROOTFS = 20 +OFF_LEN_IPC = 24 +OFF_LEN_ZIP = 28 +OFF_FILENAME = 32 # also used by the vendor in log messages +OFF_MD5_BOOT = 72 # 40-byte lowercase-hex MD5 strings, NUL-padded +OFF_MD5_BOOTARG = 112 +OFF_MD5_KERNEL = 152 +OFF_MD5_ROOTFS = 192 +OFF_MD5_IPC = 232 +OFF_MD5_ZIP = 272 +HEADER_SIZE = 512 + +# Hichip cipher constants (sub_37EA4:47049-47132). Kept for documentation and +# for a future cipher implementation; not used on the empty-zip path. +HICHIP_KEY_A = b"@Hichip+1208/pkg" +HICHIP_KEY_B = b"$Hichip-1208%aes" +HICHIP_KEY_C = b"#Hichip*1208=key" +HICHIP_CHUNK = 1024 +HICHIP_KEY_PERIOD = 16 # one key change every N chunks + +# MD5 trailer for the integrity check (sub_1CA00 final update). +MD5_TRAILER = b"IPCAM" + +# A pre-built empty ZIP: just an end-of-central-directory record (22 bytes). +EMPTY_ZIP = b"PK\x05\x06" + b"\x00" * 18 +assert len(EMPTY_ZIP) == 22 + + +# --------------------------------------------------------------------------- +# Per-component checksum (sub_1CA00 == MD5(data || "IPCAM")). + + +def md5_ipcam_digest(data: bytes) -> str: + h = hashlib.md5() + h.update(data) + h.update(MD5_TRAILER) + return h.hexdigest() + + +# --------------------------------------------------------------------------- +# ZIP-signature mangle (sub_37EA4:47093-47126). +# +# On disk the .pkg holds PK signatures with the 4th byte +3; the vendor reader +# rewrites them back to canonical. So the packer must emit the +3 variants: +# PK\x03\x04 -> PK\x03\x07 (local file header) +# PK\x01\x02 -> PK\x01\x08 (central directory header) +# PK\x05\x06 -> PK\x05\x09 (end-of-central-directory) + + +def apply_pk_mangle(data: bytes) -> bytes: + buf = bytearray(data) + n = len(buf) + i = 0 + while i + 3 < n: + if buf[i] == 0x50 and buf[i + 1] == 0x4B: # 'P', 'K' + b2, b3 = buf[i + 2], buf[i + 3] + if b2 == 3 and b3 == 4: + buf[i + 3] = 7 + elif b2 == 1 and b3 == 2: + buf[i + 3] = 8 + elif b2 == 5 and b3 == 6: + buf[i + 3] = 9 + i += 1 + return bytes(buf) + + +def hichip_encrypt_chunk(chunk: bytes, key: bytes) -> bytes: + """Stub — see module docstring. The empty-zip path never calls this.""" + raise NotImplementedError( + "Hichip cipher (sub_5A2C0) is unreversed and not implemented. " + f"Keep the zip component <= {HICHIP_CHUNK} bytes to avoid it." + ) + + +def pack_zip_component(zip_bytes: bytes) -> bytes: + if len(zip_bytes) > HICHIP_CHUNK: + raise NotImplementedError( + f"zip component must be <= {HICHIP_CHUNK} bytes (no-cipher path); " + f"got {len(zip_bytes)}. Implement hichip_encrypt_chunk for larger." + ) + return apply_pk_mangle(zip_bytes) + + +# --------------------------------------------------------------------------- +# .pkg builder. + + +@dataclass +class Components: + """A `.pkg` component bundle. `None` entries become zero-length (skipped).""" + + boot: bytes | None = None + bootarg: bytes | None = None + kernel: bytes | None = None + rootfs: bytes | None = None + ipc: bytes | None = None + zip: bytes | None = None + + +def build_header( + *, + type_: int, + filename: str, + boot: bytes | None, + bootarg: bytes | None, + kernel: bytes | None, + rootfs: bytes | None, + ipc: bytes | None, + zip_packed: bytes | None, +) -> bytes: + if len(filename.encode()) >= 40: + raise ValueError("filename must be < 40 bytes") + + hdr = bytearray(HEADER_SIZE) + struct.pack_into(" None: + if not data: + return + struct.pack_into(" bytes: + zip_in = components.zip if components.zip is not None else EMPTY_ZIP + zip_packed = pack_zip_component(zip_in) + header = build_header( + type_=TYPE_UPGRADE, + filename=filename, + boot=components.boot, + bootarg=components.bootarg, + kernel=components.kernel, + rootfs=components.rootfs, + ipc=components.ipc, + zip_packed=zip_packed, + ) + body = io.BytesIO() + body.write(header) + for blob in ( + components.boot, + components.bootarg, + components.kernel, + components.rootfs, + components.ipc, + zip_packed, + ): + if blob: + body.write(blob) + return body.getvalue() + + +# --------------------------------------------------------------------------- +# multipart/form-data wrapper for `upgrade.cgi`. +# +# `sub_36D64` reads the first 32 bytes of the form body (right after the +# multipart \r\n\r\n separator) as a `_DWORD v74[8]` length table, using +# v74[2..7] as component lengths. The PIHC header's first 32 bytes line up +# with that exactly (magic->v74[0], type->v74[1], six lengths->v74[2..7]), so +# the form body is simply the raw .pkg — no extra prefix. A plain +# `curl -F upload=@file.pkg` works just as well as this helper. + + +def build_multipart_body( + pkg_bytes: bytes, + *, + filename: str = "openipc.pkg", + boundary: str = "----coupler-camhi-boundary", +) -> tuple[bytes, str]: + out = io.BytesIO() + out.write(f"--{boundary}\r\n".encode()) + out.write( + f'Content-Disposition: form-data; name="upload"; filename="{filename}"\r\n'.encode() + ) + out.write(b"Content-Type: application/octet-stream\r\n\r\n") + out.write(pkg_bytes) + out.write(f"\r\n--{boundary}--\r\n".encode()) + return out.getvalue(), f"multipart/form-data; boundary={boundary}" + + +# --------------------------------------------------------------------------- +# CLI. + + +def _read_optional(path: str | None) -> bytes | None: + return Path(path).read_bytes() if path else None + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + p.add_argument("--boot", help="boot.img (mtd0 / u-boot) — usually omitted") + p.add_argument("--bootarg", help="bootarg.img (mtd1 / u-boot env)") + p.add_argument("--kernel", help="kernel.img (mtd2)") + p.add_argument("--rootfs", help="rootfs.img (mtd3 / squashfs)") + p.add_argument("--ipc", help="ipc.img (mtd4) — usually omitted") + p.add_argument("--zip", help="zip payload (<= 1024 bytes; default empty)") + p.add_argument("-o", "--output", required=True) + p.add_argument("--filename", default="openipc.pkg") + args = p.parse_args(argv) + + components = Components( + boot=_read_optional(args.boot), + bootarg=_read_optional(args.bootarg), + kernel=_read_optional(args.kernel), + rootfs=_read_optional(args.rootfs), + ipc=_read_optional(args.ipc), + zip=_read_optional(args.zip), + ) + pkg = build_pkg(components, filename=args.filename) + Path(args.output).write_bytes(pkg) + print( + f"wrote {args.output} ({len(pkg)} bytes, type=4098): " + f"boot={len(components.boot or b'')} " + f"bootarg={len(components.bootarg or b'')} " + f"kernel={len(components.kernel or b'')} " + f"rootfs={len(components.rootfs or b'')} " + f"ipc={len(components.ipc or b'')} " + f"zip={len(components.zip or EMPTY_ZIP)}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c55dece --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: tests + +on: + push: + paths: + - '.github/workflows/pihc_pack.py' + - '.github/workflows/uboot_env.py' + - '.github/workflows/_camhi2oipc.sh' + - '.github/workflows/camhi.yml' + - '.github/workflows/tests.yml' + - 'tests/**' + pull_request: + paths: + - '.github/workflows/pihc_pack.py' + - '.github/workflows/uboot_env.py' + - '.github/workflows/_camhi2oipc.sh' + - '.github/workflows/camhi.yml' + - '.github/workflows/tests.yml' + - 'tests/**' + +jobs: + pihc-packer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Unit tests (PIHC packer) + run: python3 tests/test_pihc_pack.py + - name: End-to-end build smoke test + # Exercises _camhi2oipc.sh end to end with a synthetic OpenIPC tarball + # and the pure-Python uboot_env.py fallback (no u-boot-tools needed). + run: bash tests/test_build_e2e.sh diff --git a/.github/workflows/uboot_env.py b/.github/workflows/uboot_env.py new file mode 100755 index 0000000..a9b69d7 --- /dev/null +++ b/.github/workflows/uboot_env.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +""" +Minimal u-boot env image generator — equivalent to `mkenvimage -s SIZE` for +the non-redundant default layout: a 4-byte little-endian CRC32 of the env +body, followed by NUL-separated KEY=VALUE entries, double-NUL terminated, and +padded to SIZE with 0xFF. + +Used by `_camhi2oipc.sh` when `mkenvimage` is not on PATH (e.g. local dev on +macOS) so the build does not require u-boot-tools to be installed. +""" + +from __future__ import annotations + +import argparse +import sys +import zlib +from pathlib import Path + + +def build_env(text: str, size: int) -> bytes: + body = bytearray() + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ValueError(f"env line missing '=': {line!r}") + body.extend(line.encode()) + body.append(0) + body.append(0) # double-NUL terminator + + payload_max = size - 4 # 4 bytes reserved for the leading CRC + if len(body) > payload_max: + raise ValueError( + f"env body ({len(body)} bytes) exceeds partition ({payload_max} bytes)" + ) + body.extend(b"\xff" * (payload_max - len(body))) + crc = zlib.crc32(bytes(body)) & 0xFFFFFFFF + return crc.to_bytes(4, "little") + bytes(body) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + p.add_argument("-s", "--size", required=True, help="env partition size (e.g. 0x10000)") + p.add_argument("-o", "--output", required=True) + p.add_argument("input", nargs="?", help="env source file (default: stdin)") + args = p.parse_args(argv) + + size = int(args.size, 0) + text = Path(args.input).read_text() if args.input else sys.stdin.read() + out = build_env(text, size) + Path(args.output).write_bytes(out) + print( + f"wrote {args.output} ({len(out)} bytes, body crc=0x{int.from_bytes(out[:4], 'little'):08x})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/README.md b/README.md index f782607..cc749b6 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,42 @@ For example: ```16AV300 IMX415b``` ## Flashing Get appropriate [file for your model](https://github.com/OpenIPC/coupler/releases/tag/herospeed) and flash it via web. +# camhi / Hichip (Hi3516CV610) — (Untested!) + +**WARNING: this target has not yet been validated on hardware.** The +firmware container is reverse-engineered and the builder is unit-tested, but +no end-to-end conversion has been confirmed to boot OpenIPC. Only attempt on a +spare unit with UART access for recovery. See +[`.github/workflows/camhi-NOTES.md`](.github/workflows/camhi-NOTES.md) for the +verified facts and the list of open questions. + +CamHi-app cameras on the **Hisilicon Hi3516CV610** SoC, using the proprietary +"PIHC" firmware container. Reference unit: vendor MPP +`HI3516CV610_MPP_V1.0.1.0 B040 Release`, 16 MB SPI NOR laid out as +`192K(boot) 64K(env) 2112K(kernel) 3456K(rootfs) 10560K(ipc)`. + +## Identifying the device +The web UI answers `http://CAM/cgi-bin/hi3510/getsysinfo.cgi` and reports +`Server: Hipcam`. The web realm default credentials are `admin` / `admin`. + +## Flashing +POST the `.pkg` to the admin-authenticated upgrade endpoint: + +```sh +curl -u admin:admin -F "upload=@openipc.hi3516cv6xx.CAMHI_CV610.pkg" \ + http://CAM/cgi-bin/upgrade.cgi +``` + +The camera stages the image, runs its own flash sequence, and reboots. Sensor +identity is not baked in — if video is absent after the first boot, set it +with `fw_setenv sensor ` (known names live in `/usr/bin/load_hisilicon` +on the running OpenIPC image). + +## Rollback +The build leaves the vendor u-boot in place (no `boot.img` component), so +standard TFTP-from-u-boot recovery applies. UART is required for the safest +rollback. + ----- ### Support diff --git a/tests/test_build_e2e.sh b/tests/test_build_e2e.sh new file mode 100755 index 0000000..22c89ea --- /dev/null +++ b/tests/test_build_e2e.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# +# End-to-end smoke test for the camhi build pipeline. Synthesises a fake +# OpenIPC release tarball, runs _camhi2oipc.sh against it, and validates the +# produced .pkg (magic, type, component layout, MD5+"IPCAM" checksums, and the +# critical u-boot env changes). Uses the pure-Python uboot_env.py fallback, so +# it needs no u-boot-tools. Exits non-zero on any failure. +# +# Run: bash tests/test_build_e2e.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="${REPO_ROOT}/.github/workflows/_camhi2oipc.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +# --- synthesise a fake OpenIPC hi3516cv6xx release tarball ----------------- +mkdir -p "${TMP}/staging" +{ printf 'FAKE-UIMAGE'; dd if=/dev/zero bs=1024 count=8 2>/dev/null; } > "${TMP}/staging/uImage.hi3516cv6xx" +{ printf 'FAKE-ROOTFS'; dd if=/dev/zero bs=1024 count=12 2>/dev/null; } > "${TMP}/staging/rootfs.squashfs.hi3516cv6xx" +tar -czf "${TMP}/openipc.hi3516cv6xx-nor-ultimate.tgz" -C "${TMP}/staging" . + +# --- run the build --------------------------------------------------------- +OUTPUTDIR="${TMP}/out" \ +WORKDIR="${TMP}/work" \ +TARBALL="${TMP}/openipc.hi3516cv6xx-nor-ultimate.tgz" \ +SOC=hi3516cv6xx RELEASE=ultimate HARDWARE=CAMHI_CV610 \ + bash "${BUILD}" + +PKG="${TMP}/out/openipc.hi3516cv6xx.CAMHI_CV610.pkg" +[ -f "${PKG}" ] || { echo "FAIL: .pkg not produced"; exit 1; } + +# --- validate the produced .pkg -------------------------------------------- +python3 - "${PKG}" <<'PY' +import struct, hashlib, sys +d = open(sys.argv[1], "rb").read() + +assert d[:4] == b"PIHC", f"bad magic {d[:4]!r}" +typ = struct.unpack(" dict: + assert len(header) == p.HEADER_SIZE + + def cstr(off: int, n: int) -> str: + end = header.find(b"\x00", off, off + n) + end = off + n if end < 0 else end + return header[off:end].decode("ascii") + + return { + "magic": struct.unpack_from("1024-byte zip") + + +# --- multipart wrapper (sub_36D64 form parser) ----------------------------- + + +def test_multipart_body_is_raw_pkg_with_header_as_v74(): + boot, kernel = b"B" * 7, b"K" * 13 + pkg = p.build_pkg(p.Components(boot=boot, kernel=kernel)) + body, ctype = p.build_multipart_body(pkg) + assert ctype.startswith("multipart/form-data; boundary=") + sep = body.find(b"\r\n\r\n") + assert sep != -1 + after = body[sep + 4 : sep + 4 + 32] + assert after == pkg[:32] # PIHC header's first 32 bytes == receiver's v74[8] + fields = struct.unpack("<8I", after) + assert fields[0] == p.PIHC_MAGIC # v74[0] unused by receiver + assert fields[1] == p.TYPE_UPGRADE # v74[1] unused by receiver + assert fields[2] == 7 # boot + assert fields[3] == 0 # bootarg + assert fields[4] == 13 # kernel + assert fields[5] == 0 # rootfs + assert fields[6] == 0 # ipc + assert fields[7] == len(p.apply_pk_mangle(p.EMPTY_ZIP)) # zip + assert body[sep + 4 + len(pkg) :].startswith(b"\r\n--") + + +# --- runner ---------------------------------------------------------------- + + +def _run() -> int: + import traceback + + tests = [(n, g) for n, g in globals().items() if n.startswith("test_") and callable(g)] + failed = 0 + for name, fn in sorted(tests): + try: + fn() + print(f"PASS {name}") + except Exception as e: # noqa: BLE001 + print(f"FAIL {name}: {e}") + traceback.print_exc() + failed += 1 + print(f"\n{len(tests) - failed} passed, {failed} failed (of {len(tests)})") + return failed + + +if __name__ == "__main__": + sys.exit(_run())