Ordio – Home Assistant Music Streamer

A physical Spotify player: NFC disks, an OLED, and Home Assistant

I wanted music my household could hold. Streaming is convenient to the point of being forgettable — you tap a name in a list and sound happens. So I built a slot reader: 3.5″ 3D-printed disks, each with an NFC tag inside and a label on the front. Slot a disk, the assigned Spotify playlist plays on the Sonos. Pull it out, the music stops. A small OLED on the front shows what’s playing, scrolling the title if it’s too long, and sleeps when the slot’s empty.

This project was inspired by https://github.com/ItsDanik/rfidisk

The electronics are the easy part. The state machine — track skips, pauses, transient reader misreads — is where the time went.

The hardware

The reader choice needs a note, because the obvious objection is right and wrong at the same time.

The RC522 caveat (read this before you buy a PN532 instead)

In ESPHome the RC522 only returns a tag’s UID — no NDEF payload reading. The well-known knock against it is presence flakiness: the cheap blue modules sometimes report a tag as gone while it’s still sitting on the antenna. For a “play while present, stop on removal” design — the entire premise here — that’s the failure mode that ruins it. Music dropping out mid-track while the disk hasn’t moved.

The usual advice is to reach for a PN532, which exposes a proper tag-removed event. I had one on order. I didn’t need it. The fix is mechanical, not electrical: a disk seated in a slot sits dead-centre on the antenna and doesn’t move, which is the stable case the RC522 is fine at. The flakiness shows up when a tag is hovering at the edge of read range — exactly what a slot enclosure prevents. With the disk geometry doing the work, presence detection has been solid.

So: if your tags are loose and hand-waved over the reader, get the PN532. If they’re physically captured in a slot, the RC522 is fine, and the Home Assistant logic below is identical either way — the reader only reports which disk is present.

Wiring

The RC522 is SPI, the OLED is I2C. They share only power, so no bus conflict.

The DevKit I used doesn’t break out GPIO16/17, which a lot of I2C tutorials assume. No matter — GPIO21 and GPIO22 are free, and they’re the standard ESP32 I2C pins anyway.

Reader (SPI): CS->GPIO5, SCK->GPIO18, MOSI->GPIO23, MISO->GPIO19, RST->GPIO4. Power on 3.3V — not 5V.

OLED (I2C): SCL->GPIO22, SDA->GPIO21. Power on 3.3V.

Important: Avoid the strapping pins (GPIO0, 2, 12, 15) for any of this — something pulling on them at boot can stop the board entering its bootloader. And GPIO34/35 are input-only, so they’re useless for chip-select or reset. The reset line is technically optional (the RC522 mostly works without it), but wiring it makes recovery from a wedged reader more reliable.

The full ESPHome config

The whole device firmware in one block. The rfid reader registers each known tag as a binary sensor — “is this specific disk present, yes or no” — rather than firing a one-shot scan event. That distinction is the whole design: a binary sensor gives you debounced presence state, which is what “play while present” needs; a scan event only tells you a tag arrived, never that it left.

The display reads three values pushed from Home Assistant and renders them, with two extras — long titles scroll as a marquee, and the panel blanks 10 seconds after the music stops to protect against OLED burn-in. The screensaver is self-contained: empty title starts the countdown, a new title wakes it.

esphome:
  name: tagreader
  friendly_name: Tag Reader

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Tagreader Fallback"
    password: !secret fallback_ap_password

captive_portal:

logger:

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    password: !secret ota_password

# --- SPI bus for the RC522 reader ---
spi:
  clk_pin: GPIO18
  miso_pin: GPIO19
  mosi_pin: GPIO23

rc522_spi:
  cs_pin: GPIO5
  reset_pin: GPIO4
  update_interval: 1s        # presence-off lag tracks this; ~1-2s after removal

# --- I2C bus for the OLED ---
i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true
  frequency: 400kHz

# Text pulled FROM Home Assistant for the display
text_sensor:
  - platform: homeassistant
    id: np_title
    entity_id: input_text.nowplaying_title
  - platform: homeassistant
    id: np_artist
    entity_id: input_text.nowplaying_artist
  - platform: homeassistant
    id: np_status
    entity_id: input_text.nowplaying_status

globals:
  - id: marquee_pos
    type: int
    restore_value: no
    initial_value: '0'
  - id: screen_on
    type: bool
    restore_value: no
    initial_value: 'true'
  - id: off_at
    type: uint32_t
    restore_value: no
    initial_value: '0'

font:
  - file: "gfonts://Roboto"
    id: font_small
    size: 12
  - file: "gfonts://Roboto"
    id: font_med
    size: 16

