RFID Jukebox. and a Nightlight. With a Clock!

by bitsandpixels in Circuits > Gadgets

114 Views, 2 Favorites, 0 Comments

RFID Jukebox. and a Nightlight. With a Clock!

anna_elsa_olaf_printed.jpg
concept_diagram.png

It all started with a once-in-a-long-time offer from the 3D printing and CNC machining service, Weerg (not affiliated): try out their clear, translucent resin 3D printing for an astonishing price of 0€!

This was around the time I’d finished the RGB Butterfly, and I was looking to build another night light for the kid. At that time, she was heavily into the Frozen, so after a bit of Internet searching I found this beautiful model.

This was too much of an opportunity to pass up on…

This project changed so many times over a few years, that it feels like a few separate ones. It ended up becoming a fancy nightlight, a clock, and a jukebox. Read the full story at bitsandpixels.io and check out the GitHub repository.

Idea of an RFID jukebox came from the Home Assistant's blog: place a card on the scanner and the music starts playing. The Home Assistant and its superb add-on, Music Assistant, are used to select what happens when a card is scanned.

Supplies

concept_diagram2.png

I decided to go with the following components, mostly because I already had some of them (they do fit surprisingly well, though):

  1. Lilygo T-Embed
  2. Muse Luxe hackable, connected speaker, or any other speaker or audio system that can be integrated with Home Assistant.
  3. PN532 RFID reader module (or any other supported by ESPHome)
  4. a couple of rings with WS2811 RGB addressable LEDs; I used:
  5. 12 LED NeoPixel ring, 37mm diameter,
  6. 16 LED NeoPixel ring, 68mm diameter
  7. LM2596S DC-DC step-down voltage converter module (like this one)
  8. USB-C breakout board
  9. semi-transparent PLA
  10. regular PLA
  11. assortment of wires

Note that except for the T-Embed dev board, you don’t have to use the exact same components from the list above. Design is flexible enough to accommodate different hardware, as long it is supported by the software (Home Assistant, ESPHome).

Even the T-Embed could be replaced by a similar development board, but it is slightly trickier because part of the casing design would have to switched to match the alternative module. Totally doable, though.

The voltage converter is adjusted to reduce and stabilize the output voltage at approximately 4.2V-4.7V (it’ll drop down even further when the LEDs are on) from the 5V supplied by the USB. The converter’s output is used to supply both LEDs and the T-Embed module.

If you’re using different ESP32 module, make sure that the output voltage of the converter is safe for that module.

The WS2811 LEDs can be supplied by voltages anywhere in the 3.3 - 5V range. T-Embed is meant to be powered from a USB connection, so it expects input to be at around 5V. It comes with its own voltage stabilizers dropping the voltage down to 3.3V.

The RFID module and the LCD screen both require 3.3V, so they’ll be powered directly from the 3.3V pin on the T-Embed. They probably could be connected directly to voltage converter’s output, but if you would like to do that, make sure that modules you’re using are 5V tolerant.

3D Prints

base_lite_cover.png
base.png
case_lite.png

STL files are attached to this step and are also downloadable here or from the project’s GitHub repository, along with the source FreeCAD design. Additional attachment is the Prusa Slicer project file for the Liligo T-Embed module case, extracted from the manufacturer's STL files.

Parts are designed to have a tight fit, so it should be possible to assemble everything without glue or screws. Still, ribs on the walls are designed to be optionally attached to the base using small screws (M1.6 or so; not bolts, there are no holes in the base).

Assembly

led_rings_distance.png
usb_breakout_and_converter.png
IMG_20241227_202328968.jpg
lilygo_t-embed.png
enclosure_exploded.png
frozen_nightlight_lite_orig.jpg

Start with assembling the LED rings together: solder the power, ground, and data wires between the two rings. There should be approximately 15mm of space between the rings, enough to mount them to their stands on the base of the enclosure.

While soldering stuff, solder some wires between the USB breakout board and the DC-DC converter. There’s around 17mm between the two components, best use ~20mm wires. There’s some space in the enclosure to align them.

Next, I would recommend to prepare a power extension cord of sorts. I used JST connectors for power and ground, to avoid unfortunate accidents with swapping power and ground pins. Take a few 2-pin JST sockets and connect them together; maybe even wrap them in a heat shrink tube.

With the extension, you’ll have plenty of power connectors to extend2 the project in the future. Next, prepare ~50mm wires for power and ground outputs from the converter and crimp a 2-pin JST connector on one end3. Solder the other end to the converter.

Now that it’s been settled which pin is power and which is ground, you can safely solder power connector to the LED ring assembly. Around 5cm should be enough.

