Flash-ready Armbian images for Bitfocus Companion — turn any ARM SBC into a Companion Satellite node (or run full Companion itself), switchable at runtime
Automated GitHub Actions build pipeline that produces ready-to-flash .img.gz images
for ARM single-board computers (Rock Pi S, Orange Pi Zero, etc.) that boot straight into
Bitfocus Companion Satellite —
or, on the Full image variant, run full Bitfocus Companion itself.
Bitfocus Buttons USB Relay is also supported as a third mode.
Switch between modes from the browser — no re-flash needed.
Write the image, plug in your Stream Deck, power on — done.
»
Download a Release »
Report Bug
·
Request Feature
·
🖨️ 3D Case for Rock Pi S
·
📸 Screenshots
This project extends the architecture of companion-satellite-armbian to run on ARM single-board computers that aren't Raspberry Pis — turning any supported board into a Bitfocus Companion Satellite node (or a full Bitfocus Companion instance, on the Full image variant) out of the box. Bitfocus Buttons USB Relay is also supported as a third mode, for boards dedicated to driving a physical USB relay instead.
The build pipeline is fully automated via GitHub Actions:
- The Armbian build framework compiles a minimal Ubuntu Noble (24.04) base image for the target board.
- HashiCorp Packer chroots into the image, installs Companion Satellite from source via the official install script,
installs Bitfocus Buttons USB Relay from this repo's
buttons-deb-mirrorrelease (maintained manually — no Bitfocus account or secrets needed in CI), and — on the Full variant — installs full Bitfocus Companion itself. All modes are installed; Companion Satellite is the default active mode, switchable to Buttons or Companion at runtime with no re-flash. - On first boot,
dpx-set-hostname.servicereads the board's Ethernet MAC address from sysfs and permanently sets the hostname todpx-buttonode-XXXX(last 4 hex chars, e.g.dpx-buttonode-C833). - The image is zeroed, gzip-compressed, and published as a GitHub Release.
Note on build time: Companion Satellite is built from source inside the chroot (Node.js + Yarn build). This adds ~30–60 minutes to the total build time; the Full variant adds more on top for the full Companion install.
A daily scheduled workflow checks whether the mirror release has a version that hasn't been built yet, and automatically triggers a full matrix build if so.
author(s): // www.dubpixel.tv - [email protected]
- Armbian Build Framework — base Linux image for ARM SBCs
- HashiCorp Packer + arm-image plugin — chroot image customization
- GitHub Actions — CI/CD build, scheduling, and release publishing
- Bitfocus Buttons USB Relay (headless) — USB relay mode
- Bitfocus Companion Satellite — Companion satellite mode
Pick your path:
- → I just want to flash a board — download, flash, done
- → Bitfocus released a new version — one command to update the pipeline
- → I need a board not in the auto-release list — manual dispatch for any of 150+ boards
The following boards are built automatically on every new Buttons release and published to Releases:
| Board | Armbian ID |
|---|---|
| Rock Pi S — buy | rockpi-s |
| Orange Pi Zero 3 — buy | orangepizero3 |
| Rock Pi 4B | rockpi-4b |
| Rock Pi 4B+ | rockpi-4bplus |
| Rock Pi S0 | rock-s0 |
| Accessory | Board | Why |
|---|---|---|
| Rock Pi S PoE HAT | Rock Pi S | Dedicated HAT — cleanest single-cable install |
| ecoPI S housing | Rock Pi S | Enclosure designed for Rock Pi S |
| Waveshare PoE Splitter USB-C 2.5A | Orange Pi Zero 3 | PoE → USB-C 5V/2.5A, metal case, gigabit |
| Waveshare PoE Splitter USB-C 5A | Orange Pi Zero 3 | Same but 25W — more headroom for heavier loads |
All 150+ Armbian-supported boards are available for one-off manual builds — see Path C.
What you need: A microSD card (8 GB min), your Rock Pi board, a Stream Deck.
Go to Releases and download the .img.gz for your board.
Each release ships two variants — pick one:
lite— Buttons USB Relay + Companion Satellite (smaller image, the default)full— everything inlite, plus full Bitfocus Companion itself installed
rockpi-s-dpx-buttonode-<version>-lite.img.gz
rockpi-s-dpx-buttonode-<version>-full.img.gz
Easiest — Balena Etcher (Mac / Windows / Linux):
- Open Etcher → Flash from file → pick the
.img.gz - Select your SD card
- Click Flash — Etcher handles the
.gzdecompression automatically
Command line (macOS):
# Find your SD card — look for the right size disk
diskutil list
diskutil unmountDisk /dev/diskN
gunzip -c rockpi-s-dpx-buttonode-0.1.0-beta.4.img.gz \
| sudo dd of=/dev/rdiskN bs=4m status=progress
diskutil eject /dev/diskNCommand line (Linux):
lsblk # find your SD card device
gunzip -c rockpi-s-dpx-buttonode-0.1.0-beta.4.img.gz \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
⚠️ Triple-check your device path (/dev/diskNor/dev/sdX). Wrong device = wiped disk.
- Insert SD card into the Rock Pi
- Plug in your Stream Deck via USB
- Plug in ethernet
- Power on — wait ~30 seconds
That's it. Open Bitfocus Buttons on your computer — the relay appears automatically under discovered devices. No configuration needed.
Hostname: Each device gets a unique hostname derived from its MAC address:
dpx-buttonode-XXXX.localwhereXXXXis the last 4 hex characters of the MAC (e.g.dpx-buttonode-C833.local). This is stable — the same board always gets the same name.
Web UI: A device config panel runs on port 8080:
http://dpx-buttonode-XXXX.local:8080— change hostname, switch DHCP/static IP, manage devices, discover other buttonodes, and switch between Buttons and Satellite mode.
SSH: disabled by default, no hardcoded credential — see SSH into the device below.
When Bitfocus releases a new version, this is the entire process:
Go to user.bitfocus.io/download, log in, and download:
bitfocus-buttons-usb-relay-headless_X.Y.Z_arm64.tar.gz
Requires GitHub CLI:
brew install ghthengh auth login
./scripts/upload-mirror.sh ~/Downloads/bitfocus-buttons-usb-relay-headless_X.Y.Z_arm64.tar.gzThis uploads the file to the buttons-deb-mirror release in this repo. Done.
The daily scheduled check at 06:00 UTC will detect the new version and automatically build all boards and publish a release.
To trigger it right now instead of waiting:
gh workflow run release-action.yaml --repo dubpixel/dpx_buttonodeWatch it: Actions → Release — dpx-buttonode Images → latest run
Any of the 150+ Armbian-supported boards can be built on demand. The artifact is available for 7 days under the Actions run (not published as a public release).
Via GitHub web UI:
- Go to Actions → Build Armbian + dpx-buttonode Image
- Click Run workflow
- Pick your board from the dropdown
- Click Run workflow
- Wait ~45-90 min, then download the
.img.gzfrom the run's Artifacts section
Via terminal:
gh workflow run armbian-builder.yaml \
--repo dubpixel/dpx_buttonode \
-f armbian-board=orangepizero3Replace orangepizero3 with any board ID from the Armbian hardware list.
- Fork this repo on GitHub
- Seed the mirror with the current package:
./scripts/upload-mirror.sh ~/Downloads/bitfocus-buttons-usb-relay-headless_0.1.0-beta.4_arm64.tar.gz - Trigger a first build:
- Actions → Release — dpx-buttonode Images → Run workflow → Force: true
- Done — updates are fully automated from here
No GitHub Secrets needed. The pipeline uses only the built-in
GITHUB_TOKEN.
Every device runs a lightweight web UI on port 8080:
http://dpx-buttonode-XXXX.local:8080
| Tab | What it does |
|---|---|
| Status | Hostname, IP, MAC, network mode, current mode + active service status, mDNS health, USB devices |
| Hostname | Change the device hostname — applies immediately and persists across reboots |
| Network | Switch between DHCP and static IP. Survives reboots. |
| Devices | USB device list, Stream Deck USB power cycle, Buttons service restart |
| Nodes | Discover all other dpx-buttonode-* units on the LAN with links to each web UI |
| Mode | Switch between Buttons and Satellite. Configure Companion server IP + port for Satellite mode. |
Note: The Network tab writes directly to
/etc/systemd/network/and restartssystemd-networkd. After an IP change, navigate to the new address — the hostname (dpx-buttonode-XXXX.local) resolves correctly via mDNS within a few seconds.
SSH ships disabled, and there's no hardcoded default credential — enable it from the web UI's SSH tab. On first boot, a random per-device root password is generated automatically. It's deliberately never shown on the web UI itself (that page has no login of its own) — the only place it's ever revealed is the Stream Deck: press the SSH key (in the action row, next to GO) to show it; press it again to hide it. Seeing it requires physically being at the device.
- On the Stream Deck, press the SSH key to reveal the generated password (press again to hide it).
- Enter it in the web UI's Enable SSH form (or use it to
sshin directly — SSH being off just means thesshservice isn't running yet; enabling it from the UI starts it). - Set your own password via the same tab — this deletes the generated one, and the SSH key goes blank.
No Stream Deck attached at first boot? The generated password is not recoverable remotely by design —
there is currently no other way to retrieve it. Connect a deck temporarily (it only needs to be attached
long enough to press the SSH key once) to get in, or reset the root password locally instead
(sudo passwd root via a serial console / direct login, if your board supports one).
ssh [email protected]
# where XXXX is the last 4 hex chars of the board's MAC addressIf mDNS isn't resolving, find the IP from your router and use that directly.
⚠️ Why this exists. Every action on the SSH tab requires the current root password — that page has no login of its own (plain HTTP, reachable by anyone on the LAN), so without this check, "enable SSH from the web UI" would let anyone on the network turn SSH on with a password of their own choosing, no guessing required. Requiring the current password keeps the bar at "you already have root," the same as SSH access always implied, just reachable through the browser too. This is aimed at keeping honest people honest on a home/studio LAN — it is not hardened against a determined attacker with sustained LAN access. SeeAGENTS.mdgotcha 10a for implementation details.
cat /etc/dpx-mode # prints: buttons or satelliteYou can also switch from SSH — same as what the UI does:
# Switch to Satellite mode
systemctl stop bitfocus-buttons-usb-relay
systemctl disable bitfocus-buttons-usb-relay
systemctl enable satellite
systemctl start satellite
echo satellite > /etc/dpx-mode
# Switch back to Buttons mode
systemctl stop satellite
systemctl disable satellite
systemctl enable bitfocus-buttons-usb-relay
systemctl start bitfocus-buttons-usb-relay
echo buttons > /etc/dpx-modeIn Satellite mode the device connects outbound to a running Bitfocus Companion instance and exposes the attached Stream Deck(s) as remote surfaces.
Requirements:
- Bitfocus Companion v3.4.0 or newer running somewhere on the network
- Companion must have Satellite enabled: Settings → Surfaces → Enable Satellite
- TCP port 16622 reachable from the dpx-buttonode to the Companion machine
Configure from the web UI:
- Open
http://dpx-buttonode-XXXX.local:8080 - Go to the Mode tab
- Enter your Companion server IP and port (default
16622) - Click Switch to Satellite — the device switches immediately and persists on reboot
Configure from SSH:
# Write persistent config
cat > /etc/dpx-satellite.conf <<EOF
HOST=192.168.1.10
PORT=16622
EOF
# Stage it for satellite's boot import
cat > /boot/satellite-config <<EOF
COMPANION_IP=192.168.1.10
COMPANION_PORT=16622
EOF
# Or push it live to the running service via REST API
curl -X POST http://localhost:9999/api/config \
-H 'Content-Type: application/json' \
-d '{"host":"192.168.1.10","port":16622}'Check satellite status:
systemctl status satellite
journalctl -u satellite -f
# REST API shows current config + connection status
curl http://localhost:9999/api/config# Is it running?
systemctl status bitfocus-buttons-usb-relay
# Watch live logs
journalctl -u bitfocus-buttons-usb-relay -f
# Restart it
sudo systemctl restart bitfocus-buttons-usb-relayBy default the relay announces itself via mDNS and Buttons discovers it automatically — no config needed for most setups.
If your network blocks mDNS (some managed switches do), point the relay directly at your Buttons server:
sudo nano /etc/default/bitfocus-buttons-usb-relayAdd this line:
EXTRA_ARGS="-buttonsAddress 192.168.1.10:3000"
Then restart:
sudo systemctl restart bitfocus-buttons-usb-relayThe device announces itself as dpx-buttonode-XXXX.local on port 3040 (Buttons relay) and port 8080 (web UI).
# Confirm it's on the network
ping dpx-buttonode-XXXX.local
# Discover all buttonodes on the LAN
avahi-browse -t _dpx-buttonode._tcp # Linux
dns-sd -B _dpx-buttonode._tcp local # macOS
# Or just open the Nodes tab in the web UI- what did we learn?
- x
- what do we like/hate?
- y
- what would/could we do differently?
- z
- Core Armbian + Packer two-stage build pipeline
- Self-hosted package mirror via GitHub Releases (no Bitfocus secrets in CI)
- Matrix builds for Orange Pi Zero family
- Daily automated version check + GitHub Release publishing
-
upload-mirror.shhelper for one-command package updates - Dynamic MAC-derived hostname (
dpx-buttonode-XXXX) on first boot -
dpx-buttonode-ui— device config web UI on port 8080 (hostname, network, devices, node discovery) - Companion Satellite A/B mode — both services baked in, switch without re-flash
- Mode tab in web UI — configure Companion server IP/port, switch modes from browser
- Additional board support (Banana Pi M2 Zero, NanoPi R4S, Orange Pi 5)
- SHA256 checksums attached to each release
- WiFi pre-configuration support in image (via Armbian
wpa_supplicantoverlay) - OTA image update — download latest release from GitHub and flash in-place (kexec into RAM,
ddto SD, reboot)
See the open issues for a full list of proposed features (and known issues).
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Distributed under the [LICENSE_TYPE] License. See LICENSE.txt for more information.
Joshua Fleitell - [email protected]
Project Link: https://github.com/dubpixel/dpx_buttonode
- elliotmatson/companion-satellite-armbian — architecture and workflow pattern this project is based on
- Bitfocus — creators of Buttons and Companion
- Armbian — Linux for ARM SBCs
- Bitfocus Buttons USB Relay — Official Docs — source of truth for installation, service management, and configuration
- Bitfocus Companion Satellite — GitHub — official install script and documentation
- dpx_raxda_rockpis — 3D printable POE enclosure for the Rock Pi S hardware this project runs on
This project uses a two-stage pipeline to produce flash-ready images for ARM SBCs. Here's the general recipe so you can adapt it for any headless software you want to bake into an Armbian image.
GitHub Actions runner (x86)
└─ 1. Build Armbian base image for target board
└─ 2. Packer chroots into image via QEMU
└─ copies your software in
└─ installs it
└─ configures it (hostname, services, etc.)
└─ 3. Compress and publish the image
No cross-compilation. No physical board needed. Runs entirely on standard x86 CI runners.
- Your software packaged as a
.deb, or an install script that runs inside a Debian/Ubuntu chroot - A GitHub repo with Actions enabled
- HashiCorp Packer — free, open source
Create your-software.pkr.hcl:
packer {
required_plugins {
arm-image = {
version = "0.2.7"
source = "github.com/solo-io/arm-image"
}
}
}
variable "url" { type = string } # path to Armbian .img
variable "deb_path" { type = string } # path to your .deb
source "arm-image" "armbian" {
iso_checksum = "none"
iso_url = var.url
target_image_size = 5000000000 # 5 GB — adjust as needed
output_filename = "output/image.img"
qemu_binary = "qemu-aarch64-static"
image_mounts = ["/"]
# Required for DNS to work inside the chroot
additional_chroot_mounts = [["bind", "/run/systemd", "/run/systemd"]]
}
build {
sources = ["source.arm-image.armbian"]
# Copy your software into the image
provisioner "file" {
source = var.deb_path
destination = "/tmp/your-software.deb"
}
# Copy your install script
provisioner "file" {
source = "scripts/install.sh"
destination = "/tmp/install.sh"
}
# System config (hostname, first-login, SSH)
provisioner "shell" {
inline = [
"rm -f /root/.not_logged_in_yet",
"echo your-device-name > /etc/hostname",
# Disable BOTH units -- ssh.socket alone keeps systemd listening on
# :22 and lazily starting ssh.service on demand (socket activation),
# so disabling only ssh.service does not actually disable SSH.
"systemctl disable --now ssh.socket || true",
"systemctl disable --now ssh.service || true",
]
}
# Install your software (runs as root)
provisioner "shell" {
execute_command = "chmod +x {{ .Path }}; {{ .Vars }} su root -c {{ .Path }}"
inline_shebang = "/bin/bash -e"
inline = ["chmod +x /tmp/install.sh", "/tmp/install.sh"]
}
}scripts/install.sh runs inside the chroot as root. Treat it like a normal Debian post-install script:
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
apt-get update -q
apt-get install -y --no-install-recommends avahi-daemon libusb-1.0-0
# Install your package
dpkg -i /tmp/your-software.deb || apt-get install -f -y
# Enable services
systemctl enable your-service
systemctl enable avahi-daemon
# Cleanup
apt-get clean- name: Install QEMU (required for ARM chroot on x86 runners)
run: sudo apt-get install -y qemu-user-static
- name: Build Armbian base image
run: |
git clone --depth=1 https://github.com/armbian/build build
sudo ./build/compile.sh build \
BOARD=your-board-id \
BRANCH=current \
RELEASE=noble \
BUILD_MINIMAL=yes \
KERNEL_CONFIGURE=no \
COMPRESS_OUTPUTIMAGE=no
sudo mv build/output/images/*.img build/output/images/armbian.img
- name: Install Packer
run: |
wget -qO - https://apt.releases.hashicorp.com/gpg \
| sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update -q && sudo apt-get install -y packer
- name: Run Packer
run: |
sudo packer init your-software.pkr.hcl
sudo packer build \
-var "url=build/output/images/armbian.img" \
-var "deb_path=path/to/your-software.deb" \
your-software.pkr.hcl
- name: Compress image
run: |
sudo apt-get install -y zerofree
IMG="output/image.img"
LOOP=$(sudo losetup -fP --show "$IMG")
sudo e2fsck -fy "${LOOP}p1" || true
sudo zerofree "${LOOP}p1"
sudo losetup -d "$LOOP"
gzip -n "$IMG"| Thing | Why it matters |
|---|---|
qemu-user-static must be installed before Packer runs |
Packer uses it to emulate ARM64 instructions inside the chroot on your x86 runner |
additional_chroot_mounts = [["bind", "/run/systemd", "/run/systemd"]] |
Without this, DNS resolution inside the chroot fails and apt-get can't reach package servers |
All packer commands need sudo |
The arm-image plugin creates loop devices and bind mounts — root required |
sudo mv the Armbian output |
The Armbian build framework runs as root inside Docker, so output files are owned by root |
zerofree before gzip |
Zeros unused filesystem blocks so the image compresses 3-5x smaller |
Set target_image_size generously |
Packer will fail if the image fills up during install. 5 GB is safe for most software |
Any board in Armbian's supported hardware list works. Look up the board's BOARD= ID from the Armbian docs or the supported boards list.