-
-
Notifications
You must be signed in to change notification settings - Fork 18
External Keypads
Lock Code Manager can check a code entered somewhere it doesn't control — an ESPHome keypad, a Wiegand reader, an intercom — against the users, PINs and conditions you already configured, and record that the credential was used.
Because the check runs against the same users driving your real locks, one PIN and one schedule cover both. Disable a user and their code stops working at the keypad too.
Lock Code Manager never opens anything. It answers, records, and fires an event; what happens next is yours.
Since 5.3.0, a configuration can manage no locks at all. Create one, leave the
lock picker empty, and add your users and PINs as usual — credentials live in the
configuration, and use_credential checks against them. Nothing needs to stand in
for a lock you don't have.
Earlier versions required at least one lock, and the advice was to add a Virtual lock as a placeholder. That is no longer necessary. An existing entry set up that way keeps working; you can remove the Virtual lock from it if it was only ever a placeholder.
- action: lock_code_manager.use_credential
data:
config_entry_title: House Locks # or config_entry_id
code: "{{ entered_code }}"
source: sensor.keypad_code # where the code was entered
target: lock.garage # what it was used against
response_variable: check| Response | Value |
|---|---|
valid |
true / false
|
user |
the user's name when valid, else null
|
reason |
unknown_code, user_disabled, or condition_not_met when invalid |
source and target are both required and may be any entity — a cover, an
alarm panel, a switch, anything you associate the use with. If you have no natural
entity for one, a template entity works.
Naming the same entity as both is fine, and is what a lock reporting a code used at its own keypad looks like.
reason distinguishes a real-but-inactive credential from an unknown one, and
the action does no rate limiting or logging — so don't wire it to untrusted
input.
The lock_code_manager_credential_used event, always:
| Field | |
|---|---|
name |
the user |
config_entry_id, config_entry_title
|
which configuration |
source |
where entered |
target |
what it was used against |
credential_type |
the kind of credential — pin today |
operation |
what the device did: lock, unlock, or unknown
|
use_credential always reports operation: unknown: it was told a credential was
used, not what happened next, and Lock Code Manager never actuates anything.
The same event fires when one of your locks reports a code used at its own keypad, so a single trigger covers both.
The user's credential_used event entity, always. It records every use of
that user's credential whatever the target was — a lock, a cover, an alarm
panel, or something Lock Code Manager knows nothing about. The entity stays
available even when every lock in the entry is unreachable, so a use reported
while the locks are down is not lost.
An invalid code produces nothing.
Import the Credential Used blueprint (see Blueprints): trigger on a credential use, optionally filter by configuration, users, sources or targets, and supply whatever actions you like — unlock a door, open a cover, start a vacuum. Lock Code Manager needs to know nothing about what you're driving.
By hand:
automation:
- alias: "Garage: open on a valid keypad code"
triggers:
- trigger: event
event_type: lock_code_manager_credential_used
event_data:
source: sensor.keypad_code
actions:
- action: cover.open_cover
target: {entity_id: cover.garage_door}A keypad that publishes entered codes to an entity puts a cleartext PIN in that entity's state, and the recorder keeps state history. Exclude it:
recorder:
exclude:
entities:
- sensor.keypad_codeLock Code Manager never publishes that entity's state — only its entity ID. The recorder is the one place it can't help, because the entity belongs to the integration that publishes it.
Recipe 2 below avoids the entity entirely, which is the most private option.
On the device, publish the code and clear it (clearing is what lets the same code be entered twice in a row):
key_collector:
- id: pin_collector
source_id: keypad
min_length: 4
max_length: 8
end_keys: "#"
end_key_required: true
on_result:
- text_sensor.template.publish:
id: keypad_code
state: !lambda 'return x;'
- delay: 1s
- text_sensor.template.publish: {id: keypad_code, state: ""}In Home Assistant:
automation:
- alias: "Garage: check keypad codes"
triggers:
- trigger: state
entity_id: sensor.keypad_code
not_to: [unknown, unavailable, ""]
actions:
- action: lock_code_manager.use_credential
data:
config_entry_title: House Locks
code: "{{ trigger.to_state.state }}"
source: sensor.keypad_code
target: lock.garage
response_variable: check
- if: "{{ check.valid }}"
then:
- action: cover.open_cover
target: {entity_id: cover.garage_door}
not_to: [unknown, unavailable, ""]is not optional — without it, a Wi-Fi reconnect that republishes the device's last value runs this with nobody at the keypad.
ESPHome 2025.10+ can capture an action's response, so the device decides locally and the code never becomes an entity state at all.
on_result:
- homeassistant.action:
action: lock_code_manager.use_credential
data_template:
config_entry_title: House Locks
code: "{{ entered }}"
source: binary_sensor.garage_keypad_status
target: lock.garage
variables:
entered: !lambda 'return x;'
capture_response: true
on_success:
- if:
condition:
lambda: 'return response["valid"].as<bool>();'
then:
- switch.turn_on: garage_relay
- rtttl.play: "ok:d=4,o=5,b=100:c6"
else:
- rtttl.play: "no:d=4,o=5,b=100:c4,c4"source still has to name an entity — any entity belonging to the keypad will
do.
- choose:
- conditions: "{{ check.valid }}"
sequence:
- action: tts.speak
data: {message: "Welcome, {{ check.user }}."}
- conditions: "{{ check.reason == 'condition_not_met' }}"
sequence:
- action: tts.speak
data: {message: "That code isn't valid right now."}
- conditions: "{{ check.reason == 'user_disabled' }}"
sequence:
- action: tts.speak
data: {message: "That code has been turned off."}
default:
- action: tts.speak
data: {message: "Code not recognized."} on_result:
- homeassistant.event:
event: esphome.keypad_code_entered # must start with "esphome."
data_template:
code: "{{ entered }}"
variables:
entered: !lambda 'return x;'Trigger on esphome.keypad_code_entered and pass
trigger.event.data.code to the action.
PIN length is set by your strictest lock. Every user's PIN must satisfy every
lock in the entry, so make key_collector's bounds agree. A configuration with no
locks has nothing imposing a length, so any code your keypad can send will do.
Whitespace is ignored on both stored PINs and submitted codes.
Codes are matched as text — nothing requires digits, though PINs are what this is designed for.
No rate limiting. Brute-force defence belongs on the device; a keypad that ignores input after several failures never troubles Home Assistant.
Getting Started
UI
Features
- Managing Guests and Rentals
- Services and Actions
- Blueprints
- Tracking lock state change events
- Using Condition Entities
- Unsupported Condition Entities
Advanced
Development
Troubleshooting
FAQ
Supported Integrations