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
DateTimeobjects - 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:
- User configures device timezone in
TimeSynchronizationfunction - Adapter provides device time via
DeviceTimestamp - 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:
Note: DeviceTimestamp stores as UTC internally (offset +00:00), framework converts based on configured timezone.
Summary¶
Adapter's job:
- ✅ Get timestamp from device (Unix, components, DateTime, etc.)
- ✅ Wrap in
DeviceTimestampusing appropriate factory method - ✅ Pass to
PqEvent.At()or function methods - ❌ Never handle timezones
- ❌ Never use
DateTimeOffset.UtcNowfor device events
Framework's job:
- User configures device timezone via
TimeSynchronization - Framework receives
DeviceTimestampfrom adapter - Framework converts to UTC using configured timezone
- 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¶
- Concepts:
- Events - Event structure and taxonomy
-
Functions - TimeSynchronization function
-
Patterns:
- Event Translation - Converting device events
-
Event Publishing - Publishing best practices
-
Reference:
- Functions Registry - TimeSynchronization configuration
- Generated Code API - PqEvent.At() usage and DeviceTimestamp helper