Skip to content

feat(ota): fix update failure reporting, add progress telemetry and delta patching - #503

Open
yudataguy wants to merge 2 commits into
mainfrom
feat/ota-update-improvements
Open

feat(ota): fix update failure reporting, add progress telemetry and delta patching#503
yudataguy wants to merge 2 commits into
mainfrom
feat/ota-update-improvements

Conversation

@yudataguy

Copy link
Copy Markdown
Collaborator

Implements #429. Design proposal and measurements were posted on the issue and agreed before this was written.

The problem, measured

A signed image is 726,784 bytes. The ground station paces file uplink at 204-byte chunks with a 0.4 s cooldown (fprime-gds.yml), about 510 B/s, so a whole image needs ~24 minutes of contact. A pass is single-digit minutes, and F Prime's file uplink has no cross-pass resume, so a dropped pass discards everything sent.

Correctness (the part worth reviewing closely)

FlashWorker::writeImage only ever assigned its return status in the CRC-mismatch branch. A file read error or flash write error logged a warning and returned OP_OK, so Updater emitted UpdateSucceeded for an image that was never fully written — and an operator following the documented procedure would then set next-boot and reboot into a truncated image.

The write path is restructured so every exit returns a classified WriteOutcome; there is no longer a mutable status a failure path can forget to set. Also fixed: the CRC-mismatch path fell through into the write loop instead of returning, and any failure forced another full 1 MB erase even when flash was never touched.

Failures that never reached flash now leave the slot usable, so a mistyped filename costs a retry instead of an erase. Failures that did reach flash still require another prepare.

Operator visibility

UpdateStage, BytesWritten, ImageTotalBytes, LastUpdateStatus, packetized as SoftwareUpdate (23, group 5). Channels rather than only events, so an operator returning a pass later can see where an update stands without replaying event history. CHUNK_DELAY_US and PROGRESS_STEP_PERCENT are parameters so write pacing can be tuned against hardware rather than rebuilt.

Fitting an update into a pass

ASSEMBLE_IMAGE joins numbered segments (<prefix>.000, ...) uplinked across several passes and verifies the result, bounding the loss from a dropped pass to one segment.

