Skip to main content
Qualcomm Linux · OTA
Dragonwing Team·Jun 29, 2026·← All posts

So here’s a thing that used to scare me: pushing a software update to a device sitting on a shelf somewhere, with no keyboard, no monitor, and nobody around to plug in a USB cable if it goes sideways. One bad update and that device is a brick. Multiply that by a fleet and you can see why people lose sleep over it. The good news is that Qualcomm Linux 2.0 already ships with a really solid answer to this, built on OSTree. And once I actually sat down and used it on an IQ-8275 EVK, the thing that surprised me most was how little I had to build myself. Most of the hard parts were already done. This is a hands-on tutorial. The commands are right here in the text, so you can read it straight through or follow it on real hardware and end up with a device that updates itself over the network. Everything shown was actually run, and where I quote a number it came off the real board. One thing up front so nobody ships this to customers by accident: this is a lab proof of concept. It uses plain HTTP and turns off signature checking so you can actually see the gears turn. That is great for learning and terrible for production. I’ll talk about the grown-up version at the end.

What we’re actually going to do

The plan is small and the payoff is fun. We build two versions of the same image, flash the first one onto the board, then move the live device to the second over the air, no second flash. IQ-8275 EVK running v1 on the bench The IQ-8275 EVK on the bench (left) and its Weston desktop running v1 (right).
Version/etc/ota-demo-versionWallpaperVim
v11grey Qualcomm logonot installed
v22purple Supergraphicinstalled
Then we:
  1. Flash the device once with v1, because there’s no OTA before the first SOTA image.
  2. Stand up a dead-simple update server.
  3. Build v2, and push it onto the running device using the device’s own little web page (the way a real user would), no reflash.
  4. Watch the old version stick around as a rollback the whole time.
  5. Measure how little the actual change is.
That last step is the one that made me grin, so stick around for it.

The part where Qualcomm did the heavy lifting

Let me be upfront, since I work here: the reason this was easy is the work was already in the layers. The distro is shipped by meta-qcom-distro (meta-qcom itself is just the BSP), and it comes in two flavors. The default, qcom-distro, is your normal package managed root filesystem. The interesting one is qcom-distro-sota. SOTA means software over the air, and picking that distro flips on the whole update stack for you. What does “the whole stack” mean? When you build with qcom-distro-sota, you get OSTree baked into the image, an initramfs that knows how to boot an OSTree deployment, and the build drops an ostree_repo next to your normal flashable image. That repo is what devices pull updates from. You didn’t write any of it. You just chose a different distro. From here on I’ll call the result a SOTA image: an ordinary flashable image that happens to have the OTA stack baked in. The build command is honestly almost boring, which for a build command is the highest praise I have:
~/.local/bin/kas build \
  meta-qcom/ci/iq-8275-evk.yml:meta-qcom/ci/qcom-distro-sota.yml \
  --target qcom-multimedia-image
That colon separated list is just kas stacking config fragments. The qcom-distro-sota.yml one is doing all the magic. Swap it for qcom-distro.yml and you get a normal image with no OTA. Same machine, same recipes, one fragment of difference. I really like that the OTA story is a build time switch and not a giant separate project you bolt on. (Later, when we build v1 and v2, you’ll see one more fragment on that line, ota-demo.yml. That one isn’t a stock Qualcomm fragment, it’s the little demo layer we add ourselves, and we’ll create it in a minute.) One thing worth knowing up front: OTA only works once the device already has the OSTree stack on it, and a plain qcom-distro image doesn’t. So you flash a SOTA image once, over USB, and from then on you update over the air.

OSTree in two minutes

People call OSTree “git for your root filesystem” and honestly that’s a good enough mental model to start. It stores complete snapshots of your filesystem as commits in a repository. Each commit is content addressed, so identical files are stored once and shared. A device pulls a new commit, builds a new deployment from it, and boots into that deployment on the next reboot. The old one stays right where it was. That last sentence is the entire safety story. Your running system is never edited in place. The new version is assembled completely off to the side, and the switch only happens on a clean reboot. If the new deployment never shows up, you’re still on the old one, untouched. Here’s what that looks like on the device once an update has landed. The star is what you booted, the second line is your safety net:
ostree admin status
* nodistro a69bc64ff5e438674d5227557f3aa9181a53eb823d3b9d8f29e34732d24d96ec.0
    Version: 2.0
    origin refspec: otaserver:iq-8275-evk
  nodistro 5774004c304409745f67a83767cb070c5ac270b65ec02f8377b3a3bdac5714ee.0 (rollback)
    Version: 2.0
    origin refspec: nodistro:iq-8275-evk
