Lumae Radio is in beta: it plays stations from your own library on a Raspberry Pi, and your phone is its remote.
This guide takes you from a blank card to a playing station, then shows how to reach the radio from Home Assistant
and your own scripts. The examples call the radio kitchen; use your Pi's host name instead.
Before you start
- A Raspberry Pi 4 with a DAC board or USB DAC and speakers. The target is 2 GB of memory with a library of about 60,000 tracks, which hasn't been measured on real hardware yet; a large library may need 4 GB.
- Raspberry Pi OS Lite (64-bit), the Debian 13 image. The Legacy image can't run the radio.
- Your servers: Navidrome, or Jellyfin 12.0 or newer with Quick Connect turned on in its dashboard, and AudioMuse-AI with Lumae Analysis (1.6.0 or newer for Jellyfin). To follow your Vibes, Lumae Analysis needs 1.4.0 or newer with its collection manager on.
- Lumae on your phone, from the current beta build (1.0.0 (32) on Android, (26) on iPhone), on the same network as the Pi.
Install on the Pi
Lumae Radio 0.4.0-beta.1
The radio installs over SSH. The files come from GitHub; the checks below come from this page, so a changed download can't pass them.
-
Prepare the Pi
In Raspberry Pi Imager choose Raspberry Pi OS Lite (64-bit). Set a host name (the radio becomes
kitchen.localon your network), a user and SSH, then sign in over SSH and install what the radio needs:sudo apt-get update sudo apt-get install -y --no-install-recommends python3 openssl ca-certificates \ libstdc++6 libgcc-s1 libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-alsa \ libasound2t64 libsqlite3-0 alsa-utils avahi-daemonSet up your DAC board's driver as its maker describes, if you use one.
-
Download
mkdir -p ~/lumae-radio && cd ~/lumae-radio r=lumae-radio-0.4.0-beta.1-linux-arm64 url=https://github.com/rendyhd/lumae/releases/download/radio-v0.4.0-beta.1 for f in $r.tar.gz $r.manifest.json $r.manifest.sig $r.install.py release-key.pem; do curl -fLO "$url/$f" done -
Check the key and the installer
Both lines must say
OK. If either doesn't, stop and don't install.sha256sum -c <<'EOF' cccccba7914ad03c6f70c6e7c9a8daf350c3230717ac47fe589a8f90ca1d2ee4 release-key.pem 7a2c4825e532739b7ee67cd7fca51ef5f19b5e51a4613f95b695c1ee423fa06a lumae-radio-0.4.0-beta.1-linux-arm64.install.py EOFThen keep the key where the installer looks for it. Every release is signed with it.
sudo install -d -o root -g root -m 755 /etc/lumae-radio sudo install -o root -g root -m 644 release-key.pem /etc/lumae-radio/release-key.pem -
Verify, then install
The installer checks the signature and every file before it changes anything. The first command only checks; the second installs and starts the radio.
args="--archive $r.tar.gz --manifest $r.manifest.json --signature $r.manifest.sig --public-key /etc/lumae-radio/release-key.pem" python3 -I -B $r.install.py $args sudo python3 -I -B $r.install.py $args --installIt names the radio after the Pi's host name, makes it its own certificate, starts it and prints its web address, such as
https://kitchen.local:8443. If the Pi had no network address yet, it stays stopped: connect it, then runsudo python3 -I -B /opt/lumae-radio/current/packaging/configure.py --autoandsudo systemctl enable --now lumae-radio-audio.service lumae-radio.service.
- Key
-
SHA-256 of
release-key.pem:cccccba7914ad03c6f70c6e7c9a8daf350c3230717ac47fe589a8f90ca1d2ee4. The radio refuses a release signed with any other key. - Archive
-
SHA-256 of
lumae-radio-0.4.0-beta.1-linux-arm64.tar.gz:187de88324c18ee141d44b56b3a8c6dd4201ab29693baf3e00fd49f092308f3f. The installer checks it against the signed manifest itself.
Set it up with the app
Do this soon after installing, on a network you trust: until a radio has an owner, the first phone that adopts it wins. After that, its password is the only way in.
- Find the radio. In Lumae, open Library ≡ → Settings → Servers & accounts → Lumae Radio and scan. Your radio shows up with Adopt.
- Adopt it. The app makes a strong password for the radio and keeps it on your phone, pins the radio's certificate, sends it your music server and AudioMuse-AI details, chooses the same library and starts the first sync. With Jellyfin the app never sends your password: the radio signs itself in as its own device, and your phone approves its Quick Connect code.
- Choose the sound output. Open the radio's remote (the radio button top right of Home), tap ⋯ → The radio, and pick your DAC while the radio is stopped. Wait for “Audio output updated” before you press Play.
- Make a station. Switch station → New: start from one of your Vibes, the song your phone is playing, a playlist or collection, seeds from the radio's library, or a pasted Vibe code. Set the energy for the day, save, and press Play once it's prepared.
The remote button appears on Home whenever your phone is on the same network as the radio. A radio someone already set up shows Sign in instead of Adopt: enter its password once. Show password under ⋯ → The radio → Access also signs you in to the radio's own web page, at its web address, in a browser that trusts the radio's certificate.
Upgrade
Download and check the new release as above (the key stays where it is), then run the installed installer. Your stations, history and settings stay; the installer keeps a copy of the databases first.
sudo python3 -I -B /opt/lumae-radio/current/packaging/install.py $args --install
Home Assistant
The Lumae Radio integration adds each radio as a media player, with its stations as sources, and a dashboard card like the app's remote: the cover, why this track, play and pause, skip, stop, volume, the station switcher, and Favor, Less, Rest and Ban. It needs Home Assistant 2025.6 or later on the same network as the radio, and the radio set up with the app.
1. Make a key
In Lumae, open the radio's remote → ⋯ → The radio → Access → + New key (or, on the radio's web
page, API access → Create a key). Tick Read and Control playback, and
Stations too if you want Favor, Less, Rest and Ban. Copy the key, which starts with
lrk.: the radio shows it only once. Home Assistant never uses the radio's password.
2. Install the integration
-
In HACS, open ⋮ → Custom repositories, add
https://github.com/rendyhd/lumae-radio-home-assistantas an Integration, and install Lumae Radio. - Restart Home Assistant.
-
Open Settings → Devices & services. The radio shows under Discovered; if it doesn't, choose
Add integration → Lumae Radio and enter its web address, such as
https://kitchen.local:8443. -
Check the identity Home Assistant shows (for example
3F:A1:07 … 52:9C) against the one under Access in the app, then paste the key.
Like the app, Home Assistant pins the radio's certificate the first time and refuses any other one after that. If you set the radio up again it gets a new identity: remove it from Home Assistant and add it again.
3. Add the card
Pick “Lumae Radio” in the card picker, or add it in YAML:
type: custom:lumae-radio-card
entity: media_player.lumae_radio
| Option | Default | What it does |
|---|---|---|
entity | The radio's media player. | |
theme | auto | Follows Home Assistant's light or dark mode; dark or light keeps one. |
show_volume | true | Show the volume slider. |
show_steering | true | Show Favor, Less, Rest and Ban (needs the key's Stations scope). |
Automations
The media player supports play, pause, stop, next, volume, source (the radio's stations by name) and media browsing. The radio pushes every change, so Home Assistant updates at once. Start a station in the morning:
action: media_player.select_source
target:
entity_id: media_player.lumae_radio
data:
source: Morning drift
Or steer the track that's playing, on the station it plays on:
action: lumae_radio.steer_track
target:
entity_id: media_player.lumae_radio
data:
choice: rest # favor, less, no_preference, rest, end_rest, ban, allow
rest_days: 30 # or rest_until: "2026-12-24 08:00"; a week if left out
If it doesn't connect
- “Couldn't reach the radio.” The radio answers only private network addresses, so Home Assistant must reach it directly, not through a VPN or a cloud relay.
-
“The radio doesn't answer to this address.” Use its
.localname, or the address its installer printed. - “This radio has no owner yet.” Set it up with the Lumae app first.
API
The radio's web page, the Lumae app and Home Assistant all use the same HTTPS API, and your scripts can too. It may
still change during the beta. Every path below starts with https://kitchen.local:8443/api/v1.
Connect
The radio speaks HTTPS only, on port 8443, and only to private network addresses: there is no port forwarding or
cloud relay. It answers only to the names it was set up with (its .local name or the address the
installer printed). Its certificate is its own, so pin it: save it once, check that its fingerprint matches the
identity under Access in the app, then pass it to every request.
RADIO=https://kitchen.local:8443
openssl s_client -connect kitchen.local:8443 -servername kitchen.local </dev/null 2>/dev/null \
| openssl x509 > radio.pem
openssl x509 -in radio.pem -noout -fingerprint -sha256
Keys
Make a key as for Home Assistant and send it as
Authorization: Bearer lrk.…. A key can do only what its scopes allow, and no key can make other keys
or sign in browsers. Revoking one under Access stops it at once.
| Scope | Lets a key |
|---|---|
read | Read status, the queue, stations, the library, settings and events. |
control | Play, pause, resume, stop, skip and set the volume. |
stations | Make, edit, prepare and delete stations, and Favor, Less, Rest and Ban tracks. |
admin | Change servers, sync, libraries, sound output, cache and other settings. |
Scopes don't include each other: a key that controls playback and reads status needs both.
Read
KEY=lrk.your-key
curl --cacert radio.pem -H "Authorization: Bearer $KEY" $RADIO/api/v1/status
Answers are JSON. /status gives the playback state (playing, paused,
stopped, buffering or fault), the station, the track with its title, artist,
album and position, the volume and the radio's health. Each answer carries an ETag, which you need to
change anything.
Change
Every change sends two headers:
-
If-Match: theETagfrom the matching read, as it came. Playback commands use/status's, a new station uses/stations's, and a change to a station, or to a track on it, uses that station's. -
Idempotency-Key: a new value for each change, 16 to 128 letters, digits,-or_. Sending the same request again with the same key does it only once.
Play a station (its id comes from GET /stations):
auth="Authorization: Bearer $KEY"
etag=$(curl -s -o /dev/null -D - --cacert radio.pem -H "$auth" $RADIO/api/v1/status \
| awk -F': ' 'tolower($1)=="etag" {print $2}' | tr -d '\r')
curl --cacert radio.pem -H "$auth" -H "If-Match: $etag" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" -d '{"action":"play","stationId":"STATION-ID"}' \
$RADIO/api/v1/commands
POST /commands | Body |
|---|---|
| Play a station | {"action":"play","stationId":"…"} |
| Pause, resume, stop, skip | {"action":"pause"}, "resume", "stop", "skip" |
| Volume | {"action":"volume","volume":0.4}, from 0 to 1 |
A change the radio accepts but finishes later answers 202 with an operationId; read
GET /operations/{id} to see it complete. If something else changed the radio first, you get
412 with the current revision in ETag: send the request again with that one.
Favor, Less, Rest and Ban
POST /stations/{id}/feedback with the stations scope and the station's
ETag. Read the station first: the soft choices also need its feedbackRevision.
{"trackId": "TRACK-ID", "action": "rest", "feedbackRevision": 3, "restUntilMs": 1791158400000}
action | Effect on this station |
|---|---|
favour, avoid, neutral | Favor, Less, or no preference. Needs feedbackRevision. |
rest, unrest | Rest until restUntilMs (within 365 days), or end the rest. Needs feedbackRevision. |
ban, unban | Keep the track off the station, or allow it again. |
None of these touch your own favorites, history or Pulse.
Events
GET /events is a stream of server-sent events, so you don't need to poll. Each says what changed;
read that resource again.
curl -N --cacert radio.pem -H "Authorization: Bearer $KEY" $RADIO/api/v1/events
event: update
data: {"data":{},"type":"status"}
Types are status (playback, the track or a fault changed), stations,
operation, settings and libraries. A key can hold two streams at once.
Endpoints
| Scope | Routes |
|---|---|
read |
GET /status, /queue, /stations, /stations/{id},
/stations/{id}/feedback, /catalogue?q=, /vibes,
/artwork/{id}, /operations/{id}, /settings, /libraries,
/sync, /cache, /outputs, /events
|
control | POST /commands |
stations |
POST /stations, PUT and DELETE /stations/{id},
POST /stations/{id}/prepare, /preview, /feedback,
POST /vibes/decode
|
admin |
GET and PUT /providers, PUT /settings, /libraries,
/outputs, POST /sync, /cache
|
Errors and limits
An error answers {"error":{"code":"…"}} with a fixed code, such as insufficient-scope or
invalid-command. 401 means the key is missing or revoked, 403 that it lacks
the scope, 428 that a change is missing its headers, and 429 that a key has more than four
reads in flight at once. Request bodies can be up to 256 KiB.