Skip to main content

ua_client/
client.rs

1use std::str::FromStr;
2use std::sync::Arc;
3
4use anyhow::{anyhow, Result};
5use opcua::client::custom_types::DataTypeTreeBuilder;
6use opcua::client::{ClientBuilder, IdentityToken, Session};
7use opcua::crypto::SecurityPolicy;
8use opcua::types::custom::{DynamicStructure, DynamicTypeLoader};
9use opcua::types::json::{JsonEncodable, JsonStreamWriter, JsonWriter};
10use opcua::types::{
11    AttributeId, BrowseDescription, BrowseDescriptionResultMask, BrowseDirection, DataValue,
12    EndpointDescription, ExpandedNodeId, MessageSecurityMode, NodeClass, NodeClassMask, NodeId,
13    QualifiedName, ReadValueId, ReferenceDescription, ReferenceTypeId, StatusCode,
14    TimestampsToReturn, TypeLoader, UserTokenPolicy, UserTokenType, Variant,
15};
16use tokio::sync::Mutex;
17use tokio::task::JoinHandle;
18
19use crate::types::{
20    AuthMode, AuthSpec, EndpointInfo, NodeAttribute, NodeSummary, ReferenceRow, SecurityMode,
21    TreeChild, ValueTree,
22};
23
24struct Connected {
25    session: Arc<Session>,
26    event_loop: JoinHandle<StatusCode>,
27}
28
29enum State {
30    Disconnected,
31    Connected(Connected),
32}
33
34pub struct UaClient {
35    state: Mutex<State>,
36}
37
38impl Default for UaClient {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl UaClient {
45    pub fn new() -> Self {
46        Self {
47            state: Mutex::new(State::Disconnected),
48        }
49    }
50
51    pub async fn connect(
52        &self,
53        endpoint_url: &str,
54        endpoint: Option<&EndpointInfo>,
55        auth: &AuthSpec,
56    ) -> Result<()> {
57        let mut guard = self.state.lock().await;
58        if matches!(*guard, State::Connected(_)) {
59            return Err(anyhow!("already connected"));
60        }
61
62        let mut client = build_client()?;
63
64        let (policy_uri, mode) = match endpoint {
65            Some(ep) => (
66                ep.security_policy_uri.clone(),
67                security_mode_to_message_mode(ep.security_mode),
68            ),
69            None => (
70                SecurityPolicy::None.to_uri().to_string(),
71                MessageSecurityMode::None,
72            ),
73        };
74        let target_url = match endpoint {
75            Some(ep) if !ep.endpoint_url.is_empty() => ep.endpoint_url.clone(),
76            _ => endpoint_url.to_string(),
77        };
78        let identity = build_identity_token(auth)?;
79        if mode != MessageSecurityMode::None {
80            log_client_cert_hint();
81        }
82
83        let (session, event_loop) = client
84            .connect_to_matching_endpoint(
85                (
86                    target_url.as_str(),
87                    policy_uri.as_str(),
88                    mode,
89                    UserTokenPolicy::anonymous(),
90                ),
91                identity,
92            )
93            .await
94            .map_err(|e| {
95                let msg = e.to_string();
96                let lower = msg.to_lowercase();
97                if lower.contains("uriinvalid") {
98                    tracing::error!(
99                        "certificate URI mismatch (BadCertificateUriInvalid). \
100                         Delete the pki/ folder and reconnect to regenerate the cert with the current application URI \"{}\".",
101                        APPLICATION_URI
102                    );
103                } else if looks_like_cert_trust_error(&lower) {
104                    tracing::error!(
105                        "server rejected the client certificate. \
106                         Mark pki/own/cert.der as trusted in the server's PKI store and try again."
107                    );
108                }
109                anyhow!("connect_to_matching_endpoint failed: {e}")
110            })?;
111
112        let mut handle = event_loop.spawn();
113        let session_for_wait = session.clone();
114        let connected = tokio::select! {
115            res = &mut handle => {
116                return Err(anyhow!(
117                    "session ended before connection was established: {res:?}"
118                ));
119            }
120            c = session_for_wait.wait_for_connection() => c,
121        };
122        if !connected {
123            handle.abort();
124            return Err(anyhow!("failed to establish connection"));
125        }
126
127        if let Err(e) = register_dynamic_type_loader(&session).await {
128            tracing::warn!("dynamic type loader setup failed: {e}");
129        }
130
131        *guard = State::Connected(Connected {
132            session,
133            event_loop: handle,
134        });
135        Ok(())
136    }
137
138    /// Build the OPC UA Part 4 Annex A.2 RelativePath text for `node_id` by
139    /// walking inverse hierarchical references back to the Root folder.
140    pub async fn browse_path(&self, node_id: &NodeId) -> Result<String> {
141        const MAX_DEPTH: usize = 64;
142        let session = self.session().await?;
143        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
144
145        let mut segments: Vec<String> = Vec::new();
146        let mut current = node_id.clone();
147        for _ in 0..MAX_DEPTH {
148            if current == root {
149                break;
150            }
151            let bn = read_browse_name(&session, &current).await?;
152            segments.push(bn);
153            match read_inverse_parent(&session, &current).await? {
154                Some(p) => current = p,
155                None => break,
156            }
157        }
158        segments.reverse();
159        Ok(if segments.is_empty() {
160            "/".to_string()
161        } else {
162            format!("/{}", segments.join("/"))
163        })
164    }
165
166    /// Return the path of NodeIds from the topmost reachable ancestor (typically
167    /// Root) down to and including `node_id`.
168    pub async fn node_path(&self, node_id: &NodeId) -> Result<Vec<NodeId>> {
169        const MAX_DEPTH: usize = 64;
170        let session = self.session().await?;
171        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
172
173        let mut path = vec![node_id.clone()];
174        let mut current = node_id.clone();
175        for _ in 0..MAX_DEPTH {
176            if current == root {
177                break;
178            }
179            match read_inverse_parent(&session, &current).await? {
180                Some(parent) => {
181                    path.push(parent.clone());
182                    current = parent;
183                }
184                None => break,
185            }
186        }
187        path.reverse();
188        Ok(path)
189    }
190
191    /// Resolve a textual browse path like "/Objects/Server/ServerStatus" into
192    /// the matching NodeId by walking hierarchical references from RootFolder.
193    /// A leading "Root" segment is accepted as a no-op. Segments may be plain
194    /// names (namespace 0) or "N:Name" for explicit namespaces.
195    pub async fn resolve_browse_path(&self, text: &str) -> Result<NodeId> {
196        let session = self.session().await?;
197        let root = NodeId::new(0, opcua::types::ObjectId::RootFolder as u32);
198
199        let mut segments: Vec<&str> = text.split('/').filter(|s| !s.is_empty()).collect();
200        if segments
201            .first()
202            .is_some_and(|s| s.eq_ignore_ascii_case("Root"))
203        {
204            segments.remove(0);
205        }
206        if segments.is_empty() {
207            return Ok(root);
208        }
209
210        let mut current = root;
211        let mut walked = String::new();
212        for seg in &segments {
213            let target = parse_qualified_name(seg);
214            match find_child_by_browse_name(&session, &current, &target).await? {
215                Some(next) => {
216                    walked.push('/');
217                    walked.push_str(seg);
218                    current = next;
219                }
220                None => {
221                    return Err(anyhow!(
222                        "no child '{seg}' under {current} (resolved {walked} so far)"
223                    ));
224                }
225            }
226        }
227        Ok(current)
228    }
229
230    pub async fn discover_endpoints(&self, endpoint_url: &str) -> Result<Vec<EndpointInfo>> {
231        let client = build_client()?;
232        let descriptions = client
233            .get_server_endpoints_from_url(endpoint_url)
234            .await
235            .map_err(|e| anyhow!("get_server_endpoints failed: {e}"))?;
236        Ok(descriptions
237            .into_iter()
238            .map(endpoint_description_to_info)
239            .collect())
240    }
241
242    pub async fn disconnect(&self) -> Result<()> {
243        let mut guard = self.state.lock().await;
244        let connected = match std::mem::replace(&mut *guard, State::Disconnected) {
245            State::Connected(c) => c,
246            State::Disconnected => return Ok(()),
247        };
248        let _ = connected.session.disconnect().await;
249        let _ = connected.event_loop.await;
250        Ok(())
251    }
252
253    async fn session(&self) -> Result<Arc<Session>> {
254        let guard = self.state.lock().await;
255        match &*guard {
256            State::Connected(c) => Ok(c.session.clone()),
257            State::Disconnected => Err(anyhow!("not connected")),
258        }
259    }
260
261    pub async fn browse_children(&self, node_id: &NodeId) -> Result<Vec<TreeChild>> {
262        let session = self.session().await?;
263        let desc = browse_hierarchical(node_id.clone());
264        let mut results = session
265            .browse(&[desc], 0, None)
266            .await
267            .map_err(|s| anyhow!("browse failed: {s}"))?;
268        let result = results
269            .pop()
270            .ok_or_else(|| anyhow!("empty browse result"))?;
271        let refs = result.references.unwrap_or_default();
272
273        let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
274        let mut children: Vec<TreeChild> = Vec::with_capacity(refs.len());
275        for r in &refs {
276            if is_excluded_tree_reference(&r.reference_type_id) {
277                continue;
278            }
279            let child = reference_to_tree_child(r);
280            if seen.insert(child.node_id.clone()) {
281                children.push(child);
282            }
283        }
284        let target_ids: Vec<NodeId> = children.iter().map(|c| c.node_id.clone()).collect();
285        let has_kids = has_children_batch(&session, &target_ids).await;
286        for (child, hk) in children.iter_mut().zip(has_kids.into_iter()) {
287            child.has_children = hk;
288        }
289        Ok(children)
290    }
291
292    pub async fn read_node_summary(&self, node_id: &NodeId) -> Result<NodeSummary> {
293        let session = self.session().await?;
294        let to_read: Vec<ReadValueId> = ALL_ATTRIBUTES
295            .iter()
296            .map(|(a, _)| ReadValueId::new(node_id.clone(), *a))
297            .collect();
298        let values = session
299            .read(&to_read, TimestampsToReturn::Both, 0.0)
300            .await
301            .map_err(|s| anyhow!("read failed: {s}"))?;
302
303        let mut attributes: Vec<NodeAttribute> = Vec::new();
304        for ((attr_id, name), dv) in ALL_ATTRIBUTES.iter().zip(values.iter()) {
305            if !attribute_status_ok(dv) {
306                continue;
307            }
308            let Some(v) = dv.value.as_ref() else { continue };
309            let tree = format_attribute_value(*attr_id, v, &session);
310            attributes.push(NodeAttribute {
311                name: name.to_string(),
312                value: tree,
313            });
314            if matches!(attr_id, AttributeId::Value) {
315                if let Some(s) = dv.status.map(|s| s.to_string()) {
316                    attributes.push(NodeAttribute {
317                        name: "StatusCode".to_string(),
318                        value: ValueTree::Leaf(s),
319                    });
320                }
321                if let Some(t) = dv.source_timestamp.as_ref() {
322                    attributes.push(NodeAttribute {
323                        name: "SourceTimestamp".to_string(),
324                        value: ValueTree::Leaf(t.to_string()),
325                    });
326                }
327                if let Some(t) = dv.server_timestamp.as_ref() {
328                    attributes.push(NodeAttribute {
329                        name: "ServerTimestamp".to_string(),
330                        value: ValueTree::Leaf(t.to_string()),
331                    });
332                }
333            }
334        }
335        attributes.sort_by(|a, b| a.name.cmp(&b.name));
336
337        Ok(NodeSummary {
338            node_id: node_id.clone(),
339            attributes,
340        })
341    }
342
343    pub async fn browse_references(&self, node_id: &NodeId) -> Result<Vec<ReferenceRow>> {
344        let session = self.session().await?;
345        let desc = BrowseDescription {
346            node_id: node_id.clone(),
347            browse_direction: BrowseDirection::Both,
348            reference_type_id: NodeId::new(0, ReferenceTypeId::References as u32),
349            include_subtypes: true,
350            node_class_mask: NodeClassMask::all().bits(),
351            result_mask: BrowseDescriptionResultMask::all().bits(),
352        };
353        let mut results = session
354            .browse(&[desc], 0, None)
355            .await
356            .map_err(|s| anyhow!("browse failed: {s}"))?;
357        let result = results
358            .pop()
359            .ok_or_else(|| anyhow!("empty browse result"))?;
360        let refs = result.references.unwrap_or_default();
361
362        let mut rows = Vec::with_capacity(refs.len());
363        for r in refs {
364            rows.push(reference_to_row(&session, r).await);
365        }
366        Ok(rows)
367    }
368}
369
370async fn read_browse_name(session: &Session, node_id: &NodeId) -> Result<String> {
371    let to_read = vec![ReadValueId::new(node_id.clone(), AttributeId::BrowseName)];
372    let values = session
373        .read(&to_read, TimestampsToReturn::Neither, 0.0)
374        .await
375        .map_err(|s| anyhow!("read BrowseName failed: {s}"))?;
376    let q = values
377        .into_iter()
378        .next()
379        .and_then(|v| v.value)
380        .and_then(|v| match v {
381            Variant::QualifiedName(q) => Some(*q),
382            _ => None,
383        });
384    Ok(match q {
385        Some(q) => format_path_segment(q.namespace_index, q.name.as_ref()),
386        None => node_id.to_string(),
387    })
388}
389
390async fn read_inverse_parent(session: &Session, node_id: &NodeId) -> Result<Option<NodeId>> {
391    let desc = BrowseDescription {
392        node_id: node_id.clone(),
393        browse_direction: BrowseDirection::Inverse,
394        reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
395        include_subtypes: true,
396        node_class_mask: NodeClassMask::all().bits(),
397        result_mask: BrowseDescriptionResultMask::all().bits(),
398    };
399    let mut results = session
400        .browse(&[desc], 0, None)
401        .await
402        .map_err(|s| anyhow!("browse inverse failed: {s}"))?;
403    let parent = results
404        .pop()
405        .and_then(|r| r.references)
406        .and_then(|refs| {
407            refs.into_iter()
408                .find(|rd| !is_excluded_tree_reference(&rd.reference_type_id))
409        })
410        .map(|r| r.node_id.node_id);
411    Ok(parent)
412}
413
414fn format_path_segment(ns: u16, name: &str) -> String {
415    let escaped = escape_browse_name(name);
416    if ns == 0 {
417        escaped
418    } else {
419        format!("{ns}:{escaped}")
420    }
421}
422
423fn escape_browse_name(s: &str) -> String {
424    let mut out = String::with_capacity(s.len());
425    for c in s.chars() {
426        if matches!(c, '&' | '/' | '.' | '<' | '>' | ':' | '#' | '!' | ';') {
427            out.push('&');
428        }
429        out.push(c);
430    }
431    out
432}
433
434fn is_excluded_tree_reference(ref_type: &NodeId) -> bool {
435    if ref_type.namespace != 0 {
436        return false;
437    }
438    let id = match &ref_type.identifier {
439        opcua::types::Identifier::Numeric(n) => *n,
440        _ => return false,
441    };
442    matches!(
443        id,
444        x if x == ReferenceTypeId::HasEventSource as u32
445            || x == ReferenceTypeId::HasNotifier as u32
446    )
447}
448
449fn browse_hierarchical(node_id: NodeId) -> BrowseDescription {
450    BrowseDescription {
451        node_id,
452        browse_direction: BrowseDirection::Forward,
453        reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
454        include_subtypes: true,
455        node_class_mask: NodeClassMask::all().bits(),
456        result_mask: BrowseDescriptionResultMask::all().bits(),
457    }
458}
459
460fn reference_to_tree_child(r: &ReferenceDescription) -> TreeChild {
461    TreeChild {
462        node_id: expanded_to_local(&r.node_id),
463        browse_name: r.browse_name.name.to_string(),
464        display_name: r.display_name.text.to_string(),
465        node_class: r.node_class,
466        has_children: false,
467    }
468}
469
470async fn reference_to_row(session: &Session, r: ReferenceDescription) -> ReferenceRow {
471    let reference_type = resolve_reference_type_name(session, &r.reference_type_id).await;
472    ReferenceRow {
473        reference_type,
474        is_forward: r.is_forward,
475        target_node_id: expanded_to_local(&r.node_id),
476        target_browse_name: r.browse_name.name.to_string(),
477        target_display_name: r.display_name.text.to_string(),
478        target_node_class: r.node_class,
479    }
480}
481
482async fn resolve_reference_type_name(session: &Session, ref_type: &NodeId) -> String {
483    let read = vec![ReadValueId::new(ref_type.clone(), AttributeId::DisplayName)];
484    match session.read(&read, TimestampsToReturn::Neither, 0.0).await {
485        Ok(vals) => vals
486            .into_iter()
487            .next()
488            .and_then(|v| v.value)
489            .and_then(|v| match v {
490                Variant::LocalizedText(t) => Some(t.text.to_string()),
491                _ => None,
492            })
493            .unwrap_or_else(|| ref_type.to_string()),
494        Err(_) => ref_type.to_string(),
495    }
496}
497
498async fn has_children_batch(session: &Session, ids: &[NodeId]) -> Vec<bool> {
499    if ids.is_empty() {
500        return Vec::new();
501    }
502    let descs: Vec<BrowseDescription> = ids
503        .iter()
504        .map(|id| BrowseDescription {
505            node_id: id.clone(),
506            browse_direction: BrowseDirection::Forward,
507            reference_type_id: NodeId::new(0, ReferenceTypeId::HierarchicalReferences as u32),
508            include_subtypes: true,
509            node_class_mask: NodeClassMask::all().bits(),
510            result_mask: BrowseDescriptionResultMask::RESULT_MASK_REFERENCE_TYPE.bits(),
511        })
512        .collect();
513    match session.browse(&descs, 0, None).await {
514        Ok(results) => results
515            .into_iter()
516            .map(|r| {
517                r.references
518                    .map(|refs| {
519                        refs.iter()
520                            .any(|rd| !is_excluded_tree_reference(&rd.reference_type_id))
521                    })
522                    .unwrap_or(false)
523            })
524            .collect(),
525        Err(_) => vec![false; ids.len()],
526    }
527}
528
529fn expanded_to_local(eid: &ExpandedNodeId) -> NodeId {
530    eid.node_id.clone()
531}
532
533fn log_client_cert_hint() {
534    let path = std::env::current_dir()
535        .unwrap_or_default()
536        .join("pki/own/cert.der");
537    tracing::info!(
538        "encrypted connection as \"{}\" ({}); client certificate at {}",
539        APPLICATION_NAME,
540        APPLICATION_URI,
541        path.display()
542    );
543    tracing::info!(
544        "if the server rejects the connection, copy that file into the server's trusted certs folder"
545    );
546}
547
548fn looks_like_cert_trust_error(msg: &str) -> bool {
549    let lower = msg.to_lowercase();
550    lower.contains("badsecurity")
551        || lower.contains("badcertificate")
552        || lower.contains("certificatevalidation")
553        || lower.contains("untrusted")
554        || lower.contains("rejected")
555}
556
557fn build_identity_token(auth: &AuthSpec) -> Result<IdentityToken> {
558    match auth.mode {
559        AuthMode::Anonymous => Ok(IdentityToken::Anonymous),
560        AuthMode::UserName => {
561            if auth.username.is_empty() {
562                return Err(anyhow!("username required"));
563            }
564            Ok(IdentityToken::new_user_name(
565                auth.username.clone(),
566                auth.password.clone(),
567            ))
568        }
569        AuthMode::Certificate => {
570            if auth.cert_path.is_empty() || auth.key_path.is_empty() {
571                return Err(anyhow!("certificate and private-key paths required"));
572            }
573            IdentityToken::new_x509_path(&auth.cert_path, &auth.key_path)
574                .map_err(|e| anyhow!("failed to load certificate/key: {e}"))
575        }
576    }
577}
578
579const APPLICATION_NAME: &str = "Rust OPC UA Client from FreeOpcUa";
580const APPLICATION_URI: &str = "urn:FreeOpcUa:ua-client";
581
582fn build_client() -> Result<opcua::client::Client> {
583    ClientBuilder::new()
584        .application_name(APPLICATION_NAME)
585        .application_uri(APPLICATION_URI)
586        .product_uri(APPLICATION_URI)
587        .trust_server_certs(true)
588        .create_sample_keypair(true)
589        .session_retry_limit(0)
590        .client()
591        .map_err(|errs| anyhow!("failed to build OPC UA client: {errs:?}"))
592}
593
594fn security_mode_to_message_mode(m: SecurityMode) -> MessageSecurityMode {
595    match m {
596        SecurityMode::None => MessageSecurityMode::None,
597        SecurityMode::Sign => MessageSecurityMode::Sign,
598        SecurityMode::SignAndEncrypt => MessageSecurityMode::SignAndEncrypt,
599    }
600}
601
602fn message_mode_to_security_mode(m: MessageSecurityMode) -> SecurityMode {
603    match m {
604        MessageSecurityMode::Sign => SecurityMode::Sign,
605        MessageSecurityMode::SignAndEncrypt => SecurityMode::SignAndEncrypt,
606        _ => SecurityMode::None,
607    }
608}
609
610fn endpoint_description_to_info(ep: EndpointDescription) -> EndpointInfo {
611    let policy_uri = ep.security_policy_uri.to_string();
612    let policy_short = SecurityPolicy::from_str(&policy_uri)
613        .map(|p| p.to_string())
614        .unwrap_or_else(|_| policy_uri.clone());
615    let tokens = ep.user_identity_tokens.unwrap_or_default();
616    let supports_anonymous = tokens
617        .iter()
618        .any(|t| matches!(t.token_type, UserTokenType::Anonymous));
619    let supports_username = tokens
620        .iter()
621        .any(|t| matches!(t.token_type, UserTokenType::UserName));
622    let supports_certificate = tokens
623        .iter()
624        .any(|t| matches!(t.token_type, UserTokenType::Certificate));
625
626    EndpointInfo {
627        endpoint_url: ep.endpoint_url.to_string(),
628        security_policy: policy_short,
629        security_policy_uri: policy_uri,
630        security_mode: message_mode_to_security_mode(ep.security_mode),
631        security_level: ep.security_level,
632        supports_anonymous,
633        supports_username,
634        supports_certificate,
635    }
636}
637
638const ALL_ATTRIBUTES: &[(AttributeId, &str)] = &[
639    (AttributeId::AccessLevel, "AccessLevel"),
640    (AttributeId::AccessLevelEx, "AccessLevelEx"),
641    (AttributeId::AccessRestrictions, "AccessRestrictions"),
642    (AttributeId::ArrayDimensions, "ArrayDimensions"),
643    (AttributeId::BrowseName, "BrowseName"),
644    (AttributeId::ContainsNoLoops, "ContainsNoLoops"),
645    (AttributeId::DataType, "DataType"),
646    (AttributeId::DataTypeDefinition, "DataTypeDefinition"),
647    (AttributeId::Description, "Description"),
648    (AttributeId::DisplayName, "DisplayName"),
649    (AttributeId::EventNotifier, "EventNotifier"),
650    (AttributeId::Executable, "Executable"),
651    (AttributeId::Historizing, "Historizing"),
652    (AttributeId::InverseName, "InverseName"),
653    (AttributeId::IsAbstract, "IsAbstract"),
654    (AttributeId::MinimumSamplingInterval, "MinimumSamplingInterval"),
655    (AttributeId::NodeClass, "NodeClass"),
656    (AttributeId::NodeId, "NodeId"),
657    (AttributeId::RolePermissions, "RolePermissions"),
658    (AttributeId::Symmetric, "Symmetric"),
659    (AttributeId::UserAccessLevel, "UserAccessLevel"),
660    (AttributeId::UserExecutable, "UserExecutable"),
661    (AttributeId::UserRolePermissions, "UserRolePermissions"),
662    (AttributeId::UserWriteMask, "UserWriteMask"),
663    (AttributeId::Value, "Value"),
664    (AttributeId::ValueRank, "ValueRank"),
665    (AttributeId::WriteMask, "WriteMask"),
666];
667
668fn attribute_status_ok(dv: &DataValue) -> bool {
669    match dv.status {
670        None => dv.value.is_some(),
671        Some(s) => s.is_good(),
672    }
673}
674
675fn format_attribute_value(attr: AttributeId, v: &Variant, session: &Session) -> ValueTree {
676    if matches!(attr, AttributeId::NodeClass)
677        && let Variant::Int32(i) = v
678        && let Ok(nc) = NodeClass::try_from(*i)
679    {
680        return ValueTree::Leaf(format!("{nc:?}"));
681    }
682    variant_to_tree(session, v)
683}
684
685fn variant_to_tree(session: &Session, v: &Variant) -> ValueTree {
686    match v {
687        Variant::Empty => ValueTree::Null,
688        Variant::Boolean(b) => ValueTree::Leaf(b.to_string()),
689        Variant::SByte(n) => ValueTree::Leaf(n.to_string()),
690        Variant::Byte(n) => ValueTree::Leaf(n.to_string()),
691        Variant::Int16(n) => ValueTree::Leaf(n.to_string()),
692        Variant::UInt16(n) => ValueTree::Leaf(n.to_string()),
693        Variant::Int32(n) => ValueTree::Leaf(n.to_string()),
694        Variant::UInt32(n) => ValueTree::Leaf(n.to_string()),
695        Variant::Int64(n) => ValueTree::Leaf(n.to_string()),
696        Variant::UInt64(n) => ValueTree::Leaf(n.to_string()),
697        Variant::Float(n) => ValueTree::Leaf(n.to_string()),
698        Variant::Double(n) => ValueTree::Leaf(n.to_string()),
699        Variant::String(s) => ValueTree::Leaf(s.to_string()),
700        Variant::DateTime(d) => ValueTree::Leaf(d.to_string()),
701        Variant::Guid(g) => ValueTree::Leaf(format!("{g:?}")),
702        Variant::StatusCode(s) => ValueTree::Leaf(s.to_string()),
703        Variant::ByteString(b) => match b.value.as_ref() {
704            Some(bytes) => ValueTree::Leaf(format!("<{} bytes>", bytes.len())),
705            None => ValueTree::Null,
706        },
707        Variant::XmlElement(_) => ValueTree::Leaf("XmlElement(…)".to_string()),
708        Variant::QualifiedName(q) => ValueTree::Leaf(q.name.to_string()),
709        Variant::LocalizedText(t) => ValueTree::Leaf(t.text.to_string()),
710        Variant::NodeId(n) => ValueTree::Leaf(n.to_string()),
711        Variant::ExpandedNodeId(n) => ValueTree::Leaf(format!("{n}")),
712        Variant::ExtensionObject(obj) => extension_object_to_tree(session, obj),
713        Variant::Variant(inner) => variant_to_tree(session, inner),
714        Variant::DataValue(_) => ValueTree::Leaf("DataValue(…)".to_string()),
715        Variant::DiagnosticInfo(_) => ValueTree::Leaf("DiagnosticInfo(…)".to_string()),
716        Variant::Array(arr) => {
717            ValueTree::Array(arr.values.iter().map(|i| variant_to_tree(session, i)).collect())
718        }
719    }
720}
721
722fn extension_object_to_tree(session: &Session, obj: &opcua::types::ExtensionObject) -> ValueTree {
723    if obj.inner_as::<DynamicStructure>().is_none() {
724        let label = obj
725            .type_name()
726            .map(|n| format!("ExtensionObject ({n})"))
727            .unwrap_or_else(|| "ExtensionObject".to_string());
728        return ValueTree::Leaf(label);
729    }
730    match dynamic_struct_to_tree(session, obj) {
731        Some(tree) => tree,
732        None => ValueTree::Leaf("ExtensionObject (decode failed)".to_string()),
733    }
734}
735
736fn dynamic_struct_to_tree(
737    session: &Session,
738    obj: &opcua::types::ExtensionObject,
739) -> Option<ValueTree> {
740    let ds = obj.inner_as::<DynamicStructure>()?;
741    let ctx_owned = session.context();
742    let ctx_guard = ctx_owned.read();
743    let ctx = ctx_guard.context();
744    let mut buf = Vec::new();
745    {
746        let writer_ref: &mut dyn std::io::Write = &mut buf;
747        let mut writer = JsonStreamWriter::new(writer_ref);
748        ds.encode(&mut writer, &ctx).ok()?;
749        writer.finish_document().ok()?;
750    }
751    let json: serde_json::Value = serde_json::from_slice(&buf).ok()?;
752    Some(json_to_tree(&json))
753}
754
755fn json_to_tree(v: &serde_json::Value) -> ValueTree {
756    match v {
757        serde_json::Value::Null => ValueTree::Null,
758        serde_json::Value::Bool(b) => ValueTree::Leaf(b.to_string()),
759        serde_json::Value::Number(n) => ValueTree::Leaf(n.to_string()),
760        serde_json::Value::String(s) => ValueTree::Leaf(s.clone()),
761        serde_json::Value::Array(arr) => ValueTree::Array(arr.iter().map(json_to_tree).collect()),
762        serde_json::Value::Object(map) => ValueTree::Object(
763            map.iter()
764                .map(|(k, v)| (k.clone(), json_to_tree(v)))
765                .collect(),
766        ),
767    }
768}
769
770async fn find_child_by_browse_name(
771    session: &Session,
772    parent: &NodeId,
773    target: &QualifiedName,
774) -> Result<Option<NodeId>> {
775    let desc = browse_hierarchical(parent.clone());
776    let mut results = session
777        .browse(&[desc], 0, None)
778        .await
779        .map_err(|s| anyhow!("browse failed: {s}"))?;
780    let refs = results
781        .pop()
782        .and_then(|r| r.references)
783        .unwrap_or_default();
784    for r in refs {
785        if is_excluded_tree_reference(&r.reference_type_id) {
786            continue;
787        }
788        if r.browse_name.namespace_index == target.namespace_index
789            && r.browse_name.name.as_ref() == target.name.as_ref()
790        {
791            return Ok(Some(r.node_id.node_id));
792        }
793    }
794    Ok(None)
795}
796
797/// Parse one path segment as a QualifiedName.
798///
799/// Accepted forms: `Name` (namespace 0), `N:Name` (namespace N — what
800/// `browse_path` emits), `ns=N:Name` (explicit prefix).
801fn parse_qualified_name(segment: &str) -> QualifiedName {
802    let body = segment.strip_prefix("ns=").unwrap_or(segment);
803    if let Some((head, rest)) = body.split_once(':')
804        && let Ok(ns) = head.parse::<u16>()
805    {
806        return QualifiedName::new(ns, rest);
807    }
808    QualifiedName::new(0, segment)
809}
810
811async fn register_dynamic_type_loader(session: &Session) -> Result<()> {
812    let type_tree = DataTypeTreeBuilder::new(|_| true)
813        .build(session)
814        .await
815        .map_err(|e| anyhow!("DataTypeTreeBuilder failed: {e}"))?;
816    let loader: Arc<dyn TypeLoader> = Arc::new(DynamicTypeLoader::new(Arc::new(type_tree)));
817    session.add_type_loader(loader);
818    Ok(())
819}