For the LED’s DI pin, I’d suggest picking a longer, ~20cm female-female wire (one for standard, 2.54mm gold pins), cutting it in half, and soldering one of the laves to the LED ring.

If using the T-Embed module, that’s the end of soldering. The LCD screen is on board, and the RFID tag reader can be connected using those nifty jumper wires.

Connect the remaining wires as follows (modules on the left, T-Embed pins on the right):

  1. LED Ring DI -> IO38
  2. PN532 SCL -> SCL
  3. PN532 SDA -> SDA
  4. PN532 VCC -> 3V3
  5. PN532 GND -> GND (any of them)

Make sure to pull the wires through the holes in the cover and the T-Embed’s encasing before connecting them to the module.

And that’s it. Assembly of the enclosure should be straightforward after all that. Align the wall so that its ribs match the notches in the base and press it down. If the fit isn’t too tight, you might need to use glue or screws to fasten the two parts together.

Finally, just glue the figure and the T-Embed on top. Hot glue works great.

Software

sequence_diagram.png

When it comes to software, there are two sides to this project: the device firmware, and the Home Assistant configurations.

The firmware, generated using ESPHome, handles the lights (including The Button that controls them), and the tag scanner (sends tag ID to the Home Assistant). It also exposes an additional binary sensor that latches the state of the scanned tag, to ensure that the card won’t be randomly re-scanned.

In the Home Assistant, one automation monitors the tags being scanned, looking for known tag IDs assigned to specific media players and media content. If a known card is scanned, the automation will call Home Assistant service to start the playback.

The other automation monitors state of the “tag active” latch. When it goes from on to off, calls Home Assistant service to stop the playback.

Check out the full description of the ESPHome project and the Home Assistant automation in the blog posts:
  1. RFID Jukebox: The FIrmware
  2. RFID Jukebox: Home Assistant Automation
Download the source files from the GitHub repository.

Result

photostudio_1750537696698.jpg
The RFID Jukebox in Action
IMG_20250620_195034718_HDR.jpg

And here's how it turned out for me. Eventually (it took a lot of time and a lots of turns).

Check out the next steps for source files of the ESPHome project and the Home Assistant automations.

Home Assistant Automation

One the Home Assistant side of things, everything boils down to two automations, one of which is nearly identical to the one shown in the Home Assistant blog article on tags.

Read more about the Home Assistant automation: RFID Jukebox: Home Assistant Automation
alias: Start Jukebox
max_exceeded: silent
mode: single
description: |-
Maps tags to media content and tag readers to media players.
When a recognized tag is scanned, starts playing media on the selected player.
triggers:
- trigger: event
event_type: tag_scanned
conditions:
- condition: and
conditions:
- condition: template
value_template: "{{ trigger.event.data.device_id in media_players }}"
- condition: template
value_template: >-
{{
states(device_entities(media_players[trigger.event.data.device_id].player_id)[0])
!= "playing"

}}
actions:
- variables:
media_player_entity_id: "{{ media_players[trigger.event.data.device_id].player_entity }}"
media_player_device_id: "{{ media_players[trigger.event.data.device_id].player_id }}"
media_content_id: "{{ tags[trigger.event.data.tag_id].media_content_id }}"
media_content_type: "{{ tags[trigger.event.data.tag_id].media_content_type }}"
- action: media_player.clear_playlist
metadata: {}
data: {}
target:
device_id: "{{ media_player_device_id }}"
- action: media_player.shuffle_set
metadata: {}
data:
shuffle: true
target:
device_id: "{{ media_player_device_id }}"
- action: media_player.play_media
target:
entity_id: "{{ media_player_entity_id }}"
data:
media_content_id: "{{ media_content_id }}"
media_content_type: "{{ media_content_type }}"
variables:
media_players:
<tag_reader_id>:
player_entity: media_player.<entity_id>
player_id: <media_player_id>
tags:
<tag_id>:
media_content_id: <media content id>
media_content_type: playlist


alias: Stop the Jukebox
description: ""
triggers:
- type: turned_off
device_id: <id of the tag reader>
entity_id: <id of the tag_active sensor>
domain: binary_sensor
trigger: device
conditions:
- condition: device
device_id: 1e0dbd5f8b410f3ab9cd24320be02dde
domain: media_player
entity_id: 492d8cae5747a15b70024ba96e93caee
type: is_playing
actions:
- action: media_player.media_pause
metadata: {}
data: {}
target:
device_id: 1e0dbd5f8b410f3ab9cd24320be02dde
mode: single


ESPHome Project

