Skip to content

Device Timestamps

Problem: Using DateTimeOffset.UtcNow for device events creates timestamps reflecting adapter server time, not device time. This breaks audit trails and event ordering.

Solution: DeviceTimestamp type forces adapters to use device-reported time, never system time. Framework handles timezone conversion automatically.

Why Not DateTimeOffset?

// ❌ WRONG - uses adapter server time
await PublishEvent(
    PqEvent.Access.Granted
        .At(DateTimeOffset.UtcNow, reader)  // ← Compilation error now!
        .Build()
);

// Problem: Card swiped at device at 14:30, but adapter records 14:32
// because of network delay. Event time is wrong.

DeviceTimestamp prevents this mistake - you must provide device time.

DeviceTimestamp Structure

public readonly struct DeviceTimestamp
{
    // Factory methods for different device formats:
    public static DeviceTimestamp FromUnixSeconds(long unixSeconds);
    public static DeviceTimestamp FromUnixMilliseconds(long unixMilliseconds);
    public static DeviceTimestamp FromComponents(int year, int month, int day,
                                                   int hour, int minute, int second);
    public static DeviceTimestamp FromDateTime(DateTime localTime);

    // For framework-generated events only (timers, internal logic):
    public static DeviceTimestamp UtcNow { get; }
}

Key insight: Adapter doesn't configure timezones or convert anything. Just wrap device time in DeviceTimestamp and framework handles the rest.

Usage Patterns

Pattern 1: Device Reports Unix Timestamp

Most IoT devices report Unix time (seconds since 1970-01-01):

protected override async Task HandleDeviceEvent(DeviceEvent evt, CancellationToken ct)
{
    // Device reports: unixSeconds = 1736950200 (Unix timestamp)
    var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixSeconds);

    await PublishEvent(
        PqEvent.Access.Door.Opened
            .At(timestamp, this)
            .Build(),
        ct
    );

    // Framework converts to UTC automatically using device's configured timezone
}

Examples:

  • Some devices report Unix timestamps
  • Many REST APIs use Unix time
  • MQTT payloads often include "timestamp": 1736950200

Pattern 2: Device Reports Date/Time Components

Some devices report time as separate fields (year, month, day, hour, minute, second):

protected override async Task HandleDeviceEvent(ControllerEvent evt, CancellationToken ct)
{
    // Device reports: year=2025, month=1, day=15, hour=14, minute=30, second=0
    var timestamp = DeviceTimestamp.FromComponents(
        evt.Year,
        evt.Month,
        evt.Day,
        evt.Hour,
        evt.Minute,
        evt.Second
    );

    await PublishEvent(
        PqEvent.Access.Granted.At(timestamp, this).Build(),
        ct
    );
}

Examples:

  • Some controllers report time as components
  • Many serial protocols use BCD-encoded date/time
  • Some SDK structs have separate fields

Pattern 3: Device Reports DateTime Object

SDK provides structured DateTime:

protected override async Task HandleDeviceEvent(DoorEvent evt, CancellationToken ct)
{
    // SDK provides: evt.Timestamp (DateTime)
    var timestamp = DeviceTimestamp.FromDateTime(evt.Timestamp);

    await PublishEvent(
        PqEvent.Access.Door.Opened.At(timestamp, this).Build(),
        ct
    );
}

Examples:

  • Vendor SDK returns DateTime objects
  • Parsing timestamp strings: DateTime.Parse(evt.TimestampString)
  • Legacy APIs

Pattern 4: Device Reports Milliseconds

Some devices use Unix milliseconds:

protected override async Task HandleDeviceEvent(DeviceEvent evt, CancellationToken ct)
{
    // Device reports: unixMilliseconds = 1736950200000
    var timestamp = DeviceTimestamp.FromUnixMilliseconds(evt.UnixMillis);

    await PublishEvent(
        PqEvent.Access.Granted.At(timestamp, this).Build(),
        ct
    );
}

Examples:

  • JavaScript-based systems (JavaScript Date.now())
  • Some cloud APIs

Pattern 5: Framework-Generated Events (Not Device Events)

