Skip to main content

bhtune_driver/
driver.rs

1//! The `Driver` trait: bhtune's single seam for all tag I/O.
2
3use async_trait::async_trait;
4
5use crate::{
6    error::DriverResult,
7    types::{
8        BrowsePage, BrowsePageRequest, DriverCapabilities, SearchEvent, SearchIndexControlAction,
9        SearchIndexRequest, SearchIndexResponse, SearchIndexStatus, SearchRequest, TagId, TagValue,
10        TagWrite, WriteOutcome,
11    },
12};
13
14/// Abstracts all tag I/O so `bhtune-core`'s tuning engine never knows what it's talking to.
15///
16/// `OpcDaDriver` (via the `opcda-bridge` crate) is the primary driver for v1.
17/// `SimulatorDriver` (an in-process FOPDT process model) and `ReplayDriver` (feeding back a
18/// recorded golden-master trace) implement this same trait for CI/demo and validation
19/// respectively. `OpcUaDriver`/`ModbusDriver` are roadmap items expected to slot in later
20/// without requiring any change to this trait or to `bhtune-core` — that's the entire point
21/// of the seam.
22///
23/// `Send + Sync` so an implementation can be held behind `Arc<dyn Driver>` and shared across
24/// async tasks (e.g. a CLI run and a concurrent history-retention sweep in the same process).
25/// Connecting/constructing a specific driver (host/port, OPC DA server name, a trace file
26/// path, simulator parameters) is deliberately *not* part of this trait — each
27/// implementation's own inherent constructor takes whatever it individually needs, since
28/// forcing one uniform "connect" signature across such different drivers would either be
29/// meaningless for some of them or leak implementation-specific parameters into the trait
30/// every other implementation would have to ignore.
31#[async_trait]
32pub trait Driver: Send + Sync {
33    /// Reads the current value of every tag in `tags`, in one batched call where the driver
34    /// supports it (a single OPC DA `Read` RPC for all of them, rather than one round trip per
35    /// tag). Returns exactly one [`TagValue`] per requested tag, in the same order as `tags`.
36    ///
37    /// A tag with genuinely bad or uncertain quality is still `Ok` — quality is data about
38    /// the reading, not a failure of the read itself — reserving `Err` for the read call not
39    /// reaching the driver at all, or the driver rejecting the request outright (e.g. an
40    /// unrecognized tag name).
41    async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>>;
42
43    /// Writes `value` to `tag`.
44    ///
45    /// Returns `Ok(WriteOutcome)` even when the driver rejects the write itself (read-only
46    /// tag, out-of-range value, permissions) — that is a normal, expected result of the
47    /// write reaching the driver, not an I/O failure. `Err` is reserved for the write call
48    /// not reaching the driver at all.
49    async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome>;
50
51    /// Reports the namespace capabilities of this driver/server pair.
52    async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
53        Err(crate::error::DriverError::Unsupported {
54            operation: "capabilities",
55        })
56    }
57
58    /// Lists one bounded page of immediate children. Navigation uses opaque session, node, and
59    /// continuation values returned by the driver; callers must not infer hierarchy by parsing
60    /// punctuation in an ItemID.
61    async fn browse(&self, request: BrowsePageRequest) -> DriverResult<BrowsePage>;
62
63    /// Releases a server-side browse session.
64    async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
65        Err(crate::error::DriverError::Unsupported {
66            operation: "browse-session close",
67        })
68    }
69
70    /// Collects a bounded namespace search. Drivers that support progressive search may expose
71    /// a richer stream through their concrete type; this method is the portable trait surface.
72    async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
73        Err(crate::error::DriverError::Unsupported {
74            operation: "search",
75        })
76    }
77
78    /// Reports the gateway-owned persistent namespace-index status.
79    async fn search_index_status(&self) -> DriverResult<SearchIndexStatus> {
80        Err(crate::error::DriverError::Unsupported {
81            operation: "indexed-search status",
82        })
83    }
84
85    /// Starts or coalesces a persistent namespace-index refresh.
86    async fn refresh_search_index(&self, _force: bool) -> DriverResult<SearchIndexStatus> {
87        Err(crate::error::DriverError::Unsupported {
88            operation: "indexed-search refresh",
89        })
90    }
91
92    /// Pauses, resumes, or cancels a persistent namespace-index build.
93    async fn control_search_index(
94        &self,
95        _action: SearchIndexControlAction,
96    ) -> DriverResult<SearchIndexStatus> {
97        Err(crate::error::DriverError::Unsupported {
98            operation: "indexed-search control",
99        })
100    }
101
102    /// Enables or disables future automatic refreshes for this server's index.
103    async fn set_search_index_auto_refresh(
104        &self,
105        _enabled: bool,
106    ) -> DriverResult<SearchIndexStatus> {
107        Err(crate::error::DriverError::Unsupported {
108            operation: "indexed-search auto-refresh",
109        })
110    }
111
112    /// Deletes this server's persistent namespace index and enrollment.
113    async fn delete_search_index(&self) -> DriverResult<SearchIndexStatus> {
114        Err(crate::error::DriverError::Unsupported {
115            operation: "indexed-search delete",
116        })
117    }
118
119    /// Queries the gateway-owned persistent namespace index.
120    async fn search_index(
121        &self,
122        _request: SearchIndexRequest,
123    ) -> DriverResult<SearchIndexResponse> {
124        Err(crate::error::DriverError::Unsupported {
125            operation: "indexed search",
126        })
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::{error::DriverError, types::Quality};
134    use chrono::{TimeZone, Utc};
135    use std::sync::Mutex;
136
137    struct BareDriver;
138
139    #[async_trait]
140    impl Driver for BareDriver {
141        async fn read(&self, _tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
142            Ok(Vec::new())
143        }
144
145        async fn write(&self, _tag: &TagId, _value: TagWrite) -> DriverResult<WriteOutcome> {
146            Ok(WriteOutcome::success())
147        }
148
149        async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
150            Ok(BrowsePage {
151                session_id: "s".into(),
152                nodes: Vec::new(),
153                next_page_token: None,
154                complete: true,
155                organization: crate::types::NamespaceOrganization::Unspecified,
156                source: crate::types::BrowseSource::Unspecified,
157                warning: None,
158            })
159        }
160    }
161
162    /// A minimal in-memory `Driver` used only to prove the trait itself is usable: object-safe
163    /// (`Box<dyn Driver>`), async-dispatchable, and that its methods compose the way real
164    /// callers (a future `driver-opcda`/`driver-simulator`) will need.
165    struct MockDriver {
166        values: std::collections::HashMap<TagId, (String, Quality)>,
167        writes: Mutex<Vec<(TagId, TagWrite)>>,
168    }
169
170    #[async_trait]
171    impl Driver for MockDriver {
172        async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
173            let timestamp = Some(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap());
174            tags.iter()
175                .map(|tag| {
176                    let (value, quality) = self.values.get(tag).cloned().ok_or_else(|| {
177                        DriverError::InvalidTagValue {
178                            tag: tag.clone(),
179                            message: "unknown tag".to_string(),
180                        }
181                    })?;
182                    Ok(TagValue {
183                        tag: tag.clone(),
184                        value,
185                        quality,
186                        timestamp,
187                    })
188                })
189                .collect()
190        }
191
192        async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
193            self.writes.lock().unwrap().push((tag.clone(), value));
194            Ok(WriteOutcome::success())
195        }
196
197        async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
198            Ok(DriverCapabilities {
199                application_version: "test".into(),
200                protocol_version: "test".into(),
201                max_page_size: 200,
202                supports_browse_sessions: true,
203                supports_search: true,
204                organization: crate::types::NamespaceOrganization::Hierarchical,
205                source: crate::types::BrowseSource::Derived,
206                supports_indexed_search: false,
207                indexed_search_protocol_version: String::new(),
208                max_indexed_search_results: 0,
209                search_index_state: crate::types::SearchIndexState::Unspecified,
210            })
211        }
212
213        async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
214            Err(DriverError::Unsupported {
215                operation: "browse",
216            })
217        }
218
219        async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
220            Err(DriverError::Unsupported {
221                operation: "browse-session close",
222            })
223        }
224
225        async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
226            Err(DriverError::Unsupported {
227                operation: "search",
228            })
229        }
230    }
231
232    fn mock() -> MockDriver {
233        let mut values = std::collections::HashMap::new();
234        values.insert(
235            "Area1.LIC101.PV".to_string(),
236            ("42.5".to_string(), Quality::Good),
237        );
238        values.insert(
239            "Area1.LIC101.MODE".to_string(),
240            ("MAN".to_string(), Quality::Uncertain),
241        );
242        MockDriver {
243            values,
244            writes: Mutex::new(Vec::new()),
245        }
246    }
247
248    #[tokio::test]
249    async fn reads_multiple_tags_in_requested_order() {
250        let driver = mock();
251        let tags = vec![
252            "Area1.LIC101.MODE".to_string(),
253            "Area1.LIC101.PV".to_string(),
254        ];
255        let values = driver.read(&tags).await.unwrap();
256        assert_eq!(values.len(), 2);
257        assert_eq!(values[0].tag, "Area1.LIC101.MODE");
258        assert_eq!(values[0].value, "MAN");
259        assert_eq!(values[0].quality, Quality::Uncertain);
260        assert_eq!(values[1].tag, "Area1.LIC101.PV");
261        assert_eq!(values[1].value, "42.5");
262        assert_eq!(values[1].quality, Quality::Good);
263    }
264
265    #[tokio::test]
266    async fn default_trait_operations_report_unsupported() {
267        let driver = BareDriver;
268        assert!(driver.read(&[]).await.unwrap().is_empty());
269        assert!(
270            driver
271                .write(&"MV".to_string(), TagWrite::Float(1.0))
272                .await
273                .unwrap()
274                .success
275        );
276        assert_eq!(
277            driver
278                .browse(BrowsePageRequest::root(1))
279                .await
280                .unwrap()
281                .session_id,
282            "s"
283        );
284        assert!(matches!(
285            driver.capabilities().await,
286            Err(DriverError::Unsupported {
287                operation: "capabilities"
288            })
289        ));
290        assert!(matches!(
291            driver.close_browse_session("session").await,
292            Err(DriverError::Unsupported {
293                operation: "browse-session close"
294            })
295        ));
296        assert!(matches!(
297            driver
298                .search(SearchRequest {
299                    query: "PV".into(),
300                    match_mode: crate::types::SearchMatchMode::Contains,
301                    session_id: None,
302                    scope_node_key: None,
303                    max_results: 10,
304                    include_branches: false,
305                    refresh: false,
306                })
307                .await,
308            Err(DriverError::Unsupported {
309                operation: "search"
310            })
311        ));
312        assert!(matches!(
313            driver.search_index_status().await,
314            Err(DriverError::Unsupported {
315                operation: "indexed-search status"
316            })
317        ));
318        assert!(matches!(
319            driver.refresh_search_index(false).await,
320            Err(DriverError::Unsupported {
321                operation: "indexed-search refresh"
322            })
323        ));
324        assert!(matches!(
325            driver
326                .control_search_index(crate::types::SearchIndexControlAction::Pause)
327                .await,
328            Err(DriverError::Unsupported {
329                operation: "indexed-search control"
330            })
331        ));
332        assert!(matches!(
333            driver.set_search_index_auto_refresh(false).await,
334            Err(DriverError::Unsupported {
335                operation: "indexed-search auto-refresh"
336            })
337        ));
338        assert!(matches!(
339            driver.delete_search_index().await,
340            Err(DriverError::Unsupported {
341                operation: "indexed-search delete"
342            })
343        ));
344        assert!(matches!(
345            driver
346                .search_index(crate::types::SearchIndexRequest::new(
347                    "PV",
348                    crate::types::SearchMatchMode::Exact,
349                    1,
350                ))
351                .await,
352            Err(DriverError::Unsupported {
353                operation: "indexed search"
354            })
355        ));
356    }
357
358    #[tokio::test]
359    async fn mock_driver_capabilities_and_explicit_unsupported_operations_are_callable() {
360        let driver = mock();
361        let capabilities = <MockDriver as Driver>::capabilities(&driver).await.unwrap();
362        assert_eq!(capabilities.application_version, "test");
363        assert!(matches!(
364            <MockDriver as Driver>::browse(&driver, BrowsePageRequest::root(1)).await,
365            Err(DriverError::Unsupported {
366                operation: "browse"
367            })
368        ));
369        assert!(matches!(
370            <MockDriver as Driver>::close_browse_session(&driver, "session").await,
371            Err(DriverError::Unsupported {
372                operation: "browse-session close"
373            })
374        ));
375        assert!(matches!(
376            <MockDriver as Driver>::search(
377                &driver,
378                SearchRequest {
379                    query: "PV".into(),
380                    match_mode: crate::types::SearchMatchMode::Exact,
381                    session_id: None,
382                    scope_node_key: None,
383                    max_results: 1,
384                    include_branches: false,
385                    refresh: false,
386                },
387            )
388            .await,
389            Err(DriverError::Unsupported {
390                operation: "search"
391            })
392        ));
393    }
394
395    #[tokio::test]
396    async fn reading_an_unknown_tag_is_invalid_tag_value_not_a_panic() {
397        let driver = mock();
398        let err = driver
399            .read(&["Nonexistent.Tag".to_string()])
400            .await
401            .unwrap_err();
402        assert!(matches!(err, DriverError::InvalidTagValue { .. }));
403    }
404
405    #[tokio::test]
406    async fn write_records_the_call_and_reports_success() {
407        let driver = mock();
408        let outcome = driver
409            .write(&"Area1.LIC101.MV".to_string(), TagWrite::Float(55.0))
410            .await
411            .unwrap();
412        assert!(outcome.success);
413        assert_eq!(
414            driver.writes.lock().unwrap().as_slice(),
415            &[("Area1.LIC101.MV".to_string(), TagWrite::Float(55.0))]
416        );
417    }
418
419    #[tokio::test]
420    async fn write_supports_raw_mode_values_for_mode_revert() {
421        let driver = mock();
422        driver
423            .write(
424                &"Area1.LIC101.MODE".to_string(),
425                TagWrite::Raw("AUT".into()),
426            )
427            .await
428            .unwrap();
429        assert_eq!(
430            driver.writes.lock().unwrap().as_slice(),
431            &[("Area1.LIC101.MODE".to_string(), TagWrite::Raw("AUT".into()))]
432        );
433    }
434
435    #[tokio::test]
436    async fn browse_returns_unsupported_when_driver_has_no_tag_tree() {
437        let err = mock()
438            .browse(BrowsePageRequest::root(20))
439            .await
440            .unwrap_err();
441        assert!(matches!(
442            err,
443            DriverError::Unsupported {
444                operation: "browse"
445            }
446        ));
447    }
448
449    #[tokio::test]
450    async fn trait_is_object_safe_and_usable_through_a_trait_object() {
451        // The real point of this test: it must compile. `Box<dyn Driver>` is exactly the
452        // shape a future config-driven "pick a driver at runtime" call site needs.
453        let driver: Box<dyn Driver> = Box::new(mock());
454        let values = driver.read(&["Area1.LIC101.PV".to_string()]).await.unwrap();
455        assert_eq!(values[0].value, "42.5");
456    }
457}