This project uses the indispensable ESPHome project to generate firmware for the ESP32 microprocessor that powers the Lilygo T-Embed. It converts a machine-readable description of what the firmware should do to code that runs on the ESP32 with little to no coding.

You can download the full configuration file in the project's GitHub repository. Also check out the ESPHome's documentation to learn more about the structure of the file, core components, supported boards, etc.

Check out the detailed walk-through this ESPHome config file in RFID Jukebox: The FIrmware.
# Copyright © 2025 bitsandpixels.io
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the “Software”), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# The RFID Jukebox: ESPHome configuration file.
# https://bitsandpixels.io/posts/rfid-jukebox-story/

substitutions:
name: "jukebox"
friendly_name: "RFID Jukebox"
wifi_ap_password: ""
led_count: "28"

esphome:
name: "${name}"
project:
name: "bitsandpixels.rfid-jukebox"
version: "1.0.1_t-embed"
platformio_options:
# CDC_ON_BOOT enables early access to serial console;
# this way it can print all the core dumps etc.
build_flags:
- "-D ARDUINO_USB_CDC_ON_BOOT=1"
- "-D BOARD_HAS_PSRAM=1"
board_build.mcu: esp32s3
board_build.f_cpu: 240000000L
board_build.name: "LilyGO T-Embed ESP32-S3"
board_build.upload.flash_size: "16MB"
board_build.upload.maximum_size: 16777216
board_build.vendor: "LilyGO"
build_type: debug
debug_speed: 12000
debug_tool: esp-builtin
debug_init_break: tbreak setup
# upload_protocol: esp-builtin
upload_protocol: esptool
monitor_filters: esp32_exception_decoder
on_boot:
priority: 600
then:
- switch.turn_on: power_on

esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
version: recommended

psram:

logger:

api:
id: ha_api

ota:
platform: esphome
password: !secret ota_password

wifi:
ap: {}
captive_portal:
improv_serial:

time:
- platform: homeassistant
id: home_time
timezone: CET-1CEST,M3.5.0,M10.5.0/3

globals:
- id: tag_active
type: bool
initial_value: "false"
- id: light_brightness
type: float
initial_value: "0.0"
- id: light_red
type: float
initial_value: "0.0"
- id: light_green
type: float
initial_value: "0.0"
- id: light_blue
type: float
initial_value: "0.0"
- id: light_effect
type: std::string
initial_value: '"None"'
- id: menu_state
type: int
initial_value: '0'

light:
- platform: esp32_rmt_led_strip
chipset: WS2811
pin: GPIO38
num_leds: ${led_count}
name: ${friendly_name}
id: internal_light
rgb_order: GRB
use_psram: false
use_dma: true
max_refresh_rate: 16ms
effects:
- addressable_rainbow:
name: Rainbow
- pulse:
transition_length: 1s
- lambda:
name: "Notification"
update_interval: 1s
lambda: |-
static int state = 0;
// https://esphome.io/api/classesphome_1_1light_1_1_light_call
auto call = id(internal_light)->make_call();
if (state == 0) {
// Dim the light
call.set_state(true);
call.set_brightness(1);
call.set_transition_length(1000);
} else if (state == 1) {
call.set_brightness(0.5);
call.set_transition_length(1000);
}
call.perform();
++state %= 2;

script:
- id: store_light_state
then:
- lambda: |-
id(internal_light).current_values_as_brightness(
&id(light_brightness)
);
id(internal_light).current_values_as_rgb(
&id(light_red), &id(light_green), &id(light_blue)
);
id(light_effect) = id(internal_light).get_effect_name();
ESP_LOGD(
"light_store",
"Storing light settings: brightness: %.1f, RGB: %.1f %.1f %.1f, effect: %s",
id(light_brightness),
id(light_red),
id(light_green),
id(light_blue),
id(light_effect).c_str()
);

- id: restore_light_state
then:
- lambda: |-
ESP_LOGD(
"light_restore",
"Re-storing light settings: brightness: %.1f, RGB: %.1f %.1f %.1f, effect: %s",
id(light_brightness),
id(light_red),
id(light_green),
id(light_blue),
id(light_effect).c_str()
);
// Change the effect first to make sure that whatever effect was there previously
// is now disabled. If we're restoring to a disabled light, previous effect
// would not get disabled because ESPHome can't set effect/flash when
// turning off the light.
auto call_effect = id(internal_light).turn_on();
call_effect.set_effect(id(light_effect));
call_effect.perform();

auto call = id(internal_light).turn_on();
call.set_brightness(id(light_brightness));
call.set_rgb(
id(light_red),
id(light_green),
id(light_blue)
);
call.perform();

