Skip to content

← 18. The security agreement · Contents

19. A DIY reader

A hardware build log, written for a complete beginner. It turns a Raspberry Pi Pico clone and a handful of cheap modules into a badge and PIN reader on a single breadboard. It is the reader half of a node in 16. The lab suitcase. Nothing here changes what a controller does.

A beginner's page

I know very little about electronics, and I had not touched a soldering iron in more than 15 years. This whole page is written from a beginner's point of view, by someone doing it for fun. The result is not professional work. It is what any beginner can reach by taking a bit of time, and every mistake along the way is listed at the end.

The finished reader: Pico, bicolor LED, MAX3485, RC522, buzzer and OLED screen planted on one breadboard, the keypad on its cable, the screen showing "Enter PIN or show badge"

The reader reads a badge or a PIN, lights an LED, beeps and shows a message. In wardn, it will never decide on its own: it sends what it read to the controller over OSDP, and the controller answers. The first parts build a reader that decides locally, so that each piece can be tested. The last parts move that decision to the controller, one OSDP command at a time.

flowchart LR
    B["Badge"] -- radio --> R["RC522"]
    K["Keypad"] --> P["Pico"]
    R -- SPI --> P
    P -- I2C --> S["OLED screen"]
    P --> L["LED + buzzer"]
    P -- "3.3 V pulse" --> D["Door"]
    P -- UART --> M["MAX3485"]
    M -- "RS-485 (A, B, GND)" --> C["Controller"]

How to read this page. Each part is short and ends with a test. Do not move on until the test passes. The prototype runs MicroPython: it is easy to poke at, and it is not meant for production.

Contents

  1. What you need
  2. All the code
  3. Meet the Pico
  4. Solder the headers
  5. The bicolor LED
  6. The RC522 badge reader
  7. The keypad
  8. The buzzer
  9. The OLED screen
  10. Everything on one breadboard
  11. The full program
  12. Open the door
  13. The bus: plain text both ways
  14. Talking OSDP
  15. What comes next
  16. What went wrong, and the fix
  17. Glossary

What you need

The shopping list below is exactly what this build used, with the price paid in September 2026, in euros, shipping excluded. Many items come in packs: one pack is enough for several readers.

The reader: about 31 €, plus a keypad and a buzzer.

Part Price Notes
Raspberry Pi Pico 3.61 € The listing offers several variants. A USB-C RP2040 clone arrived, and it works the same
MB-102 breadboard, 830 points 3.83 € 63 rows, printed 0 to 62
RC522 RFID module 2.01 € 13.56 MHz, comes with a card and a key fob
0.96" OLED, SSD1306, I2C 4.22 € 128×64, 4 pins
MAX3485 module 1.53 € 3.3 V RS-485 transceiver, 120 Ω termination on board
Bicolor LEDs, red and green, 3 mm 3.14 € Pack of 100, common cathode
Resistor kit, 1/4 W 2.82 € 30 values. You need 330 Ω and 220 Ω
Male header pins, 2.54 mm 3.36 € 10 strips of 40
22 AWG solid wire 1.84 € For the flat wires. Red, black and green
Dupont wires 4.29 € Male-male, for the keypad cable
4×4 matrix keypad — Came from an Arduino starter kit. Search for "4x4 matrix keypad", with an 8-pin connector
Passive piezo buzzer, Murata PKM22EPP-40 — Came from an old Arduino starter kit. Any passive 22 mm piezo works

Tools: about 39 €, bought once.

Tool Price Notes
Soldering iron FNIRSI HS-01 25.59 € Temperature-controlled. Needs a 65 W USB-C charger
Rosin-core solder, 0.8 mm 2.59 € 60/40
Brass tip cleaner 2.39 € A damp kitchen sponge also works
Multimeter ANENG SZ304 8.63 € Continuity, diode and Ω modes are all used here

Also: a wire stripper and cardboard to protect the table.

For the OSDP parts and beyond

Item Price Notes
Waveshare USB to RS-485 adapter 16.49 € Lets a laptop play the controller over the bus. CH343 chip: macOS needs no driver
3M Dual Lock 9.95 € To mount the keypad on a front panel
Raspberry Pi 4 — The real controller, running wardn-edge

All the code

Every file that goes on the Pico, ready to download. Copy the three libraries once, then one program per part.

Libraries, saved on the Pico under the same name:

File What it is
mfrc522.py RC522 driver, with two fixes (part 4)
ssd1306.py Screen driver, unchanged, from micropython-lib
osdp.py OSDP frames. Also used on the Mac by controller.py (part 12)

On the Mac: controller.py plays the controller over the RS-485 adapter (part 12).

One program per part. Open it in Thonny and press Run. Save it as main.py on the Pico to have it start on its own at power-up.

Part Program
1 01-blink.py
3 03-led.py
4 04-badge.py
5 05-keypad.py
6 06-buzzer.py
7 07-screen-scan.py, 07-screen.py
9 09-full-program.py
10 10-door.py
11 11-hello.py, 11-receive.py
12 12-1-poll-ack.py, 12-2-id-cap.py, 12-3-badge-keys.py, 12-4-controller-decides.py

The pins are those of the final breadboard (part 8).

1. Meet the Pico

The Pico clone: USB-C, a BOOTSEL button, no RESET button

A microcontroller runs one program, with no operating system. The Pico's chip, the RP2040, is marked RP2-B2 on clones.

Each of its 40 pins has a number (printed on the board) and a role. Most are GPIOs, GP0 to GP28: pins the program can switch or read. Here is what the finished reader does with each one.

Pico pinout, coloured by what the reader uses each pin for

