TimeSynchronization¶
Overview¶
Use time synchronization when a device has a clock that affects schedules, logs, access decisions, arming, video playback, or audit timestamps.
The Thing that owns the TimeSynchronization function must implement ITimeSynchronized. In most adapters this is the root or controller Thing because that device owns the clock. A standalone capability/service class is fine for the implementation details, but it is not sufficient by itself; the owning Thing must delegate to it.
The framework schedules synchronization and passes a TimeProvider; adapters must not call DateTimeOffset.Now or DateTimeOffset.UtcNow directly.
The
.LocalDateTimetrap (RULE-057). TheTimeProvideryou receive is timezone-aware: itsLocalTimeZoneis the device's configured zone, sotimeProvider.GetLocalNow()returns aDateTimeOffsetwhose components already carry the device wall clock (e.g.11:22 +02:00). Never convert it with.LocalDateTime— that re-projects the instant onto the host process zone, which is UTC in a container, so the clock is written off by the container↔device offset and every event the device stamps afterward is shifted (silent; corrupts the audit trail).DateTimeOffset.DateTimegives the right value but is banned (drops Kind). The fix is to keep the offset: let the protocol's set/get-time methods speakDateTimeOffsetand read its components (.Hour,.Day,.Month,.Year,.Offset, …). PasstimeProvider.GetLocalNow()straight through — that is the only sanctioned form. Any transformation of it (.LocalDateTime,.DateTime,TimeZoneInfo.ConvertTimeFromUtc(...),new DateTime(now.Year, …)) is a signal that aDateTime-typed boundary is being fought instead of retyped toDateTimeOffset.
YAML Example¶
device_types:
- type_id: AccessControlPanel
name: "Access Control Panel"
category: panel
functions:
TimeSynchronization:
# PqTimeZone enum name, not IANA/Windows/local city string.
# Examples: Utc, EuropePrague, EuropeLondon, AmericaNewYork.
# EuropePrague maps internally to IANA "Europe/Prague".
timezone: "EuropePrague"
sync_interval_minutes: 60
timezone must be one of the supported PqTimeZone enum values from the framework. Do not use arbitrary city names (Zlin), Windows timezone IDs (Central European Standard Time), or raw IANA IDs (Europe/Prague) in YAML.
States¶
None. TimeSynchronization does not expose status states.
Actions¶
None. Synchronization is driven by the framework scheduler; there are no action methods to invoke.
Properties¶
| Property | Type | Default | Range | Description |
|---|---|---|---|---|
timezone |
PqTimeZone |
Utc |
— | Device timezone for local time conversion |
sync_interval_minutes |
int |
60 |
1–1440 |
Minutes between synchronization attempts |
Implementation Pattern¶
Direct Thing Implementation¶
public partial class AccessControlPanel(Protocol protocol, ILogger<AccessControlPanel> logger)
: ITimeSynchronized
{
private static readonly TimeSpan SyncThreshold = TimeSpan.FromMinutes(2);
public async Task SynchronizeTime(TimeProvider timeProvider)
{
// GetLocalNow() carries the device wall clock in the configured zone; pass the offset straight
// through. Use GetUtcNow() instead when the device API expects UTC.
var localNow = timeProvider.GetLocalNow();
var deviceTime = await protocol.GetDeviceTime(); // returns DateTimeOffset read back from the device
var drift = (localNow - deviceTime).Duration();
if (drift <= SyncThreshold)
return;
logger.LogInformation("Device time drift {Drift} exceeds threshold, syncing", drift);
await protocol.SetDeviceTime(localNow); // SetDeviceTime takes DateTimeOffset, reads .Hour/.Day/... — never .LocalDateTime
}
}
Delegating To A Capability Class¶
Use this when you want the time sync logic isolated from the Thing class. The Thing still implements ITimeSynchronized; the helper does the work.
public partial class AccessControlPanel(TimeSynchronizationCapability timeSync)
: ITimeSynchronized
{
public Task SynchronizeTime(TimeProvider timeProvider) => timeSync.Synchronize(timeProvider, this);
}
internal sealed class TimeSynchronizationCapability(Protocol protocol, ILogger<TimeSynchronizationCapability> logger)
{
private static readonly TimeSpan SyncThreshold = TimeSpan.FromMinutes(2);
public async Task Synchronize(TimeProvider timeProvider, AccessControlPanel panel)
{
if (!panel.Address.HasValue)
return;
var localNow = timeProvider.GetLocalNow();
var deviceTime = await protocol.GetDeviceTime(panel.Address.Value); // DateTimeOffset
var drift = (localNow - deviceTime).Duration();
if (drift <= SyncThreshold)
return;
logger.LogInformation("Device time drift {Drift} exceeds threshold, syncing", drift);
await protocol.SetDeviceTime(panel.Address.Value, localNow); // pass the offset straight through
}
}
Device Time Modes¶
- UTC mode: write
timeProvider.GetUtcNow()or Unix seconds derived from it. - Local mode: write
timeProvider.GetLocalNow()and read its components on the protocol side when the device expects wall-clock time — never.LocalDateTime(re-projects onto the host zone; UTC in a container) or the banned.DateTime. - Timezone-configured mode: write timezone/offset separately (
localNow.Offset) when the protocol requires it.
Document which mode the device uses in the adapter's design-notes because timestamp interpretation affects event audit correctness.
Rules¶
- Implement
ITimeSynchronized.SynchronizeTime(TimeProvider timeProvider)on the Thing that ownsTimeSynchronization. - Put the detailed protocol logic in a helper/capability class if that keeps the Thing focused, but keep the framework interface on the Thing.
- Use
TimeProviderfor current time. - For local/wall-clock devices, make the protocol set/get-time methods take and return
DateTimeOffsetand read its components; never re-project with.LocalDateTime(RULE-057). - Use
DeviceTimestampfor event timestamps; time synchronization itself does not publish a status event by default. - Skip synchronization when the device is not connected or not addressed.
- Apply a drift threshold when reading the device clock is possible.
- Document write-only clocks where drift cannot be measured.
Implementation Archetypes¶
These are generic, vendor-neutral patterns. Pick the one that matches the device clock model:
- Readable local clock: read the device's local time over the protocol, compare against
timeProvider.GetLocalNow(), write back corrected local time only when drift exceeds the threshold. - UTC Unix clock with timezone offset: write
timeProvider.GetUtcNow()as Unix seconds and configure the timezone offset separately when the SDK expects them as distinct fields. - Command/keypad-driven clock: synchronize by issuing the protocol's set-time command sequence (no readback), treating the clock as write-only.
- Sync-on-connect: perform the synchronization step immediately after the connection is established, in addition to the scheduled interval.
See Also¶
- Functions: ConnectionBase - Connection lifecycle
- Functions: AccessSynchronization - Access data synchronization