Skip to main content

bhtune_driver/
opcda.rs

1//! `OpcDaDriver`: the primary [`Driver`] implementation, talking to a DCS/PLC's OPC DA
2//! server through the `opcda-bridge` gateway's gRPC API.
3//!
4//! This module intentionally splits into two halves: a thin async shell (`OpcDaDriver`
5//! itself) that only locks the client and calls into `opcda_bridge`, and small mapping
6//! functions that translate the bridge's typed pages, nodes, capabilities, and search events
7//! into the driver crate's protocol-neutral types. The mapping functions carry the real risk
8//! of a subtle bug and are unit-testable with no I/O; the shell is exercised end-to-end by
9//! mock-gateway smoke tests rather than re-testing the bridge's own gRPC matrix.
10
11use async_trait::async_trait;
12use tokio::sync::Mutex;
13
14use crate::{
15    driver::Driver,
16    error::{DriverError, DriverResult},
17    types::{
18        BrowseBreadcrumb, BrowseNode, BrowseNodeKind, BrowsePage, BrowsePageRequest, BrowseSource,
19        DriverCapabilities, IndexSchedulerDiagnostics, IndexedSearchMatch, IndexedSearchProgress,
20        NamespaceOrganization, Quality, SearchCompleted, SearchEvent, SearchIndexControlAction,
21        SearchIndexRequest, SearchIndexResponse, SearchIndexState, SearchIndexStatus, SearchMatch,
22        SearchMatchMode, SearchProgress, SearchRequest, TagId, TagValue, TagWrite, WriteOutcome,
23    },
24};
25
26/// Default number of children requested for one browse page. The bridge enforces its own
27/// maximum; this value keeps the browser responsive and matches the bridge library default.
28pub const DEFAULT_PAGE_SIZE: u32 = opcda_bridge::DEFAULT_PAGE_SIZE;
29
30/// Default maximum number of search matches requested by the CLI and browser.
31pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_SEARCH_MAX_RESULTS;
32
33/// Default maximum number of matches requested from the persistent namespace index.
34pub const DEFAULT_INDEX_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_INDEX_SEARCH_MAX_RESULTS;
35
36/// A gateway-wide protocol feature reported by `opcda-bridge`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum OpcDaGatewayFeature {
40    Core,
41    Namespace,
42    IndexedSearch,
43}
44
45impl OpcDaGatewayFeature {
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Core => "core",
50            Self::Namespace => "namespace",
51            Self::IndexedSearch => "indexed_search",
52        }
53    }
54}
55
56/// One gateway-wide protocol feature and the inclusive versions it supports.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct OpcDaGatewayFeatureSupport {
59    pub feature: OpcDaGatewayFeature,
60    pub min_version: u32,
61    pub max_version: u32,
62}
63
64/// Gateway-wide metadata available without contacting an OPC DA server.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct OpcDaGatewayInfo {
67    pub application_version: String,
68    pub compatibility_schema_version: u32,
69    pub features: Vec<OpcDaGatewayFeatureSupport>,
70}
71
72/// An inclusive protocol-version range supported by bhtune or reported by a gateway.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct OpcDaProtocolRange {
75    /// Lowest supported protocol version.
76    pub min: u32,
77    /// Highest supported protocol version.
78    pub max: u32,
79}
80
81impl std::fmt::Display for OpcDaProtocolRange {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}-{}", self.min, self.max)
84    }
85}
86
87/// Overall protocol compatibility between this bhtune build and an `opcda-bridge` gateway.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum OpcDaCompatibilityStatus {
91    /// Every protocol feature bhtune uses overlaps with the gateway.
92    Full,
93    /// The core protocol overlaps, but at least one optional feature is degraded.
94    Partial,
95    /// The core protocol does not overlap; live operations must be refused.
96    Incompatible,
97    /// The gateway did not report enough metadata to verify compatibility.
98    Unknown,
99}
100
101impl OpcDaCompatibilityStatus {
102    /// Stable `snake_case` identifier used in logs and stored run provenance.
103    #[must_use]
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::Full => "full",
107            Self::Partial => "partial",
108            Self::Incompatible => "incompatible",
109            Self::Unknown => "unknown",
110        }
111    }
112}
113
114/// Where the gateway protocol metadata behind a compatibility result came from.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum OpcDaCompatibilitySource {
118    /// The gateway-wide version and protocol metadata RPC.
119    GatewayInfo,
120    /// Per-server capability metadata from a gateway that predates gateway-wide metadata.
121    LegacyCapabilities,
122    /// The gateway reported no usable protocol metadata.
123    Unknown,
124}
125
126impl OpcDaCompatibilitySource {
127    /// Stable `snake_case` identifier used in logs and stored run provenance.
128    #[must_use]
129    pub const fn as_str(self) -> &'static str {
130        match self {
131            Self::GatewayInfo => "gateway_info",
132            Self::LegacyCapabilities => "legacy_capabilities",
133            Self::Unknown => "unknown",
134        }
135    }
136}
137
138/// Compatibility of one gateway protocol feature.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum OpcDaFeatureCompatibilityStatus {
142    /// bhtune and the gateway share at least one protocol version for this feature.
143    Compatible,
144    /// The gateway does not offer this optional feature.
145    Unsupported,
146    /// The gateway offers this feature, but no protocol version overlaps.
147    Incompatible,
148    /// The gateway did not report this feature.
149    Unknown,
150}
151
152impl OpcDaFeatureCompatibilityStatus {
153    /// Stable `snake_case` identifier used in logs and stored run provenance.
154    #[must_use]
155    pub const fn as_str(self) -> &'static str {
156        match self {
157            Self::Compatible => "compatible",
158            Self::Unsupported => "unsupported",
159            Self::Incompatible => "incompatible",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// The compatibility evaluation of one gateway protocol feature.
166#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167pub struct OpcDaFeatureCompatibility {
168    /// The protocol feature evaluated.
169    pub feature: OpcDaGatewayFeature,
170    /// Whether this bhtune build can use the feature with the gateway.
171    pub status: OpcDaFeatureCompatibilityStatus,
172    /// The protocol versions this bhtune build supports for the feature.
173    pub client_versions: OpcDaProtocolRange,
174    /// The protocol versions the gateway reported, if any.
175    pub gateway_versions: Option<OpcDaProtocolRange>,
176    /// The highest protocol version both sides support, if any.
177    pub negotiated_version: Option<u32>,
178    /// A human-readable explanation from the compatibility evaluation.
179    pub reason: String,
180}
181
182impl OpcDaFeatureCompatibility {
183    /// Describes the feature's status and both protocol ranges in one line.
184    #[must_use]
185    pub fn describe(&self) -> String {
186        let gateway = self
187            .gateway_versions
188            .map_or_else(|| "none".to_string(), |range| range.to_string());
189        format!(
190            "{} protocol {}: bhtune supports {}, gateway reports {gateway}",
191            self.feature.as_str(),
192            self.status.as_str(),
193            self.client_versions,
194        )
195    }
196}
197
198/// The result of checking whether an `opcda-bridge` gateway can safely serve this bhtune
199/// build.
200///
201/// Live mutations must be refused when [`Self::is_incompatible`] is true. `Partial` and
202/// `Unknown` results allow live operations but should surface [`Self::warning_message`].
203#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204pub struct OpcDaGatewayCompatibility {
205    /// The bhtune version whose protocol profile was evaluated.
206    pub client_version: String,
207    /// The gateway application version, when the gateway reported one.
208    pub gateway_version: Option<String>,
209    /// Where the gateway protocol metadata came from.
210    pub source: OpcDaCompatibilitySource,
211    /// The overall compatibility result.
212    pub status: OpcDaCompatibilityStatus,
213    /// The per-feature evaluations behind [`Self::status`].
214    pub features: Vec<OpcDaFeatureCompatibility>,
215}
216
217impl OpcDaGatewayCompatibility {
218    /// Returns true when live operations against this gateway must be refused.
219    #[must_use]
220    pub const fn is_incompatible(&self) -> bool {
221        matches!(self.status, OpcDaCompatibilityStatus::Incompatible)
222    }
223
224    /// The refusal text for an incompatible gateway, or `None` when live operations may proceed.
225    #[must_use]
226    pub fn live_mutation_refusal(&self) -> Option<String> {
227        self.is_incompatible()
228            .then(|| self.incompatibility_message())
229    }
230
231    /// A synthetic `Unknown` result used when inspection cannot classify the gateway.
232    #[must_use]
233    pub fn unverified() -> Self {
234        gateway_compatibility_from_report(opcda_bridge::unknown_compatibility_report(
235            CLIENT_VERSION,
236        ))
237    }
238
239    fn gateway_label(&self) -> String {
240        self.gateway_version.as_deref().map_or_else(
241            || "opcda-bridge gateway (version not reported)".to_string(),
242            |version| format!("opcda-bridge gateway {version}"),
243        )
244    }
245
246    fn details(&self) -> String {
247        let issues: Vec<String> = self
248            .features
249            .iter()
250            .filter(|feature| feature.status != OpcDaFeatureCompatibilityStatus::Compatible)
251            .map(OpcDaFeatureCompatibility::describe)
252            .collect();
253        if issues.is_empty() {
254            String::new()
255        } else {
256            format!(" ({})", issues.join("; "))
257        }
258    }
259
260    /// An actionable refusal message naming the gateway version, both protocol ranges for
261    /// every affected feature, and the fix.
262    #[must_use]
263    pub fn incompatibility_message(&self) -> String {
264        format!(
265            "{} is incompatible with bhtune {}{}; upgrade the opcda-bridge gateway or bhtune so \
266             their core protocol versions overlap",
267            self.gateway_label(),
268            self.client_version,
269            self.details(),
270        )
271    }
272
273    /// An operator warning for a `Partial` or `Unknown` result, or `None` when the result
274    /// needs no warning (`Full`) or must be refused instead (`Incompatible`).
275    #[must_use]
276    pub fn warning_message(&self) -> Option<String> {
277        match self.status {
278            OpcDaCompatibilityStatus::Full | OpcDaCompatibilityStatus::Incompatible => None,
279            OpcDaCompatibilityStatus::Partial => Some(format!(
280                "{} is partially compatible with bhtune {}; affected optional features are \
281                 degraded{}",
282                self.gateway_label(),
283                self.client_version,
284                self.details(),
285            )),
286            OpcDaCompatibilityStatus::Unknown => Some(format!(
287                "{} did not report enough protocol metadata to verify compatibility with bhtune \
288                 {}; proceeding without verification{}",
289                self.gateway_label(),
290                self.client_version,
291                self.details(),
292            )),
293        }
294    }
295}
296
297const fn gateway_feature_from_bridge(
298    feature: opcda_bridge::CompatibilityFeature,
299) -> OpcDaGatewayFeature {
300    match feature {
301        opcda_bridge::CompatibilityFeature::Core => OpcDaGatewayFeature::Core,
302        opcda_bridge::CompatibilityFeature::Namespace => OpcDaGatewayFeature::Namespace,
303        opcda_bridge::CompatibilityFeature::IndexedSearch => OpcDaGatewayFeature::IndexedSearch,
304    }
305}
306
307const fn compatibility_status_from_bridge(
308    status: opcda_bridge::CompatibilityStatus,
309) -> OpcDaCompatibilityStatus {
310    match status {
311        opcda_bridge::CompatibilityStatus::Full => OpcDaCompatibilityStatus::Full,
312        opcda_bridge::CompatibilityStatus::Partial => OpcDaCompatibilityStatus::Partial,
313        opcda_bridge::CompatibilityStatus::Incompatible => OpcDaCompatibilityStatus::Incompatible,
314        opcda_bridge::CompatibilityStatus::Unknown => OpcDaCompatibilityStatus::Unknown,
315    }
316}
317
318const fn compatibility_source_from_bridge(
319    source: opcda_bridge::CompatibilitySource,
320) -> OpcDaCompatibilitySource {
321    match source {
322        opcda_bridge::CompatibilitySource::GatewayInfo => OpcDaCompatibilitySource::GatewayInfo,
323        opcda_bridge::CompatibilitySource::LegacyCapabilities => {
324            OpcDaCompatibilitySource::LegacyCapabilities
325        }
326        opcda_bridge::CompatibilitySource::Unknown => OpcDaCompatibilitySource::Unknown,
327    }
328}
329
330const fn feature_status_from_bridge(
331    status: opcda_bridge::FeatureCompatibilityStatus,
332) -> OpcDaFeatureCompatibilityStatus {
333    match status {
334        opcda_bridge::FeatureCompatibilityStatus::Compatible => {
335            OpcDaFeatureCompatibilityStatus::Compatible
336        }
337        opcda_bridge::FeatureCompatibilityStatus::Unsupported => {
338            OpcDaFeatureCompatibilityStatus::Unsupported
339        }
340        opcda_bridge::FeatureCompatibilityStatus::Incompatible => {
341            OpcDaFeatureCompatibilityStatus::Incompatible
342        }
343        opcda_bridge::FeatureCompatibilityStatus::Unknown => {
344            OpcDaFeatureCompatibilityStatus::Unknown
345        }
346    }
347}
348
349const fn protocol_range_from_bridge(
350    range: opcda_bridge::ProtocolVersionRange,
351) -> OpcDaProtocolRange {
352    OpcDaProtocolRange {
353        min: range.min,
354        max: range.max,
355    }
356}
357
358fn gateway_compatibility_from_report(
359    report: opcda_bridge::CompatibilityReport,
360) -> OpcDaGatewayCompatibility {
361    OpcDaGatewayCompatibility {
362        client_version: report.client_version,
363        gateway_version: report
364            .gateway_version
365            .filter(|version| !version.trim().is_empty()),
366        source: compatibility_source_from_bridge(report.source),
367        status: compatibility_status_from_bridge(report.status),
368        features: report
369            .features
370            .into_iter()
371            .map(|feature| OpcDaFeatureCompatibility {
372                feature: gateway_feature_from_bridge(feature.feature),
373                status: feature_status_from_bridge(feature.status),
374                client_versions: protocol_range_from_bridge(feature.client_versions),
375                gateway_versions: feature.gateway_versions.map(protocol_range_from_bridge),
376                negotiated_version: feature.negotiated_version,
377                reason: feature.reason,
378            })
379            .collect(),
380    }
381}
382
383/// A cancellable stream of typed namespace-search events.
384#[derive(Debug)]
385pub struct DriverSearchStream {
386    inner: opcda_bridge::SearchStream,
387}
388
389impl DriverSearchStream {
390    /// Waits for the next event. Dropping the stream cancels the gateway-side search.
391    pub async fn next(&mut self) -> DriverResult<Option<SearchEvent>> {
392        let event = self
393            .inner
394            .message()
395            .await
396            .map_err(|err| map_bridge_error_for(err, "namespace search"))?;
397        event.map(search_event_from_bridge).transpose()
398    }
399}
400
401/// The primary [`Driver`] for v1: reads, writes, and browses OPC DA tags through an
402/// `opcda-bridge` gateway's gRPC API.
403///
404/// Holds its `opcda_bridge::Client` behind a `tokio::sync::Mutex` because the client's own
405/// methods take `&mut self` (it buffers per-call gRPC codec state) while [`Driver`]'s
406/// methods take `&self` (required so a driver can be shared behind `Arc<dyn Driver>`) —
407/// serializing calls through the one connection is a reasonable tradeoff, since a single
408/// tuning session only ever has one read/write/browse in flight at a time, and the
409/// underlying HTTP/2 channel stays cheaply multiplexed regardless of how many logical
410/// callers there are.
411#[derive(Debug)]
412pub struct OpcDaDriver {
413    client: Mutex<opcda_bridge::Client>,
414    server: String,
415}
416
417impl OpcDaDriver {
418    /// Connects to an `opcda-bridge` gateway at `host` (e.g. `"localhost:7600"`) and binds
419    /// to `server` — the OPC DA server's ProgID (e.g. `"Matrikon.OPC.Simulation.1"`) that
420    /// every subsequent `read`/`write`/`browse` call is scoped to.
421    pub async fn connect(host: &str, server: impl Into<String>) -> DriverResult<Self> {
422        let client = opcda_bridge::Client::connect(host)
423            .await
424            .map_err(|err| map_bridge_error_for(err, "connect to OPC DA bridge"))?;
425        Ok(Self {
426            client: Mutex::new(client),
427            server: server.into(),
428        })
429    }
430
431    /// Reports the bridge and OPC server's browse/search capabilities.
432    pub async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
433        let mut client = self.client.lock().await;
434        client
435            .capabilities(self.server.clone())
436            .await
437            .map_err(|err| map_bridge_error_for(err, "capability discovery"))
438            .and_then(capabilities_from_bridge)
439    }
440
441    /// Requests one bounded browse page without following its continuation token.
442    pub async fn browse_page(&self, request: BrowsePageRequest) -> DriverResult<BrowsePage> {
443        let mut client = self.client.lock().await;
444        client
445            .browse_page(opcda_bridge::BrowsePageRequest {
446                server: self.server.clone(),
447                session_id: request.session_id,
448                parent_node_key: request.parent_node_key,
449                page_token: request.page_token,
450                page_size: request.page_size,
451                refresh: request.refresh,
452            })
453            .await
454            .map_err(|err| map_bridge_error_for(err, "paged browse"))
455            .and_then(browse_page_from_bridge)
456    }
457
458    /// Starts a progressive namespace search. Dropping the returned stream cancels the search.
459    pub async fn search_stream(&self, request: SearchRequest) -> DriverResult<DriverSearchStream> {
460        let mut client = self.client.lock().await;
461        let bridge_request = opcda_bridge::SearchRequest {
462            server: self.server.clone(),
463            query: request.query,
464            match_mode: match_search_mode(request.match_mode),
465            session_id: request.session_id,
466            scope_node_key: request.scope_node_key,
467            max_results: request.max_results,
468            include_branches: request.include_branches,
469            refresh: request.refresh,
470        };
471        let inner = client
472            .search_stream(bridge_request)
473            .await
474            .map_err(|err| map_bridge_error_for(err, "namespace search"))?;
475        Ok(DriverSearchStream { inner })
476    }
477
478    /// Collects a complete search stream for callers that do not need progressive delivery.
479    pub async fn search_events(&self, request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
480        let mut stream = self.search_stream(request).await?;
481        let mut events = Vec::new();
482        while let Some(event) = stream.next().await? {
483            events.push(event);
484        }
485        Ok(events)
486    }
487
488    /// Returns the gateway-owned persistent namespace-index status for this OPC server.
489    pub async fn search_index_status(&self) -> DriverResult<SearchIndexStatus> {
490        let mut client = self.client.lock().await;
491        client
492            .search_index_status(self.server.clone())
493            .await
494            .map_err(|err| map_bridge_error_for(err, "indexed-search status"))
495            .map(search_index_status_from_bridge)
496    }
497
498    /// Enables or disables future automatic refreshes for this OPC server's index.
499    pub async fn set_search_index_auto_refresh(
500        &self,
501        enabled: bool,
502    ) -> DriverResult<SearchIndexStatus> {
503        let mut client = self.client.lock().await;
504        client
505            .set_search_index_auto_refresh(self.server.clone(), enabled)
506            .await
507            .map_err(|err| map_bridge_error_for(err, "indexed-search auto-refresh"))
508            .map(search_index_status_from_bridge)
509    }
510
511    /// Deletes this OPC server's persistent namespace index and enrollment.
512    pub async fn delete_search_index(&self) -> DriverResult<SearchIndexStatus> {
513        let mut client = self.client.lock().await;
514        client
515            .delete_search_index(self.server.clone())
516            .await
517            .map_err(|err| map_bridge_error_for(err, "indexed-search delete"))
518            .map(search_index_status_from_bridge)
519    }
520
521    /// Starts or coalesces a persistent namespace-index refresh for this OPC server.
522    pub async fn refresh_search_index(&self, force: bool) -> DriverResult<SearchIndexStatus> {
523        let mut client = self.client.lock().await;
524        client
525            .refresh_search_index(self.server.clone(), force)
526            .await
527            .map_err(|err| map_bridge_error_for(err, "indexed-search refresh"))
528            .map(search_index_status_from_bridge)
529    }
530
531    /// Pauses, resumes, or cancels a persistent namespace-index build.
532    pub async fn control_search_index(
533        &self,
534        action: SearchIndexControlAction,
535    ) -> DriverResult<SearchIndexStatus> {
536        let mut client = self.client.lock().await;
537        client
538            .control_search_index(self.server.clone(), action.into())
539            .await
540            .map_err(|err| map_bridge_error_for(err, "indexed-search control"))
541            .map(search_index_status_from_bridge)
542    }
543
544    /// Queries the gateway-owned persistent namespace index without falling back to live
545    /// namespace traversal.
546    pub async fn search_index_query(
547        &self,
548        request: SearchIndexRequest,
549    ) -> DriverResult<SearchIndexResponse> {
550        let mut client = self.client.lock().await;
551        client
552            .search_index(opcda_bridge::SearchIndexRequest {
553                server: self.server.clone(),
554                query: request.query,
555                match_mode: match_search_mode(request.match_mode),
556                max_results: request.max_results,
557            })
558            .await
559            .map_err(|err| map_bridge_error_for(err, "indexed search"))
560            .map(search_index_response_from_bridge)
561    }
562}
563
564/// Queries gateway-wide version and protocol metadata without contacting an OPC DA server.
565///
566/// This is the appropriate connectivity probe for a newly installed gateway because it works
567/// before an OPC DA server or OPCEnum is installed on the gateway host.
568pub async fn get_opcda_gateway_info(bridge_host: &str) -> DriverResult<OpcDaGatewayInfo> {
569    let mut client = opcda_bridge::Client::connect(bridge_host)
570        .await
571        .map_err(|err| map_bridge_error_for(err, "connect to OPC DA bridge"))?;
572    let info = client
573        .gateway_info()
574        .await
575        .map_err(|err| map_bridge_error_for(err, "gateway information"))?;
576    Ok(OpcDaGatewayInfo {
577        application_version: info.application_version,
578        compatibility_schema_version: info.compatibility_schema_version,
579        features: info
580            .features
581            .into_iter()
582            .map(|support| OpcDaGatewayFeatureSupport {
583                feature: gateway_feature_from_bridge(support.feature),
584                min_version: support.versions.min,
585                max_version: support.versions.max,
586            })
587            .collect(),
588    })
589}
590
591/// The bhtune version whose protocol profile is evaluated against a gateway. Every workspace
592/// crate inherits the same version, so this is bhtune's own release version.
593const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
594
595/// Builds an `Incompatible` report when the gateway rejects a metadata RPC outright.
596///
597/// The published facade reports that rejection as an error rather than a compatibility
598/// status. Inspection still needs a structured result, and live mutations need a refusal, so
599/// the core feature is marked incompatible and the rejected operation is named in each
600/// feature reason.
601fn incompatible_gateway_report(operation: &str) -> OpcDaGatewayCompatibility {
602    let profile = opcda_bridge::current_client_profile(CLIENT_VERSION);
603    let reason = format!("gateway rejected {operation} as incompatible with this client");
604    let features = profile
605        .features
606        .into_iter()
607        .map(|support| {
608            let feature = gateway_feature_from_bridge(support.feature);
609            let status = if matches!(feature, OpcDaGatewayFeature::Core) {
610                OpcDaFeatureCompatibilityStatus::Incompatible
611            } else {
612                OpcDaFeatureCompatibilityStatus::Unknown
613            };
614            OpcDaFeatureCompatibility {
615                feature,
616                status,
617                client_versions: OpcDaProtocolRange {
618                    min: support.versions.min,
619                    max: support.versions.max,
620                },
621                gateway_versions: None,
622                negotiated_version: None,
623                reason: reason.clone(),
624            }
625        })
626        .collect();
627    OpcDaGatewayCompatibility {
628        client_version: CLIENT_VERSION.to_string(),
629        gateway_version: None,
630        source: OpcDaCompatibilitySource::Unknown,
631        status: OpcDaCompatibilityStatus::Incompatible,
632        features,
633    }
634}
635
636/// Checks whether the `opcda-bridge` gateway at `bridge_host` speaks a protocol compatible
637/// with this bhtune build, without reading or writing any OPC DA tag.
638///
639/// Uses the gateway-wide metadata RPC first. A gateway that predates that RPC is evaluated
640/// from the named `server`'s legacy capability metadata when a server is supplied. When that
641/// fallback is also rejected as incompatible, the result is an `Incompatible` report rather
642/// than an error, so inspection can show the refusal and live mutations can refuse it. A
643/// gateway that cannot be classified, and does not reject the client, is reported as
644/// `Unknown`.
645///
646/// # Errors
647///
648/// Returns [`DriverError::Connect`] when the gateway cannot be reached, and
649/// [`DriverError::Operation`] when a metadata RPC fails for a reason other than an
650/// incompatible-gateway rejection.
651pub async fn check_gateway_compatibility(
652    bridge_host: &str,
653    server: Option<&str>,
654) -> DriverResult<OpcDaGatewayCompatibility> {
655    let mut client = opcda_bridge::Client::connect(bridge_host)
656        .await
657        .map_err(|err| map_bridge_error_for(err, "connect to OPC DA bridge"))?;
658    match client
659        .compatibility_with_client_version(server, CLIENT_VERSION)
660        .await
661    {
662        Ok(report) => Ok(gateway_compatibility_from_report(report)),
663        Err(opcda_bridge::Error::IncompatibleGateway { operation }) => {
664            Ok(incompatible_gateway_report(operation))
665        }
666        Err(err) => Err(map_bridge_error_for(err, "gateway compatibility")),
667    }
668}
669
670/// Lists the OPC DA servers registered on the `opcda-bridge` gateway's own host at
671/// `bridge_host` (e.g. `"localhost:7600"`).
672///
673/// A standalone free function rather than a [`Driver`] method or an [`OpcDaDriver`]
674/// associated function: server discovery is a *pre-connection* operation — it needs only a
675/// bridge host, not the OPC DA server ProgID that [`OpcDaDriver::connect`] requires and that
676/// discovery exists to help a caller find in the first place. Connects for the one call and
677/// drops the connection immediately afterward; unlike `OpcDaDriver`, there is no ongoing
678/// session to hold open here.
679///
680/// Note: `opcda_bridge::Client::list_servers` always sends `host: "localhost"` in its
681/// request — i.e. it lists servers registered on *the gateway's own* machine, not on
682/// whatever machine bhtune itself happens to run on. That is exactly right for this
683/// topology (the gateway runs next to the OPC DA server; bhtune runs wherever the
684/// engineer's browser or scheduler is) and is called out here so it is never mistaken for a
685/// bug.
686pub async fn list_opcda_servers(bridge_host: &str) -> DriverResult<Vec<String>> {
687    let mut client = opcda_bridge::Client::connect(bridge_host)
688        .await
689        .map_err(|err| map_bridge_error_for(err, "connect to OPC DA bridge"))?;
690    client
691        .list_servers()
692        .await
693        .map_err(|err| map_bridge_error_for(err, "list OPC DA servers"))
694}
695
696/// Explicitly releases one gateway-side browse session without requiring an OPC server
697/// ProgID. The session ID is the only value the bridge close RPC needs, so this is separate
698/// from [`OpcDaDriver`] for callers such as the CLI's `opc close` command that may no longer
699/// have the server name handy.
700pub async fn close_opcda_browse_session(bridge_host: &str, session_id: &str) -> DriverResult<()> {
701    let mut client = opcda_bridge::Client::connect(bridge_host)
702        .await
703        .map_err(|err| map_bridge_error_for(err, "connect to OPC DA bridge"))?;
704    client
705        .close_browse_session(session_id)
706        .await
707        .map_err(|err| map_bridge_error_for(err, "browse-session close"))
708}
709
710#[async_trait]
711impl Driver for OpcDaDriver {
712    async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
713        let mut client = self.client.lock().await;
714        let raw = client
715            .read(self.server.clone(), tags.to_vec())
716            .await
717            .map_err(|err| map_bridge_error_for(err, "read OPC DA tags"))?;
718        Ok(raw.into_iter().map(tag_value_from_raw).collect())
719    }
720
721    async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
722        let mut client = self.client.lock().await;
723        let result = client
724            .write(
725                self.server.clone(),
726                tag.clone(),
727                opc_value_from_write(value),
728            )
729            .await
730            .map_err(|err| map_bridge_error_for(err, "write OPC DA tag"))?;
731        Ok(write_outcome_from_result(result))
732    }
733
734    async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
735        self.capabilities().await
736    }
737
738    async fn browse(&self, request: BrowsePageRequest) -> DriverResult<BrowsePage> {
739        self.browse_page(request).await
740    }
741
742    async fn close_browse_session(&self, session_id: &str) -> DriverResult<()> {
743        let mut client = self.client.lock().await;
744        client
745            .close_browse_session(session_id)
746            .await
747            .map_err(|err| map_bridge_error_for(err, "browse-session close"))
748    }
749
750    async fn search(&self, request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
751        self.search_events(request).await
752    }
753
754    async fn search_index_status(&self) -> DriverResult<SearchIndexStatus> {
755        self.search_index_status().await
756    }
757
758    async fn refresh_search_index(&self, force: bool) -> DriverResult<SearchIndexStatus> {
759        self.refresh_search_index(force).await
760    }
761
762    async fn control_search_index(
763        &self,
764        action: SearchIndexControlAction,
765    ) -> DriverResult<SearchIndexStatus> {
766        self.control_search_index(action).await
767    }
768
769    async fn set_search_index_auto_refresh(
770        &self,
771        enabled: bool,
772    ) -> DriverResult<SearchIndexStatus> {
773        self.set_search_index_auto_refresh(enabled).await
774    }
775
776    async fn delete_search_index(&self) -> DriverResult<SearchIndexStatus> {
777        self.delete_search_index().await
778    }
779
780    async fn search_index(&self, request: SearchIndexRequest) -> DriverResult<SearchIndexResponse> {
781        self.search_index_query(request).await
782    }
783}
784
785/// Maps `opcda_bridge`'s raw OPC quality string to [`Quality`].
786///
787/// The gateway reports one of `"Good"`, `"Bad"`, `"Uncertain"`, or a synthesized
788/// `"Unknown(0xNNNN)"` for an OPC quality code it doesn't otherwise recognize. Any string
789/// other than an exact `"Good"`/`"Uncertain"` match — including that
790/// `"Unknown(...)"` case — is treated as [`Quality::Bad`]: an unrecognized quality is
791/// exactly the situation where guessing "trustworthy" would be the wrong default.
792pub fn quality_from_raw(raw: &str) -> Quality {
793    match raw {
794        "Good" => Quality::Good,
795        "Uncertain" => Quality::Uncertain,
796        _ => Quality::Bad,
797    }
798}
799
800/// Maps one `opcda_bridge::TagValue` (a single tag's raw read result) to this crate's
801/// [`TagValue`].
802pub fn tag_value_from_raw(raw: opcda_bridge::TagValue) -> TagValue {
803    TagValue {
804        tag: raw.tag_id,
805        value: raw.value,
806        quality: quality_from_raw(&raw.quality),
807        // The gateway reports each tag's last-change time as a *local*, offset-less
808        // "YYYY-MM-DD HH:MM:SS" string (or a "N/A"/"Invalid" sentinel for tags that have
809        // none). There is no reliable way to convert that into a trustworthy `DateTime<Utc>`
810        // without knowing the gateway
811        // host's timezone, which isn't part of the bridge protocol and can't safely be
812        // assumed to match wherever `bhtune` itself runs — so this is always `None` rather
813        // than a guess. Purely diagnostic regardless (see `TagValue::timestamp`'s doc
814        // comment): never the tick time the tuning engine itself runs on.
815        timestamp: None,
816    }
817}
818
819/// Maps a [`TagWrite`] to the `opcda_bridge::Value` its `Client::write` expects.
820pub fn opc_value_from_write(write: TagWrite) -> opcda_bridge::Value {
821    match write {
822        TagWrite::Float(f) => opcda_bridge::Value::Float(f64::from(f)),
823        TagWrite::Raw(s) => opcda_bridge::Value::String(s),
824    }
825}
826
827/// Maps an `opcda_bridge::WriteResult` (the RPC-level outcome of one write) to
828/// [`WriteOutcome`].
829pub fn write_outcome_from_result(result: opcda_bridge::WriteResult) -> WriteOutcome {
830    if result.success {
831        WriteOutcome::success()
832    } else {
833        WriteOutcome::failure(
834            result
835                .error
836                .unwrap_or_else(|| "gateway rejected the write".to_string()),
837        )
838    }
839}
840
841/// Maps the bridge's capabilities into the protocol-neutral driver model.
842pub fn capabilities_from_bridge(
843    capabilities: opcda_bridge::Capabilities,
844) -> DriverResult<DriverCapabilities> {
845    Ok(DriverCapabilities {
846        application_version: capabilities.application_version,
847        protocol_version: capabilities.protocol_version,
848        max_page_size: capabilities.max_page_size,
849        supports_browse_sessions: capabilities.supports_browse_sessions,
850        supports_search: capabilities.supports_search,
851        organization: namespace_organization_from_bridge(capabilities.organization),
852        source: browse_source_from_bridge(capabilities.source),
853        supports_indexed_search: capabilities.supports_indexed_search,
854        indexed_search_protocol_version: capabilities.indexed_search_protocol_version,
855        max_indexed_search_results: capabilities.max_indexed_search_results,
856        search_index_state: search_index_state_from_bridge(capabilities.search_index_state),
857    })
858}
859
860pub fn search_index_state_from_bridge(state: opcda_bridge::SearchIndexState) -> SearchIndexState {
861    match state {
862        opcda_bridge::SearchIndexState::Unspecified => SearchIndexState::Unspecified,
863        opcda_bridge::SearchIndexState::NotIndexed => SearchIndexState::NotIndexed,
864        opcda_bridge::SearchIndexState::Partial => SearchIndexState::Partial,
865        opcda_bridge::SearchIndexState::Ready => SearchIndexState::Ready,
866        opcda_bridge::SearchIndexState::Stale => SearchIndexState::Stale,
867        opcda_bridge::SearchIndexState::Refreshing => SearchIndexState::Refreshing,
868        opcda_bridge::SearchIndexState::Promoting => SearchIndexState::Promoting,
869        opcda_bridge::SearchIndexState::Failed => SearchIndexState::Failed,
870        opcda_bridge::SearchIndexState::Deleting => SearchIndexState::Deleting,
871    }
872}
873
874pub fn indexed_search_progress_from_bridge(
875    progress: opcda_bridge::IndexedSearchProgress,
876) -> IndexedSearchProgress {
877    IndexedSearchProgress {
878        branches_visited: progress.branches_visited,
879        entries_seen: progress.entries_seen,
880        unique_items: progress.unique_items,
881        active_time_ms: progress.active_time_ms,
882        paused_time_ms: progress.paused_time_ms,
883        items_per_second: progress.items_per_second,
884        estimated_remaining_ms: progress.estimated_remaining_ms,
885    }
886}
887
888pub fn search_index_status_from_bridge(
889    status: opcda_bridge::SearchIndexStatus,
890) -> SearchIndexStatus {
891    SearchIndexStatus {
892        server: status.server,
893        state: search_index_state_from_bridge(status.state),
894        auto_refresh_enabled: status.auto_refresh_enabled,
895        active_generation: status.active_generation,
896        entry_count: status.entry_count,
897        unique_item_count: status.unique_item_count,
898        started_at: status.started_at,
899        completed_at: status.completed_at,
900        last_error: status.last_error,
901        database_bytes: status.database_bytes,
902        organization: namespace_organization_from_bridge(status.organization),
903        source: browse_source_from_bridge(status.source),
904        progress: status.progress.map(indexed_search_progress_from_bridge),
905        scheduler: IndexSchedulerDiagnostics {
906            next_refresh_at: status.scheduler.next_refresh_at,
907            last_attempt_at: status.scheduler.last_attempt_at,
908            last_success_at: status.scheduler.last_success_at,
909            last_success_duration_ms: status.scheduler.last_success_duration_ms,
910            retry_after: status.scheduler.retry_after,
911            consecutive_failures: status.scheduler.consecutive_failures,
912            circuit_open: status.scheduler.circuit_open,
913        },
914    }
915}
916
917pub fn indexed_search_match_from_bridge(
918    found: opcda_bridge::IndexedSearchMatch,
919) -> IndexedSearchMatch {
920    IndexedSearchMatch {
921        item_id: found.item_id,
922        display_name: found.display_name,
923        kind: match found.kind {
924            opcda_bridge::BrowseNodeKind::Unspecified => BrowseNodeKind::Unspecified,
925            opcda_bridge::BrowseNodeKind::Branch => BrowseNodeKind::Branch,
926            opcda_bridge::BrowseNodeKind::Item => BrowseNodeKind::Item,
927            opcda_bridge::BrowseNodeKind::BranchAndItem => BrowseNodeKind::BranchAndItem,
928        },
929        breadcrumbs: found.breadcrumbs,
930    }
931}
932
933pub fn search_index_response_from_bridge(
934    response: opcda_bridge::SearchIndexResponse,
935) -> SearchIndexResponse {
936    SearchIndexResponse {
937        matches: response
938            .matches
939            .into_iter()
940            .map(indexed_search_match_from_bridge)
941            .collect(),
942        has_more: response.has_more,
943        status: search_index_status_from_bridge(response.status),
944    }
945}
946
947impl From<SearchIndexControlAction> for opcda_bridge::SearchIndexControlAction {
948    fn from(action: SearchIndexControlAction) -> Self {
949        match action {
950            SearchIndexControlAction::Pause => Self::Pause,
951            SearchIndexControlAction::Resume => Self::Resume,
952            SearchIndexControlAction::Cancel => Self::Cancel,
953        }
954    }
955}
956
957pub fn namespace_organization_from_bridge(
958    organization: opcda_bridge::NamespaceOrganization,
959) -> NamespaceOrganization {
960    match organization {
961        opcda_bridge::NamespaceOrganization::Unspecified => NamespaceOrganization::Unspecified,
962        opcda_bridge::NamespaceOrganization::Flat => NamespaceOrganization::Flat,
963        opcda_bridge::NamespaceOrganization::Hierarchical => NamespaceOrganization::Hierarchical,
964    }
965}
966
967pub fn browse_source_from_bridge(source: opcda_bridge::BrowseSource) -> BrowseSource {
968    match source {
969        opcda_bridge::BrowseSource::Unspecified => BrowseSource::Unspecified,
970        opcda_bridge::BrowseSource::Da3 => BrowseSource::Da3,
971        opcda_bridge::BrowseSource::Da2 => BrowseSource::Da2,
972        opcda_bridge::BrowseSource::Flat => BrowseSource::Flat,
973        opcda_bridge::BrowseSource::Derived => BrowseSource::Derived,
974    }
975}
976
977pub fn browse_node_from_bridge(node: opcda_bridge::BrowseNode) -> BrowseNode {
978    BrowseNode {
979        node_key: node.node_key,
980        display_name: node.display_name,
981        kind: match node.kind {
982            opcda_bridge::BrowseNodeKind::Unspecified => BrowseNodeKind::Unspecified,
983            opcda_bridge::BrowseNodeKind::Branch => BrowseNodeKind::Branch,
984            opcda_bridge::BrowseNodeKind::Item => BrowseNodeKind::Item,
985            opcda_bridge::BrowseNodeKind::BranchAndItem => BrowseNodeKind::BranchAndItem,
986        },
987        item_id: node.item_id,
988    }
989}
990
991pub fn browse_page_from_bridge(page: opcda_bridge::BrowsePage) -> DriverResult<BrowsePage> {
992    Ok(BrowsePage {
993        session_id: page.session_id,
994        nodes: page
995            .nodes
996            .into_iter()
997            .map(browse_node_from_bridge)
998            .collect(),
999        next_page_token: page.next_page_token,
1000        complete: page.complete,
1001        organization: namespace_organization_from_bridge(page.organization),
1002        source: browse_source_from_bridge(page.source),
1003        warning: page.warning,
1004    })
1005}
1006
1007pub fn search_event_from_bridge(event: opcda_bridge::SearchEvent) -> DriverResult<SearchEvent> {
1008    match event {
1009        opcda_bridge::SearchEvent::Match(found) => Ok(SearchEvent::Match(SearchMatch {
1010            node: browse_node_from_bridge(found.node),
1011            breadcrumbs: found
1012                .breadcrumbs
1013                .into_iter()
1014                .map(|part| BrowseBreadcrumb {
1015                    node_key: part.node_key,
1016                    display_name: part.display_name,
1017                })
1018                .collect(),
1019        })),
1020        opcda_bridge::SearchEvent::Progress(progress) => {
1021            Ok(SearchEvent::Progress(SearchProgress {
1022                visited_nodes: progress.visited_nodes,
1023                matches: progress.matches,
1024                partial: progress.partial,
1025            }))
1026        }
1027        opcda_bridge::SearchEvent::Completed(completed) => {
1028            Ok(SearchEvent::Completed(SearchCompleted {
1029                complete: completed.complete,
1030                cancelled: completed.cancelled,
1031                truncated: completed.truncated,
1032                warning: completed.warning,
1033            }))
1034        }
1035    }
1036}
1037
1038fn match_search_mode(mode: SearchMatchMode) -> opcda_bridge::SearchMatchMode {
1039    match mode {
1040        SearchMatchMode::Exact => opcda_bridge::SearchMatchMode::Exact,
1041        SearchMatchMode::Prefix => opcda_bridge::SearchMatchMode::Prefix,
1042        SearchMatchMode::Contains => opcda_bridge::SearchMatchMode::Contains,
1043    }
1044}
1045
1046/// Maps every `opcda_bridge::Error` this driver can encounter — at connect time or during
1047/// any RPC — to the matching [`DriverError`] variant: `opcda_bridge::Error::Connect` (the
1048/// gRPC channel itself couldn't be established) becomes [`DriverError::Connect`], and
1049/// `opcda_bridge::Error::Rpc` (the channel is fine, but the gateway returned a gRPC error
1050/// for this specific call) becomes [`DriverError::Operation`], except for an indexed-search
1051/// `FailedPrecondition`, which becomes [`DriverError::IndexOperationRejected`] so gateway
1052/// configuration and concurrency diagnostics remain actionable. An exhaustive match rather
1053/// than a wildcard arm, deliberately: if `opcda_bridge::Error` ever gains a new variant,
1054/// this should fail to compile and force a real decision about where it belongs, not
1055/// silently fall into one bucket.
1056fn map_bridge_error_for(err: opcda_bridge::Error, operation: &'static str) -> DriverError {
1057    match &err {
1058        opcda_bridge::Error::Connect(_) => DriverError::Connect(Box::new(err)),
1059        opcda_bridge::Error::Rpc(status)
1060            if is_indexed_search_operation(operation)
1061                && matches!(
1062                    status.code(),
1063                    tonic::Code::InvalidArgument
1064                        | tonic::Code::NotFound
1065                        | tonic::Code::AlreadyExists
1066                        | tonic::Code::FailedPrecondition
1067                ) =>
1068        {
1069            DriverError::IndexOperationRejected {
1070                message: status.message().to_string(),
1071            }
1072        }
1073        opcda_bridge::Error::Rpc(status)
1074            if (operation == "paged browse" || operation == "browse-session close")
1075                && matches!(
1076                    status.code(),
1077                    tonic::Code::NotFound | tonic::Code::FailedPrecondition
1078                ) =>
1079        {
1080            DriverError::BrowseStateInvalid
1081        }
1082        opcda_bridge::Error::UnknownIndexServer { .. }
1083        | opcda_bridge::Error::IndexNotEnrolled { .. }
1084        | opcda_bridge::Error::IndexDeleting { .. } => DriverError::IndexOperationRejected {
1085            message: err.to_string(),
1086        },
1087        opcda_bridge::Error::IncompatibleGateway { .. } => {
1088            DriverError::IncompatibleGateway { operation }
1089        }
1090        opcda_bridge::Error::Rpc(_) | opcda_bridge::Error::Protocol(_) => {
1091            DriverError::Operation(Box::new(err))
1092        }
1093    }
1094}
1095
1096fn is_indexed_search_operation(operation: &str) -> bool {
1097    matches!(
1098        operation,
1099        "indexed-search status"
1100            | "indexed-search refresh"
1101            | "indexed-search control"
1102            | "indexed-search auto-refresh"
1103            | "indexed-search delete"
1104            | "indexed search"
1105    )
1106}
1107
1108#[cfg(test)]
1109mod tests;
1110
1111/// End-to-end smoke tests against a minimal mock `Bridge` gRPC service. These prove the typed
1112/// page/session/search API is wired together correctly without re-testing the bridge's own RPC
1113/// implementation.
1114#[cfg(test)]
1115mod smoke_tests;