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