- id: notify_error
then:
- light.control:
id: internal_light
effect: "Notification"
blue: 0%
red: 100%
green: 0%
state: on
- delay: 4s

# Lilygo T-Embed peripheral power and backlight switches
switch:
- platform: gpio
pin:
number: GPIO46
mode:
output: True
name: "Power On"
id: power_on

# RC522 RFID module connections
# RFID-RC522 <-> Wemos D1 Mini
# SDA (SS) <-> GPIO16
# SCK <-> GPIO22
# MOSI <-> GPIO21
# MISO <-> GPIO17
# IRQ <-> -
# GND
# RST <-> GPIO04
# VCC
# spi:
# id: rfid_spi
# clk_pin: GPIO22
# mosi_pin: GPIO21
# miso_pin: GPIO17
i2c:
id: i2c_bus0
scl: GPIO08
sda: GPIO18
scan: True

pn532_i2c:
update_interval: 1s
i2c_id: i2c_bus0
on_tag_removed:
then:
- globals.set:
id: tag_active
value: 'false'
- logger.log:
format: "Tag removed: %i"
args: [ 'id(tag_active)' ]

on_tag:
if:
condition:
not:
api.connected:
then:
- script.execute: store_light_state
- script.execute: notify_error
- script.execute: restore_light_state
else:
if:
condition:
# Only proceed if there is no active tag
- lambda: |-
return !id(tag_active);
then:
- globals.set:
id: tag_active
value: 'true'
- logger.log:
format: "Tag found: %i"
args: [ 'id(tag_active)' ]
- script.execute: store_light_state
- homeassistant.tag_scanned: !lambda "return x;"
- light.turn_on:
id: internal_light
effect: "Notification"
brightness: 50%
red: 50%
green: 5%
blue: 92%
- delay: 2s
- script.execute: restore_light_state
else:
- logger.log:
format: "Tag active: %i"
args: [ 'id(tag_active)' ]

#
# Display
#
spi:
id: display_spi
clk_pin: GPIO12
mosi_pin: GPIO11

font:
- file: "gfonts://Teko"
id: disp_font
size: 96
- file: "gfonts://Material+Symbols+Outlined"
id: icons_20
size: 20
glyphs: [
"\U0000e63e", # wifi
"\U0000e648", # wifi-off
"\U0000e2bf", # cloud-done
"\U0000e2c1", # cloud-off
"\U0000e037", # play-arrow
"\U0000e034", # pause
]
- file: "gfonts://Material+Symbols+Outlined"
id: icons_36
size: 36
glyphs: [
"\U0000e63e", # wifi
"\U0000e648", # wifi-off
"\U0000e2bf", # cloud-done
"\U0000e2c1", # cloud-off
"\U0000e037", # play-arrow
"\U0000e034", # pause
]


display:
- platform: st7789v
model: LILYGO_T-EMBED_170X320
cs_pin: GPIO10
dc_pin: GPIO13
backlight_pin: GPIO15
reset_pin: GPIO09
update_interval: 10s
auto_clear_enabled: true
id: disp
spi_id: display_spi
lambda: |-
it.strftime(80, 16, id(disp_font), "%H:%M", id(home_time).now());
if (id(ha_api)->is_connected()) {
it.printf(0, 8, id(icons_20), "\U0000e2bf");
} else {
it.printf(0, 8, id(icons_20), "\U0000e2c1");
}
#
# Menu
#
binary_sensor:
- platform: template
name: Tag Active
lambda: |-
return id(tag_active);
- platform: gpio
id: light_btn
pin:
number: GPIO0
inverted: true
mode:
input: true
on_click:
- min_length: 50ms
max_length: 400ms
then:
# On short press light toggles between pre-defined states:
# - Off
# - 40% brightness
# - 70% brightness
# - 100% brightness
# - Rainbow
- lambda: |-
id(menu_state) = (id(menu_state) + 1) % 5;

auto clear_effect = id(internal_light).turn_on();
clear_effect.set_effect("None");
clear_effect.perform();

auto call = id(internal_light).turn_on();
call.set_rgb(1.0, 1.0, 1.0);

switch (id(menu_state)) {
case 0: call.set_brightness(0.0); break;
case 1: call.set_brightness(0.4); break;
case 2: call.set_brightness(0.7); break;
case 3: call.set_brightness(1.0); break;
case 4: call.set_effect("Rainbow"); break;
}
call.perform();
- min_length: 500ms
max_length: 5000ms
then:
- light.toggle: internal_light

button:
- platform: restart
name: "${friendly_name} Restart"