birddog-play-flasher

birddog-play-flasher

Docker app from Stoatworks Labs' Repository

Overview

Flashes a BirdDog PLAY decoder in recovery mode over WebUSB, and can inject a firmware package on the way so the unit installs it on first boot. Note: WebUSB needs a secure context. Served over plain http at a LAN address there is no navigator.usb at all, so the container cannot reach a device — open it at http://localhost:PORT on the machine holding the USB cable, or put a TLS terminator in front of it. This fails silently rather than loudly: the page loads and simply offers no device. This app is nothing but WebUSB, so without it there is no fallback at all. public/ is committed already built, hence no build stage; there is no _headers file in this repo, so the image carries the default header set.

AI-assisted project. This codebase was created with Claude Code. The USB half has met a real BirdDog PLAY: the loader push, the re-enumeration wait after it and the read-back verification were each corrected against what the device actually did, and the image parsing, the filesystem injection and the partition table are covered by tests against genuine vendor files. No injected .fw has ever been seen to install on first boot — nothing has yet been watched to boot and apply one.

BirdDog PLAY recovery flasher

A static web page that flashes a BirdDog PLAY in Rockchip recovery mode over USB, straight from the browser — and, optionally, injects a .fw update package into the image so the device installs it on first boot.

No upload, no server, no vendor key. You supply your own factory .img; the loader that gets pushed over USB is extracted from that file in the browser at run time. That is the only reason this repo can be public — see AGENTS.md §5.

Partly proven on a real PLAY (2026-08-15). The image parsing, the filesystem injection and the partition table are covered by tests against genuine vendor files. The USB half was written from rkdeveloptool's protocol and has since been corrected against a device: the loader push, the re-enumeration wait after it, and read-back verification all changed because of what the hardware did. The first-boot install of an injected package has never been observed — nothing has yet been seen to boot and apply one.

The page with a genuine PLAY_1.0.30.img chosen, showing the parsed partition table

Both files above are real: a 2.3 GB factory .img and a 35 MB .fw. The image is read off disk in slices and never held in memory, so choosing one costs nothing.

The parsed GPT, the device pairing step and the write step, with the image identified as RK3328 H223

What it does

Flash a factory .img Whole device: bootloader, u-boot, trust, kernel, DTB, rootfs, partition table. This is the brick-recovery path.
Inject a .fw Parks the package in the unused tail of the rootfs partition and adds a one-shot service that installs it on first boot via the device's own updater.
Write individual partitions The opposite job: put a replacement OS on a unit, writing only the partitions you name and leaving the rest — including the vendor's recovery partition — untouched.
Verify Optional read-back and compare of everything written.

Writing individual partitions

Restoring a factory image and installing a replacement OS are different operations, and this does the second one. Give it one file per partition — a boot image and a rootfs filesystem, say — and it writes those and nothing else.

Two things make it safer than the dd-at-an-offset it replaces:

  • It aims using the partition table read off the device, not a sector copied out of a parameter file. A number in a script is right until it meets a unit that has already been reflashed, or is not a PLAY at all — which is precisely the case where a 3.5 GiB write at a plausible offset does real damage. The table is read, the header checksum is verified, partitions are matched by name, and a file too large for its target is refused rather than truncated.
  • uboot, trust and recovery are refused by default. They are what loader mode and the vendor's restore depend on. Writing them is possible, but it takes a deliberate tick of an override, because getting them wrong is the difference between "flash it again" and "this unit is gone".

It needs the device in loader mode: a maskrom device cannot serve the LBA reads used to fetch the partition table until a loader has been pushed into it, and the loader comes out of a factory image.

Everything not listed is left exactly as it was, which is the whole point — it is what keeps the factory recovery path available after you have replaced the OS.

Why injection works

