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:
- Parse the bytes into a device-specific event structure
- Find the correct Thing instance to handle it
- 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:
- Extract address from event:
e => e.DoorIdreturns5 - IScopedThingResolver finds DoorController with
Address == 5 - 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
personIdresolves 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 callsIdentityResolver.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:
personIdresolved → passpersonId.- not resolved, but the vendor sent a user/position/id → pass it as
externalPersonRef(astring?on the function method), keeping the precise event.- 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.
personIdis optional on such events, andexternalPersonRefexists 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:
- Parent route matches event:
When(e => e.Type >= 10 && e.Type <= 20) - Resolve parent Thing:
PanelControllerwithPanelId == evt.PanelId - Get or create child router scoped to parent Thing
- Child router has routes defined on
moduleGroupreplayed onto it - Child router dispatches event to child routes
- Child route resolves
InputPointwithin 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:
- Access events use identity resolution (WithPerson + WithCredential)
- Built-in module (module=0) handled before general MapGroup
- Expansion modules cascade via MapGroup
- Input points use composite address
(module, point) - Catch-all handler publishes unknown/unresolved events for audit trail. It is optional: when no
Unhandledhandler is registered, the framework still publishesdevice.unresolvedfor routes that matched but could not resolve their target Thing. RegisterUnhandledwhen 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 interfacePq.Adapters.Framework/Events/EventRouter.cs- Router implementationPq.Adapters.Framework/Events/IEventRoute.cs- Fluent builder interfacesPq.Adapters.Framework/Events/EventRouteBuilder.cs- Builder implementationPq.Adapters.Framework/Events/EventRouteTemplate.cs- MapGroup template replayPq.Adapters.Framework/Events/IScopedThingResolver.cs- Address resolutionPq.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:
Panel.Connect()subscribes_connection.OnEvent = e => _router.Dispatch(e, ct)when opening the connection- The transport (
DeviceConnection) calls that callback on every received packet EventRouter.Dispatchevaluates 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:
- Events - Event structure and taxonomy
- Device Timestamps - Timestamp handling in handlers
- Things - Thing hierarchy and addressing
Patterns:
- Pattern 2: Event Translation - Manual dispatch alternative
- Pattern 7: Address Resolution - IProtocolAddressable interface
Reference:
- Generated Code API - PqEvent builders