Skip to content

← 19. A DIY reader · Contents · Next → 21. A DIY power supply

20. A DIY door

A hardware build log, written for a complete beginner, and the sequel to 19. A DIY reader. A second Raspberry Pi Pico plays a door on a half breadboard. It opens on the reader's wire or on an OSDP order from the controller, and it reports everything it does. Nothing here changes what a controller does.

A beginner's page

Same author, same rules as the reader: a beginner doing this for fun, not professional work. Every mistake along the way is listed at the end.

The finished door on its half breadboard, below the reader. The door's screen shows "DOOR 2", "OPEN 3 s" and "wired", its LED is green, and the RS-485 adapter sits on a USB hub

The reader only reports: a badge, a key. The door has a state: closed, open for a few seconds, or held open. The whole program turns around that state.

stateDiagram-v2
    direction LR
    state "Held open" as Held
    [*] --> Closed
    Closed --> Open: wire edge, or OUT pulse
    Open --> Open: another edge or pulse, 5 s again
    Open --> Closed: time is up, or OUT off
    Closed --> Held: OUT on
    Open --> Held: OUT on
    Held --> Closed: OUT off

A real door opens three ways. This one keeps two:

By Who decides What the door knows
Two wires (dry contact) the reader, on its own "open", nothing else
OSDP, at address 2 the controller "open for 5 s", "stay open", "lock", plus a line of text
The exit button (left out here) the door itself "someone is leaving"

The strike, the electric lock of a real door, is played by the Pico's own LED: lit means unlocked. A real relay would plug into the same logic later.

flowchart LR
    L["Reader, GP28"] -- "2 wires: signal + ground" --> P["Door Pico"]
    C["Controller (Mac)"] -- "RS-485 bus, address 2" --> P
    P --> G["Strike (the board's LED)"]
    P --> V["Red / green LED"]
    P --> E["Screen: state and countdown"]

How to read this page. It assumes the reader's page is done: soldering, laying a ribbon, reading a pin and speaking OSDP are not explained again. Each part ends with a test.

Contents

  1. What you need
  2. All the code
  3. Two Picos, one Mac
  4. The half breadboard
  5. Power, LED and strike
  6. The dry contact
  7. The screen
  8. Talking OSDP
  9. The power budget
  10. What comes next
  11. What went wrong, and the fix
  12. Glossary

What you need

Everything comes from the reader's shopping list (see 19. A DIY reader): most items came in packs.

Part Where from
A second Raspberry Pi Pico the reader's list. The same USB-C RP2040 clone
A half breadboard, 400 points, 30 rows an old Arduino starter kit
A second MAX3485 module the reader's list, pack of 5
A second 0.96" OLED screen the reader's list
A bicolor LED and two 330 Ω resistors the reader's packs
Solid wire and Dupont wires the reader's packs
Three lever connectors (Wago type) for the three-device bus

The Waveshare USB to RS-485 adapter from the reader plays the controller again. A small USB hub helps: the door and the adapter both plug into the Mac.

All the code

On the door, saved under the same name: the reader's osdp.py and ssd1306.py. Then one program per part:

Part Program
3 02-strike-and-led.py
4 04-wired-input.py
5 05-screen.py
6 06-osdp-door.py

On the Mac, next to the reader's controller.py and osdp.py:

Program What it does
door_controller.py Plays the controller for the reader (address 1) and the door (address 2). A known badge or PIN 1234 opens the door
door_cycle.py Talks to the door alone: held open, then locked, every 5 seconds. No reader needed

1. Two Picos, one Mac

Prepare the Pico as in parts 1 and 2 of the reader: solder both header strips, then drag the MicroPython .uf2 onto the RPI-RP2 drive with BOOTSEL held.

From now on, two Picos share the Mac. Two rules:

  1. Thonny talks to one Pico at a time. Its interpreter menu, bottom right, lists one usbmodem port per Pico. Plug the door alone the first time to learn which one it is.
  2. Thonny stops the program of the board it connects to. The reader must therefore run on its own: save its program as main.py, and keep Thonny on the door.