APPLY_PATCH reconstructs an image from a bsdiff-style delta against the running image, read straight out of slot0 — the RP2350 executes XIP from memory-mapped QSPI, so no filesystem copy is needed (this is simpler than the issue's "keep an image on the filesystem" suggestion, and removes the stale-copy failure mode).

Measured against real consecutive CI builds:

reference age delta uplink
2 days ~47 KB (LZMA) 1.5 min
12 days ~66 KB (bzip2) 2.1 min
whole image 726,784 B 23.8 min

Naive block diffing was measured and rejected: a code-size change shifts every later address, so ~99% of 512-byte blocks differ between builds days apart — a block-index scheme would uplink more than the image.

Safety of the patch path

The container records the size and CRC32 of the reference it was built from, and the apply refuses to run against anything else. Patching the wrong reference yields a plausible but corrupt image that would then be flashed and booted. Control records are range-checked against both images, so a malformed patch arriving over the radio cannot read or write out of bounds.

One decision left to the team

Compression is deliberately not implemented on the flight side. The patch streams are ~84% zero bytes and compress from 727 KB to ~60 KB (DEFLATE), ~47 KB (LZMA), or ~97 KB (heatshrink) — but this Zephyr workspace ships no decompressor, and adding one to flight software is a dependency decision I did not want to make unilaterally. I also measured a naive zero-RLE (no new dependency): only 241 KB / 7.9 min, because the zeros come in short scattered runs. A real LZ-class coder is required.

The container carries a codec field so a compressed format drops in without changing the applier's structure, and make-patch.py refuses to emit an uncompressed patch without --allow-uncompressed, since it would be the size of the image. Until a codec is chosen, APPLY_PATCH is functional but not yet operationally usefulASSEMBLE_IMAGE is the shipping answer to the pass-boundary problem.

Also

Partition IDs come from the device tree via FIXED_PARTITION_ID instead of a hand-counted literal that depended on DTS declaration order. Partition labels corrected — slot1 was labelled "golden" while being the slot OTA overwrites.

Verification

  • make fmt — clean
  • make test-unit — 11/11 pass, three new host suites (UpdateSequencer, SegmentPlan, PatchApplier) covering the defects above plus malformed and hostile patches
  • Firmware builds; zephyr.uf2 produced
  • End to end: make-patch.py plus the flight-side applier reconstructed a real 726,784-byte image from a real 2-day-older reference, byte-identical
  • test/int/ota_test.py added for the command surface. It deliberately never sets next-boot, so a run cannot leave a board staged to boot an unintended image. Not yet run against hardware.

Not hardware-tested: the full prepare → write → TEST boot → confirm cycle, and APPLY_PATCH on a real board.

Note for reviewers: lib/fprime-extras in my working tree was ahead of the recorded pointer and declares Svc.ComRetry, which collides with lib/fprime's. I pinned it to the recorded commit to build, then restored it; this branch does not touch the submodule.

🤖 Generated with Claude Code

https://claude.ai/code/session_011kehXMi9zAAoPJ3PgfRd81

…elta patching

An OTA update currently cannot be completed in a single ground pass. A signed
image is 726,784 bytes and the ground station paces file uplink at ~510 B/s
(204-byte chunks, 0.4 s cooldown), so a whole image needs ~24 minutes of
contact. File uplink has no cross-pass resume, so a dropped pass discards
everything transferred.

Correctness first. FlashWorker::writeImage only ever assigned its return status
in the CRC-mismatch branch, so a file read error or a flash write error logged a
warning and returned OP_OK, and the Updater announced UpdateSucceeded for an
image that was never fully written. An operator following the documented
procedure would then set next-boot and reboot into a truncated image. The write
path is restructured so every exit returns a classified outcome and there is no
mutable status a failure path can forget to set. Also fixed: the CRC-mismatch
path fell through into the write loop instead of returning, and any failure
forced another full 1 MB erase even when the flash was never touched.

Failures that never reached flash (bad file name, failed size or CRC read, CRC
mismatch) now leave the staging slot usable, so a mistyped file name costs a
retry rather than an erase. Failures that did reach flash still require another
preparation.

Operator visibility. FlashWorker gains UpdateStage, BytesWritten,
ImageTotalBytes, and LastUpdateStatus, packetized as SoftwareUpdate (23, group
5). These are channels rather than only events so an operator returning on a
later pass can see where an update stands without replaying event history.
CHUNK_DELAY_US and PROGRESS_STEP_PERCENT become parameters so the write pacing
can be tuned against hardware instead of rebuilt.

Two ways to fit an update into a pass:

ASSEMBLE_IMAGE joins numbered segments ("<prefix>.000", ...) uplinked across
several passes into one image and verifies it, bounding the loss from a dropped
pass to a single segment.

APPLY_PATCH reconstructs an image from a bsdiff-style delta against the running
image, read straight out of slot0 (the RP2350 executes XIP from memory-mapped
QSPI, so no filesystem copy is needed). Measured against real consecutive CI
builds, a compressed delta is 47-97 KB, or 1.5-3.2 minutes of uplink. Naive
block diffing was measured and rejected: a code size change shifts every later
address, so ~99% of 512-byte blocks differ between builds days apart.

The patch container records the size and CRC32 of the reference it was built
from and the apply refuses to run against anything else, because patching the
wrong reference yields a plausible but corrupt image that would then be flashed
and booted. Control records are range checked against both images so a
malformed patch arriving over the radio cannot read or write out of bounds.

Compression is deliberately not implemented on the flight side. The streams are
~84% zeros and compress to ~60 KB with DEFLATE, ~47 KB with LZMA, or ~97 KB with
heatshrink, but this Zephyr workspace ships no decompressor and choosing that
dependency is a project decision. The container carries a codec field, and
make-patch.py refuses to emit an uncompressed patch without an explicit flag
since it would be the size of the image.

Partition IDs now come from the device tree via FIXED_PARTITION_ID rather than a
hand-counted literal, which depended on DTS declaration order. Partition labels
corrected: slot1 was labelled "golden" while being the slot OTA overwrites.

Verified: make fmt clean, make test-unit 11/11 (three new host test suites),
firmware builds. The ground tool and flight applier were checked end to end by
reconstructing a real 726,784-byte image from a real 2-day-older reference,
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kehXMi9zAAoPJ3PgfRd81
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@yudataguy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f6c22503-6048-46f4-9d10-ea8c7cd12e69

📥 Commits

Reviewing files that changed from the base of the PR and between 954788b and a29752b.

📒 Files selected for processing (17)
  • PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp
  • PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi
  • PROVESFlightControllerReference/ReferenceDeployment/Top/topology.fpp
  • PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_LzssDecoder.cpp
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp
  • tools/bin/make-patch.py
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added firmware update progress, stage, status, and telemetry reporting.
    • Added segmented image assembly with validation and CRC verification.
    • Added delta-patch application with reference-image checks and error reporting.
    • Added a command-line tool for creating validated firmware patches.
    • Improved retry handling and flash-write validation.
  • Bug Fixes

    • Added clear handling for invalid segments, write failures, CRC mismatches, and malformed patches.
    • Corrected flash partition labeling and write-error status reporting.
  • Documentation

    • Expanded FlashWorker documentation with supported workflows, commands, telemetry, validation, and recovery behavior.
  • Tests

    • Added comprehensive integration and unit coverage for updates, assembly, patching, sequencing, and progress reporting.

Walkthrough

FlashWorker now supports sequenced image updates, progress telemetry, segmented image assembly, and delta-patch application. The change adds patch-generation tooling, device-tree partition naming, integration and unit tests, deployment telemetry, and complete component documentation.

Changes

FlashWorker OTA update pipeline

Layer / File(s) Summary
Update contracts and sequencing
PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp, PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.*, PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp, PROVESFlightControllerReference/test/unit-tests/*
Defines update stages, statuses, commands, telemetry, events, sequencing rules, retry behavior, and progress reporting.
Image writing and segment assembly
PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.*, PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.*, boards/.../proves_flight_control_board_v5.dtsi, PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp
Validates and writes images, reports progress, derives the staging partition from the device tree, and assembles numbered image segments.
Delta patch reconstruction
PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.*, tools/bin/make-patch.py, PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp
Adds PROVES patch decoding, streaming reconstruction, reference and output CRC checks, patch creation tooling, and malformed-input coverage.
Integration validation and documentation
PROVESFlightControllerReference/test/int/ota_test.py, PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi, PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md, docs-site/components/FlashWorker.md, lib/fprime-extras
Adds OTA integration coverage, update telemetry packetization, detailed component documentation, and an updated subproject reference.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GroundStation
  participant FlashWorker
  participant UpdateSequencer
  participant FlashRegion
  GroundStation->>FlashWorker: Prepare update
  FlashWorker->>UpdateSequencer: Record preparation result
  UpdateSequencer-->>FlashWorker: PREPARED stage
  GroundStation->>FlashWorker: Send image update
  FlashWorker->>FlashRegion: Validate, erase, and write image
  FlashWorker->>UpdateSequencer: Record write outcome
  UpdateSequencer-->>FlashWorker: Update status and stage
  FlashWorker-->>GroundStation: Report progress and completion
Loading

Suggested reviewers: mikefly123

Poem

A rabbit checks each byte in flight,
Then hops through patches left and right.
Segments line up, CRCs agree,
Progress blooms on telemetry.
The flash slots wait, prepared and bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main OTA fixes and additions, including failure reporting, progress telemetry, and delta patching.
Description check ✅ Passed The description explains the problem, design, safety considerations, testing, limitations, and pending hardware validation in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md`:
- Around line 52-58: Update the documented tools/bin/make-patch.py command in
both PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md lines
52-58 and docs-site/components/FlashWorker.md lines 52-58 to include the
required --allow-uncompressed option, preserving the remaining patch and update
workflow.
- Around line 28-159: The mirrored FlashWorker documents need markdownlint
formatting fixes: add appropriate language identifiers to every fenced command
block to resolve MD040, and insert blank lines between headings and immediately
following tables to resolve MD022 and MD058. Apply the same changes in
PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md lines 28-159
and docs-site/components/FlashWorker.md lines 28-159, keeping both documents
synchronized.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`:
- Around line 491-497: Update the CRC-mismatch branch in the FlashWorker handler
to report the dedicated CRC failure value from PatchApplier::Error instead of
SIZE_MISMATCH, adding that enum value and its .fpp definition if absent. Also
remove the destination file on this path and the failure paths near lines 476
and 485, matching the cleanup behavior used by ASSEMBLE_IMAGE_cmdHandler.
- Around line 288-348: Both ASSEMBLE_IMAGE_cmdHandler and APPLY_PATCH_cmdHandler
leave partially created destination files after failures. Add a shared private
cleanup helper that closes and removes destination, logs the failure, and sends
EXECUTION_ERROR; invoke it on all nine specified segment/apply,
verification-open, and CRC-mismatch failure paths in
PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp (anchor
lines 288-348 and sibling lines 491-497).
- Around line 160-171: Update the read loop in FlashWorker’s file-flashing logic
to increment its progress counter by the bytes actually returned in read_size,
rather than the fixed CHUNK size. Preserve the existing zero-byte termination
and file-read failure handling so the loop remains aligned with the file cursor
and processes all reported file bytes.
- Around line 454-464: Validate the patch file size before deriving or using the
stream offsets in the FlashWorker reconstruction flow. Obtain the file size
once, perform overflow-safe checks for the header size plus control, diff, and
extra stream sizes, and require their total to equal the patch size; reject
invalid or truncated headers before seeking or reading. Keep the existing
control_start, diff_start, extra_start, and reconstruction path for valid
patches.
- Around line 189-193: Update the post-write validation around written and
written_crc: check written against size before comparing CRCs, return
UpdateSequencer::WriteOutcome::FILE_READ_FAILED for a short read, and only
return FLASH_WRITE_FAILED for a CRC mismatch. Revise the nearby comment and log
event wording, including ImageWriteCrcMismatch in the FPP definition, to state
that streamed image bytes were validated without claiming flash readback or
slot-content verification.
- Around line 428-431: Before the read loop in the reference-data handling flow,
validate that header.reference_size does not exceed the flash area’s size.
Reject oversized values before any flash_area_read call and report the condition
with a clear size-related error, while preserving the existing reference-read
failure handling for actual read errors.
- Around line 172-176: Update the flash write logic around
flash_img_buffered_write in FlashWorker so the flush argument is true only when
the current chunk is the final file chunk, and false for all intermediate
chunks. Use the existing read-size, file-size, or end-of-file tracking in the
surrounding update flow to determine completion, while preserving the current
failure logging and FLASH_WRITE_FAILED return behavior.

In
`@PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp`:
- Around line 1-4: Update the title comment in test_FlashWorker_SegmentPlan.cpp
to use the actual file name, test_FlashWorker_SegmentPlan.cpp, instead of the
stale test_ImageBuilder_SegmentPlan.cpp reference.

In
`@PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp`:
- Around line 121-133: Rename the test NoFailureOutcomeReportsSuccess to a name
describing that failure outcomes map to non-OK statuses while SUCCESS maps to
OP_OK. Keep the test body and assertions unchanged.

In `@tools/bin/make-patch.py`:
- Around line 197-202: Add bsdiff4 to the project’s UV-managed dependencies and
regenerate uv.lock, then add a Makefile target that runs tools/bin/make-patch.py
and update the ImportError message in make-patch.py to direct users to that
target instead of pip install. Ensure the target is exposed through the
supported tool environment.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6683b810-4413-4eb5-9c63-215ec1031e8b

📥 Commits

Reviewing files that changed from the base of the PR and between ea143d0 and 954788b.

📒 Files selected for processing (22)
  • PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp
  • PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp
  • PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp
  • PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp
  • PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md
  • PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi
  • PROVESFlightControllerReference/test/int/ota_test.py
  • PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp
  • PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp
  • boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi
  • docs-site/components/FlashWorker.md
  • lib/fprime-extras
  • tools/bin/make-patch.py

Comment on lines +28 to +159
```
uplink /update/zephyr.signed.bin
Update.updater.PREPARE_UPDATE
Update.updater.UPDATE_IMAGE_FROM("/update/zephyr.signed.bin", <crc>)
Update.updater.CONFIGURE_NEXT_BOOT(TEST)
reboot
Update.updater.CONFIRM_UPDATE
```

Get `<crc>` from `tools/bin/calculate-crc.py`.

### Image split across several passes

```
uplink /update/img.000, /update/img.001, ... (one or more per pass)
Update.worker.ASSEMBLE_IMAGE("/update/img", <n>, "/update/candidate.bin", <crc>)
Update.updater.PREPARE_UPDATE
Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", <crc>)
```

### Typical Usage
And the typical usage of the component here
Assembly verifies the joined image against `<crc>` before anything is written to flash, so a missing or reordered segment is caught rather than left for the bootloader to find.

### Delta patch

```
(ground) tools/bin/make-patch.py <reference.bin> <target.bin> -o update.patch
uplink /update/update.patch
Update.worker.APPLY_PATCH("/update/update.patch", "/update/candidate.bin", <crc>)
Update.updater.PREPARE_UPDATE
Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", <crc>)
```

The reference is the running image, read directly out of the `slot0_partition` flash area. The RP2350 executes XIP from memory-mapped QSPI, so no copy has to be kept on the filesystem. The patch container records the size and CRC32 of the reference it was built from, and `APPLY_PATCH` refuses to run if the running image is not that one. Patching the wrong reference produces a plausible but corrupt image that would then be flashed and booted, so this check is not optional.

**Compression is not yet available on the flight side.** The patch streams are ~84% zero bytes and compress from ~727 KB to ~60 KB with DEFLATE, ~47 KB with LZMA, or ~97 KB with heatshrink, but this Zephyr workspace ships no decompressor and selecting that dependency is a project decision. Until it is made, `make-patch.py` refuses to emit a patch without `--allow-uncompressed`, because an uncompressed patch is the size of the image and worth nothing over the radio. The container carries a codec field so a compressed format can be added without changing the applier's structure.

## Flash Layout

Defined in `boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi`.

| partition | label | size | role |
|---|---|---|---|
| `boot_partition` | mcuboot | 1 MB | bootloader |
| `slot0_partition` | primary | 1 MB | running image, and the patch reference |
| `slot1_partition` | secondary | 1 MB | staging slot updates are written into |
| `slot2_partition` | reserved | 1 MB | unused in swap-using-offset mode with one image |
| `storage_partition` | n/a | 12 MB | LittleFS, holds uplinked segments and candidates |

## Class Diagram
Add a class diagram here
Partition IDs are taken from the device tree with `FIXED_PARTITION_ID`, never hard coded: fixed partition IDs follow declaration order, so a literal would silently point at the wrong region if a partition were added above it.

## Port Descriptions
| Name | Description |
|---|---|
|---|---|
| prepareImage | Erase the staging slot, from `Update.Updater` |
| updateImage | Write an image file into the staging slot |
| nextBoot | Set the next boot mode through MCUBoot |
| confirmImage | Confirm the running image so it is not reverted |
| prepareImageDone / updateImageDone | Report completion of the slow operations |

## Component States
Add component states in the chart below

The sequence is tracked by `Components::UpdateSequencer`.

| Name | Description |
|---|---|
|---|---|
| IDLE | No usable staging slot; PREPARE_UPDATE must run before an update |
| PREPARED | Staging slot erased and ready to receive an image |
| UPDATED | An image has been written to the staging slot |

## Sequence Diagrams
Add sequence diagrams here
A failure that never reached the flash (a bad file name, a failed size or CRC read, a CRC mismatch) leaves the sequence in PREPARED, so the operator can retry without paying for another 1 MB erase. A failure that did reach the flash drops to IDLE, because the slot now holds partial data and must be erased again.

## Parameters
| Name | Description |
|---|---|
|---|---|
| CHUNK_DELAY_US | Microseconds to pause after each buffered flash write, default 5000. Exposed so it can be tuned against real hardware instead of rebuilt; at the default a 727 KB image spends about 7 s asleep. |
| PROGRESS_STEP_PERCENT | Percent of the image between progress events, default 10. Larger values spend less downlink reporting on an update in flight. |

## Commands
| Name | Description |
|---|---|
|---|---|
| ASSEMBLE_IMAGE | Concatenate numbered uplink segments into one image file and verify its CRC32 |
| APPLY_PATCH | Reconstruct an image from a delta patch applied to the running image |

## Events
| Name | Description |
|---|---|
|---|---|
| UpdateProgress | Periodic progress during a write |
| NoImagePrepared | An update was requested before a successful preparation |
| NextBootSetFailed / ConfirmImageFailed | MCUBoot next-boot or confirm call failed |
| FlashEraseFailed / FlashWriteFailed | Staging slot erase or write failed |
| ImageFileReadError / ImageFileCrcMismatch | Image file could not be read, or failed validation |
| ImageWriteCrcMismatch | Bytes written to flash did not match the bytes validated |
| AssembleStarted / AssembleSucceeded / AssembleFailed / InvalidSegmentCount | Segment assembly |
| PatchStarted / PatchSucceeded / PatchFailed / PatchReferenceMismatch | Patch application |

## Telemetry
| Name | Description |
|---|---|
|---|---|
| UpdateStage | IDLE, PREPARING, PREPARED, WRITING, UPDATED, or FAILED |
| BytesWritten | Bytes of the image written into the staging slot so far |
| ImageTotalBytes | Total size of the image being written |
| LastUpdateStatus | Status of the most recent preparation or update |

These are channels rather than only events so that an operator returning on a later pass can ask where an update stands without replaying event history. They are packetized in `SoftwareUpdate` (packet 23, group 5).

## Unit Tests
Add unit test descriptions in the chart below

Host tests, no F Prime or Zephyr dependency. Run with `make test-unit`.

| Name | Description | Output | Coverage |
|---|---|---|---|
|---|---|---|---|
| test_FlashWorker_UpdateSequencer | Sequence ordering, status mapping, retry cost, progress arithmetic | pass/fail | `UpdateSequencer` |
| test_FlashWorker_SegmentPlan | Segment naming, zero padding, buffer and index bounds | pass/fail | `SegmentPlan` |
| test_FlashWorker_PatchApplier | Container decoding, patch application, malformed and hostile patches | pass/fail | `PatchApplier` |

Integration tests covering the command surface against hardware are in `test/int/ota_test.py`. They deliberately never set the next boot, so a run cannot leave the board staged to boot an unintended image.

## Requirements
Add requirements in the chart below

| Name | Description | Validation |
|---|---|---|
|---|---|---|
| A failed update is never reported as a success | Read and write failures propagate a failure status to the Updater | Unit test |
| A retry costs an erase only when one is needed | Failures that never reached flash leave the slot usable | Unit test, integration test |
| An image is validated before it is flashed | CRC32 is checked before the write, and the written bytes are verified after | Unit test, inspection |
| A patch is applied only to the image it was built from | The container records the reference size and CRC and both are checked | Unit test |
| A malformed patch cannot read or write out of bounds | Control records are range checked against both images | Unit test |

## Change Log
| Date | Description |
|---|---|
|---| Initial Draft |
| n/a | Initial Draft |
| 2026-08-04 | Correct failure reporting, add progress telemetry and parameters, add segment assembly and delta patching |

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make both FlashWorker documents pass markdownlint.

The command fences have no language. Several headings directly precede tables. Add fence languages and required blank lines in both mirrored documents.

  • PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md#L28-L159: fix MD040, MD022, and MD058 findings.
  • docs-site/components/FlashWorker.md#L28-L159: apply the same markdownlint fixes.
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 28-28: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 41-41: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 52-52: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 78-78: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 79-79: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 99-99: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 100-100: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 105-105: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 106-106: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 111-111: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 112-112: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 123-123: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 124-124: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 155-155: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 156-156: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

📍 Affects 2 files
  • PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md#L28-L159 (this comment)
  • docs-site/components/FlashWorker.md#L28-L159
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md` around
lines 28 - 159, The mirrored FlashWorker documents need markdownlint formatting
fixes: add appropriate language identifiers to every fenced command block to
resolve MD040, and insert blank lines between headings and immediately following
tables to resolve MD022 and MD058. Apply the same changes in
PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md lines 28-159
and docs-site/components/FlashWorker.md lines 28-159, keeping both documents
synchronized.

Source: Linters/SAST tools

Comment thread PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md
Comment on lines +160 to 171
for (FwSizeType i = 0; i < size; i += CHUNK) {
FwSizeType read_size = CHUNK;
file_status = file.read(this->m_data, read_size);
if (file_status != Os::File::Status::OP_OK) {
this->log_WARNING_LO_ImageFileReadError(file_name,
Os::FileStatus(static_cast<Os::FileStatus::T>(file_status)));
return UpdateSequencer::WriteOutcome::FILE_READ_FAILED;
}
// The file ended earlier than its reported size; stop rather than flushing empty writes
if (read_size == 0) {
break;
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Advance the loop counter by the bytes actually read.

read_size is an in/out parameter. A short read returns fewer bytes than CHUNK. The counter still advances by CHUNK, so the counter and the file cursor diverge. The loop can then exit while bytes remain in the file.

The truncation is not silent, because the CRC check at Line 190 fails. But the outcome is reported as FLASH_WRITE_FAILED. UpdateSequencer::dirtiesStagingSlot treats that outcome as dirty, so the operator pays for a full 1 MB erase before a retry, and the reported cause names the flash instead of the file read.

Advance by the consumed count so the loop matches the cursor.

🐛 Proposed fix
-    for (FwSizeType i = 0; i < size; i += CHUNK) {
+    for (FwSizeType i = 0; i < size;) {
         FwSizeType read_size = CHUNK;
         file_status = file.read(this->m_data, read_size);
         if (file_status != Os::File::Status::OP_OK) {
             this->log_WARNING_LO_ImageFileReadError(file_name,
                                                     Os::FileStatus(static_cast<Os::FileStatus::T>(file_status)));
             return UpdateSequencer::WriteOutcome::FILE_READ_FAILED;
         }
         // The file ended earlier than its reported size; stop rather than flushing empty writes
         if (read_size == 0) {
             break;
         }
+        i += read_size;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (FwSizeType i = 0; i < size; i += CHUNK) {
FwSizeType read_size = CHUNK;
file_status = file.read(this->m_data, read_size);
if (file_status != Os::File::Status::OP_OK) {
this->log_WARNING_LO_ImageFileReadError(file_name,
Os::FileStatus(static_cast<Os::FileStatus::T>(file_status)));
return UpdateSequencer::WriteOutcome::FILE_READ_FAILED;
}
// The file ended earlier than its reported size; stop rather than flushing empty writes
if (read_size == 0) {
break;
}
for (FwSizeType i = 0; i < size;) {
FwSizeType read_size = CHUNK;
file_status = file.read(this->m_data, read_size);
if (file_status != Os::File::Status::OP_OK) {
this->log_WARNING_LO_ImageFileReadError(file_name,
Os::FileStatus(static_cast<Os::FileStatus::T>(file_status)));
return UpdateSequencer::WriteOutcome::FILE_READ_FAILED;
}
// The file ended earlier than its reported size; stop rather than flushing empty writes
if (read_size == 0) {
break;
}
i += read_size;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`
around lines 160 - 171, Update the read loop in FlashWorker’s file-flashing
logic to increment its progress counter by the bytes actually returned in
read_size, rather than the fixed CHUNK size. Preserve the existing zero-byte
termination and file-read failure handling so the loop remains aligned with the
file cursor and processes all reported file bytes.

Comment on lines 172 to +176
status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, true);
if (status != 0) {
break;
this->log_WARNING_LO_FlashWriteFailed(static_cast<I32>(-1 * status), i);
return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED;
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Zephyr flash_img_buffered_write flush parameter last chunk semantics

💡 Result:

In the Zephyr RTOS, the flash_img_buffered_write function is used to write image data to a flash slot in blocks [1][2]. The flush parameter serves as a signal to finalize the write operation for the current data stream [1][3]. When flush is set to true, the function forces any remaining buffered data—data that has not yet filled a full flash block—to be written to flash memory [1][2]. Because flash memory must be written in blocks, this final operation includes padding the remaining space (from the last byte written up to the next multiple of CONFIG_IMG_BLOCK_BUF_SIZE) with 0xff [1][4]. In practical usage, you should set flush to true only on the final call to the function for a given image upload, as it completes the buffering process and ensures the remaining partial block is committed to the device [1][2]. Setting this parameter to false allows the API to continue holding data in an internal buffer, waiting until subsequent calls provide enough bytes to form a complete, alignable flash block [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching FlashWorker.cpp:\n'
fd -a 'FlashWorker\.cpp$' . || true

printf '\nRelevant source section:\n'
FILE="$(fd 'FlashWorker\.cpp$' . | head -n 1)"
if [ -n "${FILE:-}" ]; then
  wc -l "$FILE"
  sed -n '130,190p' "$FILE" | cat -n
fi

printf '\nSearch for flash_img_buffered_write declarations/calls in repo:\n'
rg -n "flash_img_buffered_write|BUFFER_BLOCK|CONFIG_IMG|chunk|flush" . -g '*cpp' -g '*c' -g '*hpp' -g '*fpp' -g 'CMakeLists.txt' -g 'kconfig*' | head -n 120

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 9766


Pass flush as true only on the final chunk.

flash_img_buffered_write uses flush to complete the buffered image stream. Passing true on every chunk flushes after each small write instead of buffering until the image ends. Track whether the current chunk completes the file and pass flush accordingly.

🐛 Proposed fix
-        status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, true);
+        const bool last_chunk = ((written + read_size) >= size);
+        status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, last_chunk);
         if (status != 0) {
             this->log_WARNING_LO_FlashWriteFailed(static_cast<I32>(-1 * status), i);
             return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, true);
if (status != 0) {
break;
this->log_WARNING_LO_FlashWriteFailed(static_cast<I32>(-1 * status), i);
return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED;
}
const bool last_chunk = ((written + read_size) >= size);
status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, last_chunk);
if (status != 0) {
this->log_WARNING_LO_FlashWriteFailed(static_cast<I32>(-1 * status), i);
return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`
around lines 172 - 176, Update the flash write logic around
flash_img_buffered_write in FlashWorker so the flush argument is true only when
the current chunk is the final file chunk, and false for all intermediate
chunks. Use the existing read-size, file-size, or end-of-file tracking in the
surrounding update flow to determine completion, while preserving the current
failure logging and FLASH_WRITE_FAILED return behavior.

Comment on lines +189 to 193
// What landed in the slot must match what was validated before the write started
if (written_crc != expected_crc32) {
this->log_WARNING_HI_ImageWriteCrcMismatch(expected_crc32, written_crc);
return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED;
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

State what the CRC check covers, and classify a short image separately.

written_crc accumulates the bytes read from the file. It does not read back from flash. The comment and the ImageWriteCrcMismatch event text say "Image written to flash failed verification", which claims a readback that does not happen. Correct the wording so an operator does not conclude the slot contents were verified.

A short file also lands here. written < size after the loop means the read ended early, not that the flash write failed. Reporting FLASH_WRITE_FAILED marks the staging slot dirty and forces another erase. Test written against size first, and return FILE_READ_FAILED for that case.

🐛 Proposed fix
-    // What landed in the slot must match what was validated before the write started
+    // The file ended before its reported size, so the image is incomplete. This is a read
+    // shortfall, not a flash fault, and classifying it correctly keeps the slot retryable.
+    if (written != size) {
+        this->log_WARNING_LO_ImageFileReadError(file_name,
+                                                Os::FileStatus(Os::FileStatus::T::OTHER_ERROR));
+        return UpdateSequencer::WriteOutcome::FILE_READ_FAILED;
+    }
+
+    // The bytes handed to the flash must match what was validated before the write started.
+    // This covers the file changing underneath the write; it is not a readback of the slot.
     if (written_crc != expected_crc32) {
Update the `ImageWriteCrcMismatch` format string in `PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp` at Line 88 so it does not claim a flash readback:
format "Image bytes streamed to flash failed verification: expected 0x{x} and actual 0x{x}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`
around lines 189 - 193, Update the post-write validation around written and
written_crc: check written against size before comparing CRCs, return
UpdateSequencer::WriteOutcome::FILE_READ_FAILED for a short read, and only
return FLASH_WRITE_FAILED for a CRC mismatch. Revise the nearby comment and log
event wording, including ImageWriteCrcMismatch in the FPP definition, to state
that streamed image bytes were validated without claiming flash readback or
slot-content verification.

Comment on lines +454 to +464
const FwSizeType control_start = static_cast<FwSizeType>(PatchApplier::HEADER_SIZE);
const FwSizeType diff_start = control_start + static_cast<FwSizeType>(header.control_size);
const FwSizeType extra_start = diff_start + static_cast<FwSizeType>(header.diff_size);
const bool opened = (control_file.open(patch.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) &&
(diff_file.open(patch.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) &&
(extra_file.open(patch.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) &&
(out_file.open(destination.toChar(), Os::File::Mode::OPEN_CREATE,
Os::File::OverwriteType::OVERWRITE) == Os::File::Status::OP_OK) &&
(control_file.seek(control_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK) &&
(diff_file.seek(diff_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK) &&
(extra_file.seek(extra_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK);

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the header stream sizes against the patch file size.

control_start, diff_start, and extra_start are derived from header.control_size and header.diff_size with no bound. Two problems follow from the missing check.

The sums are FwSizeType. If FwSizeType is 32-bit on this target, a header that declares a control_size near UINT32_MAX wraps the sum, and the extra cursor seeks back into the header region. The reconstruction then reads the wrong bytes.

A truncated patch also passes here. Each cursor seeks to an offset past the end of the file, and the first read fails inside PatchApplier::apply rather than at a clear validation point.

The output CRC check at Line 491 does catch both cases, so a wrong image is not reported as success. The gap is that neither case is diagnosed at its cause, and the flight side spends a full reconstruction pass before rejecting a patch it could have refused from the header.

Size the patch file once and require the declared streams to account for it exactly.

🛡️ Proposed validation
+    // The declared streams must account for the patch file exactly. This refuses a truncated
+    // container and removes any chance of the cursor offsets below wrapping.
+    FwSizeType patch_size = 0;
+    {
+        Os::File sizer;
+        if ((sizer.open(patch.toChar(), Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) ||
+            (sizer.size(patch_size) != Os::File::Status::OP_OK)) {
+            flash_area_close(area);
+            this->log_WARNING_HI_PatchFailed(static_cast<U8>(PatchApplier::Error::PATCH_READ_FAILED));
+            this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR);
+            return;
+        }
+    }
+    const U64 declared = static_cast<U64>(PatchApplier::HEADER_SIZE) + header.control_size + header.diff_size +
+                         header.extra_size;
+    if (declared != static_cast<U64>(patch_size)) {
+        flash_area_close(area);
+        this->log_WARNING_HI_PatchFailed(static_cast<U8>(PatchApplier::Error::TRUNCATED_PATCH));
+        this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR);
+        return;
+    }
+
     const FwSizeType control_start = static_cast<FwSizeType>(PatchApplier::HEADER_SIZE);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`
around lines 454 - 464, Validate the patch file size before deriving or using
the stream offsets in the FlashWorker reconstruction flow. Obtain the file size
once, perform overflow-safe checks for the header size plus control, diff, and
extra stream sizes, and require their total to equal the patch size; reject
invalid or truncated headers before seeking or reading. Keep the existing
control_start, diff_start, extra_start, and reconstruction path for valid
patches.

Comment on lines +491 to +497
if (actual_crc != crc32) {
this->log_WARNING_LO_ImageFileCrcMismatch(destination, Os::FileStatus(Os::FileStatus::T::OP_OK), crc32,
actual_crc);
this->log_WARNING_HI_PatchFailed(static_cast<U8>(PatchApplier::Error::SIZE_MISMATCH));
this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR);
return;
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report a CRC mismatch with a CRC error code.

The reconstructed image failed the CRC check. The handler reports PatchApplier::Error::SIZE_MISMATCH. The size may be correct and only the content wrong, so the code misdirects the operator. PatchFailed carries this code as the only machine-readable cause, and the .fpp documents it as Components::PatchApplier::Error.

Use a code that names the CRC failure. If PatchApplier::Error has no such value, add one.

The destination file also stays on the filesystem on this path and on the failure paths at Lines 476 and 485. Remove it, for the same storage reason as in ASSEMBLE_IMAGE_cmdHandler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp`
around lines 491 - 497, Update the CRC-mismatch branch in the FlashWorker
handler to report the dedicated CRC failure value from PatchApplier::Error
instead of SIZE_MISMATCH, adding that enum value and its .fpp definition if
absent. Also remove the destination file on this path and the failure paths near
lines 476 and 485, matching the cleanup behavior used by
ASSEMBLE_IMAGE_cmdHandler.

Comment on lines +1 to +4
// ======================================================================
// \title test_ImageBuilder_SegmentPlan.cpp
// \brief Unit tests for uplink segment naming and validation
// ======================================================================

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the file name in the title comment.

The header says test_ImageBuilder_SegmentPlan.cpp. The file is test_FlashWorker_SegmentPlan.cpp, and SegmentPlan lives under Components/FlashWorker. The stale name refers to a component that does not exist in this change.

📝 Proposed fix
-// \title  test_ImageBuilder_SegmentPlan.cpp
+// \title  test_FlashWorker_SegmentPlan.cpp
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ======================================================================
// \title test_ImageBuilder_SegmentPlan.cpp
// \brief Unit tests for uplink segment naming and validation
// ======================================================================
// ======================================================================
// \title test_FlashWorker_SegmentPlan.cpp
// \brief Unit tests for uplink segment naming and validation
// ======================================================================
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp`
around lines 1 - 4, Update the title comment in test_FlashWorker_SegmentPlan.cpp
to use the actual file name, test_FlashWorker_SegmentPlan.cpp, instead of the
stale test_ImageBuilder_SegmentPlan.cpp reference.

Comment on lines +121 to +133
TEST(UpdateSequencerTest, NoFailureOutcomeReportsSuccess) {
// Every outcome other than SUCCESS must map to a non-OK status
const UpdateSequencer::WriteOutcome failures[] = {
UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED, UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED,
UpdateSequencer::WriteOutcome::CRC_MISMATCH, UpdateSequencer::WriteOutcome::FILE_READ_FAILED,
UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED,
};
for (const UpdateSequencer::WriteOutcome outcome : failures) {
EXPECT_NE(UpdateSequencer::Status::OP_OK, UpdateSequencer::statusForOutcome(outcome));
}
EXPECT_EQ(UpdateSequencer::Status::OP_OK,
UpdateSequencer::statusForOutcome(UpdateSequencer::WriteOutcome::SUCCESS));
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the test to describe what it asserts.

The name NoFailureOutcomeReportsSuccess states the inverse of the assertions. The body asserts that every failure outcome maps to a non-OK status and that only SUCCESS maps to OP_OK. A failing run in CI would print a name that contradicts the failure.

♻️ Proposed rename
-TEST(UpdateSequencerTest, NoFailureOutcomeReportsSuccess) {
+TEST(UpdateSequencerTest, OnlySuccessOutcomeMapsToOpOk) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TEST(UpdateSequencerTest, NoFailureOutcomeReportsSuccess) {
// Every outcome other than SUCCESS must map to a non-OK status
const UpdateSequencer::WriteOutcome failures[] = {
UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED, UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED,
UpdateSequencer::WriteOutcome::CRC_MISMATCH, UpdateSequencer::WriteOutcome::FILE_READ_FAILED,
UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED,
};
for (const UpdateSequencer::WriteOutcome outcome : failures) {
EXPECT_NE(UpdateSequencer::Status::OP_OK, UpdateSequencer::statusForOutcome(outcome));
}
EXPECT_EQ(UpdateSequencer::Status::OP_OK,
UpdateSequencer::statusForOutcome(UpdateSequencer::WriteOutcome::SUCCESS));
}
TEST(UpdateSequencerTest, OnlySuccessOutcomeMapsToOpOk) {
// Every outcome other than SUCCESS must map to a non-OK status
const UpdateSequencer::WriteOutcome failures[] = {
UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED, UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED,
UpdateSequencer::WriteOutcome::CRC_MISMATCH, UpdateSequencer::WriteOutcome::FILE_READ_FAILED,
UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED,
};
for (const UpdateSequencer::WriteOutcome outcome : failures) {
EXPECT_NE(UpdateSequencer::Status::OP_OK, UpdateSequencer::statusForOutcome(outcome));
}
EXPECT_EQ(UpdateSequencer::Status::OP_OK,
UpdateSequencer::statusForOutcome(UpdateSequencer::WriteOutcome::SUCCESS));
}
🧰 Tools
🪛 Clang (14.0.6)

[warning] 121-121: use a trailing return type for this function

(modernize-use-trailing-return-type)


[warning] 121-121: all parameters should be named in a function

(readability-named-parameter)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp`
around lines 121 - 133, Rename the test NoFailureOutcomeReportsSuccess to a name
describing that failure outcomes map to non-OK statuses while SUCCESS maps to
OP_OK. Keep the test body and assertions unchanged.

Comment thread tools/bin/make-patch.py
Comment on lines +197 to +202
import bsdiff4
except ImportError:
print(
"bsdiff4 is required: pip install bsdiff4",
file=sys.stderr,
)

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -HI -t f '^(pyproject\.toml|uv\.lock|requirements.*|Makefile)$' . -x sh -c '
  echo "=== $1 ==="
  rg -n -C 2 "bsdiff4|make-patch|uv|pip install" "$1" || true
' sh {}

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 3738


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== files ==="
fd -HI -t f 'make-patch\.py|pyproject\.toml|uv\.lock|requirements.*|Makefile' . | sed -n '1,120p'

echo "=== tools/bin/make-patch.py relevant area ==="
sed -n '160,220p' tools/bin/make-patch.py

echo "=== pyproject/requirements mentions of bsdiff4 ==="
rg -n "bsdiff4" . || true

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 2876


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== pyproject files ==="
fd -HI -t f 'pyproject\.toml$' . -x sh -c '
  echo "=== $1 ==="
  sed -n "1,220p" "$1" | rg -n -C 3 "dependencies|optional-dependencies|project|tool|u?v|bsdiff4|make-patch" || true
' sh {}

echo "=== Makefile make-patch mentions ==="
rg -n -C 3 "make-patch|patch" Makefile || true

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 3281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== files ==="
fd -HI -t f 'make-patch\.py|pyproject\.toml|uv\.lock|requirements.*|Makefile' . | sed -n '1,120p'

echo "=== tools/bin/make-patch.py relevant area ==="
sed -n '160,220p' tools/bin/make-patch.py

echo "=== global bsdiff4 references ==="
rg -n "bsdiff4" . || true

echo "=== Makefile patch references ==="
rg -n -C 3 "make-patch|patch" Makefile || true

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 4501


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== pyproject files ==="
fd -HI -t f 'pyproject\.toml$' . -x sh -c '
  echo "=== $1 ==="
  sed -n "1,220p" "$1" | rg -n -C 3 "dependencies|optional-dependencies|project|tool|u?v|bsdiff4|make-patch" || true
' sh {}

Repository: Open-Source-Space-Foundation/proves-core-reference

Length of output: 1636


Register and expose bsdiff4 through the supported tool environment.

tools/bin/make-patch.py requires bsdiff4, but no project dependency, requirement, or uv.lock declares it. Add the package to UV-managed dependencies and add a Makefile target for tools/bin/make-patch.py; replace the pip install bsdiff4 instruction with that target.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/bin/make-patch.py` around lines 197 - 202, Add bsdiff4 to the project’s
UV-managed dependencies and regenerate uv.lock, then add a Makefile target that
runs tools/bin/make-patch.py and update the ImportError message in make-patch.py
to direct users to that target instead of pip install. Ensure the target is
exposed through the supported tool environment.

Source: Coding guidelines

Completes the two decisions left open when the delta patch path landed.

Compression. A raw patch is about the size of the image it rebuilds, so it was
worth nothing over the radio. Rather than add a third party decompressor to
flight software, this adds a small LZ77 codec whose exact decoder can be
round-tripped against real patches in host tests. Measured on a real patch
between consecutive CI builds: 728,388 bytes becomes 101,208, turning ~24
minutes of uplink into ~3.3.

The window is 4 KB. Measured alternatives: 16 KB saves 6% and 64 KB saves 8%,
neither of which pays for the RAM, and 4 KB already matches what heatshrink
achieved (~97 KB) in earlier measurements. A naive zero-RLE was also measured
and rejected at 241 KB, because the zeros come in short scattered runs.

The three streams are compressed together and decoded once into a scratch file,
so PatchApplier keeps operating on plain streams and its existing tests still
cover the apply unchanged.

Auto-confirm. An image booted in TEST mode reverts unless it is confirmed before
the next reboot, so a missed pass throws away a working image along with the
uplink that delivered it. AUTO_CONFIRM_ENABLED lets the ground hand that
decision to the spacecraft; it defaults to disabled, so confirmation stays
operator-in-the-loop until explicitly armed. Because the decision is made after
the reboot into the test image, arming it requires PRM_SAVE as well as PRM_SET,
which is why it is documented that way.

Confirmation is refused unless it was armed, the running image is actually
pending confirmation, and it has run continuously for AUTO_CONFIRM_DELAY_SECONDS
(default 1800). A delay of zero still requires one elapsed second, so an image
that crashes immediately cannot confirm itself. RunningImageConfirmed and
PendingConfirmSeconds are downlinked so an operator can see the countdown.

The check runs on the 1 Hz rate group, which had a free member port; the
configured ActiveRateGroupOutputPorts of 25 already covers it.

Verified: make fmt clean, make test-unit 12/12, firmware builds. The codec was
checked end to end by decoding a real 101,208 byte compressed patch and
rebuilding a real 726,784 byte image from it, byte-identical to the target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kehXMi9zAAoPJ3PgfRd81
@yudataguy

Copy link
Copy Markdown
Collaborator Author

Both open decisions are now resolved and implemented in a29752b.

1. Compression: a self-contained LZ77 codec, not a vendored library

The choice was heatshrink vs. LZMA vs. DEFLATE, all of which meant adding a third-party decompressor to flight software. Measuring first changed the answer: a ~120-line LZ77 coder whose exact decoder can be round-tripped against real patches in host tests matches heatshrink's result without the dependency.

Measured on a real patch between consecutive CI builds:

size uplink
raw patch 728,388 B 23.8 min
LZ77, 4 KB window 101,208 B 3.3 min
LZ77, 16 KB window 95,496 B 3.1 min
LZ77, 64 KB window 93,814 B 3.1 min
heatshrink (earlier measurement) ~97,000 B 3.2 min
naive zero-RLE 240,921 B 7.9 min

4 KB window. A 16× larger window buys 0.2 minutes — the compressible content is bsdiff's difference stream, which is short zero runs repeating at close range, so distance is cheap and window size barely matters. Zero-RLE was measured and rejected: the zeros come in short scattered runs (~10 bytes), so a real LZ-class coder is required.

The three streams are compressed together and decoded once into a scratch file, so PatchApplier still operates on plain streams and its existing tests cover the apply unchanged.

Verified end to end: decoding a real 101,208-byte compressed patch and rebuilding a real 726,784-byte image from it, byte-identical to the target.

2. Auto-confirm: operator-armed, defaulting to off

To answer the question directly — yes, the operator arms it from the ground, and it takes two commands:

Update.worker.AUTO_CONFIRM_ENABLED_PRM_SET(TRUE)
Update.worker.AUTO_CONFIRM_ENABLED_PRM_SAVE

The PRM_SAVE is not optional. The confirm decision happens after the reboot into the test image, so an unsaved parameter would be lost exactly when it is needed. FileHandling.prmDb is the real Svc.PrmDb and its file lives on the LittleFS storage partition, which the slot swap does not touch, so an armed setting survives the update.

Confirmation is refused unless all three hold: it was armed from the ground, the running image is actually pending confirmation (boot_is_img_confirmed() is false), and it has run continuously for AUTO_CONFIRM_DELAY_SECONDS (default 1800). A configured delay of zero still requires one elapsed second, so an image that crashes on boot cannot confirm itself. RunningImageConfirmed and PendingConfirmSeconds are downlinked so the countdown is visible.

Default is false — confirmation stays operator-in-the-loop until deliberately handed over.

The check runs on the 1 Hz rate group. ActiveRateGroupOutputPorts is configured at 25 and member 19 was free, so no config change was needed.

Verification

make fmt clean, make test-unit 12/12 (a new LzssDecoder suite covering overlapping matches, ring-buffer wrap, and malformed streams), firmware builds.

Still not hardware-tested: the full prepare → write → TEST boot → confirm cycle, APPLY_PATCH on a real board, and the auto-confirm path across an actual reboot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant