Skip to content

Reader

Overview

Credential reader for access control supporting card, PIN, biometric, and mobile credentials. Handles access events (granted, denied, unknown), lockout conditions after failed attempts, and duress code detection.

  • Added in: PQ Framework 2.1
  • Namespace: Pq.Adapters.Framework
  • Construction: extended_construction: true (adapter provides constructor with relation resolver)

When to Use

  • Card readers (RFID, NFC, magnetic stripe)
  • PIN keypads
  • Biometric readers (fingerprint, face, iris, palm vein)
  • Mobile credential readers (Bluetooth, QR code)
  • Multi-factor readers (card + PIN, biometric + PIN)
  • Any credential authentication device

States

Each state below is the canonical state identifier the reader reports.

Security States

State Description
access.reader.lockout Reader locked after too many failed attempts
access.reader.duress Duress code entered - silent alarm

Note: Reader inherits standard connectivity states from ConnectionBase (online, offline, degraded) and may include tamper states if implementing Tamper function.

Actions

Access Granted

AccessGranted(timestamp, credentialId?, credentialType?, direction?, personId?) (custom)

  • Description: Access granted after successful authentication
  • Events: pq.event.access.granted
  • Parameters:
  • credentialId (Guid?) - Credential identifier if available
  • credentialType (string?) - Type of credential ("card", "pin", "fingerprint", "mobile")
  • direction (string?) - Direction of access ("in", "out")
  • personId (Guid?) - Person identifier if credential is linked
  • Side Effects: Automatically triggers Door.GrantAccess() on associated door via relation resolver
  • Usage: Credential successfully validated, user granted entry

AccessGrantedAntipassback(timestamp, direction?, credentialId?, credentialType?, externalPersonRef?, personId?) (custom)

  • Description: Access granted despite antipassback violation (soft APB)
  • Events: pq.event.access.granted.antipassback
  • Parameters:
  • direction (string?) - Direction of access ("in", "out")
  • credentialId (Guid?) - Credential identifier if available
  • credentialType (string?) - Type of credential
  • externalPersonRef (string?) - Vendor actor reference (user/position/id) to record when personId cannot be resolved — keeps the precise event
  • personId (Guid?) - Resolved PQ person if available
  • Usage: Antipassback rule violated but configured as soft (warn-only)
  • Identity: prefer personId; if IdentityResolver.ResolvePerson returns null, pass externalPersonRef instead. Do not downgrade to a generic event for a missing person (RULE-027 rung 2).

Access Denied

AccessDenied(timestamp, credentialId?, credentialType?, personId?) (custom)

  • Description: Access denied after failed authentication
  • Events: pq.event.access.denied
  • Parameters:
  • credentialId (Guid?) - Credential identifier if available
  • credentialType (string?) - Type of credential
  • personId (Guid?) - Resolved PQ person if available
  • Usage: Valid credential but access denied (generic denial). This is the least specific denial — use a more specific variant (antipassback, schedule, …) whenever the device tells you the reason.

AccessDeniedAntipassback(timestamp, credentialId?, credentialType?, externalPersonRef?, personId?) (custom)

  • Description: Access denied due to antipassback violation
  • Events: pq.event.access.denied.antipassback
  • Parameters:
  • credentialId (Guid?) - Credential identifier if available
  • credentialType (string?) - Type of credential
  • externalPersonRef (string?) - Vendor actor reference (user/position/id) to record when personId cannot be resolved — keeps the precise event
  • personId (Guid?) - Resolved PQ person if available
  • Usage: Hard antipassback rule violated (no re-entry without exit)
  • Identity: prefer personId; if IdentityResolver.ResolvePerson returns null, pass externalPersonRef instead. Do not fall back to AccessDenied for a missing person (RULE-027 rung 2) — personId is optional here, not required.

AccessDeniedSchedule(timestamp) (custom)

  • Description: Access denied outside scheduled hours
  • Events: pq.event.access.denied.schedule
  • Usage: Credential valid but outside its permitted time zone