For adapter-generated events (heartbeats, synthetic events):

protected override async Task OnConnected(CancellationToken ct)
{
    // Adapter generates connection event (no device timestamp)
    var timestamp = DeviceTimestamp.UtcNow;

    await PublishConnected(timestamp, ct);
}

When to use:

  • Connection/disconnection events
  • Adapter heartbeats
  • Synthetic timeout events
  • Internal state changes

Never use for events originating from device!

Thing Public Methods

Thing methods accept DeviceTimestamp:

public class ReaderThing : ThingBase
{
    public async Task PublishAccessGranted(
        string? personId,
        string? credentialId,
        DeviceTimestamp timestamp,  // ← DeviceTimestamp parameter
        CancellationToken ct)
    {
        await PublishEvent(
            PqEvent.Access.Granted
                .At(timestamp, this)
                .WithIdentity(personId, credentialId)
                .Build(),
            ct
        );
    }
}

// Protocol creates DeviceTimestamp from device data
var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
await reader.PublishAccessGranted(personId, credId, timestamp, ct);

What About Timezones?

Adapter doesn't handle timezones at all!

Framework handles timezone conversion automatically:

  1. User configures device timezone in TimeSynchronization function
  2. Adapter provides device time via DeviceTimestamp
  3. Framework converts to UTC using configured timezone
# adapter-registration.yaml - User configures timezone
device_types:
  - type_id: "Controller"
    functions:
      TimeSynchronization:
        timezone: "EuropePrague"  # PqTimeZone enum name, maps to IANA "Europe/Prague"

Adapter code is timezone-agnostic:

// Adapter just wraps device time - doesn't care about timezone
var timestamp = DeviceTimestamp.FromComponents(2025, 1, 15, 14, 30, 0);

// Framework automatically:
// - Interprets as "14:30 in device's timezone" (Prague = UTC+1)
// - Converts to UTC: 13:30 UTC
// - Stores in database

Complete Example

public class DoorThing : ThingBase
{
    public DoorFunction Door { get; }

    protected override async Task HandleDeviceEvent(byte[] rawData, CancellationToken ct)
    {
        var evt = ParseDeviceEvent(rawData);

        // Create DeviceTimestamp from whatever format device provides
        DeviceTimestamp timestamp = evt.Format switch
        {
            TimestampFormat.UnixSeconds => DeviceTimestamp.FromUnixSeconds(evt.UnixSec),
            TimestampFormat.Components => DeviceTimestamp.FromComponents(
                evt.Year, evt.Month, evt.Day, evt.Hour, evt.Minute, evt.Second),
            TimestampFormat.DateTime => DeviceTimestamp.FromDateTime(evt.DateTime),
            _ => DeviceTimestamp.UtcNow  // Fallback if device doesn't provide timestamp
        };

        switch (evt.Type)
        {
            case DeviceEventType.DoorOpened:
                await Door.Opened(timestamp);
                break;

            case DeviceEventType.AccessGranted:
                await PublishAccessGranted(
                    evt.PersonId,
                    evt.CredentialId,
                    timestamp,  // ← Just pass DeviceTimestamp
                    ct
                );
                break;
        }
    }

    public async Task PublishAccessGranted(
        string? personId,
        string? credentialId,
        DeviceTimestamp timestamp,
        CancellationToken ct)
    {
        await PublishEvent(
            PqEvent.Access.Granted
                .At(timestamp, this)
                .WithIdentity(personId, credentialId)
                .Build(),
            ct
        );
    }
}

Common Mistakes

❌ Wrong: Using DateTimeOffset.UtcNow

// WRONG - compilation error, DateTimeOffset not accepted
await PublishEvent(
    PqEvent.Access.Granted
        .At(DateTimeOffset.UtcNow, this)  // ❌ Won't compile!
        .Build(),
    ct
);

Why wrong: Records adapter time, not device time. Event timestamps are incorrect.

Fix:

// Use device-reported time
var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
await PublishEvent(
    PqEvent.Access.Granted.At(timestamp, this).Build(),
    ct
);

