A Raspberry Pi Zero W 2 application that bridges an RC522 RFID/NFC reader to an Inventory management server over WebSocket, with a 16×2 I²C LCD display, passive buzzer feedback, and a local web dashboard.
- Hardware Requirements
- Hardware Wiring
- Raspberry Pi OS Setup
- Installation
- Configuration
- Running
- Web Dashboard
- WebSocket Integration
- Development
- Architecture & Code Structure
| Component | Notes |
|---|---|
| Raspberry Pi Zero W 2 | Any Pi with SPI + I²C will work |
| RC522 RFID module | Connected via SPI (hardware SPI0) |
| 16×2 I²C LCD | PCF8574 I²C expander (default address 0x27) |
| Passive buzzer | Connected to a GPIO pin that supports hardware PWM |
| Micro-SD card | 8 GB+ recommended; Raspberry Pi OS Lite 64-bit |
| RC522 Pin | Pi GPIO (BCM) | Pi physical pin |
|---|---|---|
| SDA (CS) | GPIO 8 | Pin 24 |
| SCK | GPIO 11 | Pin 23 |
| MOSI | GPIO 10 | Pin 19 |
| MISO | GPIO 9 | Pin 21 |
| RST | GPIO 25 | Pin 22 |
| 3.3V | 3V3 | Pin 17 |
| GND | GND | Pin 20 |
All GPIO numbers above are the defaults; every pin is overridable via .env (see Configuration).
| LCD Pin | Pi physical pin |
|---|---|
| VCC | Pin 2 (5V) |
| GND | Pin 6 (GND) |
| SDA | Pin 3 (GPIO 2) |
| SCL | Pin 5 (GPIO 3) |
Connect the positive leg to GPIO 18 (default) and the negative leg to GND. GPIO 18 supports hardware PWM on all Raspberry Pi models. The pin is configurable via BUZZER_PIN in .env.
-
Flash Raspberry Pi OS Lite (64-bit) to your SD card with the Raspberry Pi Imager.
Enable SSH and configure Wi-Fi in the imager's advanced settings. -
Enable SPI and I²C via
raspi-config:sudo raspi-config # → Interface Options → SPI → Enable # → Interface Options → I2C → Enable
Or add these lines to
/boot/firmware/config.txt(path may be/boot/config.txton older images):dtparam=spi=on dtparam=i2c_arm=on -
Install pigpiod (required for hardware PWM buzzer):
sudo apt update && sudo apt install -y pigpio sudo systemctl enable pigpiod sudo systemctl start pigpiod
-
Install Python 3.12+. Raspberry Pi OS Bookworm ships with Python 3.11; install a newer version from deadsnakes or use
uv(which manages its own Python):curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.local/bin/env # or restart your shell
# Clone the repository
git clone https://github.com/afonsoingles/universal-reader.git
cd universal-reader
# Install dependencies (uv will download Python 3.12 automatically if needed)
uv syncCopy the example environment file and edit it:
cp .env.example .env
nano .env| Variable | Default | Description |
|---|---|---|
INVENTORY_WS_URL |
ws://192.168.1.100:8000/ws/reader |
WebSocket endpoint of the Inventory server |
INVENTORY_API_KEY |
(empty) | API key sent during the register handshake |
DASHBOARD_PASSWORD |
changeme |
Password for the local web dashboard |
DASHBOARD_PORT |
5050 |
TCP port the dashboard listens on |
LCD_I2C_ADDR |
0x27 |
I²C address of the LCD's PCF8574 expander |
BUZZER_PIN |
18 |
BCM GPIO pin for the passive buzzer |
RC522_MISO |
9 |
BCM GPIO — RC522 MISO |
RC522_MOSI |
10 |
BCM GPIO — RC522 MOSI |
RC522_SCK |
11 |
BCM GPIO — RC522 SCK |
RC522_SDA |
8 |
BCM GPIO — RC522 SDA (chip-select) |
RC522_RST |
25 |
BCM GPIO — RC522 RST |
uv run universal-readerThe application will:
- Start the RC522 reader daemon thread.
- Connect to the Inventory server via WebSocket (with automatic reconnection).
- Serve the web dashboard on
http://<pi-ip>:<DASHBOARD_PORT>.
Create /etc/systemd/system/universal-reader.service:
[Unit]
Description=Universal RFID Reader
After=network-online.target pigpiod.service
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/universal-reader
EnvironmentFile=/home/pi/universal-reader/.env
ExecStart=/home/pi/.local/bin/uv run universal-reader
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetAdjust
WorkingDirectory,EnvironmentFile, andExecStartpaths if you cloned the repo elsewhere.
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable universal-reader
sudo systemctl start universal-reader
# View logs
sudo journalctl -u universal-reader -fOpen http://<pi-ip>:5050 (or the port set in DASHBOARD_PORT) in any browser.
| Page | Path | Description |
|---|---|---|
| Dashboard | / |
Reader state, WebSocket status, uptime, last scan |
| Login | /login |
Password authentication (cookie-based session) |
| Debug | /debug |
Simulate WS messages, test LCD/buzzer, read a raw UID |
| Status API | /status |
JSON reader status (unauthenticated) |
| Logs API | /logs |
JSON structured log entries (authenticated) |
On startup the reader connects to INVENTORY_WS_URL and sends a register message:
{ "type": "register", "api_key": "<INVENTORY_API_KEY>" }The Inventory server replies with:
{ "type": "registered", "reader_number": 1 }| Type | Fields | Effect |
|---|---|---|
activate |
timeout_seconds: int |
Moves reader to ACTIVE; starts activation timer |
deactivate |
— | Returns reader to HIBERNATED |
read |
— | Moves reader to READING; waits for a tag scan |
result |
status: "success"|"not_found"|"network_error"|"retry", item_id?: str |
Shows result on LCD/buzzer; returns to ACTIVE |
ping |
— | Reader replies with {"type":"pong"} |
| Type | Fields | Sent when |
|---|---|---|
register |
api_key |
On connection |
uid_scanned |
uid: str |
Tag scanned while in READING state |
pong |
— | In response to a ping |
status |
state: str |
On state change |
error |
reason: str |
Rejected command (e.g. scan already in progress) |
HIBERNATED ──activate──▶ ACTIVE ──read──▶ READING ──tag scanned──▶ AWAITING_RESULT
▲ │ │
└───────deactivate/timeout result received ──────────┘
SYSTEM_FAILURE (ws disconnected — auto-recovers on reconnect)
LOCALLY_DISABLED (dashboard disable — ignores all WS messages)
# Install dev dependencies
uv sync --group dev
# Run tests
uv run pytest tests/
# Start without real hardware (mocks are used automatically on non-Pi platforms)
uv run universal-readerHardware drivers (RC522 via mfrc522, LCD via RPLCD, buzzer via pigpio) fall back to mock stubs automatically when the libraries are not importable, so the full application — including the dashboard — can be developed and tested on any machine.
The codebase is organized into focused, single-responsibility modules for maintainability:
src/reader/
├── main.py # Application orchestrator
├── startup.py # Hardware initialization & cleanup
├── state.py # State machine (StateManager)
├── ws_client.py # WebSocket client
├── models.py # Pydantic data models
├── config.py # Configuration loading
├── logger.py # In-memory log
├── handlers/ # Message & event handlers
│ ├── message_handlers.py # WebSocket message dispatch
│ ├── tag_scan_handler.py # RC522 scan & timeouts
│ ├── lcd_handler.py # LCD display updates
│ └── buzzer_handler.py # Audio feedback
├── hardware/ # Hardware abstractions
│ ├── lcd.py # LCD I2C control
│ ├── buzzer.py # Buzzer PWM control
│ └── rc522.py # RC522 RFID reader
└── dashboard/ # FastAPI web dashboard
Key features:
- Handlers are independent modules: Easy to test, modify, and extend
- State machine is central: All state changes trigger appropriate callbacks
- Timeout cascade: Global activation timeout bounds all sub-operations
- Clean separation: Hardware, state, messaging, and UI are decoupled
For detailed architecture information, see:
ARCHITECTURE.md— Component responsibilities and data flowARCHITECTURE_DIAGRAMS.md— Visual flow diagramsREFACTORING_NOTES.md— What changed and why