Troubleshooting¶
Common issues and their solutions.
Build & Source Generation Issues¶
Problem: Generated files not appearing¶
Symptoms:
- Build succeeds but no
.GeneratedFiles/directory - "Type or namespace not found" errors for Thing classes
Solutions:
-
Verify YAML is marked as AdditionalFiles:
-
Check YAML syntax:
-
Enable generator diagnostics:
-
Clean and rebuild:
-
Check generator errors:
- Look for
AdapterSourceGeneratorwarnings in build output - Check MSBuild binlog:
dotnet build /bl - Open
msbuild.binlogin MSBuild Structured Log Viewer
Problem: "Partial class has no definition"¶
Cause: Generated partial class doesn't exist.
Solution: Device type in YAML must match namespace convention:
# YAML
device_types:
- type_id: "ControllerDevice" # ← Must match class name
# C#
namespace Pq.Adapter.Vendor.Product.Devices;
internal sealed partial class ControllerDevice { } # ← Must match type_id
Problem: Property validation errors at runtime¶
Cause: YAML property constraints violated.
Example:
Solution: Validate configuration before adapter starts:
protected override Task OnStart(CancellationToken cancellationToken)
{
if (Port < 1 || Port > 65535)
throw new ArgumentOutOfRangeException(nameof(Port), "Port must be 1-65535");
// ...
}
Connection Issues¶
Problem: Device never connects¶
Symptoms:
Connect()called repeatedly- Logs show "Connection failed, retrying..."
- Framework retry loop active
Debug steps:
-
Check network connectivity:
-
Verify credentials:
- Password correct?
- Device locked out?
-
SDK license valid?
-
Check return value:
-
Enable verbose SDK logging:
Problem: ConnectionBase function not updating in legacy/custom lifecycle code¶
Symptoms:
- Device connects but status stays "Disconnected"
- NATS doesn't receive status updates
Cause: Legacy/custom lifecycle code bypasses the normal IDeviceConnection return-value lifecycle.
Solution:
public async Task<bool> Connect(ConnectionContext context, CancellationToken ct)
{
return await _protocol.ConnectAndInitialize(ct);
}
For current adapters, prefer returning true/false from IDeviceConnection.Connect; raw signal calls are legacy/custom-only.
Problem: "Connection lost" spam¶
Symptoms:
- Rapid connect/disconnect cycles
- Connection metrics show < 1 second uptime
Causes:
- Receive loop crashing:
Fix: Add try/catch:
try
{
ParseAndDispatch(data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Parse failed");
// don't disconnect on parse errors
}
- Heartbeat/keepalive missing:
- Some SDKs require periodic ping
-
Add timer to send keepalive packets
-
SDK timeout too aggressive:
- Increase SDK socket timeout
- Add connection health check
Event Issues¶
Problem: Missing events in audit trail¶
Symptoms:
- Some device events don't appear in PQ UI
- Audit trail has gaps
- ABAC rules not triggering
CRITICAL REQUIREMENT: Adapters MUST translate ALL relevant device events to PQ events.
Common mistake: Only translating access events (granted/denied) and ignoring:
- Door state changes (opened, closed, forced)
- Alarms (tamper, duress, fire)
- System events (offline, online, battery low)
- Reader events (card read failure, biometric timeout)
- Configuration changes (schedule updated, user added)
What to exclude:
- Protocol acks and confirmations
- Keepalive/heartbeat messages
- Low-level transport messages
- Internal SDK state changes
Solution: Review device event documentation, map EVERY user-visible event to PQ taxonomy.
For unmapped events, publish an unknown/unresolved event instead of dropping them. Prefer the generated router Unhandled hook:
router.Unhandled(async (owner, evt, reason, ct) =>
{
await owner.Unknown(
evt.Timestamp,
$"vendor:0x{evt.Code:X4}",
reason,
rawData: Convert.ToBase64String(evt.RawData),
cancellationToken: ct);
_logger.LogWarning("Unmapped event {Code} published as unknown", evt.Code);
});
Known events should call generated function methods or generated PqEvent builders from the matched Thing:
router.Route(
e => e.Code == 0x1000,
e => e.DeviceId,
async (door, evt, ct) =>
{
await door.Opened(evt.Timestamp);
});
Manual dispatch should use the same rule:
switch (evt.Code)
{
case 0x1000:
await door.Opened(evt.Timestamp);
break;
default:
await door.Unknown(evt.Timestamp, $"0x{evt.Code:X4}", UnhandledReason.Unmapped,
rawData: Convert.ToBase64String(evt.RawData), cancellationToken: ct);
break;
}
Why this matters:
- Preserves complete audit trail
- PQ can detect missing mappings
- Debugging is easier with raw data
- Compliance requirements met
Problem: Events not appearing in PQ UI¶
Debug steps:
-
Verify event reception:
-
Check event taxonomy:
- Event name must exist in
pq-events.yaml -
Use exact string:
"pq.event.access.granted"not"access.granted" -
Verify NATS publishing:
-
Check device ID:
- Every event needs valid
DeviceId -
Must match Thing.DeviceId in device tree
-
Validate timestamp:
- Use device timestamp, not
DateTime.UtcNow - Format: ISO 8601 or Unix epoch
Problem: Events duplicated¶
Causes:
- Multiple event subscriptions:
- SDK event handler registered multiple times
-
Unsubscribe in
OnDisconnected() -
Event not consumed in WaitForEvent:
Problem: Wrong identity in events¶
Symptoms:
- Access event shows wrong person
- ABAC rules don't trigger
Cause: Protocol address not mapped to PQ ID.
Solution: Resolve protocol IDs to PQ IDs via AccessSynchronizationFunction, then call the typed function method (not EventBuilder — see RULE-003):
var personId = _accessSync.ResolvePerson(evt.ProtocolPersonId);
var credentialId = _accessSync.ResolveCredential(evt.ProtocolCardNumber);
await reader.AccessGranted(evt.Timestamp, credentialId, personId: personId);
Problem: Person not in access-sync storage (ResolvePerson returns null)¶
Symptoms:
- A specific, known denial/grant event is logged as the generic
pq.event.access.deniedinstead of its precise sub-type (e.g.…antipassback) - The event shows no person even though the device sent a user ID
Cause: The vendor user has no mapping in access-sync storage, so ResolvePerson returns null — and the handler downgraded the event type to compensate. That loses information you already had (RULE-027 rung 2).
Solution: Keep the precise event type; degrade only the identity detail. Pass the vendor reference as externalPersonRef when personId is null:
var personId = _accessSync.ResolvePerson(evt.UserId.ToString());
await reader.AccessDeniedAntipassback(
evt.Timestamp,
externalPersonRef: personId.HasValue ? null : evt.UserId.ToString(),
personId: personId);
// renders: "Access denied to unknown person (120) at Door 5: antipassback violation"
personId is optional on these events; never fall back to a generic event just because it did not resolve.
Access Synchronization Issues¶
Problem: "Address space exhausted"¶
Symptoms:
- Sync fails with
AddressSpaceExhaustedException - Only first N entities upload successfully
Cause: Range in access-model.yaml too small.
Solution:
Check device limits:
- Consult SDK documentation for max entities
- Some devices have slot limits (e.g., 32K users max)
Problem: "Address already assigned"¶
Symptoms:
- First access sync works, next sync fails.
- Exception contains
InvalidOperationException: Address already assigned.
Cause: Transform() mutates address state (SetAddress() or address-prefilled objects), then differ sets address again for unchanged entities.
Do not do this:
- Do not call
SetAddress()inTransform(). - Do not read
entity.AddressinTransform()to create references.
Do this instead:
- Keep entity addresses null in
Transform()and return object references (EntityName/EntityName[]). - Set address only when device truly assigns it in command response.
Verification: Run identical sync twice. Second run must not throw.
Problem: Sync never completes¶
Symptoms:
StartSynchronization()called but no completion- PQ UI shows "Syncing..." indefinitely
Cause: Not calling completion methods.
Solution:
public async Task PerformSync(CancellationToken cancellationToken)
{
try
{
await _accessSync.StartSynchronization(cancellationToken);
// ... upload logic
await _accessSync.CompleteSynchronization(cancellationToken); // ← Must call on success
}
catch (Exception ex)
{
logger.LogError(ex, "Access synchronization failed");
await _accessSync.FailSynchronization(cancellationToken); // ← Must call on error
throw;
}
}
Problem: Changes not detected¶
Symptoms:
- Person modified in PQ but device not updated
- Expected UPDATE but got no operation
Cause: Hash computation doesn't match.
Debug:
// In access model entity
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(CardNumber);
hash.Add(Name);
hash.Add(ValidFrom);
// ❌ Missing ValidUntil - change won't be detected!
return hash.ToHashCode();
}
Solution: Include ALL properties in hash:
Problem: "Missing dependency" error¶
Cause: Access model entities in wrong order.
Solution: Define in dependency order (leaves first):
model:
# Layer 0 - no dependencies
- type_id: Schedule
# Layer 1 - references Schedule
- type_id: AccessLevel
properties:
schedule_address: uint # ← points to Schedule
# Layer 2 - references AccessLevel
- type_id: Cardholder
properties:
access_levels: uint[] # ← points to AccessLevel
Command Handling Issues¶
Problem: Commands not found / timeout¶
Symptoms:
- PQ UI shows "Command not found" or "Command timed out"
- Command reaches adapter but no response
Causes:
-
Command not declared in YAML:
-
Handler signature mismatch:
// ❌ Wrong - missing DeviceCommandResult return type public async Task Open(DoorDevice door, Access.Open cmd) { } // ✅ Correct public static async Task<DeviceCommandResult> Open( DoorDevice door, Access.Open cmd, Protocol protocol, CancellationToken ct) { await protocol.OpenDoor(door.Address, ct); return DeviceCommandResult.Succeeded(); } -
Missing
publicaccessibility: -
Missing Thing or command parameter:
// ❌ Wrong - external handler needs both Thing and command parameters public static async Task<DeviceCommandResult> Open( Access.Open cmd, Protocol protocol) // ✅ Recommended external handler shape public static async Task<DeviceCommandResult> Open( DoorDevice door, // ← Thing first Access.Open cmd, // ← Command second Protocol protocol)
Problem: "Command handler not found" warning¶
Symptoms:
- Build warning:
PQ0001: Command handler not found - Command declared in YAML but not handled
Cause: No method matching command signature.
Solution:
// In Commands/DoorCommands.cs
public static class DoorCommands
{
// Framework looks for method accepting Access.Open command
public static async Task<DeviceCommandResult> Open(
DoorDevice door,
Access.Open command, // ← Must match command type
Protocol protocol,
CancellationToken ct)
{
// implementation
await protocol.OpenDoor(door.Address, ct);
return DeviceCommandResult.Succeeded();
}
}
Problem: "Command handler without YAML declaration" warning¶
Symptoms:
- Build warning:
PQ0002: Command handler without YAML declaration - Handler exists but command not in device type's
commandslist
Cause: Handler method exists but command not declared in YAML.
Solution: Add command to device type:
device_types:
- type_id: "Door"
commands:
- pq.command.access.open # ← Add missing command
- pq.command.access.lock
- pq.command.access.unlock
Problem: Handler called but execution fails¶
Symptoms:
- No build warnings
- Command reaches handler
- Exception or wrong behavior
Debug:
public static async Task<DeviceCommandResult> Open(
DoorDevice door,
Access.Open command,
Protocol protocol,
ILogger<DoorCommands> logger, // ← Add logger for debugging
CancellationToken ct)
{
logger.LogInformation("Opening door {Address}", door.Address);
try
{
await protocol.OpenDoor(door.Address, ct);
logger.LogInformation("Door opened successfully");
return DeviceCommandResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to open door");
return DeviceCommandResult.Failed();
}
}
Performance Issues¶
Problem: Slow event processing¶
Symptoms:
- Events delayed by seconds
- Event queue building up
Causes:
- Blocking in OnEventReceived:
Fix:
protected override async Task OnEventReceived(DeviceEvent evt)
{
await Task.Delay(1000); // ✅ Use async
}
- Synchronous SDK calls:
Fix:
Problem: High memory usage¶
Causes:
- Event buffer not cleared:
- Some SDKs buffer events in memory
-
Call acknowledge/commit after processing
-
LiteDB not disposed:
-
Large event payloads:
- Don't include raw bytes in logs
- Trim unnecessary event data
NATS / Messaging Issues¶
Problem: "NATS connection refused"¶
Cause: NATS server not running.
Solution:
Problem: Events published but not received by server¶
Symptoms:
- Adapter logs show
PublishEventcalls - PQ server doesn't receive events
Debug:
Causes:
- Wrong subject format:
- Framework publishes events to
pq.event.{category}.{adapterId}.{thingId} - Framework publishes status to
pq.status.{adapterId} - Framework receives commands on
pq.command.{adapterId} -
Don't manually publish to NATS
-
Event validation failed:
- Check event builder includes required fields
- Verify DeviceId is valid GUID
Debugging Techniques¶
Enable maximum logging¶
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Pq.Adapter": "Trace",
"Pq.Adapters.Framework": "Trace"
}
}
}
Inspect device tree¶
// In DeviceAdapterBase
protected override Task OnStart(CancellationToken cancellationToken)
{
_logger.LogInformation("Device tree: {@Tree}", DumpTree());
return base.OnStart(cancellationToken);
}
private object DumpTree(Thing? thing = null, int depth = 0)
{
thing ??= this;
return new
{
Type = thing.GetType().Name,
DeviceId = thing.DeviceId,
Children = thing.Children.Select(c => DumpTree(c, depth + 1))
};
}
Monitor NATS traffic¶
# All messages
nats sub ">"
# Device events only
nats sub "pq.event.>"
# Adapter status only
nats sub "pq.status.<adapter-id>"
Use breakpoints effectively¶
- OnEventReceived - see all incoming events
- IDeviceConnection.Connect/Disconnect - verify connection flow
- DispatchCommand - debug command handling
- CreateCommands - inspect access upload operations
Capture network traffic¶
# For TCP protocols
tcpdump -i any -s 65535 -w capture.pcap port <device-port>
# Analyze in Wireshark
wireshark capture.pcap
Common Mistakes Summary¶
- ❌ Bypassing
IDeviceConnectionlifecycle or returningtruebefore initialization completes - ❌ Blocking in async methods (
Thread.Sleep, sync I/O) - ❌ Command handler not
publicor wrong return type - ❌ Command declared in YAML but no matching handler signature
- ❌ Forgetting
ClearPendingCommands()on disconnect - ❌ Wrong event taxonomy strings (typos, wrong namespace)
- ❌ Using protocol IDs instead of PQ IDs in events
- ❌ Not disposing resources in
OnStop() - ❌ Missing properties in hash computation
- ❌ Wrong access model dependency order
- ❌ Partial class name mismatch with YAML
type_id - ❌ Implementing custom transport for TCP when framework TCP transport fits; use
customfor serial, UDP, vendor SDKs, or protocol-specific transports
Getting Help¶
- Check framework reference:
- Functions Registry - complete function reference
- Generated Code API - PqEvent, commands, functions
-
Event Taxonomy - all event types and usage
-
Review this documentation set:
- Check the closest archetype under
archetypes/ - Look for related patterns and examples in this folder
-
Prefer generalized guidance over ad hoc inference
-
Enable diagnostics:
- Trace logging
- NATS monitoring
-
Network captures
-
Create minimal repro:
- Isolate issue to smallest code snippet
- Remove vendor-specific details
-
Share logs and stack traces
-
Document findings:
- Note any documentation gap you find so it can be addressed
- Record device-specific quirks alongside your adapter
- Help future developers