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 availablecredentialType(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 availablecredentialType(string?) - Type of credentialexternalPersonRef(string?) - Vendor actor reference (user/position/id) to record whenpersonIdcannot be resolved — keeps the precise eventpersonId(Guid?) - Resolved PQ person if available- Usage: Antipassback rule violated but configured as soft (warn-only)
- Identity: prefer
personId; ifIdentityResolver.ResolvePersonreturns null, passexternalPersonRefinstead. 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 availablecredentialType(string?) - Type of credentialpersonId(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 availablecredentialType(string?) - Type of credentialexternalPersonRef(string?) - Vendor actor reference (user/position/id) to record whenpersonIdcannot be resolved — keeps the precise eventpersonId(Guid?) - Resolved PQ person if available- Usage: Hard antipassback rule violated (no re-entry without exit)
- Identity: prefer
personId; ifIdentityResolver.ResolvePersonreturns null, passexternalPersonRefinstead. Do not fall back toAccessDeniedfor a missing person (RULE-027 rung 2) —personIdis 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 supportedcredential_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
IThingRelationResolverparameter to support automatic door integration - Door Integration:
AccessGranted()automatically callsDoor.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 viaEventBuilder.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