Label each Pico

Thonny cannot rename a port. Instead, connected to the door, type open("DOOR.txt", "w").close(). The empty file shows up in Thonny's Files pane the moment you connect, and tells you which board you are talking to.

Test: from machine import Pin; Pin(25, Pin.OUT).value(1) lights the LED on the board.

2. The half breadboard

The reader's layout needs 63 rows. The door lives on a 30-row half breadboard, and that changes three things.

The half breadboard from the Arduino kit: 30 rows, columns a to j, rails along the edges in groups of five holes

The letters run the other way. With row 1 on the left, J is at the top and A at the bottom: the reverse of the reader's board. The rails are reversed too: − outside at the top, + outside at the bottom. The plan below follows this board's own print.

The rails have gaps. Each rail has 25 holes in groups of five, and no hole faces rows 1, 7, 13, 19 and 25. A wire to a rail goes to the neighbouring hole. They run unbroken from end to end: no bridges.

The Pico takes rows 1 to 20, whole. Letting it hang off the end to save two rows does not work: the plastic runs about 1.7 holes past row 1, and pins 1, 2, 39 and 40 would rest on it. Cutting those pins was ruled out. On this board, the pin number is the row number on the C side (the BOOTSEL side), and 41 minus the row on the H side.

That leaves 10 rows for the LED, the MAX3485 and the screen. The screen alone is 27 mm wide and covers about 11 rows above the top rail, so the exit button had to go. The other choices follow from that:

  • The MAX3485 sits at the far end, rows 26 to 30, shifted three columns towards the channel so its board clears the screen's.
  • The screen is planted last, upside down, its board over the top rail and the wires laid under it. Its edge clears the Pico by a hair: dry-fit it before laying any wire.
  • The resistors go straight from the Pico's pins to the LED's legs, with no green wire.

Full layout of the door on the half breadboard

Final pin map

Function Pico pins
Bicolor LED GP14 red, GP15 green
Strike GP25, the board's own LED
Dry contact from the reader GP21, pulled down
OLED (I2C1) GP26 SDA, GP27 SCL
MAX3485 (UART1) GP4 TX, GP5 RX, GP2 transmit enable
5 V out to the reader VBUS, pin 40
Free GP22, the exit button's pin, among others

3. Power, LED and strike

Wiring (20 min)

What Holes
Pico columns C and H, rows 1 to 20, USB on the left
red, 3V3 J5 → top rail + (inner line)
black, GND J3 → top rail − (outer line)
black, GND A3 → bottom rail − (inner line)
red, rail to rail top rail +, row 24 → across row 25 → bottom rail +, row 24
LED red, common, green E21, E22, E23
330 Ω, red A19 (GP14) → A21, standing up
330 Ω, green B20 (GP15) → B23
black, common A22 → bottom rail −

Test: about 3.3 V between + and −, at the top and at the bottom.

The door opens on its own (10 min)

The first program has no input yet: the door opens for 5 seconds every 10 seconds. It introduces the heart of the project, the Door class:

class Door:
    """Closed, or open until a given time. The strike follows it."""

    def __init__(self):
        self.open_until = time.ticks_ms()

    def open_for(self, milliseconds):
        self.open_until = time.ticks_add(time.ticks_ms(), milliseconds)

    def is_open(self):
        return time.ticks_diff(self.open_until, time.ticks_ms()) > 0

Opening the door starts no timer: it writes down until when it stays open. Each turn of the loop, the strike and the LED follow is_open(). Nothing blocks, and the door closes by itself.

Test: 02-strike-and-led.py shows red at rest. Every 10 s, the LED turns green and the board's LED lights, for 5 s. If it is green at rest, turn the LED half a turn, middle leg in place.

4. The dry contact

