Skip to content

Event Routing

Automatic event delivery from transport to the correct Thing handler using declarative routing rules.


The Problem: Event Delivery

When a device sends event bytes over the transport layer, the adapter must:

  1. Parse the bytes into a device-specific event structure
  2. Find the correct Thing instance to handle it
  3. Call the appropriate handler method with context

Consider these examples:

Device Event:  { type: 5, door: 5, person: 123, credential: 456 }
Question:      Which Thing should handle this?
Answer:        DoorController with address=5

Device Event:  { type: 9, panel: 1, module: 3, point: 2, state: "open" }
Question:      Which Thing should handle this?
Answer:        InputPoint with panel=1, module=3, point=2

Without routing, adapters manually dispatch events in Protocol.OnEventReceived():

protected override async Task OnEventReceived(DeviceEvent evt)
{
    // manual dispatch - fragile and verbose
    switch (evt.Type)
    {
        case 5: // access event
            var door = FindByAddress(evt.DoorId) as DoorController;
            if (door is null) return;

            var personId = await _accessSync.ResolvePerson(evt.PersonId);
            var credId = await _accessSync.ResolveCredential(evt.CredentialId);
            await door.PublishAccessGranted(personId, credId, timestamp, ct);
            break;

        case 9: // door state
            var door2 = FindByAddress(evt.DoorId) as DoorController;
            if (door2 is null) return;
            await door2.Opened(DeviceTimestamp.UtcNow);
            break;

        // 50+ more cases...
    }
}

Problems:

  • Fragile: Adding new event types requires modifying central dispatch
  • Verbose: Every route repeats address resolution, null checks, identity resolution
  • Hierarchical addressing is hard: Events with multi-level addresses (Panel → Module → Point) require complex cascading
  • No routing control: Single switch statement, no multicast or terminate options

EventRouter solves this with declarative routing.


EventRouter Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         Transport Layer                         │
│                  (receives bytes from device)                   │
└────────────────────────────┬────────────────────────────────────┘
                             │ parse bytes
                             v
                    ┌────────────────┐
                    │  DeviceEvent   │
                    │  (typed event) │
                    └────────┬───────┘
                             v
┌─────────────────────────────────────────────────────────────────┐
│              EventRouter<DeviceEvent>.Dispatch()                │
│                                                                 │
│  Routes (evaluated in order, multicast by default):            │
│                                                                 │
│  Route 1: When(e => e.Type == 5)                               │
│           .WithAddress<DoorController>(e => e.DoorId)          │
│           .WithPerson(e => e.PersonId)                         │
│           .WithCredential(e => e.CredentialId)                 │
│           .Handle(async (door, personId, credId, evt) => ...)  │
│                                                                 │
│  Route 2: When(e => e.Type == 9)                               │
│           .WithAddress<DoorController>(e => e.DoorId)          │
│           .Handle(async (door, evt) => ...)                    │
│                                                                 │
│  MapGroup: panel events → child router → cascade to module things │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                             v
                    ┌────────────────┐
                    │  Thing.Handle  │
                    │  (handler)     │
                    └────────────────┘