And /usr, the actual OS content, is mounted read only, so nothing can quietly scribble on it:
mount | grep ' on /usr '
# /dev/sda2 on /usr type ext4 (ro,relatime)
Your runtime config and state should live outside that immutable part, under places like /etc, /var, and /home; those runtime areas are meant to carry across updates instead of getting wiped. That’s a design rule, not magic for anything you happen to drop there at build time, and we’ll come back to that distinction near the end with a little file.

OSTree or A/B, and why I went with OSTree

This is the question everyone asks, so let me actually answer it. A/B updates, also called dual bank, are the approach where you carve storage into two full copies of the system, slot A and slot B. You run from A, write the entire new image into B, flip a flag, and boot B next time. It’s simple to reason about and rock solid, which is why phones have done it forever. But A/B has two costs that are easy to gloss over. First is space. You permanently give up enough flash for two complete copies of the OS, all the time, even though you only ever run one. On a cost sensitive edge device that second slot is real money in parts. Second is bandwidth. In the simplest A/B setups an update means shipping a whole new system image, because you’re writing a whole slot. OSTree comes at it from the other direction. There aren’t two fixed slots. There’s one repository that stores deployments, and they share every file they have in common. When you update, the device only pulls the objects that actually changed. That’s where it gets fun, and I have a real number for you later that I still think is a little ridiculous. So the trade is roughly this. A/B gives you a dead simple mental model and a guaranteed known good slot, at the cost of double the flash and chunky updates. OSTree gives you tiny, bandwidth efficient updates and atomic deployments with rollback safety, without reserving a second full slot. It asks two things back: a slightly less obvious mental model, and the discipline of a read only OS, which means you have to decide up front where your writable data lives (/etc, /var, /home) rather than scattering it across a filesystem you can edit anywhere. For an edge Linux device that ships features regularly, the OSTree side of that trade is really attractive, and again, Qualcomm already wired it up.

Setting up: three machines

You need three roles. They can be three boxes or, honestly, a board plus a laptop wearing two hats.
BUILD_HOST=<build-host-ip>        # Ubuntu box with the build tree (the Qualcomm Linux Yocto checkout)
DEVICE_IP=<device-ip>             # the IQ-8275, already flashed with a SOTA image (see above)
UPDATE_SERVER_IP=<server-ip>      # anything that can serve static HTTP
The build tree lives at ~/yocto-build, and the directory that matters most is where the build drops its output:
~/yocto-build/build/tmp/deploy/images/iq-8275-evk/
~/yocto-build/build/tmp/deploy/images/iq-8275-evk/ostree_repo   <- the repo devices pull from
One assumption to be clear about: this starts from an existing Qualcomm Linux Yocto checkout that already has the meta-qcom layers and kas fragments in place. It does not cover repo initialization or the first cold clone of all the layers, so it’s not a from-zero SDK setup guide. If you’re standing the tree up for the first time, do that part with the normal Qualcomm Linux getting-started flow — see Build Qualcomm Linux using Yocto — then come back here.
Tested with Qualcomm Linux 2.0, meta-qcom / meta-qcom-distro at the qli-2.0 release tag, on an IQ-8275 EVK. This tutorial is sensitive to layer versions — if paths or package names have moved in a newer release, pin to this tag to follow along exactly.
A note on speed before you start: reuse the build directory, the sstate-cache, and downloads between v1 and v2. Do not wipe them. With a warm cache each rebuild here took about eight minutes, not the multi hour cold build. That’s the difference between iterating and going for coffee.

The little Yocto layer that holds it together

I put everything custom into one small layer, meta-ota-demo. It has three jobs: install the update agent, write a visible version marker to /etc/ota-demo-version, and override the desktop wallpaper so updates are obvious on screen. There’s no magic in any of it, just a handful of small files. Start with the layer layout on the build host:
cd ~/yocto-build
mkdir -p meta-ota-demo/conf
mkdir -p meta-ota-demo/recipes-ota/ota-agent/files
mkdir -p meta-ota-demo/recipes-ota/ota-demo-version
mkdir -p meta-ota-demo/recipes-graphics/wayland
Every file below is listed in full, with a copy button, on the companion files page — here I’ll just say what each one is and link to it, in the order you create them. Start with the layer config, layer.conf, saved at meta-ota-demo/conf/layer.conf. It registers meta-ota-demo with the build. Next, the version-marker recipe ota-demo-version.bb, saved at meta-ota-demo/recipes-ota/ota-demo-version/ota-demo-version.bb. Bumping OTA_DEMO_VERSION is “knob 1” later; it just writes the value to /etc/ota-demo-version, shipped as a conffile so it survives updates. Then the wallpaper override weston-init.bbappend, saved at meta-ota-demo/recipes-graphics/wayland/weston-init.bbappend. It prepends our file path so our qcom-background.png wins over the stock one (“knob 2”). The agent recipe ota-agent_1.0.bb, saved at meta-ota-demo/recipes-ota/ota-agent/ota-agent_1.0.bb. Its one non-obvious part is RDEPENDS: the agent is pure Python standard library, but on a minimal Yocto image the stdlib is chopped into separate packages, so you have to name exactly the ones the agent imports (more on that in a minute). Its systemd unit ota-agent.service, saved at meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.service. The agent’s config file ota-agent.conf, saved at meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.conf. The agent program itself, ota-agent.py, saved at meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.py — it’s the one file long enough to deserve its own section, and we’ll come back to it in a moment. Last, the kas fragment ota-demo.yml, saved at meta-qcom/ci/ota-demo.yml. This one pulls the layer into the build and adds packages to the image; note it lives next to the other Qualcomm CI fragments, not inside the layer. We’ll edit that fragment’s IMAGE_INSTALL:append line per build to add or drop Vim. That’s one of our three “knobs.” Here’s where everything lands. Eight files go in the new meta-ota-demo layer; the ninth, ota-demo.yml, sits beside the stock Qualcomm CI fragments in meta-qcom:
~/yocto-build/
├── meta-ota-demo/                                  ← the new layer
│   ├── conf/
│   │   └── layer.conf
│   ├── recipes-ota/
│   │   ├── ota-demo-version/
│   │   │   └── ota-demo-version.bb
│   │   └── ota-agent/
│   │       ├── ota-agent_1.0.bb
│   │       └── files/
│   │           ├── ota-agent.py
│   │           ├── ota-agent.service
│   │           └── ota-agent.conf
│   ├── recipes-graphics/
│   │   └── wayland/
│   │       ├── weston-init.bbappend
│   │       └── weston-init/
│   │           └── qcom-background.png             ← active wallpaper (copied per build)
│   ├── wallpapers-v1.png                           ← source images you stage at the layer root
│   └── wallpapers-v2.png

└── meta-qcom/
    └── ci/
        └── ota-demo.yml                            ← kas fragment, NOT inside the layer
The weston-init/qcom-background.png file isn’t one you create by hand — each build copies wallpapers-v1.png or wallpapers-v2.png onto that path, which is how the FILESEXTRAPATHS:prepend in the bbappend picks it up. We’ll do that copy in the per-version build steps below. While you’re here, point the agent at your server. The agent reads /etc/ota-agent.conf on the device, which comes from the file in the layer:
sed -i "s#^SERVER_URL=.*#SERVER_URL=http://${UPDATE_SERVER_IP}:8000#" \
  meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.conf

A quick word about the agent, because it’s almost nothing

OSTree gives you the engine. It does not hand you a button. The grown-up production answer is aktualizr-lite talking to a real backend like the SOTA stack’s TUF path, and that client is already included in the distro. But I wanted to see the gears, so I wrote the smallest possible thing: a tiny Python service, no frameworks, nothing to pip install, that serves one web page on port 8088. The page shows what’s running, checks the server for what’s available, and if there’s something new it shows a button. Under the hood the button runs exactly this:
ostree remote add --no-gpg-verify otaserver http://server/ostree_repo
ostree pull otaserver iq-8275-evk
ostree admin deploy otaserver:iq-8275-evk
reboot
That’s the whole update. Pull the new commit, deploy it, reboot into it. The web page is just a friendly face over those four lines. The whole agent is one file, only the Python standard library, and you can read it in a couple of minutes. The full source is ota-agent.py on the companion files page — copy it from there rather than retyping the ~320 lines, and save it where the recipe expects it, at meta-ota-demo/recipes-ota/ota-agent/files/ota-agent.py. If you want to skim it without leaving the page, the heart of it is small: a do_update() that registers the remote, runs ostree pull then ostree admin deploy, and reboots, wrapped in a single-page HTTP handler that renders the status card and the button. Everything else is parsing ostree admin status and formatting the page. There’s one Yocto gotcha hiding in that recipe that’s worth calling out, because it cost me an hour. On a minimal image the Python standard library is chopped into a bunch of separate packages. So even though the agent only imports “built in” modules, the image didn’t actually have them, and the service crash looped on first boot with ModuleNotFoundError: No module named 'html', which is a deeply confusing thing to read when you’re sure you didn’t import anything fancy. The fix is to declare exactly which packages you need in the recipe:
RDEPENDS:${PN} = "python3-core python3-html python3-netclient python3-netserver python3-json ostree"
Once those were declared, the agent came up clean on a fresh image with zero hand fixing, which is the bar you want. If you have to SSH in and patch something after every flash, you haven’t really shipped anything.

Two wallpapers

We want the change to be visible, so we use a different wallpaper per version. The two PNGs are shown below — or use any two images of your own. wallpapers-v1.png — grey Version 1 wallpaper. Download: wallpapers-v1.png wallpapers-v2.png — purple Version 2 wallpaper. Download: wallpapers-v2.png Drop the two PNGs in:
cp /path/to/wallpapers-v1.png ~/yocto-build/meta-ota-demo/wallpapers-v1.png
cp /path/to/wallpapers-v2.png ~/yocto-build/meta-ota-demo/wallpapers-v2.png
Each build copies one of these onto the recipe’s actual wallpaper path. That’s our second knob.

Build v1: marker 1, wallpaper A, no Vim

Three knobs, then build:
cd ~/yocto-build

# knob 1: version marker
sed -i 's/^OTA_DEMO_VERSION ?= .*/OTA_DEMO_VERSION ?= "1"/' \
  meta-ota-demo/recipes-ota/ota-demo-version/ota-demo-version.bb

# knob 2: wallpaper A
mkdir -p meta-ota-demo/recipes-graphics/wayland/weston-init
cp meta-ota-demo/wallpapers-v1.png \
   meta-ota-demo/recipes-graphics/wayland/weston-init/qcom-background.png

# knob 3: no vim
sed -i 's/IMAGE_INSTALL:append = .*/IMAGE_INSTALL:append = " ota-agent ota-demo-version"/' \
  meta-qcom/ci/ota-demo.yml

# build (reuses the cache, one target)
~/.local/bin/kas build \
  meta-qcom/ci/iq-8275-evk.yml:meta-qcom/ci/qcom-distro-sota.yml:meta-qcom/ci/performance.yml:meta-qcom/ci/ota-demo.yml \
  --target qcom-multimedia-image

# grab the commit this build produced
V1_COMMIT=$(cat build/tmp/deploy/images/iq-8275-evk/ostree_repo/refs/heads/iq-8275-evk)
echo "V1_COMMIT=$V1_COMMIT"
With a warm cache this took about eight minutes for me, and the build log confirms almost everything was reused:
Tasks Summary: Attempted 13385 tasks of which 13324 didn't need to be rerun and all succeeded.
real 458.83

Stand up the update server

This part is almost embarrassingly simple. The server is a static HTTP server pointed at a web root that holds two things: a copy of the build’s ostree_repo (which we’ll sync over when we publish v2), and a small version.json that says “here’s the latest version and its commit.” Create the web root and start serving it:
mkdir -p ~/ota-server/www
cd ~/ota-server/www
python3 -m http.server 8000 --bind 0.0.0.0
That’s it. Make sure the device can reach it:
curl http://${UPDATE_SERVER_IP}:8000/version.json
If this 404s right now, that’s fine; the point at this stage is just that the server is reachable. We write version.json when we publish v2. Want updates to come from the internet instead of your laptop later? Point the agent at a public URL or stick the repo behind a CDN. The device side does not change, because to OSTree a remote is just a URL.

Flash v1 onto the device

This is the one and only time we flash over USB. There’s no OTA before the first SOTA image, so v1 has to be flashed the normal way. The build dropped a flashable image next to the ostree_repo:
~/yocto-build/build/tmp/deploy/images/iq-8275-evk/
Put the board in EDL mode and flash that image with the standard Qualcomm flashing flow (qdl / the flat-build flashing tool you already use for this board). I’m not going to re-document EDL flashing here, it’s covered in the board’s getting-started material, and it’s the same process whether or not the image is SOTA. Once it boots, confirm v1 is live and the agent came up on its own:
ssh root@${DEVICE_IP}
ostree admin status                 # '*' on V1_COMMIT
cat /etc/ota-demo-version           # 1
which vim || echo "no vim, as expected"
curl -s -m4 http://127.0.0.1:8088/ >/dev/null && echo "agent UI up"
You just moved a running device to a freshly built OS version over the network, and the old version is sitting right there as a rollback. No cable. That alone would have impressed me a year ago.

Build v2 and update the easy way

Same three knobs, different values: marker 2, wallpaper B, and this time Vim goes in.
cd ~/yocto-build
sed -i 's/^OTA_DEMO_VERSION ?= .*/OTA_DEMO_VERSION ?= "2"/' \
  meta-ota-demo/recipes-ota/ota-demo-version/ota-demo-version.bb
cp meta-ota-demo/wallpapers-v2.png \
   meta-ota-demo/recipes-graphics/wayland/weston-init/qcom-background.png
sed -i 's/IMAGE_INSTALL:append = .*/IMAGE_INSTALL:append = " ota-agent ota-demo-version vim"/' \
  meta-qcom/ci/ota-demo.yml

~/.local/bin/kas build \
  meta-qcom/ci/iq-8275-evk.yml:meta-qcom/ci/qcom-distro-sota.yml:meta-qcom/ci/performance.yml:meta-qcom/ci/ota-demo.yml \
  --target qcom-multimedia-image

V2_COMMIT=$(cat build/tmp/deploy/images/iq-8275-evk/ostree_repo/refs/heads/iq-8275-evk)
echo "V2_COMMIT=$V2_COMMIT"
Now publish it. Publishing is two steps: copy the build’s freshly produced ostree_repo into the server’s web root, then write the version.json the device’s agent reads. If your build host and update server are the same machine, the copy is a local rsync; if they’re separate, the script pulls over SSH (there’s a commented line in it for that). It’s a small script — the full source is publish.sh on the companion files page. Put it on the update server, make it executable, and skim the top of it to confirm DEPLOY_DIR matches where your build dropped its output:
mkdir -p ~/ota-server
# save publish.sh (from the companion files page) to ~/ota-server/publish.sh, then:
chmod +x ~/ota-server/publish.sh
If your update server and build host are different machines, uncomment the SSH rsync line near the top of publish.sh (it’s right above the local one) and set DEPLOY_DIR to the path on the build host.
Run it to publish v2:
DEPLOY_DIR=~/yocto-build/build/tmp/deploy/images/iq-8275-evk \
  ~/ota-server/publish.sh 2 "v2: marker=2, wallpaper B, vim"

curl -s http://${UPDATE_SERVER_IP}:8000/version.json
Now do the update the way a real user would. Open the device’s web page in a browser:
http://<device-ip>:8088/
It shows the device on v1, v2 available, and a button. Click it. The update runs in the background and the page shows the update log — refresh to see progress. The device reboots, and about two minutes later it’s back on v2. OTA web UI before and after the update Left: the web UI showing v2 available with the “Download & install” button. Right: the same page after the update, with v2 running and v1 retained as rollback. If you’re headless, the button is just a POST and you can do the exact same thing from a terminal:
curl -X POST http://${DEVICE_IP}:8088/update
Reconnect and confirm the new world:
ssh root@${DEVICE_IP}
ostree admin status        # '*' on V2_COMMIT, V1_COMMIT now the rollback
cat /etc/ota-demo-version  # 2
which vim                  # /usr/bin/vim   <- a whole new package, delivered over the air
The wallpaper changed, vim appeared, and the version file reads 2. The visual difference is hard to miss: Desktop before and after: v1 grey wallpaper vs v2 purple Supergraphic v1 desktop (left): grey Qualcomm wallpaper, no vim. v2 desktop (right): purple Supergraphic wallpaper, vim installed. Same device, updated over the air. The wallpaper on screen changed too. One small honesty note from doing this for real: the web UI shows the OSTree Version: field, which is the distro version 2.0 for every build, not my demo marker. It still compares the actual commits correctly, so “up to date” is right, but if I were productizing this I’d display a real product release version from signed metadata instead of leaning on that field.

The number I promised you

Here’s the headline. How big is the actual change to add a whole text editor, swap the wallpaper, and bump a version file? First, the size of the OS content we’d be replacing in a naive full image push:
du -sh /usr
# 2.1G
Now generate a static delta between the two commits and let OSTree tell us how big the real change is:
ostree --repo=/ostree/repo static-delta generate \
  --from=$V1_COMMIT --to=$V2_COMMIT --filename=/tmp/v1-v2.delta
uncompressed=40818190 compressed=8218038 loose=40542896
The compressed delta was 8,218,038 bytes. About 7.8 MB. Against 2.1 GB of OS content, that’s roughly 260 times smaller. To add Vim and change the wallpaper. You can see exactly what changed, too:
ostree --repo=/ostree/repo diff $V1_COMMIT $V2_COMMIT
M    /usr/etc/ota-demo-version
M    /usr/share/backgrounds/qcom-background.png
A    /usr/bin/vim
A    /usr/bin/vimdiff
A    /usr/bin/xxd
A    /usr/share/vim/...
One honest caveat about that number. static-delta generate measures how small the change compresses to — about 7.8 MB. The bare-bones POC flow in this post doesn’t publish that delta to the server, so the device’s ostree pull actually fetched the changed objects loose (the loose=40542896 figure above, ~40 MB) rather than one tidy 7.8 MB file. That’s still tiny next to 2.1 GB, but to make the device pull the 7.8 MB delta on the wire you generate the static delta into the served repo before devices update — which is exactly what production does. Now picture that across a fleet on metered cellular or flaky rural links. The difference between every device pulling single-digit megabytes versus a couple of gigabytes is the difference between an update you push casually on a Tuesday and an update you schedule like a military operation. For production you’d pre-generate these static deltas on the server so each device gets one tidy file. Same idea, even tighter on the wire.

Proving the persistence claim

I said /etc survives updates. Let me actually show it instead of asserting it. Before the v1 to v2 update, drop a file in /etc:
echo created-before-v2 > /etc/ota-persistence-test
After the update and reboot:
cat /etc/ota-persistence-test    # still there
rm -f /etc/ota-persistence-test
It survives. That’s the OSTree model doing what it promises: the OS in /usr got swapped wholesale, but your stuff in /etc came along. One thing the build will warn you about, and you should listen: data dropped into places like /var/lib or /home at build time is not preserved the way you might expect. In an OSTree system, initial image content and persistent runtime state are different things, and deciding what lives where is a real design step for your product, not an afterthought.

Where to take it next

What I built is a proof of concept on purpose. It uses plain HTTP, skips signing so the mechanics stay visible, and the agent runs as root because deploying and rebooting need that. None of that is what you ship. For production you’d sign your commits, serve over HTTPS, give devices real identity, and lean on the aktualizr-lite plus TUF stack the SOTA distro already bundles, so devices verify exactly what they’re installing and you get a real fleet story with rollout policy and update reporting. You’d also pre-generate static deltas for known version paths. But the foundation under all of that is the same one we just used, and it came in the box with Qualcomm Linux 2.0. Atomic deployments, retained rollback, tiny deltas, a clean line between the read only OS and your persistent data. The hard, scary, brick-your-device parts are handled. What’s left is the genuinely fun part, deciding how and when your devices should update, and that part you get to design however your product needs. For an embedded customer the interesting thing here was never the little Python web page. That page is disposable. The interesting thing is that choosing qcom-distro-sota turns the image into an OSTree managed system, and a real device can move between real Yocto-built OS commits over the network without reflashing, keeping the old one as a safety net, paying only for what changed. That’s the piece you can build a product on. If you want to run this yourself, the full source of the longer files lives on the companion files page — the ota-agent.py web agent and the publish.sh server script, each with a copy button — and the short meta-ota-demo recipes are inline above as copy-paste heredocs. Create them as shown, point the three IP addresses at your own gear, and go push an update to something.