ESP32 + R503 Fingerprint Reader → LOQED via ESPHome & Homey

Hey everyone,

I wanted to share a small DIY project I recently finished: a fingerprint reader built on an ESP32 with ESPHome, which unlocks my LOQED Touch Smart Lock through a Homey Flow. No cloud dependency, no proprietary app — just ESPHome talking to Homey over the native API.

What it does

  • Enroll fingerprints from a button in Homey (or from a built-in web dashboard)
  • Recognizes an enrolled finger and briefly triggers an “Access Granted” sensor
  • That sensor drives a Homey Flow that unlocks the smart lock
  • A live status log (readable/unmatched/misplaced/etc.) shows up both in Homey and on a simple web dashboard on the device’s own IP
  • Adjustable match sensitivity (the R503’s security level, 1–5) via two extra buttons, since ESPHome doesn’t expose that setting out of the box

Hardware

  • ESP32 dev board
  • Grow R503 capacitive fingerprint sensor (6-pin JST)
  • A decent 5V/2A USB power source (a laptop USB port isn’t always enough — the sensor plus WiFi TX spikes can brown out a weak supply)

Wiring

R503 pin Name ESP32 pin Notes
1 VCC 3V3 Check your module’s silkscreen — some R503 variants need 3.6–6V instead of 3.3V
2 GND GND
3 TXD GPIO16 (RX2) Crossed: sensor TX → ESP32 RX
4 RXD GPIO17 (TX2) Crossed: sensor RX → ESP32 TX
5 WAKEUP GPIO4 Important — without this wired, ESPHome’s sensing_pin gate silently skips all polling and the sensor never detects a finger at all
6 3.3VT (touch power) 3V3 Separate power line for the capacitive touch/wake circuit

That WAKEUP pin (5) tripped me up for a while — without it, the sensor compiles and boots fine, reports its capacity correctly, but simply never reacts to a finger. If you’re debugging a “nothing happens” symptom, check that wire first.

Software / setup

  1. Flash with ESPHome (python -m esphome run fingerprint-lock.yaml, USB the first time, OTA afterwards)
  2. Add each entity in Homey via the ESPHome Controller app (IP + port 6053 + the API encryption key)
  3. Build a Flow: WHEN “Access Granted” turns ON → THEN unlock the smart lock

Full yaml config, a secrets.yaml template, and a more detailed README are attached below if anyone wants to replicate this or adapt it for a different lock/sensor combo. Happy to answer questions if you get stuck on the wiring or the ESPHome side.

fingerprint-lock_14.yaml

esphome:
  name: fingerprint-lock
  friendly_name: Fingerprint Reader

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_key

ota:
  - platform: esphome
    password: !secret ota_password

logger:

web_server:
  port: 80

uart:
  id: uart_bus
  tx_pin: GPIO17
  rx_pin: GPIO16
  baud_rate: 57600

