Skip to content

Event Intercepting

Event intercepting enables command-response patterns where command handlers need to wait for specific device response events before completing their operations. This is a critical mechanism for implementing reliable two-way communication with physical access control devices.

The Command-Response Pattern

The Problem

Physical access control devices typically operate asynchronously:

  1. You send a command to the device (e.g., "lock door")
  2. The device processes the command
  3. The device sends back an event confirming the action (e.g., "door locked")

This creates a challenge: your command handler needs to know whether the operation succeeded, but the confirmation arrives as a separate event that would normally be routed to event handlers.

Why Normal Event Routing Doesn't Work

The event routing system is designed for fire-and-forget event processing. Events flow from the transport layer through the router to registered handlers. This works perfectly for notifications and monitoring, but fails for command-response flows because:

  • The event would be processed by a handler in a different execution context
  • The command handler has no way to receive the response
  • There's no mechanism to correlate the response event with the original command
  • The command handler cannot determine success or failure

The Solution: Event Interception

Event interception allows command handlers to "intercept" specific events before they reach the normal routing pipeline. The handler registers a waiter with a predicate, sends the command, then waits for the matching response event.

This pattern ensures:

  • Reliable command-response correlation
  • Synchronous behavior from the command handler's perspective
  • Proper error handling with timeouts
  • Optional event consumption to prevent duplicate processing

How EventInterceptor Works

The EventInterceptor sits at the beginning of the event dispatch pipeline. When an event arrives:

  1. EventRouter.Dispatch() receives the event from the transport layer
  2. EventRouter checks the EventInterceptor FIRST, before any routing
  3. EventInterceptor tests the event against all active waiter predicates
  4. If a match is found:
  5. The waiter's Task is completed with the event
  6. The event can be consumed (stop dispatch) or passed through
  7. The waiter is removed from the active list
  8. If no match, the event continues to normal routing

Thread Safety

EventInterceptor uses a ConcurrentDictionary<Guid, EventWaiter> to manage active waiters. This provides:

  • Thread-safe registration of new waiters
  • Thread-safe removal when waiters complete or timeout
  • Support for 100+ concurrent waiters without performance degradation
  • O(1) waiter removal using Guid keys

Event Flow Diagram

Device Event Arrives
        |
        v
EventRouter.Dispatch(event)
        |
        v
EventInterceptor.TryIntercept(event)
        |
        +---> Match found?
        |     |
        |     +---> YES: Complete waiter Task
        |     |     |
        |     |     +---> consumeEvent = true?
        |     |           |
        |     |           +---> YES: STOP (event consumed)
        |     |           |
        |     |           +---> NO: Continue to routing
        |     |
        |     +---> NO: Continue to routing
        |
        v
Route matching (multicast, unless Terminate)
        |
        v
Execute handler

WaitForEvent API

The WaitForEvent method is the primary interface for event interception:

Task<TEvent> WaitForEvent(
    Func<TEvent, bool> predicate,
    TimeSpan timeout,
    CancellationToken ct)

Parameters

  • predicate: Function that tests each incoming event for a match
  • Should be specific enough to avoid false matches
  • Typically combines device ID, entity ID, and event code
  • Example: e => e.DeviceID == deviceId && e.EventCode == EventCode.DoorLocked

  • timeout: Maximum time to wait for the matching event

  • Default: 30 seconds
  • Should account for device response time and network latency
  • Too short: false timeouts when device is slow
  • Too long: delays error reporting when device is offline

  • ct: Cancellation token for early termination

  • Allows external cancellation (e.g., user cancels operation)
  • Timeout is implemented as a linked cancellation token

Return Value

Returns a Task<TEvent> that completes when:

  • A matching event arrives (task succeeds with the event)
  • The timeout expires (task throws TimeoutException)
  • The cancellation token is triggered (task throws OperationCanceledException)

Access Points