The simplest door in the world: one wire that says "open". It is called a dry contact: no protocol, no address. The door knows neither who was accepted nor why. The reader decides, alone, as in part 10 of the reader.

The reader runs on its own (5 min)

Connect Thonny to the reader, open the reader's 10-door.py, then Save as… → Raspberry Pi Pico as main.py. Unplug and replug it: it shows "Enter PIN or show badge" without Thonny.

The wires (10 min)

Wire Reader Door
yellow, signal A7 (GP28) J14 (GP21, pin 27)
black, ground A8 J13 (GND, pin 28)
red, 5 V j2 (VSYS, pin 39) J1 (VBUS, pin 40)

The red wire is optional. It answers a practical problem: the Mac had no free port for a second Pico. The door, plugged in over USB, gets 5 V on its VBUS pin and passes it to the reader's VSYS, the Pico's power input. The reader's own regulator makes its 3.3 V from it. The black wire closes the loop: a current always needs a way back.

flowchart LR
    M["Mac USB port<br/>5 V"] -- "USB cable" --> DV["Door VBUS<br/>pin 40, J1"]
    DV --> DR["Door regulator<br/>5 V to 3.3 V"]
    DR --> D3["Door 3.3 V<br/>Pico, screen, LED, MAX3485"]
    DV -- "red wire, J1 to j2" --> RV["Reader VSYS<br/>pin 39, j2"]
    RV --> RR["Reader regulator<br/>5 V to 3.3 V"]
    RR --> R3["Reader 3.3 V<br/>Pico, RC522, screen, keypad, buzzer, MAX3485"]
    R3 -. "back to the door through the black wire, a8 to J13" .-> DV

Two rules with the 5 V wire

Check j2 twice: the reader's 3V3 is three holes further, and 5 V on a 3V3 or GP pin damages the board. And never plug the reader's own USB while the red wire is in: two 5 V sources on one Pico are best avoided. Remove the red wire first to reprogram the reader.

The code (10 min)

GP21 is pulled down: with the wire unplugged it reads 0, and the door stays closed. A loose wire must never open a door.

wired = wired_input.value() == 1
if wired and not was_wired:
    door.open_for(OPEN_MS)
was_wired = wired

The door reacts to the edge, the moment the signal goes to 1, not for as long as it stays there. The reader sends a pulse of about 1.5 s, and the door keeps its own 5 s: the door decides how long, the wire only says when.

The reader's pulse on GP21 against the door: each rising edge starts 5 s, staying high changes nothing, the door closes 5 s after the last edge

Test: with 04-wired-input.py running, 1234# on the reader turns it green and opens the door for 5 s. A wrong code leaves it closed.

No reader at hand

A short wire from the top rail +, touched against J14 for a second, plays the reader: it is the same 3.3 V. A meter in voltage mode does not work, it only listens.

5. The screen

Wiring (15 min)

Lay the wires first: the screen covers them.

What Holes
green, GP26 → SDA J10 → J21, top ribbon, closest track
green, GP27 → SCK J9 → J22, over it
red J23 → top rail +
black J24 → top rail −
screen, upside down, its board over the rail I21 to I24 (SDA, SCK, VDD, GND)

Test: from machine import I2C, Pin; I2C(1, sda=Pin(26), scl=Pin(27)).scan() returns [60].

The door fully wired, screen planted last over the top ribbon, MAX3485 at the far end

The program (5 min)

Save the reader's ssd1306.py on the door first: without it, the program stops on ImportError.

The screen shows DOOR, the state (CLOSED or OPEN 4 s) and why it opened (wired). It redraws only when something changes, and at most every 200 ms: drawing takes time, and the loop must stay free.

Test: 05-screen.py shows CLOSED. 1234# on the reader shows OPEN 5 s, OPEN 4 s… then CLOSED, with wired below.

6. Talking OSDP

