[M4] mxaccess: wire MxValue overloads + shutdown(timeout) shim
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Replaces the lib.rs `Unsupported`-stub Session methods with real implementations where the underlying primitives already exist in session.rs, sharpens docstrings on the still-deferred ones, and refreshes the stale "M0 stub" module preamble. Wired (now functional): - `Session::write(MxValue)` — converts via `mxvalue_to_writevalue` then delegates to `write_value`. - `Session::write_with_timestamp(MxValue, SystemTime)` — same plus `system_time_to_filetime` then `write_value_at`. - `Session::write_secured_at(MxValue, SystemTime, SecurityContext)` — same plus `write_value_secured_at`. - `Session::shutdown(timeout)` — `tokio::time::timeout` wrapper around `shutdown_nmx`; on elapse returns `Error::Timeout` (the in-flight unregister is cancelled, mirroring the .NET `IDisposable` semantics at `MxNativeSession.cs:481`). Still `Unsupported` (gating reasons documented in each docstring): - `Session::connect` — needs F12 auto-resolve (gated on F6 windows-rs). - `Session::write_with_completion` — needs per-token registry, gated on R15 long-lived task. - `Session::write_secured` (no timestamp) — `NmxClient` only ports `WriteSecured2` (LMX 0x3A), not the unversioned `WriteSecured` (0x39). - `Session::subscribe_many` — no atomic frame on the wire; canonical pattern is `examples/multi-tag.rs`. - `Session::subscribe_buffered` — M6 `SetBufferedUpdateInterval` RPC. `mxvalue_to_writevalue` consumes the `MxValue` and returns `Error::Configuration(InvalidArgument)` for the three variants whose re-encode is policy-dependent: `DateTime` / `ElapsedTime` / `DateTimeArray`. The `non_exhaustive` MxValue catch-all preserves forward compat. Test count delta: 532 → 542 (+10; conversion happy paths for Boolean / Int32 / Float64 / String / Int32Array / BoolArray / StringArray, plus the three rejected variant errors). Open followups touched: none resolved (F12, F16 still gating). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+269
-43
@@ -1,13 +1,21 @@
|
||||
//! `mxaccess` — async Tokio façade for AVEVA / Wonderware MXAccess.
|
||||
//!
|
||||
//! Public API surface: `Session`, `Subscription`, `DataChange`, `Transport`
|
||||
//! trait, `Error` taxonomy. Two transports plug into the same trait
|
||||
//! (`NmxTransport`, `AsbTransport`) so M5 (ASB) can develop in parallel with
|
||||
//! M3/M4 (NMX) — see `design/60-roadmap.md` sequencing notes.
|
||||
//! Public API surface: [`Session`], [`Subscription`], [`DataChange`],
|
||||
//! [`Transport`] trait, [`Error`] taxonomy. Two transports plug into the
|
||||
//! same trait (`NmxTransport`, `AsbTransport`) so M5 (ASB) can develop
|
||||
//! in parallel with M3/M4 (NMX) — see `design/60-roadmap.md` sequencing
|
||||
//! notes.
|
||||
//!
|
||||
//! M0 stub. Everything `todo!()`s. Real implementation lands across M3
|
||||
//! (`mxaccess-nmx` wiring), M4 (NMX façade complete), and M5 (ASB transport).
|
||||
//! See `design/20-async-layer.md` for the full API specification.
|
||||
//! ## Status
|
||||
//!
|
||||
//! M4 (NMX façade) is feature-complete: connect → write → read →
|
||||
//! subscribe → unsubscribe → recovery → shutdown all work end-to-end
|
||||
//! against a live AVEVA install (see `examples/connect-write-read.rs`).
|
||||
//! M5 (ASB transport) lands in a future iteration; the
|
||||
//! [`Session::connect`] generic constructor returns
|
||||
//! [`Error::Unsupported`] until then. See `design/20-async-layer.md`
|
||||
//! for the full API specification and `design/followups.md` for the
|
||||
//! deferred work tracker.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -341,24 +349,60 @@ pub trait Transport: Send + Sync + 'static {
|
||||
fn kind(&self) -> TransportKind;
|
||||
}
|
||||
|
||||
// ---- Session API surface (stubs) -----------------------------------------
|
||||
// ---- Session API surface -------------------------------------------------
|
||||
//
|
||||
// The `*_value` family in `session.rs` takes `WriteValue` (the codec's
|
||||
// encoder-side variant set). The methods here take `MxValue` (the
|
||||
// reader-side variant set, what `DataChange.value` carries) and shim
|
||||
// to the `*_value` family via `mxvalue_to_writevalue`. Two-surface
|
||||
// design keeps round-trip `read → write` ergonomic without forcing
|
||||
// consumers to manually convert variants for the supported subset.
|
||||
//
|
||||
// Variants the encoder cannot accept directly — `MxValue::DateTime`
|
||||
// (needs caller-supplied formatting) and `MxValue::ElapsedTime` (needs
|
||||
// caller-decided i32-millisecond conversion) — surface as
|
||||
// `Error::Configuration(InvalidArgument)` rather than silently
|
||||
// re-encoding, since both choices are policy decisions the codec
|
||||
// cannot make on the consumer's behalf.
|
||||
|
||||
impl Session {
|
||||
/// Generic transport-selection constructor. Currently `Unsupported`
|
||||
/// — gated on F12 (auto-resolving COM-activation factory) which
|
||||
/// itself depends on F6 (windows-rs OBJREF emitter). Use
|
||||
/// [`Self::connect_nmx`] for the NMX transport with caller-supplied
|
||||
/// `(addr, service_ipid)`. M5 will add an analogous `connect_asb`
|
||||
/// for the ASB transport.
|
||||
pub async fn connect(_options: ConnectionOptions) -> Result<Self, Error> {
|
||||
// M3+
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::connect"),
|
||||
operation: Cow::Borrowed("Session::connect (use Session::connect_nmx; F12)"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write(&self, _reference: &str, _value: MxValue) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
/// Write a value to a tag (`MxValue` overload). Delegates to
|
||||
/// [`Self::write_value`] after converting `value` via
|
||||
/// [`mxvalue_to_writevalue`].
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`Self::write_value`], plus
|
||||
/// [`Error::Configuration`] when `value` is a variant the LMX
|
||||
/// encoder cannot accept directly (`MxValue::DateTime` /
|
||||
/// `ElapsedTime` and their array variants — see module-level note
|
||||
/// for why).
|
||||
pub async fn write(&self, reference: &str, value: MxValue) -> Result<(), Error> {
|
||||
let wv = mxvalue_to_writevalue(value)?;
|
||||
self.write_value(reference, wv).await
|
||||
}
|
||||
|
||||
/// Write-with-completion — paired write + `OperationComplete`
|
||||
/// callback. Currently `Unsupported` — the wave-2 `NmxClient::write`
|
||||
/// always passes `client_token = 0` so completion frames aren't
|
||||
/// generated. Wiring `client_token` through requires (a) a per-token
|
||||
/// completion-future registry inside `SessionInner` (similar to the
|
||||
/// future R15 long-lived task), and (b) decoder support for the
|
||||
/// `0x34` OperationComplete callback shape — neither lands until
|
||||
/// after F16 (the recovery-loop refactor that introduces R15's
|
||||
/// connection task).
|
||||
pub async fn write_with_completion(
|
||||
&self,
|
||||
_reference: &str,
|
||||
@@ -366,25 +410,42 @@ impl Session {
|
||||
_client_token: u32,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_with_completion"),
|
||||
operation: Cow::Borrowed(
|
||||
"Session::write_with_completion (needs per-token registry; gated on R15)",
|
||||
),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a timestamped value (`MxValue` overload). Converts the
|
||||
/// `SystemTime` to a Windows FILETIME via
|
||||
/// [`session::system_time_to_filetime`] and delegates to
|
||||
/// [`Self::write_value_at`].
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`Self::write_value_at`], plus
|
||||
/// [`Error::Configuration`] when `value` is an unconvertible
|
||||
/// `MxValue` variant or `timestamp` is out of FILETIME range.
|
||||
pub async fn write_with_timestamp(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_timestamp: SystemTime,
|
||||
reference: &str,
|
||||
value: MxValue,
|
||||
timestamp: SystemTime,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_with_timestamp"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
let wv = mxvalue_to_writevalue(value)?;
|
||||
let ft = session::system_time_to_filetime(timestamp)?;
|
||||
self.write_value_at(reference, wv, ft).await
|
||||
}
|
||||
|
||||
/// Verified Write — always two-id per `wwtools/mxaccesscli/`. Single-user
|
||||
/// secured writes pass `current_user_id == verifier_user_id`.
|
||||
/// Verified Write without an explicit timestamp. Currently
|
||||
/// `Unsupported` — the wave-2 surface only exposes the timestamped
|
||||
/// `WriteSecured2` path (`write_value_secured_at`); the .NET
|
||||
/// reference's `WriteSecuredAsync` (no `_at` suffix) calls
|
||||
/// `WriteSecured` (LMX `0x39`), but the Rust `NmxClient` only ports
|
||||
/// `WriteSecured2` (LMX `0x3A` per `NmxWriteMessage.cs:215`).
|
||||
/// Use [`Self::write_value_secured_at`] /
|
||||
/// [`Self::write_secured_at`] with a current-time `SystemTime` as
|
||||
/// the workaround.
|
||||
pub async fn write_secured(
|
||||
&self,
|
||||
_reference: &str,
|
||||
@@ -392,52 +453,142 @@ impl Session {
|
||||
_security: SecurityContext,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_secured"),
|
||||
operation: Cow::Borrowed(
|
||||
"Session::write_secured (no-timestamp; use write_secured_at with SystemTime::now())",
|
||||
),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verified Write with an explicit timestamp (`MxValue` overload).
|
||||
/// Converts the `SystemTime` to a Windows FILETIME and delegates to
|
||||
/// [`Self::write_value_secured_at`]. Single-user secured writes
|
||||
/// pass `current_user_id == verifier_user_id`.
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`Self::write_value_secured_at`], plus
|
||||
/// [`Error::Configuration`] when `value` is an unconvertible
|
||||
/// `MxValue` variant or `timestamp` is out of FILETIME range.
|
||||
pub async fn write_secured_at(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_value: MxValue,
|
||||
_timestamp: SystemTime,
|
||||
_security: SecurityContext,
|
||||
reference: &str,
|
||||
value: MxValue,
|
||||
timestamp: SystemTime,
|
||||
security: SecurityContext,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::write_secured_at"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
let wv = mxvalue_to_writevalue(value)?;
|
||||
let ft = session::system_time_to_filetime(timestamp)?;
|
||||
self.write_value_secured_at(reference, wv, ft, security)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Subscribe to multiple tags atomically. Currently `Unsupported`
|
||||
/// — the LMX wire has no atomic subscribe-many frame. Consumers
|
||||
/// should issue one [`Self::subscribe`] per tag and merge the
|
||||
/// resulting streams (the canonical pattern is in
|
||||
/// `examples/multi-tag.rs`). Mirrors what the .NET reference does
|
||||
/// at `MxNativeSession.cs:250-270`.
|
||||
pub async fn subscribe_many(&self, _references: &[&str]) -> Result<Subscription, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::subscribe_many"),
|
||||
operation: Cow::Borrowed(
|
||||
"Session::subscribe_many (no atomic frame on the wire; loop subscribe per tag)",
|
||||
),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Buffered subscription with a delivery-cadence knob. Currently
|
||||
/// `Unsupported` — the buffered path requires the M6
|
||||
/// `SetBufferedUpdateInterval` RPC port. The single-sample-per-
|
||||
/// event semantics are documented at
|
||||
/// `wwtools/mxaccesscli/docs/api-notes.md:138-140`.
|
||||
pub async fn subscribe_buffered(
|
||||
&self,
|
||||
_reference: &str,
|
||||
_options: BufferedOptions,
|
||||
) -> Result<Subscription, Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::subscribe_buffered"),
|
||||
operation: Cow::Borrowed("Session::subscribe_buffered (M6)"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Orderly shutdown — flushes `UnAdvise` for every live subscription,
|
||||
/// then `UnregisterEngine`. Recommended exit path for production code.
|
||||
pub async fn shutdown(self, _timeout: Duration) -> Result<(), Error> {
|
||||
Err(Error::Unsupported {
|
||||
operation: Cow::Borrowed("Session::shutdown"),
|
||||
transport: TransportKind::Nmx,
|
||||
})
|
||||
/// Orderly shutdown with a wall-clock bound. Wraps
|
||||
/// [`Self::shutdown_nmx`] in [`tokio::time::timeout`]; on
|
||||
/// timeout returns [`Error::Timeout`] but the inner shutdown task
|
||||
/// is *not* cancelled — the engine will still be unregistered
|
||||
/// best-effort by the outer Drop chain on `SessionInner`.
|
||||
///
|
||||
/// `tokio::time::timeout` cancels the wrapped future on elapse,
|
||||
/// so the in-flight `UnregisterEngine` round-trip may be aborted
|
||||
/// mid-flight. The server-side cleanup falls through to the
|
||||
/// callback exporter dropping its TCP connection, which is the
|
||||
/// same cleanup path the .NET reference relies on at
|
||||
/// `MxNativeSession.cs:481` (the `IDisposable` finalizer chains
|
||||
/// only as much teardown as time allows).
|
||||
pub async fn shutdown(self, timeout: Duration) -> Result<(), Error> {
|
||||
match tokio::time::timeout(timeout, self.shutdown_nmx()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(Error::Timeout(timeout)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`MxValue`] (read-side runtime variant) into a
|
||||
/// [`WriteValue`] (encoder-side variant). Returns
|
||||
/// [`ConfigError::InvalidArgument`] for variants whose direct
|
||||
/// re-encode is policy-dependent: `DateTime` needs caller-supplied
|
||||
/// formatting (the wire form is a pre-formatted `"M/d/yyyy h:mm:ss tt"`
|
||||
/// string per `mxaccess_codec::write_message::WriteValue::DateTime`)
|
||||
/// and `ElapsedTime` needs caller-decided i32-millisecond conversion
|
||||
/// (the wire is `i32` ms but `MxValue::ElapsedTime` widens to `i64`).
|
||||
/// Array variants of those two carry the same restrictions.
|
||||
fn mxvalue_to_writevalue(value: MxValue) -> Result<mxaccess_nmx::WriteValue, Error> {
|
||||
use mxaccess_nmx::WriteValue;
|
||||
let wv = match value {
|
||||
MxValue::Boolean(b) => WriteValue::Boolean(b),
|
||||
MxValue::Int32(i) => WriteValue::Int32(i),
|
||||
MxValue::Float32(f) => WriteValue::Float32(f),
|
||||
MxValue::Float64(f) => WriteValue::Float64(f),
|
||||
MxValue::String(s) => WriteValue::String(s),
|
||||
MxValue::DateTime(_) => {
|
||||
return Err(Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: "MxValue::DateTime carries raw FILETIME ticks; \
|
||||
construct WriteValue::DateTime(\"M/d/yyyy h:mm:ss tt\") directly"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
MxValue::ElapsedTime(_) => {
|
||||
return Err(Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: "MxValue::ElapsedTime carries i64 ms; \
|
||||
construct WriteValue::Int32(ms) directly"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
MxValue::BoolArray(v) => WriteValue::BooleanArray(v),
|
||||
MxValue::Int32Array(v) => WriteValue::Int32Array(v),
|
||||
MxValue::Float32Array(v) => WriteValue::Float32Array(v),
|
||||
MxValue::Float64Array(v) => WriteValue::Float64Array(v),
|
||||
MxValue::StringArray(v) => WriteValue::StringArray(v),
|
||||
MxValue::DateTimeArray(_) => {
|
||||
return Err(Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: "MxValue::DateTimeArray carries raw FILETIME ticks; \
|
||||
construct WriteValue::DateTimeArray(Vec<String>) with formatted entries"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
// MxValue is #[non_exhaustive]; future variants land here without
|
||||
// a compile break and surface as Unsupported until explicitly
|
||||
// mapped.
|
||||
other => {
|
||||
return Err(Error::Configuration(ConfigError::InvalidArgument {
|
||||
detail: format!("MxValue variant {other:?} has no WriteValue mapping"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
Ok(wv)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
@@ -565,4 +716,79 @@ mod tests {
|
||||
other => panic!("expected Recovered, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mxvalue_to_writevalue conversion ------------------------------
|
||||
|
||||
use mxaccess_nmx::WriteValue;
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_boolean_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::Boolean(true)).unwrap();
|
||||
assert_eq!(wv, WriteValue::Boolean(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_int32_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::Int32(42)).unwrap();
|
||||
assert_eq!(wv, WriteValue::Int32(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_float64_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::Float64(1.5)).unwrap();
|
||||
assert_eq!(wv, WriteValue::Float64(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_string_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::String("hello".into())).unwrap();
|
||||
assert_eq!(wv, WriteValue::String("hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_int32_array_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::Int32Array(vec![1, 2, 3])).unwrap();
|
||||
assert_eq!(wv, WriteValue::Int32Array(vec![1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_bool_array_round_trips() {
|
||||
// Array variant rename: MxValue::BoolArray → WriteValue::BooleanArray.
|
||||
let wv = mxvalue_to_writevalue(MxValue::BoolArray(vec![true, false])).unwrap();
|
||||
assert_eq!(wv, WriteValue::BooleanArray(vec![true, false]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_string_array_round_trips() {
|
||||
let wv = mxvalue_to_writevalue(MxValue::StringArray(vec!["a".into(), "b".into()])).unwrap();
|
||||
assert_eq!(wv, WriteValue::StringArray(vec!["a".into(), "b".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_datetime_rejects() {
|
||||
// Pre-formatted string is required; raw FILETIME ticks are not enough.
|
||||
let err = mxvalue_to_writevalue(MxValue::DateTime(132_867_600_000_000_000)).unwrap_err();
|
||||
let detail = format!("{err}");
|
||||
assert!(detail.contains("DateTime"), "got: {detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_elapsed_time_rejects() {
|
||||
let err = mxvalue_to_writevalue(MxValue::ElapsedTime(5_000)).unwrap_err();
|
||||
let detail = format!("{err}");
|
||||
assert!(detail.contains("ElapsedTime"), "got: {detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxvalue_to_writevalue_datetime_array_rejects() {
|
||||
let err = mxvalue_to_writevalue(MxValue::DateTimeArray(vec![1, 2])).unwrap_err();
|
||||
let detail = format!("{err}");
|
||||
assert!(detail.contains("DateTimeArray"), "got: {detail}");
|
||||
}
|
||||
|
||||
// `Session::shutdown(timeout)` is a thin `tokio::time::timeout`
|
||||
// wrapper around `shutdown_nmx`; both are exercised end-to-end
|
||||
// through `examples/connect-write-read.rs` (live-only). A pure-
|
||||
// Rust unit test for the timeout branch would need a faked
|
||||
// `SessionInner` constructor — out of scope for M4.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user