Mechanical Door Unlocker | Wirelessly Unlock Doors From Anywhere

What's the Project?

Since I've been quite busy building my PolyCast5 multi-tool remote recently, I thought it would be cool if I could use it (or any other ESP32-based device) to wirelessly unlock doors.

Automatic Wireless Door Unlocker

The door I interact with every day requires a NFC key card to unlock and open. This is great for security, but can be quite inconvenient. There had been numerous times when I left my key card inside and got locked out. Not great! Luckily, the door can open freely from the inside, which presented an idea!

This device uses a BLDC motor, driver, and some 3D printed parts to mount along the bottom of the door and then reel in the handle like a fishing rod.



How It Works

To get this working, the biggest issue by far was achieving smooth brushless motor movement. To accomplish this, I used my ERAD2.1 driver, which is basically my ERAD2 BLDC motor driver but with an encoder added to the back. I'll post ERAD2.1 in a separate article, but for now just know it's ERAD2 with an encoder added.

For ERAD2.1 to drive, I'm using an ML5010 BLDC motor. This motor is rated at 300KV, making it a decent pick for a robotics-like application such as this. 300KV means it will theoretically achieve 300 RPM for every 1 volt supplied.

But at 12V, this is still pretty fast. It's dialed down by the SimpleFOC firmware ERAD2.1 is running, but is still too fast and will result in a low torque. To accommodate for this, we can gear it down to transform some of that rotational speed into a higher torque and physically pull down the door handle.


To do this, I'm using a 5:1 gear ratio with 3D printed helical gears.

Not bad! Then to get even more torque, the part that actually reels in the rope attached to the handle has a smaller diameter than that of it's larger gear part. This makes the 'reeling' slower, giving it even more torque to help pull down the handle.

Ironically, this 'reeling' torque was so strong it would actually occasionally break the 3D-printed handle attachment haha. Not to worry though, since the encoder attached to ERAD2.1 is 14-bit, making it so precise that it accounts for every 0.022 degrees! (360/(2^14), though more realistically ~0.05°.) This means that you can control the motor such that it starts and stops at exactly the same place each time, leading to negligible extra 'reeling' to break the handle or it's parts.



Wireless Control

With the mechatronics part done, we need a way to control it. Luckily ERAD2.1 has built-in wireless! (ERAD2 clone with an encoder.)

One of my favorite methods of wireless control is ESP-NOW, although you could definitely also set up a simple web server and control it with your phone. For quick and easy ESP-NOW capabilities, I can use my PolyCast5 multi-tool remote! It's DIY device sender capability allows for easy control of any ESP32-based project such as this (ERAD2.1's MCU is an ESP32-S3). Though, PolyCast5 is still in pre-launch, so I understand if you don't have one. (But you can sign up for the pre-launch to be notified upon release!) Though like I said, any ESP32 can work to send ESP-NOW commands! Generic tutorial here.

ESP-NOW also has a pretty good range of up to ~150m, making it a good pick. If you want to be able to unlock it while even further away (infinite distance), you can also always connect ERAD2.1 to Wi-Fi and talk to it via MQTT.



ERAD2.1

Like I mentioned previously, this device uses a custom BLDC motor driver (ERAD2.1) which is an improved version of ERAD2 by adding an encoder. 

The original ERAD2 (Everything Robotic Arm Driver v2)

Like ERAD2, ERAD2.1 will also be open-sourced in its entirety! I hope you can build some cool stuff with it, since it can drive basically any BLDC motor (<~10A) and has built-in wireless which is pretty cool. A separate article will be posted shortly with the changes made as well as all the new files. If you would like to build this project, please wait for that to be posted so you can use the new design. (I will update this when it is!)


When the new design is posted, you can order the boards easily via a PCB manufacturer such as PCBWay! They supplied the boards I used in prototyping, and they've always been great.

I've never had to question their quality and they also offer many other services such as CNC machining, 3D-printing, and PCB assembly. Since I designed with KiCad, I didn't even have to leave my design software to check out, thanks to their convenient plug-in! If not though, you can always just go to PCBWay.com, click on quick-order PCB, and upload the Gerber file for the board. Or alternatively, just go here which I have saved in my favorites bar.

