Skip to main content

ua_client/
client.rs

1use std::str::FromStr;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use anyhow::{Result, anyhow};
6use opcua::client::custom_types::DataTypeTreeBuilder;
7use opcua::client::{ClientBuilder, IdentityToken, Session};
8use opcua::crypto::SecurityPolicy;
9use opcua::types::custom::{DynamicStructure, DynamicTypeLoader};
10use opcua::types::json::{JsonEncodable, JsonStreamWriter, JsonWriter};
11use opcua::types::{
12    Argument, Array, AttributeId, BrowseDescription, BrowseDescriptionResultMask, BrowseDirection,
13    CallMethodRequest, DataTypeId, DataValue, EndpointDescription, ExpandedNodeId, Guid,
14    Identifier, MessageSecurityMode, NodeClass, NodeClassMask, NodeId, QualifiedName, ReadValueId,
15    ReferenceDescription, ReferenceTypeId, StatusCode, TimestampsToReturn, TryFromVariant,
16    TypeLoader, UAString, UserTokenType, Variant, VariantScalarTypeId,
17};
18use tokio::sync::Mutex;
19use tokio::task::JoinHandle;
20
21use crate::types::{
22    AuthMode, AuthSpec, EndpointInfo, MethodArgument, MethodCallOutcome, MethodSignature,
23    NodeAttribute, NodeSummary, ReferenceRow, SecurityMode, TreeChild, ValueTree,
24};
25
26struct Connected {
27    session: Arc<Session>,
28    event_loop: JoinHandle<StatusCode>,
29}
30
31enum State {
32    Disconnected,
33    Connected(Connected),
34}
35
36pub struct UaClient {
37    state: Mutex<State>,
38    /// When true, ClientBuilder is configured with `verify_server_certs(false)`,
39    /// which makes async-opcua skip server-certificate time, hostname and
40    /// application-URI checks. Defaults to `true` because many real servers
41    /// (Beckhoff TwinCAT, several Siemens setups, NAT'd deployments) ship
42    /// certificates that don't match the routable address the client uses.
43    /// A loud warning is emitted on every UaClient construction.
44    verify_certificate_metadata: AtomicBool,
45}
46
47impl Default for UaClient {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl UaClient {
54    pub fn set_verify_cert_metadata(&self, on: bool) {
55        self.verify_certificate_metadata
56            .store(on, Ordering::Relaxed);
57    }
58
59    pub fn new() -> Self {
60        warn_insecure_default();
61        Self {
62            state: Mutex::new(State::Disconnected),
63            verify_certificate_metadata: AtomicBool::new(false),
64        }
65    }
66
67    pub async fn connect(
68        &self,
69        endpoint_url: &str,
70        endpoint: Option<&EndpointInfo>,
71        auth: &AuthSpec,
72    ) -> Result<()> {
73        let mut guard = self.state.lock().await;
74        if matches!(*guard, State::Connected(_)) {
75            return Err(anyhow!("already connected"));
76        }
77
78        let mut client = build_client(self.verify_certificate_metadata.load(Ordering::Relaxed))?;
79
80        let (policy_uri, mode) = match endpoint {
81            Some(ep) => (
82                ep.security_policy_uri.clone(),
83                security_mode_to_message_mode(ep.security_mode),
84            ),
85            None => (
86                SecurityPolicy::None.to_uri().to_string(),
87                MessageSecurityMode::None,
88            ),
89        };
90        let identity = build_identity_token(auth)?;
91        if mode != MessageSecurityMode::None {
92            log_client_cert_hint();
93        }
94
95        // Fetch the server's endpoints ourselves so we have the full
96        // EndpointDescription (with server_certificate + user_identity_tokens),
97        // then connect_to_endpoint_directly. This sidesteps a Beckhoff/PLC
98        // quirk where servers return endpoints with internal hostnames the
99        // client can't resolve — `connect_to_matching_endpoint` would obey the
100        // server-reported URL and fail at TCP-resolve time. We always force the
101        // transport URL back to whatever the user actually typed.
102        let descriptions = client
103            .get_server_endpoints_from_url(endpoint_url)
104            .await
105            .map_err(|e| anyhow!("get_server_endpoints failed: {e}"))?;
106        let mut matched = descriptions
107            .into_iter()
108            .find(|d| d.security_policy_uri.as_ref() == policy_uri && d.security_mode == mode)
109            .ok_or_else(|| {
110                anyhow!(
111                    "server has no endpoint with policy '{}' and mode {:?}",
112                    policy_uri,
113                    mode
114                )
115            })?;
116        let reported_url = matched.endpoint_url.as_ref().to_string();
117        if !reported_url.is_empty() && reported_url != endpoint_url {
118            tracing::info!(
119                "server endpoint URL is {reported_url}; forcing transport to typed URL {endpoint_url}"
120            );
121        }
122        matched.endpoint_url = endpoint_url.into();
123
124        let (session, event_loop) = client
125            .connect_to_endpoint_directly(matched, identity)
126            .map_err(|e| {
127                let msg = e.to_string();
128                let lower = msg.to_lowercase();
129                if lower.contains("uriinvalid") {
130                    tracing::error!(
131                        "certificate URI mismatch (BadCertificateUriInvalid). \
132                         Delete the pki/ folder and reconnect to regenerate the cert with the current application URI \"{}\".",
133                        APPLICATION_URI
134                    );
135                } else if looks_like_cert_trust_error(&lower) {
136                    tracing::error!(
137                        "server rejected the client certificate. \
138                         Mark pki/own/cert.der as trusted in the server's PKI store and try again."
139                    );
140                }
141                anyhow!("connect_to_endpoint_directly failed: {e}")
142            })?;
143
144        let mut handle = event_loop.spawn();
145        let session_for_wait = session.clone();
146        let connected = tokio::select! {
147            res = &mut handle => {
148                return Err(anyhow!(
149                    "session ended before connection was established: {res:?}"
150                ));
151            }
152            c = session_for_wait.wait_for_connection() => c,
153        };
154        if !connected {
155            handle.abort();
156            return Err(anyhow!("failed to establish connection"));
157        }
158
159        if let Err(e) = register_dynamic_type_loader(&session).await {
160            tracing::warn!("dynamic type loader setup failed: {e}");
161        }
162
163        *guard = State::Connected(Connected {
164            session,
165            event_loop: handle,
166        });
167        Ok(())
168    }
169
170    /// Build the OPC UA Part 4 Annex A.2 RelativePath text for `node_id` by
171    /// walking inverse hierarchical references back to the Root folder.
172    pub async fn browse_path(&self, node_id: &NodeId) -> Result<String> {
173        const MAX_DEPTH: usize = 64;
174        let session = self.session().await?;
175        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
176
177        let mut segments: Vec<String> = Vec::new();
178        let mut current = node_id.clone();
179        for _ in 0..MAX_DEPTH {
180            if current == root {
181                break;
182            }
183            let bn = read_browse_name(&session, &current).await?;
184            segments.push(bn);
185            match read_inverse_parent(&session, &current).await? {
186                Some(p) => current = p,
187                None => break,
188            }
189        }
190        segments.reverse();
191        Ok(if segments.is_empty() {
192            "/".to_string()
193        } else {
194            format!("/{}", segments.join("/"))
195        })
196    }
197
198    /// Return the path of NodeIds from the topmost reachable ancestor (typically
199    /// Root) down to and including `node_id`.
200    pub async fn node_path(&self, node_id: &NodeId) -> Result<Vec<NodeId>> {
201        const MAX_DEPTH: usize = 64;
202        let session = self.session().await?;
203        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
204
205        let mut path = vec![node_id.clone()];
206        let mut current = node_id.clone();
207        for _ in 0..MAX_DEPTH {
208            if current == root {
209                break;
210            }
211            match read_inverse_parent(&session, &current).await? {
212                Some(parent) => {
213                    path.push(parent.clone());
214                    current = parent;
215                }
216                None => break,
217            }
218        }
219        path.reverse();
220        Ok(path)
221    }
222
223    /// Resolve a textual browse path like "/Objects/Server/ServerStatus" into
224    /// the matching NodeId by walking hierarchical references from RootFolder.
225    /// A leading "Root" segment is accepted as a no-op. Segments may be plain
226    /// names (namespace 0) or "N:Name" for explicit namespaces.
227    pub async fn resolve_browse_path(&self, text: &str) -> Result<NodeId> {
228        let session = self.session().await?;
229        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
230
231        let mut segments: Vec<&str> = text.split('/').filter(|s| !s.is_empty()).collect();
232        if segments
233            .first()
234            .is_some_and(|s| s.eq_ignore_ascii_case("Root"))
235        {
236            segments.remove(0);
237        }
238        if segments.is_empty() {
239            return Ok(root);
240        }
241
242        let mut current = root;
243        let mut walked = String::new();
244        for seg in &segments {
245            let target = parse_qualified_name(seg);
246            match find_child_by_browse_name(&session, &current, &target).await? {
247                Some(next) => {
248                    walked.push('/');
249                    walked.push_str(seg);
250                    current = next;
251                }
252                None => {
253                    return Err(anyhow!(
254                        "no child '{seg}' under {current} (resolved {walked} so far)"
255                    ));
256                }
257            }
258        }
259        Ok(current)
260    }
261
262    pub async fn discover_endpoints(&self, endpoint_url: &str) -> Result<Vec<EndpointInfo>> {
263        let client = build_client(self.verify_certificate_metadata.load(Ordering::Relaxed))?;
264        let descriptions = client
265            .get_server_endpoints_from_url(endpoint_url)
266            .await
267            .map_err(|e| anyhow!("get_server_endpoints failed: {e}"))?;
268        Ok(descriptions
269            .into_iter()
270            .map(endpoint_description_to_info)
271            .collect())
272    }
273
274    pub async fn disconnect(&self) -> Result<()> {
275        let mut guard = self.state.lock().await;
276        let connected = match std::mem::replace(&mut *guard, State::Disconnected) {
277            State::Connected(c) => c,
278            State::Disconnected => return Ok(()),
279        };
280        let _ = connected.session.disconnect().await;
281        let _ = connected.event_loop.await;
282        Ok(())
283    }
284
285    async fn session(&self) -> Result<Arc<Session>> {
286        let guard = self.state.lock().await;
287        match &*guard {
288            State::Connected(c) => Ok(c.session.clone()),
289            State::Disconnected => Err(anyhow!("not connected")),
290        }
291    }
292
293    pub async fn browse_children(&self, node_id: &NodeId) -> Result<Vec<TreeChild>> {
294        let session = self.session().await?;
295        let desc = browse_hierarchical(node_id.clone());
296        let mut results = session
297            .browse(&[desc], 0, None)
298            .await
299            .map_err(|s| anyhow!("browse failed: {s}"))?;
300        let result = results
301            .pop()
302            .ok_or_else(|| anyhow!("empty browse result"))?;
303        let refs = result.references.unwrap_or_default();
304
305        let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
306        let mut children: Vec<TreeChild> = Vec::with_capacity(refs.len());
307        for r in &refs {
308            if is_excluded_tree_reference(&r.reference_type_id) {
309                continue;
310            }
311            let child = reference_to_tree_child(r);
312            if seen.insert(child.node_id.clone()) {
313                children.push(child);
314            }
315        }
316        let target_ids: Vec<NodeId> = children.iter().map(|c| c.node_id.clone()).collect();
317        let has_kids = has_children_batch(&session, &target_ids).await;
318        for (child, hk) in children.iter_mut().zip(has_kids.into_iter()) {
319            child.has_children = hk;
320        }
321        Ok(children)
322    }
323
324    pub async fn read_node_summary(&self, node_id: &NodeId) -> Result<NodeSummary> {
325        let session = self.session().await?;
326        let to_read: Vec<ReadValueId> = ALL_ATTRIBUTES
327            .iter()
328            .map(|(a, _)| ReadValueId::new(node_id.clone(), *a))
329            .collect();
330        let values = session
331            .read(&to_read, TimestampsToReturn::Both, 0.0)
332            .await
333            .map_err(|s| anyhow!("read failed: {s}"))?;
334
335        let mut attributes: Vec<NodeAttribute> = Vec::new();
336        for ((attr_id, name), dv) in ALL_ATTRIBUTES.iter().zip(values.iter()) {
337            if !attribute_status_ok(dv) {
338                continue;
339            }
340            let Some(v) = dv.value.as_ref() else { continue };
341            let tree = format_attribute_value(*attr_id, v, &session);
342            attributes.push(NodeAttribute {
343                name: name.to_string(),
344                value: tree,
345            });
346            if matches!(attr_id, AttributeId::Value) {
347                if let Some(s) = dv.status.map(|s| s.to_string()) {
348                    attributes.push(NodeAttribute {
349                        name: "StatusCode".to_string(),
350                        value: ValueTree::Leaf(s),
351                    });
352                }
353                if let Some(t) = dv.source_timestamp.as_ref() {
354                    attributes.push(NodeAttribute {
355                        name: "SourceTimestamp".to_string(),
356                        value: ValueTree::Leaf(t.to_string()),
357                    });
358                }
359                if let Some(t) = dv.server_timestamp.as_ref() {
360                    attributes.push(NodeAttribute {
361                        name: "ServerTimestamp".to_string(),
362                        value: ValueTree::Leaf(t.to_string()),
363                    });
364                }
365            }
366        }
367        attributes.sort_by(|a, b| {
368            let rank = |n: &str| if n == "Value" { 0 } else { 1 };
369            rank(&a.name).cmp(&rank(&b.name)).then_with(|| a.name.cmp(&b.name))
370        });
371
372        Ok(NodeSummary {
373            node_id: node_id.clone(),
374            attributes,
375        })
376    }
377
378    pub async fn read_method_signature(&self, method_node_id: &NodeId) -> Result<MethodSignature> {
379        let session = self.session().await?;
380        let node_class = read_node_class(&session, method_node_id).await?;
381        if node_class != NodeClass::Method {
382            return Err(anyhow!("node {method_node_id} is not a Method ({node_class:?})"));
383        }
384        let parent_object = read_inverse_parent(&session, method_node_id)
385            .await?
386            .ok_or_else(|| anyhow!("method has no parent object"))?;
387        let method_display_name = read_display_name(&session, method_node_id)
388            .await
389            .unwrap_or_else(|_| method_node_id.to_string());
390
391        let (inputs_node, outputs_node) = find_argument_properties(&session, method_node_id).await?;
392        let inputs = match inputs_node {
393            Some(n) => read_argument_list(&session, &n).await?,
394            None => Vec::new(),
395        };
396        let outputs = match outputs_node {
397            Some(n) => read_argument_list(&session, &n).await?,
398            None => Vec::new(),
399        };
400
401        let mut input_args = Vec::with_capacity(inputs.len());
402        for a in inputs {
403            input_args.push(argument_to_method_argument(&session, a).await);
404        }
405        let mut output_args = Vec::with_capacity(outputs.len());
406        for a in outputs {
407            output_args.push(argument_to_method_argument(&session, a).await);
408        }
409
410        Ok(MethodSignature {
411            parent_object,
412            method_node: method_node_id.clone(),
413            method_display_name,
414            inputs: input_args,
415            outputs: output_args,
416        })
417    }
418
419    pub async fn call_method(
420        &self,
421        parent_object: &NodeId,
422        method_node_id: &NodeId,
423        inputs: Vec<Variant>,
424    ) -> Result<MethodCallOutcome> {
425        let session = self.session().await?;
426        let request = CallMethodRequest {
427            object_id: parent_object.clone(),
428            method_id: method_node_id.clone(),
429            input_arguments: Some(inputs),
430        };
431        let r = session
432            .call_one(request)
433            .await
434            .map_err(|s| anyhow!("call failed: {s}"))?;
435        let status = r.status_code.to_string();
436        let outputs = r
437            .output_arguments
438            .unwrap_or_default()
439            .iter()
440            .map(|v| variant_to_tree(&session, v))
441            .collect();
442        let input_arg_errors = r
443            .input_argument_results
444            .unwrap_or_default()
445            .into_iter()
446            .map(|s| if s.is_good() { None } else { Some(s.to_string()) })
447            .collect();
448        Ok(MethodCallOutcome {
449            status,
450            outputs,
451            input_arg_errors,
452        })
453    }
454
455    pub async fn browse_references(&self, node_id: &NodeId) -> Result<Vec<ReferenceRow>> {
456        let session = self.session().await?;
457        let desc = BrowseDescription {
458            node_id: node_id.clone(),
459            browse_direction: BrowseDirection::Both,
460            reference_type_id: NodeId::new(0, ReferenceTypeId::References as u32),
461            include_subtypes: true,
462            node_class_mask: NodeClassMask::all().bits(),
463            result_mask: BrowseDescriptionResultMask::all().bits(),
464        };
465        let mut results = session
466            .browse(&[desc], 0, None)
467            .await
468            .map_err(|s| anyhow!("browse failed: {s}"))?;
469        let result = results
470            .pop()
471            .ok_or_else(|| anyhow!("empty browse result"))?;
472        let refs = result.references.unwrap_or_default();
473
474        let mut rows = Vec::with_capacity(refs.len());
475        for r in refs {
476            rows.push(reference_to_row(&session, r).await);
477        }
478        Ok(rows)
479    }
480}
481
482async fn read_node_class(session: &Session, node_id: &NodeId) -> Result<NodeClass> {
483    let to_read = vec![ReadValueId::new(node_id.clone(), AttributeId::NodeClass)];
484    let values = session
485        .read(&to_read, TimestampsToReturn::Neither, 0.0)
486        .await
487        .map_err(|s| anyhow!("read NodeClass failed: {s}"))?;
488    let v = values
489        .into_iter()
490        .next()
491        .and_then(|v| v.value)
492        .ok_or_else(|| anyhow!("NodeClass attribute missing for {node_id}"))?;
493    match v {
494        Variant::Int32(i) => NodeClass::try_from(i)
495            .map_err(|_| anyhow!("invalid NodeClass {i} for {node_id}")),
496        other => Err(anyhow!("unexpected NodeClass variant: {other:?}")),
497    }
498}
499
500async fn read_display_name(session: &Session, node_id: &NodeId) -> Result<String> {
501    let to_read = vec![ReadValueId::new(node_id.clone(), AttributeId::DisplayName)];
502    let values = session
503        .read(&to_read, TimestampsToReturn::Neither, 0.0)
504        .await
505        .map_err(|s| anyhow!("read DisplayName failed: {s}"))?;
506    let text = values
507        .into_iter()
508        .next()
509        .and_then(|v| v.value)
510        .and_then(|v| match v {
511            Variant::LocalizedText(t) => Some(t.text.to_string()),
512            _ => None,
513        });
514    Ok(text.unwrap_or_else(|| node_id.to_string()))
515}
516
517async fn find_argument_properties(
518    session: &Session,
519    method_node_id: &NodeId,
520) -> Result<(Option<NodeId>, Option<NodeId>)> {
521    let desc = BrowseDescription {
522        node_id: method_node_id.clone(),
523        browse_direction: BrowseDirection::Forward,
524        reference_type_id: NodeId::new(0, ReferenceTypeId::HasProperty as u32),
525        include_subtypes: true,
526        node_class_mask: NodeClassMask::VARIABLE.bits(),
527        result_mask: BrowseDescriptionResultMask::all().bits(),
528    };
529    let mut results = session
530        .browse(&[desc], 0, None)
531        .await
532        .map_err(|s| anyhow!("browse properties failed: {s}"))?;
533    let refs = results
534        .pop()
535        .and_then(|r| r.references)
536        .unwrap_or_default();
537    let mut inputs = None;
538    let mut outputs = None;
539    for r in refs {
540        if r.browse_name.namespace_index != 0 {
541            continue;
542        }
543        match r.browse_name.name.as_ref() {
544            "InputArguments" => inputs = Some(r.node_id.node_id),
545            "OutputArguments" => outputs = Some(r.node_id.node_id),
546            _ => {}
547        }
548    }
549    Ok((inputs, outputs))
550}
551
552async fn read_argument_list(session: &Session, property_node: &NodeId) -> Result<Vec<Argument>> {
553    let to_read = vec![ReadValueId::new(property_node.clone(), AttributeId::Value)];
554    let values = session
555        .read(&to_read, TimestampsToReturn::Neither, 0.0)
556        .await
557        .map_err(|s| anyhow!("read {property_node} failed: {s}"))?;
558    let Some(variant) = values.into_iter().next().and_then(|v| v.value) else {
559        return Ok(Vec::new());
560    };
561    if matches!(variant, Variant::Empty) {
562        return Ok(Vec::new());
563    }
564    <Vec<Argument>>::try_from_variant(variant)
565        .map_err(|e| anyhow!("decode Argument array failed: {e}"))
566}
567
568async fn argument_to_method_argument(session: &Session, a: Argument) -> MethodArgument {
569    let type_label = data_type_label(session, &a.data_type, a.value_rank).await;
570    MethodArgument {
571        name: a.name.to_string(),
572        description: a.description.text.to_string(),
573        data_type: a.data_type,
574        value_rank: a.value_rank,
575        type_label,
576    }
577}
578
579async fn data_type_label(session: &Session, data_type: &NodeId, value_rank: i32) -> String {
580    let base = match builtin_data_type_label(data_type) {
581        Some(s) => s.to_string(),
582        None => read_display_name(session, data_type)
583            .await
584            .unwrap_or_else(|_| data_type.to_string()),
585    };
586    if value_rank >= 1 {
587        format!("{base}[]")
588    } else {
589        base
590    }
591}
592
593fn builtin_data_type_label(id: &NodeId) -> Option<&'static str> {
594    if id.namespace != 0 {
595        return None;
596    }
597    let Identifier::Numeric(n) = id.identifier else {
598        return None;
599    };
600    Some(match n {
601        x if x == DataTypeId::Boolean as u32 => "Boolean",
602        x if x == DataTypeId::SByte as u32 => "SByte",
603        x if x == DataTypeId::Byte as u32 => "Byte",
604        x if x == DataTypeId::Int16 as u32 => "Int16",
605        x if x == DataTypeId::UInt16 as u32 => "UInt16",
606        x if x == DataTypeId::Int32 as u32 => "Int32",
607        x if x == DataTypeId::UInt32 as u32 => "UInt32",
608        x if x == DataTypeId::Int64 as u32 => "Int64",
609        x if x == DataTypeId::UInt64 as u32 => "UInt64",
610        x if x == DataTypeId::Float as u32 => "Float",
611        x if x == DataTypeId::Double as u32 => "Double",
612        x if x == DataTypeId::String as u32 => "String",
613        x if x == DataTypeId::DateTime as u32 => "DateTime",
614        x if x == DataTypeId::Guid as u32 => "Guid",
615        x if x == DataTypeId::ByteString as u32 => "ByteString",
616        x if x == DataTypeId::NodeId as u32 => "NodeId",
617        x if x == DataTypeId::ExpandedNodeId as u32 => "ExpandedNodeId",
618        x if x == DataTypeId::StatusCode as u32 => "StatusCode",
619        x if x == DataTypeId::QualifiedName as u32 => "QualifiedName",
620        x if x == DataTypeId::LocalizedText as u32 => "LocalizedText",
621        _ => return None,
622    })
623}
624
625/// Parse a user-typed string into a `Variant` of the expected `data_type`.
626/// Honors `value_rank`: rank ≥ 1 expects comma-separated values.
627pub fn parse_variant(input: &str, data_type: &NodeId, value_rank: i32) -> Result<Variant, String> {
628    let is_array = value_rank >= 1;
629    let scalar_type = builtin_scalar_type(data_type)
630        .ok_or_else(|| format!("unsupported data type: {data_type}"))?;
631    if !is_array {
632        return parse_scalar(input.trim(), scalar_type);
633    }
634    let trimmed = input.trim().trim_start_matches('[').trim_end_matches(']');
635    let tokens: Vec<&str> = if trimmed.is_empty() {
636        Vec::new()
637    } else {
638        trimmed.split(',').map(|s| s.trim()).collect()
639    };
640    let mut variants = Vec::with_capacity(tokens.len());
641    for (i, t) in tokens.iter().enumerate() {
642        let v = parse_scalar(t, scalar_type).map_err(|e| format!("item {i}: {e}"))?;
643        variants.push(v);
644    }
645    let variant_type = scalar_type_to_variant_scalar(scalar_type);
646    let array = Array::new(variant_type, variants).map_err(|e| format!("array build: {e}"))?;
647    Ok(Variant::Array(Box::new(array)))
648}
649
650#[derive(Clone, Copy)]
651enum ScalarType {
652    Boolean,
653    SByte,
654    Byte,
655    Int16,
656    UInt16,
657    Int32,
658    UInt32,
659    Int64,
660    UInt64,
661    Float,
662    Double,
663    String,
664    NodeId,
665    Guid,
666}
667
668fn builtin_scalar_type(id: &NodeId) -> Option<ScalarType> {
669    if id.namespace != 0 {
670        return None;
671    }
672    let Identifier::Numeric(n) = id.identifier else {
673        return None;
674    };
675    Some(match n {
676        x if x == DataTypeId::Boolean as u32 => ScalarType::Boolean,
677        x if x == DataTypeId::SByte as u32 => ScalarType::SByte,
678        x if x == DataTypeId::Byte as u32 => ScalarType::Byte,
679        x if x == DataTypeId::Int16 as u32 => ScalarType::Int16,
680        x if x == DataTypeId::UInt16 as u32 => ScalarType::UInt16,
681        x if x == DataTypeId::Int32 as u32 => ScalarType::Int32,
682        x if x == DataTypeId::UInt32 as u32 => ScalarType::UInt32,
683        x if x == DataTypeId::Int64 as u32 => ScalarType::Int64,
684        x if x == DataTypeId::UInt64 as u32 => ScalarType::UInt64,
685        x if x == DataTypeId::Float as u32 => ScalarType::Float,
686        x if x == DataTypeId::Double as u32 => ScalarType::Double,
687        x if x == DataTypeId::String as u32 => ScalarType::String,
688        x if x == DataTypeId::NodeId as u32 => ScalarType::NodeId,
689        x if x == DataTypeId::Guid as u32 => ScalarType::Guid,
690        _ => return None,
691    })
692}
693
694fn scalar_type_to_variant_scalar(t: ScalarType) -> VariantScalarTypeId {
695    match t {
696        ScalarType::Boolean => VariantScalarTypeId::Boolean,
697        ScalarType::SByte => VariantScalarTypeId::SByte,
698        ScalarType::Byte => VariantScalarTypeId::Byte,
699        ScalarType::Int16 => VariantScalarTypeId::Int16,
700        ScalarType::UInt16 => VariantScalarTypeId::UInt16,
701        ScalarType::Int32 => VariantScalarTypeId::Int32,
702        ScalarType::UInt32 => VariantScalarTypeId::UInt32,
703        ScalarType::Int64 => VariantScalarTypeId::Int64,
704        ScalarType::UInt64 => VariantScalarTypeId::UInt64,
705        ScalarType::Float => VariantScalarTypeId::Float,
706        ScalarType::Double => VariantScalarTypeId::Double,
707        ScalarType::String => VariantScalarTypeId::String,
708        ScalarType::NodeId => VariantScalarTypeId::NodeId,
709        ScalarType::Guid => VariantScalarTypeId::Guid,
710    }
711}
712
713fn parse_scalar(s: &str, t: ScalarType) -> Result<Variant, String> {
714    if matches!(t, ScalarType::String) {
715        return Ok(Variant::String(UAString::from(s)));
716    }
717    if s.is_empty() {
718        return Err("value required".to_string());
719    }
720    Ok(match t {
721        ScalarType::Boolean => Variant::Boolean(
722            s.parse::<bool>().map_err(|e| format!("invalid Boolean: {e}"))?,
723        ),
724        ScalarType::SByte => {
725            Variant::SByte(s.parse::<i8>().map_err(|e| format!("invalid SByte: {e}"))?)
726        }
727        ScalarType::Byte => {
728            Variant::Byte(s.parse::<u8>().map_err(|e| format!("invalid Byte: {e}"))?)
729        }
730        ScalarType::Int16 => {
731            Variant::Int16(s.parse::<i16>().map_err(|e| format!("invalid Int16: {e}"))?)
732        }
733        ScalarType::UInt16 => Variant::UInt16(
734            s.parse::<u16>().map_err(|e| format!("invalid UInt16: {e}"))?,
735        ),
736        ScalarType::Int32 => {
737            Variant::Int32(s.parse::<i32>().map_err(|e| format!("invalid Int32: {e}"))?)
738        }
739        ScalarType::UInt32 => Variant::UInt32(
740            s.parse::<u32>().map_err(|e| format!("invalid UInt32: {e}"))?,
741        ),
742        ScalarType::Int64 => {
743            Variant::Int64(s.parse::<i64>().map_err(|e| format!("invalid Int64: {e}"))?)
744        }
745        ScalarType::UInt64 => Variant::UInt64(
746            s.parse::<u64>().map_err(|e| format!("invalid UInt64: {e}"))?,
747        ),
748        ScalarType::Float => {
749            Variant::Float(s.parse::<f32>().map_err(|e| format!("invalid Float: {e}"))?)
750        }
751        ScalarType::Double => Variant::Double(
752            s.parse::<f64>().map_err(|e| format!("invalid Double: {e}"))?,
753        ),
754        ScalarType::String => unreachable!(),
755        ScalarType::NodeId => Variant::NodeId(Box::new(
756            NodeId::from_str(s).map_err(|e| format!("invalid NodeId: {e}"))?,
757        )),
758        ScalarType::Guid => Variant::Guid(Box::new(
759            Guid::from_str(s).map_err(|e| format!("invalid Guid: {e:?}"))?,
760        )),
761    })
762}
763
764async fn read_browse_name(session: &Session, node_id: &NodeId) -> Result<String> {
765    let to_read = vec![ReadValueId::new(node_id.clone(), AttributeId::BrowseName)];
766    let values = session
767        .read(&to_read, TimestampsToReturn::Neither, 0.0)
768        .await
769        .map_err(|s| anyhow!("read BrowseName failed: {s}"))?;
770    let q = values
771        .into_iter()
772        .next()
773        .and_then(|v| v.value)
774        .and_then(|v| match v {
775            Variant::QualifiedName(q) => Some(*q),
776            _ => None,
777        });
778    Ok(match q {
779        Some(q) => format_path_segment(q.namespace_index, q.name.as_ref()),
780        None => node_id.to_string(),
781    })
782}
783
784async fn read_inverse_parent(session: &Session, node_id: &NodeId) -> Result<Option<NodeId>> {
785    let desc = BrowseDescription {
786        node_id: node_id.clone(),
787        browse_direction: BrowseDirection::Inverse,
788        reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
789        include_subtypes: true,
790        node_class_mask: NodeClassMask::all().bits(),
791        result_mask: BrowseDescriptionResultMask::all().bits(),
792    };
793    let mut results = session
794        .browse(&[desc], 0, None)
795        .await
796        .map_err(|s| anyhow!("browse inverse failed: {s}"))?;
797    let parent = results
798        .pop()
799        .and_then(|r| r.references)
800        .and_then(|refs| {
801            refs.into_iter()
802                .find(|rd| !is_excluded_tree_reference(&rd.reference_type_id))
803        })
804        .map(|r| r.node_id.node_id);
805    Ok(parent)
806}
807
808fn format_path_segment(ns: u16, name: &str) -> String {
809    let escaped = escape_browse_name(name);
810    if ns == 0 {
811        escaped
812    } else {
813        format!("{ns}:{escaped}")
814    }
815}
816
817fn escape_browse_name(s: &str) -> String {
818    let mut out = String::with_capacity(s.len());
819    for c in s.chars() {
820        if matches!(c, '&' | '/' | '.' | '<' | '>' | ':' | '#' | '!' | ';') {
821            out.push('&');
822        }
823        out.push(c);
824    }
825    out
826}
827
828fn is_excluded_tree_reference(ref_type: &NodeId) -> bool {
829    if ref_type.namespace != 0 {
830        return false;
831    }
832    let id = match &ref_type.identifier {
833        opcua::types::Identifier::Numeric(n) => *n,
834        _ => return false,
835    };
836    matches!(
837        id,
838        x if x == ReferenceTypeId::HasEventSource as u32
839            || x == ReferenceTypeId::HasNotifier as u32
840    )
841}
842
843fn browse_hierarchical(node_id: NodeId) -> BrowseDescription {
844    BrowseDescription {
845        node_id,
846        browse_direction: BrowseDirection::Forward,
847        reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
848        include_subtypes: true,
849        node_class_mask: NodeClassMask::all().bits(),
850        result_mask: BrowseDescriptionResultMask::all().bits(),
851    }
852}
853
854fn reference_to_tree_child(r: &ReferenceDescription) -> TreeChild {
855    TreeChild {
856        node_id: expanded_to_local(&r.node_id),
857        browse_name: r.browse_name.name.to_string(),
858        display_name: r.display_name.text.to_string(),
859        node_class: r.node_class,
860        has_children: false,
861    }
862}
863
864async fn reference_to_row(session: &Session, r: ReferenceDescription) -> ReferenceRow {
865    let reference_type = resolve_reference_type_name(session, &r.reference_type_id).await;
866    ReferenceRow {
867        reference_type,
868        is_forward: r.is_forward,
869        target_node_id: expanded_to_local(&r.node_id),
870        target_browse_name: r.browse_name.name.to_string(),
871        target_display_name: r.display_name.text.to_string(),
872        target_node_class: r.node_class,
873    }
874}
875
876async fn resolve_reference_type_name(session: &Session, ref_type: &NodeId) -> String {
877    let read = vec![ReadValueId::new(ref_type.clone(), AttributeId::DisplayName)];
878    match session.read(&read, TimestampsToReturn::Neither, 0.0).await {
879        Ok(vals) => vals
880            .into_iter()
881            .next()
882            .and_then(|v| v.value)
883            .and_then(|v| match v {
884                Variant::LocalizedText(t) => Some(t.text.to_string()),
885                _ => None,
886            })
887            .unwrap_or_else(|| ref_type.to_string()),
888        Err(_) => ref_type.to_string(),
889    }
890}
891
892async fn has_children_batch(session: &Session, ids: &[NodeId]) -> Vec<bool> {
893    if ids.is_empty() {
894        return Vec::new();
895    }
896    let descs: Vec<BrowseDescription> = ids
897        .iter()
898        .map(|id| BrowseDescription {
899            node_id: id.clone(),
900            browse_direction: BrowseDirection::Forward,
901            reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
902            include_subtypes: true,
903            node_class_mask: NodeClassMask::all().bits(),
904            result_mask: BrowseDescriptionResultMask::RESULT_MASK_REFERENCE_TYPE.bits(),
905        })
906        .collect();
907    match session.browse(&descs, 0, None).await {
908        Ok(results) => results
909            .into_iter()
910            .map(|r| {
911                r.references
912                    .map(|refs| {
913                        refs.iter()
914                            .any(|rd| !is_excluded_tree_reference(&rd.reference_type_id))
915                    })
916                    .unwrap_or(false)
917            })
918            .collect(),
919        Err(_) => vec![false; ids.len()],
920    }
921}
922
923fn expanded_to_local(eid: &ExpandedNodeId) -> NodeId {
924    eid.node_id.clone()
925}
926
927fn log_client_cert_hint() {
928    let path = std::env::current_dir()
929        .unwrap_or_default()
930        .join("pki/own/cert.der");
931    tracing::info!(
932        "encrypted connection as \"{}\" ({}); client certificate at {}",
933        APPLICATION_NAME,
934        APPLICATION_URI,
935        path.display()
936    );
937    tracing::info!(
938        "if the server rejects the connection, copy that file into the server's trusted certs folder"
939    );
940}
941
942fn looks_like_cert_trust_error(msg: &str) -> bool {
943    let lower = msg.to_lowercase();
944    lower.contains("badsecurity")
945        || lower.contains("badcertificate")
946        || lower.contains("certificatevalidation")
947        || lower.contains("untrusted")
948        || lower.contains("rejected")
949}
950
951fn build_identity_token(auth: &AuthSpec) -> Result<IdentityToken> {
952    match auth.mode {
953        AuthMode::Anonymous => Ok(IdentityToken::Anonymous),
954        AuthMode::UserName => {
955            if auth.username.is_empty() {
956                return Err(anyhow!("username required"));
957            }
958            Ok(IdentityToken::new_user_name(
959                auth.username.clone(),
960                auth.password.clone(),
961            ))
962        }
963        AuthMode::Certificate => {
964            if auth.cert_path.is_empty() || auth.key_path.is_empty() {
965                return Err(anyhow!("certificate and private-key paths required"));
966            }
967            IdentityToken::new_x509_path(&auth.cert_path, &auth.key_path)
968                .map_err(|e| anyhow!("failed to load certificate/key: {e}"))
969        }
970    }
971}
972
973const APPLICATION_NAME: &str = "Rust OPC UA Client from FreeOpcUa";
974const APPLICATION_URI: &str = "urn:FreeOpcUa:ua-client";
975
976fn warn_insecure_default() {
977    tracing::warn!(
978        "INSECURE DEFAULT: server-certificate checks (time, hostname, application-URI) are DISABLED — trusted networks only"
979    );
980}
981
982fn build_client(verify_cert_metadata: bool) -> Result<opcua::client::Client> {
983    ClientBuilder::new()
984        .application_name(APPLICATION_NAME)
985        .application_uri(APPLICATION_URI)
986        .product_uri(APPLICATION_URI)
987        .trust_server_certs(true)
988        .verify_server_certs(verify_cert_metadata)
989        .create_sample_keypair(true)
990        .session_retry_limit(0)
991        .client()
992        .map_err(|errs| anyhow!("failed to build OPC UA client: {errs:?}"))
993}
994
995fn security_mode_to_message_mode(m: SecurityMode) -> MessageSecurityMode {
996    match m {
997        SecurityMode::None => MessageSecurityMode::None,
998        SecurityMode::Sign => MessageSecurityMode::Sign,
999        SecurityMode::SignAndEncrypt => MessageSecurityMode::SignAndEncrypt,
1000    }
1001}
1002
1003fn message_mode_to_security_mode(m: MessageSecurityMode) -> SecurityMode {
1004    match m {
1005        MessageSecurityMode::Sign => SecurityMode::Sign,
1006        MessageSecurityMode::SignAndEncrypt => SecurityMode::SignAndEncrypt,
1007        _ => SecurityMode::None,
1008    }
1009}
1010
1011fn endpoint_description_to_info(ep: EndpointDescription) -> EndpointInfo {
1012    let policy_uri = ep.security_policy_uri.to_string();
1013    let policy_short = SecurityPolicy::from_str(&policy_uri)
1014        .map(|p| p.to_string())
1015        .unwrap_or_else(|_| policy_uri.clone());
1016    let tokens = ep.user_identity_tokens.unwrap_or_default();
1017    let supports_anonymous = tokens
1018        .iter()
1019        .any(|t| matches!(t.token_type, UserTokenType::Anonymous));
1020    let supports_username = tokens
1021        .iter()
1022        .any(|t| matches!(t.token_type, UserTokenType::UserName));
1023    let supports_certificate = tokens
1024        .iter()
1025        .any(|t| matches!(t.token_type, UserTokenType::Certificate));
1026
1027    EndpointInfo {
1028        endpoint_url: ep.endpoint_url.to_string(),
1029        security_policy: policy_short,
1030        security_policy_uri: policy_uri,
1031        security_mode: message_mode_to_security_mode(ep.security_mode),
1032        security_level: ep.security_level,
1033        supports_anonymous,
1034        supports_username,
1035        supports_certificate,
1036    }
1037}
1038
1039const ALL_ATTRIBUTES: &[(AttributeId, &str)] = &[
1040    (AttributeId::AccessLevel, "AccessLevel"),
1041    (AttributeId::AccessLevelEx, "AccessLevelEx"),
1042    (AttributeId::AccessRestrictions, "AccessRestrictions"),
1043    (AttributeId::ArrayDimensions, "ArrayDimensions"),
1044    (AttributeId::BrowseName, "BrowseName"),
1045    (AttributeId::ContainsNoLoops, "ContainsNoLoops"),
1046    (AttributeId::DataType, "DataType"),
1047    (AttributeId::DataTypeDefinition, "DataTypeDefinition"),
1048    (AttributeId::Description, "Description"),
1049    (AttributeId::DisplayName, "DisplayName"),
1050    (AttributeId::EventNotifier, "EventNotifier"),
1051    (AttributeId::Executable, "Executable"),
1052    (AttributeId::Historizing, "Historizing"),
1053    (AttributeId::InverseName, "InverseName"),
1054    (AttributeId::IsAbstract, "IsAbstract"),
1055    (
1056        AttributeId::MinimumSamplingInterval,
1057        "MinimumSamplingInterval",
1058    ),
1059    (AttributeId::NodeClass, "NodeClass"),
1060    (AttributeId::NodeId, "NodeId"),
1061    (AttributeId::RolePermissions, "RolePermissions"),
1062    (AttributeId::Symmetric, "Symmetric"),
1063    (AttributeId::UserAccessLevel, "UserAccessLevel"),
1064    (AttributeId::UserExecutable, "UserExecutable"),
1065    (AttributeId::UserRolePermissions, "UserRolePermissions"),
1066    (AttributeId::UserWriteMask, "UserWriteMask"),
1067    (AttributeId::Value, "Value"),
1068    (AttributeId::ValueRank, "ValueRank"),
1069    (AttributeId::WriteMask, "WriteMask"),
1070];
1071
1072fn attribute_status_ok(dv: &DataValue) -> bool {
1073    match dv.status {
1074        None => dv.value.is_some(),
1075        Some(s) => s.is_good(),
1076    }
1077}
1078
1079fn format_attribute_value(attr: AttributeId, v: &Variant, session: &Session) -> ValueTree {
1080    if matches!(attr, AttributeId::NodeClass)
1081        && let Variant::Int32(i) = v
1082        && let Ok(nc) = NodeClass::try_from(*i)
1083    {
1084        return ValueTree::Leaf(format!("{nc:?}"));
1085    }
1086    variant_to_tree(session, v)
1087}
1088
1089fn variant_to_tree(session: &Session, v: &Variant) -> ValueTree {
1090    match v {
1091        Variant::Empty => ValueTree::Null,
1092        Variant::Boolean(b) => ValueTree::Leaf(b.to_string()),
1093        Variant::SByte(n) => ValueTree::Leaf(n.to_string()),
1094        Variant::Byte(n) => ValueTree::Leaf(n.to_string()),
1095        Variant::Int16(n) => ValueTree::Leaf(n.to_string()),
1096        Variant::UInt16(n) => ValueTree::Leaf(n.to_string()),
1097        Variant::Int32(n) => ValueTree::Leaf(n.to_string()),
1098        Variant::UInt32(n) => ValueTree::Leaf(n.to_string()),
1099        Variant::Int64(n) => ValueTree::Leaf(n.to_string()),
1100        Variant::UInt64(n) => ValueTree::Leaf(n.to_string()),
1101        Variant::Float(n) => ValueTree::Leaf(n.to_string()),
1102        Variant::Double(n) => ValueTree::Leaf(n.to_string()),
1103        Variant::String(s) => ValueTree::Leaf(s.to_string()),
1104        Variant::DateTime(d) => ValueTree::Leaf(d.to_string()),
1105        Variant::Guid(g) => ValueTree::Leaf(format!("{g:?}")),
1106        Variant::StatusCode(s) => ValueTree::Leaf(s.to_string()),
1107        Variant::ByteString(b) => match b.value.as_ref() {
1108            Some(bytes) => ValueTree::Leaf(format!("<{} bytes>", bytes.len())),
1109            None => ValueTree::Null,
1110        },
1111        Variant::XmlElement(_) => ValueTree::Leaf("XmlElement(…)".to_string()),
1112        Variant::QualifiedName(q) => ValueTree::Leaf(q.name.to_string()),
1113        Variant::LocalizedText(t) => ValueTree::Leaf(t.text.to_string()),
1114        Variant::NodeId(n) => ValueTree::Leaf(n.to_string()),
1115        Variant::ExpandedNodeId(n) => ValueTree::Leaf(format!("{n}")),
1116        Variant::ExtensionObject(obj) => extension_object_to_tree(session, obj),
1117        Variant::Variant(inner) => variant_to_tree(session, inner),
1118        Variant::DataValue(_) => ValueTree::Leaf("DataValue(…)".to_string()),
1119        Variant::DiagnosticInfo(_) => ValueTree::Leaf("DiagnosticInfo(…)".to_string()),
1120        Variant::Array(arr) => ValueTree::Array(
1121            arr.values
1122                .iter()
1123                .map(|i| variant_to_tree(session, i))
1124                .collect(),
1125        ),
1126    }
1127}
1128
1129fn extension_object_to_tree(session: &Session, obj: &opcua::types::ExtensionObject) -> ValueTree {
1130    if obj.inner_as::<DynamicStructure>().is_none() {
1131        let label = obj
1132            .type_name()
1133            .map(|n| format!("ExtensionObject ({n})"))
1134            .unwrap_or_else(|| "ExtensionObject".to_string());
1135        return ValueTree::Leaf(label);
1136    }
1137    match dynamic_struct_to_tree(session, obj) {
1138        Some(tree) => tree,
1139        None => ValueTree::Leaf("ExtensionObject (decode failed)".to_string()),
1140    }
1141}
1142
1143fn dynamic_struct_to_tree(
1144    session: &Session,
1145    obj: &opcua::types::ExtensionObject,
1146) -> Option<ValueTree> {
1147    let ds = obj.inner_as::<DynamicStructure>()?;
1148    let ctx_owned = session.context();
1149    let ctx_guard = ctx_owned.read();
1150    let ctx = ctx_guard.context();
1151    let mut buf = Vec::new();
1152    {
1153        let writer_ref: &mut dyn std::io::Write = &mut buf;
1154        let mut writer = JsonStreamWriter::new(writer_ref);
1155        ds.encode(&mut writer, &ctx).ok()?;
1156        writer.finish_document().ok()?;
1157    }
1158    let json: serde_json::Value = serde_json::from_slice(&buf).ok()?;
1159    Some(json_to_tree(&json))
1160}
1161
1162fn json_to_tree(v: &serde_json::Value) -> ValueTree {
1163    match v {
1164        serde_json::Value::Null => ValueTree::Null,
1165        serde_json::Value::Bool(b) => ValueTree::Leaf(b.to_string()),
1166        serde_json::Value::Number(n) => ValueTree::Leaf(n.to_string()),
1167        serde_json::Value::String(s) => ValueTree::Leaf(s.clone()),
1168        serde_json::Value::Array(arr) => ValueTree::Array(arr.iter().map(json_to_tree).collect()),
1169        serde_json::Value::Object(map) => ValueTree::Object(
1170            map.iter()
1171                .map(|(k, v)| (k.clone(), json_to_tree(v)))
1172                .collect(),
1173        ),
1174    }
1175}
1176
1177async fn find_child_by_browse_name(
1178    session: &Session,
1179    parent: &NodeId,
1180    target: &QualifiedName,
1181) -> Result<Option<NodeId>> {
1182    let desc = browse_hierarchical(parent.clone());
1183    let mut results = session
1184        .browse(&[desc], 0, None)
1185        .await
1186        .map_err(|s| anyhow!("browse failed: {s}"))?;
1187    let refs = results.pop().and_then(|r| r.references).unwrap_or_default();
1188    for r in refs {
1189        if is_excluded_tree_reference(&r.reference_type_id) {
1190            continue;
1191        }
1192        if r.browse_name.namespace_index == target.namespace_index
1193            && r.browse_name.name.as_ref() == target.name.as_ref()
1194        {
1195            return Ok(Some(r.node_id.node_id));
1196        }
1197    }
1198    Ok(None)
1199}
1200
1201/// Parse one path segment as a QualifiedName.
1202///
1203/// Accepted forms: `Name` (namespace 0), `N:Name` (namespace N — what
1204/// `browse_path` emits), `ns=N:Name` (explicit prefix).
1205fn parse_qualified_name(segment: &str) -> QualifiedName {
1206    let body = segment.strip_prefix("ns=").unwrap_or(segment);
1207    if let Some((head, rest)) = body.split_once(':')
1208        && let Ok(ns) = head.parse::<u16>()
1209    {
1210        return QualifiedName::new(ns, rest);
1211    }
1212    QualifiedName::new(0, segment)
1213}
1214
1215async fn register_dynamic_type_loader(session: &Session) -> Result<()> {
1216    let type_tree = DataTypeTreeBuilder::new(|_| true)
1217        .build(session)
1218        .await
1219        .map_err(|e| anyhow!("DataTypeTreeBuilder failed: {e}"))?;
1220    let loader: Arc<dyn TypeLoader> = Arc::new(DynamicTypeLoader::new(Arc::new(type_tree)));
1221    session.add_type_loader(loader);
1222    Ok(())
1223}