AccessDeniedUnknown(timestamp) (custom)

  • Description: Access denied - credential not in database
  • Events: pq.event.access.denied.unknown
  • Usage: Credential presented but not enrolled in system

AccessDeniedInvalid(timestamp) (custom)

  • Description: Access denied - unreadable or bad format credential
  • Events: pq.event.access.denied.invalid
  • Usage: Read error, corrupted data, unsupported format

AccessDeniedExpired(timestamp) (custom)

  • Description: Access denied - expired credential
  • Events: pq.event.access.denied.expired
  • Usage: Credential validity period has ended

AccessDeniedBlocked(timestamp) (custom)

  • Description: Access denied - blocked credential
  • Events: pq.event.access.denied.blocked
  • Usage: Credential marked as blocked (lost, stolen, terminated)

AccessDeniedLocked(timestamp) (custom)

  • Description: Access denied - access point locked
  • Events: pq.event.access.denied.locked
  • Usage: Door/access point is in a locked state

AccessDeniedInterlock(timestamp) (custom)

  • Description: Access denied - interlock active (mantrap, airlock)
  • Events: pq.event.access.denied.interlock
  • Usage: Interlock rule prevents access while another door is open

AccessDeniedPin(timestamp) (custom)

  • Description: Access denied - invalid PIN
  • Events: pq.event.access.denied.pin
  • Usage: PIN required but entered incorrectly

AccessDeniedOccupancy(timestamp) (custom)

  • Description: Access denied - occupancy limit reached
  • Events: pq.event.access.denied.occupancy
  • Usage: Area is at maximum permitted occupancy

AccessDeniedDual(timestamp) (custom)

  • Description: Access denied - second person required (two-person rule, escort)
  • Events: pq.event.access.denied.dual
  • Usage: Two-person/escort rule not satisfied

AccessDeniedIncomplete(timestamp) (custom)

  • Description: Access denied - incomplete multifactor authentication sequence
  • Events: pq.event.access.denied.incomplete
  • Usage: Required additional factor (e.g. PIN after card) not provided

AccessDeniedFraud(timestamp) (custom)

  • Description: Access denied - biometric fraud detected (fake finger, fake face)
  • Events: pq.event.access.denied.fraud
  • Usage: Liveness/anti-spoofing check failed

Enrollment Actions

EnrollmentSucceeded(timestamp)

  • Description: Credential enrollment completed successfully
  • Events: pq.event.access.credential.enrollment.succeeded
  • Usage: New credential captured and stored on device

EnrollmentFailed(timestamp)

  • Description: Credential enrollment failed
  • Events: pq.event.access.credential.enrollment.failed
  • Usage: Enrollment aborted or capture failed

EnrollmentDuplicate(timestamp)

  • Description: Duplicate credential detected during enrollment
  • Events: pq.event.access.credential.enrollment.duplicate
  • Usage: Credential already enrolled on the device

Credential Lifecycle Actions

CredentialDeleted(timestamp)

  • Description: Single credential deleted from device
  • Events: pq.event.access.credential.deleted.single
  • Usage: One credential removed from device storage

CredentialsDeletedAll(timestamp)

  • Description: All credentials deleted from device
  • Events: pq.event.access.credential.deleted.all
  • Usage: Device credential store wiped

UsageLimitChanged(timestamp)

  • Description: Credential usage limit changed
  • Events: pq.event.access.credential.limit.usage.changed
  • Usage: Remaining-use count for a credential updated

UsageLimitExhausted(timestamp)

  • Description: Credential usage limit exhausted
  • Events: pq.event.access.credential.limit.usage.exhausted
  • Usage: Credential has no remaining permitted uses

Duress Actions

DuressCodeEntered(timestamp)

  • Description: Duress code entered - silent alarm
  • Target State: access.reader.duress
  • Events: pq.event.intrusion.duress
  • Usage: User entered duress PIN indicating coercion

