ESPHome Keypad: A Fingerprint and PIN Access Controller

Posted on 03 May 2026 by Vithurshan Selvarajah 10 min

Introduction

The problem was straightforward: I wanted a alarm arming controller that worked locally, integrated with Home Assistant natively, and didn't depend on cloud services or proprietary apps.

Everything off the shelf either locked you into an ecosystem or had poor local integration. So I built one.

This project is an ESP32-S3-based keypad that supports both PIN entry and fingerprint scanning, gives LED feedback on every interaction, backs up fingerprint templates to Home Assistant, and deploys clean firmware through ESPHome. It runs entirely on your network, with no cloud in the loop.

What the Project Is

The keypad combines three input and feedback systems into a single ESPHome device:

  • A 3×4 matrix keypad for PIN entry.
  • An R503 fingerprint sensor for biometric access.
  • A 4-LED WS2811 strip for visual feedback during interactions.

All access decisions and events surface to Home Assistant through the native ESPHome API. No MQTT, no HTTP webhooks, no middleware.

1) Why ESPHome and Not Custom Firmware

Building on ESPHome rather than writing raw Arduino or ESP-IDF firmware was a deliberate choice.

ESPHome handles WiFi reconnects, OTA updates, Home Assistant API negotiation, and entity exposure for free. Writing those from scratch would have taken weeks and produced code that was harder to maintain and harder for anyone else to use.

The tradeoff is that ESPHome's YAML model has limits — particularly for low-level hardware control. For anything that needed direct UART access to the fingerprint sensor, like raw template backup and restore, I used a custom external C++ component loaded from the repository. That kept the firmware maintainable while still allowing low-level work where it was actually needed.

2) The LED System and the First-Press Bug

Visual feedback sounds simple. It wasn't.

The LED strip is the real-time indicator for keypad state: one LED lights per digit entered during PIN entry, a success or failure flash on submission, and a steady color when idle. A led_brightness entity exposes the strip to Home Assistant for automations.

A bug appeared early: the first keypress after a PIN submission would flash blue momentarily before correcting. Subsequent presses worked fine.

The root cause was ordering. The led_brightness_boost script — which temporarily raises brightness to 100% so keypresses are visible in bright environments — was being called before the keypad_active flag was set to true. That flag gates an on_value handler on led_brightness that calls light.turn_on with a 200ms transition. So on the first press, led_brightness_boost fired a full-strip light transition that overwrote the addressable pixel write from the progress script before it could render.

The fix was ordering: snapshot LED state, set keypad_active = true, then call led_brightness_boost. One line moved, half an hour of state tracing.

3) Fingerprint Backup and Restore: Why It Had to Be Built from Scratch

What fingerprint_grow does and does not do

ESPHome's built-in fingerprint_grow component is the standard way to use Grow/ZFM-series sensors. It handles enrollment, matching, deletion, and fires events on scan results. It exposes all of that cleanly through ESPHome's entity model, which is exactly what you want for 90% of fingerprint sensor use.

What it does not do is export or import templates. There is no backup or restore action, no API for reading raw template data from the sensor's flash, and no mechanism to move templates between sensor units. This is a known gap — there is an open feature request in the ESPHome issue tracker — but it is not implemented. If your sensor is wiped or replaced, every enrolled fingerprint is gone and must be re-enrolled in person.

For a alarm system where there is multiple sensors, that is not acceptable. Re-enrollment requires physical presence of every person at every sensor, which is not always practical. The backup system was a requirement, not a nice-to-have.

The Grow serial protocol

The R503 uses a binary UART protocol called the Grow protocol. Commands and responses are structured packets with a fixed preamble (0xEF 0x01), a four-byte address field, a packet type byte, a two-byte length, a payload, and a two-byte checksum.

The protocol has commands for the exact operations needed:

  • LoadChar (0x07): loads a stored template from the sensor's flash into one of its character buffers.
  • UpChar (0x08): uploads the contents of a character buffer to the host over UART, as a stream of data packets terminated by an end packet.
  • DownChar (0x09): the reverse — accepts a stream of data packets from the host and loads them into a character buffer.
  • Store (0x06): writes a character buffer back to a specific flash slot.

Backup is LoadCharUpChar. Restore is DownCharStore. The template data is typically around 512 bytes and is streamed in 128-byte chunks with intermediate packets (pid = 0x02) followed by a final end packet (pid = 0x08).

None of this is exposed by fingerprint_grow. The component uses the same UART port, but it only ever sends the commands it knows about — scan, enroll steps, delete — and parses their specific response formats. The raw template transfer commands sit below that layer.

Why a custom external component

The options were:

  1. Contribute a backup feature upstream to ESPHome's fingerprint_grow component.
  2. Write a separate microcontroller sketch that shares the UART.
  3. Write a custom ESPHome external component that shares the existing UART instance.

