Skip to content
raman325 edited this page Aug 26, 2026 · 2 revisions

External Keypads and Code Readers

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.

The action

- 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. If you have no natural entity for one, use a template entity as the source and a Virtual lock as the target — see Virtual integration. A keypad-only setup needs a Virtual lock anyway, since a config entry requires at least one lock.

Don't name the same entity as both. A use where source and target are equal is treated as one the lock observed by itself, and is not recorded again.

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.

What a valid code produces

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

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, when target is a lock in that config entry that can record events. Otherwise only the event fires — which is normal for a keypad opening something LCM doesn't manage.

An invalid code produces nothing.

Act on it

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}

Security: keep the code out of your history

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_code

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


Recipes

1. Keypad entity → validate → open

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.

2. Let the keypad ask for itself

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.

3. Different feedback per rejection reason

      - 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."}

4. A device that fires an event instead of publishing state

    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.


Things to know

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.

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.

See also

Clone this wiki locally