Skip to content

install: add Hikvision U-Boot migration for HiWatch DS-I203 - #137

Merged
openipc-ai merged 5 commits into
OpenIPC:masterfrom
ArthurKoba:install/stock-uboot-migration
Sep 16, 2026
Merged

openipc-ai merged 5 commits into
OpenIPC:masterfrom
ArthurKoba:install/stock-uboot-migration

Conversation

@ArthurKoba

@ArthurKoba ArthurKoba commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

While porting OpenIPC to a HiWatch DS-I203 (HI3518EV100, IMX122, 256 MiB DDR3, 16 MiB SPI NOR), I found that Defib's normal HiSilicon boot-ROM recovery path is not usable through the camera's exposed UART.

The recovery path available on the stock camera is Hikvision U-Boot 2010.06: Ctrl+U enters the HKVS # console, and loady can receive a replacement U-Boot over YMODEM.

The board also needs the DDR3/256 MiB OpenIPC U-Boot added here: OpenIPC/u-boot-hi3516cv100#6
The matching OpenIPC device profile is here: OpenIPC/builder#159

This PR connects that stock Hikvision recovery path to Defib's normal install flow and hardens the shared install/transport code found during review.

What changed

  • Added a reusable stock-U-Boot bootstrap interface and registry, with Hikvision as the first implementation. This remains separate from BootProtocol: boot protocols describe SoC boot-ROM recovery, while vendors.* starts from an already-running vendor U-Boot.
  • Added Hikvision console handling for warm attach, Ctrl+U, HKVS #, factory-MAC capture, loady/YMODEM, go, and detection of an already-running OpenIPC U-Boot.
  • Stock-console commands that can alter control flow, including loady and go, now use UART echo verification before the terminating CR is sent.
  • Added classic U-Boot variants so hi3518ev100:hiwatch-ds-i203 resolves to u-boot-hi3518ev100-ddr3-256m-universal.bin without changing the generic hi3518ev100 artifact. --uboot remains available for a local override.
  • Chainload now sends the raw U-Boot binary over YMODEM. Padding to the fixed boot partition size is only applied to the flash image.
  • Moved the installer implementation out of cli/app.py into defib.install.
  • Added runtime NOR detection and standard OpenIPC 8/16/32 MiB layouts. --nor-size is a genuine explicit override; the CLI default is now 0, meaning auto-detect.
  • Restored the historical explicit RAM address on generic/download-mode tftpboot; only the stock-U-Boot path uses the short loadaddr form.
  • Added an explicit hi3518ev100 RAM base instead of relying on prefix/fallback ordering.
  • Made TFTP and flash CRC verification fail closed when U-Boot output is missing or malformed.
  • Added full rootfs_data erase verification.
  • Preserved the factory ethaddr across environment replacement. Installer-only values such as DS-I203 phyaddru=3 remain transient; persistent runtime board policy stays in the Builder profile.
  • Persistent environment verification now checks the actual SPI contents after saveenv: Defib re-probes SPI, reads the env partition, computes its data CRC, and compares it to the stored environment CRC.
  • Transport failures, including TransportTimeout, now use the controlled installer failure path and release UART/power/TFTP resources. Pre-TFTP environment verification failures are covered as well.
  • SerialTransport.flush_output() uses a bounded out_waiting drain instead of unbounded tcdrain() or output-buffer purging.
  • RFC2217 cannot provide a reliable remote TX-drain primitive through pyserial, so its flush_output() is intentionally an explicit no-op and never uses PURGE_DATA.
  • Restored --output json error behavior for installer preflight failures.
  • Tightened U-Boot command-result error parsing so unrelated banner text such as optional calibration failures does not look like a destructive flash-command failure.
  • Removed unused environment/layout helpers and duplicate imports, exposed stock-U-Boot selectors through list-chips, and added protocol-level YMODEM tests including retry and final-handshake behavior.
  • Added --stage / --skip-stage install controls in a separate commit for targeted development/recovery validation. The normal no-flag production flow is unchanged; explicit stage selection only performs the requested persistent operations and starts TFTP only when required.

Hardware verification

Final end-to-end acceptance was performed on a physical HiWatch DS-I203:

  • Hi3518EV100
  • Sony IMX122
  • 256 MiB DDR3
  • GD25Q128 16 MiB SPI NOR
  • factory MAC preserved

The final migration run used:

