Skip to content

ota/client ​

ts
import * as otaClient from 'mikro/ota/client'

The built-in update client: it checks in with the registry the device was enrolled against, downloads and stages an offered build, restarts to install it, and confirms the trial of a freshly installed build. It is the device half of the check-in protocol in the registry spec, speaking CBOR over HTTPS, and it sits on top of mikro/ota, which stays available for apps that bring their own wire.

Enrollment (mikro ota enroll) is the opt-in: the client reads the registry url and update key it provisioned, and an un-enrolled device gets an explicit answer instead of updates — check() resolves to {status: 'not-enrolled'}, and watch() returns an err and starts no loop. Connectivity is your app's business: the client never touches mikro/wifi; bring the network up before a check (or in the beforeCheck hook in watch mode).

There are two modes, one per app. Do not combine them: overlapping checks are queued one behind the other, but the two cadences fight over the same update state.

otaClient.check(options?) ​

ts
check(options?: CheckOptions): Promise<CheckResult>

One update check, for wake-cycle apps: reconcile the previous boot's update, check in (confirming the running trial if the check-in completes), and download and stage any offered build. It never restarts the device: on {status: 'staged'} your app calls restart() once its in-flight work is done, and the firmware installs the build on the way back up.

ts
import * as otaClient from 'mikro/ota/client'
import {deepSleep} from 'mikro/sleep'
import {restart} from 'mikro/sys'

const checked = await otaClient.check({trialBoots: 3})
if (checked.status === 'staged') restart()
// ...the cycle's work...
deepSleep(60 * 60_000)

Keep the check before the cycle's work. With a watchdog.awake limit set, a cycle that gets stuck later still checks in at the top of every wake, so a fix can arrive over the air.

The result says what happened, and only 'staged' requires action:

