Skip to main content

bhtune_driver/
types.rs

1//! Plain data types moved across the [`crate::Driver`] trait boundary.
2
3use chrono::{DateTime, Utc};
4use std::fmt;
5
6/// An identifier for one tag/item a [`crate::Driver`] knows how to read or write. For OPC
7/// DA this is the fully-qualified item name (e.g. `"Area1.LIC101.PV"`, matching
8/// `bhtune_core::tags::LoopTags`'s tag fields exactly); other drivers may use any string
9/// convention of their own, since only the implementing driver interprets it.
10///
11/// A plain `String` alias rather than a newtype: there is no invariant here worth enforcing
12/// (a tag ID is valid or not only in the sense that the driver does or doesn't recognize
13/// it, which no wrapper type can check ahead of time), so a newtype would only add ceremony.
14pub type TagId = String;
15
16/// How much a [`TagValue`] should be trusted, mirroring OPC's own three-state quality model
17/// rather than collapsing it to a bool. "Uncertain" (e.g. a value held at its last known
18/// reading during a brief comms hiccup) is a real, actionable distinction from outright bad
19/// quality — not merely a presentation nuance — so callers that need to decide whether to
20/// trust a reading can react to it directly rather than losing the distinction.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Quality {
23    Good,
24    Uncertain,
25    Bad,
26}
27
28impl Quality {
29    /// Whether a value with this quality should be trusted for tuning-critical decisions —
30    /// feeding an MRFT tick, or treating an initial-readings snapshot as having actually
31    /// succeeded. Only `Good` counts: MRFT's peak/trough detection has no way to partially
32    /// discount a merely-`Uncertain` reading the way a human operator glancing at a trend
33    /// might, so treating it as untrustworthy is the safe default.
34    pub fn is_trustworthy(self) -> bool {
35        matches!(self, Quality::Good)
36    }
37}
38
39/// One tag's freshly read value, as returned by [`crate::Driver::read`].
40///
41/// `value` is a raw string, not a parsed `f32` — deliberately, since not every tag a
42/// [`crate::Driver`] reads is numeric. Mode/direction/attribute tags hold small raw codes
43/// (e.g. `"MAN"`, `"0"`) that `bhtune_core`'s own parsing functions interpret directly (see
44/// `bhtune_core::ControllerDirection::from_raw_tag_value`), so this type must not assume
45/// every tag's value is a number. Parsing a numeric tag's `value` into `f32` — and treating a
46/// parse failure as an error rather than silently substituting a default — is left to the
47/// caller that knows which of its requested tags are numeric, mirroring how
48/// `opcda-bridge`'s own `Client::read` returns every value as a string for the same reason.
49///
50/// `timestamp` is `Option`, not a bare `DateTime<Utc>` — deliberately, since not every driver
51/// can honestly supply one. OPC DA over the bridge, in particular, reports the item's last-
52/// change time as a *local*, offset-less `"YYYY-MM-DD HH:MM:SS"` string, with `"N/A"`/
53/// `"Invalid"` sentinels for items that have none (see `driver-opcda`'s `parse_timestamp`).
54/// `None` when a driver cannot supply a trustworthy value, rather than a synthetic
55/// stand-in — this field is diagnostic (e.g. detecting a frozen tag whose timestamp stops
56/// advancing while its value doesn't change), never the tick time the tuning engine itself
57/// runs on, which comes from the caller's own polling clock instead.
58#[derive(Debug, Clone, PartialEq)]
59pub struct TagValue {
60    pub tag: TagId,
61    pub value: String,
62    pub quality: Quality,
63    pub timestamp: Option<DateTime<Utc>>,
64}
65
66/// A value to write to a tag via [`crate::Driver::write`].
67///
68/// Deliberately narrower than a full OPC VARIANT-style type space: bhtune only ever writes
69/// numeric process values (relay steps during MRFT, PID constants at write-back) or a raw
70/// mode code (reverting a loop's Auto/Manual mode after a completed test, per
71/// `bhtune_core::DcsTemplate::revert_mode`) — never, say, a boolean or an arbitrary integer.
72#[derive(Debug, Clone, PartialEq)]
73pub enum TagWrite {
74    Float(f32),
75    Raw(String),
76}
77
78/// The result of one [`crate::Driver::write`] call that reached the driver.
79///
80/// Kept distinct from a [`crate::DriverError`]: a driver (or the DCS/PLC behind it)
81/// rejecting a write — the tag is read-only, the value is out of range, permissions — is a
82/// normal, expected outcome of the call succeeding at the transport level, not a
83/// connection/operation failure. This shape mirrors `bhtune_db::models::TuneWriteRow`'s own
84/// `success`/`error_message` columns exactly, so a caller recording a write-back audit row
85/// can copy this outcome straight into that table with no translation.
86#[derive(Debug, Clone, PartialEq)]
87pub struct WriteOutcome {
88    pub success: bool,
89    pub error_message: Option<String>,
90}
91
92impl WriteOutcome {
93    /// A write the driver accepted outright.
94    pub fn success() -> WriteOutcome {
95        WriteOutcome {
96            success: true,
97            error_message: None,
98        }
99    }
100
101    /// A write the driver rejected, with its own explanation of why.
102    pub fn failure(error_message: impl Into<String>) -> WriteOutcome {
103        WriteOutcome {
104            success: false,
105            error_message: Some(error_message.into()),
106        }
107    }
108}
109
110/// How the OPC server organizes its namespace.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub enum NamespaceOrganization {
113    Unspecified,
114    Flat,
115    Hierarchical,
116}
117
118/// Native or configured strategy that produced browse results.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub enum BrowseSource {
121    Unspecified,
122    Da3,
123    Da2,
124    Flat,
125    Derived,
126}
127
128/// Whether a browse node is expandable, selectable as an OPC item, or both.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum BrowseNodeKind {
131    Unspecified,
132    Branch,
133    Item,
134    BranchAndItem,
135}
136
137impl BrowseNodeKind {
138    pub fn is_branch(self) -> bool {
139        matches!(self, Self::Branch | Self::BranchAndItem)
140    }
141
142    pub fn is_item(self) -> bool {
143        matches!(self, Self::Item | Self::BranchAndItem)
144    }
145}
146
147/// Gateway and namespace features reported for one OPC server.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DriverCapabilities {
150    pub application_version: String,
151    pub protocol_version: String,
152    pub max_page_size: u32,
153    pub supports_browse_sessions: bool,
154    pub supports_search: bool,
155    pub organization: NamespaceOrganization,
156    pub source: BrowseSource,
157    pub supports_indexed_search: bool,
158    pub indexed_search_protocol_version: String,
159    pub max_indexed_search_results: u32,
160    pub search_index_state: SearchIndexState,
161}
162
163/// One child returned by a browse page.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct BrowseNode {
166    /// Opaque navigation identity. Round-trip it unchanged when expanding.
167    pub node_key: String,
168    /// One local label suitable for display.
169    pub display_name: String,
170    pub kind: BrowseNodeKind,
171    /// Exact OPC DA ItemID, present only for selectable nodes.
172    pub item_id: Option<String>,
173}
174
175/// One bounded page of immediate children and its continuation metadata.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct BrowsePage {
178    pub session_id: String,
179    pub nodes: Vec<BrowseNode>,
180    pub next_page_token: Option<String>,
181    pub complete: bool,
182    pub organization: NamespaceOrganization,
183    pub source: BrowseSource,
184    pub warning: Option<String>,
185}
186
187/// Parameters for one browse-page request. The connected driver supplies its configured
188/// OPC server; callers only provide session/navigation state returned by earlier pages.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct BrowsePageRequest {
191    pub session_id: Option<String>,
192    pub parent_node_key: Option<String>,
193    pub page_token: Option<String>,
194    pub page_size: u32,
195    pub refresh: bool,
196}
197
198impl BrowsePageRequest {
199    /// Open a new browse session and request its root page.
200    pub fn root(page_size: u32) -> Self {
201        Self {
202            session_id: None,
203            parent_node_key: None,
204            page_token: None,
205            page_size,
206            refresh: false,
207        }
208    }
209
210    /// Request the first page beneath an already-discovered branch.
211    pub fn children(
212        session_id: impl Into<String>,
213        parent_node_key: impl Into<String>,
214        page_size: u32,
215    ) -> Self {
216        Self {
217            session_id: Some(session_id.into()),
218            parent_node_key: Some(parent_node_key.into()),
219            page_token: None,
220            page_size,
221            refresh: false,
222        }
223    }
224
225    /// Request the next page for a root or child browse.
226    pub fn next(
227        session_id: impl Into<String>,
228        parent_node_key: Option<String>,
229        page_token: impl Into<String>,
230        page_size: u32,
231    ) -> Self {
232        Self {
233            session_id: Some(session_id.into()),
234            parent_node_key,
235            page_token: Some(page_token.into()),
236            page_size,
237            refresh: false,
238        }
239    }
240
241    pub fn with_refresh(mut self, refresh: bool) -> Self {
242        self.refresh = refresh;
243        self
244    }
245}
246
247/// Match behavior for namespace search.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub enum SearchMatchMode {
250    Exact,
251    Prefix,
252    Contains,
253}
254
255/// Readiness of a gateway-owned persistent namespace index.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
257pub enum SearchIndexState {
258    Unspecified,
259    NotIndexed,
260    Partial,
261    Ready,
262    Stale,
263    Refreshing,
264    Promoting,
265    Failed,
266    Deleting,
267}
268
269impl SearchIndexState {
270    pub const fn as_str(self) -> &'static str {
271        match self {
272            Self::Unspecified => "unspecified",
273            Self::NotIndexed => "not_indexed",
274            Self::Partial => "partial",
275            Self::Ready => "ready",
276            Self::Stale => "stale",
277            Self::Refreshing => "refreshing",
278            Self::Promoting => "promoting",
279            Self::Failed => "failed",
280            Self::Deleting => "deleting",
281        }
282    }
283}
284
285impl fmt::Display for SearchIndexState {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        f.write_str(self.as_str())
288    }
289}
290
291/// Operator action applied to an active namespace-index build.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293pub enum SearchIndexControlAction {
294    Pause,
295    Resume,
296    Cancel,
297}
298
299/// Parameters for one persistent-index query. The connected driver supplies its configured
300/// OPC DA server, just as it does for [`SearchRequest`].
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SearchIndexRequest {
303    pub query: String,
304    pub match_mode: SearchMatchMode,
305    pub max_results: u32,
306}
307
308impl SearchIndexRequest {
309    pub fn new(query: impl Into<String>, match_mode: SearchMatchMode, max_results: u32) -> Self {
310        Self {
311            query: query.into(),
312            match_mode,
313            max_results,
314        }
315    }
316}
317
318/// Progress reported for a running persistent namespace inventory.
319#[derive(Debug, Clone, PartialEq)]
320pub struct IndexedSearchProgress {
321    pub branches_visited: u64,
322    pub entries_seen: u64,
323    pub unique_items: u64,
324    pub active_time_ms: u64,
325    pub paused_time_ms: u64,
326    pub items_per_second: f64,
327    pub estimated_remaining_ms: Option<u64>,
328}
329
330/// Persistent namespace-index state and build metadata.
331#[derive(Debug, Clone, PartialEq)]
332pub struct SearchIndexStatus {
333    pub server: String,
334    pub state: SearchIndexState,
335    pub auto_refresh_enabled: bool,
336    pub active_generation: u64,
337    pub entry_count: u64,
338    pub unique_item_count: u64,
339    pub started_at: Option<String>,
340    pub completed_at: Option<String>,
341    pub last_error: Option<String>,
342    pub database_bytes: u64,
343    pub organization: NamespaceOrganization,
344    pub source: BrowseSource,
345    pub progress: Option<IndexedSearchProgress>,
346    pub scheduler: IndexSchedulerDiagnostics,
347}
348
349/// Scheduler and retry information for a persistent namespace index.
350#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct IndexSchedulerDiagnostics {
352    pub next_refresh_at: Option<String>,
353    pub last_attempt_at: Option<String>,
354    pub last_success_at: Option<String>,
355    pub last_success_duration_ms: Option<u64>,
356    pub retry_after: Option<String>,
357    pub consecutive_failures: u32,
358    pub circuit_open: bool,
359}
360
361/// One selectable result from the persistent namespace index.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct IndexedSearchMatch {
364    pub item_id: String,
365    pub display_name: String,
366    pub kind: BrowseNodeKind,
367    pub breadcrumbs: Vec<String>,
368}
369
370/// Ranked persistent-index matches plus snapshot readiness metadata.
371#[derive(Debug, Clone, PartialEq)]
372pub struct SearchIndexResponse {
373    pub matches: Vec<IndexedSearchMatch>,
374    pub has_more: bool,
375    pub status: SearchIndexStatus,
376}
377
378/// Parameters for a bounded namespace search.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct SearchRequest {
381    pub query: String,
382    pub match_mode: SearchMatchMode,
383    pub session_id: Option<String>,
384    pub scope_node_key: Option<String>,
385    pub max_results: u32,
386    pub include_branches: bool,
387    pub refresh: bool,
388}
389
390impl SearchRequest {
391    pub fn new(query: impl Into<String>, match_mode: SearchMatchMode, max_results: u32) -> Self {
392        Self {
393            query: query.into(),
394            match_mode,
395            session_id: None,
396            scope_node_key: None,
397            max_results,
398            include_branches: false,
399            refresh: false,
400        }
401    }
402}
403
404/// One navigation step associated with a search match.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct BrowseBreadcrumb {
407    pub node_key: String,
408    pub display_name: String,
409}
410
411/// A progressively emitted namespace-search result.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct SearchMatch {
414    pub node: BrowseNode,
415    pub breadcrumbs: Vec<BrowseBreadcrumb>,
416}
417
418/// Progress emitted while a namespace search is still running.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct SearchProgress {
421    pub visited_nodes: u32,
422    pub matches: u32,
423    pub partial: bool,
424}
425
426/// Terminal search metadata.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct SearchCompleted {
429    pub complete: bool,
430    pub cancelled: bool,
431    pub truncated: bool,
432    pub warning: Option<String>,
433}
434
435/// One event from the driver's namespace-search stream.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub enum SearchEvent {
438    Match(SearchMatch),
439    Progress(SearchProgress),
440    Completed(SearchCompleted),
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn search_index_state_strings_cover_all_wire_states() {
449        let states = [
450            (SearchIndexState::Unspecified, "unspecified"),
451            (SearchIndexState::NotIndexed, "not_indexed"),
452            (SearchIndexState::Partial, "partial"),
453            (SearchIndexState::Ready, "ready"),
454            (SearchIndexState::Stale, "stale"),
455            (SearchIndexState::Refreshing, "refreshing"),
456            (SearchIndexState::Promoting, "promoting"),
457            (SearchIndexState::Failed, "failed"),
458            (SearchIndexState::Deleting, "deleting"),
459        ];
460
461        for (state, expected) in states {
462            assert_eq!(state.as_str(), expected);
463            assert_eq!(state.to_string(), expected);
464        }
465    }
466
467    #[test]
468    fn browse_page_request_builders_preserve_navigation_and_refresh_state() {
469        assert_eq!(
470            BrowsePageRequest::root(25),
471            BrowsePageRequest {
472                session_id: None,
473                parent_node_key: None,
474                page_token: None,
475                page_size: 25,
476                refresh: false,
477            }
478        );
479        assert_eq!(
480            BrowsePageRequest::children("session", "parent", 10),
481            BrowsePageRequest {
482                session_id: Some("session".into()),
483                parent_node_key: Some("parent".into()),
484                page_token: None,
485                page_size: 10,
486                refresh: false,
487            }
488        );
489        assert_eq!(
490            BrowsePageRequest::next("session", Some("parent".into()), "token", 5)
491                .with_refresh(true),
492            BrowsePageRequest {
493                session_id: Some("session".into()),
494                parent_node_key: Some("parent".into()),
495                page_token: Some("token".into()),
496                page_size: 5,
497                refresh: true,
498            }
499        );
500    }
501
502    #[test]
503    fn only_good_quality_is_trustworthy() {
504        assert!(Quality::Good.is_trustworthy());
505        assert!(!Quality::Uncertain.is_trustworthy());
506        assert!(!Quality::Bad.is_trustworthy());
507    }
508
509    #[test]
510    fn write_outcome_success_has_no_error_message() {
511        let outcome = WriteOutcome::success();
512        assert!(outcome.success);
513        assert_eq!(outcome.error_message, None);
514    }
515
516    #[test]
517    fn write_outcome_failure_carries_message() {
518        let outcome = WriteOutcome::failure("tag is read-only");
519        assert!(!outcome.success);
520        assert_eq!(outcome.error_message.as_deref(), Some("tag is read-only"));
521    }
522}