Adapter Credential Synchronization Guide¶
Complete guide for adapter developers implementing credential/access synchronization to physical devices.
Overview¶
Access synchronization transfers person credentials (cards, PINs, biometrics) and access schedules from PQ server to physical access control devices. The framework handles complexity (diffing, ordering, storage), adapter implements device-specific logic.
What Framework Does¶
- Fetches authorization data from PQ API
- Detects changes using hash comparison
- Orders operations by dependencies (leaves-first for adds, roots-first for deletes)
- Persists hash-to-address mappings in LiteDB
- Executes commands in correct order
- Handles partial failures gracefully
What Adapter Does¶
- Define device-specific data model in YAML
- Implement
Transform()method - converts PQ data to device model - Implement protocol commands - actual device communication
Implementation Checklist¶
[ ] 1. Define access model in adapter-registration.yaml
[ ] 2. Build solution (generates entity classes)
[ ] 3. Create AccessSynchronization class inheriting AccessSynchronizationBase
[ ] 4. Implement Transform() method
[ ] 5. Create protocol command classes
[ ] 6. Implement *Commands.cs partial files for each entity
[ ] 7. Test with real device
[ ] 8. Run unchanged sync twice (second run must not throw `Address already assigned`)
Item 8 is a hard gate for access sync changes.
Step 1: Define Access Model (YAML)¶
In adapter-registration.yaml, define all entity types needed for your device.
Model Design Principles¶
Before writing YAML, understand these core principles:
1. Closed Model Principle
The access model must be self-contained. All references between entities must be explicit typed references (not raw uint). The only exception is references to physical Things (doors, readers) which use uint as device address.
# CORRECT - explicit reference to entity in model
levels: AccessLevel[] # framework knows this references AccessLevel
# WRONG - raw ID, framework doesn't know what it references
level_ids: uint[] # just numbers, no relationship tracking
2. Trackable vs Embedded Decision
Ask these questions to decide:
- Does the device have a separate API to create/delete this data? → Trackable (needs
address) - Is this data always sent as part of a parent entity? → Embedded (no
address) - Can this data be shared between multiple parents? → Trackable
- Is this just structural grouping of fields? → Embedded
3. Flatten Complex Nested Structures
Device protocols often have deeply nested structures. Flatten them for simpler sync:
# DEVICE HAS:
# Schedule → WeeklySchedule → DaySchedule[7] → TimePeriod[]
# FLATTEN TO:
- type_id: Schedule
properties:
address: uint
periods: SchedulePeriod[] # flat array
- type_id: SchedulePeriod # embedded, combines day + time
properties:
day_of_week: int # 0-6
start_time: int # minutes
end_time: int
Reconstruct the nested structure in your protocol command when sending to device.
4. Physical vs Logical References
| Reference Type | Syntax | Example | Notes |
|---|---|---|---|
| To entity in model | EntityName |
schedule: Schedule |
Framework tracks dependency |
| To physical Thing | uint |
door_id: uint |
Just an address, Thing managed elsewhere |
Entity vs Embedded Structure¶
Has address |
Type | Separate API | Storage | Hash includes |
|---|---|---|---|---|
| Yes | Trackable Entity | Yes | LiteDB mapping | Referenced entity addresses only |
| No | Embedded | No (part of parent) | None | Full content recursively |
Decision Tree:
Does device have Create/Delete API for this data?
├─ YES → Does it have its own ID/address on device?
│ ├─ YES → TRACKABLE ENTITY (add address property)
│ └─ NO → TRACKABLE ENTITY (framework assigns address)
└─ NO → Is it always part of another entity?
├─ YES → EMBEDDED STRUCTURE (no address)
└─ NO → Reconsider - probably needs to be trackable
Complete Example: Access Control System¶
Real-world example showing all concepts:
model:
# ============================================
# TRACKABLE ENTITIES (have address, separate device API)
# Listed in dependency order: leaves → roots
# ============================================
# Layer 0 - leaf (no dependencies on other entities)
- type_id: Schedule
properties:
address: uint
periods: SchedulePeriod[] # embedded, flattened from nested structure
# Layer 1 - references Schedule
- type_id: AccessLevel
properties:
address: uint
door_schedules: DoorSchedule[] # embedded, contains entity reference
# Layer 2 - references AccessLevel
- type_id: AccessGroup
properties:
address: uint
levels: AccessLevel[] # entity reference array
# Layer 3 - root (references AccessGroup, contains embedded credentials)
- type_id: User
properties:
address: string # some devices use string IDs
name: string
pin: byte[]
access_groups: AccessGroup[] # entity reference array
cards: CardData[] # embedded credentials
fingers: FingerData[] # embedded biometrics
# ============================================
# EMBEDDED STRUCTURES (no address, part of parent)
# ============================================
# Flattened schedule - replaces nested Weekly/Daily/TimePeriod
- type_id: SchedulePeriod
properties:
day_of_week: int # 0=Sun, 1=Mon, ... 6=Sat
start_time: int # minutes from midnight (0-1440)
end_time: int
# Links door (physical Thing) to schedule (model entity)
- type_id: DoorSchedule
properties:
door_id: uint # physical Thing address (outside model)
schedule: Schedule # entity reference (inside model)
# Credentials - embedded because device API sends them with User
- type_id: CardData
properties:
card_type: int
data: byte[]
- type_id: FingerData
properties:
finger_index: int # 0-9
flag: uint
template: byte[] # single blob, not byte[][]
Optional: Person Profiles¶
If the panel stores per-user flags that are not credentials or access grants, add a
person_profiles section to access-model.yaml:
person_profiles:
- slot_type: User
fields:
- name: user_level
type: int
default: 0
range: [0, 3]
category: alarm
description: "Alarm user level"
- name: dual_code
type: bool
default: false
category: alarm
description: "Requires dual-code behavior"
The generated code maps profile JSON from the server to a typed profile record before
Transform() runs. Adapter code reads the profile from the person, not from each
device access entry.
Dependency Graph:
User (Layer 3 - root)
│
├──► AccessGroup[] (Layer 2)
│ │
│ └──► AccessLevel[] (Layer 1)
│ │
│ └──► DoorSchedule[] (embedded)
│ ├──► door_id (physical Thing)
│ └──► Schedule (Layer 0 - leaf)
│ │
│ └──► SchedulePeriod[] (embedded)
│
├──► CardData[] (embedded)
└──► FingerData[] (embedded)
Why credentials are embedded:
- Device API sends credentials as part of user (e.g.,
User.Enroll(userWithCredentials)) - No separate
Card.Create()API on device - Credential cannot exist without a user on this device type
Why Schedule is trackable:
- Device has separate
Schedule.Add()/Schedule.Delete()API - Can be shared by multiple AccessLevels (deduplicated)
- Has its own ID/address on device
Why DoorSchedule is embedded but contains entity reference:
- No separate API for DoorSchedule - it's part of AccessLevel
- But it references Schedule which IS a trackable entity
- Framework resolves
schedule: Scheduleto Schedule.Address when computing hash
Supported Property Types¶
| YAML Type | C# Type | Use Case |
|---|---|---|
string |
string |
Names, string IDs |
uint |
uint |
Numbers, physical Thing addresses |
int |
int |
Signed numbers |
bool |
bool |
Flags |
byte[] |
byte[] |
Binary data (biometrics, card data) |
uint[] |
uint[] |
Only for physical Thing addresses |
EntityName |
EntityName |
Single reference to model entity |
EntityName[] |
EntityName[] |
Array reference to model entities |
Important: Use EntityName[] for references to entities in your model, not uint[]. The framework needs explicit references to track dependencies and compute hashes correctly.
Step 2: Build and Review Available Types¶
After building, these types become available:
Entity Records¶
// For entity with address
public partial record Card(
string CardNumber,
uint FacilityCode)
{
public uint? Address { get; private set; }
public void SetAddress(uint address) { ... }
public byte[] ComputeHash() => ...;
}
// For embedded structure (no Address property/method)
public partial record DoorSchedule(
uint DoorId,
uint TimeStart,
uint TimeEnd)
{
public byte[] ComputeHash() => ...;
}
Access Model Container¶
public partial record AccessModel(
Card[] Cards,
Person[] Persons,
AccessLevel[] AccessLevels,
Schedule[] Schedules);
Base Class (inherit from this)¶
public abstract class AccessSynchronizationBase
{
// you must implement this
protected abstract AccessModel Transform(PersonAccess[] persons);
// optional reset hook (default no-op)
protected virtual IEnumerable<IProtocolCommand> ClearDeviceMemory(
IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups) => [];
// call this from your handler to run the sync orchestration
protected Task<DeviceCommandResult> Synchronize(
Thing thing,
Access.Synchronize command,
IServiceProvider services,
CancellationToken cancellationToken);
}
Step 3: Create AccessSynchronization Class¶
// Capabilities/AccessSynchronization.cs
using Pq.Adapters.Framework;
using Pq.Adapters.Framework.AccessUpload;
using Pq.Adapters.Framework.Commands;
namespace Pq.Adapter.YourAdapter;
public class AccessSynchronization(Protocol protocol) : AccessSynchronizationBase
{
/// <summary>
/// Protocol for device communication (used by commands).
/// </summary>
public Protocol Protocol => protocol;
protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
{
// implement transformation logic
}
}
The generated base provides the synchronization command orchestration. Add a hand-written synchronization wrapper only when the adapter needs real pre/post behavior such as authoritative device cleanup, slot preallocation, or custom reset semantics.
Reset Hook for Device Memory Cleanup¶
Access.Synchronize.Reset now runs in this order:
- Build
occupiedSlotGroupssnapshot fromMappingStore.GetAll(...)for tracked entity types inaccess-model.yaml, grouped byEntityTypeand ordered for delete (roots -> leaves) - Build and execute clear commands from
ClearDeviceMemory(occupiedSlotGroups) ClearMappings()- clears local PQ↔device address mapping store after successful device cleanupSynchronize(...)- full upload from current PQ state
If your device keeps old credentials in panel memory, override ClearDeviceMemory.
Clear commands should fail when device cleanup fails. Do not catch and ignore protocol errors inside reset cleanup commands; preserving mappings after a failed clear is safer than forgetting which credentials may still exist on the device.
Two valid strategies:
- authoritative bulk wipe - protocol has one command that clears whole memory
- targeted delete - protocol deletes specific slots/items
Do not combine both in one naive implementation (ClearAllUsers() and then per-slot delete), because it is usually redundant and can fail on the second phase when slots are already gone.
Example for authoritative bulk wipe:
protected override IEnumerable<IProtocolCommand> ClearDeviceMemory(
IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups)
{
yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
{
await ctx.Protocol.ClearAllUsers(ct);
});
}
Use occupiedSlotGroups as authoritative input and clear exactly what the framework tracked. Avoid blind sweeps such as for (var i = 1; i <= 999; i++) when the protocol supports targeted delete.
How to know which address belongs to which entity¶
occupiedSlotGroups contains groups with:
EntityType- model type (User,Schedule,TimeRange, ...)Slots-IReadOnlyList<AddressMapping>for that type
Each AddressMapping in Slots contains:
DeviceAddress- address stored for that type- identity/hash metadata used by the framework
So in a mixed model (for example users in 1..1000 and time ranges in 100..200), you do not infer ownership from numeric range. You route by EntityType and parse DeviceAddress to the expected protocol format.
Type grouping and ordering are already provided by framework; adapter code should not recompute dependency order.
Example for targeted delete by entity type:
protected override IEnumerable<IProtocolCommand> ClearDeviceMemory(
IReadOnlyList<OccupiedSlotGroup> occupiedSlotGroups)
{
foreach (var group in occupiedSlotGroups)
{
switch (group.EntityType)
{
case "User":
foreach (var mapping in group.Slots)
{
if (int.TryParse(mapping.DeviceAddress, out var userSlot))
yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
{
await ctx.Protocol.DeleteUser(userSlot, ct);
});
}
break;
case "TimeRange":
foreach (var mapping in group.Slots)
{
if (int.TryParse(mapping.DeviceAddress, out var rangeSlot))
yield return new ProtocolCommand<AccessSynchronization>(async (ctx, ct) =>
{
await ctx.Protocol.DeleteTimeRange(rangeSlot, ct);
});
}
break;
}
}
}
If your protocol supports batch/range deletes, consume each provided group with the best primitive for that type. Group ordering is already provided by framework (roots -> leaves).
If not overridden, default behavior remains unchanged (empty command sequence, no device-side clear).
Step 4: Implement Transform Method¶
Transform receives array of persons with their credentials and device access:
Input Data Structure¶
public record PersonAccess(
Guid PersonId, // PQ person ID (for confirmations)
string Name, // person name
CredentialEntry[] Credentials, // all credentials
DeviceAccess[] Devices, // devices this person can access
bool ApbExempt);
public record CredentialEntry
{
string Type; // "Pin", "PersonCard", "Biometric"
Guid CredentialId; // PQ credential ID
object? Data; // CardCredentialData, PIN string, etc.
}
public record DeviceAccess(
Thing Device, // resolved device Thing
WeeklyTimeRange[]? Schedule); // null = 24/7 access
Transform Implementation Pattern¶
protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
{
var cards = new List<Card>();
var users = new List<Person>();
var schedules = new List<Schedule>();
// deduplicate schedules
var scheduleMap = new Dictionary<string, Schedule>();
foreach (var person in persons)
{
// 1. process credentials
foreach (var cred in person.Credentials)
{
if (cred.Type == "PersonCard" && cred.Data is CardCredentialData cardData)
{
var card = new Card(
CardNumber: cardData.CardNumber,
FacilityCode: ParseFacilityCode(cardData));
cards.Add(card);
}
}
// 2. process schedules (deduplicate)
var personSchedules = new List<Schedule>();
foreach (var device in person.Devices)
{
if (device.Schedule != null)
{
var scheduleKey = ComputeScheduleKey(device.Schedule);
if (!scheduleMap.TryGetValue(scheduleKey, out var schedule))
{
schedule = ConvertSchedule(device.Schedule);
scheduleMap[scheduleKey] = schedule;
schedules.Add(schedule);
}
personSchedules.Add(schedule);
}
}
// 3. create person with references
var user = new Person(
Name: person.Name,
Cards: cards.Where(c => BelongsToPerson(c, person)).ToArray());
users.Add(user);
}
return new AccessModel.AccessModel(
Cards: cards.ToArray(),
Persons: users.ToArray(),
Schedules: schedules.ToArray(),
// ... other entity arrays
);
}
When person_profiles is declared, the generated base uses adapter-specific access
input instead:
protected override AccessModel.AccessModel Transform(AccessModel.PersonAccess[] persons)
{
foreach (var person in persons)
{
var userLevel = person.UserProfile?.UserLevel ?? 0;
var dualCode = person.UserProfile?.DualCode ?? false;
// build device-specific user record
}
}
Do not parse profile JSON manually. The generated person.<SlotType>Profile property
is the adapter-facing API.
Key Transform Rules¶
- Never prefill Address in Transform - all entities start with
Address = null; framework/differ assigns addresses - Deduplicate shared entities - schedules may be shared by multiple persons
- Build references between entities - Person.Cards references Card entities
- Filter by target device - only include data relevant to current device
Forbidden in Transform (Do Not Do This)¶
- Do not call
SetAddress()insideTransform()for tracked entities. - Do not read
entity.AddressinsideTransform()to build other entities. - Do not model tracked-entity references as
uint/uint[]whenEntityName/EntityName[]is possible.
If you break these rules, repeated sync can fail with InvalidOperationException: Address already assigned when the differ tries to set address for UNCHANGED entities.
Reconstructing Nested Structures in Commands¶
When you flattened a structure in your model, reconstruct it when sending to device:
// Model has flat SchedulePeriod[]
// Device expects nested WeeklySchedule → DaySchedule[7] → TimePeriod[]
internal sealed class CreateScheduleCommand(Schedule schedule) : IProtocolCommand
{
public async Task Execute(object context, CancellationToken ct)
{
var sync = (AccessSynchronization)context;
// reconstruct nested structure from flat periods
var weeklySchedule = new DeviceWeeklySchedule();
for (int day = 0; day < 7; day++)
{
var daySchedule = new DeviceDaySchedule();
var periodsForDay = schedule.Periods
.Where(p => p.DayOfWeek == day)
.Select(p => new DeviceTimePeriod
{
StartTime = p.StartTime,
EndTime = p.EndTime
});
daySchedule.Periods.AddRange(periodsForDay);
weeklySchedule.DaySchedules.Add(daySchedule);
}
await sync.Protocol.CreateScheduleAsync(
schedule.Address!.Value,
weeklySchedule,
ct);
}
}
Key point: Your model is optimized for sync (flat, simple). Device protocol may need different structure. Transform in your command.
Step 5: Create Protocol Commands¶
Commands execute actual device communication:
// AccessModel/Commands/CreateCardCommand.cs
using Pq.Adapters.Framework.AccessUpload.Commands;
namespace Pq.Adapter.YourAdapter.AccessModel;
internal sealed class CreateCardCommand(Card card) : IProtocolCommand
{
public async Task Execute(object context, CancellationToken cancellationToken)
{
// context is your AccessSynchronization instance
var sync = (AccessSynchronization)context;
// use protocol to send command to device
await sync.Protocol.SendCardAsync(new DeviceCard
{
Address = card.Address!.Value,
CardNumber = card.CardNumber,
FacilityCode = card.FacilityCode
}, cancellationToken);
}
}
internal sealed class DeleteCardCommand(uint address) : IProtocolCommand
{
public async Task Execute(object context, CancellationToken cancellationToken)
{
var sync = (AccessSynchronization)context;
await sync.Protocol.DeleteCardAsync(address, cancellationToken);
}
}
Step 6: Implement Command Generation¶
Each entity needs partial file implementing command generation:
// AccessModel/Card.Commands.cs
using Pq.Adapters.Framework.AccessUpload.Commands;
namespace Pq.Adapter.YourAdapter.AccessModel;
public partial record Card
{
/// <summary>
/// Creates commands to add this entity to device.
/// </summary>
public IEnumerable<IProtocolCommand> CreateCommands()
=> [new CreateCardCommand(this)];
/// <summary>
/// Creates commands to update this entity on device.
/// </summary>
public IEnumerable<IProtocolCommand> UpdateCommands()
=> [new UpdateCardCommand(this)];
/// <summary>
/// Creates commands to delete entity at specified address.
/// </summary>
public static IEnumerable<IProtocolCommand> DeleteCommands(string address)
=> [new DeleteCardCommand(uint.Parse(address))];
}
Command Generation for Each Entity¶
Create EntityName.Commands.cs for every entity with address property:
Execution Flow¶
When Access.Synchronize command is received:
1. Framework fetches PersonAccess[] from PQ API
2. Your Transform() converts to AccessModel
3. Framework computes hashes for all entities
4. Framework loads stored mappings from LiteDB
5. Framework classifies entities:
- NEW: hash not in storage
- UNCHANGED: hash matches stored
- DELETED: stored hash not in desired
6. Framework allocates addresses for NEW entities
7. Framework builds command list:
- Additions: leaves first (Card before Person)
- Deletions: roots first (Person before Card)
8. Framework executes commands via your Execute()
9. On success: framework persists hash mapping
On failure: logged, continues with next
Dependency Ordering¶
Why Order Matters¶
Device typically requires referenced entity to exist before referencing it:
- Schedule must exist before AccessLevel references it
- AccessLevel must exist before AccessGroup references it
- AccessGroup must exist before User references it
Layer Concept¶
Framework automatically determines layers from your entity references:
Layer 0 (leaves): Schedule ← no entity references
Layer 1: AccessLevel ← references Schedule
Layer 2: AccessGroup ← references AccessLevel
Layer 3 (roots): User ← references AccessGroup
Embedded structures don't have layers - they're processed with their parent entity.
Visual Example¶
ADDITIONS (leaves → roots): DELETIONS (roots → leaves):
1. Schedule.Add() 1. User.Delete()
↓ ↓
2. AccessLevel.Add() 2. AccessGroup.Delete()
↓ ↓
3. AccessGroup.Add() 3. AccessLevel.Delete()
↓ ↓
4. User.Add() 4. Schedule.Delete()
Execution Order¶
| Operation | Order | Reason |
|---|---|---|
| Additions | Leaves → Roots | Referenced entity must exist first |
| Updates | Leaves → Roots | Same as additions |
| Deletions | Roots → Leaves | Remove references before deleting referenced entity |
How Framework Determines Order¶
- Parse entity references from YAML (
levels: AccessLevel[]) - Build dependency graph
- Topological sort to determine layers
- Execute in layer order
Circular dependencies are not allowed - framework will fail at build time.
Hash Computation¶
Hash determines if entity changed. Framework computes automatically.
What's Included in Hash¶
| Property Type | Included |
|---|---|
Primitives (string, uint) |
Value |
Primitive arrays (uint[]) |
All values |
| Entity reference | Only Address |
| Entity array reference | All Addresses |
| Embedded structure | Full ComputeHash() recursively |
| Address property | Never |
Implication¶
Changing Card content changes Card hash, but NOT Person hash (only Card.Address is in Person hash).
Storage¶
Framework persists to LiteDB (data/adapters/{deviceId}/access-mappings.db):
AddressMapping {
Id: "Card:A1B2C3..." // EntityType:HashHex
EntityType: "Card"
Hash: byte[20] // SHA1
DeviceAddress: "42"
SyncedAt: 1703520000 // unix timestamp
}
On Successful Add/Update¶
Storage.Upsert(new AddressMapping {
Id = $"{type}:{hashHex}",
Hash = hash,
DeviceAddress = address.ToString(),
SyncedAt = now
});
On Successful Delete¶
Address Allocation¶
Framework manages device address space:
// Range allocator (default)
var allocator = new RangeAddressAllocator(min: 1, max: 65535);
// During sync:
// - Stored addresses: allocator.MarkUsed(addr)
// - Deleted addresses: allocator.Release(addr)
// - New entities: addr = allocator.Allocate()
Device-Assigned Addresses¶
If device assigns addresses itself during upload:
public class DeviceAssignedAllocator : IAddressAllocator<uint>
{
public uint Allocate() => throw new InvalidOperationException(
"Device assigns address during upload");
}
// In command execution, capture assigned address and set it:
var response = await protocol.CreateCard(card);
card.SetAddress(response.AssignedAddress);
Do not use this pattern when your adapter sends the slot/address to the device (for example WriteUser(slot, ...)). In that case the device is not assigning addresses; treat it as allocator or adapter-selected addressing and keep Transform() address-free.
Error Handling¶
Partial Sync¶
Framework continues after individual failures:
- Failed entity logged
- Dependent entities may fail naturally
- Successful entities persisted
- Final result contains success/failure counts
Address Space Exhausted¶
Situation: Device has max 1000 cards, all slots used
Result: AddressSpaceExhaustedException
- Entity marked as FAILED
- Dependent entities propagate failure
- Other entities continue
Resolution: User must delete something first
Command Failure¶
public async Task Execute(object context, CancellationToken ct)
{
try
{
await protocol.SendCard(...);
}
catch (DeviceException ex)
{
// throw to signal failure
// framework logs and continues
throw new ProtocolCommandException($"Failed to create card: {ex.Message}", ex);
}
}
Complete Example: Simple Card-Only Adapter¶
adapter-registration.yaml¶
adapter_id: "simple-card-adapter"
name: "Simple Card Reader"
model:
- type_id: Person
properties:
address: uint
name: string
cards: Card[]
- type_id: Card
properties:
address: uint
card_number: string
AccessSynchronization.cs¶
public class AccessSynchronization(Protocol protocol) : AccessSynchronizationBase
{
public Protocol Protocol => protocol;
protected override AccessModel.AccessModel Transform(PersonAccess[] persons)
{
var cards = new List<Card>();
var users = new List<Person>();
foreach (var person in persons)
{
var personCards = new List<Card>();
foreach (var cred in person.Credentials.Where(c => c.Type == "PersonCard"))
{
if (cred.Data is CardCredentialData cardData)
{
var card = new Card(CardNumber: cardData.CardNumber);
cards.Add(card);
personCards.Add(card);
}
}
var user = new Person(
Name: person.Name,
Cards: personCards.ToArray());
users.Add(user);
}
return new AccessModel.AccessModel(
Cards: cards.ToArray(),
Persons: users.ToArray());
}
}
Card.Commands.cs¶
public partial record Card
{
public IEnumerable<IProtocolCommand> CreateCommands()
=> [new CreateCardCommand(this)];
public IEnumerable<IProtocolCommand> UpdateCommands()
=> [new UpdateCardCommand(this)];
public static IEnumerable<IProtocolCommand> DeleteCommands(string address)
=> [new DeleteCardCommand(uint.Parse(address))];
}
CreateCardCommand.cs¶
internal sealed class CreateCardCommand(Card card) : IProtocolCommand
{
public async Task Execute(object context, CancellationToken ct)
{
var sync = (AccessSynchronization)context;
await sync.Protocol.WriteCard(card.Address!.Value, card.CardNumber, ct);
}
}
Troubleshooting¶
"Hash not stable after SetAddress"¶
Address property is excluded from hash. If hash changes after SetAddress, you have a bug - Address shouldn't affect hash.
"Address already assigned"¶
Symptoms:
- First sync succeeds, second sync with same data fails.
- Exception includes
InvalidOperationException: Address already assigned. - Stack trace points to generated
SetAddress(...)from differ path.
Root cause:
Transform()already calledSetAddress()(or used prefilled addresses) and differ callsSetAddress()again forUNCHANGEDentities.
Fix:
- Remove all
SetAddress()calls fromTransform()(except true device-assigned command responses). - Return object references between entities, not numeric address references.
- Keep address selection in allocator/differ path or in post-device-response path only.
Prevention test:
- Run the same
Access.Synchronizetwice with unchanged input. - The second run must be no-op and must not throw.
"Entity reference has null Address"¶
Dependencies not processed in correct order. Check:
- YAML dependencies are correct (use
EntityNamenotuint) - Referenced entity is included in your model output
- All entities that should have addresses are returned from Transform()
"AddressSpaceExhausted during first sync"¶
Allocator range too small or incorrectly initialized. Check device capacity.
"Commands execute in wrong order"¶
Framework orders by dependency. If device has additional ordering requirements beyond data dependencies, you may need to emit multiple commands from single entity.
"Stored mappings not found after restart"¶
Check LiteDB file path and permissions. Storage path: data/adapters/{deviceId}/access-mappings.db
"Commands execute in wrong order" (dependency detection)¶
Problem: User commands execute before AccessLevel commands, causing device errors.
Root cause: The source generator detects dependencies by parsing property types. It looks for property types that match entity names (like AccessLevel). If you use uint[] instead of EntityName[], the generator sees just numbers - no dependency is detected.
# WRONG - generator sees "uint[]", no dependency detected
# Result: User uploaded BEFORE AccessLevel exists on panel
- type_id: User
properties:
access_levels: uint[] # just numbers, no relationship
# CORRECT - generator sees "AccessLevel[]", dependency detected
# Result: AccessLevel uploaded FIRST, then User
- type_id: User
properties:
access_levels: AccessLevel[] # explicit reference to entity
Symptoms:
- Device rejects commands (entity not found, invalid reference)
- Commands timeout or return errors
- Access sync partially works (some entities OK, dependent ones fail)
- Credentials work for some access points but not all configured
How to verify: After build, check generated AccessUploadBatchBuilder.g.cs. The foreach loops should appear in dependency order:
// Expected order (leaves → roots):
foreach (var n in diff.Schedule.New) // Layer 0
foreach (var n in diff.AccessLevel.New) // Layer 1 (references Schedule)
foreach (var n in diff.User.New) // Layer 2 (references AccessLevel)
If User appears before AccessLevel, your dependencies are not detected.
"Framework doesn't track entity changes correctly"¶
Problem: Using uint[] instead of EntityName[] for references.
# WRONG - framework can't track dependencies
access_group_ids: uint[]
# CORRECT - framework tracks dependencies
access_groups: AccessGroup[]
Fix: Use explicit entity references. The model must be self-contained.
"Deeply nested structure is complex to handle"¶
Problem: Device protocol has A → B → C → D nesting.
Solution: Flatten in model, reconstruct in command:
// Command - reconstruct nested structure
var nested = ReconstructNestedStructure(schedule.Periods);
await protocol.CreateSchedule(nested);
"Biometric data has byte[][] (array of arrays)"¶
Problem: Device stores multiple templates per finger, but byte[][] is not supported.
Solution: Flatten to single template per record:
# Each template is separate record
- type_id: FingerData
properties:
finger_index: int
template: byte[] # single template
Or concatenate templates into single blob if device accepts it.
"Credentials should be separate entities but device API sends them with user"¶
Problem: You want Card as trackable entity, but device has User.Enroll(userWithCards) not Card.Create().
Solution: Make credentials embedded in User. The framework syncs User entity which includes all credentials. If a card changes, User hash changes, and entire User is re-synced.
"Shared entity (Schedule) duplicated instead of reused"¶
Problem: Same schedule appears multiple times in model.
Solution: Deduplicate in Transform:
var scheduleMap = new Dictionary<string, Schedule>();
foreach (var access in accesses)
{
var key = ComputeScheduleKey(access.Schedule); // hash of content
if (!scheduleMap.TryGetValue(key, out var schedule))
{
schedule = ConvertSchedule(access.Schedule);
scheduleMap[key] = schedule;
}
// reuse existing schedule reference
}
"Circular dependency detected"¶
Problem: Entity A references B, and B references A.
Solution: This is not supported. Redesign your model:
- Make one direction embedded instead of reference
- Introduce intermediate entity to break cycle
- Question if circular dependency is really needed
Key Files Reference¶
| File | Purpose |
|---|---|
adapter-registration.yaml |
Access model definition |
Capabilities/AccessSynchronization.cs |
Your Transform implementation |
AccessModel/*.Commands.cs |
Command generation for entities |
AccessModel/Commands/*.cs |
Protocol command implementations |
Framework Types Reference¶
The types below are part of the Pq.Adapters.Framework access-upload API you build against. Reference them by type name; resolve the exact member surface from the assembly you compile against.
| Type | Purpose |
|---|---|
PersonAccess |
Input data structure (a person's resolved access rights) |
DeviceAccess |
Device + schedule pairing |
IProtocolCommand |
Interface your protocol commands implement |
RangeAddressAllocator |
Framework-managed address allocation |
IAddressMappingStore |
Persistence interface for address mappings |