Everything else you can leave as the default, unless you have any specific adjustments you want to make. (Lead-free surface finish, remove product number, etc.) You can also increase the copper thickness if you plan to use this for a high-power application like an e-bike. This will help with cooling and enable better conduction.


After that, you're good to go!



Code

To program ERAD2.1, I'm using Arduino IDE with SimpleFOC. ERAD2.1 is fully compatible with Arduino IDE thanks to it using an ESP32-S3 at the center, which has its own Arduino core. This is great because it makes ERAD2.1 super easy to program. With just ~250 lines of code you have a working wireless BLDC motor driver! Let's check it out below.

/*
 * ERAD2.1 door unlocker example program for ESP32S3-based wireless BLDC motor driver. Check it out at roboticworx.io!
 */

#include <SimpleFOC.h>
#include <SPI.h>
#include <esp_now.h>
#include <WiFi.h>

/* Pins */

// Define the BLDC motor pins
#define BLDC_PWM_UH_GPIO 7
#define BLDC_PWM_UL_GPIO 8
#define BLDC_PWM_VH_GPIO 9
#define BLDC_PWM_VL_GPIO 10
#define BLDC_PWM_WH_GPIO 11
#define BLDC_PWM_WL_GPIO 12

// TMC6200 / gate driver pins
#define DRV0_CSN_PIN 14
#define DRV1_SCK_PIN 13

// Driver enable + user LED
#define ENABLE_PIN 17
#define BLINK_PIN 38

// Encoder (AS5048A) pins
#define AS5_CLK_PIN  15 // SCK
#define AS5_MISO_PIN 16 // MISO
#define AS5_MOSI_PIN 18 // MOSI
#define AS5_CSN_PIN  21 // CS (chip select)

// Motor mechanical configuration
// CHANGE THIS: number of permanent magnets in YOUR motor divided by 2
#define POLE_PAIRS 7

/* Globals */

volatile bool toggle_request = false;
volatile unsigned int cmd_received = 0;
bool pos_state = false; // false -> pos A, true -> pos B

// Callback that triggers when data is received
void receive_cb(const esp_now_recv_info_t *info, const uint8_t *data, int len)
{
  // Optionally print the sender MAC (PolyCast5)
  Serial.print("From ");
  for (int i = 0; i < 6; i++) {
    Serial.printf("%02X", info->src_addr[i]);
    if (i < 5) Serial.print(":");
  }
  Serial.print(" | ");


  // The received data will be in the form "PolyCast5_Command_Value: %u" for security purposes
  // We need to extract the %u (unsigned int) command

  #define MAX_BUF_SIZE 65 // Maximum number of bytes the data could be
  char buf[MAX_BUF_SIZE]; // Create a buffer to store the data string

  // If the received data length is less than the max buffer size, make len the new buffer size
  size_t copy_len = len < MAX_BUF_SIZE - 1 ? len : MAX_BUF_SIZE - 1;
  memcpy(buf, data, copy_len); // Copy len bytes of the data into the buffer
  buf[copy_len] = '\0'; // Null-terminate the string

  // Try to parse "PolyCast5_Command_Value: %u" out of the string
  if (sscanf(buf, "PolyCast5_Command_Value: %u", &cmd_received) == 1) { // If success -> data is valid and the command is now stored in cmd_received
    Serial.print("Command: ");
    Serial.println(cmd_received); // The command received
  }
  else { // Data is not valid
    Serial.print("Unexpected data: ");
    Serial.println(buf);
  }

  if (cmd_received != 0) {
    toggle_request = true; // Request a toggle once
  }
}

unsigned long previousMillis = 0;
bool blinkState = true;

// Angle set point variable
float target_angle = 0; // Default target angle (radians)

// Encoder debug print timing (so we don't spam Serial and slow down FOC)
static unsigned long lastPrintMillis = 0;

/* Motor / Driver / Sensor Instances */

// BLDC motor and driver instance
BLDCMotor motor = BLDCMotor(POLE_PAIRS);
BLDCDriver6PWM driver = BLDCDriver6PWM(
  BLDC_PWM_UH_GPIO, BLDC_PWM_UL_GPIO,
  BLDC_PWM_VH_GPIO, BLDC_PWM_VL_GPIO,
  BLDC_PWM_WH_GPIO, BLDC_PWM_WL_GPIO
);