You can call WaitForEvent from two places:

  1. IEventRouter.WaitForEvent() - Standard interface method
  2. Protocol.WaitForEvent() - Convenience wrapper (internally calls router)

Both methods delegate to the same underlying EventInterceptor<TEvent>.WaitForEvent().

Usage Pattern: Command-Response Flow

Here's a complete example showing the recommended pattern:

public async Task<DeviceCommandResult> LockDoor(Access.Lock command)
{
    if (!protocol.ProtocolId.HasValue)
        return new DeviceCommandResult(CommandResult.Failure);

    // STEP 1: START WAITING (before sending command)
    // predicate includes device ID, entity ID, and event code to avoid false matches
    var intercept = protocol.WaitForEvent(
        predicate: e => e.DeviceID == protocol.ProtocolId &&
                        e.EntityID == Address &&
                        e.EventCode == EventCode.DoorLocked,
        timeout: TimeSpan.FromSeconds(120));

    // STEP 2: SEND COMMAND
    await protocol.LockDoor([Address]);

    try
    {
        // STEP 3: WAIT FOR RESPONSE
        var deviceEvent = await intercept;

        // STEP 4: PROCESS RESPONSE - use device timestamp, not UtcNow
        var timestamp = DeviceTimestamp.FromUnixMilliseconds(deviceEvent.Timestamp);
        await LockedRemote(timestamp, command.OperatorIdentity);

        return new DeviceCommandResult(CommandResult.Success);
    }
    catch (TimeoutException)
    {
        // STEP 5: HANDLE TIMEOUT
        return new DeviceCommandResult(CommandResult.Failure);
    }
}

Why Start Waiting First?

The critical pattern is starting the waiter BEFORE sending the command:

// CORRECT: Start waiting first
var intercept = protocol.WaitForEvent(...);
await protocol.SendCommand();
var response = await intercept;

// WRONG: Race condition possible
await protocol.SendCommand();
var response = await protocol.WaitForEvent(...); // Response might already have arrived!

If the device responds very quickly (or the response was already in the network buffer), you could miss the event if you don't start waiting first.

Predicate Specificity

Predicates should be specific enough to avoid false matches:

// TOO BROAD: Might match events from other devices
e => e.EventCode == EventCode.DoorLocked

// BETTER: Includes device ID
e => e.DeviceID == deviceId && e.EventCode == DOOR_EventCode.DoorLocked

// BEST: Includes device ID, entity ID, and event code
e => e.DeviceID == deviceId &&
     e.EntityID == doorId &&
     e.EventCode == DOOR_EventCode.DoorLocked

The more specific the predicate, the less likely you'll get false matches from unrelated events.

Timeout Selection

Choose timeouts based on device characteristics:

// Fast local device (on-premises)
timeout: TimeSpan.FromSeconds(30)

// Slower device or remote connection
timeout: TimeSpan.FromSeconds(120)

// Critical operation that might require user interaction
timeout: TimeSpan.FromMinutes(5)

Consider:

  • Device processing time
  • Network latency
  • User interaction time (if applicable)
  • Acceptable wait time for the operation

Event Consumption

The consumeEvent parameter controls whether matched events continue through normal routing:

// Consume event (prevent normal dispatch)
await interceptor.WaitForEvent(predicate, timeout, consumeEvent: true, ct);

// Pass through (allow normal dispatch)
await interceptor.WaitForEvent(predicate, timeout, consumeEvent: false, ct);

When to Consume Events

Consume events (consumeEvent: true) when:

  • The event is an internal protocol ACK/NAK
  • The event has no audit or monitoring value
  • You don't want the event to trigger other handlers

Do NOT consume events (consumeEvent: false) when:

  • The event should be audited (e.g., door locked by operator)
  • The event should be monitored or logged
  • Other handlers might need to process the event
  • The event represents a state change that should be broadcast

Default Behavior

The IEventRouter.WaitForEvent() method defaults to consumeEvent: true:

public Task<TEvent> WaitForEvent(
    Func<TEvent, bool> predicate,
    TimeSpan timeout,
    CancellationToken ct)
    => Interceptor.WaitForEvent(predicate, timeout, consumeEvent: true, ct);

This is the conservative default for command-response patterns. If you need pass-through, call the EventInterceptor.WaitForEvent() directly with consumeEvent: false.

Audit Trail Consideration

For command-response flows, the default consumeEvent: true is the right choice. The command handler receives the device event, extracts the device timestamp, and emits the audit event with operator context via a *Remote method:

// consume = true (default): command handler takes full responsibility for the audit event
var deviceEvent = await protocol.WaitForEvent(
    predicate: e => e.DeviceID == deviceId &&
                    e.EntityID == doorId &&
                    e.EventCode == EventCode.DoorLocked,
    timeout: TimeSpan.FromSeconds(30));

// emit audit event with operator identity and device timestamp
await LockedRemote(deviceEvent.Timestamp, command.OperatorIdentity);

The alternative consumeEvent: false lets the event continue to normal routing after the waiter fires. Use this only when the event should be processed by a routing handler in addition to the command handler — be aware that the routing handler will call a non-Remote method (without operator identity), so the audit event will lack the operator context.

Multiple Concurrent Waiters

EventInterceptor efficiently handles many concurrent waiters:

// Multiple commands waiting for different events simultaneously
var task1 = protocol.WaitForEvent(e => e.DeviceID == 1 && e.EventCode == EventCode.DoorLocked);
var task2 = protocol.WaitForEvent(e => e.DeviceID == 1 && e.EventCode == UNEventCode.DoorLocked);
var task3 = protocol.WaitForEvent(e => e.DeviceID == 2 && e.EventCode == EventCode.AlarmTriggered);

// All three waiters are active concurrently
// Each will complete independently when its event arrives

First-Match-Wins

When multiple waiters could match the same event, the first registered waiter wins:

// Waiter A: e => e.EventCode == EventCode.DoorLocked
// Waiter B: e => e.DeviceID == 1 && e.EventCode == EventCode.DoorLocked

// Event arrives: DeviceID=1, EventCode=EventCode.DoorLocked
// Waiter A will match (registered first)
// Waiter B will timeout (event was consumed by A)

To avoid this, make predicates mutually exclusive:

// Better: Specific predicates that won't overlap
var waiterA = protocol.WaitForEvent(
    e => e.DeviceID == 1 && e.EventCode == EventCode.DoorLocked);
var waiterB = protocol.WaitForEvent(
    e => e.DeviceID == 2 && e.EventCode == EventCode.DoorLocked);

Performance Characteristics

The EventInterceptor uses ConcurrentDictionary<Guid, EventWaiter> for O(1) operations:

  • Registration: O(1) - TryAdd(guid, waiter)
  • Removal: O(1) - TryRemove(guid, out _)
  • Matching: O(n) where n = number of active waiters
  • Iterates all waiters testing predicates
  • Stops on first match
  • Typically very fast (most adapters have < 10 concurrent waiters)

For 100+ concurrent waiters, matching is still fast because:

  • Dictionary iteration is optimized
  • Predicates are simple comparisons
  • First match exits early

When to Use Event Intercepting

Use Event Intercepting For

Command-Response Patterns

// Send command, wait for confirmation
await SendLockCommand();
var confirmation = await WaitForEvent(e => e.EventCode == EventCode.DoorLocked);

Synchronization Points

// Wait for device to be ready
var ready = await WaitForEvent(e => e.EventCode == EventCode.DeviceReady);
await ConfigureDevice();

Testing and Verification

// Test: verify event is published after action
var eventTask = WaitForEvent(e => e.EventCode == DOOR_OPENED);
await TriggerDoorOpen();
var evt = await eventTask;
Assert.That(evt.Timestamp, Is.GreaterThan(beforeTime));

Protocol State Machines