DuressCleared(timestamp)

  • Description: Duress condition cleared
  • Target State: none
  • Events: pq.event.intrusion.duress.cleared
  • Usage: Duress state manually cleared by operator

Lockout Actions

LockoutTriggered(timestamp, failedAttempts, lockoutDuration?)

  • Description: Reader locked out after failed attempts
  • Target State: access.reader.lockout
  • Events: pq.event.access.reader.lockout.triggered
  • Usage: Too many invalid credentials presented within timeout period

LockoutCleared(timestamp)

  • Description: Reader lockout cleared
  • Target State: none
  • Events: pq.event.access.reader.lockout.cleared
  • Usage: Lockout timeout expired or manually cleared

Properties

None. All configuration is adapter-specific. Define properties in adapter-registration.yaml for reader-specific settings.

Common adapter properties might include:

  • lockout_threshold - Failed attempts before lockout (e.g., 5)
  • lockout_duration_seconds - How long lockout lasts (e.g., 300)
  • supports_duress - Whether duress codes are supported
  • credential_types - Supported credential types (card, pin, biometric)
  • led_feedback - Visual feedback mode (always, on_read, never)
  • beep_feedback - Audible feedback enabled

YAML Example

device_types:
  - type_id: CardReader
    name: "RFID Card Reader"
    category: reader
    functions:
      Reader:
      ConnectionBase:
      Tamper:
    properties:
      credential_types:
        type: "string[]"
        default: ["card", "pin"]
        description: "Supported credential types"
      lockout_threshold:
        type: "int"
        default: 5
        range: [0, 20]
        description: "Failed attempts before lockout (0=disabled)"
      lockout_duration_seconds:
        type: "int"
        default: 300
        range: [0, 3600]
        description: "Lockout duration in seconds"
      supports_duress:
        type: "bool"
        default: true
        description: "Duress code support enabled"

  - type_id: BiometricReader
    name: "Fingerprint Reader"
    category: reader
    functions:
      Reader:
      ConnectionBase:
    properties:
      credential_types:
        type: "string[]"
        default: ["fingerprint"]
      match_threshold:
        type: "int"
        default: 50
        range: [0, 100]
        description: "Biometric match threshold (0=strict, 100=loose)"

Code Usage

Basic Access Events

public class CardReaderThing : Thing
{
    public ReaderFunction Reader { get; }

    public CardReaderThing(Thing parent, IThingRelationResolver resolver) : base(parent)
    {
        Reader = new ReaderFunction(this, resolver);
    }

    protected override async Task HandleDeviceEvent(ReaderEvent evt, CancellationToken ct)
    {
        switch (evt.Type)
        {
            case ReaderEventType.AccessGranted:
                await Reader.AccessGranted(
                    evt.Timestamp,
                    credentialId: evt.CredentialId,
                    credentialType: "card",
                    direction: "in",
                    personId: evt.PersonId
                );
                break;

            case ReaderEventType.AccessDenied:
                await Reader.AccessDenied(evt.Timestamp);
                break;

            case ReaderEventType.UnknownCard:
                await Reader.AccessDeniedUnknown(evt.Timestamp);
                break;
        }
    }
}

Lockout Management

private int _failedAttempts = 0;
private DateTimeOffset? _lockoutUntil;