fingerprint_grow:
  id: fingerprint_reader
  uart_id: uart_bus
  sensing_pin: GPIO4        # connect to WAKEUP (pin 5) of the R503
  on_finger_scan_start:
    - text_sensor.template.publish:
        id: access_log
        state: "Finger detected, scanning..."
  on_finger_scan_matched:
    - logger.log:
        format: "Match! ID %d, confidence %d"
        args: [ 'finger_id', 'confidence' ]
    - text_sensor.template.publish:
        id: access_log
        state: !lambda |-
          char buf[64];
          snprintf(buf, sizeof(buf), "Access granted - ID %d (confidence %d)", finger_id, confidence);
          return std::string(buf);
    - binary_sensor.template.publish:
        id: access_granted
        state: ON
    - delay: 1s
    - binary_sensor.template.publish:
        id: access_granted
        state: OFF
  on_finger_scan_unmatched:
    - logger.log: "No match found"
    - text_sensor.template.publish:
        id: access_log
        state: "Unknown finger - access denied"
  on_finger_scan_invalid:
    - logger.log: "Invalid scan"
    - text_sensor.template.publish:
        id: access_log
        state: "Invalid scan - try again"
  on_finger_scan_misplaced:
    - text_sensor.template.publish:
        id: access_log
        state: "Finger misplaced - try again"
  on_enrollment_scan:
    - logger.log:
        format: "Enrollment scan, step %d"
        args: [ 'scan_num' ]
    - sensor.template.publish:
        id: enroll_status
        state: !lambda "return scan_num;"
    - text_sensor.template.publish:
        id: enroll_message
        state: !lambda |-
          char buf[64];
          if (scan_num < 2) {
            snprintf(buf, sizeof(buf), "Scan %d ok - place your finger again", scan_num);
          } else {
            snprintf(buf, sizeof(buf), "Scan %d ok - please wait...", scan_num);
          }
          return std::string(buf);
  on_enrollment_done:
    - logger.log:
        format: "Finger successfully enrolled with ID %d"
        args: [ 'finger_id' ]
    - sensor.template.publish:
        id: enroll_status
        state: 100
    - text_sensor.template.publish:
        id: enroll_message
        state: !lambda |-
          char buf[48];
          snprintf(buf, sizeof(buf), "Done! Finger enrolled (ID %d)", finger_id);
          return std::string(buf);
  on_enrollment_failed:
    - logger.log: "Enrollment failed"
    - sensor.template.publish:
        id: enroll_status
        state: -1
    - text_sensor.template.publish:
        id: enroll_message
        state: "Failed - try again"

text_sensor:
  - platform: template
    name: "Enroll Message"
    id: enroll_message
  - platform: template
    name: "Access Log"
    id: access_log

binary_sensor:
  - platform: template
    name: "Access Granted"
    id: access_granted

sensor:
  - platform: fingerprint_grow
    fingerprint_count:
      name: "Fingerprint Count"
    status:
      name: "Fingerprint Status"
    capacity:
      name: "Fingerprint Capacity"
    security_level:
      name: "Security Level"
  - platform: template
    name: "Enroll Status"
    id: enroll_status
    accuracy_decimals: 0
    icon: "mdi:fingerprint"
    # 0 = idle, 1-2 = scan X of 2 done, 100 = success, -1 = failed

button:
  - platform: restart
    name: "Restart"
  - platform: template
    name: "Enroll Fingerprint"
    on_press:
      - sensor.template.publish:
          id: enroll_status
          state: 0
      - text_sensor.template.publish:
          id: enroll_message
          state: "Starting... place your finger on the sensor"
      - fingerprint_grow.enroll:
          finger_id: 1
          num_scans: 2
  - platform: template
    name: "Cancel Enroll"
    on_press:
      - fingerprint_grow.cancel_enroll:
  - platform: template
    name: "Delete All Fingerprints"
    on_press:
      - fingerprint_grow.delete_all:
  - platform: template
    name: "Set Security Level Lenient (2)"
    on_press:
      - lambda: |-
          // Raw R503/AS608 command: PS_SetSysPara (0x0E), parameter 5 = security level, value 2
          uint8_t packet[] = {0xEF, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x05, 0x0E, 0x05, 0x02, 0x00, 0x1B};
          id(uart_bus).write_array(packet, sizeof(packet));
  - platform: template
    name: "Set Security Level Default (3)"
    on_press:
      - lambda: |-
          // Raw R503/AS608 command: PS_SetSysPara (0x0E), parameter 5 = security level, value 3
          uint8_t packet[] = {0xEF, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x05, 0x0E, 0x05, 0x03, 0x00, 0x1C};
          id(uart_bus).write_array(packet, sizeof(packet));

secrets.yaml.example

wifi_ssid: "YOUR_WIFI_SSID"
wifi_password: "YOUR_WIFI_PASSWORD"
api_key: "GENERATE_WITH: python -c \"import base64, os; print(base64.b64encode(os.urandom(32)).decode())\""
ota_password: "choose_your_own_ota_password"