Flash MicroPython (5 min)

  1. Hold the BOOTSEL button and plug the USB cable in. A drive called RPI-RP2 appears.
  2. Download the latest RPI_PICO firmware (.uf2) from https://micropython.org/download/.
  3. Drag the .uf2 onto the drive. The drive disappears: that is expected.

Talk to it (10 min)

  1. Install Thonny.
  2. Bottom right, pick the interpreter MicroPython (Raspberry Pi Pico).
  3. In the Shell, type print("hello"). The Pico answers.

Test: blink the onboard LED. Save this as main.py on the Pico. It runs at every power-up.

from machine import Pin
import time

led = Pin(25, Pin.OUT)
while True:
    led.toggle()
    time.sleep(0.5)

No RESET button

To get back to BOOTSEL mode, unplug, hold BOOTSEL, plug back in.

2. Solder the headers

The Pico comes without pins. Solder two rows of 20 header pins so it can sit in a breadboard.

Use the breadboard as a jig (5 min)

  1. Push two 20-pin headers into the breadboard, long side down, 7 holes apart (columns C and H).
  2. Lay the Pico on top, pins through its holes.

The breadboard keeps the pins straight while you solder.

Solder (45 min)

  1. Set the iron to about 330 °C and tin the tip.
  2. Touch the tip to the pin and the copper pad at the same time, for 1 to 2 seconds.
  3. Feed solder on the other side of the pin, not on the iron. It flows into the hole.
  4. Remove the solder, then the iron.
  5. Do one corner pin per side first, check the board is flat, then do the rest.

A first joint: shiny, cone-shaped, the pad fully wetted

A good joint is shiny and shaped like a small volcano. A dull ball sitting on the pin is a cold joint: reheat it.

A good joint wets the pad and climbs the pin. A cold joint sits on top as a ball

The Pico with its headers soldered, sitting in the breadboard

Test: no bridges (10 min). Multimeter in continuity mode ·))). Probe each pair of neighbouring pins, counting from the USB end. No beep between neighbours, except between two GND pins. Then plug the Pico in: the LED still blinks.

Crooked pins in a photo

Pins often look crooked in a photo because of perspective. The real test is whether the Pico drops into the breadboard without forcing.

3. The bicolor LED

An LED only lets current through one way, and it must never be wired alone: without a resistor, the current is only limited by the chip, and something burns.

Ohm's law. The Pico outputs 3.3 V. The LED drops about 1.9 V. The resistor takes the rest:

I = (3.3 V − 1.9 V) / 330 Ω ≈ 4 mA

4 mA is bright enough, and easy on a Pico pin.

Find the legs. The longest leg is the common cathode (−). The medium one is red, the shortest green. To check: multimeter in diode mode, black probe on the common leg, red probe on another leg. That colour lights up faintly.

The bicolor LED circuit: each colour through its own 330 Ω resistor, the common leg to ground

Wire it (15 min)

Pico pin Through LED leg
GP14 330 Ω red
GP15 330 Ω green
GND — common

Test. In the Shell:

from machine import Pin
red = Pin(14, Pin.OUT)
green = Pin(15, Pin.OUT)
red.value(1)

Red lights up. green.value(1) adds green: both together make a (very red) orange. You can measure it: about 1.9 V across the LED, 1.2 V across the resistor.

4. The RC522 badge reader

The RC522 powers a badge by radio and reads its UID, a 4-byte serial number. It talks to the Pico over SPI: a clock, a wire each way, and a chip-select. The screen will use I2C, and the RS-485 module a UART:

SPI, I2C and UART side by side

The RC522 with its header soldered pins-down, connected with Dupont wires

Solder the header (15 min). Pins go through from the component side, long end down, soldered on the back. Tape holds the header square while you do the first pin.

Wiring

RC522 Pico
SDA (chip select) GP17
SCK GP18
MOSI GP19
MISO GP16
IRQ not connected
GND GND
RST 3.3V (or GP20)
3.3V 3V3

3.3 V only

The RC522 is a 3.3 V part. Never feed it 5 V.

Install the driver (5 min)

The driver comes from danjperron/micropython-mfrc522 (MIT licence). The original has two bugs, so use the fixed copy:

  1. Download mfrc522.py.
  2. In Thonny, File → Open → This computer, open it, then Save as… → Raspberry Pi Pico, name it mfrc522.py.

The two fixes, four lines in all, are described at the top of the file:

  • One byte, not two. The driver built each byte with b'%c' % value. On MicroPython 1.29 that turns any byte from 0x80 up into two bytes, and the RC522 receives garbage. It now uses bytes([value]).
  • A wait loop that stops. The loop that waits for the RC522 used ~, a bitwise not, as a logical not. It ignored the chip's timer and spun 2000 register reads whenever no badge was present, blocking the Pico for a long moment. It now stops on the timer.

Test: read a badge.

from mfrc522 import MFRC522
import time

reader = MFRC522(spi_id=0, sck=18, mosi=19, miso=16, cs=17, rst=20)
previous = []
misses = 0
while True:
    reader.init()
    status, _ = reader.request(reader.REQIDL)
    if status == reader.OK:
        status, uid = reader.SelectTagSN()
    if status == reader.OK:
        misses = 0
        if uid != previous:
            print("Badge:", reader.tohexstring(uid))
            previous = uid
    else:
        misses += 1
        if misses > 10:
            previous = []
    time.sleep_ms(50)

Hold a badge above the antenna. Its UID prints once, for example [0x6B, 0xAD, 0x30, 0x8E]. The misses counter matters: the RC522 misses some reads even with the badge in place, so a badge is only forgotten after several misses in a row.

5. The keypad

The keypad, front

