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 reader.AccessGranted(DateTimeOffset.UtcNow);  // ← Compilation error!

// 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 door.Opened(timestamp);

    // 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 reader.AccessGranted(timestamp);
}

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 door.Opened(timestamp);
}

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 reader.AccessGranted(timestamp);
}

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

Every event method the generator emits on a Thing takes DeviceTimestamp as its first parameter — function actions (door.Opened, reader.AccessGranted) and declared event methods alike:

// generated on a Thing with the Reader function — do not write this yourself
public ValueTask<bool> AccessGranted(
    DeviceTimestamp timestamp,  // ← DeviceTimestamp parameter
    Guid? credentialId = null,
    string? credentialType = null,
    string? direction = null,
    Guid? personId = null)

The adapter creates the DeviceTimestamp from device data and passes it in:

// Protocol creates DeviceTimestamp from device data
var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
await reader.AccessGranted(timestamp, credentialId: credentialId, personId: personId);

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 DeviceEventRouter
{
    public async Task Route(AcmeDoor door, AcmeReader reader, byte[] rawData)
    {
        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 reader.AccessGranted(
                    timestamp,  // ← Just pass DeviceTimestamp
                    credentialId: evt.CredentialId,
                    personId: evt.PersonId
                );
                break;
        }
    }
}

Common Mistakes

❌ Wrong: Using DateTimeOffset.UtcNow

// WRONG - compilation error, DateTimeOffset not accepted
await reader.AccessGranted(DateTimeOffset.UtcNow);  // ❌ Won't compile!

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

Fix:

// Use device-reported time
var timestamp = DeviceTimestamp.FromUnixSeconds(evt.UnixTimestamp);
await reader.AccessGranted(timestamp);

❌ 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 reader.AccessGranted(timestamp);

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 reader.AccessGranted(timestamp);

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 door.Opened(timestamp);

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 it to the generated Thing methods (function actions or declared event 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