RFID-Based Access Control System

Minjae Kim · February 24, 2025

Hello and welcome! This is my first (and hopefully not last) blog post in which I will be documenting my journey through a short project developing an RFID reader.

There are two main goals for this project:

First, I want to try working on a project from the bottom up, completely from scratch, with as few tutorials, starter code, or outside help as possible. Even if it means adding unnecessary complexity or spending time inefficiently, I want this project to be a learning experience at the core.

Second, I hope to get hands-on experience with new embedded systems concepts. Particularly, I’ll be working with SPI and ESP32 microcontrollers for the first time which will expand my overall knowledge.

This post will go over the process in which I completed the project, starting from how I picked parts and assembled the device to writing the code and optimizing performance and user experience.

Table of Contents

Part 1 - Vision and Structure

Since this project is framed more as a learning experience rather than creating something useful, there won’t be as much practicality or complexity as expected.

The main jist is this: I’m going to have an RFID reader continuously poll for a tag, confirming whether each tag is valid or invalid by comparing its information with the tags in the database. If a button is tapped, the reader still polls for a tag, but will save the first tag read in the database, and if the button is held for a certain amount of time instead, we clear the database completely.

Here is a rough state diagram showcasing this.

I am slightly concerned the project is a bit too simple, so I have plans to expand by either connecting a linear actuator to enable a door-locking mechanism or connecting the board to a computer via wifi/Bluetooth and storing data there instead.

Part 2 - Determining the Parts

As for the development board, I was looking at a few options, particularly Arduino Uno, but I decided on the ESP-WROOM-32 dev board for a few reasons.

I wanted to avoid abstracting low-level concepts because I didn’t want to gloss over the fundamentals. Therefore, I plan on writing my own device drivers and SPI drivers instead of using given APIs or copying code from GitHub.

Also, this dev board was cheap but had everything required for the project. As I increase complexity in future projects, owning a board with a bit more versatility seemed beneficial.

A simple Google search gave me a short list of RDIF readers to pick from, from which I decided on the PN532. It does the job and has a lot of versatility in terms of protocols (I2C, SPI, UART).

I’ll be using the SPI protocol for the reader because I wanted to try it out it’s fast and efficient, and although it uses up a lot of pins, I had plenty to go around anyway. Additionally, since the project is very simple, I don’t have to worry about the complexity of having too many slaves.

I also needed an active buzzer for auditory feedback, as well as a simple pushbutton to allow users to interact with the board.

To put everything together I needed a breadboard, a few jumper wires, and a standard Micro USB cable for power and flashing.

(At this point in the project, I didn’t think I would need much hardware debugging. Turns out I needed a soldering kit, multimeter, and logic analyzer later.)

Assembly was as simple as expected, although I had to connect a logic analyzer for debugging later on.

(image of final setup will be placed here)

For the PN532, 4 GPIO pins (5, 18, 19, 23) were used for SPI connection (SS, CLK, MISO, MOSI). There were two optional ports, RSTO and IRQ, but I decided not to use them.

The buzzer was connected to GPIO 22 and the pushbutton was connected to GPIO 4.

The RFID reader required through-hole soldering with header pins. Good thing I had some (minimal) experience with soldering.

Part 3 - Building and Environment Setup

Before coding the actual project, I will briefly sidetrack to build and flash a provided example project on my board. This step is necessary because this is my first time building a project using ESP32 microcontrollers.

I used Expressif’s IoT Development Framework (ESP-IDF) which utilizes CMake and Ninja to build and flash projects to my board. I’ll be keeping this same setup for the rest of the project.

The example project simply blinks the on-board LEDs at even length time intervals. Here is the main function.

void app_main(void) {
    /* Configure the peripheral according to the LED type */
    configure_led();
    while (1) {
        ESP_LOGI(TAG, "Turning LED %s!", s_led_state == true ? "ON" : "OFF");
        blink_led();
        /* Toggle the LED state */
        s_led_state = !s_led_state;
        vTaskDelay(CONFIG_BLINK_PERIOD / portTICK_PERIOD_MS);
    }
}

I had many issues with drivers and imports and had to troubleshoot for a while until I fixed everything, but eventually, I got build and flash to work successfully.

(this took me 4 hours to achieve 🤦)

Now, I’ll describe how I organized the structure of the project.

main -
    Common -
        Globals.h
        HardwareConfig.h
    Reader -
        PN532Drv.h
        PN532Drv.c
        ReaderApi.h
        ReaderApi.c
    Buzzer -
        BuzzerDrv.h
        BuzzerDrv.c
    Button -
        ButtonDrv.h
        ButtonDrv.C
    Storage -
        StorageApi.h
        StorageApi.c
    main.c