// Encoder instance (AS5048A SPI absolute magnetic encoder)
MagneticSensorSPI sensor = MagneticSensorSPI(AS5048_SPI, AS5_CSN_PIN);

// Instantiate the commander (SimpleFOC serial command helper)
Commander command = Commander(Serial);
void doTarget(char* cmd) { command.scalar(&target_angle, cmd); }

/* Setup */

void setup()
{
  // Use monitoring with serial
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);

  // Initialize ESP-NOW
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }

  // Register the receive callback
  esp_now_register_recv_cb(receive_cb);

  // If you want internal SimpleFOC debug prints, uncomment this,
  // but it can slow down loopFOC() noticeably.
  // motor.useMonitoring(Serial);

  // Configure GPIOs
  pinMode(ENABLE_PIN, OUTPUT);
  pinMode(BLINK_PIN, OUTPUT);
  pinMode(DRV0_CSN_PIN, OUTPUT);
  pinMode(DRV1_SCK_PIN, OUTPUT);

  // Set TMC6200 drive configuration (medium strength)
  // (Your board uses these lines to set configuration. Keep as-is.)
  digitalWrite(DRV1_SCK_PIN, LOW);
  digitalWrite(DRV0_CSN_PIN, HIGH);

  // Toggle reset to clear pending errors
  digitalWrite(ENABLE_PIN, LOW);
  delay(250);
  digitalWrite(ENABLE_PIN, HIGH);

  // Start SPI bus using your chosen pins
  // SPI.begin(SCK, MISO, MOSI, SS)
  SPI.begin(AS5_CLK_PIN, AS5_MISO_PIN, AS5_MOSI_PIN, AS5_CSN_PIN);

  // Initialize sensor hardware on that SPI bus
  // This sets up the driver to read angle data from the AS5048A
  sensor.init(&SPI);

  // If your motor direction is reversed (e.g. target angle increases but motor moves opposite),
  // you can flip the sensor direction:
  // sensor.direction = Direction::CW;
  // sensor.direction = Direction::CCW;

  // Link the motor to the encoder sensor
  motor.linkSensor(&sensor);

  // Power supply voltage [V]
  driver.voltage_power_supply = 24; // Change to your real supply voltage
  driver.init();

  // Link the motor and the driver
  motor.linkDriver(&driver);

  // Aligning voltage [V]
  // START SMALL THEN INCREASE IF NEEDED.
  // This is the voltage used during initial alignment routine
  motor.voltage_sensor_align = 1;

  // Index search velocity [rad/s]
  motor.velocity_index_search = 3;

  // Set motion control loop to be used
  // angle mode allows holding at a location
  motor.controller = MotionControlType::angle;

  // Velocity PI controller parameters
  // Default parameters are in defaults.h
  motor.PID_velocity.P = 0.05f;
  motor.PID_velocity.I = 1;
  motor.PID_velocity.D = 0;

  // Voltage limit [V]
  // START SMALL THEN INCREASE IF NEEDED.
  motor.voltage_limit = 3;

  // Jerk control using voltage ramp
  // Default value is 300 volts per sec ~ 0.3V per millisecond
  motor.PID_velocity.output_ramp = 100;

  // Velocity low pass filtering time constant
  motor.LPF_velocity.Tf = 0.01f;

  // Angle P controller
  motor.P_angle.P = 20;

  // Maximal velocity of the position control
  motor.velocity_limit = 150;

  // Initialize motor hardware (driver + internal state)
  motor.init();

  // Align sensor and start FOC
  // For an absolute encoder like AS5048A, this will:
  // - establish electrical angle reference
  // - ensure the motor commutation matches the encoder readings
  motor.initFOC();

  // Add target command "T"
  // Example: send "T 1.57" to target ~90 degrees (in radians)
  command.add('T', doTarget, "target angle");

  Serial.println(F("Motor ready (AS5048A encoder)."));
  delay(1000);
}

/* Loop */

