Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added

- Add `ua::Guid`.
- Add associated constants with min/max limits to `ua::DateTime`.
- Check min/max limits when converting `time::OffsetDateTime` into `ua::DateTime`.

## [0.8.3] - 2025-03-27

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ paste = "1.0.14"
serde = { version = "1.0.194", optional = true }
serde_json = { version = "1.0.111", optional = true }
thiserror = "2.0.3"
time = { version = "0.3.36", optional = true }
time = { version = "0.3.36", optional = true, features = ["macros"] }
tokio = { version = "1.38.2", optional = true, features = [
"rt",
"sync",
Expand Down
65 changes: 63 additions & 2 deletions src/ua/data_types/date_time.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
crate::data_type!(DateTime);

impl DateTime {
/// Minimum value.
///
/// See also: <https://reference.opcfoundation.org/Core/Part6/v105/docs/5.2.2.5>
pub const MIN: Self = Self(0);

/// Maximum value.
///
/// See also: <https://reference.opcfoundation.org/Core/Part6/v105/docs/5.2.2.5>
pub const MAX: Self = Self(2_650_467_743_990_000_000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this exact value come from? Is it the number of 100 nanosecond intervals that matches 9999-12-31 23:59:59 UTC? Maybe we can add a comment or otherwise make it clearer what the value represents.


/// Minimum UTC timestamp.
///
/// See also: <https://reference.opcfoundation.org/Core/Part6/v105/docs/5.2.2.5>
#[cfg(feature = "time")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move all the individually guarded attributes and method to_utc() to a separate impl block. This way, we'd only need one guard pragma, making it clearer what belongs together.

pub const MIN_UTC: time::OffsetDateTime = time::macros::datetime!(1601-01-01 00:00:00 UTC);

/// Maximum UTC timestamp.
///
/// See also: <https://reference.opcfoundation.org/Core/Part6/v105/docs/5.2.2.5>
#[cfg(feature = "time")]
pub const MAX_UTC: time::OffsetDateTime = time::macros::datetime!(9999-12-31 23:59:59 UTC);

/// Converts the value to an UTC timestamp.
///
/// The values [`MIN`](Self::MIN) and [`MAX`](Self::MAX) are converted as is and not
/// mapped to the corresponding limits of [`time::OffsetDateTime`].
#[cfg(feature = "time")]
#[must_use]
pub fn to_utc(&self) -> Option<time::OffsetDateTime> {
use open62541_sys::{UA_DATETIME_UNIX_EPOCH, UA_DATETIME_USEC};

// TODO: How to handle values that are out of range?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sgoll ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since out-of-range values should never be sent over OPC UA, we can treat them as an exception. I would not panic, however, because those values are still runtime values from another system.

That being said, I'd treat them similar to the possible error from from_unix_timestamp_nanos() at the end of the function, i.e. by returning None.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec out of range values should be clamped for encoding, i.e. either 0 (too low) or i64::MAX (too high):

https://reference.opcfoundation.org/Core/Part6/v105/docs/5.2.2.5

🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So DateTime::MAX should never be sent over the wire. Strange rules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's how I read it, too—when the value is equal to 9999-12-31T11:59:59Z or later, then send i64::MAX (i.e. 9_223_372_036_854_775_807 instead of 2_650_466_915_990_000_000).

Similarly, for 1601-01-01T12:00:00Z or earlier, there is also the ambiguity that we can't know whether the value was exactly that time (encoded as 0) or earlier (also encoded as 0).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I think we have to handle this case specially, i.e. we should have the following conditions:

  • value < 0None (not a valid value)
  • value == 0Some(Self::MIN), but it may have been clamped
  • 0 < value && value < 2_650_466_915_990_000_000Some(…)
  • 2_650_466_915_990_000_000 <= value && value < 9_223_372_036_854_775_807 None (not a valid value)
  • value == 9_223_372_036_854_775_807Some(Self::MAX), but it may have been clamped

@sgoll sgoll Apr 10, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could expose the "possibly clamped" state in the return type, either as None, Some(…), or use a third state with an appropriate enum variant.

On the other hand, since time::OffsedDateTime can represent times outside the range of 1601-01-01 and 9999-12-31 (the latter with feature flag large-dates), we could accept such values when we receive them (marked with "not a valid value" above)—be conservative in what you send, be liberal in what you accept.

So, we could leave it to from_unix_timestamp_nanos() to deal with it, possibly only handling the value of i64::MAX explicitly because that has special meaning and may mean 9999-12-31T11:59:59Z instead (in which case, I'd probably return None).

//debug_assert!(*self >= Self::MIN, "DateTime exceeds minimum value");
//debug_assert!(*self <= Self::MAX, "DateTime exceeds maximum value");

// OPC UA encodes `DateTime` as Windows file time: a 64-bit value that represents the number
// of 100-nanosecond intervals that have elapsed since 12:00 A.M. January 1, 1601 (UTC).
let ticks_ua = i128::from(self.0);
Expand Down Expand Up @@ -34,7 +64,8 @@ impl TryFrom<time::OffsetDateTime> for DateTime {
///
/// # Errors
///
/// The date/time must be valid and in range of the 64-bit representation of [`DateTime`].
/// The date/time must be valid and in range of the 64-bit representation of [`DateTime`]
/// within the limits [`MIN`](Self::MIN) and [`MAX`](Self::MAX).
fn try_from(from: time::OffsetDateTime) -> Result<Self, Self::Error> {
use open62541_sys::{UA_DATETIME_UNIX_EPOCH, UA_DATETIME_USEC};

Expand All @@ -44,6 +75,14 @@ impl TryFrom<time::OffsetDateTime> for DateTime {
let ticks_unix = nanos_unix / i128::from(1000 / UA_DATETIME_USEC);
let ticks_ua = ticks_unix + i128::from(UA_DATETIME_UNIX_EPOCH);

// Limit to min/max values.
if ticks_ua < Self::MIN.0.into() {
return Err(crate::Error::internal("DateTime exceeds minimum value"));
}
if ticks_ua > Self::MAX.0.into() {
return Err(crate::Error::internal("DateTime exceeds maximum value"));
}

i64::try_from(ticks_ua)
// Explicit module path to avoid linter errors when feature is not enable by `#[cfg()]`.
.map_err(|_| crate::Error::internal("DateTime should be in range"))
Expand Down Expand Up @@ -72,11 +111,33 @@ mod tests {
let dt = time::macros::datetime!(2023-11-20 16:51:15.9876543 -2:00);
assert_eq!(time::macros::offset!(-2:00), dt.offset());
assert_ne!(time::macros::offset!(UTC), dt.offset());
let dt_ua = crate::ua::DateTime::try_from(dt).unwrap();
let dt_ua = super::DateTime::try_from(dt).unwrap();
let dt_utc = dt_ua.to_utc().unwrap();
// Equal to the original timestamp, but the offset is now UTC.
assert_eq!(time::macros::offset!(UTC), dt_utc.offset());
assert_ne!(dt.offset(), dt_utc.offset());
assert_eq!(dt, dt_utc);
}

#[cfg(feature = "time")]
#[test]
fn min_max_utc() {
let min_ua = super::DateTime::try_from(super::DateTime::MIN_UTC).unwrap();
assert_eq!(min_ua, super::DateTime::MIN);
assert!(super::DateTime::try_from(
super::DateTime(super::DateTime::MIN.0 - 1)
.to_utc()
.unwrap()
)
.is_err());

let max_ua = super::DateTime::try_from(super::DateTime::MAX_UTC).unwrap();
assert_eq!(max_ua, super::DateTime::MAX);
assert!(super::DateTime::try_from(
super::DateTime(super::DateTime::MAX.0 + 1)
.to_utc()
.unwrap()
)
.is_err());
}
}