16 keys, but only 8 wires: the keys sit at the crossings of 4 rows and 4 columns. Pressing a key connects its row to its column.

Solder it (20 min). This keypad is single-sided: there is no copper on the back, so the pins are soldered on the key side.

The back of the keypad: bare board, no pads

Once soldered, the pins were too short to hold a female Dupont. Half a male-male Dupont wire soldered onto each pin makes a flexible cable.

The keypad with its header soldered

Map the connector (15 min). The contacts are conductive rubber, so continuity mode stays silent. Use the Ω range instead: hold a key, probe two connector pins, and look for a low reading. On this keypad:

Connector pin 1 2 3 4 5 6 7 8 9 10
Role — C1 C2 C3 C4 L1 L2 L3 L4 —

Wiring

Keypad L1 L2 L3 L4 C1 C2 C3 C4
Pico GP9 GP8 GP7 GP6 GP13 GP12 GP11 GP10

How the scan works. Rows are outputs, held at 1. Columns are inputs with a pull-up, so they read 1 at rest. To test a row, drive it to 0 and read the columns: a column reading 0 has a pressed key on that row.

Keypad scan: row L2 driven low, key 5 pressed, column C2 reads 0

from machine import Pin
import time

KEYS = [
    ["1", "2", "3", "A"],
    ["4", "5", "6", "B"],
    ["7", "8", "9", "C"],
    ["*", "0", "#", "D"],
]

rows = [Pin(n, Pin.OUT, value=1) for n in (9, 8, 7, 6)]
columns = [Pin(n, Pin.IN, Pin.PULL_UP) for n in (13, 12, 11, 10)]


def pressed_key():
    found = None
    for row, labels in zip(rows, KEYS):
        row.value(0)
        for column, label in zip(columns, labels):
            if column.value() == 0:
                found = label
        row.value(1)
    return found


previous = None
while True:
    key = pressed_key()
    if key is not None and key != previous:
        print("Key:", key)
    previous = key
    time.sleep_ms(20)

Test. All 16 keys print their own name, once per press. The 20 ms pause is the debounce: a contact bounces for a few milliseconds when it closes, and sampling slower than that sees one clean press.

6. The buzzer

The Murata PKM22EPP-40 piezo buzzer

This buzzer is passive: it has no oscillator inside. The Pico makes the sound itself with PWM, switching a pin on and off thousands of times per second. It is loudest at its resonant frequency, 4 kHz.

PWM at 4 kHz: a 50 % duty cycle is loud, 5 % is quiet

Wiring: GP0 → 220 Ω → one leg, other leg → GND. The resistor limits the current spikes a piezo draws on each edge.

Test.

from machine import Pin, PWM
import time

buzzer = PWM(Pin(0))
buzzer.freq(4000)
buzzer.duty_u16(32768)
time.sleep(0.3)
buzzer.duty_u16(0)

duty_u16(32768) keeps the pin high half of the time. Try freq(2000) or duty_u16(3000) and hear the difference.

7. The OLED screen

A 128×64 pixel screen, driven over I2C: two wires, data (SDA) and clock (SCL), shared by any number of devices, each with an address.

Labels vary

On this module, VDD means VCC (power) and SCK means SCL (clock).

Wiring: SDA → GP26, SCK → GP27, VDD → 3V3, GND → GND.

Find it on the bus.

from machine import Pin, I2C
i2c = I2C(1, sda=Pin(26), scl=Pin(27))
print(i2c.scan())

[60] means one device at address 0x3C: the screen.

Install the driver. Save ssd1306.py, from micropython-lib, on the Pico under the same name, the same way as the RC522 driver.

Test.

from machine import Pin, I2C
from ssd1306 import SSD1306_I2C

screen = SSD1306_I2C(128, 64, I2C(1, sda=Pin(26), scl=Pin(27)))
screen.fill(0)
screen.text("Warden", 0, 0)
screen.text("Show your badge", 0, 24)
screen.show()

8. Everything on one breadboard

With five modules on Dupont wires next to the Pico, the prototype was a tangle. This part moves everything onto one large breadboard, one module at a time, with a test after each.

Full breadboard layout

How to read the breadboard

Breadboard anatomy: rails along the edges, 5-hole strips on each side of the channel

  • Rows are numbered 0 to 62 along the top. Use the numbers printed at the top, read upright. The bottom ones are upside down and reversed.
  • Columns are lettered A to E (top half) and F to J (bottom half), read on the left.
  • One row, one half: one strip. The 5 holes A–E of a row are connected. So are F–J. The two halves are not connected.
  • Rails run along the edges: + (red line) and − (blue line). On this board they are split in the middle and stop at row 60.

The Pico sits across the centre channel, columns C and H, rows 1 to 20, USB on the left. On the H side (BOOTSEL side), the pin number is the row number. On the C side, the pin number is 41 minus the row.

Count on the board, not on a photo

Every placement error in this build came from counting holes on a photo. Count on the breadboard itself, with a finger, from a known landmark such as the last pin of the Pico.

The design rules

The Pico is a wall. It covers columns C to H on rows 1 to 20. Only two columns stay free on each side (A–B, I–J). Every signal leaves through them.

Signals travel in ribbons. Each signal wire starts at column A (or J), rises onto the smooth strip between the column and the rail, runs along the board, and drops down to its module. Laid side by side, they form a ribbon: 6 wires along the top, 4 along the bottom.

Laying a ribbon: the first wire is closest to column A, each next one lies outside

Ribbon rules

  1. Short power wires go in first. The ribbons lie over them.
  2. Flatten the rail bridges so the ribbon can lie over them.
  3. Lay the wire closest to the column first. Each next wire lies outside the previous one.
  4. Cut each wire to length, strip 5 mm at each end, bend it square at every corner. Dry-fit before cutting.
  5. A few crossings are fine. The last wire laid goes over.