For such a small project with each peripheral doing so little, I have a decent number of files and folders.

It may seem unnecessary, but organizing the project in this way helped me improve productivity.

Part 4 - Writing the Code

This is the part where I explain the drivers and firmware I coded for the project.

Since I was not familiar with the ESP-IDF framework, I had to allocate a lot of time to getting familiar. I’ll be omitting most of the boring aspects of the process (reading documentation/datasheets, debugging, etc) and focusing on showing how I structured the project, adding useful information as I go on.

Click here to access the GitHub repository for the project and here for the PN532 manual.

Since this is going to be long and text-intensive, I’ll be separating things into subparts to make things easier to navigate.

PN532Drv

The PN532Drv.c and PN532Drv.h files are used for initializing SPI connection between the ESP32 board and PN532 reader, as well as the low-level process of sending and receiving data between the two.

Initially, I attempted to use ESP-IDF’s spi_master driver, which gave me access to functions such as spi_bus_add_device and spi_device_transmit, which removed the need to manipulate individual pins or manually determine clock speed or SPI mode.

However, I couldn’t for the love of my life get the PN532 to respond correctly to commands using this driver.

This was the biggest block during the development process, and after a month of debugging with every possible method, I decided to write my own SPI driver so that I could precisely customize the connection for the reader.

Here are the write/readByte functions I have implemented.

// Writes a byte to PN532
static void SPI_writeByte(INT8U byte) {
    for (INT8U i = 0; i < 8; i++) {
        if (byte & (1 << i)) {
            gpio_set_level(READER_MOSI_PIN, 1);
        }
        else {
            gpio_set_level(READER_MOSI_PIN, 0);
        }
        gpio_set_level(READER_CLK_PIN, 1);
        ets_delay_us(50);
        gpio_set_level(READER_CLK_PIN, 0);
        ets_delay_us(50);
    }
}

 // Reads a byte from PN532
static void SPI_readByte(INT8U* byte) {
    *byte = 0;
    for (int i = 0; i < 8; i++) {
        gpio_set_level(READER_CLK_PIN, 1);
        ets_delay_us(50);
        if (gpio_get_level(READER_MISO_PIN)) {
            *byte |= (1 << i);
        }
        gpio_set_level(READER_CLK_PIN, 0);
        ets_delay_us(50);
    }
}

As you can see, I am reading the LSB first and using SPI mode 0, as defined in the user manual.

Now, if we want to send commands to the PN532, we must pack them in a frame before transmission. These frames contain data such as the length of the command, as well as checksums to ensure the data has not been corrupted. Here is the format of the information frame as well as my completed WriteCommmand function:

void PN532_WriteCommand(INT8U *cmd, INT8U cmd_length) {
    INT8U frame[cmd_length + 9];

    // Start of frame
    frame[0] = PN532_DW;                                // Direction Byte
    frame[1] = PN532_PREAMBLE;                          // Preamble
    frame[2] = PN532_STARTCODE1;                        // Startcode
    frame[3] = PN532_STARTCODE2;
    frame[4] = (cmd_length + 1);                        // Length
    frame[5] = (INT8U)(~frame[4] + 1);                  // Length Checksum
    frame[6] = PN532_TFI;                               // Data (including TFI)
    memcpy(&frame[7], cmd, cmd_length);
    frame[7 + cmd_length] = PN532_TFI;                  // Data Checksum
    for (INT8U i = 0; i < cmd_length; i++) {
        frame[7 + cmd_length] += frame[7 + i];
    }
    frame[7 + cmd_length] = (INT8U)(~frame[7 + cmd_length] + 1);
    frame[8 + cmd_length] = PN532_POSTAMBLE;            // Postamble
    // End of frame

    // Sending frame via SPI
    gpio_set_level(READER_SS_PIN, 0);
    ets_delay_us(500);
    for (int i = 0; i < cmd_length + 9; i++) {
        SPI_writeByte(frame[i]);
    }
    gpio_set_level(READER_SS_PIN, 1);
    ets_delay_us(500);
}

The length checksum is a byte that satisfies [LENGTH + LCS] = 0x00, and the data checksum satisfies [TFI + DATA1 + DATA2 + … + DCS] = 0x00.

The direction byte at the start of the frame is required when using SPI. Before every transaction (host -> PN532 or PN532 -> host), the host must send a byte that indicates the purpose of the current transaction.

When the host sends a command, the PN532 sends an ACK frame, and then a response frame, as shown below.

Because the host doesn’t know if the PN532 has a frame ready at the current moment, the host can poll by sending a single SR byte.

The PN532 will respond with a 0x01 if a response is ready, and a 0x00 if not.

Here is the function that continuously polls the PN532 until a response is ready.