❌ Wrong: Trying to Handle Timezone in Adapter

// WRONG - adapter shouldn't do timezone conversion!
var localTime = DateTime.Parse(evt.TimestampString);
var utc = TimeZoneInfo.ConvertTimeToUtc(localTime, _deviceTimeZone);  // ❌
var timestamp = DeviceTimestamp.FromDateTime(utc);

Why wrong: Framework handles conversion. Adapter doing it causes double-conversion bugs.

Fix:

// Just wrap device time - framework handles timezone
var localTime = DateTime.Parse(evt.TimestampString);
var timestamp = DeviceTimestamp.FromDateTime(localTime);  // ✅

❌ Wrong: Using DeviceTimestamp.UtcNow for Device Events

// WRONG - loses device timestamp
protected override async Task HandleDeviceEvent(DoorEvent evt, CancellationToken ct)
{
    var timestamp = DeviceTimestamp.UtcNow;  // ❌ Ignores evt.Timestamp!

    await Door.Opened(timestamp);
}

Why wrong: UtcNow is for adapter-generated events only. Device events must use device timestamp.

Fix:

// Use device-reported timestamp
protected override async Task HandleDeviceEvent(DoorEvent evt, CancellationToken ct)
{
    var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);  // ✅

    await Door.Opened(timestamp);
}

Edge Cases

Device Doesn't Report Timestamp

Some devices don't include timestamps:

protected override async Task HandleDeviceEvent(DoorEvent evt, CancellationToken ct)
{
    DeviceTimestamp timestamp;

    if (evt.HasTimestamp)
    {
        // Prefer device timestamp
        timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
    }
    else
    {
        // Fallback to current time
        timestamp = DeviceTimestamp.UtcNow;

        _logger.LogWarning(
            "Device event without timestamp, using current time"
        );
    }

    await Door.Opened(timestamp);
}

Device Reports Future Time

Device clock is wrong:

var timestamp = DeviceTimestamp.FromComponents(evt.Year, evt.Month, evt.Day,
                                                 evt.Hour, evt.Minute, evt.Second);

// Framework logs warning if timestamp is >5 minutes in future
// but still accepts it (device clock might be misconfigured)

await PublishEvent(
    PqEvent.Access.Granted.At(timestamp, this).Build(),
    ct
);

Framework handles this - no adapter code needed.

Parsing Timestamp Strings

SDK gives timestamp as string:

// Device reports: "2025-01-15T14:30:00"
var parsed = DateTime.Parse(evt.TimestampString);
var timestamp = DeviceTimestamp.FromDateTime(parsed);

await PublishEvent(
    PqEvent.Access.Granted.At(timestamp, this).Build(),
    ct
);

Debugging

Log Timestamps

var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);

_logger.LogDebug(
    "Event received: DeviceTime={DeviceTime}, Unix={Unix}",
    (DateTimeOffset)timestamp,  // Implicit conversion for logging
    evt.UnixTimestamp
);

await PublishEvent(
    PqEvent.Access.Door.Opened.At(timestamp, this).Build(),
    ct
);

Output:

Event received: DeviceTime=2025-01-15 14:30:00 +00:00, Unix=1736950200

Note: DeviceTimestamp stores as UTC internally (offset +00:00), framework converts based on configured timezone.

Summary

Adapter's job:

  1. ✅ Get timestamp from device (Unix, components, DateTime, etc.)
  2. ✅ Wrap in DeviceTimestamp using appropriate factory method
  3. ✅ Pass to PqEvent.At() or function methods
  4. ❌ Never handle timezones
  5. ❌ Never use DateTimeOffset.UtcNow for device events

Framework's job:

  1. User configures device timezone via TimeSynchronization
  2. Framework receives DeviceTimestamp from adapter
  3. Framework converts to UTC using configured timezone
  4. Framework stores in database

Benefits:

  • Adapter code is simple (no timezone logic)
  • Event timestamps reflect device time (accurate audit trail)
  • Framework handles timezone complexity (DST, multi-timezone)
  • Type safety prevents accidental use of system time

See Also