Pins were chosen so wires barely cross. That is why the buzzer is on GP0, the RS-485 link on UART1, and the RC522's RST is tied to 3.3 V instead of a GPIO: each of those removed several crossings.

Two modules are planted upside down. Their boards would otherwise cover the ribbons. The RC522's antenna hangs past the bottom edge, away from the metal strips that would shorten its range: prop it up. The screen's board hangs past the top edge, and two commands flip the display back.

Colour code: red for 3.3 V, black for ground, green for every signal.

Final pin map

Function Pico pins
Bicolor LED GP14 red, GP15 green
RC522 (SPI0) GP16 MISO, GP17 SDA, GP18 SCK, GP19 MOSI. RST tied to 3.3 V
Keypad GP9–GP6 rows L1–L4, GP13–GP10 columns C1–C4
Buzzer (PWM) GP0
OLED (I2C1) GP26 SDA, GP27 SCL
MAX3485 (UART1) GP4 TX, GP5 RX, GP2 transmit enable
Door pulse GP28 (added in part 10)
Free GP1, GP3, GP20, GP21, GP22

Step 1: rails and power (30 min)

  1. Bridge each rail across its split, near rows 29 to 32: red on +, black on −.
  2. Continuity test: each rail beeps end to end. + and − never beep together.
  3. Plant the Pico: columns C and H, rows 1 to 20, USB on the left.
  4. Power wires:
Wire From To
red A5 (3V3) top rail +
black A3 (GND) top rail −
black J3 (GND) bottom rail −
red top rail +, row 37 bottom rail +, row 37, straight across

Test: about 3.3 V between + and −, on each rail, on both sides of the split.

Step 2: the LED (15 min)

What Holes
LED red, common, green F26, F27, F28
330 Ω, red H23–H26
330 Ω, green H28–H31
green wire, GP14 J19–J23
green wire, GP15 I20–I31
black wire, common J27 → bottom rail −

Power, LED and resistors on the big breadboard

Test: the LED blinks red then green.

Step 3: the keypad (10 min)

The only module left on Dupont wires: it belongs on a front panel.

Keypad L4 L3 L2 L1 — C4 C3 C2 C1
Hole J9 J10 J11 J12 row 13 empty (GND) J14 J15 J16 J17

Test: the scan program prints all 16 keys.

Step 4: the MAX3485 (30 min)

The MAX3485 turns the Pico's UART into RS-485: a voltage difference between two wires, A and B, that carries hundreds of metres and shrugs off noise. It does not know OSDP. It only converts.

From the Pico's UART to the controller: the MAX3485 drives A and B as mirror images

MAX3485, component side MAX3485, back side with the pin names

First, check the pin roles (10 min). TXD and RXD do not say which way data flows. The chip does: leg 1 (RO) outputs what comes from the bus, leg 4 (DI) takes what goes to the bus, legs 2 and 3 set the direction.

MAX3485 continuity test

All three tests beep: TXD goes to the Pico's RX, RXD comes from the Pico's TX.

Plant it. Its two headers face each other 6 holes apart, so it straddles the channel like the Pico. Components up, 3-pin header towards the top.

What Holes
GND, TXD, RXD, VCC, EN F32–F36
B, A, GND B33–B35
black wire J32 → bottom rail −
red wire J35 → bottom rail +

Bottom ribbon, in laying order:

Wire From To
1. GP5 (RX) ← TXD J7 J33
2. GP4 (TX) → RXD J6 J34
3. GP2 → EN J4 J36

The MAX3485 straddling the channel, the bottom ribbon in place

Test: about 3.3 V between H35 and H32. The module's red LED is on.

The meter reads 0 V

Push the probe deeper, down to the metal strip inside the hole.

Step 5: the buzzer (20 min)

Planted directly: one leg in the last hole of the bottom − rail (row 60), the other in J60. Its legs are 4 holes apart, which is exactly the distance from J to the outer rail.

What Holes
buzzer bottom rail − (row 60) and J60
220 Ω H55–H60
4th bottom ribbon wire, GP0 J1 → J55, outside the MAX3485 wires

The buzzer at the far end, its ribbon wire running along the bottom edge

Test: the beep program, with PWM(Pin(0)).

Step 6: the RC522 (30 min)

Upside down in column E, rows 41 to 48, edge to edge with the MAX3485. Seen from above, the pins read 3.3V, RST, GND, IRQ, MISO, MOSI, SCK, SDA from left to right.

What Holes
RC522 E41–E48
red wire, 3.3 V A41 → top rail +
black wire, ground A43 → top rail −
red wire, RST to 3.3 V B41–B42

Top ribbon, first four wires, in laying order:

Wire From To
1. GP16 → MISO A20 A45
2. GP17 → SDA A19 A48
3. GP18 → SCK A17 A47
4. GP19 → MOSI A16 A46

The RC522 planted upside down, its antenna past the edge of the board

With RST tied high, the RC522 stays on, and the driver resets it by command at every init(). The code still says rst=20: GP20 is simply not connected anymore.

Test: the badge program prints your UID. Range here: about 4 cm, against 5 cm off the board.

Step 7: the screen (20 min)

Upside down in column B, rows 59 to 62, its board over the top rails. Seen from above: SDA, SCK, VDD, GND.

What Holes
screen B59–B62
red wire A61 → last hole of the top rail +, bent
black wire A62 → last hole of the top rail −, bent
5. GP26 → SDA A10, drops at row 53 into column D, ends at D59
6. GP27 → SCK A9, drops at row 54 into column C, ends at C60