A .fw is not a partition image — it is a gzip'd tar whose ./update runs as root on a booted device. In maskrom mode there is no OS to run it, so it cannot simply be flashed. Two facts from the factory image make the injection clean:

  • The rootfs partition has a 1.26 GiB hole. parameter.txt gives rootfs 7,340,032 sectors (3.5 GiB) while rootfs.img is 2,399,595,520 bytes (2.235 GiB). Nothing ever grows into it — only userdata:grow grows, and that is a different partition. The package lives there as raw bytes, costing no filesystem space.
  • The rootfs is plain ext2dir_index filetype and nothing else. No journal, no extents, no metadata checksums. So three small files can be added to it correctly from JavaScript, and e2fsck agrees.

On first boot the injected service dds the package back off the partition, hands it to BirdDogUpdateRunner exactly as the vendor's own network path does, waits for the updater to finish, and reboots. It marks itself done before installing, so a package that kills the box cannot reinstall itself on every boot.

Getting a PLAY into recovery mode

The recovery button is not on the outside of the case — it sits inside the 3.5 mm headphone socket. Straighten a paperclip or use a SIM-eject tool, push it gently all the way in, and you will feel the button click.

  1. Power the unit off.
  2. Push the pin into the headphone socket and hold the button down.
  3. Still holding it, apply power, and keep holding for a few seconds after.
  4. Connect USB, then release. The unit enumerates as vendor 2207, in either maskrom or loader mode — the page reports which and handles both.

If it boots normally instead, the button was not held down far enough or long enough; the socket is deep and the button is right at the bottom.

Windows needs Zadig to rebind the device to WinUSB, because Rockchip's own driver claims it and WebUSB cannot then reach it. macOS needs nothing. Linux needs a udev rule for 2207:*.

Installing a .fw on a unit that still boots

You do not need any of this to update a working PLAY — the device's own web interface does it. Browse to the unit's IP address, log in, go to the System tab, and upload the .fw there. The device installs it and reboots.

This tool is for the case where that is not an option: a unit that will not boot, will not take a web-UI update, or needs the whole device put back to a known state. Injecting the .fw here just saves a second trip once it is back up.

Tests

npm test

CI-safe tests build their own ext2 filesystem with mke2fs and check the result with e2fsck, debugfs and an independent Python GPT parser — no vendor files involved. The end-to-end tests need real firmware and are skipped unless you point them at your own copies:

PLAY_IMG=~/Downloads/PLAY_1.0.30.img PLAY_FW=~/fw/BirdDog_PLAY-1.0.34.fw npm test

Deploying

cf-run npx wrangler deploy

Assets only — there is no Worker, because there is nothing for a server to do. Deliberately not connected to Workers Builds, so pushing a branch cannot publish to production.

Run your own copy

BirdDog PLAY Flasher is a static page, so hosting it yourself is one container serving the built files — the same files the hosted copy serves, running somewhere that still works when the venue has no internet.

Docker. The image is built by this repo's docker.yml workflow on every push and published as ghcr.io/stoatworks-labs/birddog-play-flasher:

docker run -d --name birddog-play-flasher --restart unless-stopped -p 8539:80 ghcr.io/stoatworks-labs/birddog-play-flasher:latest

Or docker compose up -d with the docker-compose.yml in this repo, which maps the same port. Either way it is then at http://localhost:8539/.

Unraid. Search Community Applications for BirdDog PLAY Flasher — the template is templates/birddog-play-flasher.xml in stoatworks-unraid, which is what the CA feed reads.

Stoatworks Burrow lists it under Self-hosted, with the compose file a click away.

The Dockerfile, docker-compose.yml, docker/ and the workflow are generated from fleet.json in stoatworks-unraid. Change them there and regenerate rather than editing them here.

Licence

MIT. Not affiliated with, endorsed by, or supported by BirdDog.

Install birddog-play-flasher on Unraid in a few clicks.

Find birddog-play-flasher in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.

Open the Apps tab on your Unraid server Search Community Apps for birddog-play-flasher Review the template variables and paths Click Install

Related apps

Details

Repository
ghcr.io/stoatworks-labs/birddog-play-flasher:latest
Last Updated2026-09-07
First Seen2026-09-07

Runtime arguments

Web UI
http://[IP]:[PORT:80]/
Network
bridge
Shell
sh
Privileged
false

Template configuration

WebUI PortPorttcp

Host port for the web interface.

Target
80
Default
8539
Value
8539