python -m defib install `
  -c hi3518ev100:hiwatch-ds-i203 `
  --firmware "$HOME\Downloads\hiwatch-ds-i203-202609151816.tgz" `
  --uboot "$HOME\Downloads\u-boot-hi3518ev100-ddr3-256m-universal.bin" `
  --wipe-env `
  -p COM15 `
  --tftp-via host `
  --nic "Беспроводная сеть" `
  --host-ip 192.168.1.11 `
  --device-ip 192.168.1.64 `
  --no-final-reset `
  -d

The run completed:

  • genuine Hikvision U-Boot 2010.06 autoboot interruption and HKVS # entry;
  • verified UART echo for loady and go;
  • raw U-Boot YMODEM transfer;
  • OpenIPC U-Boot chainload;
  • 16 MiB NOR detection;
  • CRC-verified U-Boot, kernel and rootfs flashing;
  • verified rootfs_data erase;
  • explicit environment erase and mandatory internal reset;
  • compiled OpenIPC defaults loading;
  • detected-layout mtdparts persistence;
  • factory MAC restoration;
  • saveenv;
  • SPI re-probe and physical environment readback;
  • stored/data CRC comparison (SPI CRC 7A58A0B5);
  • final device reset intentionally skipped, leaving the board at the OpenIPC U-Boot prompt.

The environment-only stage path was also exercised against an already-running OpenIPC U-Boot while validating the post-reset SPI re-probe and persistent CRC check.

Earlier complete device-profile acceptance also confirmed 256 MiB physical RAM, the intended 128 MiB Linux / 128 MiB MMZ split, PHY address 3 / MDIO0, IMX122, and Majestic startup.

Testing

Final local gate on the published tree:

  • 873 passed, 10 skipped, 4 deselected in the full Python suite.
  • The four deselected tests are the already-reproduced Windows host baseline cases: localized netsh decoding and Windows symlink privilege.
  • 16 passed in tests/fuzz.
  • Ruff 0.15.8 from the repository lockfile: clean on src/ and tests/.
  • strict mypy: clean across 77 source files.
  • git diff --check: clean.
  • Targeted installer/UART/YMODEM/environment/stage regression suites also pass.

The first three commits were also rewritten to include the rationale, verification commands, and hardware evidence required by CLAUDE.md.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add Hikvision U-Boot migration for HiWatch DS-I203

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds reusable Hikvision stock U-Boot chainloading for HiWatch DS-I203 via YMODEM.
• Refactors installation with flash detection, verified writes, environment migration, and MAC
 preservation.
• Adds dedicated U-Boot resolution, UART safety, documentation, and end-to-end contract tests.
Diagram

graph TD
  CLI["Install CLI"] --> RES["Artifact Resolver"] --> MODE{"Recovery Path"}
  MODE -->|Stock| HIK["Hikvision Bootstrap"] --> SHELL["OpenIPC Shell"] --> INST["TFTP Installer"] --> NOR["SPI NOR"]
  MODE -->|Boot ROM| ROM["Boot ROM"] --> SHELL
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Separate migration command
  • ➕ Clearly separates stock bootloader replacement from normal firmware installation.
  • ➕ Allows operators to validate the chainloaded U-Boot before starting flash operations.
  • ➖ Duplicates target selection, transport setup, and recovery handling.
  • ➖ Creates a manual handoff where transient network settings or preserved identity could be lost.
  • ➖ Risks divergent verification and safety behavior between migration and installation commands.
2. Use an external YMODEM package
  • ➕ Could reduce ownership of packet framing and protocol edge cases.
  • ➕ May provide broader interoperability with other YMODEM receivers.
  • ➖ Common implementations are synchronous and tied directly to file or pyserial APIs.
  • ➖ Would require adapters for Defib's asynchronous Transport abstraction and progress events.
  • ➖ Hikvision-specific timing, stray-byte handling, and flush semantics would still need custom logic.

Recommendation: Keep the PR's integrated installer and reusable bootstrap registry. It preserves one verified flashing pipeline for both recovery paths, confines Hikvision behavior to a vendor implementation, and keeps board-specific metadata declarative. The focused async YMODEM sender is justified by Defib's transport abstraction and hardware-specific timing requirements; a separate migration command would add operational handoffs without improving safety.

Files changed (25) +3449 / -814

Enhancement (9) +2146 / -8
firmware.pyResolve dedicated classic U-Boot board variants +35/-8

Resolve dedicated classic U-Boot board variants

• Maps the DS-I203 selector to its DDR3/256 MiB U-Boot artifact. Prevents registered variants from falling back to incompatible chip-wide cache entries and improves unknown-variant errors.

src/defib/firmware.py

layout.pyCentralize flash layouts and verification helpers +177/-0

Centralize flash layouts and verification helpers

• Defines standard NOR and NAND layouts, NOR-capacity parsing, MTD strings, alignment, erased-region CRCs, command-error detection, and verified runtime environment assignment.

src/defib/install/layout.py

orchestrator.pyImplement vendor-aware verified installation orchestration +1145/-0

Implement vendor-aware verified installation orchestration

• Moves the complete install workflow from the CLI into a dedicated module and integrates registered stock-U-Boot bootstraps. Adds runtime NOR selection, verified environment and flash operations, rootfs_data cleanup, factory-MAC preservation, persistent environment migration, and optional final reset.

src/defib/install/orchestrator.py

ymodem.pyAdd asynchronous YMODEM sender +215/-0

Add asynchronous YMODEM sender

• Implements CRC16 packet framing, metadata headers, 1 KiB data packets, retries, cancellation handling, EOT negotiation, progress reporting, and transfer statistics over Defib transports.

src/defib/recovery/ymodem.py

uboot_env.pyAdd environment migration and MAC-selection helpers +84/-0

Add environment migration and MAC-selection helpers

• Adds full printenv parsing, bounded variable expansion, semantic value comparison, and installation MAC selection. Vendor migrations can preserve factory identity and safely refuse generated replacements.

src/defib/uboot_env.py

__init__.pyExpose stock-U-Boot bootstrap interfaces +10/-0

Expose stock-U-Boot bootstrap interfaces

• Creates the vendor bootstrap package API and exports its protocol, result type, and factory.

src/defib/vendors/init.py

base.pyDefine reusable stock-U-Boot bootstrap protocol +35/-0

Define reusable stock-U-Boot bootstrap protocol

• Introduces the bootstrap result model and asynchronous protocol used to reach an OpenIPC U-Boot shell while preserving selected environment values.

src/defib/vendors/base.py

hikvision.pyImplement Hikvision stock U-Boot chainloading +360/-0

Implement Hikvision stock U-Boot chainloading

• Handles warm and cold console detection, Ctrl+U interruption, HKVS commands, factory-MAC capture, YMODEM transfer, go execution, and existing OpenIPC detection. Centralizes production timing with scalable test timing.

src/defib/vendors/hikvision.py

registry.pyRegister the DS-I203 Hikvision migration target +85/-0

Register the DS-I203 Hikvision migration target

• Adds reusable bootstrap factories and exact selector metadata. Registers the DS-I203 load address, dedicated Hikvision handler, and transient PHY address required during installation.

src/defib/vendors/registry.py

Bug fix (3) +121 / -12
flashdump.pyVerify legacy UART command echo before execution +111/-10

Verify legacy UART command echo before execution

• Adds optional byte-by-byte command echo verification with cancellation and retry handling. The terminating carriage return is withheld until the entire U-Boot command is acknowledged correctly.

src/defib/flashdump.py

rfc2217.pyDrain RFC2217 output instead of discarding it +5/-1

Drain RFC2217 output instead of discarding it

• Changes flush_output to wait for queued transmission through pyserial. This prevents bootloader commands and YMODEM frames from being truncated.

src/defib/transport/rfc2217.py

serial.pyDrain serial output instead of discarding it +5/-1

Drain serial output instead of discarding it

• Runs pyserial flush asynchronously rather than resetting the output buffer. Queued UART traffic now completes before protocol processing continues.

src/defib/transport/serial.py

Refactor (5) +145 / -787
app.pyDelegate install execution to the install package +51/-786

Delegate install execution to the install package

• Replaces the embedded installer with InstallRequest construction and run_install delegation. Adds U-Boot override, automatic NOR detection defaults, and final-reset control while retaining compatibility aliases for private layout imports.

src/defib/cli/app.py

__init__.pyExpose the modular installation API +6/-0

Expose the modular installation API

• Exports InstallRequest and run_install as the public entry points for installation orchestration.

src/defib/install/init.py

firmware.pyExtract and validate OpenIPC firmware bundles +59/-0

Extract and validate OpenIPC firmware bundles

• Introduces a typed firmware bundle loader that reads kernel and rootfs payloads and validates matching MD5 entries in one archive pass.

src/defib/install/firmware.py

model.pyDefine immutable installer input model +28/-0

Define immutable installer input model

• Adds InstallRequest to carry CLI inputs, including U-Boot override, NOR auto-detection, TFTP routing, and final-reset behavior.

src/defib/install/model.py

loader.pyNormalize protocol import ordering +1/-1

Normalize protocol import ordering

• Reorders local protocol imports without changing profile discovery behavior.

src/defib/profiles/loader.py

Tests (6) +900 / -1
test_ds_i203_final_contract.pyCover complete DS-I203 migration contracts +560/-0

Cover complete DS-I203 migration contracts

• Tests selector metadata, artifact resolution, warm and cold attach states, chainload failures, and missing factory identity. Simulates the full 16 MiB NOR installation to verify padding, offsets, erasure, MAC restoration, and policy isolation.

tests/test_ds_i203_final_contract.py

test_firmware.pyTest classic board-specific U-Boot resolution +32/-1

Test classic board-specific U-Boot resolution

• Verifies DS-I203 artifact and URL selection, preservation of the generic artifact, unknown-variant rejection, and cache isolation from incompatible universal images.

tests/test_firmware.py

test_install_flash_helpers.pyTest layout detection and flash safety helpers +118/-0

Test layout detection and flash safety helpers

• Covers alignment, NOR probe parsing, standard layouts, MTD strings, erased-region CRCs, flash-error recognition, and verified runtime environment retries.

tests/test_install_flash_helpers.py

test_uart_command_integrity.pyTest UART command echo verification +89/-0

Test UART command echo verification

• Verifies that commands execute only after complete echoes, corrupted attempts are cancelled before Enter, and consoles without acknowledgements fail safely.

tests/test_uart_command_integrity.py

test_uboot_env.pyTest environment parsing and identity preservation +81/-0

Test environment parsing and identity preservation

• Covers full environment parsing, reference expansion, semantic comparisons, factory-MAC precedence, vendor migration refusal, and generic rescue-MAC generation.

tests/test_uboot_env.py

test_ymodem.pyTest YMODEM packet primitives +20/-0

Test YMODEM packet primitives

• Validates the CRC16 reference vector, 1 KiB packet framing, sequence complement, and block-zero filename and size metadata.

tests/test_ymodem.py

Documentation (2) +137 / -6
README.mdDocument stock-U-Boot installation and automatic NOR layouts +30/-6

Document stock-U-Boot installation and automatic NOR layouts

• Updates the installation workflow to cover registered stock-U-Boot bootstraps, runtime NOR detection, and board-specific U-Boot variants. Adds a DS-I203 invocation and clarifies ownership of bootloader, installer, and device-profile policy.

README.md

hiwatch-ds-i203.mdDocument the DS-I203 migration contract +107/-0

Document the DS-I203 migration contract

• Describes Hikvision console entry, YMODEM chainloading, U-Boot artifact selection, standard 16 MiB layout, environment migration, and policy boundaries. Records the hardware acceptance results.

docs/boards/hiwatch-ds-i203.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Protocol tools miss Hikvision recovery 📘 Rule violation ⚙ Maintainability
Description
HikvisionUBootBootstrap implements the UART bootstrap independently of BootProtocol, and
_BOOTSTRAPS places it in a separate vendor registry without @register. Any command or extension
using find_protocol or list_protocols sees the generic chip handler rather than the Hikvision
recovery implementation, while only the install path knows about the parallel registry.
Code

src/defib/vendors/registry.py[R31-33]

+_BOOTSTRAPS: dict[str, BootstrapFactory] = {
+    "hikvision": HikvisionUBootBootstrap,
+}
Evidence
Compliance rule 3 requires new UART byte-stream recovery protocols to subclass BootProtocol and
use the supported registration mechanism. The Hikvision class communicates over a byte-stream
transport but is selected from the newly introduced _BOOTSTRAPS dictionary instead of the protocol
registry.

CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly: CLAUDE.md: UART Protocol Plugins Must Be Registered Correctly
src/defib/vendors/hikvision.py[67-79]
src/defib/vendors/registry.py[31-40]
src/defib/protocol/registry.py[22-26]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new Hikvision UART recovery implementation bypasses the repository's `BootProtocol` registration and discovery mechanism by using a separate vendor registry.
## Fix Focus Areas
- src/defib/vendors/hikvision.py[67-360]
- src/defib/vendors/registry.py[31-48]
## Recommended Fix
Expose the Hikvision recovery implementation through a `BootProtocol`-compatible class, decorate it with `@register`, and add a `defib.protocols` entry point if packaged discovery requires one. Preserve any stock-environment metadata through an explicit protocol result extension rather than a separate discovery system.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Stock installs erase saved settings ✓ Resolved 📘 Rule violation ≡ Correctness
Description
run_install enters the environment-migration block whenever has_stock_uboot and not nand and
issues sf erase without checking the default-false wipe_env option. Every stock-U-Boot NOR
migration therefore removes persistent variables other than the few values reconstructed later, even
when the user did not request an environment wipe.
Code

src/defib/install/orchestrator.py[R978-980]

+                env_erase_resp = await _cmd(
+                    f"sf erase 0x{env_off:x} 0x{env_size:x}", timeout=30.0
+                )
Evidence
Compliance rule 7 requires the environment partition to remain preserved unless explicitly wiped.
The request model defaults wipe_env to false, but the new stock migration condition does not
consult it before erasing the environment partition.

CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved
src/defib/install/model.py[22-24]
src/defib/install/orchestrator.py[944-980]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Stock-U-Boot NOR migrations erase the persistent environment even when `wipe_env` remains at its default false value, contrary to the destructive-flash preservation requirement.
## Fix Focus Areas
- src/defib/install/orchestrator.py[944-980]
- src/defib/install/model.py[22-24]
## Recommended Fix
Preserve the environment partition by default. Gate the erase-and-default migration behind explicit user consent such as `wipe_env`, or introduce a separately named migration option that clearly warns which persistent values will be discarded.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Missing checksums still permit flashing ✓ Resolved 📘 Rule violation ☼ Reliability
Description
tftp_and_flash() compares the transferred image and NOR readback checksums only when each crc32
response matches ==> XXXXXXXX, so absent, truncated, malformed, or changed output skips
verification. Because send_command() can return empty or partial output on timeout, the image can
proceed through partition erasure and writing and ultimately reach the success path without either
checksum being validated.
Code

src/defib/install/orchestrator.py[R746-748]

+                m = re_mod.search(r"==>\s*([0-9a-fA-F]{8})", resp)
+                if m:
+                    ram_crc = int(m.group(1), 16)
Evidence
Both checksum checks are guarded by if m and have no failure branch when the regular expression
does not match, while the helper reports success afterward. Since the command reader returns
whatever output it has collected when its timeout expires, missing or incomplete CRC output is
reachable; the stricter environment and rootfs_data checks show the intended behavior by
explicitly rejecting a missing match, consistent with compliance rule 7's requirement for CRC32
verification by default.

CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved: CLAUDE.md: Destructive Flash Safety Properties Must Be Preserved
src/defib/install/orchestrator.py[740-761]
src/defib/install/orchestrator.py[802-829]
src/defib/install/orchestrator.py[740-756]
src/defib/install/orchestrator.py[817-834]
src/defib/flashdump.py[290-314]
src/defib/install/orchestrator.py[998-1010]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`tftp_and_flash()` silently skips RAM and NOR readback verification when U-Boot's `crc32` response lacks a parseable checksum. Empty, truncated, malformed, or changed command output must fail the installation rather than allowing destructive writes or reporting an unverified image as successful.
## Fix Focus Areas
- src/defib/install/orchestrator.py[742-761]
- src/defib/install/orchestrator.py[817-831]
## Recommended Fix
Require each CRC response to contain the expected checksum format and raise a controlled installation error when parsing fails. Reject a missing or mismatched RAM checksum before erasing flash, and reject a missing or mismatched NOR readback checksum before reporting success, applying the same mandatory-match behavior already used for the environment and `rootfs_data` CRC checks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (1)
4. Installer lines exceed project limit ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
run_install adds multiple statements longer than the configured 100-character limit, including the
device_label assignment on line 209. These unexcepted lines make the changed installer
inconsistent with the repository's required Python formatting contract and complicate later
automated formatting changes.
Code

src/defib/install/orchestrator.py[209]

+                device_label = port_basename.removeprefix("uart-") if port_basename.startswith("uart-") else port_basename
Evidence
Compliance rule 1 explicitly limits changed Python lines to 100 characters, matching the repository
configuration. Added installer lines such as line 209 exceed that limit without an exception.

CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking: CLAUDE.md: Python Changes Must Pass Repository Linting and Strict Type Checking
pyproject.toml[60-65]
src/defib/install/orchestrator.py[198-212]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added installer contains lines longer than the repository's required 100-character maximum without an allowed exception.
## Fix Focus Areas
- src/defib/install/orchestrator.py[198-212]
- src/defib/install/orchestrator.py[726-732]
- src/defib/install/orchestrator.py[805-808]
## Recommended Fix
Wrap overlong assignments, exception clauses, calls, and formatted output using parentheses and one argument or expression component per line, then run the repository Ruff and strict mypy commands over all changed Python files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Valid flash probes abort migration ✓ Resolved 🐞 Bug ≡ Correctness
Description
detect_nor_size_mb() omits recognized formats such as SPI Nor total size: 16MB and `SF: Detected
... total 16MB. When sf probe 0 emits one of these forms without a separate Chip:` field,
stock-U-Boot migration cannot select a layout and exits unless the user supplies an override.
Code

src/defib/install/layout.py[R154-158]

+    patterns = (
+        (r"\bChip:\s*(\d+)\s*MB\b", 1),
+        (r"\bspi\s+size:\s*(\d+)\s*MB\b", 1),
+        (r"\b(\d+)\s+MiB\b[^\n]*(?:hi_sfc|spi)", 1),
+        (r"\b(\d+)\s+KiB\b[^\n]*(?:hi_sfc|spi)", 1024),
Evidence
The installer aborts stock migrations when the new parser returns None. Existing repository tests
and the general flash parser explicitly recognize standalone total size formats that none of the
new parser's four expressions match.

src/defib/install/orchestrator.py[519-545]
src/defib/install/layout.py[150-168]
src/defib/flashdump.py[341-360]
tests/test_flashdump.py[139-141]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new NOR capacity parser recognizes fewer U-Boot probe formats than the repository's existing flash parser. Valid output containing `total size` or `total NMB` can therefore make automatic stock-U-Boot migration abort.
## Fix Focus Areas
- src/defib/install/layout.py[150-168]
- src/defib/install/orchestrator.py[528-545]
## Recommended Fix
Reuse the existing flash-size parser with byte-to-MiB conversion, or broaden `detect_nor_size_mb()` to recognize the repository's documented `SPI Nor total size` and `SF: Detected ... total NMB` forms while retaining protection against matching RAM sizes. Add focused tests for each existing format.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Oversized overrides crash the installer ✓ Resolved 🐞 Bug ☼ Reliability
Description
run_install() passes the newly supported local U-Boot payload directly to pad_to_size() without
handling its ValueError. If an override exceeds the boot partition, the command terminates with an
uncaught traceback instead of a controlled install error explaining that the artifact cannot fit.
Code

src/defib/install/orchestrator.py[R172-175]

+    # Install writes a fixed boot partition. Release/override payloads may be
+    # shorter, so preserve the existing installer contract by padding the tail
+    # with erased flash bytes before chainload and flashing.
+    uboot_data = pad_to_size(uboot_raw, b_sz)
Evidence
The PR adds a user-provided U-Boot path and reads its bytes without constraining their size.
pad_to_size() explicitly raises when those bytes exceed the target, and this call is outside
either of run_install()'s existing ValueError handlers.

src/defib/cli/app.py[2151-2158]
src/defib/install/orchestrator.py[152-175]
src/defib/firmware.py[183-195]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `--uboot` override accepts a local artifact, but an artifact larger than the selected boot partition raises an uncaught `ValueError` from `pad_to_size()`. The installer should reject this user input through its normal CLI error path.
## Fix Focus Areas
- src/defib/install/orchestrator.py[152-175]
- src/defib/firmware.py[183-195]
## Recommended Fix
Catch the size-validation `ValueError` around `pad_to_size()`, print a concise error containing the artifact and partition sizes, and raise `typer.Exit(1)` from the exception. Preserve `pad_to_size()` itself as the reusable validation boundary.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/defib/vendors/registry.py
Comment thread src/defib/install/orchestrator.py
Comment thread src/defib/install/orchestrator.py Outdated
Comment thread src/defib/install/orchestrator.py Outdated
Comment thread src/defib/install/layout.py
Comment thread src/defib/install/orchestrator.py Outdated

@openipc-ai openipc-ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the DS-I203 path itself is careful work, the hardware evidence in the description is exactly what this repo asks for, and the docs (docs/boards/hiwatch-ds-i203.md, README) are genuinely good. CI is fully green on the branch here too: 842 passed / 3 skipped, fuzz 16 passed, ruff check clean, mypy clean across 77 files.

Requesting changes because the PR bundles three separable things — the Hikvision bootstrap, the extraction of the installer out of cli/app.py, and a semantics change to Transport.flush_output() — and the defects below all live in the shared install/transport layers used by every chip, not in the new Hikvision code. The DS-I203 hardware run cannot have exercised any of them, and none is caught by CI.

Blocking:

  1. _wait_for_openipc_shell_after_reset is out of scope on the download_mode branch — UnboundLocalError fires immediately after the env partition is erased.
  2. SerialTransport.flush_output() now calls unbounded tcdrain(), directly below the comment explaining why write() needs a ceiling.
  3. The installer dropped the explicit tftpboot address and now hard-requires a verified loadaddr round-trip.
  4. The post-saveenv "verified" check reads the in-RAM env and cannot detect a failed SPI write.
  5. TransportTimeout is not a builtin TimeoutError, so echo-verification failures escape as a bare traceback.
  6. loady/go bypass the echo verification added for exactly that corrupting UART.

Also worth fixing before merge: the RFC2217 flush_output() no-op, --nor-size behaving as an assertion rather than the override the README promises, the missing hi3518ev100 RAM_BASE entry, the --output json regression, and the substring scan in uboot_flash_command_error().

Two smaller repo-convention asks:

  • All three commit bodies are empty. CLAUDE.md is explicit that bodies carry the real documentation — the why, the exact command, and the hardware verification evidence. All of that already exists in the PR description; it just needs to land in the commits.
  • CLAUDE.md needs updating: src/defib/vendors/ is a new registration mechanism alongside profiles and the V500/CV6xx frozensets, and "CLI — one large app.py" is no longer accurate.

One thing I checked and am not asking you to change: the switch from run mtdpartsnor{8,16}m to a literal setenv mtdparts hi_sfc:.... I traced it to 9c9ea03, which introduced the identical bare-literal form for the 32 MiB case and verified it end to end on a hi3516av300, so the format is proven. Worth one glance that the exact 8/16 MiB strings match OpenIPC's default env, but I don't consider it a risk.

If you're open to it, splitting the transport change and the cli/app.py extraction into their own PRs would make the DS-I203 work much easier to land — the Hikvision bootstrap, the vendor registry and the board docs are close to ready as-is.

)

await _cmd("reset", timeout=1.0)
await _wait_for_openipc_shell_after_reset()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — UnboundLocalError at the most dangerous point in the flow.

_wait_for_openipc_shell_after_reset is defined at line 483, inside the shell-mode else: branch of the if download_mode: split (col_offset 8). This call site is on the has_stock_uboot and not nand path and is not guarded by that condition.

If step 3.5's detection sets download_mode = True, only the if branch's _cmd is bound. The install then proceeds normally, flashes U-Boot/kernel/rootfs, erases rootfs_data, erases the env partition at line 1013, runs reset at 1048 — and raises UnboundLocalError here. The board is left with a wiped environment, no mtdparts, and the compiled-in default MAC.

Reachability is narrow (a chainloaded U-Boot answering in download-command mode), but this is the single worst place in the file to abort. Either hoist the helper out of the branch or assert not download_mode before the stock-U-Boot env migration begins.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _wait_for_openipc_shell_after_reset is no longer branch-scoped, and a stock migration is also rejected before destructive work if it unexpectedly lands in download-command mode. Added regression coverage for the download-mode case.

Comment thread src/defib/transport/serial.py Outdated
# pyserial reset_output_buffer() does the opposite: it discards queued
# bytes, which can truncate bootloader/YMODEM traffic on USB-UART links.
# Serial.flush() blocks until the OS/driver TX queue has drained.
await asyncio.get_event_loop().run_in_executor(None, self._port.flush)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — unbounded tcdrain() with no cancellation path.

The semantics change itself is right: Transport.flush_output() is documented "Ensure all buffered output has been sent", and reset_output_buffer() did the opposite. Good catch.

But Serial.flush() is termios.tcdrain(self.fd) on POSIX — no timeout, not interruptible. Compare write() twenty lines above, which sets write_timeout = 5.0 with a comment spelling out exactly this hazard: "asyncio.wait_for can't help here — cancelling a run_in_executor future leaves the underlying thread still blocked."

A hung USB-serial adapter, an unplugged cable or a CTS stall mid-install blocks tcdrain forever, permanently consuming the executor thread; the event loop never gets the result and Ctrl-C does not recover it. Please give this the same bounded treatment — e.g. poll out_waiting against a deadline — rather than an unguarded drain.

Separately: this is the primary recovery path for all 112 UART chips, and hisilicon_standard.py:198 calls flush_input(); flush_output() before every frame retransmission. Whether that call site was relying on the old purge is worth a hardware check on any HiSilicon board before merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. SerialTransport.flush_output() no longer calls Serial.flush() / tcdrain() and never purges the TX buffer. It polls out_waiting against a bounded deadline instead. tests/test_transport_serial.py covers successful drain and timeout behavior.

Comment thread src/defib/install/orchestrator.py Outdated
# from the live shell before issuing tftpboot.
await set_uboot_env_verified(_cmd, "ipaddr", device_ip)
await set_uboot_env_verified(_cmd, "serverip", serverip)
await set_uboot_env_verified(_cmd, "loadaddr", f"0x{ram_addr:x}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the explicit tftpboot address was load-bearing.

On master this was tftpboot 0x{ram_addr:x} {filename} with fire-and-forget setenv ipaddr / setenv serverip (cli/app.py:2717-2725). The address never depended on env state at all.

Now all three values go through set_uboot_env_verified(), which raises RuntimeError unless parse_printenv_value() returns an exact match, and _tftp_to_ram relies on loadaddr. In download_process mode _cmd goes through DownloadCommandClient.send_command, which warns and returns partial output on ok=False; if that dialect's printenv reply isn't in name=value form the install now aborts with "U-Boot runtime environment verify failed for ipaddr" on chips where it previously completed.

The stated motivation — "Keep TFTP command lines short on old UART consoles" — applies to the Hikvision link specifically. Could the short-filename/loadaddr form be gated on has_stock_uboot, leaving the explicit-address form (which is strictly more robust) for everyone else?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Generic/download-mode installs again use tftpboot 0x<ram_addr> and do not depend on a printenv loadaddr round-trip. The short loadaddr form is limited to the stock-U-Boot path where command length matters.

Comment thread src/defib/install/orchestrator.py Outdated
if has_stock_uboot:
from defib.uboot_env import parse_printenv_value

verify_resp = await _cmd("printenv ethaddr", timeout=5.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this verification cannot fail for the reason it exists.

printenv reports U-Boot's in-RAM environment hashtable, not what landed in SPI. These two read-backs return the values set at lines 1115 and 1078, so both comparisons pass by construction.

Concretely: saveenv emits OK-looking output (so uboot_flash_command_error at 1123 passes) but the SPI write is short, or the env sector wasn't fully erased. Both checks here still pass, the CLI prints "Environment saved and verified", and the first cold boot loads a bad-CRC env, falls back to compiled defaults, and comes up with ethaddr=00:00:23:34:45:66 and the wrong mtdparts.

Every other partition in this file is verified with sf read + crc32 read-back (837-864, 953-972, 1023-1046). The environment — the one thing this migration path exists to get right — is the only one that isn't. A sf read of the env partition plus a CRC comparison would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Persistent environment verification now checks the actual SPI contents after saveenv, rather than relying only on printenv.

Defib re-runs sf probe 0, reads the environment partition from SPI, computes the CRC over the environment data, stores that result in RAM, and compares it with the CRC stored in the flash environment header using cmp.l.

The extra sf probe 0 is intentional: hardware validation showed that the SPI selection is lost across the mandatory internal reset, so attempting the read without re-probing fails with No SPI flash selected.

This path was revalidated on the physical DS-I203. The final hardware run completed the persistent check successfully with SPI CRC 7A58A0B5 before reporting the environment as verified.

Comment thread src/defib/flashdump.py
except Exception:
pass

raise TransportTimeout(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this exception is not caught anywhere upstream.

TransportTimeout derives from TransportError(Exception) (transport/base.py:9-14), not the builtin TimeoutError. The orchestrator guards bootstrap() with except TimeoutError (orchestrator.py:375), and the install body's only handler is the no-op except Exception: raise at 1159.

So after 8 failed echo attempts on a noisy UART this propagates out of run_install as a bare Python traceback: the user sees a stack dump instead of a CLI error, and transport.close() / power_controller.close() at 1164-1166 never run — serial port and PoE session both left open.

Same gap applies to transport.write() raising TransportTimeout inside hikvision._run_stock_command. Either raise a builtin TimeoutError here or widen the handlers to except (TimeoutError, TransportError).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. TransportTimeout is handled through the broader TransportError path, so UART command/echo failures no longer escape as an unhandled traceback.

The installer now converts those failures into the normal controlled error path and releases UART/power/TFTP resources. The destructive phase is protected by cleanup handling, and the preserved/transient environment verification path before TFTP is also covered so a normal RuntimeError there still closes the transport.

Regression coverage includes a forced stock-migration transport timeout and verifies that the installer exits cleanly and closes the UART.

_STOCK_UBOOT_TARGETS: dict[str, StockUBootTarget] = {
"hi3518ev100:hiwatch-ds-i203": StockUBootTarget(
selector="hi3518ev100:hiwatch-ds-i203",
handler="hikvision",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi3518ev100 needs an explicit RAM_BASE entry.

get_ram_staging_addr("hi3518ev100:hiwatch-ds-i203") returns 0x82000000, which is correct — but only by accident. The chip isn't in RAM_BASE, load_profile() raises (there's no hi3518ev100.json), so it falls through to the last-resort scan:

for prefix, base in RAM_BASE.items():
    if chip_lower.startswith(prefix[:8]):
        return base + RAM_STAGING_OFFSET

That matches on the bare "hi3518" key (0x80000000) purely because it appears before "hi3518ev300" (0x40000000) in an unordered dict. Alphabetise or reorder RAM_BASE and the answer flips to 0x42000000 — the installer would TFTP into unmapped memory and sf write whatever is there into flash.

The host-side CRC compare would catch it (expected CRC is computed from the file, not re-read from the device), so it fails safe rather than bricking — but a one-line "hi3518ev100": 0x80000000 entry in flashdump.RAM_BASE removes the dependency on dict ordering entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. RAM_BASE now includes an explicit hi3518ev100: 0x80000000 mapping.

get_ram_staging_addr() also normalizes a chip:variant selector to its base chip before exact lookup, so hi3518ev100:hiwatch-ds-i203 deterministically stages at 0x82000000 without relying on prefix matching or dictionary ordering.

Regression coverage verifies the DS-I203 staging address.

Comment thread src/defib/install/orchestrator.py Outdated
try:
mode = recovery_mode(chip)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--output json regression.

Master routed both of these early exits through _usb_fail() (cli/app.py:2292), which still exists at app.py:3107 and carries the comment "Routed through _usb_fail so --output json still gets an event." It emits {"event": "error", "message": ...} for JSON consumers and rich.markup.escapes the message otherwise.

This prints raw Rich markup instead, so automation parsing stdout as JSON gets an unparseable line — and the unescaped {exc} means a ValueError mentioning e.g. hi3516cv608:[variant] has that fragment swallowed as markup.

Same applies to the USB-recovery exit just below at 92-95.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Installer preflight failures now go through the common CLI-safe failure helper.

In JSON mode it emits a structured error event, while human mode uses escaped Rich output. This covers the early selector/profile and unsupported recovery-mode paths that previously printed non-JSON text.

Regression coverage includes early CLI failure in --output json mode.

return crc & 0xFFFFFFFF


def uboot_flash_command_error(response: str) -> str | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substring scan over the whole response, and it fires after the erase.

Two things:

  1. " failed" can never match without "failed" also matching — the first entry is dead.
  2. More substantively, this runs against the full _cmd buffer. With verify_echo off (every generic install) that includes the echoed command plus anything the device printed earlier in the same read window. A U-Boot that emits e.g. "...failed to read" during an unrelated banner aborts the install at orchestrator.py:805 — after the erase has already happened.

Anchoring on the command's own result line rather than scanning the whole buffer would make this safe to rely on for destructive operations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. U-Boot command failure detection is now line/result-oriented rather than searching arbitrary substrings across the complete response buffer.

Unrelated text such as warning: failed to read optional otp calibration no longer turns into a false destructive-command failure, while actual erase/write/probe failures are still detected.

No SPI flash selected is also recognized explicitly; that case was encountered during physical environment-verification testing after the internal reset.

self.timing.read_timeout, max(0.0, deadline - loop.time())
),
)
except TransportTimeout:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hot spin: continue with no sleep.

Both this loop and YModemSender._read_control (ymodem.py:103-104) do except TransportTimeout: continue inside a while loop.time() < deadline. Transports that raise without blocking — MockTransport.read on an empty buffer, the new ScriptedTransport, and a SocketTransport/rack bridge after the peer closes — turn the timeout window into a full-core busy-wait: 30 s for _detect_console's boot_timeout, 15 s for _read_control's start_timeout.

_chainload's loop at line 308 already does the right thing with await asyncio.sleep(self.timing.openipc_poll_interval). Same treatment here and in _read_control.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Hikvision console-detection timeout path now yields with asyncio.sleep() instead of immediately continuing a tight loop when a transport raises TransportTimeout.

For YMODEM, _read_control() no longer retries a tight continue loop on TransportTimeout; that timeout ends the current control-read attempt instead.

This avoids the full-core busy-wait behavior described here for transports that can raise immediately rather than blocking for the requested timeout.

Comment thread src/defib/install/layout.py Outdated
)


def nor_bootargs() -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead in production — five new helpers have no non-test callers.

nor_bootargs() here, plus parse_printenv(), expand_env_references() and env_values_equivalent() in uboot_env.py, and list_stock_uboot_variants() in registry.py.

Two of those look like they were meant to be wired up and weren't:

  • env_values_equivalent()'s docstring says "Verification needs semantic rather than byte-for-byte comparison because legacy U-Boot may expand variables while executing setenv" — but orchestrator.py:1140 verifies mtdparts with a plain saved_mtdparts != expected_mtdparts, exactly the byte-for-byte compare it was written to replace.
  • list_stock_uboot_variants() would solve a real discoverability gap: hi3518ev100:hiwatch-ds-i203 doesn't appear in defib list-chips at all (no profile JSON), so the README is currently the only way to find it.

Also in this area: create_uboot_bootstrap is imported twice (orchestrator.py:55 and :320); parse_printenv_value/select_install_ethaddr are re-imported three times (:987, :1087, :1127); re_mod.search(r"==>\s*([0-9a-fA-F]{8})", ...) is open-coded at :963 and :1033 despite parse_uboot_crc32 being imported at :24; and there's a no-op except Exception: raise at :1159.

Separately, two gaps worth closing while you're here: the chainload sends the 0xFF-padded 256 KiB image rather than raw U-Boot (uboot_data feeds both bootstrap() and the flash write — several wasted seconds of YMODEM per run), and preserved_stock_env is applied with a bare setenv at :513 while the far less critical transient_env gets set_uboot_env_verified() at :523. The factory ethaddr is the one value that cannot be re-derived.

Finally: YModemSender.send(), its NAK-retry loop and the EOT/final-header handshake are never executed by the suite — test_ds_i203_final_contract.py monkeypatches the sender out, and test_ymodem.py covers only CRC16 and packet framing. That's the new wire protocol carrying U-Boot onto a live camera; a scripted-receiver test (happy path, one NAK retry, EOT sequence) would be cheap. While you're in there, _chainload starts YMODEM without a flush_input(), so _wait_crc_request skips strays hunting for 0x43 — any uppercase C in a modified loady banner would be misread as a CRC request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the cleanup and protocol gaps from this thread.

Unused environment/layout helpers and duplicate imports were removed. Stock-U-Boot selectors are now exposed through list-chips.

The chainload path now sends the raw U-Boot binary over YMODEM and only pads the image for the fixed-size flash partition. Preserved factory environment values use the verified setter path.

YMODEM now has scripted protocol tests covering the successful transfer path, NAK retry, two-EOT negotiation, and the final empty header. The Hikvision chainload path also flushes stale input before waiting for the receiver's CRC request.

The final DS-I203 hardware run used the raw U-Boot YMODEM path successfully.

Move the install implementation out of the CLI into reusable defib.install modules and add a separate stock-U-Boot bootstrap abstraction for migrations that start from an already-running vendor bootloader rather than the SoC boot ROM.

Add the vendor bootstrap registry, the reusable Hikvision U-Boot console/YMODEM implementation, firmware artifact override support, standard NOR layout helpers, environment helpers, and installer plumbing needed for a board-specific stock migration. Keep this mechanism separate from BootProtocol because BootProtocol describes the SoC boot-ROM recovery dialect, while vendors.* starts from a running vendor U-Boot.

Verification:
python -m pytest tests/test_install_flash_helpers.py tests/test_uart_command_integrity.py tests/test_uboot_env.py tests/test_ymodem.py -q

Hardware verification is not standalone for this commit because it intentionally does not register a concrete board selector. The following HiWatch DS-I203 commit binds this reusable layer to physical hardware and carries the end-to-end stock-U-Boot migration evidence.
Bind the reusable stock-U-Boot migration path to the physical HiWatch DS-I203.

Register hi3518ev100:hiwatch-ds-i203 as a Hikvision stock-U-Boot target, map it to the DDR3/256 MiB OpenIPC U-Boot variant, use the safe 0x81000000 chainload address, and apply transient phyaddru=3 only for installer TFTP. Add the board documentation and migration contract tests covering the stock Hikvision console, existing-OpenIPC detection, factory MAC preservation, NOR layout ownership, and release artifact selection.

Verification:
python -m pytest tests/test_ds_i203_final_contract.py tests/test_firmware.py -q

Hardware verification used a physical HiWatch DS-I203 with Hi3518EV100, IMX122, 256 MiB DDR3 and 16 MiB GD25Q128 SPI NOR. Defib entered Hikvision U-Boot 2010.06 through Ctrl+U / HKVS, chainloaded the DDR3/256 MiB OpenIPC U-Boot over YMODEM, detected the 16 MiB NOR layout, flashed OpenIPC firmware, preserved the factory MAC, and booted the matching DS-I203 firmware profile successfully.

The validated path was:
Hikvision U-Boot -> Ctrl+U / HKVS -> YMODEM OpenIPC U-Boot -> TFTP flash -> environment migration -> OpenIPC boot.
Harden the stock-U-Boot migration path so destructive install steps fail closed instead of continuing on ambiguous or invalid state.

Require explicit --wipe-env for registered stock-U-Boot NOR migrations, reject oversized U-Boot overrides through the normal CLI error path, broaden NOR-size parsing for valid U-Boot probe formats, and require parseable CRC values for both TFTP RAM verification and flash readback instead of silently skipping verification when output is incomplete.

Keep vendor migrations from erasing the environment as part of the U-Boot partition write, preserve the captured factory ethaddr across the explicit environment migration, and add focused regression coverage for the destructive preflight and CRC failure cases.

Verification:
python -m pytest tests/test_ds_i203_final_contract.py tests/test_install_flash_helpers.py -q

Hardware verification used the HiWatch DS-I203 stock-U-Boot migration path with explicit --wipe-env. The board completed the migration on 16 MiB NOR, preserved the factory MAC and reached OpenIPC successfully; later review commits further strengthen transport and persistent-environment verification without changing this commit's fail-closed contract.
Fix the shared installer and transport issues found during review of the HiWatch DS-I203 stock-U-Boot migration.

Restore explicit TFTP RAM addressing for generic installs, bound serial TX draining, keep RFC2217 flush semantics explicit, verify persistent SPI environment contents after saveenv, handle TransportError cleanly, and protect Hikvision loady/go with UART echo verification.

Also make --nor-size a true override, add the hi3518ev100 RAM base, restore JSON error output, harden U-Boot error parsing, remove dead helpers, use raw U-Boot for YMODEM and pad only for flash, and expand YMODEM/transport/install regression coverage.

Hardware verification:
python -m defib install -c hi3518ev100:hiwatch-ds-i203 --firmware $HOME\Downloads\hiwatch-ds-i203-202609151816.tgz --uboot $HOME\Downloads\u-boot-hi3518ev100-ddr3-256m-universal.bin --wipe-env -p COM15 --tftp-via host --nic "Беспроводная сеть" --host-ip 192.168.1.11 --device-ip 192.168.1.64 --no-final-reset -d

The DS-I203 completed stock Hikvision U-Boot -> OpenIPC U-Boot migration on hardware, detected 16 MiB NOR, flashed and CRC-verified U-Boot/kernel/rootfs/rootfs_data, preserved the factory MAC, saved the environment, re-probed SPI, and physically verified the environment CRC before leaving the board at the OpenIPC prompt.
Add explicit install-stage selection for development, recovery, and targeted validation without replaying the complete production install.

--stage selects an exact set from uboot, kernel, rootfs, rootfs-data, env, and reset. --skip-stage subtracts stages from the normal production sequence, and the two forms cannot be combined. Explicit stage selection performs reset only when reset is selected.

Keep the default install path unchanged. Start TFTP only when selected stages need image transfer, keep environment erase coupled to the env stage, and reject unsafe partial persistent installs when a genuine stock U-Boot was only chainloaded temporarily.

Regression coverage verifies default and explicit stage resolution, skip semantics, env-only execution without TFTP or partition writes, reset selection, invalid combinations, and stock-migration safety.

Verification:
python -m pytest tests/test_install_stages.py -q

The stage controls were also exercised on the HiWatch DS-I203 with an env-only run against an already-running OpenIPC U-Boot. That run completed the environment migration and its physical SPI CRC verification and was used while validating the required post-reset sf probe handling.
@ArthurKoba
ArthurKoba force-pushed the install/stock-uboot-migration branch from aa18898 to ab20fbe Compare September 15, 2026 20:08
@ArthurKoba

Copy link
Copy Markdown
Contributor Author

Addressed the requested review changes and force-pushed the rewritten five-commit series.

The original three commits now have full bodies with rationale, verification commands, and hardware evidence. Review remediation is isolated in install: address stock migration review findings, with selectable install stages kept in a separate final commit.

The final tree was revalidated on the physical DS-I203 after the review fixes, including UART echo verification for loady/go, raw YMODEM chainload, 16 MiB NOR detection, flash CRC verification, factory-MAC preservation, and physical SPI environment CRC verification after saveenv.

Local final gate: 873 passed / 10 skipped / 4 known Windows-baseline tests deselected, fuzz 16 passed, locked Ruff 0.15.8 clean, strict mypy clean, and git diff --check clean.

@openipc-ai openipc-ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 13 findings from the previous review are fixed. I verified each one in the code rather than going by the thread replies:

Blockers

  • _wait_for_openipc_shell_after_reset is no longer branch-scoped (col_offset 4, was 8), and a stock migration that lands in download-command mode is now rejected before any destructive work.
  • SerialTransport.flush_output() polls out_waiting against a 5.0 s deadline with a 10 ms interval — matching the ceiling write() already had — and raises TransportTimeout instead of blocking in tcdrain forever.
  • Generic and download-mode installs use explicit tftpboot 0x<ram_addr> <file> again; the short loadaddr form is gated to has_stock_uboot.
  • verify_spi_environment_crc() re-probes SPI, reads the env partition back, CRCs the data region into a scratch word and cmp.ls it against the on-flash header CRC. The re-probe after the internal reset is a good catch, and using cmp.l to avoid host-endianness assumptions is the right call.
  • except (TimeoutError, TransportError) now covers the bootstrap call site, with cleanup around the destructive phase.
  • loady, go and the stock command path all route through write_uboot_line_with_echo_verify.

Remaining seven--nor-size warns and honours the override; explicit hi3518ev100 RAM_BASE entry plus :variant stripping; RFC2217 flush_output() an explicit documented no-op; fail() emitting JSON events and escaping markup; line-oriented uboot_flash_command_error(); both hot-spin loops yielding; dead helpers gone, list_stock_uboot_selectors() wired into list-chips, raw U-Boot over YMODEM with padding only for flash, preserved ethaddr through the verified setter, and YMODEM sender tests covering the happy path and NAK retry.

Both convention asks are done: all five commit bodies now carry rationale, verification commands and hardware evidence — including 72424c9 stating plainly why it has no standalone evidence — and CLAUDE.md gained the vendor-bootstrap section with corrected CLI and safety-invariant text.

I also read install: add selectable install stages, which arrived outside the review scope. It holds up: mutually exclusive modes, unknown stages rejected, --stage reset / --no-final-reset conflict caught, empty selection rejected, default flow unchanged, and a real guard against a partial persistent install that omits uboot while the board still boots vendor U-Boot.

Local gate on ab20fbea: 868 passed / 3 skipped, fuzz 16 passed, ruff check clean, mypy clean across 77 source files.

One non-blocking follow-up, noted so it isn't lost: protocol/hisilicon_standard.py:198 is unchanged and still calls flush_input(); flush_output() before every frame retransmission, so that path now drains where it previously purged — for all 112 UART chips. The divergence only shows up when a retry fires while the previous frame is still partly queued, and sending a complete frame there is probably better than a truncated one, so I don't think it's a risk. But the DS-I203 evidence can't speak to it, and a single defib burn on any HiSilicon board would retire the question.

@openipc-ai
openipc-ai merged commit 52582f0 into OpenIPC:master Sep 16, 2026
13 checks passed
openipc-ai added a commit that referenced this pull request Sep 16, 2026
)

52582f0 (#137) changed `Transport.flush_output()` from "discard queued TX" to
"wait for queued TX to drain". That matches the documented contract, but it
also turned a call that effectively never raised into one that raises
`TransportTimeout` after 5 s — and two retry loops called it from outside
their own try block.

`HiSiliconStandard._send_frame_with_retry` runs the flushes before its try, and
`RecoverySession.run()` invokes the protocol inside try/finally with no
handler, so the raise propagated out of `burn`. Measured against a port whose
`out_waiting` never reaches zero, the old purge behaviour completed four
attempts and returned False while the drain behaviour raised with zero frames
written: a stalled PL2303 or FT232R aborted the burn instead of retrying, on
the primary recovery path for all 112 UART chips. `YModemSender._send_packet`
had the same shape with a loop that only caught `YModemError`, aborting a
stock-U-Boot chainload mid-transfer.

This was a re-introduction, not a new class of defect.
`tests/test_protocol_standard.py::TestWriteTimeoutRetry` already exists because
`transport.write()` used to sit outside that same try, and its docstring names
the same trigger — a hung write with the PL2303 TX buffer not draining. It
pinned the invariant for `write()` only, so moving the flushes out went
unnoticed.

Move the flushes inside the try in both loops. A flush that waits on hardware
is an I/O operation, not bookkeeping, so it belongs where transient failures
are already absorbed. The bounded drain from #137 is kept exactly as merged.

Adding that retry then created a second problem, caught in review. Retrying a
stalled drain retransmits a packet whose first copy is still queued, so when
the link recovers the receiver sees it twice and answers twice. `_send_packet`
consumed one response and left the spare behind, where the next packet's read
window picked it up — a NAK the receiver really sent was read as an ACK, and
the data it asked to have resent was never retransmitted. Silent truncation of
the chainloaded image rather than a failed transfer.

Flush the receive buffer before every attempt, which is what
`_send_frame_with_retry` already does and why the same duplicate-transmission
window never bit the HiSilicon path. `_finish()` gets the same flush, because
the last data packet can leave a spare response that would otherwise be read
as the EOT answer. Purge-then-resend is self-synchronising; drain-then-resend
is not.

Verification:
uv run pytest tests/ -q --ignore=tests/fuzz
uv run pytest tests/fuzz/ -q --hypothesis-seed=0

871 passed / 3 skipped, fuzz 16 passed, ruff and mypy clean. Three regression
tests, each beside the existing one it mirrors: the two stall tests fail
against 52582f0 with a raw TransportTimeout, and the duplicate-response test
NAKs a packet after an earlier one stalls and duplicates, then asserts it is
actually retransmitted.

Not hardware verified. Reproduced against a port whose `out_waiting` never
reaches zero; on real hardware the divergence needs a link where the frame is
still draining when the per-attempt ACK timeout fires. The change only restores
the previous behaviour on the stalled path, so a `defib burn` on any HiSilicon
board should look exactly as it does today.

`YModemSender._finish()` still lets a transport error escape. It has no retry
semantics to restore, and the installer reports it cleanly through the
orchestrator's TransportError handler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants