Step-by-Step Guide¶
Prerequisites:
- .NET 10 SDK installed
- Understanding of async/await patterns
- Familiarity with dependency injection
- Access to device SDK documentation
Step 1: Create Project Structure¶
1.1 Create Project¶
1.2 Create .csproj¶
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>.\.GeneratedFiles</CompilerGeneratedFilesOutputPath>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Pq.Adapters.Framework\Pq.Adapters.Framework.csproj" />
<ProjectReference Include="..\..\Pq.Domain\Pq.Domain.csproj" />
<ProjectReference Include="..\..\Pq.Adapters.Framework.SourceGenerator\Pq.Adapters.Framework.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="adapter-registration.yaml" />
<!-- Optional: <AdditionalFiles Include="access-model.yaml" /> -->
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
1.3 Create appsettings.json¶
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"System": "Warning"
}
},
"Adapter": {
"Id": "unique-adapter-instance-id",
"NatsUrl": "nats://localhost:4222"
}
}
Step 2: Define Device Types (adapter-registration.yaml)¶
Before writing YAML, choose the closest archetype from archetypes/ and define the smallest file set you actually need.
2.1 Basic Metadata¶
adapter_id: "00000000-0000-0000-0000-000000000000" # Generate new GUID
name: "Vendor Product Adapter"
version: "1.0.0"
manufacturer: "Vendor Name"
description: "Integration for Vendor Product series controllers"
adapter_type: "security"
security_domains: ["access"]
capabilities:
auto_discovery: false
time_sync: true
firmware_update: false
config_backup: false
2.2 Transport Configuration¶
For TCP/IP protocols:
transport:
type: tcp
protocol:
type: "Events"
event_type: "Pq.Adapter.Vendor.Product.Communication.DeviceEvent"
address_type: uint
For custom protocols (gRPC, serial):
transport:
type: custom
protocol:
type: "Events"
event_type: "Pq.Adapter.Vendor.Product.Communication.DeviceEvent"
address_type: string
2.3 Define Device Types¶
Example: Controller with doors
device_types:
# Main controller
- type_id: "Controller"
name: "Main Controller Unit"
description: "Central processing unit with integrated I/O"
commands:
- pq.command.reset
properties:
ip_address:
type: host
required: true
category: connection
description: "Controller host or IP address"
port:
type: int
required: false
default: 4050
range: [1, 65535]
category: connection
password:
type: string
required: false
sensitive: true
category: connection
functions:
Power:
ConnectionBase:
extended_construction: true
# Access point (door)
- type_id: "Door"
name: "Access Point"
description: "Door with reader and lock"
access_point: true
commands:
- pq.command.access.open
- pq.command.access.lock
- pq.command.access.unlock
properties:
address:
type: uint
required: true
description: "Door number on controller"
name:
type: string
required: true
description: "Door friendly name"
functions:
Door:
Reader:
parents:
Controller:
max_siblings: 32
required: true
Key points:
type_id- Unique identifier for device typeaccess_point: true- Marks as IAccessPoint (generates access synchronization)commands- References from pq-commands.yamlfunctions- References from functions.yaml or custom_functionsparents- Defines hierarchy and cardinalityproperties- Generate validated properties with defaults
Step 3: Implement Transport (if needed)¶
IMPORTANT: For TCP request-reply protocols, skip this step entirely.
The framework generates Transport class automatically when you configure transport: { type: tcp } in YAML. This generated Transport:
- Manages TCP connections per device
- Provides
ConnectChannel()for Protocol to use - Handles disconnect detection and retry coordination
Only implement custom Transport for:
- gRPC connections
- Serial/COM port communication
- Vendor SDK with native callbacks
- Shared process management (e.g., external gateway executables)
If using TCP, proceed directly to Step 4.1.
3.1 Using Framework Transports (TCP/UDP)¶
For standard TCP/UDP protocols, framework handles everything automatically. Just configure in YAML:
transport:
type: tcp # or udp
protocol:
event_type: "Pq.Adapter.Vendor.Product.Communication.DeviceEvent"
address_type: uint
Framework provides:
- Connection management
- Message queuing
- Event routing to Protocol instances
- Automatic retry and reconnect
You only implement:
- Protocol class (device-specific communication)
- Event translation
3.2 Custom Transport (gRPC, Serial, etc.)¶
For custom protocols, create Communication/Transport.cs:
using Pq.Adapters.Framework.Communication;
namespace Pq.Adapter.Vendor.Product.Communication;
/// <summary>
/// Manages shared SDK resources (gRPC channels, SDK processes) for all devices.
/// One instance per adapter - only for custom protocols.
/// </summary>
internal sealed class Transport : TransportBase
{
private readonly ILogger<Transport> _logger;
private GrpcChannel? _grpcChannel;
public Transport(ILogger<Transport> logger)
: base(logger)
{
_logger = logger;
}
/// <summary>
/// Initialize SDK resources on adapter start.
/// </summary>
protected override async Task OnStart(CancellationToken cancellationToken)
{
_logger.LogInformation("Initializing gRPC channel");
_grpcChannel = GrpcChannel.ForAddress("http://localhost:4000");
await _grpcChannel.ConnectAsync(cancellationToken);
}
/// <summary>
/// Cleanup SDK resources on adapter stop.
/// </summary>
protected override async Task OnStop(CancellationToken cancellationToken)
{
_logger.LogInformation("Disposing gRPC channel");
if (_grpcChannel != null)
{
await _grpcChannel.ShutdownAsync();
_grpcChannel.Dispose();
_grpcChannel = null;
}
}
/// <summary>
/// Execute device command with queuing (thread-safe).
/// </summary>
public Task<TResult> ExecuteCommand<TResult>(Func<Task<TResult>> command)
=> Enqueue(command);
/// <summary>
/// Get gRPC channel for protocol use.
/// </summary>
public GrpcChannel GetChannel()
=> _grpcChannel ?? throw new InvalidOperationException("gRPC not initialized");
}
Key responsibilities:
- Initialize/dispose custom communication resources
- Manage shared SDK clients, channels, or processes
- Provide command queuing via
Enqueue() - Route events to correct Protocol instance (if needed)
Step 4: Implement Protocol (Per-Device Logic)¶
Follow the closest self-contained archetype and keep the protocol boundary small: device operations in protocol, shared infrastructure in transport, mapping in routing code.
4.1 For TCP Request-Reply Protocols (Most Common)¶
If your device uses TCP with request-response pattern, follow the dedicated archetype:
- Read archetypes/tcp-request-reply.md - complete implementation pattern
- Reference concepts/tcp-transport.md - API details, retry, bulk operations
- Use the "Retry Design Checklist" in archetypes/tcp-request-reply.md before finalizing retry behavior
Quick summary:
- Inherit
TcpProtocolBase<TEvent, TFrame>(notProtocolBase) - Implement
FrameBasesubclass withSerialize()andIsResponseTo() - Implement
IFrameParser<TFrame>for byte-stream parsing - Use
await Send(request, timeout, ct)for commands - Override
TransformToEvents()for unsolicited device events
Skip to Step 5 after implementing TCP protocol.
4.2 For Custom Protocols (gRPC, Serial, SDK)¶
For non-TCP protocols (gRPC, serial, vendor SDK), create Communication/Protocol.cs:
using Pq.Adapters.Framework.Communication;
namespace Pq.Adapter.Vendor.Product.Communication;
/// <summary>
/// Handles protocol communication for a specific device.
/// One instance per device Thing.
/// </summary>
internal sealed class Protocol : ProtocolBase<DeviceEvent>, IEventReceiver<DeviceEvent>
{
private readonly Transport _transport;
private readonly ILogger<Protocol> _logger;
private readonly ControllerDevice _device;
public Protocol(
Transport transport,
ControllerDevice device,
ILogger<Protocol> logger)
: base(logger)
{
_transport = transport;
_device = device;
_logger = logger;
}
/// <summary>
/// Called when device connects (from IDeviceConnection).
/// </summary>
protected override async Task OnConnected(CancellationToken cancellationToken)
{
_logger.LogInformation("Device {DeviceId} connected", _device.DeviceId);
// perform initial device configuration
var sdk = _transport.GetSdkClient();
await sdk.ConfigureDevice(_device.IpAddress, cancellationToken);
// subscribe to device events
await sdk.SubscribeEvents(_device.Address, cancellationToken);
}
/// <summary>
/// Called when device disconnects.
/// </summary>
protected override Task OnDisconnected(CancellationToken cancellationToken)
{
_logger.LogInformation("Device {DeviceId} disconnected", _device.DeviceId);
ClearPendingCommands(); // important: cancel waiting commands
return Task.CompletedTask;
}
/// <summary>
/// Receive events from Transport and dispatch to device tree.
/// </summary>
public void ReceiveEvent(DeviceEvent evt)
{
DispatchEvent(evt); // routes to OnEventReceived
}
/// <summary>
/// Translate device events to PQ domain events.
/// </summary>
protected override Task OnEventReceived(DeviceEvent evt)
{
// find target Thing in device tree
var door = _device.Children.OfType<DoorDevice>()
.FirstOrDefault(d => d.Address == evt.DoorId);
if (door == null)
return Task.CompletedTask;
// translate event based on event code
return evt.Code switch
{
0x1000 => door.OnAccessGranted(evt),
0x1001 => door.OnAccessDenied(evt),
0x2000 => door.OnDoorOpened(evt),
0x2001 => door.OnDoorClosed(evt),
_ => Task.CompletedTask
};
}
/// <summary>
/// Execute command via SDK with queuing.
/// </summary>
public Task<bool> OpenDoor(uint doorId, CancellationToken cancellationToken)
{
return _transport.ExecuteCommand(async () =>
{
var sdk = _transport.GetSdkClient();
return await sdk.OpenDoor(_device.Address, doorId, cancellationToken);
});
}
}
Key responsibilities:
- Per-device command execution
- Event translation to PQ events
- Event routing to child Things
- Connection lifecycle hooks
Step 5: Implement Device Things¶
Create Devices/ControllerDevice.cs:
namespace Pq.Adapter.Vendor.Product.Devices;
/// <summary>
/// Main controller device - extends generated partial class.
/// </summary>
internal sealed partial class ControllerDevice : IDeviceConnection
{
private readonly Protocol _protocol;
private readonly ILogger<ControllerDevice> _logger;
private TcpClient? _connection;
// Constructor called by framework
public ControllerDevice(
Protocol protocol,
ILogger<ControllerDevice> logger)
{
_protocol = protocol;
_logger = logger;
}
/// <summary>
/// Establish device connection (called by framework with retry).
/// </summary>
public async Task<bool> Connect(ConnectionContext context, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Connecting to {IpAddress}:{Port}", IpAddress, Port);
_connection = new TcpClient();
await _connection.ConnectAsync(IpAddress, Port ?? 4050, cancellationToken);
// authenticate if needed
await Authenticate(cancellationToken);
// notify protocol
await _protocol.OnConnected(cancellationToken);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Connection failed");
return false;
}
}
/// <summary>
/// Disconnect from device.
/// </summary>
public async Task Disconnect(CancellationToken cancellationToken)
{
await _protocol.OnDisconnected(cancellationToken);
_connection?.Close();
_connection = null;
}
private async Task Authenticate(CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(Password))
return;
// send login command via SDK
await _protocol.ExecuteCommand(async () =>
{
// your SDK login logic
return true;
});
}
}
Create Devices/DoorDevice.cs:
namespace Pq.Adapter.Vendor.Product.Devices;
/// <summary>
/// Door access point - extends generated partial class.
/// </summary>
internal sealed partial class DoorDevice
{
private readonly Protocol _protocol;
private readonly ILogger<DoorDevice> _logger;
public DoorDevice(
Protocol protocol,
ILogger<DoorDevice> logger)
{
_protocol = protocol;
_logger = logger;
}
/// <summary>
/// Called when device reports access granted event.
/// </summary>
internal async Task OnAccessGranted(DeviceEvent evt)
{
// Publish using generated PqEvent class
await PublishEvent(
PqEvent.Access.Granted
.At(evt.Timestamp, this)
.WithIdentity(evt.PersonId, evt.CredentialId)
.WithReason(evt.AccessMode.ToString())
.Build(),
CancellationToken.None
);
// Update door state via function (preferred for standard states)
// await this.Opened(DeviceTimestamp.UtcNow);
}
}
Command Handling¶
Create Commands/DoorCommands.cs:
using Pq.Adapters.Framework;
using Pq.Adapters.Framework.Commands; // for Access.Open, Access.Lock, etc.
namespace Pq.Adapter.Vendor.Product.Commands;
/// <summary>
/// Command handlers for door operations.
/// Framework auto-discovers these by signature - no interface needed.
/// </summary>
public static class DoorCommands
{
/// <summary>
/// Opens a door (momentary unlock).
/// </summary>
/// <param name="door">Target door Thing.</param>
/// <param name="command">Typed command from framework.</param>
/// <param name="protocol">Protocol instance (DI injected).</param>
/// <returns>Command result.</returns>
public static async Task<DeviceCommandResult> Open(
DoorDevice door,
Access.Open command, // ← Typed command class from framework
Protocol protocol) // ← Additional params resolved from DI
{
var success = await protocol.OpenDoor(door.Address, default);
return success
? new DeviceCommandResult(CommandResult.Success)
: new DeviceCommandResult(CommandResult.Failure);
}
/// <summary>
/// Locks a door (permanent lock).
/// </summary>
public static async Task<DeviceCommandResult> Lock(
DoorDevice door,
Access.Lock command,
Protocol protocol)
{
var success = await protocol.LockDoor(door.Address, default);
if (success)
await door.LockedRemote(DeviceTimestamp.UtcNow, command.OperatorIdentity);
return success
? DeviceCommandResult.Succeeded()
: DeviceCommandResult.Failed();
}
}
Key points:
- Typed commands: Framework generates
Access.Open,Access.Lock, etc. - No
ICommandDispatcher- framework detects handlers by signature - Static methods - no instance needed, framework calls directly
- Additional parameters - resolved from DI (Protocol, ILogger, etc.)
- Signature requirements:
public static(or instance method in Thing)Task<DeviceCommandResult>orValueTask<DeviceCommandResult>return- First param: Thing type
- Second param: Command type (
Access.Open, etc.) - Additional params: DI services
Alternative: Handler in Thing class
// In DoorDevice.cs
internal sealed partial class DoorDevice
{
/// <summary>
/// Self-handler - Thing handles its own command.
/// </summary>
public async Task<DeviceCommandResult> Open(
Access.Open command, // ← No Thing param (this IS the Thing)
Protocol protocol) // ← DI injected
{
var success = await protocol.OpenDoor(Address, default);
return success
? new DeviceCommandResult(CommandResult.Success)
: new DeviceCommandResult(CommandResult.Failure);
}
}
Framework detection rules:
- Scans all classes for matching method signatures
- No interface or naming requirements - pure signature detection
- Validates against commands declared in YAML
- Emits warnings if handler exists without YAML declaration (or vice versa)
Step 6: Dependency Injection (Optional)¶
Framework auto-registers:
- Generated DeviceAdapterBase
- Thing classes
- Protocol instances (one per device)
- Transport (if custom)
You only need DI configuration for:
- Custom services (validators, mappers, etc.)
- Shared resources (SDK clients, process managers)
- Mock implementations for testing
Example (only if needed):
using Microsoft.Extensions.DependencyInjection;
namespace Pq.Adapter.Vendor.Product;
internal static class DependencyInjection
{
public static IServiceCollection AddAdapterServices(this IServiceCollection services)
{
// Only register custom services
services.AddSingleton<ISdkClientFactory, SdkClientFactory>();
services.AddTransient<IEventMapper, EventMapper>();
return services;
}
}
Note: Protocol and Transport are already registered by framework - don't re-register them.
Step 7: Build and Test¶
7.1 Build Project¶
Verify source generation:
- Check
.GeneratedFiles/directory for generated Thing classes - Review generated function interfaces
7.2 Run Adapter¶
7.3 Verify Connectivity¶
Check logs for:
- SDK initialization
- Device connection attempts
- Event reception
Step 8: Implement Access Synchronization (Optional)¶
If your device supports offline access control, implement access synchronization.
See 03-patterns.md section "Access Synchronization Pattern" for detailed guide.
Next Steps¶
- Add error handling - retry logic, timeout handling
- Implement remaining commands - lock, unlock, reset
- Add event translation - complete event mapping
- Test with real hardware - verify all functions
- Add custom functions - device-specific capabilities
- Add device configuration import - if the device can enumerate its own subtree, implement
IDeviceImportDiscoveryon the root and follow Pattern 14 - Implement enrollment - biometric template capture (if applicable)
- Performance tuning - batch operations, caching
Common Mistakes¶
- Returning
truebefore connection initialization completes - lifecycle state becomes misleading - Not calling
ClearPendingCommands()on disconnect - commands hang - Blocking in
OnEventReceived()- use async operations - Not validating properties - framework does basic validation, add business logic
- Mixing sync/async - always use async patterns
- Hardcoded addresses - use
IProtocolAddressable<T>.Address - Missing error handling - SDK calls can fail, always try/catch
- Leaking resources - dispose connections in
OnStop()
Debugging Tips¶
- Enable trace logging - set
LogLevel.Tracein appsettings.json - Use breakpoints - in
OnEventReceived()to see all events - Check DeviceTreeRegistry - verify Thing hierarchy is correct
- Monitor NATS - use
nats sub ">"to see all messages - Review generated code - understand what framework provides
- Test incremental - connection → events → commands → access sync