Lay the power wires before planting the screen: they slide under its board.

Test: i2c.scan() returns [60]. Then flip the display:

screen.write_cmd(0xA0)
screen.write_cmd(0xC0)

The first command mirrors left and right, the second top and bottom.

9. The full program

One loop handles both the keypad and the badge. The PIN is 1234, and one badge is known.

flowchart TD
    S["Show 'Enter PIN or show badge'"] --> L{"Every 20 ms"}
    L --> K["Scan the keypad"]
    K -->|"digit"| T["Add it to the PIN, show a star"]
    K -->|"#"| D["decide(PIN == 1234)"]
    K -->|"*"| C["Clear the PIN"]
    L --> P{"100 ms since the last poll?"}
    P -->|"yes"| R["Ask the RC522 for a badge"]
    R -->|"new badge"| B["decide(badge is known)"]
    R -->|"no badge 5 times"| F["Forget the last badge"]
    D --> G["Granted: green, long beep"]
    D --> X["Denied: red, three beeps"]
    B --> G
    B --> X
from machine import Pin, PWM, I2C
import time
from mfrc522 import MFRC522
from ssd1306 import SSD1306_I2C

KEYS = [
    ["1", "2", "3", "A"],
    ["4", "5", "6", "B"],
    ["7", "8", "9", "C"],
    ["*", "0", "#", "D"],
]
PIN_CODE = "1234"
KNOWN_BADGE = [0x6B, 0xAD, 0x30, 0x8E]
BADGE_POLL_MS = 100
BADGE_GONE_AFTER_MISSES = 5

rows = [Pin(n, Pin.OUT, value=1) for n in (9, 8, 7, 6)]
columns = [Pin(n, Pin.IN, Pin.PULL_UP) for n in (13, 12, 11, 10)]
red = Pin(14, Pin.OUT)
green = Pin(15, Pin.OUT)
buzzer = PWM(Pin(0))
buzzer.freq(4000)
reader = MFRC522(spi_id=0, sck=18, mosi=19, miso=16, cs=17, rst=20)
screen = SSD1306_I2C(128, 64, I2C(1, sda=Pin(26), scl=Pin(27)))
screen.write_cmd(0xA0)
screen.write_cmd(0xC0)


def pressed_key():
    found = None
    for row, labels in zip(rows, KEYS):
        row.value(0)
        for column, label in zip(columns, labels):
            if column.value() == 0:
                found = label
        row.value(1)
    return found


def read_badge():
    reader.init()
    status, _ = reader.request(reader.REQIDL)
    if status == reader.OK:
        status, uid = reader.SelectTagSN()
    return uid if status == reader.OK else None


def beep(milliseconds):
    buzzer.duty_u16(32768)
    time.sleep_ms(milliseconds)
    buzzer.duty_u16(0)


def beeps(count, milliseconds):
    for _ in range(count):
        beep(milliseconds)
        time.sleep_ms(milliseconds)


def display(*lines):
    screen.fill(0)
    for number, line in enumerate(lines):
        screen.text(line, 0, number * 16)
    screen.show()


def show_idle():
    display("Enter PIN", "or show badge")


def grant_access():
    display("Access granted")
    green.value(1)
    beep(600)
    time.sleep_ms(900)
    green.value(0)


def deny_access():
    display("Access denied")
    red.value(1)
    beeps(3, 120)
    time.sleep_ms(800)
    red.value(0)


def decide(allowed):
    if allowed:
        grant_access()
    else:
        deny_access()
    show_idle()


typed = ""
previous_key = None
previous_badge = None
misses = 0
last_poll = time.ticks_ms()
show_idle()

while True:
    key = pressed_key()
    if key is not None and key != previous_key:
        beep(40)
        if key == "#":
            decide(typed == PIN_CODE)
            typed = ""
        elif key == "*":
            typed = ""
            show_idle()
        else:
            typed += key
            display("PIN:", "*" * len(typed))
    previous_key = key

    if time.ticks_diff(time.ticks_ms(), last_poll) >= BADGE_POLL_MS:
        last_poll = time.ticks_ms()
        uid = read_badge()
        if uid is None:
            misses += 1
            if previous_badge is not None and misses > BADGE_GONE_AFTER_MISSES:
                print("Badge removed")
                previous_badge = None
        else:
            misses = 0
            if uid != previous_badge:
                previous_badge = uid
                print("Badge:", reader.tohexstring(uid))
                decide(uid == KNOWN_BADGE)
                typed = ""

    time.sleep_ms(20)

What to notice

  • One loop, two inputs. The keypad is read every 20 ms. The RC522 is slower, so ticks_diff limits it to every 100 ms.
  • decide is the only place that grants or denies, whether the request came from the keypad or a badge. In wardn, that is exactly the part that moves to the controller.
  • A badge left on the reader triggers once. It is forgotten after half a second without a read.
  • Why not use the Pico's second core? The loop is busy less than 10 % of the time, and two cores sharing the screen and buzzer would need locks. One loop is simpler and fast enough.

Test

  1. 1234#: green, long beep, "Access granted".
  2. Any other code: red, three beeps, "Access denied". * clears.
  3. The known badge is granted, another badge is denied.

10. Open the door

A reader that says "Access granted" and opens nothing is not much use. Here, the reader sends a pulse: one pin goes to 3.3 V while the green LED is on, then back to 0 V.

The pulse goes to a second breadboard that plays the door. That door has its own build log, 20. A DIY door: it will open either on this direct pulse or on an OSDP command. This part only builds the reader's side.

The door pulse: 0 V at rest, 3.3 V for about 1.5 s after an accepted PIN

Two wires (10 min). GP28 is free, and the pin next to it is a ground. Both are on the C side of the Pico:

Wire Hole Pico pin
yellow, signal A7 pin 34, GP28
black, ground A8 pin 33, GND

The ground travels with the signal. A voltage is always measured between two points, so the door needs the reader's ground to make sense of its 3.3 V.

The code (10 min). In the full program, add the pin and raise it in grant_access():

door = Pin(28, Pin.OUT, value=0)


def grant_access():
    display("Access granted")
    green.value(1)
    door.value(1)
    beep(600)
    time.sleep_ms(900)
    door.value(0)
    green.value(0)

value=0 matters: the door stays closed from power-up until someone is accepted.

Test: meter on 20 V DC between the two free ends. 0 at rest, about 3.3 after 1234#, still 0 after a wrong code.

One hand for the meter, one for the keypad

Plant both free ends in a small spare breadboard. The probes stay in by themselves.

11. The bus: plain text both ways

Before OSDP, a simpler test: plain text over the bus, first from the Pico to the laptop, then back. It proves the wires, the transmit-enable pin and the speed.

Wire the bus (15 min). Three male-male Dupont wires, from the breadboard to the adapter's screw terminals, adapter unplugged:

Wire Hole Adapter
green, A A34 A+
white, B A33 B-
black, ground A35 GND

There is no official colour standard for RS-485 or OSDP. Green and white for data is a habit inherited from Wiegand readers.

Find the port. Run ls /dev/tty.* before and after plugging the adapter into the Mac. The new line is the adapter. Here it was /dev/tty.usbmodem5ACC0227761: the long number is the adapter's serial. The Pico itself shows up as another usbmodem, with a short number.

flowchart LR
    subgraph Mac
        T["Thonny"]
        S["screen / picocom<br/>or controller.py"]
    end
    subgraph Pico
        PR["print()"]
        U["uart.write()"]
    end
    PR -- "USB: tty.usbmodem1101" --> T
    U -- "GP4 → MAX3485 → A/B → adapter<br/>tty.usbmodem5ACC0227761" --> S

Two ports, two roads. print() goes over USB to Thonny. uart.write() goes over the bus, and only a program reading the adapter's port sees it. That is the controller's seat.

From the Pico to the Mac (15 min). The Pico raises GP2 to talk, writes, waits for the last byte to leave, and lowers GP2 to listen again:

from machine import Pin, UART
import time

uart = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5))
transmit_enable = Pin(2, Pin.OUT, value=0)


def send(text):
    transmit_enable.value(1)
    uart.write(text)
    uart.flush()
    transmit_enable.value(0)


count = 0
while True:
    print("sent hello", count)
    send("hello {}\r\n".format(count))
    count += 1
    time.sleep(1)

On the Mac, read the adapter's port:

picocom -b 9600 /dev/tty.usbmodem5ACC0227761

screen /dev/tty.usbmodem5ACC0227761 9600 does the same and is already installed. Quit picocom with Ctrl-A then Ctrl-X, screen with Ctrl-A, K, Y.

Test: hello 0, hello 1… scroll, one per second.

Garbage, one burst per second

A and B are crossed: the signal arrives upside down. Swap the green and white wires. Some vendors call A what others call B. Here, the first wiring was right, and swapping it caused the garbage.

From the Mac to the Pico (15 min). The reverse: what you type in picocom shows on the OLED, and Enter clears it.

from machine import Pin, UART, I2C
from ssd1306 import SSD1306_I2C

uart = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5))
transmit_enable = Pin(2, Pin.OUT, value=0)
screen = SSD1306_I2C(128, 64, I2C(1, sda=Pin(26), scl=Pin(27)))
screen.write_cmd(0xA0)
screen.write_cmd(0xC0)


def display(text):
    screen.fill(0)
    screen.text("Received:", 0, 0)
    screen.text(text[-16:], 0, 24)
    screen.show()


received = ""
display(received)
while True:
    if uart.any():
        for byte in uart.read():
            received = "" if byte == 13 else received + chr(byte)
        display(received)

Test: letters typed on the Mac appear on the reader's screen.

12. Talking OSDP

The rule never changes: the controller talks, the reader answers. The reader never speaks first. Each controller message is a command, each reader message is a reply, and both travel as the same kind of frame.

Anatomy of an OSDP frame: SOM, address, length, control, code, data, CRC

Two files do the work, both in All the code. The reader also needs the fixed RC522 driver from part 4: the original one is too slow to answer in time.

  • osdp.py builds and reads frames. The same file runs on the Pico and on the Mac, and it produces the same bytes as edge/wardn-osdp, the real controller's OSDP code. Its CRC is checked against the standard's check value.
  • controller.py plays the controller from the Mac, through the adapter. Plain Python 3, nothing to install.

Copy osdp.py onto the Pico with Thonny (File → Open → This computer, then Save as… → Raspberry Pi Pico). Close picocom before running python3 controller.py: a serial port opens only once, and the second program gets Resource busy.

sequenceDiagram
    participant C as Controller (Mac)
    participant R as Reader (Pico)
    C->>R: ID
    R-->>C: PDID: who I am
    C->>R: CAP
    R-->>C: PDCAP: what I can do
    loop every second
        C->>R: POLL
        alt nothing new
            R-->>C: ACK
        else badge waiting
            R-->>C: RAW: 6B AD 30 8E
        else keys waiting
            R-->>C: KEYPAD: 1234#
        end
    end

Step 1: POLL and ACK (30 min)

The smallest possible conversation. The heart of the reader is a table: a command comes in, a reply goes out. An unknown command gets NAK with reason 3, "unknown command".

