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