The door becomes a second OSDP device, at address 2, on the same bus as the reader. The controller asks each in turn. A badge read by the reader opens the door: the architecture of a real site, and of the lab suitcase.

The MAX3485 (20 min)

What Holes
GND, TXD, RXD, VCC, EN B26 to B30
B, A, GND F27 to F29
black A26 → bottom rail −
red A29 → bottom rail +
GP5 ← TXD A7 → A27, bottom ribbon, closest track
GP4 → RXD A6 → A28
GP2 → EN A4 → A30, outermost track

Test: the module's red LED lights.

The bus with three devices (15 min)

RS-485 is a bus: every device hangs on the same three wires, A to A, B to B, ground to ground. Each has its own address, and only the one addressed answers. One lever connector per wire, three wires in each:

flowchart LR
    C["Controller<br/>Mac and USB adapter<br/>the only one to speak first"] === W["3 lever connectors<br/>A, B, ground"]
    W === R["Reader<br/>address 1"]
    W === D["Door<br/>address 2"]
Connector Adapter Reader Door
green, A A+ A34 H28
white, B B- A33 H27
black, ground GND A35 H29

Each MAX3485 carries a 120 Ω termination resistor, and the adapter may too. Three in parallel make 40 Ω, below what the standard expects. Over a metre of wire it works. If frames start to go missing, that is the first suspect.

The programs (15 min)

The reader's osdp.py now knows OUT, ISTAT and OSTAT.

  1. On the door: save osdp.py and ssd1306.py, then run 06-osdp-door.py from Thonny.
  2. On the reader: save the new osdp.py, and 12-4-controller-decides.py as main.py.
  3. On the Mac:
cd docs/assets/diy-door/code
python3 door_controller.py

Test: at startup, ID and CAP answer for reader and for door. The door announces 1 input (the wire), 1 output (the strike), 1 LED and 1 screen.

What the controller does (20 min)

On the reader Sent to the door The door
badge 1, or 1234# OUT 5 s + TEXT open 5 s, then closes by itself
7777# OUT on for good HELD OPEN, until told otherwise
0000# OUT off for good closed, and a running opening stops
another badge, another code nothing closed
sequenceDiagram
    participant R as Reader (address 1)
    participant C as Controller (Mac)
    participant D as Door (address 2)
    C->>R: POLL
    R-->>C: RAW: badge 6B AD 30 8E
    C->>R: LED green, BUZ, TEXT "Access granted"
    R-->>C: ACK, one per command
    C->>D: OUT: output 0, pulse, 50 tenths of a second
    D-->>C: ACK
    C->>D: TEXT "badge: open 5 s"
    D-->>C: ACK
    C->>D: POLL
    D-->>C: OSTATR: strike open

The opening time travels inside the command: OUT has an "on for X, then back to rest" mode. The standard counts X in tenths of a second, so 5 s is written 50.

The screen splits the work. The first three lines are the door's own: its name, its state and the reason it opened. Only the last line comes from the controller, through TEXT. The controller gives orders, the door decides what they look like.

The door reports what it did alone

An OSDP device never speaks first. About every 0.2 s, the controller sends POLL, "anything new?", and the door answers ACK, "nothing". Those quiet answers are not printed.

When the wire opens the door without asking anyone, two things change, and the door tells the next POLLs:

sequenceDiagram
    participant W as Reader's wire
    participant D as Door (address 2)
    participant C as Controller (Mac)
    C->>D: POLL
    D-->>C: ACK, nothing new
    W->>D: 3.3 V edge on GP21
    Note over D: opens on its own, for 5 s
    C->>D: POLL
    D-->>C: ISTATR: wire on
    C->>D: POLL
    D-->>C: OSTATR: strike open
    W->>D: back to 0 V, 1.5 s later
    C->>D: POLL
    D-->>C: ISTATR: wire off
    Note over D: 5 s are up, closes
    C->>D: POLL
    D-->>C: OSTATR: strike closed