REPLIES = {
    osdp.POLL: (osdp.ACK, b""),
}


def answer(command):
    return REPLIES.get(command.code, (osdp.NAK, bytes([3])))


buffer = b""
while True:
    if uart.any():
        buffer += uart.read()
    command, buffer = osdp.parse(buffer)
    if command is not None and command.address == 1:
        code, data = answer(command)
        send(osdp.reply(1, command.sequence, code, data))

Test:

-> 53 01 08 00 04 60 ba 00
<- ACK, sequence 0
-> 53 01 08 00 05 60 8b 33
<- ACK, sequence 1

The sequence runs 0, then 1, 2, 3 and round again. 0 tells the reader the controller just started.

Step 2: ID and CAP (20 min)

When a controller discovers a reader, it asks two questions. ID: who are you? CAP: what can you do? Two more lines in the table:

IDENTITY = bytes([0x00, 0x00, 0x00, 1, 1]) + unique_id()[-4:] + bytes([0, 1, 0])
CAPABILITIES = bytes([
    3, 1, 0,
    4, 4, 1,
    5, 2, 1,
    6, 1, 1,
    8, 1, 0,
    10, 128, 0,
])

REPLIES = {
    osdp.POLL: (osdp.ACK, b""),
    osdp.ID: (osdp.PDID, IDENTITY),
    osdp.CAP: (osdp.PDCAP, CAPABILITIES),
}

The serial number comes from the chip: every RP2040 has a unique ID burned in. The vendor is 00 00 00, because a real vendor has an assigned number and a hobbyist does not. Each capability is three bytes: function, level, count.

Test:

<- PDID, sequence 0: vendor 00 00 00, model 1, version 1, serial 1c879238, firmware 0.1.0
<- PDCAP, sequence 1:
     card data format: compliance 1, count 0
     reader LED control: compliance 4, count 1
     reader buzzer: compliance 2, count 1
     reader text output: compliance 1, count 1
     check character support: compliance 1, count 0
     receive buffer size: compliance 128, count 0

The serial 1c879238 is the same chip ID the Mac shows as the Pico's USB serial, read backwards.

Step 3: badge and keypad go up (30 min)

Since the reader never speaks first, a badge or a key waits in a queue until the next POLL. The reader then answers with the event instead of ACK:

  • RAW (0x50): a badge. Reader number, format (0 = raw bits), bit count, then the badge's UID.
  • KEYPAD (0x53): keys. Reader number, key count, then the keys as text.

The reader no longer knows whether the PIN is right or the badge is known. PIN_CODE, KNOWN_BADGE and decide() are gone: that is the controller's job now. It still beeps on each key, like every reader on the market.

def answer(command):
    global pending_keys
    if command.code == osdp.POLL and pending_badges:
        return badge_reply(pending_badges.pop(0))
    if command.code == osdp.POLL and pending_keys:
        keys, pending_keys = pending_keys, ""
        return keys_reply(keys)
    return REPLIES.get(command.code, (osdp.NAK, UNKNOWN_COMMAND))


def handle(command):
    global last_sequence, last_reply
    repeated = command.sequence == last_sequence and command.sequence != 0
    if not repeated:
        code, data = answer(command)
        last_reply = osdp.reply(ADDRESS, command.sequence, code, data)
    last_sequence = command.sequence
    send(last_reply)

handle() covers a detail of the protocol. When the controller sends the same sequence number twice, its reply got lost. The reader sends its last reply again, without taking the next event from the queue. Without it, a lost badge would never reach the controller.

Test:

<- KEYPAD, sequence 3: reader 0, keys 1
<- KEYPAD, sequence 1: reader 0, keys 2
<- RAW, sequence 2: reader 0, format 0, 32 bits: 6B AD 30 8E

Replies one POLL late, keys lost

With the original RC522 driver, only one key in four reached the controller, and each reply carried the previous command's sequence number. The reader answered after more than 200 ms, OSDP's limit, which the real controller (wardn-edge) also enforces, so the controller dropped the late replies and the keys in them. The driver's wait loop spun while no badge was present. With the fixed driver, every key arrives.

Step 4: the controller decides (30 min)

The last piece: the controller answers. On a good PIN or a known badge, it sends three commands, the same three the real controller sends:

Command Granted Denied
LED (0x69) green for 2 s red for 2 s
BUZ (0x6A) one short beep three long beeps
TEXT (0x6B) "Access granted" "Access denied"

It also shows PIN: *** while the PIN is typed, and puts "Badge or PIN" back 2 seconds after a decision. The reader only obeys.

The LED and the buzzer now run in the background. Three long beeps last more than a second, and a reader that blocks while beeping misses the 200 ms limit. So the buzzer only remembers when to switch next:

class Buzzer:
    def command(self, data):
        tone, on, off, count = data[1], data[2], data[3], data[4]
        self.on_ms, self.off_ms = on * 100, off * 100
        self.edges_left = 2 * count if tone == osdp.TONE_DEFAULT else 0
        self.next_edge = time.ticks_ms()

    def refresh(self):
        if self.edges_left > 0 and time.ticks_diff(time.ticks_ms(), self.next_edge) >= 0:
            sounding = self.edges_left % 2 == 0
            buzzer.duty_u16(32768 if sounding else 0)
            self.next_edge = time.ticks_add(time.ticks_ms(), self.on_ms if sounding else self.off_ms)
            self.edges_left -= 1

The main loop calls refresh() every few milliseconds, between two bus reads. The full program is 12-4-controller-decides.py.

Test:

<- KEYPAD, sequence 2: reader 0, keys 4#
   decision: Access granted
