53-9A-E0-29-34-00-01 RFID Card scanned

The Vinyl Remote

My records now double as remote controls. Each one carries a small RFID sticker. Set a record on the reader and its album plays from the library, on whatever speaker Home Assistant has picked. The reader is an ESP32, and the glue is Home Assistant plus a small web service in a FreeBSD jail. Here's how the pieces fit, and every place they bit me.

The Nevermind LP standing on a small black stand on a wooden sideboard, next to a vase of dried flowers, with an amplifier in the shelf below
Nevermind, up on the stand. The reader hides behind the record: set one down and its digital copy starts playing on the speakers.
The empty record stand: two black wooden boards crossed in an X, with a small white RFID 2 module gaffer-taped to the top of one board and a cable running down to the taped-up ESP32 near the base
The same stand without the record. The white RFID 2 unit is gaffer-taped to the top of one leg, right behind where the record rests, and its cable runs down to the ESP32, taped up in its own bundle by the base. Industrial design, by gaffer tape.

The pitch fits on a sticker, which is convenient, because that's the whole hardware budget per album. Every record gets an RFID tag stuck on it. Pull a record off the shelf, set it on the reader, and the album starts on whichever speaker is selected in Home Assistant. Lift the record and the music stops. A freshly stickered record shows up on a web page, where I give it its album by searching the library. Nobody edits YAML at eleven at night anymore.

The build is five components that each do one job and mostly don't know the others exist. That's the part I'm proudest of, so that's what this post is about.

The cast

The taga sticker with a UID
A small, cheap RFID sticker on the record. Nothing is ever written to it. Its factory ID, something like 53-13-0B-2A-34-00-01, is the whole identity. The record doesn't know it's part of a system, and I intend to keep it that way.
The readerESP32-S3 + RC522, ESPHome
An M5Stack RFID 2 unit (RC522-compatible, on I²C), wired to an ESP32-S3 running ESPHome and taped inside the record stand. When a record lands on it, it fires Home Assistant's tag_scanned. When the record leaves, it fires a custom esphome.tag_removed event, because HA's tag system was built for one-shot phone taps and has no idea things can be taken away.
Home Assistant1 blueprint, 2 scripts
The conductor. One automation built from a blueprint listens for both events, and a dropdown helper holds the current speaker. Two scripts do the work: play_album_by_tag and stop_album_by_tag. Each reaches the service through a rest_command.
tag-albumsFastAPI, SQLite, htmx
The new piece. It owns which tag means which album. It has an API for HA and a web page for me to assign albums. It runs under daemon(8) in a FreeBSD jail behind Caddy. The code is on my Gitea: twisla/tag-albums.
Music Assistanton top of Navidrome
Searches the library and plays albums on speakers. HA drives it with music_assistant.play_media. tag-albums only borrows its search and its cover images.
Two stacks of vinyl records on a wooden table, including The Mob's Let the Tribe Increase and IDLES live at Le Bataclan, next to a closed laptop with a small envelope of RFID stickers on it
Tagging night. One sticker per record, one record at a time. Somewhere in that pile is The Mob's Let the Tribe Increase, which gets its own paragraph further down.
Browser assign page Record RFID sticker RFID reader ESP32-S3 + RC522 Home Assistant blueprint + scripts tag-albums FastAPI · SQLite · jail Speaker picked in a dropdown Music Assistant search · play Navidrome the actual files set down tag_scanned tag_removed POST /api/scans GET /api/tags/… polls every 3 s album search covers, via Caddy play_media media_stop streams reads
The blue path is a sticker's UID on its way to becoming music. Solid arrows happen on every scan. The dashed ones only happen while I'm assigning albums on the web page. The reader never talks to tag-albums, and tag-albums never talks to a speaker.

One record, start to finish

  1. A record lands on the reader. The RC522 reads its sticker's UID and ESPHome fires tag_scanned with it.

  2. The HA automation calls play_album_by_tag, passing the speaker currently selected in the dropdown.

  3. The script sends POST /api/scans to tag-albums. The service writes down the scan. If it has never seen this tag before, it files it as an unassigned tag. Either way it answers with the artist and album, or with "assigned": false.

  4. HA calls music_assistant.play_media for each album, in order, by its exact ID in the library. The first one replaces whatever was playing, and the rest are queued after it.

  5. The record comes off, and ESPHome fires esphome.tag_removed. stop_album_by_tag does a read-only GET and stops the speaker only if that tag has an album. Trying out a freshly stickered record doesn't cut whatever's playing mid-song.

Show me the YAML

The RFID part of the reader is about fifteen lines of ESPHome. Everything else it does, like Wi-Fi and shipping logs, comes from shared packages.

# rfid-reader-music.yaml (ESPHome), log lines trimmed
i2c:
  sda: GPIO02
  scl: GPIO01

rc522_i2c:
  address: 0x28
  on_tag:
    then:
      - homeassistant.tag_scanned: !lambda 'return x;'
  on_tag_removed:
    then:
      # HA's tag system has no "removed", so fire a custom event
      - homeassistant.event:
          event: esphome.tag_removed
          data:
            tag_id: !lambda 'return x;'
The reader's side. It knows nothing about albums; it only reports what it sees.

On the HA side, the play script asks tag-albums what's on the stand and hands each album to Music Assistant in order. Here it is with the error handling and the play-by-name fallback trimmed out:

# scripts.yaml (Home Assistant), error handling trimmed
play_album_by_tag:
  sequence:
    # Ask tag-albums: records the scan, answers with the albums
    - service: rest_command.tag_albums_scan
      data:
        tag_id: "{{ tag_id }}"
      response_variable: scan
    # First album replaces the queue, the others go after it
    - repeat:
        for_each: "{{ scan.content.albums }}"
        sequence:
          - service: music_assistant.play_media
            target:
              entity_id: "{{ target_player }}"
            data:
              media_id: "{{ repeat.item.uri }}"   # exact library album
              media_type: album
              enqueue: "{{ 'replace' if repeat.first else 'add' }}"
HA's side. The first album replaces whatever was playing, the rest are queued after it.

And this is what tag-albums answers for a tag with two albums, from a test run:

{
  "tag_id": "53-5D-4E-2B-34-00-01",
  "assigned": true,
  "albums": [
    { "artist": "Kiasmos", "album": "Kiasmos",   "uri": "library://album/2928" },
    { "artist": "Kiasmos", "album": "Thrown EP", "uri": "library://album/984" }
  ]
}
A POST /api/scans response. The uri is the exact album in Music Assistant's library, so near-duplicates can't get picked by mistake.

Version zero

It started on my desk with the reader dangling off its cable and the two blue test cards it came with. No stickers, no records, no service: just ESPHome firing an event, and HA's activity log scrolling as the album mapped to that card in a YAML file started playing. Ugly, but it proved the loop worked end to end.

19 September, an early test: a card on the reader, and HA's activity log confirming each scan.

Why a service, and not a YAML file

Version one was a tag_albums.yaml map pulled into a script with !include. It worked, in the way that a sticky note on the fridge works. Adding a record meant sticking a tag on it, scanning it, digging the UID out of a log, typing the artist and album by hand, and reloading HA. Typos were silent: the script simply found nothing and played nothing.

tag-albums turned that into one step. The request that looks up a tag's album is the same request that announces a new tag. Set a freshly stickered record on the reader and its tag shows up on the page within three seconds, because the page polls with htmx. Click Assign, search Music Assistant's library, and click the album. The names come straight from the library, so they match what play_media resolves.

The tag-albums web page: an empty Unassigned tags list, then 40 assignments sorted by most recently assigned, each with a cover or grey placeholder, the album and artist, the tag ID, and when it was assigned and last scanned
The web page, sorted by most recently assigned. Nothing waiting for an album, 40 records done. The one at the top is the vinyl with two EPs on it.

I even ended up writing a glossary for it. "Encoding a tag" turned out to mean no data gets written to the tag at all. The words are now Tag, Assignment and Unassigned tag, and the code uses them too.

One record, two EPs

Then a record broke the model. Samaris's self-titled vinyl compiles two EPs, Hljóma Þú and Stofnar Falla, and the digital library has them as two separate releases. So a tag now holds an ordered list of albums. HA plays the first one, replacing whatever was on, and queues the rest behind it. On the web page, Add appends an album and Assign replaces them all. Box sets are covered too, whenever one turns up.