Key components:

  • EventRouter: Routes events to handlers based on registered routes
  • IEventRouteConfiguration: Pattern for organizing route definitions (like EF Core's IEntityTypeConfiguration)
  • IScopedThingResolver: Resolves Thing instances by protocol address
  • Multicast routing: All matching routes execute by default; call .Terminate() to stop after a specific handler

Fluent Route Registration API

Routes are defined using a fluent builder API:

Basic Route Structure

router
    .When(predicate)               // Filter: which events match this route?
    .And(additionalPredicate)      // Additional filter (AND semantics)
    .WithAddress<TThing, TAddress>(extractor)  // Resolve Thing by address
    .Handle(async (thing, evt) => {...})       // Execute handler

Filtering: When() and And()

// Single predicate
router
    .When(e => e.Type == 5)
    .WithAddress<DoorController, uint>(e => e.DoorId)
    .Handle(async (door, evt) => { ... });

// Multiple predicates (AND semantics)
router
    .When(e => e.Type == 5)
    .And(e => e.Result == AccessResult.Granted)
    .WithAddress<DoorController, uint>(e => e.DoorId)
    .Handle(async (door, evt) => { ... });

Address Resolution: WithAddress()

Resolves Thing by protocol address:

router
    .When(e => e.Type == 5)
    .WithAddress<DoorController, uint>(e => e.DoorId)  // Resolve DoorController by DoorId
    .Handle(async (door, evt) =>
    {
        // 'door' is DoorController instance with address matching evt.DoorId
        await door.Opened(DeviceTimestamp.UtcNow);
        return true;
    });

How it works:

  1. Extract address from event: e => e.DoorId returns 5
  2. IScopedThingResolver finds DoorController with Address == 5
  3. Handler receives resolved Thing instance

If Thing not found: the framework publishes a device.unresolved audit event on the adapter's root Thing by default, so the resolution failure is visible in the audit log instead of being silently dropped. The handler is not called. Register an Unhandled handler (below) to customize this — for example to attach a device-accurate timestamp or the vendor event code.

Identity Resolution: WithPerson / WithCredential

Resolve person or credential identities for access events:

router
    .When(e => e.Type == 5)
    .WithAddress<DoorController, uint>(e => e.DoorId)
    .WithPerson(e => e.PersonId.ToString())
    .WithCredential(e => e.CredentialId.ToString())
    .Handle(async (door, personId, credentialId, evt) =>
    {
        // personId: resolved PQ GUID (or null if not found)
        // credentialId: resolved PQ GUID (or null if not found)
        var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
        await door.AccessGranted(timestamp, personId, credentialId);
        return true;
    });

The selector returns a string?. The framework resolves that string to a Guid? by looking up the person or credential in PQ. Pass whatever your device uses as an identifier (numeric ID, card number, etc.) converted to string — the handler receives the resolved Guid?.

When personId resolves to null, do NOT downgrade the event type. A missing actor identity is a parameter problem, not an event-type problem — and the two are independent (RULE-027 rung 2). The framework calls IdentityResolver.ResolvePerson(...) and may return null when the vendor user has no access-sync mapping. The event is still valid; keep its precise type and degrade only the identity detail:

  1. personId resolved → pass personId.
  2. not resolved, but the vendor sent a user/position/id → pass it as externalPersonRef (a string? on the function method), keeping the precise event.
  3. neither → the precise event is still published, just without actor detail.
.Handle(async (door, personId, evt) =>
{
    // antipassback denial — precise event kept even when the person is unknown
    await door.AccessDeniedAntipassback(
        Timestamp(evt),
        externalPersonRef: personId.HasValue ? null : evt.UserId.ToString(),
        personId: personId);
    return true;
});
// renders: "Access denied to unknown person (120) at Door 5: antipassback violation"

The rule is general: never substitute a less-specific event type for a more-specific one because a parameter would not fill — the antipassback→generic case is just one instance. personId is optional on such events, and externalPersonRef exists precisely to preserve what you do know.

Identity resolution variants:

// Person only
.WithAddress<DoorController, uint>(e => e.DoorId)
.WithPerson(e => e.PersonId.ToString())
.Handle(async (door, personId, evt) => { ... });

// Credential only
.WithAddress<DoorController, uint>(e => e.DoorId)
.WithCredential(e => e.CredentialId.ToString())
.Handle(async (door, credentialId, evt) => { ... });

// Both (can be in any order)
.WithAddress<DoorController, uint>(e => e.DoorId)
.WithPerson(e => e.PersonId.ToString())
.WithCredential(e => e.CredentialId.ToString())
.Handle(async (door, personId, credentialId, evt) => { ... });

Handler Registration: Handle()

Final step: register the handler function.

Signatures based on context:

// No identity resolution
.Handle(async (thing, evt) => { return true; });

// Person or Credential only
.Handle(async (thing, identityId, evt) => { return true; });

// Both Person and Credential
.Handle(async (thing, personId, credentialId, evt) => { return true; });

Multicast Routing with Terminate()

Routes are evaluated in definition order. All matching routes execute by default (multicast). Call .Terminate() after .Handle() to stop routing after that handler.

public void Configure(IEventRoute<DeviceEvent> router)
{
    // Route 1: Reserved input → SIO health status
    router
        .When(e => e.Type == 7)
        .And(e => IsReservedTamperInput(e))
        .WithAddress<SioModule, uint>(e => e.SioId)
        .Handle(async (sio, evt) =>
        {
            // Update SIO health status
            return await sio.ApplyTamperEvent(evt);
        });
        // No .Terminate() → continues to Route 2

    // Route 2: Same event also handled as MonitorPoint
    router
        .When(e => e.Type == 7)
        .WithAddress<MonitorPoint, uint>(e => e.PointId)
        .Handle(async (mp, evt) =>
        {
            // Update MonitorPoint state
            return await mp.ApplyStateChange(evt);
        });

    // Both routes execute for reserved inputs if MonitorPoint is configured!

    // Route 3: Heartbeat → ignore and stop
    router
        .When(e => e.Type == 0)
        .Ignore("heartbeat")
        .Terminate();  // Stop routing - no other handler sees heartbeats
}

Use cases:

  • Multicast (default): Same event updates multiple Things (e.g., tamper input updates SIO health AND MonitorPoint)
  • Terminate: Control-plane noise (heartbeats, keepalives) should not propagate

Cascading Routers for Hierarchy

Many devices have hierarchical addressing:

Panel 1
├─ Module 0 (built-in)
│  ├─ Input Point 1
│  ├─ Input Point 2
│  └─ Output Point 1
├─ Module 1 (expansion)
│  ├─ Input Point 1
│  └─ Input Point 2
└─ Module 2 (expansion)
   └─ Input Point 1

Panel 2
├─ Module 0 (built-in)
   └─ Input Point 1

Event structure:

record DeviceEvent(
    byte Type,
    byte PanelId,    // Panel address
    byte ModuleId,   // I/O module address within panel
    byte PointId     // Point address within module
);

Solution: MapGroup creates child routers scoped to parent Thing:

public void Configure(IEventRoute<DeviceEvent> router)
{
    // Route panel-level events
    router
        .When(e => e.Type == 1)  // Panel heartbeat
        .WithAddress<PanelController, byte>(e => e.PanelId)
        .Handle(async (panel, evt) => { ... });

    // Cascade module events to child routers
    var moduleGroup = router
        .When(e => e.Type >= 10 && e.Type <= 20)  // Module event types
        .MapGroup<PanelController, byte>(e => e.PanelId);

    // Routes defined on moduleGroup are replayed onto child routers
    // Child router is scoped to PanelController (resolved by PanelId)
    moduleGroup
        .When(e => e.Type == 15)  // Input point event
        .WithAddress<InputPoint, (byte, byte)>(e => (e.ModuleId, e.PointId))
        .Handle(async (point, evt) =>
        {
            // 'point' is InputPoint child of the resolved PanelController
            var timestamp = DeviceTimestamp.FromComponents(
                evt.Year, evt.Month, evt.Day, evt.Hour, evt.Minute, evt.Second);
            await point.Input.SetActive(timestamp, CancellationToken.None);
        });
}

How MapGroup works:

  1. Parent route matches event: When(e => e.Type >= 10 && e.Type <= 20)
  2. Resolve parent Thing: PanelController with PanelId == evt.PanelId
  3. Get or create child router scoped to parent Thing
  4. Child router has routes defined on moduleGroup replayed onto it
  5. Child router dispatches event to child routes
  6. Child route resolves InputPoint within parent's subtree

Template pattern: Routes on moduleGroup are recorded as templates, then replayed onto each lazy-created child router. This preserves route order and configuration.

Lazy creation: Child routers created on first event for parent Thing. Subsequent events reuse cached child router.


Complete Example: Access Control Panel Event Routing

namespace Pq.Adapter.Example.Communication;

/// <summary>
/// Configures event routing for access control panel devices.
/// </summary>
internal sealed class PanelEventRouting : IEventRouteConfiguration<DeviceEvent>
{
    public void Configure(IEventRoute<DeviceEvent> router)
    {
        // Access Control Events (type 5) → Reader
        router
            .When(e => e.EventType == 5)
            .WithAddress<ReaderDevice, ushort>(e => e.ReaderAddress)
            .WithPerson(e => e.UserId.ToString())
            .WithCredential(e => e.CredentialId.ToString())
            .Handle(async (reader, personId, credentialId, evt) =>
            {
                var timestamp = DeviceTimestamp.FromComponents(
                    evt.Year, evt.Month, evt.Day,
                    evt.Hour, evt.Minute, evt.Second);

                switch (evt.AccessResult)
                {
                    case 0: // Granted
                        await reader.PublishAccessGranted(personId, credentialId, timestamp, ct);
                        break;
                    case 1: // Denied
                        await reader.PublishAccessDenied(
                            GetDenialReason(evt.DenialReason),
                            timestamp,
                            ct);
                        break;
                    case 2: // Unknown credential
                        await reader.PublishAccessUnknown(credentialId, timestamp, ct);
                        break;
                }
            });

        // Door Events (type 9) - built-in module (module=0) → Door on Panel
        router
            .When(e => e.EventType == 9)
            .And(e => e.ModuleAddress == 0)
            .WithAddress<PanelController, ushort>(e => e.PanelId)
            .Handle(async (panel, evt) =>
            {
                // Built-in module: door is property of panel
                var timestamp = DeviceTimestamp.FromComponents(
                    evt.Year, evt.Month, evt.Day,
                    evt.Hour, evt.Minute, evt.Second);

                switch (evt.DoorState)
                {
                    case 0: // Closed
                        await panel.Closed(timestamp);
                        break;
                    case 1: // Opened
                        await panel.Opened(timestamp);
                        break;
                    case 2: // Forced
                        await panel.PublishForcedEntry(timestamp, ct);
                        await panel.ForcedOpen(timestamp);
                        break;
                }
            });

        // Door Events (type 9) - expansion modules (module=1+) → cascade
        var moduleGroup = router
            .When(e => e.EventType == 9)
            .MapGroup<PanelController, ushort>(e => e.PanelId);

        // Routes on moduleGroup are replayed onto child routers scoped to PanelController
        moduleGroup
            .WithAddress<ExpansionModule, byte>(e => e.ModuleAddress)
            .Handle(async (module, evt) =>
            {
                // Expansion module has its own door
                var timestamp = DeviceTimestamp.FromComponents(
                    evt.Year, evt.Month, evt.Day,
                    evt.Hour, evt.Minute, evt.Second);

                switch (evt.DoorState)
                {
                    case 0:
                        await module.Closed(timestamp);
                        break;
                    case 1:
                        await module.Opened(timestamp);
                        break;
                }
            });

        // Input Point Events (type 11) → cascade to module child routers
        var pointGroup = router
            .When(e => e.EventType == 11)
            .MapGroup<PanelController, ushort>(e => e.PanelId);

        pointGroup
            .WithAddress<InputPoint, (byte module, byte point)>(
                e => (e.ModuleAddress, e.PointNumber))
            .Handle(async (point, evt) =>
            {
                var timestamp = DeviceTimestamp.FromComponents(
                    evt.Year, evt.Month, evt.Day,
                    evt.Hour, evt.Minute, evt.Second);

                switch (evt.PointState)
                {
                    case 0:
                        await point.Input.SetInactive(timestamp, ct);
                        break;
                    case 1:
                        await point.Input.SetActive(timestamp, ct);
                        break;
                }
            });

        // catch-all for unhandled/unresolved events
        router.Unhandled(async (owner, e, reason, ct) =>
        {
            var ts = DeviceTimestamp.FromComponents(e.Year, e.Month, e.Day, e.Hour, e.Minute, e.Second);
            await owner.Unknown(ts, $"vendor:{e.EventType}:{e.SubCode}", reason, cancellationToken: ct);
        });
    }
}

Key patterns:

  1. Access events use identity resolution (WithPerson + WithCredential)
  2. Built-in module (module=0) handled before general MapGroup
  3. Expansion modules cascade via MapGroup
  4. Input points use composite address (module, point)
  5. Catch-all handler publishes unknown/unresolved events for audit trail. It is optional: when no Unhandled handler is registered, the framework still publishes device.unresolved for routes that matched but could not resolve their target Thing. Register Unhandled when you want richer detail (device timestamp, vendor event code) or to also surface unmapped (unknown) event codes — the framework leaves those to the adapter.

When to Use Event Routing

EventRouter is optional. Use it when:

Good fit:

  • Complex event structures with many event types (20+ types)
  • Hierarchical device addressing (Panel → Module → Point)
  • Multiple Thing types need different events
  • Identity resolution required (person/credential)
  • Code organization: separate routing config from handler logic

Not needed:

  • Simple devices with 5-10 event types
  • Flat addressing (no hierarchy)
  • Single Thing type handles all events
  • Manual dispatch is clearer

Alternative: Manual translation in Protocol.OnEventReceived():

protected override async Task OnEventReceived(DeviceEvent evt)
{
    var thing = FindByAddress(evt.ThingId);

    switch (evt.Type)
    {
        case 1:
            await thing.HandleEvent1(evt);
            break;
        case 2:
            await thing.HandleEvent2(evt);
            break;
        // etc.
    }
}

See Pattern 2: Event Translation for manual approach.


Relationship to Pattern 2: Event Translation

EventRouter is an evolution of Pattern 2's manual dispatch:

Pattern 2: Manual Dispatch

protected override async Task OnEventReceived(DeviceEvent evt)
{
    var door = FindByAddress(evt.DoorId) as DoorController;
    if (door is null) return;

    switch (evt.Code)
    {
        case EventCode.AccessGranted:
            var personId = await _accessSync.ResolvePerson(evt.PersonId);
            var credentialId = await _accessSync.ResolveCredential(evt.CredentialId);
            await door.PublishAccessGranted(personId, credentialId, evt.Timestamp, ct);
            break;

        case EventCode.AccessDenied:
            await door.PublishAccessDenied(GetDenialReason(evt.RawData), evt.Timestamp, ct);
            break;
    }
}

Manual dispatch:

  • All routing logic in one method
  • Explicit address resolution
  • Explicit identity resolution
  • Switch statement for event types

EventRouter: Declarative Routes

public void Configure(IEventRoute<DeviceEvent> router)
{
    router
        .When(e => e.Code == EventCode.AccessGranted)
        .WithAddress<DoorController, uint>(e => e.DoorId)
        .WithPerson(e => e.PersonId)
        .WithCredential(e => e.CredentialId)
        .Handle(async (door, personId, credentialId, evt) =>
        {
            await door.PublishAccessGranted(personId, credentialId, evt.Timestamp, ct);
        });

    router
        .When(e => e.Code == EventCode.AccessDenied)
        .WithAddress<DoorController, uint>(e => e.DoorId)
        .Handle(async (door, evt) =>
        {
            await door.PublishAccessDenied(GetDenialReason(evt.RawData), evt.Timestamp, ct);
        });
}

EventRouter:

  • Routing rules separated from handlers
  • Automatic address resolution
  • Automatic identity resolution
  • Multicast by default (all matching routes execute)
  • Terminate() opt-out for control-plane noise
  • Hierarchical cascading via MapGroup

Choose based on complexity: Simple devices → manual dispatch. Complex/hierarchical → EventRouter.


DeviceTimestamp Integration

Handlers receive device events and must extract timestamps themselves. EventRouter does not pass timestamps automatically.

router
    .When(e => e.Type == 5)
    .WithAddress<DoorController, uint>(e => e.DoorId)
    .Handle(async (door, evt) =>
    {
        // Handler extracts timestamp from event
        var timestamp = DeviceTimestamp.FromComponents(
            evt.Year, evt.Month, evt.Day,
            evt.Hour, evt.Minute, evt.Second);

        await door.Opened(timestamp);
    });

Why: Different devices report timestamps in different formats (Unix seconds, components, DateTime). Handlers decide how to extract timestamps based on device SDK.

See Device Timestamps for details.


Event Flow Diagram

┌──────────────────────────────────────────────────────────────────┐
│                         Device Hardware                          │
└───────────────────────────────┬──────────────────────────────────┘
                                │ sends bytes
                                v
                    ┌───────────────────────┐
                    │   Transport Layer     │
                    │   (serial/TCP/gRPC)   │
                    └───────────┬───────────┘
                                │ parse
                                v
                    ┌───────────────────────┐
                    │    DeviceEvent        │
                    │  (typed structure)    │
                    └───────────┬───────────┘
                                v
┌──────────────────────────────────────────────────────────────────┐
│                  EventRouter.Dispatch(evt)                       │
│                                                                  │
│  1. Check interceptor (WaitForEvent pattern)                    │
│     if intercepted → return                                     │
│                                                                  │
│  2. Evaluate routes in order (multicast):                       │
│     Route 1: predicates match? → resolve Thing → call handler   │
│              if Terminate() → STOP, else continue               │
│                                                                  │
│     Route 2: predicates match? → call handler (or MapGroup)     │
│              continue to next route...                          │
│                                                                  │
│     Unhandled: no route succeeded (all declined or unresolved)  │
│              → call Unhandled handler with UnhandledReason      │
└──────────────────────────────────────────────────────────────────┘
                                v
                    ┌───────────────────────┐
                    │   Thing.Handler       │
                    │   (publishes events)  │
                    └───────────┬───────────┘
                                v
                    ┌───────────────────────┐
                    │   PublishEvent()      │
                    │   (to NATS)           │
                    └───────────────────────┘

Implementation Reference

Source files:

  • Pq.Adapters.Framework/Events/IEventRouter.cs - Router interface
  • Pq.Adapters.Framework/Events/EventRouter.cs - Router implementation
  • Pq.Adapters.Framework/Events/IEventRoute.cs - Fluent builder interfaces
  • Pq.Adapters.Framework/Events/EventRouteBuilder.cs - Builder implementation
  • Pq.Adapters.Framework/Events/EventRouteTemplate.cs - MapGroup template replay
  • Pq.Adapters.Framework/Events/IScopedThingResolver.cs - Address resolution
  • Pq.Adapters.Framework/Events/IEventRouteConfiguration.cs - Configuration pattern

Usage pattern — manual IEventRouteConfiguration:

// 1. Define configuration class
internal sealed class MyEventRouting : IEventRouteConfiguration<DeviceEvent>
{
    public void Configure(IEventRoute<DeviceEvent> router)
    {
        // register routes
    }
}

// 2. Protocol creates router and applies configuration
private readonly EventRouter<DeviceEvent> _eventRouter;

public MyProtocol(...)
{
    _eventRouter = new EventRouter<DeviceEvent>(this, logger);
    new MyEventRouting().Configure(_eventRouter);
}

// 3. Transport dispatches events
protected override async Task OnBytesReceived(byte[] bytes)
{
    var evt = ParseEvent(bytes);
    await _eventRouter.Dispatch(evt, cancellationToken);
}

Usage pattern — source-generated partial void ConfigureRoutes:

When the adapter YAML declares event routing, the source generator emits a partial class with a generated ConfigureRoutes that wires the router. Implement the companion partial method to add your routes:

// generated (do not edit):
//   partial void ConfigureRoutes(IEventRoute<DeviceEvent> router);

internal partial class DeviceEventRouting
{
    partial void ConfigureRoutes(IEventRoute<DeviceEvent> router)
    {
        router
            .When(e => e.EventType == DeviceEventType.AccessGranted)
            .WithAddress<Panel, int>(e => e.PartitionId)
            .WithPerson(e => e.UserId.ToString())
            .Handle(async (panel, personId, evt) => { ... });
    }
}

The generated side calls ConfigureRoutes during construction; the partial method is a no-op if not implemented.


Dispatch Chain

Understanding how events flow from the transport to the router helps when debugging missing or duplicated dispatches.

Protocol.OnEvent callback
    └─> Panel.Connect() wires the callback on startup
            └─> DeviceConnection.Dispatch(evt)
                    └─> EventRouter<DeviceEvent>.Dispatch(evt, ct)
                            └─> route match → Thing handler

In practice:

  1. Panel.Connect() subscribes _connection.OnEvent = e => _router.Dispatch(e, ct) when opening the connection
  2. The transport (DeviceConnection) calls that callback on every received packet
  3. EventRouter.Dispatch evaluates routes and calls the matched handler

If events are not reaching handlers, verify that Panel.Connect actually wires the callback before starting to receive data.


See Also

Concepts:

Patterns:

Reference: