Skip to content

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 .LocalDateTime trap (RULE-057). The TimeProvider you receive is timezone-aware: its LocalTimeZone is the device's configured zone, so timeProvider.GetLocalNow() returns a DateTimeOffset whose 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.DateTime gives the right value but is banned (drops Kind). The fix is to keep the offset: let the protocol's set/get-time methods speak DateTimeOffset and read its components (.Hour, .Day, .Month, .Year, .Offset, …). Pass timeProvider.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 a DateTime-typed boundary is being fought instead of retyped to DateTimeOffset.

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 11440 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 owns TimeSynchronization.
  • 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 TimeProvider for current time.
  • For local/wall-clock devices, make the protocol set/get-time methods take and return DateTimeOffset and read its components; never re-project with .LocalDateTime (RULE-057).
  • Use DeviceTimestamp for 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