Things that bit me

The album that played twice

I had two automations built from the same blueprint, one of them supposedly aimed at a different speaker. It was aimed through an input the blueprint didn't have. HA ignored that input without a word, so both automations used the dropdown's speaker, and every scan played everything twice. The fix was deleting one automation. The lesson was reading the automation traces before trusting anything.

Home Assistant doesn't trust my CA

The service sits behind Caddy, with a certificate from my home CA. My laptop trusts that CA. HA's HTTP client only trusts its own built-in list of public CAs, and there's no setting to add one. For now, the LAN hop from HA to the service runs with verify_ssl: false: encrypted, but not checked. That's a stopgap, not a solution. Getting HA to actually verify the certificate is still on the to-do list.

Covers vs. mixed content

Music Assistant hands out cover URLs as plain http:// on its own port. My page is https, so browsers quietly refused every image. The service now downloads each assigned album's cover and serves it itself. Search results load their covers through a small proxy that only fetches URLs the service signed itself, so it isn't an open proxy on my network. The jail couldn't reach Music Assistant's port either, so Caddy now publishes exactly one path, /imageproxy/*, and nothing else. Some albums have no cover in Music Assistant at all. Those get a grey square, which builds character.

Two albums, one capital letter

One night The Mob's Let the Tribe Increase went on the stand and nothing played at all. Music Assistant said "There is nothing to play here." The library held two entries, "Let the Tribe Increase" and "Let The Tribe Increase", and only one of them had any tracks. Playing by name, Music Assistant ignored the capitals and picked the empty one. Now each album is stored with its exact library ID, checked on every scan and re-found by name (exact spelling first) if the library ever gets rebuilt.

The tag page for The Mob's record: it plays Let the Tribe Increase, and a library search for let the tribe returns two albums, Let The Tribe Increase without a cover and Let the Tribe Increase with its red cover
The culprit, caught on camera. Search the library and both spellings come back: the capital-T one has no cover and nothing to play, the other is the real album. The tag now points at that one, by ID.

The record that was never scanned

My favourite bug: a freshly stickered record "didn't show up". The reader's logs, which it ships to VictoriaLogs over syslog, said the tag had been set down at 21:46, before the switch-over, and never picked up again. The reader only reports a tag when it's put down. The bug was a record that had been sitting on the reader the whole time.

The supporting cast

The reader is one device in a small ESPHome fleet, next to a row of Bluetooth proxies and an AtomS3R that shows what's playing. That repo got a cleanup along the way. Each kind of device now has a role package, such as "Bluetooth proxy", which pulls in feature packages for networking and telemetry, so each device file is five lines. OTA updates moved to encryption, which meant flashing the older devices twice: once with the old password to get them onto new firmware, then again to require encryption. Every device ships its logs to VictoriaLogs, which is how the forgotten-record bug above got solved in two minutes instead of two hours.

LayerWhatWhy this one
ReaderESP32-S3, RC522 over I²C, ESPHomeCheap, and it talks to HA natively
GlueHA blueprint, 2 scripts, 2 rest_commandsHA already knows the speakers
ServiceFastAPI, SQLite, Jinja, htmx (vendored)One process, one file of data, no build step
HostingFreeBSD jail, daemon(8), CaddyWhere everything else already lives
MusicMusic Assistant on top of NavidromeSearch and playback without writing either
Logssyslog to VictoriaLogsSo a forgotten record takes minutes to find

What's next

Getting Home Assistant to trust my home CA, so verify_ssl: false can go. Covers for the albums Music Assistant draws a blank on, probably fetched straight from Navidrome. Maybe an LED that blinks when a tag is unassigned, so nobody stands there waiting for music that isn't coming. And more stickers. There are always more records than stickers.

The service's code, tests and deployment notes are at git.twis.la/twisla/tag-albums.

If you build one: keep the reader dumb, let HA do the talking, and put the tag-to-album list somewhere you can edit without SSH. Future you, holding a freshly stickered record at eleven at night, will be grateful.