Custom firmware for the Spotify Car Thing that transforms discontinued hardware into a dedicated media companion with a native raylib-based GUI.
- Overview
- Architecture
- Build Prerequisites
- Build Process
- Pre-Build Setup: Binaries and Data
- Build Stages
- Runtime Services
- Flashing
- Directory Structure
- Customization
- Troubleshooting
- Credits
LlizardOS repurposes abandoned Spotify Car Thing hardware into a dedicated media display. Unlike web-based alternatives, LlizardOS uses a native DRM GUI (llizardGUI) that renders directly to the display for minimal resource usage and fast boot times.
- Native DRM Rendering - No Weston/Chromium, direct GPU access via Mali driver
- Media Display - Album art, track information, playback controls
- Physical Controls - Dial and buttons for skip, pause, volume
- Bluetooth Integration - BLE GATT protocol via Mercury daemon
- Plugin System - Expandable functionality through Salamander plugins
- Read-Only Root - System partition is read-only for reliability
| Component | Description |
|---|---|
| llizardGUI | Native raylib GUI application (DRM backend) |
| Mercury | Go BLE daemon bridging phone media to Redis |
| Redis | In-memory data store for media state |
| BlueZ | Bluetooth stack |
/dev/system_a - Root filesystem (read-only, A/B slot)
/dev/system_b - Root filesystem (read-only, A/B slot)
/dev/data - Mounted at /var (read-write, persistent)
/dev/settings - Mounted at /var/lib (read-write, persistent)
/usr/bin/llizardGUI - Main GUI application
/usr/bin/mercury - BLE media bridge daemon
/usr/lib/llizard/plugins/ - Plugin shared objects (.so)
/usr/lib/llizard/data/fonts/ - UI fonts
/var/llizard/ - Runtime config storage
/etc/llizardOS/ - Build info
Docker handles all dependencies automatically. Required tools on host:
- Docker
- just (command runner)
- QEMU user-mode (for ARM emulation on x86_64)
Install these tools on your system:
curl- File downloadszip,unzip- Archive handlinggenimage- Disk image generationm4- Macro processorxbps-install- Void Linux package managermkpasswd- Password hashingpatchelf- ELF binary patchingrsync- File synchronization
Note: xbps-install can be installed on any distro using the static binaries.
# 1. Set up QEMU for ARM emulation (required on x86_64 hosts)
just docker-qemu
# 2. Build the firmware image
just docker-runOutput: output/llizardOS_image_vX.X.X.zip
# 1. Set up QEMU if not on ARM
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# 2. Download stock CarThing files (requires root for loop mount)
cd resources/stock-files
sudo ./download.sh
cd ../..
# 3. Build the image
sudo ./build.shjust -l
# Available recipes:
# docker-build # Build the Docker image
# docker-qemu # Set up QEMU binfmt for ARM emulation
# docker-run # Build the firmware image (runs docker-build first)
# lint # Run pre-commit hooks
# run # Build directly (requires root, all deps installed)Environment variables in build.sh:
| Variable | Default | Description |
|---|---|---|
LLIZARDOS_VERSION |
v1.0.0 |
Version string in output filename |
VOID_BUILD |
20250202 |
Void Linux rootfs version |
DEFAULT_HOSTNAME |
llizardos |
Device hostname |
DEFAULT_ROOT_PASSWORD |
llizardos |
Root SSH password |
SIZE_ROOT_FS |
516M |
Root partition size |
STAGES |
00 10 20 30 40 |
Build stages to run |
Before building the image, you must place the pre-compiled ARM binaries and data files in the correct locations. The build script automatically discovers and installs all files from these directories.
resources/llizardgui/
├── bins/ # ARM binaries (auto-installed to /usr/bin/)
│ ├── llizardGUI # Main GUI application
│ ├── mercury # BLE media bridge daemon
│ └── plugins/ # Plugin shared objects (auto-installed to /usr/lib/llizard/plugins/)
│ ├── album_art_viewer.so
│ ├── clock.so
│ ├── nowplaying.so
│ └── ... (all .so files)
└── data/ # Data files for plugins and GUI
├── fonts/ # TTF fonts (installed to /usr/lib/llizard/data/fonts/)
├── flashcards/ # Plugin data (installed to /usr/lib/llizard/plugins/flashcards/)
└── millionaire/ # Plugin data (installed to /usr/lib/llizard/plugins/millionaire/)
The build script (scripts/stages/20/30-llizardgui.sh) programmatically discovers and installs:
-
Main Binaries: Any file in
resources/llizardgui/bins/(excluding subdirectories) is installed to/usr/bin/with executable permissions. -
Plugins: All
.sofiles inresources/llizardgui/bins/plugins/are installed to/usr/lib/llizard/plugins/. -
Plugin Data: Any subdirectory in
resources/llizardgui/data/(exceptfonts/) is installed to/usr/lib/llizard/plugins/<dirname>/. This is where plugins look for their data files. -
Fonts: The
fonts/directory is installed to/usr/lib/llizard/data/fonts/.
To add new binaries or plugins, simply place them in the appropriate directory:
| Component Type | Location | Installed To |
|---|---|---|
| Main binary | resources/llizardgui/bins/<name> |
/usr/bin/<name> |
| Plugin | resources/llizardgui/bins/plugins/<name>.so |
/usr/lib/llizard/plugins/<name>.so |
| Plugin data | resources/llizardgui/data/<pluginname>/ |
/usr/lib/llizard/plugins/<pluginname>/ |
| Fonts | resources/llizardgui/data/fonts/*.ttf |
/usr/lib/llizard/data/fonts/ |
No script modifications are required - the build system automatically picks up new files.
If you're building binaries from source, use the provided script to copy them to the correct locations:
./copy_bins_before_commit_push.shThis copies binaries from the expected build output directories (e.g., build-armv7-drm/, golang_ble_client/bin/) to resources/llizardgui/bins/.
The build system runs 5 stages in sequence:
| Script | Purpose |
|---|---|
10-create-directories.sh |
Create basic directory structure |
20-cache.sh |
Restore xbps package cache |
30-xbps.sh |
Download Void rootfs, install base packages |
40-resolv.conf.sh |
Set up DNS for package downloads |
50-stock-files.sh |
Copy CarThing stock libraries, create symlinks |
Key Actions:
- Downloads Void Linux ARMv7l rootfs tarball
- Installs
base-llizardosmeta-package - Copies proprietary libraries from stock CarThing firmware:
libMali.so- GPU driver (GBM, EGL, GLESv2)libdrm.so- DRM/KMSlibinput.so,libevdev.so,libmtdev.so- Input handling- Image libraries (libjpeg, libpng, libwebp)
- Font libraries (freetype, fontconfig)
- Creates required symlinks for versioned libraries
| Script | Purpose |
|---|---|
10-etc.sh |
SSH, hostname, root password, motd |
20-bins.sh |
Install system utilities |
30-core-services.sh |
First-boot script, filesystem mounts |
40-network.sh |
USB gadget, NetworkManager, dnsmasq |
50-display.sh |
Input udev rules, seatd, auto-brightness |
60-bluetooth.sh |
BlueZ, firmware, adapter configuration |
65-redis.sh |
Redis installation and configuration |
Network Configuration:
- USB RNDIS gadget for USB networking
- Static IP:
172.16.42.2on usb0 - DNS: CloudFlare/Google fallback
Redis Configuration:
- Bound to localhost only
- Persistence disabled (ephemeral cache)
- 32MB memory limit with LRU eviction
| Script | Purpose |
|---|---|
30-llizardgui.sh |
Install llizardGUI, Mercury, plugins, fonts |
Installed Components:
/usr/bin/llizardGUI- Main GUI binary/usr/bin/mercury- BLE daemon/usr/lib/llizard/plugins/*.so- All plugins/usr/lib/llizard/data/fonts/- UI fonts- Plugin data (flashcards questions, millionaire questions)
- runit service definitions
| Script | Purpose |
|---|---|
10-save-cache.sh |
Save xbps cache for faster rebuilds |
20-resolv.conf.sh |
Remove temporary DNS config |
30-clean-dirs.sh |
Remove unnecessary locales, man pages |
40-services.sh |
Enable services, remove unused agetty |
| Script | Purpose |
|---|---|
10-system-img.sh |
Generate ext4 system image via genimage |
20-update.sh |
Create update tarball (for OTA updates) |
30-copy.sh |
Assemble bootloader, partitions into zip |
40-output.sh |
Display final image sizes |
Output Files:
output/llizardOS_image_vX.X.X.zip- Full flashable imageoutput/llizardOS_update_vX.X.X.tar.zst- OTA update package
LlizardOS uses runit for service supervision. Services are defined in scripts/services/:
| Service | Dependency | Description |
|---|---|---|
llizardGUI |
dbus | Native GUI application (loads Mali module, runs from /usr/lib/llizard) |
mercury |
dbus, redis, bluetooth_adapter, bluetoothd | BLE media bridge daemon |
redis |
- | In-memory data store for media state |
| Service | Description |
|---|---|
usb-gadget |
USB RNDIS network gadget setup |
bluetooth_adapter |
Hardware init via btattach |
superbird_init |
CarThing-specific Bluetooth GPIO setup |
auto_brightness |
Ambient light sensor brightness control |
llizardGUI (scripts/services/llizardGUI/run):
# Key steps:
1. Load Mali GPU kernel module
2. Trigger udev for input devices
3. Set XDG_RUNTIME_DIR=/run/llizard
4. cd /usr/lib/llizard && exec llizardGUIMercury (scripts/services/mercury/run):
# Waits for Bluetooth hardware (hci0) to be ready
# up to 30 seconds before starting- Windows: Terbium driver required
irm https://driver.terbium.app/get | iex
- Download
llizardOS_image_vX.X.X.zipfrom build output or releases - Put Car Thing into burn mode: Hold buttons 1+4, then plug in USB
- Flash using Terbium
# SSH access (after USB cable connected)
ssh root@172.16.42.2
# Password: llizardos
# Check service status
sv status llizardGUI
sv status mercury
sv status redisllizardOS/
├── build.sh # Main build script
├── copy_bins_before_commit_push.sh # Update binaries before commit
├── Dockerfile # Docker build environment
├── docker-entrypoint.sh # Docker entry point
├── Justfile # Build commands
├── resources/
│ ├── config/ # System configuration files
│ │ ├── bluetooth.conf # BlueZ configuration
│ │ ├── fstab # Filesystem mount table
│ │ ├── motd # Login message
│ │ ├── sshd_config # SSH daemon config
│ │ └── weston.ini # (Legacy) Weston config
│ ├── firmware/brcm/ # Bluetooth firmware blobs
│ ├── flash/ # Bootloader env and logo
│ ├── llizardgui/
│ │ ├── bins/ # Pre-built ARM binaries
│ │ │ ├── llizardGUI
│ │ │ ├── mercury
│ │ │ └── plugins/*.so
│ │ └── data/ # Plugin data files
│ │ ├── fonts/
│ │ ├── flashcards/
│ │ └── millionaire/
│ ├── m4/ # genimage templates
│ ├── stock-files/ # CarThing proprietary files
│ │ ├── download.sh # Downloads stock firmware
│ │ ├── extract/ # Raw partition dumps
│ │ └── output/ # Extracted libraries
│ └── xbps/ # Custom xbps packages
├── scripts/
│ ├── build-helpers/ # Build utility scripts
│ ├── services/ # runit service definitions
│ │ ├── auto_brightness/
│ │ ├── bluetooth_adapter/
│ │ ├── llizardGUI/
│ │ ├── mercury/
│ │ ├── superbird_init/
│ │ └── usb-gadget/
│ ├── stages/ # Build stage scripts
│ │ ├── 00/ # Root filesystem setup
│ │ ├── 10/ # System configuration
│ │ ├── 20/ # LlizardOS installation
│ │ ├── 30/ # Cleanup
│ │ └── 40/ # Image generation
│ ├── firstboot.sh # First boot initialization
│ ├── reset-data # Factory reset /var
│ └── reset-settings # Factory reset /var/lib
├── cache/ # xbps package cache
└── output/ # Build output directory
Use the provided script to copy the latest ARM binaries from your build:
# From the llizardOS directory
./copy_bins_before_commit_push.sh
# Then commit and push
git add resources/llizardgui/
git commit -m "Update binaries and plugin data"
git pushThis script automatically copies:
- llizardGUI - Main host application from
build-armv7-drm/llizardgui-host - mercury - BLE service from
golang_ble_client/bin/mediadash-client - plugins/*.so - All plugin binaries from
build-armv7-drm/*.so - Plugin data - Question banks from
salamanders/(flashcards, millionaire)
If you prefer manual copying, place ARM binaries in resources/llizardgui/bins/:
# Build llizardgui-host for CarThing
cd /path/to/llizardgui-host
mkdir -p build-armv7-drm && cd build-armv7-drm
cmake -DCMAKE_TOOLCHAIN_FILE=../toolchain-armv7.cmake -DPLATFORM=DRM ..
make -j$(nproc)
# Copy to llizardOS resources
cp llizardgui-host /path/to/llizardOS/resources/llizardgui/bins/llizardGUI
cp *.so /path/to/llizardOS/resources/llizardgui/bins/plugins/Place TTF fonts in resources/llizardgui/data/fonts/.
For plugins that need data files (questions, etc.):
# Example: flashcards plugin
mkdir -p resources/llizardgui/data/flashcards/questions/
cp your_questions.json resources/llizardgui/data/flashcards/questions/Edit service scripts in scripts/services/{service}/run.
Edit DEFAULT_ROOT_PASSWORD in build.sh or set environment variable:
DEFAULT_ROOT_PASSWORD="your_password" ./build.shEdit SIZE_ROOT_FS in build.sh:
SIZE_ROOT_FS="600M" ./build.shStock files not downloaded:
cd resources/stock-files
sudo ./download.shCheck service status and logs:
ssh root@172.16.42.2
sv status llizardGUI
# Check if Mali module loaded
lsmod | grep mali
# Try manual load
insmod /lib/modules/4.9.113/hardware/aml-4.9/arm/gpu/mali.koMissing symlink. On device:
ln -sf libMali.so /usr/lib/libgbm.so.1Check Bluetooth status:
sv status bluetooth_adapter
sv status bluetoothd
hciconfig -a
bluetoothctl showsv status redis
redis-cli pingGUI service crashing. Check:
sv status llizardGUI
dmesg | tail -50System partition is read-only and sized at 516MB. If full during build:
- Increase
SIZE_ROOT_FSin build.sh - Check
scripts/stages/30/30-clean-dirs.shremoves unnecessary files
USB gadget may not be up:
# On host, check USB device appeared
lsusb | grep -i linux
# Check network interface
ip link show # look for enp* or usb*This project is heavily inspired by Nocturne and uses a modified version of their image build system. Thanks to:
- The Nocturne team
- raspi-alpine/builder
- bishopdynamics
- Thing Labs
This project is licensed under the Apache License 2.0.
"Spotify" and "Car Thing" are trademarks of Spotify AB. This software is not affiliated with or endorsed by Spotify AB.