Building a module
How to make something the generator packs into your .fw and the
installer runs on the PLAY, next to Tailscale, the KVM endpoint, the media player and the
UVC converter — which are all modules in everything but packaging.
- What a module is
- Layout and
module.conf - What
installgets - The rules
- Limits the format imposes
- Building and checking
- On the device
- Worked examples
What a module is
The whole package format is a gzip'd tar with an executable update at the top
level, which BirdDog's updater extracts into a temp dir and runs as root with no signature
check. Our update is a bash script. A module is a directory it
finds at modules/<name>/ inside that package, carrying an
install script that it runs — as root, from that directory — once its own
payloads are in place.
You hand the generator a .tgz of that directory under Custom
modules. It is read in this browser (nothing is uploaded anywhere), checked, and
written into the .fw at ./modules/<name>/. The quickest way
to a working one is bd-play-module-template:
a complete module that installs a heartbeat service, with every line of its
install commented.
Layout and module.conf
module.conf NAME, VERSION, DESCRIPTION, HOMEPAGE — read, never sourced
install bash; the installer runs it as root with this directory as cwd
uninstall optional but expected; copy it onto the device so it can be run later
payload/ whatever install copies to /userdata/bd-<name>/
run.sh the unit's ExecStart
bin/ compiled programs — aarch64 only
Only module.conf and install are required, and only their
names are — what goes in payload/ and how install uses it
are yours. The archive may hold these at its root, or all inside one top-level directory
(what tar czf module.tgz mymodule/ makes); the generator strips that directory.
macOS ._ files and .DS_Store are dropped.
module.conf is shell-style KEY=value, one per line, quotes
optional. It is parsed with a regular expression here and with sed on the
device; nothing in it is executed.
| Key | Rule | Used for |
|---|---|---|
NAME |
required; 1–32 characters of a-z 0-9 -, starting with a letter or digit.
Not one of tailscale tailscale-ui kvm cam cam-api play probe modules,
which the generator's own payloads use. |
modules/<NAME>/ in the package; by convention
/userdata/bd-<NAME> and bd-<NAME>.service on the
device. Two chosen modules with one NAME are refused. |
VERSION |
required; 1–32 characters of A-Z a-z 0-9 . _ + - |
logged on the device with each install |
DESCRIPTION | optional | shown in the build log here |
HOMEPAGE | optional | for whoever reads the file later |
What install gets
The installer runs bash ./install — bash regardless of your shebang — with
the module directory as the working directory and this in the environment:
| Variable | Value |
|---|---|
BD_MODULE | NAME from module.conf |
BD_MODULE_VERSION | VERSION from module.conf |
BD_MODULE_DIR | absolute path of the module directory (also the cwd) |
BD_BUILD_TAG | the package's build tag, as typed on the generator |
BD_HARDWARE | BirdDog PLAY or BirdDog Pod — anything
else has already been refused before any module runs |
BD_INSTALL_LOG | /tmp/bd-custom-install.log, where your stdout
and stderr are appended |
bd_log | an exported function. bd_log "text" appears in the
web UI's live update log as [module:<name>] text, and in the install
log. Use it for the two or three lines a person watching the upload should see. |
Around your script, the installer has already: checked the hardware ID, remounted the
rootfs read-write if it needed to, made sure /userdata exists, and installed
whichever built-in payloads were selected — so a module may rely on, say,
/userdata/tailscale being present if the user ticked Tailscale, and should
cope if they did not.
It then runs your module in a way that means one that fails — exits non-zero, crashes, hangs — cannot take the install down with it:
- in a subshell, so your
exit,cdandset -eaffect only your script; - under
timeout 600where the command exists — Debian 10's coreutils, which stock PLAY firmware runs, provides it; a module still running after ten minutes is killed, because a hung one would hang BirdDog's update wrapper with the HDMI output dark. Withouttimeouta hang is not caught; - a non-zero exit is logged as a warning and the next module runs, so the
installer reaches its last step, restarting
BirdDogRunner, which is what brings the picture back after the wrapper stopped it.
None of that contains a module that means harm or gets it badly wrong: it runs as
root, and it can reboot, stop BirdDogRunner without starting it again, or kill
the updater mid-way, and no wrapper will stop it. That is what the rules below are for, and
why the generator asks you to read what you package.
Modules run in name order, after the built-in payloads and the rootfs overlay, before the
probe report and before the built-in units are enabled. One line per module —
name= version= build= rc= — is appended to
/userdata/bd-modules.log.
The rules
Nothing enforces these. They are what has kept every unit this project has touched recoverable, and they are the same rules the built-in installer follows.
- Keep everything under
/userdata/bd-<name>. The rootfs gets a systemd unit at most./userdatasurvives vendor firmware updates — the vendor'sbdup.shdeletes only its own paths there — and the rootfs does not. - Never touch
sshd,sshd_config,birddog-update-wrapper,BirdDogUpdateRunnerorbirddog-web-ui. They are how you get back in when something is wrong. If you must patch a web UI page — bdts and bdcam do — copy their shape: a marker-wrapped, exactly reversible edit, a pristine backup beside the file, a restart ofBirdDogWebUI(the unit, not the binary name), a check that it came back as a new pid and answers, and an automatic rollback if not. - Be idempotent.
installruns again on every reinstall of the package. Keep a config the user edited on the device rather than overwriting it; the template'shello.confshows the pattern. - Do not reboot, and do not stop
BirdDogRunnerunless you start it again. The package has its own reboot option. A stoppedBirdDogRunneris a dark HDMI output that looks exactly like a bricked unit. - Exit 0 on success. A service that failed to start is a matter for
bd_logandjournalctl, not a failed firmware install. - Pick a port nobody has. 80 and 8080 are BirdDog's (and 8080 has no
authentication of any kind), 8090
bd-cam-api, 8091bd-play, 8092bd-tailscale-ui, 9031 sshd. A collision is silent: the second service fails to bind and its page never answers. - Supervise yourself. A unit with
Restart=alwaysand a program that exits 0 when it has nothing to do (no device attached, say) is a hotplug poller with no udev rule — that is howbd-kvmandbd-camwork, and why they need nothing from BirdDog's own hooks.
Limits the format imposes
- Paths are at most 100 bytes, counting the
./modules/<name>/prefix. The package is plain ustar, and the writer here refuses a longer name rather than truncating it. The generator names the offending path and says by how much. - No symlinks or hard links in the archive. Create them from
install. - Binaries must be aarch64 ELF — anything else is refused here, because
it could never run on the device. Build fully static, or against Debian 10's glibc 2.28.
Go with
CGO_ENABLED=0 GOOS=linux GOARCH=arm64is the easy route (that is how bdplay is built); bdcam and bdts show cgo with zig as the cross toolchain. - Keep it small. The updater extracts the whole package into a temp dir on the device before anything runs. Tens of megabytes are proven — Tailscale is 68 MB unpacked — and a module over 256 MB is refused.
- What the device has: bash, coreutils, systemd, curl, GStreamer with the
Rockchip hardware decoder, and BirdDog's
libndi(which bdkvm and bdcamdlopenrather than ship). No python, no perl, no package manager.
Building and checking
Any tar will do — tar czf mymodule.tgz mymodule/ — but the template's
build.sh packs only what the device needs, as ustar owned by root, and runs
its check.sh first: names, path lengths, links, ELF architecture, that every
script parses, and a warning if install so much as mentions the recovery
path or rebooting. Its CI then has GNU tar list the archive and imports this page's own
fw.js to prove the generator will accept it:
./check.sh
./build.sh # dist/<NAME>-<VERSION>.tgz
node scripts/patcher-accepts.mjs dist/*.tgz # the generator's reader, same messages as here
Then choose the .tgz under Custom modules on the
generator. Each chosen file is read and checked immediately and listed with
a tick or the reason it was refused; a refused file blocks the build rather than being
dropped quietly, because you chose it. The build log names each module and where it lands
in the package. If you want to see exactly what will run, the .fw is a plain
tarball: tar tzvf BirdDog_PLAY-custom-*.fw.
On the device
- Upload the
.fwon the PLAY's firmware page. Yourbd_loglines appear in its live log as[module:<name>] …. - Read
http://<play-ip>/static/bd-probe.txt— the probe report now ends with one line per module and its exit code, and works without SSH. - Everything your
installprinted is in/tmp/bd-custom-install.log;journalctl -u bd-<name>has the service. - To remove a module built from the template:
bash /userdata/bd-<name>/uninstall.
Worked examples
Every payload this generator ships is a module in all but packaging, and each is public:
- bd-play-module-template
— the starting point: a heartbeat service,
install,uninstall,build.sh,check.shand CI. - bdts — patches a web UI page with a health check and rollback; the pattern to copy if you must touch the UI.
- bdcam — a self-supervising unit that exits 0 with no camera attached, a separate settings-API unit, and a config kept across reinstalls.
- bd-play-usb-player
— a static Go binary taking the display from
BirdDogRunnerand giving it back on every exit path. - agent/
in this repo — bdkvm, and
installer/update, the installer itself. Itsmodulessection is the code that runs yours; read it before you rely on anything written here.