static void SPI_waitForRDY(void) {
    INT8U RDY = 0x00;
    while(1) {
        SPI_writeByte(PN532_SR);
        SPI_readByte(&RDY);
        if (RDY == 0x01 || stopPolling) {
            break;
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

Once we know there is a response, we initialize a read transaction to get the response. This process of polling until the RDY byte is set, and receiving the response is contained in the ReadResponse function.

void PN532_ReadResponse(INT8U *buf, INT8U buf_length) {
    // Waiting until RDY bit is 0x01
    gpio_set_level(READER_SS_PIN, 0);
    ets_delay_us(50);
    SPI_waitForRDY();
    gpio_set_level(READER_SS_PIN, 1);
    ets_delay_us(50);

    // Receiving data via SPI
    gpio_set_level(READER_SS_PIN, 0);
    ets_delay_us(50);
    SPI_writeByte(PN532_DR);
    for (int i = 0; i < buf_length; i++) {
        SPI_readByte(&buf[i]);
    }
    gpio_set_level(READER_SS_PIN, 1);
    ets_delay_us(50);
}

The only functions that are global from this file are PN532_Init, which just initializes the GPIO pins, PN532_WriteCommand, and PN532_ReadResponse. These functions will be used by the high-level functions in ReaderApi.

That wraps up this file. Let’s look at ReaderApi next.

ReaderApi

As described in the image above, a transaction requires the PN532 to send a command, then receive an ACK/NACK signal, then receive the response. The local function Reader_SendCommand does just that.

static void Reader_SendCommand(INT8U *cmd, INT8U cmd_length, INT8U rsp_length) {
    memset(rspBuffer, 0, 50);
    PN532_WriteCommand(cmd, cmd_length);            // Sending command
    PN532_ReadResponse(rspBuffer, 6);               // Receiving ack/nack
    for (INT8U i = 0; i < 5; i++) {                 // Receiving response
        PN532_ReadResponse(rspBuffer, rsp_length + 8);
        if (rspBuffer[1] == 0xFF || rspBuffer[2] == 0xFF) {
            break;
        }
        else {
            PN532_WriteNACK();
        }
    }
    ets_delay_us(100);
}

Instead of having to pass a buffer to hold the response, created rspBuffer which is just a big array to temporarily store responses.

The PN532 occasionally failed to send a valid response when reading tags (0xAA, 0xAA, 0xAA … for some reason). By sending a NACK signal, we can prompt the reader to try sending the response again.

There are many configurations that can be made to the PN532, so the Reader_Init function sends commands to make sure the reader is in the right mode before we start reading.

void Reader_Init(void) {
    PN532_Init();
    Reader_SendCommand((INT8U[])SAMCONFIG_NORM, 4, 1);
    Reader_SendCommand((INT8U[])RFCONFIG_AUTO, 3, 1);
    // add any more config needed during startup
    memset(rspBuffer, 0, 50);
}

SAMConfiguration is used to define how to use the SAM in the board as well as whether to use the IRQ pin. I have configured to not use both.

RFConfiguration is technically not necessary but it helps prevent RF interference by checking if there is a preexisting RF field.

To start polling for a tag, we can use the InAutoPoll command that can have the PN532 wait indefinitely for a tag to be read. Once a tag is read, we can check the memory database for the tag information, manipulating the buzzer or any other peripherals as needed.

void Reader_PollTag(void) {
    Reader_SendCommand((INT8U[])INAUTOPOLL, 4, 30);

    // extracting tag information from buffer
    INT8U tagData[14];
    memcpy(tagData, rspBuffer + 5, 14);
    memset(rspBuffer, 0, 50);

    // Checking if tag is already stored in memory or not
    if (StorageRead(tagData)) {
        Buzzer_Once();
    }
    else {
        Buzzer_Twice();
    }
}

The other files (button, buzzer, storage) were all either too simple or insignificant to have to explain, so I’ll move on to the main function.

Main

The main.c file will contain the implementation of the state diagram shown above, utilizing FreeRTOS to manage processes smoothly.

It’ll contain the main function that initializes all peripherals, interrupts, and tasks, the pollingTask function that continuously polls for a tag, and the ISR for button presses.

Here is the main function:

void app_main(void) {
    Reader_Init();
    Buzzer_Init();
    Storage_Init();
    Button_Init(buttonInterrupt);
    xTaskCreate(pollingTask, "polling tag", 1024, NULL, 5, NULL);
}

Button_Init takes the ISR as an argument to initialize it while xTaskCreate initializes pollingTask.

(Because of the simplicity of this project, I'm pretty sure the RTOS functionality is technically not necessary. I decided to keep using it because I wanted to get some exposure though.)

Part 5 - Optimizing and Wrapping Up

Part 6 - End