Firmware update orchestration GenServer.
Library-shape: no host dependencies. All wiring (PubSub module,
topic, download dir, fwup devpath/task, GithubClient module,
asset-matcher closure) comes through start_link/1 opts.
State machine
:idle ─→ :checking ─→ :idle
:idle ─→ :downloading ─→ :flashing ─→ :idle (legacy, unverified)
:idle ─→ :verifying ─→ :downloading ─→ :flashing ─→ :idle (manifest, verified)
any phase above ↘ :error ↗ (next check/1 clears to :idle)Every phase transition broadcasts {:fw_update_progress, payload}
on the configured PubSub topic. :error clears to :idle on the
next check/1. There is no retry, no cancel, no HTTP-range resume
(conservative error policy — see plan Q7).
Progress snapshot caveat
state.pct is updated on phase transitions only (so a state/1
snapshot reports 0 at the start of :downloading / :flashing).
Intermediate per-byte progress is broadcast via PubSub but does
not mutate the GenServer state — the install runs inside a
single handle_info/2 that blocks the GenServer, so routing
progress through self-messages would just buffer them all until
the install completes. A subscriber that reads a fresh state/1
snapshot mid-install therefore sees a stale pct for the brief
window until the next PubSub event lands and corrects it.
Conditional verification
When opts :verification_required is false (v1 default), the
install flow is the legacy path: asset_matcher.(tag, assets)
resolves a single firmware asset, which is downloaded unverified and
flashed (:downloading → :flashing, no :verifying phase). A
Logger.warning fires on every such install so the unverified
state is auditable in RingLogger.
When :verification_required is true, the flow is the manifest
path (:verifying → :downloading → :flashing) — see "## Manifest
verification" below.
Downgrade gate
Before either path runs, release.tag_name is compared against
:current_version_fn.() via VersionCompare.compare/2. A :lt
result (release older than the running firmware) is refused unless
:allow_downgrade is true. This is belt-and-braces: the manifest
counter (see below) is the primary rollback control and is the only
check that applies on the manifest path; this semver check also
guards the legacy (unverified) path, which has no counter at all.
:gt, :eq, :missing, and :incomparable all proceed.
Manifest verification
Wire contract: docs/manifest-format.md. Summary of the device-side
pipeline (entered when :verification_required is true):
- Transition to
:verifying. - Download
release-manifest.json+release-manifest.sigfrom the release assets (missing either ⇒:missing_manifest/:missing_manifest_signature). sig_mod.verify_manifest(manifest_bytes, sig_bytes, pubkey)— signed message issha512(manifest_bytes), not the raw bytes.Manifest.parse/1— schema + version validation.Manifest.check_expiry/2— policy-gated, default off (:enforce_expiry).Manifest.check_counter/2against:kv_get.("fw_manifest_counter")— rejects a lower counter (rollback), accepts equal or absent.:target_fn.()resolves the device's Nerves target;Manifest.target/2looks up its%{asset, sha256, size}entry.- Transition to
:downloading; download the named release asset withexpected_sha256/expected_size— the client verifies the streamed hash before the atomic rename. - Flash (
:flashing). On success,:kv_put.("fw_manifest_counter", counter)before reboot — the counter anchor only advances once the new firmware is actually on disk.
Any failure at any step broadcasts {:fw_update_progress, %{phase: :error, ...}} and resets to :idle on the next check/1.
Opts
:owner_repo,:github_token,:public_key— as before.:verification_required(defaultfalse) — selects legacy vs manifest install path (see above).:channel(default:stable) — forwarded toclient.latest_release/2;:stableor:prerelease.:allow_downgrade(defaultfalse) — see "Downgrade gate".:enforce_expiry(defaultfalse) — see "Manifest verification".:kv_get—(String.t() -> String.t() | nil), reads the persisted counter anchor. Missing ⇒fn _ -> nil end(no anchor, first-contact trust).:kv_put—(String.t(), String.t() -> :ok | {:error, term()}), persists the new counter anchor after a successful flash. A non-:okreturn is logged (rollback protection may be stale) but does not fail the already-completed install. Missing ⇒fn _, _ -> :ok end(no-op — degrades gracefully rather than crashing the install on a host/test without Nerves.Runtime.KV).:target_fn—(-> String.t()), resolves the device's Nerves target for the manifest path. Missing ⇒{:error, :missing_target_fn}(fails the install via the normalfail/3path; the legacy path never calls it).:current_version_fn—(-> String.t() | nil), the device's current firmware version for the downgrade gate. Missing ⇒fn -> nil end(comparison againstnilis:missing, which proceeds).:asset_matcher— legacy-path only. Contract is(tag, assets) -> {:ok, fw_asset} | {:error, reason}(no more sig asset — the manifest path replaces per-asset signatures entirely).
Test seams
:client— module implementinglatest_release/2anddownload_asset/3(defaultNervesGithubUpdater.GithubClient).:fwup— module implementingapply/2(defaultNervesGithubUpdater.Fwup).:signature— module implementingverify_manifest/3(defaultNervesGithubUpdater.Signature).
Summary
Functions
Kicks off a check for a new release. Returns :ok immediately;
the actual result is broadcast via PubSub.
Returns a specification to start this module under a supervisor.
Starts download → (optionally verify) → flash for the most recently
fetched release. Returns {:error, :no_release_cached} if check/1
has not produced a release yet, or {:error, :busy} if a phase is
in flight.
Synchronous snapshot for a subscriber's initial render.
Update mutable opts. Takes effect for the next check/install — in-flight operations continue with the opts they started with.
Functions
@spec check(GenServer.server()) :: :ok
Kicks off a check for a new release. Returns :ok immediately;
the actual result is broadcast via PubSub.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec install_latest(GenServer.server()) :: :ok | {:error, :no_release_cached | :busy}
Starts download → (optionally verify) → flash for the most recently
fetched release. Returns {:error, :no_release_cached} if check/1
has not produced a release yet, or {:error, :busy} if a phase is
in flight.
@spec state(GenServer.server()) :: map()
Synchronous snapshot for a subscriber's initial render.
@spec update_config( GenServer.server(), keyword() ) :: :ok | {:error, :immutable | :unknown}
Update mutable opts. Takes effect for the next check/install — in-flight operations continue with the opts they started with.
Returns :ok | {:error, :immutable | :unknown}.