RFID-Based Access Control System

Minjae Kim · February 24, 2025

Hello and welcome to 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 were two main goals for this project:

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

Second, I hoped 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, the completed prototype won’t be very practical for actual use. However, 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 ID with the tags in the database.

The user can also press (tap) a button to save the next tag ID into the database or hold the button for three seconds to clear the database completely.

Here is a rough state diagram showcasing this.

Since the project is a bit simple, I have plans to expand on it by either connecting a linear actuator to enable a door-locking mechanism or connecting the board to an external device via wifi/Bluetooth.

Part 2 - Determining the Parts

There are Arduino libraries for the RFID reader I’ll be using, but I decided to to use an ESP32 board with ESP-IDF instead.

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 bit-bang SPI (I realized bit-banging is stupid and stopped using it later on) instead of using prebuilt libraries.

A simple Google search gave me a short list of RDIF readers to pick from, from which I decided on the PN532. I liked how it had a few protocols to choose from (I2C, SPI, UART)

I used the SPI protocol 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 a single buzzer and pushbutton, breadboard, jumper wires, and a standard Micro USB cable.

(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.

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 had to briefly sidetrack to build and flash a provided example project on my board. This step was necessary because this is my first time building a project using ESP32 microcontrollers.

I used Expressif’s IoT Development Framework (ESP-IDF) to build and flash projects to my board. I kept this same setup for the rest of the project as well.

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.

PN532Drv

The PN532Drv files contain the functions that initialize SPI connection between the ESP32 board and PN532 reader, as well as sending and receiving frames.

As mentioned above, I used to use bit-bang SPI for this part of the project because I couldn’t get ESP-IDF’s SPI driver to work for the love of my life. Thank god it’s working now, so here are the finished funtions.

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
    SPI_write(frame, cmd_length + 9);
}

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 SR = PN532_SR;
    INT8U RDY = 0x00;
    while(1) {
        SPI_writeRead(&SR, 1, &RDY, 1);
        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
    SPI_waitForRDY();

    // Receiving data via SPI
    INT8U DR = PN532_DR;
    SPI_writeRead(&DR, 1, buf, buf_length);
}

The only functions that are global from this file are PN532_Init, which just initializes the SPI bus and device, PN532_WriteCommand, PN532_ReadResponse, and PN532_WriteNACK. 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 || stopPolling) {
            break;
        }
        else {
            PN532_WriteNACK();
        }
    }
    ets_delay_us(50);
}

Instead of having to pass a buffer to hold the response, I 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 (stopPolling == false) {
        Storage_Read(tagData);
    }
}

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);
    semaphoreHandle = xSemaphoreCreateBinary();

    xTaskCreate(pollingTask, "polling tag", 4096, NULL, 2, NULL);
    xTaskCreate(buttonTask, "button pressed", 2048, NULL, 3, NULL);
}

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

(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.)

pollingTask loops infinately, calling either Reader_PollTag or Reader_SaveTag based on whether the saveTag flag is set or not.

void pollingTask(void *arg) {
    while(1) {
        if (stopPolling == false) {
            if (saveTag == false) {
                Reader_PollTag();
            }
            else {
                Reader_SaveTag();
                saveTag = false;
            }
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

On press, buttonTask takes the semaphore from the buttonInterrupt ISR and stops all polling. On release, it either clears the storage or sets the saveTag flag depending on how long the button has been down for.

void buttonTask(void* arg) {
    while (1) {
        if (xSemaphoreTake(semaphoreHandle, portMAX_DELAY) == pdTRUE) {
            vTaskDelay(pdMS_TO_TICKS(5));
            // start counting time on rising edge
            if (gpio_get_level(BUTTON_PIN) == 1) {
                stopPolling = true;
                pressedTime = esp_timer_get_time();
            }
            // calculate how long button was pressed on falling edge
            else {
                pressedTime = esp_timer_get_time() - pressedTime;
                // if button was held for over 2 seconds, clear memory
                if (pressedTime > 2000000) {
                    Storage_Clear();
                }
                // if button was not held, save next tag
                else {
                    saveTag = true;
                    vTaskDelay(pdMS_TO_TICKS(10));
                }
                stopPolling = false;
            }
        }
    }
}

Here is a video demo of the reader at work.

Part 5 - Optimizing and Wrapping Up

Part 6 - End