door   <- ISTATR ...
   door input: wired pulse on
door   <- OSTATR ...
   door is now open

ISTATR is the state of its inputs, OSTATR the state of its outputs. Without them, the controller would never know that a door opened by another path than itself. In wardn, that is what feeds the activity log.

Test: a badge opens the door over OSDP. Touch J14 with the wire from the rail: the Mac prints wired pulse on, then door is now open, then closed 5 s later.

Without the reader: the door on a cycle (5 min)

door_cycle.py plays a controller that only talks to the door. Every 5 s it alternates OUT on (held open) and OUT off (locked), and prints what the door reports. The adapter wired straight to H27 to H29 is enough. Without the reader, door_controller.py also works, but fills the terminal with reader <- no reply.

Test: the screen alternates HELD OPEN and CLOSED, the LED green and red. Touching J14 during a locked phase still opens the door for 5 s: the controller was not asked, but it finds out.

What the door understands

Command Code The door… Reply
POLL 0x60 says "nothing new", or reports an input or output change ACK, ISTATR, OSTATR
ID, CAP 0x61, 0x62 introduces itself: 1 input, 1 output, 1 LED, 1 screen PDID, PDCAP
OUT 0x68 opens for a while (code 5), holds open (2) or locks (1) ACK
ISTAT 0x65 gives the state of the reader's wire ISTATR
OSTAT 0x66 says whether the strike is open OSTATR
LED 0x69 forces a colour for a while, then back to red or green ACK
TEXT 0x6B writes a line at the bottom of the screen ACK

Building the door corrected the real controller: wardn-osdp sent OUT with code 3 and a time in milliseconds, and BUZ with tone 1, which means "silence". Both were checked against libosdp, the reference open implementation.

7. The power budget

Both Picos run from one USB port of the Mac. Why that is fine, in numbers. These are estimates from typical datasheet values, except the one measured line.

flowchart LR
    M["Mac USB port<br/>at least 500 mA"] -- "about 85 mA in all" --> D["Door<br/>about 40 mA, estimated"]
    D -- "red wire<br/>42 to 43 mA, measured" --> R["Reader"]

What each board draws on its 3.3 V. Each Pico makes its own 3.3 V, and everything on 3V3 goes through its regulator.

Door Current
Pico, MicroPython running about 25 mA
Board LED (the strike), lit about 2 mA
Bicolor LED, one colour: (3.3 − 2.0) V / 330 Ω about 4 mA
OLED screen, depending on lit pixels 10 to 20 mA
MAX3485 at rest, with its LED about 2 mA
At rest about 50 mA
MAX3485 answering, for a few milliseconds + about 40 mA
Reader Current
Pico about 25 mA
RC522, antenna on, with its LED about 25 mA
OLED screen 10 to 20 mA
Bicolor LED about 4 mA
Piezo buzzer, keypad under 3 mA
MAX3485 at rest, with its LED about 2 mA
At rest about 80 mA
MAX3485 answering + about 40 mA

The 40 mA peak: the MAX3485 pushes about 1.5 V into the termination resistors, three 120 Ω in parallel, so 40 Ω. 1.5 V / 40 Ω ≈ 40 mA.