void loop()
{
  unsigned long currentMillis = millis();

  // Demo motion pattern
  // Every 500ms, we change the target by "offset" radians.
  // After 18 changes, we reverse direction.
  if (currentMillis - previousMillis >= 500)
  {
    // Blink LED for heartbeat
    digitalWrite(BLINK_PIN, blinkState);
    blinkState = !blinkState;

    previousMillis = currentMillis;
  }

  // Print encoder angle every 100ms
  if (currentMillis - lastPrintMillis >= 100)
  {
    Serial.print("Encoder angle (rad): ");
    Serial.println(sensor.getAngle());
    lastPrintMillis = currentMillis;
  }

  // Main FOC algorithm function
  // The faster you run this function the better
  motor.loopFOC();

  if (toggle_request) {
    toggle_request = false;

    pos_state = !pos_state;
    target_angle = pos_state ? cmd_received : 0;

    Serial.printf("Toggled target_angle -> %.3f rad\n", target_angle);
  }

  // Motion control function
  // Velocity, position (angle), or voltage depending on motor.controller
  motor.move(target_angle);

  // User communication
  // Allows serial command parsing (e.g., "T 3.14")
  command.run();
}

Now we can break down the important parts.

The first important bit is these global variables to track what is happening. We only want the motor to move whenever a command is sent, and it is the right command. So, we can use a toggle_request variable to determine if it actually received anything as well as a cmd_received variable to hold what was sent.


Since there are only two positions (door unlocked or locked) we can use another bool pos_state to track which state should be picked.

volatile bool toggle_request = false;
volatile unsigned int cmd_received = 0;
bool pos_state = false; // false -> pos A, true -> pos B

The next important part is the callback that is executed whenever a command is received. This is important because me must verify what command was actually sent. Wouldn't want anyone to be able to unlock out door!


Though I should note that this current example does NOT use encryption. This means that anyone with an ESP32 who is also reading this article would be able to open the door. Always use encrypted ESP-NOW for sensitive applications! (This is just an example.)

// Callback that triggers when data is received
void receive_cb(const esp_now_recv_info_t *info, const uint8_t *data, int len)
{
  // Optionally print the sender MAC (PolyCast5)
  Serial.print("From ");
  for (int i = 0; i < 6; i++) {
    Serial.printf("%02X", info->src_addr[i]);
    if (i < 5) Serial.print(":");
  }
  Serial.print(" | ");


  // The received data will be in the form "PolyCast5_Command_Value: %u" for security purposes
  // We need to extract the %u (unsigned int) command

  #define MAX_BUF_SIZE 65 // Maximum number of bytes the data could be
  char buf[MAX_BUF_SIZE]; // Create a buffer to store the data string

  // If the received data length is less than the max buffer size, make len the new buffer size
  size_t copy_len = len < MAX_BUF_SIZE - 1 ? len : MAX_BUF_SIZE - 1;
  memcpy(buf, data, copy_len); // Copy len bytes of the data into the buffer
  buf[copy_len] = '\0'; // Null-terminate the string

  // Try to parse "PolyCast5_Command_Value: %u" out of the string
  if (sscanf(buf, "PolyCast5_Command_Value: %u", &cmd_received) == 1) { // If success -> data is valid and the command is now stored in cmd_received
    Serial.print("Command: ");
    Serial.println(cmd_received); // The command received
  }
  else { // Data is not valid
    Serial.print("Unexpected data: ");
    Serial.println(buf);
  }

  if (cmd_received != 0) {
    toggle_request = true; // Request a toggle once
  }
}

I should also point out that you typically shouldn't use print (Serial.print) statements in callbacks since they slow execution. Anyway, let's dive in!


In the callback, we firstly print out the sender's MAC address. This isn't required but can be useful for debugging. Then, we verify the data:

  #define MAX_BUF_SIZE 65 // Maximum number of bytes the data could be
  char buf[MAX_BUF_SIZE]; // Create a buffer to store the data string

  // If the received data length is less than the max buffer size, make len the new buffer size
  size_t copy_len = len < MAX_BUF_SIZE - 1 ? len : MAX_BUF_SIZE - 1;
  memcpy(buf, data, copy_len); // Copy len bytes of the data into the buffer
  buf[copy_len] = '\0'; // Null-terminate the string

  // Try to parse "PolyCast5_Command_Value: %u" out of the string
  if (sscanf(buf, "PolyCast5_Command_Value: %u", &cmd_received) == 1) { // If success -> data is valid and the command is now stored in cmd_received
    Serial.print("Command: ");
    Serial.println(cmd_received); // The command received
  }
  else { // Data is not valid
    Serial.print("Unexpected data: ");
    Serial.println(buf);
  }

