Lumae Radio guide

Install, set up and connect your radio

For Lumae Radio 0.4.0-beta.1 · Updated October 12, 2026

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

October 9, 2026 · Raspberry Pi (64-bit) · 45 MB

Files on GitHub

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.

  1. Prepare the Pi

    In Raspberry Pi Imager choose Raspberry Pi OS Lite (64-bit). Set a host name (the radio becomes kitchen.local on 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-daemon

    Set up your DAC board's driver as its maker describes, if you use one.

  2. 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
  3. 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
    EOF

    Then 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
  4. 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 --install

    It 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 run sudo python3 -I -B /opt/lumae-radio/current/packaging/configure.py --auto and sudo 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.

  1. Find the radio. In Lumae, open Library ≡ → Settings → Servers & accounts → Lumae Radio and scan. Your radio shows up with Adopt.
  2. 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.
  3. 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.
  4. 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

  1. In HACS, open ⋮ → Custom repositories, add https://github.com/rendyhd/lumae-radio-home-assistant as an Integration, and install Lumae Radio.
  2. Restart Home Assistant.
  3. 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.
  4. 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
OptionDefaultWhat it does
entityThe radio's media player.
themeautoFollows Home Assistant's light or dark mode; dark or light keeps one.
show_volumetrueShow the volume slider.
show_steeringtrueShow 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 .local name, 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.

ScopeLets a key
readRead status, the queue, stations, the library, settings and events.
controlPlay, pause, resume, stop, skip and set the volume.
stationsMake, edit, prepare and delete stations, and Favor, Less, Rest and Ban tracks.
adminChange 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: the ETag from 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 /commandsBody
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}
actionEffect on this station
favour, avoid, neutralFavor, Less, or no preference. Needs feedbackRevision.
rest, unrestRest until restUntilMs (within 365 days), or end the rest. Needs feedbackRevision.
ban, unbanKeep 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

ScopeRoutes
read GET /status, /queue, /stations, /stations/{id}, /stations/{id}/feedback, /catalogue?q=, /vibes, /artwork/{id}, /operations/{id}, /settings, /libraries, /sync, /cache, /outputs, /events
controlPOST /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.