Skip to content

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

mkdir Pq.Adapter.Vendor.Product

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 type
  • access_point: true - Marks as IAccessPoint (generates access synchronization)
  • commands - References from pq-commands.yaml
  • functions - References from functions.yaml or custom_functions
  • parents - Defines hierarchy and cardinality
  • properties - 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:

  1. Read archetypes/tcp-request-reply.md - complete implementation pattern
  2. Reference concepts/tcp-transport.md - API details, retry, bulk operations
  3. Use the "Retry Design Checklist" in archetypes/tcp-request-reply.md before finalizing retry behavior

Quick summary:

  • Inherit TcpProtocolBase<TEvent, TFrame> (not ProtocolBase)
  • Implement FrameBase subclass with Serialize() and IsResponseTo()
  • 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> or ValueTask<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:

  1. Scans all classes for matching method signatures
  2. No interface or naming requirements - pure signature detection
  3. Validates against commands declared in YAML
  4. 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

dotnet build

Verify source generation:

  • Check .GeneratedFiles/ directory for generated Thing classes
  • Review generated function interfaces

7.2 Run Adapter

dotnet run

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 IDeviceImportDiscovery on the root and follow Pattern 14
  • Implement enrollment - biometric template capture (if applicable)
  • Performance tuning - batch operations, caching

Common Mistakes

  1. Returning true before connection initialization completes - lifecycle state becomes misleading
  2. Not calling ClearPendingCommands() on disconnect - commands hang
  3. Blocking in OnEventReceived() - use async operations
  4. Not validating properties - framework does basic validation, add business logic
  5. Mixing sync/async - always use async patterns
  6. Hardcoded addresses - use IProtocolAddressable<T>.Address
  7. Missing error handling - SDK calls can fail, always try/catch
  8. Leaking resources - dispose connections in OnStop()

Debugging Tips

  • Enable trace logging - set LogLevel.Trace in 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