Since I'm sending with my PolyCast5, I added a unique prefix to the command "PolyCast5_Command_Value: ". This ensures that it isn't possible to guess the correct number when encryption is enabled, or to just make sure that the door never randomly opens when a command is received. (Because the command must be prefixed with the correct string.)


This said, if you decide to try this project out, make sure to either send the command with the string or remove the string parsing part of the code!


After that, if the command is extracted successfully and it isn't a zero, set the toggle_request bool to signal that there should be a change in state.

  if (cmd_received != 0) {
    toggle_request = true; // Request a toggle once
  }

Other than that is just the typical SimpleFOC setup code for use with an encoder. Then in the main loop, we have two timers. One for blinking the on-board LED (every 500ms), and one for debugging (Serial.print every 100ms).

  if (currentMillis - previousMillis >= 500)
  {
    // Blink LED for heartbeat
    digitalWrite(BLINK_PIN, blinkState);
    blinkState = !blinkState;

    previousMillis = currentMillis;
  }

  // Print encoder angle every 100ms
  if (currentMillis - lastPrintMillis >= 100)
  {
    Serial.print("Encoder angle (rad): ");
    Serial.println(sensor.getAngle());
    lastPrintMillis = currentMillis;
  }

Then, if the toggle_request bool was set in the callback (something was received), simply flip the position state and set the new target angle to either the command received (uint8_t in radians) or 0 (starting position).


That's all!



Assembly

First, print everything out and get some M3 nuts and bolts handy. After that, you can go ahead and mount the smaller helical gear to the rotor of the ML5010 using four M3x30 bolts.

From there, go ahead and push in two M3 nuts to the back of the motor mounting piece. This is required for mounting the ERAD2.1 PCB later. 

You can then similarly do the same for the connecting bracket.

Now you can screw in the motor using four M3x12 bolts.

Before progressing, it's important to point out here that you will need a small diametrically magnetized magnet to glue to the back of the ML5010 so it can be measured. For this I just bought a cheap encoder off Amazon, then stole the magnet. Wasteful, but it works.

With that, you can go ahead and screw in ERAD2.1 using two M3x12 bolts. They should thread nicely through the nuts you pushed into the housing earlier.


For wiring up ERAD2.1, I just connected the U, V, and W phases to the motor leads sequentially. It doesn't really matter the order, since if yours is different from mine it'll just spin the other direction which can be corrected in the code. To power it all, I'm just using a basic AC adapter in which I exposed the DC ends. It only supplies 12V, but that's still plenty for just pulling a door handle down.

Then we need to mount the bearings. To do this I used a bar clamp, though a mallet would also work. It's a tight fit, but that's intentional!

You can then use some thin paracord rope and push it through the hole in the larger helical gear. If you have trouble getting it in, I would recommend pushing it down with some tweezers.

From there you can pop it into the motor casing.

And the other half!

Then to secure it, insert two M3x12 bolts (flexible sizing) through the connector bracket.

The only thing left is to connect the rope to the door handle.


To start, grab the two handle halves and push two M3 nuts into the hex indentions.

Then, snap them around the handle and screw in two M3x12 bolts (flexible sizing).

With that, you can just wrap around the paracord and tie a quick knot. 


Be sure that the rope tension is tight, since you want to make sure that the device starts reeling as soon as the motor moves without any extra slack.

That's it!



BOM (Bill of Materials)

I’ll put everything that you need to have to build the project here (besides the ERAD2.1 files which will be posted separately) so that you don’t have to go scrolling around looking for the links I sprinkled throughout the article.

Disclosure: These are affiliate links. I get a portion of product sales at no extra cost to you.



Thanks for reading! I hope this was a helpful and informative article. If you decide to do the build, please feel free to leave any questions/thoughts in the comments below. If not, I hope you were still able to enjoy reading and learn something new!


Have constructive criticism or a suggestion for a future project? I’m always looking to improve my work. Leave it in the comments! Until next time.


Be sure to follow me on Instagram! If you want to learn more cool stuff, I also highly recommend Branch Education and Kurzgesagt on YouTube. :)


If you feel this read was worth at least $5, please consider becoming a plus subscriber. I don’t use ads, so I rely on your support to keep going. This is a quick read for you, but I’ve been working on this for a while! You will also receive the following benefits.

Back to blog

Leave a comment

Please note, comments need to be approved before they are published.