Skip to main content

ua_client/
client.rs

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