On the 5 V side. A regulator converts power, not current. It takes 5 V and gives 3.3 V with about 85 % efficiency (the official Pico's figure, not checked on the clone):

current at 5 V ≈ current at 3.3 V × 3.3 / (5 × 0.85) ≈ current at 3.3 V × 0.78

At rest During an OSDP reply
Door (estimated) about 40 mA about 70 mA
Reader, through the red wire about 60 mA estimated, 42 to 43 mA measured about 95 mA
Both, on the door's USB about 85 mA with the measurement about 130 mA

The peaks do not add up: on an RS-485 bus, only one device talks at a time.

Why it works:

  • A USB port must supply at least 500 mA (USB 2) or 900 mA (USB 3). 130 mA is about a quarter of 500.
  • Each Pico's 3.3 V regulator handles about 300 mA. The door draws at most 90, the reader at most 120.
  • The red wire carries only the reader's current, under 100 mA: any Dupont wire handles it.

Measure it (5 min). The meter takes the red wire's place, in series, so the reader's current flows through it:

Door J1 (5 V) ──► red probe ─[meter]─ black probe ──► Reader j2 (VSYS)

On the ANENG SZ304: selector on 200m in the A zone, SEL until DC, red lead in the BAT/mA socket, black in COM. Red probe in J1, black in j2, door on USB, reader's USB unplugged. Afterwards, put the red lead back in VΩHz at once: left in BAT/mA, the next voltage measurement is a short circuit and blows the fuse.

This only measures the reader: the door's own current enters through its USB cable and never crosses the meter. The total would need a USB tester between the Mac and the cable.

8. What comes next

  1. The exit button. Leaving must always work, even when the controller is down: a real door decides that on its own. It comes back on two wires, between GP22 (pin 29, H12) and ground.
  2. A real strike. A relay or a transistor on a free pin, in place of GP25. Only the pin number changes in the code.
  3. One port for both links. The bus's A, B and ground holes could take either the OSDP bus or the reader's two wires, the door picking the mode in its program. It needs a 10 kΩ resistor between line A and GP21: the adapter's transceiver runs on 5 V, more than a Pico pin accepts.
  4. 12 V power. A real site runs one 12 V supply along the bus, a fourth wire next to A, B and ground. Each box makes its own 5 V with a small converter into VSYS, set with a meter before it is plugged in.

What went wrong, and the fix

Symptom Cause Fix
The reader's layout does not fit A 30-row half breadboard, not a 63-row MB-102 A new plan: MAX3485 at the far end, screen planted last, no exit button
The Pico cannot hang its pins off the end The plastic runs 1.7 holes past row 1 Keep the Pico whole on rows 1 to 20, and drop the button
A rail wire has no hole to go to Rails skip rows 1, 7, 13, 19 and 25 Use the neighbouring hole
Every hole on the plan is mirrored This board letters its columns the other way Redraw the plan with the board's own letters and rails
The programs still read an exit button The button was dropped after they were written Remove it from every program, and CAP now announces 1 input
No reader at hand to test the wire A wire from the + rail touched against J14
05-screen.py stops on ImportError ssd1306.py is not on the door Save it on the door first
Two Picos, no free USB port The door's VBUS feeds the reader's VSYS, with a shared ground
The terminal fills with reader <- no reply The reader is absent, and quiet POLLs are not printed Normal. Use door_cycle.py to test the door alone
The OSDP test showed nothing new on the door The news is on the Mac side ISTATR and OSTATR tell the controller what the door did alone
The current measurement stayed at 0 200m exists in volts and in amps, AC was selected, the lead was in the wrong socket A zone, DC, red lead in BAT/mA. A voltage check (5.23 V on J1) first proved the probes were fine
"Both devices" turned out to be one measurement The meter replaced only the reader's 5 V wire Measure the total with a USB tester, or keep the door's estimate

Glossary

Word Meaning
Strike The electric lock of a door. Here, the Pico's own LED
Dry contact A wire that only says "open": no protocol, no address
Edge The moment a signal changes, as opposed to its level
Pull-down An internal resistor that holds an input at 0 when nothing drives it
VBUS Pin 40: the 5 V that arrives over USB
VSYS Pin 39: the Pico's power input, turned into 3.3 V by its regulator
In series In the current's path, as a meter must be to measure a current
Termination The 120 Ω resistor between A and B at the ends of an RS-485 bus
ISTATR, OSTATR OSDP replies with the state of a device's inputs, and of its outputs
OUT The OSDP command that drives an output: pulse, on or off

The door opens on the reader's wire or on the controller's order, shows its state and countdown, and reports every change it makes on its own, from a single USB port.