display:
  - platform: ssd1306_i2c
    model: "SH1106 128x64"   # left 2 columns garbled? try "SSD1306 128x64"
    address: 0x3C            # some panels are 0x3D - check the I2C scan log
    id: oled
    update_interval: 100ms   # 100ms, not 500ms, or the marquee jerks
    lambda: |-
      bool has_track = id(np_title).has_state() && id(np_title).state.length() > 0;
      uint32_t now = millis();

      // screensaver: blank 10s after the title clears, wake on a new title
      if (has_track) {
        id(screen_on) = true;
        id(off_at) = 0;
      } else {
        if (id(off_at) == 0) {
          id(off_at) = now + 10000;   // 10s countdown -> retune here
        }
        if (now >= id(off_at)) {
          id(screen_on) = false;
        }
      }

      if (!id(screen_on)) {
        it.fill(COLOR_OFF);   // all-black: removes burn-in risk
        return;
      }

      it.printf(0, 0, id(font_small), TextAlign::TOP_LEFT, "Now Playing");
      it.line(0, 15, 128, 15);

      // marquee: scroll the title only when it's wider than the panel
      std::string title = id(np_title).state;
      int char_w = 9;                       // ~px per char at font_med - nudge 8-10 if it mis-judges
      int text_w = title.length() * char_w;
      if (text_w <= 128) {
        it.printf(0, 20, id(font_med), TextAlign::TOP_LEFT, "%s", title.c_str());
        id(marquee_pos) = 0;
      } else {
        int x = 0 - id(marquee_pos);
        it.printf(x, 20, id(font_med), TextAlign::TOP_LEFT, "%s", title.c_str());
        id(marquee_pos) += 2;
        if (id(marquee_pos) > text_w + 20) id(marquee_pos) = -128;  // gap, then loop
      }

      it.printf(0, 42, id(font_small), TextAlign::TOP_LEFT, "%s", id(np_artist).state.c_str());
      if (id(np_status).has_state() && id(np_status).state.length() > 0) {
        it.printf(127, 0, id(font_small), TextAlign::TOP_RIGHT, "%s", id(np_status).state.c_str());
      }

binary_sensor:
  - platform: status
    name: "Status"
    entity_category: diagnostic

  # One block per disk. Copy each uid EXACTLY from HA (Settings > Tags) -
  # case and hyphens must match or the sensor silently never fires.
  - platform: rc522
    uid: "04-C1-57-B9-CE-2A-81"
    name: "Disk 01"
    id: disk_01
  - platform: rc522
    uid: "04-BF-57-B9-CE-2A-81"
    name: "Disk 02"
    id: disk_02
  # ...repeat for each disk

Critical: the uid strings must match what Home Assistant logs byte-for-byte, hyphens and case included. A mismatch fails silent — no error, the sensor just never fires. Tap each tag on the reader first (it shows up under Settings > Tags) and copy the UID from there.

Don’t shrink update_interval below 1s to make removal feel snappier. Faster polling worsens the misread problem — you’ll trade a 1-2s stop delay for music that stutters mid-disk. 1s is the sweet spot.

The text helpers the display reads

The firmware references three input_text helpers — input_text.nowplaying_title, nowplaying_artist, and nowplaying_status. The OLED shows nothing until these exist. Create them in the UI: Settings > Devices & Services > Helpers > Create Helper > Text. Make three.

Important: the entity ID is derived from the name you type. “Now Playing Title” becomes input_text.now_playing_title — with underscores — which won’t match the entity_id: lines in the firmware above. Either name each helper so the ID lands on nowplaying_title (no spaces between “now” and “playing”), or edit the firmware to match whatever IDs you end up with. A silent mismatch here is the most likely reason a freshly-built reader shows a blank screen.

Home Assistant: three automations

This is where the system thinks. The reader is dumb on purpose. These live in Home Assistant, not on the device — each is a complete automation you can paste straight into the YAML editor.

1. Play and stop. One automation watches every disk’s presence sensor. Disk present -> switch the Sonos to the Music Assistant source, set the volume, play the mapped playlist. Disk absent -> stop. The disk-to-playlist mapping is a single lookup table, so adding a disk is one new line in the map plus one binary sensor in firmware — not a new automation per disk.

alias: "Slot Reader: Play While Present"
description: Plays the mapped playlist while a disk is in the slot; stops on removal.
triggers:
  - trigger: state
    entity_id:
      - binary_sensor.tag_reader_disk_01
      - binary_sensor.tag_reader_disk_02
      # ...one line per disk
    to: "on"
    id: inserted
  - trigger: state
    entity_id:
      - binary_sensor.tag_reader_disk_01
      - binary_sensor.tag_reader_disk_02
      # ...one line per disk
    to: "off"
    id: removed