// Wait for handshake completion
await SendConnectRequest();
var response = await WaitForEvent(e => e.EventCode == EventCode.ConnectAck);
if (response.Status == StatusCode.Success)
    connectionState = Connected;

Do NOT Use Event Intercepting For

Normal Event Processing

// WRONG: Don't intercept every event
while (true)
{
    var evt = await WaitForEvent(_ => true); // This blocks normal routing!
    ProcessEvent(evt);
}

// RIGHT: Use event routing
router.When(evt => evt.EventCode == EventCode.DoorOpened)
      .WithAddress<DoorController, uint>(evt => evt.DoorId)
      .Call((door, evt) => door.OnOpened(evt));

Fire-and-Forget Commands

// WRONG: Don't wait if you don't need the response
await SendStatusRequest();
var status = await WaitForEvent(...); // Unnecessary waiting

// RIGHT: Let routing handle it
await SendStatusRequest(); // Status response will be routed normally

Monitoring and Alerts

// WRONG: Don't intercept monitoring events
var alarm = await WaitForEvent(e => e.EventCode == TAMPER_EventCode.AlarmTriggered);
SendAlert(alarm);

// RIGHT: Use event routing
router.When(evt => evt.EventCode == EventCode.TamperAlarm)
      .Call((controller, evt) => SendAlert(evt));

Common Patterns

Door Open Commands

public static Task<DeviceCommandResult> OpenDoor(
    DoorDevice door,
    Access.Open command,
    Protocol protocol,
    CancellationToken ct)
{
    return door.ExecuteCommand(
        DoorState.Unsecured,
        send: async () => await protocol.OpenDoor(door.Address, ct),
        protocol,
        predicate: e => e.EntityID == door.Address && e.EventCode == EventCode.StrikeReleased,
        onSuccess: async e => await door.UnsecuredRemote(
            DeviceTimestamp.FromUnixMilliseconds(e.Timestamp),
            command.OperatorIdentity),
        ct: ct);
}

Configuration with ACK

public async Task<bool> ConfigureAccessLevel(AccessLevelConfig config)
{
    var intercept = protocol.WaitForEvent(
        predicate: e => e.EventCode == EventCode.ConfigAck &&
                        e.ConfigType == ConfigType.AccessLevel,
        timeout: TimeSpan.FromSeconds(30));

    await protocol.SendAccessLevelConfig(config);

    try
    {
        var ack = await intercept;
        return ack.Status == StatusCode.Success;
    }
    catch (TimeoutException)
    {
        return false;
    }
}

Connection Handshake

public async Task<bool> Connect()
{
    var intercept = protocol.WaitForEvent(
        predicate: e => e.EventCode == EventCode.ConnectResponse,
        timeout: TimeSpan.FromSeconds(10));

    await protocol.SendConnectRequest();

    try
    {
        var response = await intercept;
        if (response.Status == StatusCode.Connected)
        {
            connectionState = ConnectionState.Connected;
            return true;
        }
        return false;
    }
    catch (TimeoutException)
    {
        connectionState = ConnectionState.Disconnected;
        return false;
    }
}

Momentary Strike Release (Open Command)

The open command releases the strike momentarily and relies on the framework/device relock path to secure the strike after the configured timeout. Do not implement manual relock with Task.Delay.

public static Task<DeviceCommandResult> OpenDoor(
    DoorDevice door,
    Access.Open command,
    Protocol protocol,
    CancellationToken ct)
{
    return door.ExecuteCommand(
        DoorState.Unsecured,
        send: async () => await protocol.OpenDoor(door.Address, ct),
        protocol,
        predicate: e => e.EntityID == door.Address && e.EventCode == EventCode.StrikeReleased,
        onSuccess: async e => await door.UnsecuredRemote(
            DeviceTimestamp.FromUnixMilliseconds(e.Timestamp),
            command.OperatorIdentity),
        ct: ct);
}

Best Practices

1. Always Start Waiting First

// CORRECT
var intercept = protocol.WaitForEvent(...);
await protocol.SendCommand();
var response = await intercept;

