Skip to main content

opc_da_client/
provider.rs

1use crate::opc_da::errors::{OpcError, OpcResult};
2use async_trait::async_trait;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::time::Duration;
6use tokio::sync::mpsc;
7use uuid::Uuid;
8
9#[cfg(feature = "test-support")]
10use mockall::automock;
11
12/// A single tag's read result.
13///
14/// Returned by [`OpcProvider::read_tag_values`] and
15/// [`OpcProvider::read_tag_values_for_display`].
16///
17/// # Examples
18///
19/// ```
20/// use opc_da_client::TagValue;
21///
22/// let tv = TagValue {
23///     tag_id: "Simulation.Random.1".to_string(),
24///     value: "42.5".to_string(),
25///     quality: "Good".to_string(),
26///     timestamp: "2026-01-01 00:00:00".to_string(),
27/// };
28/// assert_eq!(tv.tag_id, "Simulation.Random.1");
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TagValue {
32    /// The fully qualified tag identifier (e.g., `"Channel1.Device1.Tag1"`).
33    pub tag_id: String,
34    /// The current value as a string representation.
35    ///
36    /// [`OpcProvider::read_tag_values`] preserves `VT_BSTR` contents exactly.
37    /// [`OpcProvider::read_tag_values_for_display`] may add presentation quotes
38    /// around BSTR values.
39    pub value: String,
40    /// OPC quality indicator (e.g., `"Good"`, `"Bad"`, or `"Uncertain"`).
41    pub quality: String,
42    /// Timestamp of the last value change, formatted as a local time string.
43    pub timestamp: String,
44}
45
46/// Typed value to write to an OPC DA tag.
47///
48/// # Examples
49///
50/// ```
51/// use opc_da_client::OpcValue;
52///
53/// let v = OpcValue::Float(3.14);
54/// assert_eq!(v, OpcValue::Float(3.14));
55/// ```
56#[derive(Debug, Clone, PartialEq)]
57pub enum OpcValue {
58    /// String value (`VT_BSTR`) — server may coerce to target type.
59    String(String),
60    /// 32-bit integer (`VT_I4`).
61    Int(i32),
62    /// 64-bit float (`VT_R8`).
63    Float(f64),
64    /// Boolean (`VT_BOOL`).
65    Bool(bool),
66}
67
68/// Result of a single write operation.
69///
70/// # Examples
71///
72/// ```
73/// use opc_da_client::WriteResult;
74///
75/// let wr = WriteResult {
76///     tag_id: "Tag1".to_string(),
77///     success: true,
78///     error: None,
79/// };
80/// assert!(wr.success);
81/// ```
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct WriteResult {
84    /// The tag that was written to.
85    pub tag_id: String,
86    /// Whether the write succeeded.
87    pub success: bool,
88    /// Error message if the write failed, `None` on success.
89    pub error: Option<String>,
90}
91
92/// OPC DA address-space organization reported by a server.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum BrowseNamespace {
95    /// All item IDs live in one flat namespace.
96    Flat,
97    /// Items are organized under browsable branches.
98    Hierarchical,
99    /// The server supports DA 3.0 browsing but does not expose the DA 2.x
100    /// namespace query needed to classify its organization.
101    Unknown,
102}
103
104/// Native browse features available from an OPC DA server.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct BrowseCapabilities {
107    /// Address-space organization reported by the server.
108    pub namespace: BrowseNamespace,
109    /// Whether native OPC DA 3.0 `IOPCBrowse` is available.
110    pub supports_da3: bool,
111    /// Whether OPC DA 2.x `IOPCBrowseServerAddressSpace` is available.
112    pub supports_da2: bool,
113    /// Largest page size accepted by [`OpcProvider::browse_page`].
114    pub max_page_size: u32,
115}
116
117macro_rules! opaque_browse_token {
118    ($name:ident, $doc:literal) => {
119        #[doc = $doc]
120        #[derive(Clone, Copy, PartialEq, Eq, Hash)]
121        pub struct $name(Uuid);
122
123        impl $name {
124            pub(crate) fn new() -> Self {
125                Self(Uuid::new_v4())
126            }
127
128            /// Parse a token previously encoded with [`ToString::to_string`].
129            ///
130            /// This allows transport adapters to round-trip opaque tokens
131            /// without accessing their internal representation.
132            pub fn parse(value: &str) -> Result<Self, uuid::Error> {
133                value.parse()
134            }
135        }
136
137        impl std::fmt::Debug for $name {
138            fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139                formatter
140                    .debug_tuple(stringify!($name))
141                    .field(&self.0)
142                    .finish()
143            }
144        }
145
146        impl std::fmt::Display for $name {
147            fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148                self.0.fmt(formatter)
149            }
150        }
151
152        impl std::str::FromStr for $name {
153            type Err = uuid::Error;
154
155            fn from_str(value: &str) -> Result<Self, Self::Err> {
156                value.parse().map(Self)
157            }
158        }
159    };
160}
161
162opaque_browse_token!(
163    BrowseSessionToken,
164    "Opaque identifier for a browse session owned by the COM worker."
165);
166opaque_browse_token!(
167    BrowseNodeToken,
168    "Opaque identifier for a node returned by a browse session."
169);
170opaque_browse_token!(
171    BrowsePageToken,
172    "Opaque continuation token for the next bounded browse page."
173);
174
175/// Kinds of nodes returned by a native browse.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum BrowseNodeKind {
178    /// A branch that can have children but is not itself an item.
179    Branch,
180    /// An item that has no browsable children.
181    Item,
182    /// A node that is both an item and a branch.
183    BranchAndItem,
184}
185
186impl BrowseNodeKind {
187    /// Returns whether the node can be used as the parent of another browse.
188    pub fn has_children(self) -> bool {
189        matches!(self, Self::Branch | Self::BranchAndItem)
190    }
191
192    /// Returns whether the node identifies a selectable OPC item.
193    pub fn is_item(self) -> bool {
194        matches!(self, Self::Item | Self::BranchAndItem)
195    }
196}
197
198/// Node-kind filter for a one-level browse request.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum BrowseNodeFilter {
201    /// Return only branches.
202    Branches,
203    /// Return only items.
204    Items,
205    /// Return both branches and items.
206    All,
207}
208
209/// One address-space node returned by [`OpcProvider::browse_page`].
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct BrowseNode {
212    /// Opaque token used to browse this node's immediate children.
213    pub token: BrowseNodeToken,
214    /// Display name relative to the requested parent.
215    pub name: String,
216    /// Exact fully-qualified item ID when the server supplies one.
217    pub item_id: Option<String>,
218    /// Whether this node is a branch, item, or both.
219    pub kind: BrowseNodeKind,
220}
221
222/// Parameters for one bounded, non-recursive browse operation.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct BrowsePageRequest {
225    /// Parent node, or `None` to browse the root.
226    pub parent: Option<BrowseNodeToken>,
227    /// Node kinds to return.
228    pub filter: BrowseNodeFilter,
229    /// Maximum number of nodes to return.
230    pub max_elements: u32,
231    /// Opaque continuation returned by the preceding page.
232    pub continuation: Option<BrowsePageToken>,
233}
234
235/// One bounded page of immediate address-space children.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct BrowsePage {
238    /// Nodes returned in this page.
239    pub nodes: Vec<BrowseNode>,
240    /// Opaque token for the next page, or `None` when enumeration is complete.
241    pub continuation: Option<BrowsePageToken>,
242}
243
244/// Maximum number of native entries requested for one inventory slice.
245pub const MAX_INVENTORY_BATCH_SIZE: u32 = 1_000;
246
247/// Options controlling one bounded namespace inventory.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub struct InventoryOptions {
250    /// Maximum number of native entries requested per browse operation.
251    pub batch_size: u32,
252    /// Optional safety cap for a deliberately bounded inventory.
253    pub max_entries: Option<u64>,
254}
255
256impl Default for InventoryOptions {
257    fn default() -> Self {
258        Self {
259            batch_size: 100,
260            max_entries: None,
261        }
262    }
263}
264
265/// One selectable OPC DA item discovered during inventory.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct InventoryEntry {
268    /// Local display name returned by the server.
269    pub display_name: String,
270    /// Exact ItemID returned by the server.
271    pub item_id: String,
272    /// Whether the item is also a browsable branch.
273    pub kind: BrowseNodeKind,
274    /// Stable display labels for the item's ancestors.
275    pub breadcrumbs: Vec<String>,
276}
277
278/// Progress emitted between bounded inventory operations.
279#[derive(Debug, Clone, PartialEq)]
280pub struct InventoryProgress {
281    pub branches_visited: u64,
282    pub entries_seen: u64,
283    pub unique_items: u64,
284    pub active_time_ms: u64,
285    pub paused_time_ms: u64,
286    pub items_per_second: f64,
287    pub estimated_remaining_ms: Option<u64>,
288}
289
290/// Transport used for one bounded inventory slice.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum InventorySliceBackend {
293    /// OPC DA 3.0 `IOPCBrowse`.
294    Da3,
295    /// OPC DA 2.x `IOPCBrowseServerAddressSpace`.
296    Da2,
297}
298
299/// Observation emitted after each bounded inventory slice.
300///
301/// A slice is one page-sized inventory step. Native DA2 enumeration may use
302/// several COM calls to produce one slice; `native_operations` reports that
303/// bounded work without exposing COM implementation details.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct InventorySliceObservation {
306    /// Monotonically increasing slice number, starting at one.
307    pub sequence: u64,
308    /// Native browsing protocol used by the slice.
309    pub backend: InventorySliceBackend,
310    /// Number of nodes returned by the slice.
311    pub nodes_returned: u64,
312    /// Whether another slice remains for the same branch.
313    pub has_more: bool,
314    /// Number of bounded native operations performed for this slice.
315    pub native_operations: u64,
316    /// Wall-clock duration of the slice, including pacing waits.
317    pub elapsed_ms: u64,
318    /// Total native nodes observed through this slice.
319    pub entries_seen: u64,
320    /// Total unique item IDs observed through this slice.
321    pub unique_items: u64,
322}
323
324/// Terminal result for one inventory operation.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct InventoryCompleted {
327    pub complete: bool,
328    pub cancelled: bool,
329    pub truncated: bool,
330    pub warning: Option<String>,
331    pub capabilities: BrowseCapabilities,
332}
333
334/// Event emitted by [`InventoryStream`].
335#[derive(Debug, Clone, PartialEq)]
336pub enum InventoryEvent {
337    Entry(InventoryEntry),
338    Progress(InventoryProgress),
339    Slice(InventorySliceObservation),
340    Completed(InventoryCompleted),
341}
342
343#[derive(Debug)]
344struct InventoryControlState {
345    cancelled: AtomicBool,
346    paused: AtomicBool,
347    pacing_interval_ns: AtomicU64,
348    batch_size: AtomicUsize,
349}
350
351/// Dynamic delay enforced before each bounded native inventory operation.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct InventoryPacing {
354    /// Minimum interval between the starts of bounded native operations.
355    pub min_interval: Duration,
356}
357
358impl Default for InventoryPacing {
359    fn default() -> Self {
360        Self {
361            min_interval: Duration::ZERO,
362        }
363    }
364}
365
366/// Control handle for a running inventory.
367#[derive(Clone, Debug)]
368pub struct InventoryControl {
369    state: Arc<InventoryControlState>,
370}
371
372impl InventoryControl {
373    pub(crate) fn new() -> Self {
374        Self {
375            state: Arc::new(InventoryControlState {
376                cancelled: AtomicBool::new(false),
377                paused: AtomicBool::new(false),
378                pacing_interval_ns: AtomicU64::new(0),
379                batch_size: AtomicUsize::new(0),
380            }),
381        }
382    }
383
384    pub(crate) fn new_with_batch_size(batch_size: u32) -> Self {
385        debug_assert!((1..=MAX_INVENTORY_BATCH_SIZE).contains(&batch_size));
386        let control = Self::new();
387        control
388            .state
389            .batch_size
390            .store(batch_size as usize, Ordering::Release);
391        control
392    }
393
394    /// Request cancellation at the next bounded COM boundary.
395    pub fn cancel(&self) {
396        self.state.cancelled.store(true, Ordering::Release);
397    }
398
399    /// Pause before the next bounded COM operation.
400    pub fn pause(&self) {
401        self.state.paused.store(true, Ordering::Release);
402    }
403
404    /// Resume a paused inventory.
405    pub fn resume(&self) {
406        self.state.paused.store(false, Ordering::Release);
407    }
408
409    /// Replace the pacing applied before the next bounded native operation.
410    ///
411    /// Updates are observed by a running inventory without restarting it.
412    pub fn set_pacing(&self, pacing: InventoryPacing) {
413        let nanos = u64::try_from(pacing.min_interval.as_nanos().min(u128::from(u64::MAX)))
414            .unwrap_or(u64::MAX);
415        self.state
416            .pacing_interval_ns
417            .store(nanos, Ordering::Release);
418    }
419
420    /// Return the pacing currently applied to this inventory.
421    pub fn pacing(&self) -> InventoryPacing {
422        InventoryPacing {
423            min_interval: Duration::from_nanos(
424                self.state.pacing_interval_ns.load(Ordering::Acquire),
425            ),
426        }
427    }
428
429    /// Replace the batch size used for the next inventory slice.
430    ///
431    /// The value must be between 1 and [`MAX_INVENTORY_BATCH_SIZE`].
432    pub fn set_batch_size(&self, batch_size: u32) -> OpcResult<()> {
433        if !(1..=MAX_INVENTORY_BATCH_SIZE).contains(&batch_size) {
434            return Err(OpcError::InvalidState(format!(
435                "Inventory batch size must be between 1 and {MAX_INVENTORY_BATCH_SIZE}"
436            )));
437        }
438        self.state
439            .batch_size
440            .store(batch_size as usize, Ordering::Release);
441        Ok(())
442    }
443
444    pub(crate) fn batch_size(&self) -> Option<u32> {
445        let batch_size = self.state.batch_size.load(Ordering::Acquire);
446        u32::try_from(batch_size).ok().filter(|value| *value != 0)
447    }
448
449    /// Return whether cancellation has been requested.
450    pub fn is_cancelled(&self) -> bool {
451        self.state.cancelled.load(Ordering::Acquire)
452    }
453
454    pub(crate) fn is_paused(&self) -> bool {
455        self.state.paused.load(Ordering::Acquire)
456    }
457}
458
459/// Cancellable stream of bounded inventory events.
460pub struct InventoryStream {
461    receiver: mpsc::Receiver<OpcResult<InventoryEvent>>,
462    control: InventoryControl,
463    worker: Option<std::thread::JoinHandle<()>>,
464}
465
466impl InventoryStream {
467    pub(crate) fn new(
468        receiver: mpsc::Receiver<OpcResult<InventoryEvent>>,
469        control: InventoryControl,
470        worker: std::thread::JoinHandle<()>,
471    ) -> Self {
472        Self {
473            receiver,
474            control,
475            worker: Some(worker),
476        }
477    }
478
479    /// Wait for the next inventory event.
480    ///
481    /// A failed inventory is delivered as `Some(Err(_))`, which is the
482    /// terminal event for the stream. A successful or cancelled inventory
483    /// ends with an [`InventoryEvent::Completed`] message.
484    pub async fn message(&mut self) -> Option<OpcResult<InventoryEvent>> {
485        self.receiver.recv().await
486    }
487
488    /// Return a control handle for this inventory.
489    pub fn control(&self) -> InventoryControl {
490        self.control.clone()
491    }
492
493    /// Request cancellation of this inventory.
494    pub fn cancel(&self) {
495        self.control.cancel();
496    }
497
498    /// Pause this inventory before its next bounded operation.
499    pub fn pause(&self) {
500        self.control.pause();
501    }
502
503    /// Resume this inventory.
504    pub fn resume(&self) {
505        self.control.resume();
506    }
507
508    /// Replace the pacing applied before the next bounded native operation.
509    pub fn set_pacing(&self, pacing: InventoryPacing) {
510        self.control.set_pacing(pacing);
511    }
512
513    /// Replace the batch size used for the next inventory slice.
514    pub fn set_batch_size(&self, batch_size: u32) -> OpcResult<()> {
515        self.control.set_batch_size(batch_size)
516    }
517}
518
519impl Drop for InventoryStream {
520    fn drop(&mut self) {
521        // Close the receiver before joining so a worker blocked on a full
522        // event channel can observe the disconnect and finish.
523        self.receiver.close();
524        self.control.cancel();
525        if let Some(worker) = self.worker.take() {
526            let _ = worker.join();
527        }
528    }
529}
530
531#[cfg(test)]
532mod inventory_stream_tests {
533    use super::*;
534
535    #[test]
536    fn dropping_inventory_stream_cancels_and_joins_worker() {
537        let control = InventoryControl::new();
538        let worker_control = control.clone();
539        let finished = Arc::new(AtomicBool::new(false));
540        let worker_finished = Arc::clone(&finished);
541        let (_sender, receiver) = mpsc::channel(1);
542        let worker = std::thread::spawn(move || {
543            while !worker_control.is_cancelled() {
544                std::thread::yield_now();
545            }
546            worker_finished.store(true, Ordering::Release);
547        });
548
549        drop(InventoryStream::new(receiver, control, worker));
550        assert!(finished.load(Ordering::Acquire));
551    }
552}
553
554#[cfg(test)]
555mod read_display_fallback_tests {
556    use super::*;
557
558    struct FallbackProvider;
559
560    #[async_trait]
561    impl OpcProvider for FallbackProvider {
562        async fn list_servers(&self, _host: &str) -> OpcResult<Vec<String>> {
563            Ok(Vec::new())
564        }
565
566        async fn browse_tags(
567            &self,
568            _server: &str,
569            _max_tags: usize,
570            _progress: Arc<AtomicUsize>,
571            _tags_sink: Arc<std::sync::Mutex<Vec<String>>>,
572        ) -> OpcResult<Vec<String>> {
573            Ok(Vec::new())
574        }
575
576        async fn read_tag_values(
577            &self,
578            _server: &str,
579            tag_ids: Vec<String>,
580        ) -> OpcResult<Vec<TagValue>> {
581            Ok(tag_ids
582                .into_iter()
583                .map(|tag_id| TagValue {
584                    tag_id,
585                    value: "AUT".to_string(),
586                    quality: "Good".to_string(),
587                    timestamp: String::new(),
588                })
589                .collect())
590        }
591
592        async fn write_tag_value(
593            &self,
594            _server: &str,
595            tag_id: &str,
596            _value: OpcValue,
597        ) -> OpcResult<WriteResult> {
598            Ok(WriteResult {
599                tag_id: tag_id.to_string(),
600                success: true,
601                error: None,
602            })
603        }
604    }
605
606    #[tokio::test]
607    async fn display_read_defaults_to_semantic_read() {
608        let values = FallbackProvider
609            .read_tag_values_for_display("Server", vec!["Tag".to_string()])
610            .await
611            .unwrap();
612
613        assert_eq!(values[0].value, "AUT");
614    }
615}
616
617/// Async trait for OPC DA operations.
618///
619/// This is the stable public API. Backend implementations provide
620/// the actual COM/DCOM interaction.
621#[cfg_attr(feature = "test-support", automock)]
622#[async_trait]
623pub trait OpcProvider: Send + Sync {
624    /// List available OPC DA servers on the given host.
625    ///
626    /// # Errors
627    /// Returns `Err` if COM initialization fails or the server registry
628    /// cannot be enumerated.
629    async fn list_servers(&self, host: &str) -> OpcResult<Vec<String>>;
630
631    /// Browse tags recursively, pushing discoveries to `tags_sink`.
632    ///
633    /// # Errors
634    /// Returns `Err` if the server connection fails, the `ProgID` cannot be
635    /// resolved, or the namespace walk encounters an unrecoverable error.
636    async fn browse_tags(
637        &self,
638        server: &str,
639        max_tags: usize,
640        progress: Arc<AtomicUsize>,
641        tags_sink: Arc<std::sync::Mutex<Vec<String>>>,
642    ) -> OpcResult<Vec<String>>;
643
644    /// Return the native browse capabilities of an OPC DA server.
645    ///
646    /// # Errors
647    /// Returns `Err` if the server cannot be connected or exposes no supported
648    /// OPC DA browse interface.
649    async fn browse_capabilities(&self, server: &str) -> OpcResult<BrowseCapabilities> {
650        let _ = server;
651        Err(OpcError::NotImplemented(
652            "Native browsing is not implemented by this provider".to_string(),
653        ))
654    }
655
656    /// Open an isolated native browse session with its own server connection.
657    ///
658    /// # Errors
659    /// Returns `Err` if the server cannot be connected, browsing is unsupported,
660    /// or the worker's bounded session capacity has been reached.
661    async fn open_browse_session(&self, server: &str) -> OpcResult<BrowseSessionToken> {
662        let _ = server;
663        Err(OpcError::NotImplemented(
664            "Native browsing is not implemented by this provider".to_string(),
665        ))
666    }
667
668    /// Browse one bounded level of an open native browse session.
669    ///
670    /// # Errors
671    /// Returns `Err` for invalid, closed, or expired tokens; invalid page sizes;
672    /// unsupported requests; or underlying OPC browse failures.
673    async fn browse_page(
674        &self,
675        session: &BrowseSessionToken,
676        request: BrowsePageRequest,
677    ) -> OpcResult<BrowsePage> {
678        let _ = (session, request);
679        Err(OpcError::NotImplemented(
680            "Native browsing is not implemented by this provider".to_string(),
681        ))
682    }
683
684    /// Explicitly close a native browse session and release its server connection.
685    ///
686    /// # Errors
687    /// Returns `Err` if the session token is invalid, expired, or already closed.
688    async fn close_browse_session(&self, session: &BrowseSessionToken) -> OpcResult<()> {
689        let _ = session;
690        Err(OpcError::NotImplemented(
691            "Native browsing is not implemented by this provider".to_string(),
692        ))
693    }
694
695    /// Start a cancellable, bounded namespace inventory on a separate
696    /// connection from foreground operations.
697    ///
698    /// The returned stream never exposes interactive browse-session tokens;
699    /// all traversal state remains private to the inventory worker.
700    async fn start_inventory(
701        &self,
702        server: &str,
703        options: InventoryOptions,
704    ) -> OpcResult<InventoryStream> {
705        let _ = (server, options);
706        Err(OpcError::NotImplemented(
707            "Namespace inventory is not implemented by this provider".to_string(),
708        ))
709    }
710
711    /// Read current values for the given tag IDs.
712    ///
713    /// `VT_BSTR` values preserve their exact COM contents. No quote characters
714    /// are added or removed, so this method is suitable for machine consumers.
715    ///
716    /// # Errors
717    /// Returns `Err` if the server connection fails, no items can be added
718    /// to the OPC group, or the synchronous read operation fails.
719    async fn read_tag_values(&self, server: &str, tag_ids: Vec<String>)
720    -> OpcResult<Vec<TagValue>>;
721
722    /// Read current values formatted for human-readable display.
723    ///
724    /// The native provider wraps `VT_BSTR` contents in quote characters while
725    /// leaving all other value formatting unchanged. The default implementation
726    /// delegates to [`Self::read_tag_values`] so third-party providers remain
727    /// source-compatible.
728    ///
729    /// # Errors
730    /// Returns the same errors as [`Self::read_tag_values`].
731    async fn read_tag_values_for_display(
732        &self,
733        server: &str,
734        tag_ids: Vec<String>,
735    ) -> OpcResult<Vec<TagValue>> {
736        self.read_tag_values(server, tag_ids).await
737    }
738
739    /// Write a value to a single OPC DA tag.
740    ///
741    /// # Errors
742    /// Returns `Err` if the server connection fails, the tag cannot be added
743    /// to the OPC group, or the synchronous write operation fails.
744    async fn write_tag_value(
745        &self,
746        server: &str,
747        tag_id: &str,
748        value: OpcValue,
749    ) -> OpcResult<WriteResult>;
750}