ts
type CheckResult =
  | {status: 'staged'; offer: Offer} // armed; restart when ready
  | {status: 'up-to-date'; configUpdated?: boolean}
  | {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
  | {status: 'failed'; error: CheckError} // check-in never completed
  | {status: 'unauthorized'} // 401: re-enroll over the cable
  | {status: 'not-enrolled'}

'up-to-date' carries configUpdated: true when the running build's stored config changed since the app could last have read it: delivered or cleared in this round, or applied by this boot's install or rollback. An app that read ota.config() earlier in the cycle knows to read it again.

'not-staged' means an offer arrived but was not armed; reason is the policy outcome ('current', 'exhausted', …) or 'download-failed'/'install-failed' with the error attached. 'failed' is transient (network, a bad response) and, importantly, does not confirm a running trial: a build that cannot complete a check-in is the build that should revert.

Every expected outcome is a value, but a genuinely unexpected throw (say, an out-of-memory error inside the native HTTP layer) rejects the promise rather than being swallowed. Watch mode contains those; a one-shot caller that does not catch them halts loudly, restarts, and checks again next cycle.

otaClient.watch(options?) ​

ts
watch(options?: WatchOptions): Result<Watcher, NotEnrolledError>

Periodic update checks, for always-on apps: a detached background loop that runs a check on a jittered cadence, retries sooner after a failed check, and restarts the device by itself after staging a build. It returns immediately; the loop never rejects, and a crashed check is contained and retried.

The result is the watcher, or err({name: 'NotEnrolled'}) on a device that has no enrollment pair, in which case no loop is started. That state is permanent for the boot (enrollment is written over the cable), so report it and move on; do not retry watch():

ts
import * as otaClient from 'mikro/ota/client'

const watching = otaClient.watch({checkinIntervalMs: 30 * 60_000})
if (!watching.ok) console.error('OTA updates are disabled:', watching.error)

A device that powers its radio down between checks brings it up per round with beforeCheck. The hook returns the matching teardown, so everything the round needs lives in one scope:

ts
otaClient
  .watch({
    checkinIntervalMs: 30 * 60_000,
    beforeCheck: async () => {
      const conn = await wifi.connect({ssid, passphrase})
      if (!conn.ok) return conn // round skipped, retried sooner
      return () => wifi.disconnect() // teardown: runs after the round
    },
  })
  .orPanic('device not enrolled')

Return whichever of these fits:

returneffect
a functionthe round runs, and that function runs after it
nothingthe round runs, with no teardown
an errthe round is skipped and retried sooner, and no teardown runs
an okas its value: a teardown function, or nothing

Throwing has the same effect as returning an err. A failed setup can hand its own error straight back, as above, since any err will do, and the teardown needs no ok() around it. Whatever a teardown returns is ignored, so it can call something that reports a Result without unwrapping or discarding it. A promise is awaited before the round is over.

Unwinding a partial setup is the hook's own job: when it reports failure, no teardown runs.

The teardown runs after its round settles (after the check and any download, including before a restart), and a beforeCheck failure skips the round without running it.

A watch loop is otherwise silent about what its rounds found. onConfig fires after a round changes the config the running build reads, whether delivered or cleared by that round or applied by this boot's install or rollback, so an app does not have to poll for it:

ts
otaClient
  .watch({
    beforeCheck: async () => {
      /* ... */
    },
    onConfig: (config) => applySettings(config),
  })
  .orPanic('device not enrolled')

It is not called for a config staged alongside an offered build: that one applies at its trial boot, so the running app cannot read it yet. Nor is it called when a registry re-sends a document the device already holds unchanged: that is not a change, and it costs nothing.

watcher.stop() cancels the pending sleep and prevents future rounds. A round already in flight completes, but a build it stages no longer auto-restarts: it stays armed for the next natural reboot.

watcher.setCheckinInterval(ms) changes the cadence without restarting the watcher, which is what a device does when the interval arrives from remote config:

ts
const watcher = otaClient
  .watch({
    checkinIntervalMs: config.checkinInterval,
    onConfig: (next) => watcher.setCheckinInterval(next.checkinInterval),
  })
  .orPanic('device not enrolled')

It is floored at 30s like checkinIntervalMs, and pulls retryAfterFailureMs down with it if that was longer. A wait already counting is re-timed from when it started, so a shorter interval brings the next round forward instead of waiting out the old one, and a config that changes often cannot push the next round away indefinitely. The initial delay is left alone.

Options ​

All options are optional.

OptionDefaultMeaning
checkinTimeoutMs10_000Wallclock budget for the check-in round trip
downloadTimeoutMs300_000Wallclock budget for the build download (cancels mid-stream)
requireConfirmtrueKeep an installed build only once a check-in completes
trialBoots1Clean boots an unconfirmed build or config survives before reverting

Watch mode adds:

OptionDefaultMeaning
checkinIntervalMs1_800_000Steady cadence, end of one round to start of next. Floored at 30s: each round's TLS session leaves heap and socket residue that small-heap devices need time to reclaim
initialDelayMs5_000Delay before the first round
retryAfterFailureMs60_000Cadence after a failed round (capped at the interval)
jittertrueSpread every sleep by ±10%
beforeChecknonePer-round network setup; returns the teardown
onConfignoneCalled after a round changes the config the running build reads

By default every scheduled sleep is jittered by ±10% so a fleet that lost power together does not check in phase-locked forever. Pass jitter: false for exact intervals, at the cost of that spread: useful when watching for an update to land, or on a single device where the spread only delays it.

trialBoots on a wake cycle ​

A deep-sleep wake counts as a clean trial boot. With requireConfirm and the default trialBoots: 1, a build installed on one cycle must complete a check-in within the next wake or two; a single wake without WiFi is enough to roll back a perfectly healthy build. Wake-cycle apps on networks that flake should raise it (the example uses trialBoots: 3): a healthy build then rides out offline wakes, while a build that can never reach the registry still reverts.

What a check does ​

Each check runs the same sequence in both modes:

  1. Once per boot, ota.reconcile(): what happened to the previous update is logged and reported to the registry (the report is held until a check-in actually completes).
  2. POST the check-in (device identity, the running build, the name pair, free staging space, the config rev the device holds) as CBOR to <registry>/api/v1/checkin, authenticated with the update key. A registry that does not accept CBOR fails the check with a clear "upgrade the registry" error.
  3. On a completed check-in: confirm the running trial (see below), adopt a device name the registry sent down, store any config delivered for the running release, and validate any offer.
  4. On an offer: stream the build straight to flash with mikro/ota's staging session, resuming an interrupted download with a Range request, and attach the update key only when the build url is on the registry's own origin.

The client confirms on any completed check-in, before taking the offer, and it does so unconditionally: the requireConfirm option decides what happens to a build that never completes a check-in, not whether the client confirms. A completed check-in shows the build boots and its network path works; it does not prove the rest of your app works. If that signal is too weak for your app (it can hang after checking in, stuck in a state a reboot would not clear), this client cannot express a later confirm, because its first check-in resolves the trial either way. Move the confirm past a point only a working build reaches by driving mikro/ota yourself (see below), or keep the client and guard against the hang with a hardware watchdog the app must feed.

Plaintext registries are refused except http:// on a private network (LAN, loopback, .local), which is the development setup; the device then warns on every boot.

Rolling your own ​

Everything the client does is built on the public mikro/ota module, and the registry spec documents the wire. An app on a different transport (CoAP, BLE, an existing fleet backend) skips this module and drives applyOffer itself; the guide shows the shape.

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.