-> LED 53 01 16 00 07 69 …
<- ACK, sequence 3
-> BUZ 53 01 0d 00 05 6a …
<- ACK, sequence 1
-> TEXT 53 01 1c 00 06 6b …
<- ACK, sequence 2

1234# or the known badge: green, one beep, "Access granted". Anything else: red, three beeps, "Access denied".

What the reader understands

Command Code The reader… Reply
POLL 0x60 says "nothing new", or sends a waiting event ACK, RAW, KEYPAD
ID 0x61 says who it is PDID
CAP 0x62 says what it can do PDCAP
LED 0x69 lights red, green or amber, for a set time ACK
BUZ 0x6A beeps N times, in the background ACK
TEXT 0x6B shows a line on the OLED ACK
anything else refuses, with reason 3, "unknown command" NAK

Left out on purpose:

  • OUT: opening the door is the door's job, over its own OSDP address.
  • COMSET: the reader stays at address 1 and 9600 baud.
  • LSTAT, ISTAT, OSTAT, RSTAT: the reader has no tamper switch and no inputs of its own.
  • Secure Channel (KEYSET, CHLNG, SCRYPT): AES encryption, for the Rust version.
  • File transfer, biometrics, advanced smart cards: no hardware for them. The RC522 only reads the badge's number.

13. What comes next

The reader is finished: it reads badges and PINs, and speaks OSDP to a controller that decides. The next steps grow it in two directions.

Towards production

  1. The real controller. wardn-edge on a Raspberry Pi, in place of controller.py, on the same bus.
  2. Rust firmware. The MicroPython prototype moves to Rust with embassy, reusing the wardn-osdp crate, so the reader and the controller share one implementation of the protocol. The Secure Channel comes with it.

New ways to identify someone

  1. A QR code reader. A second reader on the same bus, for visitors: a QR code on a phone instead of a badge.
  2. ANPR. A camera reads a car's number plate and hands it to the controller already read, as a credential, the same way a badge number arrives over OSDP.
  3. Virtual badges over Bluetooth Low Energy. A phone carries the badge and exchanges it with the reader over BLE, with no card at all. It needs a board with a radio, such as a Pico W: the clone used here has none.

What went wrong, and the fix

Symptom Cause Fix
The Pico does not match the shopping list A USB-C clone, no RESET button Identify the chip (RP2-B2 is an RP2040). BOOTSEL: unplug, hold, replug
No badge detected, version register reads 0x84 Driver bug: b'%c' % value makes two bytes from 0x80 up Use bytes([value])
Keys reach the controller only one time in four Driver bug: the RC522 wait loop ignores its timer and blocks the Pico when no badge is present, so replies miss the 200 ms limit The fixed mfrc522.py: the loop stops on the timer
The same badge prints over and over The RC522 misses some reads Forget a badge only after several misses in a row
Run in Thonny does not show an expression's value That only happens in the Shell Use print(...)
The keypad has no copper on the back Single-sided board Solder on the key side
Female Dupont connectors fall off the keypad Pins too short after soldering Solder half male-male Dupont wires
Continuity mode never beeps on a key Conductive rubber contacts Use the Ω range
The keypad looked scrambled One bad measurement Redo a doubtful measurement before drawing conclusions
220R printed on a resistor R stands for Ω 220 Ω
The "orange" is almost red Green is dimmer at the same current Balance with the resistor values if it matters
Breadboard numbers printed twice, once upside down Printed for either orientation Read only the top numbers and the left letters
Dupont wires everywhere The Pico only leaves two free columns per side Ribbons of solid wire along the edges, pins chosen to avoid crossings
Modules cover the ribbons Boards wider than the free space Plant them upside down, overhanging the opposite edge
A module one row off the plan The plan was drawn from a photo Count on the breadboard, then update the plan
The meter reads 0 V on a powered module Probe not touching the metal strip Push it deeper
Two usbmodem ports appear One is the Pico, one the adapter The long serial number is the adapter
screen quits at once with [screen is terminating] The port name was copied from an example Use the real name, or complete it with Tab
The bus program shows nothing in Thonny It writes to the bus, not to USB Read the adapter's port with picocom or screen
Garbage, one burst per second A and B crossed Swap green and white
Resource busy on the adapter's port picocom still had it open Close picocom first: a port opens only once
Every OSDP reply arrives one POLL late The reader answers after more than 200 ms Same cause as the lost keys: the fixed mfrc522.py

Glossary

Word Meaning
GPIO A chip pin the program can set to 3.3 V or 0 V, or read
BOOTSEL Boot mode where the Pico shows up as a USB drive to receive firmware
REPL The prompt where each line of Python runs on the Pico right away
Pull-up An internal resistor that holds an input at 1 when nothing drives it
Debounce Ignoring the few milliseconds a contact bounces when it closes
PWM Switching a pin on and off fast, to make a sound or dim a light
SPI A 4-wire bus: clock, data out, data in, chip select
I2C A 2-wire bus shared by addressed devices
UART The serial port: one wire out, one wire in
RS-485 UART over a twisted pair, as a voltage difference, over long distances
OSDP The access-control protocol spoken over RS-485 between readers and controller
UID A badge's serial number
Frame One OSDP message: start byte, address, length, control, code, data, CRC
CRC A checksum over the frame. One damaged bit and it no longer matches
POLL / ACK "Anything new?" / "Nothing new." The heartbeat of an OSDP bus
Sequence number 0 to 3, echoed in the reply. The same one twice means the reply was lost
Cold joint Solder that never wetted the metal: dull, balled, unreliable

The reader reads badges and PINs, opens a door on its own, and speaks OSDP: it reports every badge and key, and the controller answers by lighting, beeping and showing on it.