// WRONG: Race condition
await protocol.SendCommand();
var response = await protocol.WaitForEvent(...);

2. Use Specific Predicates

// GOOD: Specific predicate
e => e.DeviceID == deviceId &&
     e.EntityID == doorId &&
     e.EventCode == EventCode.DoorLocked

// BAD: Too broad
e => e.EventCode == EventCode.DoorLocked

3. Set Reasonable Timeouts

// Consider device characteristics
var timeout = deviceType switch
{
    DeviceType.Local => TimeSpan.FromSeconds(30),
    DeviceType.Remote => TimeSpan.FromSeconds(120),
    DeviceType.SlowNetwork => TimeSpan.FromMinutes(5),
    _ => TimeSpan.FromSeconds(30)
};

4. Always Handle Timeouts

// GOOD: Handle timeout
try
{
    var response = await intercept;
    return ProcessResponse(response);
}
catch (TimeoutException)
{
    logger.LogWarning("Device timeout");
    return new DeviceCommandResult(CommandResult.Failure);
}

// BAD: Let timeout propagate
var response = await intercept; // Unhandled exception on timeout

5. Don't Consume Audit Events

// GOOD: Let audit events flow through
var evt = await protocol.WaitForEvent(...); // consumeEvent defaults to true
await PublishAuditEvent(evt); // Explicitly publish audit

// BETTER: Don't consume, let routing handle audit
await interceptor.WaitForEvent(..., consumeEvent: false);
// Event will be routed normally for audit

6. Await the Task

// GOOD: Await the task
var response = await protocol.WaitForEvent(...);

// BAD: Fire and forget
var _ = protocol.WaitForEvent(...); // Task not awaited!

7. Clean Up on Cancellation

public async Task<DeviceCommandResult> LockDoor(
    Access.Lock command,
    CancellationToken ct)
{
    try
    {
        var intercept = protocol.WaitForEvent(..., timeout, ct);
        await protocol.LockDevice();
        var response = await intercept;
        return Success;
    }
    catch (OperationCanceledException)
    {
        // Clean up if needed
        return Cancelled;
    }
}

Common Mistakes

Mistake 1: Waiting After Sending

// WRONG: Response might arrive before wait starts
await protocol.SendCommand();
var response = await protocol.WaitForEvent(...);

// RIGHT: Start waiting first
var intercept = protocol.WaitForEvent(...);
await protocol.SendCommand();
var response = await intercept;

Mistake 2: Too-Broad Predicates

// WRONG: Matches any EventCode.DoorLocked event from any device
await protocol.WaitForEvent(e => e.EventCode == EventCode.DoorLocked);

// RIGHT: Match specific device
await protocol.WaitForEvent(e => e.DeviceID == deviceId && e.EventCode == EventCode.DoorLocked);

Mistake 3: Ignoring Timeouts

// WRONG: Timeout will crash the handler
var response = await protocol.WaitForEvent(...);

// RIGHT: Handle timeout explicitly
try
{
    var response = await protocol.WaitForEvent(...);
}
catch (TimeoutException)
{
    return Failure;
}

Mistake 4: Using for Normal Event Processing

// WRONG: Blocks event routing
while (true)
{
    var evt = await protocol.WaitForEvent(_ => true);
    ProcessEvent(evt);
}

// RIGHT: Use event routing
router.When(evt => true).Call((thing, evt) => thing.ProcessEvent(evt));

Mistake 5: Not Consuming Protocol ACKs

// WRONG: ACK event will be routed and might cause confusion
await interceptor.WaitForEvent(
    e => e.EventCode == EventCode.ProtocolAck,
    timeout,
    consumeEvent: false); // ACK should be consumed!

// RIGHT: Consume internal protocol events
await interceptor.WaitForEvent(
    e => e.EventCode == EventCode.ProtocolAck,
    timeout,
    consumeEvent: true);

Mistake 6: Multiple Overlapping Waiters

