1use 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
26pub const DEFAULT_PAGE_SIZE: u32 = opcda_bridge::DEFAULT_PAGE_SIZE;
29
30pub const DEFAULT_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_SEARCH_MAX_RESULTS;
32
33pub const DEFAULT_INDEX_SEARCH_MAX_RESULTS: u32 = opcda_bridge::DEFAULT_INDEX_SEARCH_MAX_RESULTS;
35
36#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct OpcDaProtocolRange {
75 pub min: u32,
77 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum OpcDaCompatibilityStatus {
91 Full,
93 Partial,
95 Incompatible,
97 Unknown,
99}
100
101impl OpcDaCompatibilityStatus {
102 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum OpcDaCompatibilitySource {
118 GatewayInfo,
120 LegacyCapabilities,
122 Unknown,
124}
125
126impl OpcDaCompatibilitySource {
127 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum OpcDaFeatureCompatibilityStatus {
142 Compatible,
144 Unsupported,
146 Incompatible,
148 Unknown,
150}
151
152impl OpcDaFeatureCompatibilityStatus {
153 #[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167pub struct OpcDaFeatureCompatibility {
168 pub feature: OpcDaGatewayFeature,
170 pub status: OpcDaFeatureCompatibilityStatus,
172 pub client_versions: OpcDaProtocolRange,
174 pub gateway_versions: Option<OpcDaProtocolRange>,
176 pub negotiated_version: Option<u32>,
178 pub reason: String,
180}
181
182impl OpcDaFeatureCompatibility {
183 #[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
204pub struct OpcDaGatewayCompatibility {
205 pub client_version: String,
207 pub gateway_version: Option<String>,
209 pub source: OpcDaCompatibilitySource,
211 pub status: OpcDaCompatibilityStatus,
213 pub features: Vec<OpcDaFeatureCompatibility>,
215}
216
217impl OpcDaGatewayCompatibility {
218 #[must_use]
220 pub const fn is_incompatible(&self) -> bool {
221 matches!(self.status, OpcDaCompatibilityStatus::Incompatible)
222 }
223
224 #[must_use]
226 pub fn live_mutation_refusal(&self) -> Option<String> {
227 self.is_incompatible()
228 .then(|| self.incompatibility_message())
229 }
230
231 #[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 #[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 #[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#[derive(Debug)]
385pub struct DriverSearchStream {
386 inner: opcda_bridge::SearchStream,
387}
388
389impl DriverSearchStream {
390 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#[derive(Debug)]
412pub struct OpcDaDriver {
413 client: Mutex<opcda_bridge::Client>,
414 server: String,
415}
416
417impl OpcDaDriver {
418 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 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 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 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 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 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 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 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 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 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 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
564pub 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
591const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
594
595fn 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
636pub 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
670pub 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
696pub 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
785pub fn quality_from_raw(raw: &str) -> Quality {
793 match raw {
794 "Good" => Quality::Good,
795 "Uncertain" => Quality::Uncertain,
796 _ => Quality::Bad,
797 }
798}
799
800pub 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 timestamp: None,
816 }
817}
818
819pub 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
827pub 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
841pub 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
1046fn 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#[cfg(test)]
1115mod smoke_tests;