variables:
  target_speaker: media_player.living_room_2
  target_volume: 0.3
  disk_map:   # binary_sensor entity -> Spotify URI. Add a line per disk.
    binary_sensor.tag_reader_disk_01: spotify:playlist:1NY1ZsQPf1R8gwBQZx1jIG
    binary_sensor.tag_reader_disk_02: spotify:playlist:6uq4jqPumwrfC82M50YDm4
    # ...
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: inserted
        sequence:
          - action: media_player.select_source
            target:
              entity_id: "{{ target_speaker }}"
            data:
              source: Music Assistant Queue
          - action: media_player.volume_set
            target:
              entity_id: "{{ target_speaker }}"
            data:
              volume_level: "{{ target_volume }}"
          - action: music_assistant.play_media
            target:
              entity_id: "{{ target_speaker }}"
            data:
              media_id: "{{ disk_map[trigger.entity_id] }}"   # which disk fired
      - conditions:
          - condition: trigger
            id: removed
        sequence:
          - action: media_player.media_stop
            target:
              entity_id: "{{ target_speaker }}"
mode: restart

2. Update the display. Watches the Sonos for track changes and for playback resuming, then copies title and artist into the helpers. Watching “resumed playing” as well as “track changed” matters — un-pausing the same track doesn’t change the track, so without that trigger the screen wouldn’t come back after a pause.

alias: "Now Playing: Update Display"
description: Copies the current track into the OLED helpers on track change or resume.
triggers:
  - trigger: state
    entity_id: media_player.living_room_2
    attribute: media_title
  - trigger: state
    entity_id: media_player.living_room_2
    to: "playing"            # without this, un-pausing the same track won't refresh the screen
condition:
  - condition: template
    value_template: >-
      {{ state_attr('media_player.living_room_2', 'media_title') not in [none, ''] }}
actions:
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_title
    data:
      value: "{{ state_attr('media_player.living_room_2', 'media_title') | truncate(255, true, '') }}"
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_artist
    data:
      value: "{{ state_attr('media_player.living_room_2', 'media_artist') | default('', true) | truncate(255, true, '') }}"
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_status
    data:
      value: "{{ '>' if is_state('media_player.living_room_2', 'playing') else '||' }}"
mode: restart

3. Clear the display. Blanks the helpers when playback genuinely stops, which lets the screensaver fire. “Genuinely” is doing the work. Skipping a track makes the player flicker through a transitional state for a fraction of a second, and a naive clear reads that blip as “stopped” and kills the screen mid-song. The fix: require the stopped state to persist before clearing.

alias: "Now Playing: Clear Display"
description: Blanks the OLED helpers when playback genuinely stops, letting the screensaver fire.
triggers:
  - trigger: state
    entity_id: media_player.living_room_2
    to: ["idle", "off", "standby"]   # NOT "paused" - that's the skip-transition state
    for:
      seconds: 3                     # a skip blips through faster than this
actions:
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_title
    data:
      value: ""
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_artist
    data:
      value: ""
  - action: input_text.set_value
    target:
      entity_id: input_text.nowplaying_status
    data:
      value: ""
mode: single

That third one was the last real bug, and it’s the lesson of the whole build: the hardware worked early, but skips, pauses, and removal each needed the state machine taught what “stopped” actually means.

Lessons learned

  1. RC522 unreliable presence detection — fix it with disk geometry, not a new reader. A captured tag in a slot is the stable case; the flakiness is a hovering-tag problem.
  2. ESPHome binary_sensor uid not matching — copy the UID from HA byte-for-byte, hyphens and case included. It fails silent otherwise.
  3. RC522 update_interval too low causes stutter — keep it at 1s. Faster polling makes misreads worse, not removal faster.
  4. OLED stays blank after building — the input_text helper IDs don’t match the firmware. Check for underscores the UI inserted into the name.
  5. OLED display blanks during track skip — your clear automation is firing on the transitional state. Exclude paused and add a for: delay so a skip can’t qualify as stopped.
  6. Now-playing screen won’t return after pause — your display-update automation only triggers on track change. Add a trigger on the player returning to playing.
  7. ESP32 DevKit has no GPIO16/17 broken out — use GPIO21/22 for I2C, the standard pins anyway.
  8. SH1106 garbage in the first two columns — switch the model string to SSD1306 128x64; the driver offset differs.

The rfid reader reports presence; Home Assistant decides everything else. That split is why the messy parts stayed in software where they’re cheap to fix, and why swapping the reader later — if I ever do — changes nothing above the firmware line.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.