Option 1 would have been the cleanest long term, but ESPHome component development has a review cycle and I needed this to work now. Option 2 would have required a second microcontroller or careful UART multiplexing, which added hardware complexity for no benefit. Option 3 was the right fit: a self-contained C++ header loaded via ESPHome's external_components system, using the same UART component that fingerprint_grow already configures.

ESPHome's external_components lets you load a component directly from a local path or a git repository. The component registers itself with ESPHome's build system but does not need to know anything about the rest of the firmware. It is just a header with two static functions — backup_slot() and restore_slot() — that accept a UART component pointer and a slot number.

What the component actually does

The backup path:

  1. Flush any stale bytes from the UART receive buffer.
  2. Send LoadChar for the requested slot into buffer 1.
  3. Send UpChar for buffer 1.
  4. Read data packets in a loop until an end packet arrives, accumulating template bytes.
  5. Base64-encode the result and return it as a std::string.

The restore path:

  1. Flush the UART buffer.
  2. Send DownChar for buffer 1 to start a download.
  3. Decode the base64 input back to raw bytes.
  4. Stream the bytes to the sensor in 128-byte chunks, each as a properly framed Grow data packet, with the final chunk marked as an end packet.
  5. Feed the ESP32 watchdog timer between chunks so a large transfer does not trigger a watchdog reset.
  6. Send Store to write buffer 1 back to the target slot.

The base64 encoding and decoding is implemented inline in the header — no external library dependency, no heap allocation beyond the template buffer itself. The entire component is a single fingerprint_backup.h file plus a minimal __init__.py for ESPHome's build system.

The component hooks into the firmware through a YAML service call. When Home Assistant calls fingerprint_backup_slot or fingerprint_restore_slot, the action lambda calls the appropriate static function, then fires a Home Assistant event with the result. The base64 template string travels as an event data attribute, where a HA automation can pick it up and store it in a helper or write it to a file.

Integration and reliability details

A few things that matter in practice:

  • The sensor must stay powered throughout a transfer. The idle_period_to_sleep is set to 300 seconds, so there is a generous window after a scan naturally wakes it. All HA actions also automatically power-cycle the sensor before starting, so you never need to touch the sensor first.
  • The component shares the UART with fingerprint_grow. This works because backup and restore are only triggered by explicit HA action calls, never during normal scan operation. There is no concurrent access.
  • A power-cycle runs on every ESP boot to flush the R503's startup byte. The sensor sends a spurious 0x00 on power-on that confuses fingerprint_grow's ACK parser. Cycling power at boot prevents that from causing missed scans on first use.

4) The Package Structure

ESPHome projects get monolithic fast. This one uses a modular YAML package layout:

  • keypad/board.yaml — chip config, logger, boot behavior.
  • keypad/network.yaml — WiFi, HA API, OTA, and all HA actions.
  • keypad/fingerprint.yaml — UART, sensor config, all fingerprint event handlers.
  • keypad/keypad.yaml — matrix keypad, PIN collector, LED feedback scripts.
  • keypad/status_light.yaml — LED strip, brightness entity, boost script.
  • keypad/debug.yaml — web server, diagnostic sensors, test buttons.

Two entry points exist. keypad.yaml fetches packages from GitHub using remote_package — this is used for CI and remote flash. keypad-local.yaml uses local !include references and adds the debug package — this is used during development.

The benefit is that CI compiles the same packages as a production build, and local development gets a richer debug surface without that surface ever appearing in a release binary.

5) CI and Release Automation

The project uses GitHub Actions for CI and releases.

On every push and pull request, CI validates the project structure, compiles the firmware, and lints the YAML configuration. Releases are triggered by a commit with a release: prefix, which compiles the firmware, generates a changelog, and attaches the binary as a release asset.

A wiki sync workflow keeps docs/ in the repository in sync with the GitHub Wiki bidirectionally, so documentation doesn't live in two places that drift apart.

Lessons from This Project

  • State ordering in ESPHome lambdas is where bugs hide. The LED issue was not a logic error — it was a sequencing error.
  • External C++ components are the right escape hatch when you need hardware access the built-in components don't expose.
  • The LED and keypad feedback systems need to be designed together. Treating them as independent leads to flag management complexity.
  • Modular YAML packages pay off once you have more than a couple of hardware subsystems.
  • The fingerprint sensor power-cycle on boot is one of those fixes that looks odd until you understand why it is necessary.

Closing Thoughts

This turned out to be a more involved project than expected — less because the hardware was complex, and more because getting the details right (LED timing, fingerprint reliability, clean backup and restore) took iteration.

The result is a system that works without any cloud dependency, integrates deeply with Home Assistant, and backs up its own biometric data. That was the original goal, and it is what the project delivers.

If you are building something similar, the fingerprint backup is the part I would focus on. Most ESPHome fingerprint guides stop at enrollment. The reliability of the system over time depends on what happens when the sensor eventually needs to be replaced.

Learn More

ESPHome Keypad GitHub Repository