use std::borrow::Cow;
use std::collections::HashMap;
use std::future;
use std::ops::Deref;
use std::ops::DerefMut;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use anyhow::Context as _;
use anyhow::Result;
use anyhow::bail;
use futures_util::FutureExt as _;
use futures_util::Stream;
use futures_util::StreamExt;
use futures_util::select_biased;
use futures_util::stream::select_all;
use itertools::Itertools as _;
use open62541::AsyncClient;
use open62541::AsyncMonitoredItem;
use open62541::Certificate;
use open62541::ClientBuilder;
use open62541::DataType;
use open62541::MonitoredItemCreateRequestBuilder;
use open62541::PrivateKey;
use open62541::SubscriptionBuilder;
use open62541::ua;
use tokio::runtime;
use tokio::sync::oneshot;
use tokio::task::yield_now;
use tokio::time::Instant;
use tokio::time::Interval;
use tokio::time::MissedTickBehavior;
use tokio::time::interval;
use tokio::time::sleep;
use super::generate_self_signed_cert;
use super::metrics::NodeReadCounts;
use super::metrics::PollLoopMetricsLogger;
use super::types::OpcUaDataPoint;
use super::types::OpcUaMonitoredItemConfig;
use super::types::OpcUaNode;
use super::types::OpcUaPki;
use super::types::OpcUaSample;
use super::types::OpcUaSecurityMode;
use super::types::OpcUaSecurityPolicy;
use super::types::OpcUaSubscriptionConfig;
use super::types::OpcUaUserToken;
use crate::types::OpcUaNodeId;
pub trait OpcUaNodeListSource<'nodes> {
fn into_node_list(self) -> Cow<'nodes, [OpcUaNode]>;
}
impl<'nodes, D: Deref<Target = [OpcUaNode]>> OpcUaNodeListSource<'nodes> for &'nodes D {
fn into_node_list(self) -> Cow<'nodes, [OpcUaNode]> {
Cow::Borrowed(self.deref())
}
}
impl OpcUaNodeListSource<'static> for Vec<OpcUaNode> {
fn into_node_list(self) -> Cow<'static, [OpcUaNode]> {
Cow::Owned(self)
}
}
#[derive(Debug, Clone)]
pub struct OpcUaNodeReadBatch<'nodes> {
nodes: Cow<'nodes, [OpcUaNode]>,
node_attr_pairs: Vec<(ua::NodeId, ua::AttributeId)>,
}
impl<'nodes> OpcUaNodeReadBatch<'nodes> {
pub fn new<Src>(nodes: Src, attr: ua::AttributeId) -> Self
where
Src: OpcUaNodeListSource<'nodes>,
{
let nodes = nodes.into_node_list();
Self {
node_attr_pairs: nodes
.iter()
.map(|node| (node.node_id.clone().into(), attr.clone()))
.collect_vec(),
nodes,
}
}
pub fn nodes(&self) -> &[OpcUaNode] {
&self.nodes
}
pub fn pairs(&self) -> &[(ua::NodeId, ua::AttributeId)] {
&self.node_attr_pairs
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
type TerminationReceiver = mpsc::Receiver<()>;
type StopSender = oneshot::Sender<()>;
#[derive(Debug)]
struct SessionHandle {
stop_tx: StopSender,
term_rx: TerminationReceiver,
}
impl SessionHandle {
fn stop(self) -> Result<()> {
if let Err(e) = self.stop_tx.send(()) {
tracing::warn!(
target: "opcua::client::stream_session",
error = ?e,
"stream session likely terminated before stop signal could be sent"
);
}
self.term_rx.recv().context("waiting for session to exit")
}
fn stop_timeout(self, timeout: Duration) -> Result<()> {
if let Err(e) = self.stop_tx.send(()) {
tracing::warn!(
target: "opcua::client::stream_session",
error = ?e,
"stream session likely terminated before stop signal could be sent"
);
}
self.term_rx
.recv_timeout(timeout)
.context("waiting for session to exit")
}
}
#[derive(Debug)]
pub struct OpcUaClient {
client: AsyncClient,
}
impl OpcUaClient {
pub async fn disconnect(self: Arc<Self>) -> Result<()> {
match Arc::into_inner(self) {
None => bail!(
"OPC-UA client has outstanding references; cannot perform graceful disconnect"
),
Some(Self { client }) => {
client.disconnect().await;
Ok(())
}
}
}
#[must_use = "dropping the returned session will stop the polling loop"]
pub fn start_polling(
self: &Arc<Self>,
nodes: Vec<OpcUaNode>,
polling_interval: Duration,
on_data: impl FnMut(Box<dyn Iterator<Item = OpcUaSample>>) + Send + 'static,
) -> Result<OpcUaStreamSession> {
let this = Arc::downgrade(self);
OpcUaStreamSession::new("opcua-poll-loop", async move || {
Self::poll_loop(this, nodes, polling_interval, on_data).await;
})
}
pub async fn read_nodes(&self, node_list: &OpcUaNodeReadBatch<'_>) -> Result<Vec<OpcUaSample>> {
let read_result = self
.read_many_attributes(node_list.pairs())
.await
.context("reading node attributes")?;
if read_result.len() != node_list.nodes().len() {
bail!(
"read result length does not match node list length: {} != {}",
read_result.len(),
node_list.nodes().len()
);
}
Ok(node_list
.nodes()
.iter()
.zip(read_result)
.enumerate()
.filter_map(|(i, (node, value))| match OpcUaDataPoint::try_from(value) {
Err(e) => {
tracing::warn!(
target: "opcua::client",
error = ?e,
node_index = i,
"discarding data due to error decoding value for node"
);
None
}
Ok(value) => Some(OpcUaSample::new(node.node_id.clone(), value)),
})
.collect_vec())
}
#[must_use = "dropping the returned session will deregister the subscription"]
pub async fn start_subscription<F>(
self: &Arc<Self>,
nodes: Vec<OpcUaNode>,
sub_config: OpcUaSubscriptionConfig,
item: OpcUaMonitoredItemConfig,
on_data: F,
) -> Result<OpcUaStreamSession>
where
F: FnMut(Box<dyn Iterator<Item = OpcUaSample>>) + Send + 'static,
{
let subscription_builder = SubscriptionBuilder::from(sub_config);
let item_builder = item.apply_to_builder(MonitoredItemCreateRequestBuilder::new(
nodes.iter().map(|n| ua::NodeId::from(n.node_id.clone())),
));
let (_, subscription) = subscription_builder
.create(self)
.await
.context("creating OPC-UA subscription")?;
let item_results = AsyncMonitoredItem::create(&subscription, item_builder)
.await
.context("creating OPC-UA monitored items")?;
if item_results.len() != nodes.len() {
bail!(
"OPC-UA server returned {} monitored-item results for {} requested nodes",
item_results.len(),
nodes.len()
);
}
let requested_count = nodes.len();
let mut valid_streams = Vec::with_capacity(requested_count);
for (node, result) in nodes.iter().cloned().zip(item_results) {
let node_id = node.node_id;
match result {
Ok((create_result, monitored_item)) => {
item.validate(&node_id, &create_result);
valid_streams.push(
monitored_item
.filter_map(move |value| {
let node_id = node_id.clone();
async move {
match OpcUaDataPoint::try_from(value) {
Ok(data) => Some((node_id, data)),
Err(e) => {
tracing::warn!(
target: "opcua::client::subscribe",
error = ?e,
"Notification for node {node_id} returned an invalid value, discarding sample"
);
None
}
}
}
})
.boxed(),
);
}
Err(e) => {
tracing::warn!(
target: "opcua::client::subscribe",
error = ?e,
"skipping monitored item creation for node {node_id}"
);
}
}
}
if valid_streams.is_empty() {
bail!(
"OPC-UA subscription has no valid monitored items; all {requested_count} \
requested node(s) failed",
);
}
let reader = ClientNodeReader {
client: Arc::downgrade(self),
};
let timer = sub_config
.background_poll_interval
.filter(|period| !period.is_zero())
.map(IntervalPollTimer::new);
OpcUaStreamSession::new("opcua-subscription-pump", async move || {
let _subscription = subscription;
Self::subscription_loop(reader, nodes, select_all(valid_streams), on_data, timer).await;
})
}
const POLL_LOOP_METRICS_FLUSH_INTERVAL: Duration = Duration::from_secs(5);
async fn poll_loop(
this: Weak<Self>,
nodes: Vec<OpcUaNode>,
polling_interval: Duration,
mut on_data: impl FnMut(Box<dyn Iterator<Item = OpcUaSample>>),
) {
let node_list = OpcUaNodeReadBatch::new(&nodes, ua::AttributeId::VALUE);
let total_nodes = node_list.nodes().len() as u64;
let mut metrics = PollLoopMetricsLogger::new(Self::POLL_LOOP_METRICS_FLUSH_INTERVAL);
loop {
let start_time = Instant::now();
let read_start = metrics.start_read();
let read_result = if let Some(this) = this.upgrade() {
this.read_nodes(&node_list)
.await
.context("reading nodes from poll loop")
} else {
tracing::warn!(
target: "opcua::client::poll",
"OPC-UA client has been dropped, stopping poll loop"
);
break;
};
let (outcome, samples) = match read_result {
Ok(values) => {
let successful_reads = values.len() as u64;
let failed_reads = total_nodes.saturating_sub(successful_reads);
(
Some(NodeReadCounts {
valid_samples: successful_reads,
invalid_samples: failed_reads,
}),
Some(values),
)
}
Err(e) => {
tracing::error!(
target: "opcua::client::poll",
error = ?e,
"error reading node attributes"
);
(None, None)
}
};
metrics.finish_read(read_start, outcome);
if let Some(samples) = samples {
on_data(Box::new(samples.into_iter()));
}
match polling_interval.checked_sub(start_time.elapsed()) {
Some(duration) => sleep(duration).await,
None => yield_now().await, }
}
}
async fn subscription_loop<R, N, S, F, T>(
reader: R,
nodes: N,
stream: S,
mut on_data: F,
mut timer: Option<T>,
) where
R: NodeReader,
S: Stream<Item = (OpcUaNodeId, OpcUaDataPoint)> + Send + Unpin + 'static,
F: FnMut(Box<dyn Iterator<Item = OpcUaSample>>) + Send + 'static,
N: IntoIterator<Item = OpcUaNode>,
T: PollTimer,
{
let all_nodes = nodes
.into_iter()
.map(|n| (n.node_id.clone(), n))
.collect::<HashMap<_, _>>();
let mut quiet_nodes = all_nodes.clone();
let mut polled_nodes = HashMap::<OpcUaNodeId, OpcUaSample>::new();
let mut stream = stream.ready_chunks(10);
loop {
let maybe_tick = async {
if let Some(timer) = timer.as_mut() {
timer.tick().await;
} else {
future::pending::<()>().await;
}
};
select_biased! {
_ = maybe_tick.fuse() => {
if !reader.is_alive() {
break;
}
let polled_samples_to_flush = polled_nodes
.drain()
.map(|(_, sample)| sample)
.collect_vec();
on_data(Box::new(polled_samples_to_flush.into_iter()));
if !quiet_nodes.is_empty() {
let nodes = quiet_nodes.values().cloned().collect_vec();
let batch = OpcUaNodeReadBatch::new(&nodes, ua::AttributeId::VALUE);
let reads = match reader.read_nodes(&batch).await {
Ok(reads) => reads,
Err(e) => {
tracing::error!(
target: "opcua::client::subscribe",
error = ?e,
"error reading nodes in subscription loop"
);
continue;
}
};
let to_flush = reads
.into_iter()
.map(|sample| (sample.node_id.clone(), sample));
polled_nodes.extend(to_flush);
}
quiet_nodes = all_nodes
.iter()
.map(|(node_id, n)| (node_id.clone(), n.clone()))
.collect();
},
chunk = stream.next().fuse() => if let Some(chunk) = chunk {
let mut samples = Vec::with_capacity(chunk.len() * 2);
for (node_id, data) in chunk {
quiet_nodes.remove(&node_id);
if let Some(polled_sample) = polled_nodes.remove(&node_id)
&& polled_sample.data.server_timestamp < data.server_timestamp
{
samples.push(polled_sample);
}
samples.push(OpcUaSample::new(node_id, data));
}
if !samples.is_empty() {
on_data(Box::new(samples.into_iter()));
}
} else {
tracing::info!(
target: "opcua::client::subscribe",
"subscription pump received no more items, stopping"
);
break;
},
}
}
tracing::info!(
target: "opcua::client::subscribe",
"subscription loop stopped"
);
let samples_to_flush = polled_nodes.drain().map(|(_, sample)| sample).collect_vec();
on_data(Box::new(samples_to_flush.into_iter()));
}
}
#[allow(async_fn_in_trait)]
pub(crate) trait NodeReader {
fn is_alive(&self) -> bool;
async fn read_nodes(&self, batch: &OpcUaNodeReadBatch<'_>) -> Result<Vec<OpcUaSample>>;
}
pub(crate) struct ClientNodeReader {
client: Weak<OpcUaClient>,
}
impl NodeReader for ClientNodeReader {
fn is_alive(&self) -> bool {
self.client.strong_count() > 0
}
async fn read_nodes(&self, batch: &OpcUaNodeReadBatch<'_>) -> Result<Vec<OpcUaSample>> {
match self.client.upgrade() {
Some(client) => client.read_nodes(batch).await,
None => bail!("OPC-UA client dropped before background poll read"),
}
}
}
#[allow(async_fn_in_trait)]
pub(crate) trait PollTimer {
async fn tick(&mut self);
}
pub(crate) struct IntervalPollTimer {
period: Duration,
interval: Option<Interval>,
}
impl IntervalPollTimer {
fn new(period: Duration) -> Self {
Self {
period,
interval: None,
}
}
}
impl PollTimer for IntervalPollTimer {
async fn tick(&mut self) {
let period = self.period;
let timer = self.interval.get_or_insert_with(|| {
let mut timer = interval(period);
timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
timer.reset();
timer
});
timer.tick().await;
}
}
#[derive(Debug, Default)]
pub struct OpcUaClientBuilder {
user_token: Option<OpcUaUserToken>,
security_mode: Option<OpcUaSecurityMode>,
security_policy: Option<OpcUaSecurityPolicy>,
timeout: Option<Duration>,
accept_any_cert: bool,
pki: OpcUaPki,
}
impl OpcUaClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn trust_server_certs(self, accept_any_cert: bool) -> Self {
Self {
accept_any_cert,
..self
}
}
pub fn user_identity_token(self, token: OpcUaUserToken) -> Self {
Self {
user_token: Some(token),
..self
}
}
pub fn security_mode(self, mode: OpcUaSecurityMode) -> Self {
Self {
security_mode: Some(mode),
..self
}
}
pub fn security_policy(self, security_policy: OpcUaSecurityPolicy) -> Self {
Self {
security_policy: Some(security_policy),
..self
}
}
pub fn timeout(self, timeout: Duration) -> Self {
Self {
timeout: Some(timeout),
..self
}
}
pub fn pki(self, pki: OpcUaPki) -> Self {
Self { pki, ..self }
}
pub fn use_pki(self, certificate: Certificate, private_key: PrivateKey) -> Self {
Self {
pki: OpcUaPki::UseProvided(certificate, private_key),
..self
}
}
pub fn generate_self_signed_pki(self) -> Self {
Self {
pki: OpcUaPki::GenerateSelfSigned,
..self
}
}
fn connect_app_description() -> ua::ApplicationDescription {
ua::ApplicationDescription::init()
.with_application_name("en-US", "Nominal OPC UA Client")
.with_product_uri("urn:nominal:opcua-client")
.with_application_type(ua::ApplicationType::CLIENT)
.with_application_uri("urn:nominal:opcua-client")
}
#[must_use = "dropping the returned client will immediately disconnect from the OPC-UA server"]
pub fn connect(self, endpoint_url: &str) -> Result<Arc<OpcUaClient>> {
let user_token = self.user_token.context("no user token provided")?;
let security_mode = match self.security_mode {
Some(mode) if !mode.is_invalid() => mode.into(),
Some(_) => bail!("security mode was specified but was invalid"),
None => bail!("security mode was not specified"),
};
let mut builder = match self.pki {
OpcUaPki::UseProvided(certificate, private_key) => {
ClientBuilder::default_encryption(&certificate, &private_key)?
}
OpcUaPki::GenerateSelfSigned => {
let (certificate, private_key) = generate_self_signed_cert()?;
ClientBuilder::default_encryption(&certificate, &private_key)?
}
OpcUaPki::None => ClientBuilder::default(),
};
builder = builder
.secure_channel_life_time(Duration::from_millis(u32::MAX as u64))
.user_identity_token(&user_token.try_into()?)
.client_description(Self::connect_app_description())
.security_mode(security_mode);
if let Some(timeout) = self.timeout {
builder = builder.timeout(timeout);
}
if let Some(security_policy) = self.security_policy {
builder = builder.security_policy_uri(security_policy.into());
}
if self.accept_any_cert {
builder = builder.accept_all();
}
let client = builder.connect(endpoint_url)?.into_async();
Ok(OpcUaClient::new(client))
}
}
impl OpcUaClient {
fn new(client: AsyncClient) -> Arc<Self> {
Arc::new(Self { client })
}
}
impl Deref for OpcUaClient {
type Target = AsyncClient;
fn deref(&self) -> &Self::Target {
&self.client
}
}
impl DerefMut for OpcUaClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.client
}
}
#[derive(Debug)]
pub struct OpcUaStreamSession {
handle: Option<SessionHandle>,
}
impl OpcUaStreamSession {
fn new<F>(name: &'static str, f: F) -> Result<Self>
where
F: AsyncFnOnce() -> () + Send + 'static,
{
let runtime = runtime::Builder::new_current_thread()
.enable_time()
.build()
.context("creating tokio runtime for opcua task")?;
let (stop_tx, stop_rx) = oneshot::channel();
let (term_tx, term_rx) = mpsc::sync_channel(1);
let _ = thread::Builder::new()
.name(format!("opcua-worker-thread-{name}"))
.spawn(move || runtime.block_on(async move {
tokio::select! {
_ = stop_rx => {
tracing::info!(target: "opcua::client::spawn_task", "task {name} stopped cooperatively");
}
_ = f() => ()
}
if let Err(e) = term_tx.send(()) {
tracing::error!(
target: "opcua::client::spawn_task",
err = ?e,
task = name,
"error sending finished signal to task"
);
}
}))
.context("spawning thread for opcua task")?;
Ok(Self {
handle: Some(SessionHandle { stop_tx, term_rx }),
})
}
pub fn stop(mut self) -> Result<()> {
if let Some(handle) = self.handle.take() {
handle.stop()?;
}
Ok(())
}
pub fn stop_timeout(mut self, timeout: Duration) -> Result<()> {
if let Some(handle) = self.handle.take() {
handle.stop_timeout(timeout)?;
}
Ok(())
}
}
impl Drop for OpcUaStreamSession {
fn drop(&mut self) {
if let Some(sync) = self.handle.take()
&& let Err(e) = sync.stop_timeout(Duration::from_secs(2))
{
tracing::error!(
target: "opcua::client::stream_session",
error = ?e,
"error stopping stream session"
);
}
}
}
#[cfg(test)]
mod subscription_loop_tests {
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context as _;
use anyhow::Result;
use anyhow::anyhow;
use futures_util::Stream;
use futures_util::StreamExt as _;
use tokio::sync::mpsc;
use tokio::time::timeout;
use super::NodeReader;
use super::OpcUaClient;
use super::OpcUaNodeReadBatch;
use super::PollTimer;
use crate::types::NodeIdInner;
use crate::types::OpcUaDataPoint;
use crate::types::OpcUaNode;
use crate::types::OpcUaNodeClass;
use crate::types::OpcUaNodeId;
use crate::types::OpcUaSample;
use crate::types::OpcUaValue;
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
struct ReaderState {
is_alive: bool,
next_timestamp: u64,
requested: Vec<HashSet<OpcUaNodeId>>,
}
struct MockNodeReader {
state: Arc<Mutex<ReaderState>>,
read_done: mpsc::UnboundedSender<()>,
}
impl MockNodeReader {
fn new(
initial_timestamp: u64,
) -> (Self, Arc<Mutex<ReaderState>>, mpsc::UnboundedReceiver<()>) {
let state = Arc::new(Mutex::new(ReaderState {
is_alive: true,
next_timestamp: initial_timestamp,
requested: Vec::new(),
}));
let (read_done, read_done_rx) = mpsc::unbounded_channel();
let reader = Self {
state: Arc::clone(&state),
read_done,
};
(reader, state, read_done_rx)
}
}
impl NodeReader for MockNodeReader {
fn is_alive(&self) -> bool {
self.state.lock().map(|s| s.is_alive).unwrap_or(false)
}
async fn read_nodes(&self, batch: &OpcUaNodeReadBatch<'_>) -> Result<Vec<OpcUaSample>> {
let samples = {
let mut state = self
.state
.lock()
.map_err(|_| anyhow!("reader state poisoned"))?;
let timestamp = state.next_timestamp;
state.next_timestamp = state.next_timestamp.saturating_add(1);
let requested = batch
.nodes()
.iter()
.map(|node| node.node_id.clone())
.collect::<HashSet<_>>();
state.requested.push(requested);
batch
.nodes()
.iter()
.map(|node| OpcUaSample::new(node.node_id.clone(), datapoint(timestamp, 0.0)))
.collect::<Vec<_>>()
};
let _ = self.read_done.send(());
Ok(samples)
}
}
struct MockPollTimer {
pulses: mpsc::UnboundedReceiver<()>,
}
impl PollTimer for MockPollTimer {
async fn tick(&mut self) {
if self.pulses.recv().await.is_none() {
std::future::pending::<()>().await;
}
}
}
fn test_node(id: u32, name: &str) -> OpcUaNode {
OpcUaNode {
node_id: OpcUaNodeId {
namespace: 1,
inner: NodeIdInner::Numeric(id),
},
browse_name: name.to_owned(),
display_name: name.to_owned(),
node_class: OpcUaNodeClass::Variable,
children: Vec::new(),
}
}
fn datapoint(server_timestamp: u64, value: f64) -> OpcUaDataPoint {
OpcUaDataPoint {
server_timestamp,
source_timestamp: None,
value: OpcUaValue::Double(value),
}
}
fn notification_stream(
rx: mpsc::UnboundedReceiver<(OpcUaNodeId, OpcUaDataPoint)>,
) -> impl Stream<Item = (OpcUaNodeId, OpcUaDataPoint)> + Send + Unpin + 'static {
futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
})
.boxed()
}
fn output_sink() -> (
impl FnMut(Box<dyn Iterator<Item = OpcUaSample>>) + Send + 'static,
mpsc::UnboundedReceiver<Vec<OpcUaSample>>,
) {
let (tx, rx) = mpsc::unbounded_channel::<Vec<OpcUaSample>>();
let on_data = move |samples: Box<dyn Iterator<Item = OpcUaSample>>| {
let _ = tx.send(samples.collect::<Vec<_>>());
};
(on_data, rx)
}
async fn await_signal(rx: &mut mpsc::UnboundedReceiver<()>, what: &str) -> Result<()> {
timeout(TEST_TIMEOUT, rx.recv())
.await
.with_context(|| format!("timed out waiting for {what}"))?
.with_context(|| format!("channel closed waiting for {what}"))?;
Ok(())
}
async fn recv_nonempty(
rx: &mut mpsc::UnboundedReceiver<Vec<OpcUaSample>>,
what: &str,
) -> Result<Vec<OpcUaSample>> {
loop {
let batch = timeout(TEST_TIMEOUT, rx.recv())
.await
.with_context(|| format!("timed out waiting for {what}"))?
.with_context(|| format!("channel closed waiting for {what}"))?;
if !batch.is_empty() {
return Ok(batch);
}
}
}
fn drain_batches(rx: &mut mpsc::UnboundedReceiver<Vec<OpcUaSample>>) -> Vec<Vec<OpcUaSample>> {
let mut batches = Vec::new();
while let Ok(batch) = rx.try_recv() {
batches.push(batch);
}
batches
}
async fn await_loop(handle: tokio::task::JoinHandle<()>) -> Result<()> {
timeout(TEST_TIMEOUT, handle)
.await
.context("subscription loop did not terminate")?
.map_err(|e| anyhow!("subscription loop task failed: {e}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn polls_static_node_each_tick() -> Result<()> {
let x = test_node(1, "Static");
let (reader, state, mut read_done_rx) = MockNodeReader::new(1);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
for _ in 0..3 {
pulse_tx.send(()).context("pulsing poll tick")?;
await_signal(&mut read_done_rx, "background poll read").await?;
}
drop(note_tx); await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(samples.len(), 3, "expected one polled sample per tick");
for sample in &samples {
assert_eq!(sample.node_id, x.node_id);
}
let timestamps = samples
.iter()
.map(|s| s.data.server_timestamp)
.collect::<Vec<_>>();
assert_eq!(
timestamps,
vec![1, 2, 3],
"polled samples should carry successive read timestamps",
);
let requested = state
.lock()
.map_err(|_| anyhow!("state poisoned"))?
.requested
.clone();
assert_eq!(requested.len(), 3);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dedup_drops_stale_polled_when_notification_not_newer() -> Result<()> {
let x = test_node(1, "Node");
let (reader, state, mut read_done_rx) = MockNodeReader::new(100);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
pulse_tx.send(()).context("pulsing poll tick")?;
await_signal(&mut read_done_rx, "background poll read").await?;
note_tx
.send((x.node_id.clone(), datapoint(100, 1.0)))
.context("sending notification")?;
drop(note_tx);
await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(samples.len(), 1, "stale polled sample should be dropped");
let sample = samples.first().context("missing notification sample")?;
assert_eq!(sample.node_id, x.node_id);
assert_eq!(sample.data.server_timestamp, 100);
assert_eq!(sample.data.value, OpcUaValue::Double(1.0));
let _ = state; Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_both_when_polled_older_than_notification() -> Result<()> {
let x = test_node(1, "Node");
let (reader, _state, mut read_done_rx) = MockNodeReader::new(100);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
pulse_tx.send(()).context("pulsing poll tick")?;
await_signal(&mut read_done_rx, "background poll read").await?;
note_tx
.send((x.node_id.clone(), datapoint(200, 2.0)))
.context("sending notification")?;
drop(note_tx);
await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(samples.len(), 2, "both polled and notification should emit");
let polled = samples.first().context("missing polled sample")?;
let notification = samples.get(1).context("missing notification sample")?;
assert_eq!(
polled.data.server_timestamp, 100,
"polled sample emitted first"
);
assert_eq!(notification.data.server_timestamp, 200);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn flush_on_exit_drains_buffer() -> Result<()> {
let x = test_node(1, "Static");
let (reader, _state, mut read_done_rx) = MockNodeReader::new(7);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
pulse_tx.send(()).context("pulsing poll tick")?;
await_signal(&mut read_done_rx, "background poll read").await?;
drop(note_tx);
await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(
samples.len(),
1,
"exit drain should flush the buffered sample"
);
let sample = samples.first().context("missing drained sample")?;
assert_eq!(sample.node_id, x.node_id);
assert_eq!(sample.data.server_timestamp, 7);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn quiet_node_repolled_after_going_quiet() -> Result<()> {
let x = test_node(1, "Static");
let y = test_node(2, "Active");
let (reader, state, mut read_done_rx) = MockNodeReader::new(1);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone(), y.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
pulse_tx.send(()).context("pulsing tick 1")?;
await_signal(&mut read_done_rx, "tick 1 read").await?;
note_tx
.send((y.node_id.clone(), datapoint(10, 1.0)))
.context("sending Y notification")?;
recv_nonempty(&mut out_rx, "Y notification batch").await?;
pulse_tx.send(()).context("pulsing tick 2")?;
await_signal(&mut read_done_rx, "tick 2 read").await?;
pulse_tx.send(()).context("pulsing tick 3")?;
await_signal(&mut read_done_rx, "tick 3 read").await?;
drop(note_tx);
await_loop(handle).await?;
let _ = drain_batches(&mut out_rx);
let requested = state
.lock()
.map_err(|_| anyhow!("state poisoned"))?
.requested
.clone();
assert_eq!(requested.len(), 3, "expected three poll reads");
let both = HashSet::from([x.node_id.clone(), y.node_id.clone()]);
let only_x = HashSet::from([x.node_id.clone()]);
assert_eq!(requested.first(), Some(&both), "tick 1 polls both nodes");
assert_eq!(
requested.get(1),
Some(&only_x),
"tick 2 excludes the active node"
);
assert_eq!(
requested.get(2),
Some(&both),
"tick 3 re-polls the now-quiet node"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn none_interval_emits_only_notifications() -> Result<()> {
let x = test_node(1, "Static");
let (reader, state, _read_done_rx) = MockNodeReader::new(1);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Option::<MockPollTimer>::None,
));
note_tx
.send((x.node_id.clone(), datapoint(7, 9.5)))
.context("sending notification")?;
drop(note_tx);
await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(samples.len(), 1, "only the notification should be emitted");
let sample = samples.first().context("missing notification sample")?;
assert_eq!(sample.data.server_timestamp, 7);
let requested = state
.lock()
.map_err(|_| anyhow!("state poisoned"))?
.requested
.clone();
assert!(
requested.is_empty(),
"no background reads should occur without a poll interval"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mixed_static_and_active() -> Result<()> {
let x = test_node(1, "Static");
let y = test_node(2, "Active");
let (reader, _state, mut read_done_rx) = MockNodeReader::new(1);
let (note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone(), y.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
let mut batches: Vec<Vec<OpcUaSample>> = Vec::new();
note_tx
.send((y.node_id.clone(), datapoint(5, 1.0)))
.context("sending Y notification")?;
batches.push(recv_nonempty(&mut out_rx, "first Y notification").await?);
pulse_tx.send(()).context("pulsing tick 1")?;
await_signal(&mut read_done_rx, "tick 1 read").await?;
note_tx
.send((y.node_id.clone(), datapoint(6, 2.0)))
.context("sending second Y notification")?;
batches.push(recv_nonempty(&mut out_rx, "second Y notification").await?);
pulse_tx.send(()).context("pulsing tick 2")?;
await_signal(&mut read_done_rx, "tick 2 read").await?;
drop(note_tx);
await_loop(handle).await?;
batches.extend(drain_batches(&mut out_rx));
let samples = batches.into_iter().flatten().collect::<Vec<_>>();
let static_count = samples.iter().filter(|s| s.node_id == x.node_id).count();
let active_timestamps = samples
.iter()
.filter(|s| s.node_id == y.node_id)
.map(|s| s.data.server_timestamp)
.collect::<HashSet<_>>();
assert!(
static_count >= 2,
"static node should keep being polled (got {static_count})"
);
assert!(
active_timestamps.contains(&5) && active_timestamps.contains(&6),
"both active-node notifications should be delivered, got {active_timestamps:?}",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn breaks_before_flush_on_dropped_client() -> Result<()> {
let x = test_node(1, "Static");
let (reader, state, mut read_done_rx) = MockNodeReader::new(1);
let (_note_tx, note_rx) = mpsc::unbounded_channel();
let (pulse_tx, pulse_rx) = mpsc::unbounded_channel();
let (on_data, mut out_rx) = output_sink();
let handle = tokio::spawn(OpcUaClient::subscription_loop(
reader,
vec![x.clone()],
notification_stream(note_rx),
on_data,
Some(MockPollTimer { pulses: pulse_rx }),
));
pulse_tx.send(()).context("pulsing tick 1")?;
await_signal(&mut read_done_rx, "tick 1 read").await?;
state
.lock()
.map_err(|_| anyhow!("state poisoned"))?
.is_alive = false;
pulse_tx.send(()).context("pulsing tick 2")?;
await_loop(handle).await?;
let samples = drain_batches(&mut out_rx)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(
samples.len(),
1,
"only the buffered sample should be drained on exit"
);
let sample = samples.first().context("missing drained sample")?;
assert_eq!(sample.data.server_timestamp, 1);
let requested = state
.lock()
.map_err(|_| anyhow!("state poisoned"))?
.requested
.clone();
assert_eq!(
requested.len(),
1,
"dropped-client tick must not issue a read"
);
assert!(
read_done_rx.try_recv().is_err(),
"tick 2 must not complete a read"
);
Ok(())
}
}