protected override async Task HandleDeviceEvent(ReaderEvent evt, CancellationToken ct)
{
    var now = timeProvider.GetLocalNow();

    // check if reader is locked out
    if (_lockoutUntil.HasValue)
    {
        if (now < _lockoutUntil.Value)
            return; // still locked out

        // lockout expired
        _lockoutUntil = null;
        _failedAttempts = 0;
        await Reader.LockoutCleared(DeviceTimestamp.UtcNow);
    }

    switch (evt.Type)
    {
        case ReaderEventType.AccessGranted:
            _failedAttempts = 0; // reset counter on success
            await Reader.AccessGranted(evt.Timestamp, evt.CredentialId, "card", "in", evt.PersonId);
            break;

        case ReaderEventType.UnknownCard:
        case ReaderEventType.InvalidCard:
            _failedAttempts++;

            if (_failedAttempts >= LockoutThreshold)
            {
                _lockoutUntil = now.AddSeconds(LockoutDurationSeconds);
                await Reader.LockoutTriggered(
                    evt.Timestamp,
                    _failedAttempts,
                    TimeSpan.FromSeconds(LockoutDurationSeconds));
            }
            else if (evt.Type == ReaderEventType.UnknownCard)
            {
                await Reader.AccessDeniedUnknown(evt.Timestamp);
            }
            else
            {
                await Reader.AccessDeniedInvalid(evt.Timestamp);
            }
            break;
    }
}

Duress Code Detection

protected override async Task HandleDeviceEvent(ReaderEvent evt, CancellationToken ct)
{
    if (evt.Type == ReaderEventType.DuressCode)
    {
        // silent alarm - appears normal to user
        await Reader.DuressCodeEntered(evt.Timestamp);

        // optionally also grant access to avoid suspicion
        if (SupportsDuressAccessGrant)
        {
            await Reader.AccessGranted(
                evt.Timestamp,
                credentialId: evt.CredentialId,
                credentialType: "pin",
                personId: evt.PersonId
            );
        }
    }
}

Multi-Factor Authentication

private Guid? _pendingCredential;
private DateTimeOffset _pendingExpiry;

protected override async Task HandleDeviceEvent(ReaderEvent evt, CancellationToken ct)
{
    var now = timeProvider.GetLocalNow();

    switch (evt.Type)
    {
        case ReaderEventType.CardRead:
            // first factor - card
            _pendingCredential = evt.CredentialId;
            _pendingExpiry = now.AddSeconds(30);
            // await for PIN entry
            break;

        case ReaderEventType.PinEntered:
            // second factor - PIN
            if (_pendingCredential.HasValue && now < _pendingExpiry)
            {
                // validate both factors together
                bool granted = await ValidateMultiFactor(_pendingCredential.Value, evt.Pin);

                if (granted)
                {
                    await Reader.AccessGranted(
                        evt.Timestamp,
                        credentialId: _pendingCredential,
                        credentialType: "card+pin",
                        direction: "in",
                        personId: evt.PersonId
                    );
                }
                else
                {
                    await Reader.AccessDenied(evt.Timestamp);
                }

                _pendingCredential = null;
            }
            else
            {
                await Reader.AccessDenied(evt.Timestamp);
            }
            break;
    }
}

Notes

  • Extended Construction: Reader requires constructor with IThingRelationResolver parameter to support automatic door integration
  • Door Integration: AccessGranted() automatically calls Door.GrantAccess() if reader is related to a door via relation resolver
  • Access Event Metadata: Access actions emit their pq.event.access.* events directly. Adapters typically enrich them with credential metadata (credential id, type, person) built via EventBuilder.Event() before invoking the action
  • No State Machine: Reader states (lockout, duress) are simple flags, not a full state machine like Door or Partition
  • Lockout Logic: Framework does not manage lockout timing - adapter must track failed attempts and timing
  • Direction Parameter: "in"/"out" direction is informational for reporting, does not affect access control logic
  • Credential Types: Use standard strings: "card", "pin", "fingerprint", "face", "iris", "palmvein", "mobile", or combined like "card+pin"

Constructor Pattern

Since extended_construction: true, you must provide constructor with relation resolver:

public partial class ReaderFunction
{
    // Framework requires this constructor signature
    public ReaderFunction(Thing owner, IThingRelationResolver resolver)
        : base(owner)
    {
        // resolver injected for door integration
    }
}

The framework supplies this constructor; an adapter Thing simply passes the resolver through when creating the function.

See Also

  • Functions: Door - Physical access control door
  • Functions: Partition - Duress detection in intrusion systems
  • Patterns: Event Publishing - Using EventBuilder vs function actions