// WRONG: Both waiters might match the same event
var task1 = protocol.WaitForEvent(e => e.EventCode == EventCode.DoorLocked);
var task2 = protocol.WaitForEvent(e => e.DeviceID == 1 && e.EventCode == EventCode.DoorLocked);
// Only one will complete, the other will timeout

// RIGHT: Mutually exclusive predicates
var task1 = protocol.WaitForEvent(e => e.DeviceID == 1 && e.EventCode == EventCode.DoorLocked);
var task2 = protocol.WaitForEvent(e => e.DeviceID == 2 && e.EventCode == EventCode.DoorLocked);

Integration with EventRouter

EventInterceptor is a component of EventRouter, checked at the beginning of the dispatch pipeline:

public async Task Dispatch(TEvent evt, CancellationToken ct)
{
    // STEP 1: Check interceptor FIRST
    if (Interceptor.TryIntercept(evt, out bool consume))
    {
        Logger.LogDebug("Event intercepted");
        if (consume)
            return; // Stop processing
    }

    // STEP 2: First-match-wins routing
    foreach (var route in _routes)
    {
        if (route.Matches(evt))
        {
            await route.Execute(evt, ct);
            return;
        }
    }

    // STEP 3: Unhandled event
    if (_unhandledHandler is not null)
        await _unhandledHandler(evt, ct);
    else if (reason.Unresolved)
        await PublishUnresolved(evt, reason, ct); // framework default: publish device.unresolved
    else
        Logger.LogWarning("No route matched event: {Event}", evt); // unmapped: log only
}

Relationship to Event Routing

Event intercepting and event routing are complementary, not competing:

  • EventInterceptor: Synchronous command-response patterns
  • Used by command handlers
  • Blocks waiting for specific events
  • Temporary (duration of command execution)
  • Consumes or passes through individual events

  • EventRouter: Asynchronous event processing

  • Used for monitoring, alerts, state updates
  • Fires and forgets
  • Permanent (configured at startup)
  • Processes all matching events

When to Use Each

// Use INTERCEPTOR for command-response
public async Task<DeviceCommandResult> LockDoor(Access.Lock command)
{
    var intercept = protocol.WaitForEvent(e => e.EventCode == EventCode.DoorLocked);
    await protocol.SendLockCommand();
    var response = await intercept;
    return Success;
}

// Use ROUTER for event processing
public void ConfigureRouting(IEventRouter<DeviceEvent> router)
{
    router.When(e => e.EventCode == EventCode.DoorLocked)
          .WithAddress<DoorController, uint>(e => e.DoorId)
          .Call((door, evt) => door.OnLocked(evt));
}

Implementation References

The event intercepting system consists of three key components:

EventInterceptor

Core implementation handling waiter registration, event matching, and timeout management.

Key methods:

  • WaitForEvent() - Registers waiter and returns awaitable task
  • TryIntercept() - Tests event against all active waiters
  • EventWaiter (inner class) - Manages individual waiter lifecycle

IEventRouter

Interface exposing event routing and interception to adapter code.

Key methods:

  • Dispatch() - Entry point for all events from transport
  • WaitForEvent() - Convenience wrapper for interceptor

EventRouter

Concrete implementation coordinating interception and routing.

Key properties:

  • Interceptor - Shared EventInterceptor instance
  • Accessible via Protocol for convenience

Summary

Event intercepting provides a reliable mechanism for command-response patterns in physical access control adapters:

  • Start waiting BEFORE sending commands to avoid race conditions
  • Use specific predicates to avoid false matches
  • Set appropriate timeouts based on device characteristics
  • Handle timeouts gracefully to deal with offline/busy devices
  • Don't consume audit events to maintain audit trail
  • Use routing for normal event processing - intercepting is for command-response only
  • Support concurrent waiters for parallel command execution

The system integrates seamlessly with EventRouter, providing both synchronous command-response flows and asynchronous event processing in a single coherent architecture.