Skip to main content

bhtune_server/routes/opc/
dto.rs

1use bhtune_db::models::SampleQuality;
2use bhtune_driver::{
3    BrowseNode, BrowseNodeKind, BrowsePage, DriverCapabilities, IndexedSearchMatch,
4    IndexedSearchProgress, OpcDaFeatureCompatibility, OpcDaGatewayCompatibility,
5    SearchIndexResponse, SearchIndexStatus,
6};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use utoipa::{IntoParams, ToSchema};
10
11use super::helpers::{organization_name, source_name};
12use super::validation::{
13    default_index_search_max_results, default_page_size, default_search_match_mode,
14    default_search_max_results,
15};
16
17/// Inclusive protocol range stored with a gateway compatibility snapshot.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
19pub struct GatewayProtocolRangeResponse {
20    pub min: u32,
21    pub max: u32,
22}
23
24/// One protocol feature in a gateway compatibility snapshot.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
26pub struct GatewayFeatureCompatibilityResponse {
27    pub feature: String,
28    pub status: String,
29    pub client_versions: GatewayProtocolRangeResponse,
30    pub gateway_versions: Option<GatewayProtocolRangeResponse>,
31    pub negotiated_version: Option<u32>,
32    pub reason: String,
33}
34
35impl From<&OpcDaFeatureCompatibility> for GatewayFeatureCompatibilityResponse {
36    fn from(feature: &OpcDaFeatureCompatibility) -> Self {
37        Self {
38            feature: feature.feature.as_str().to_string(),
39            status: feature.status.as_str().to_string(),
40            client_versions: GatewayProtocolRangeResponse {
41                min: feature.client_versions.min,
42                max: feature.client_versions.max,
43            },
44            gateway_versions: feature.gateway_versions.as_ref().map(|range| {
45                GatewayProtocolRangeResponse {
46                    min: range.min,
47                    max: range.max,
48                }
49            }),
50            negotiated_version: feature.negotiated_version,
51            reason: feature.reason.clone(),
52        }
53    }
54}
55
56/// Gateway protocol compatibility attached to inspection responses and run detail.
57///
58/// String fields match the snake_case snapshot stored in
59/// `tune_runs.gateway_compatibility_json`. Discovery, browse, and read attach this even when
60/// the gateway is only partially compatible or predates a requested operation; live mutations
61/// refuse an incompatible core instead.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
63pub struct GatewayCompatibilityResponse {
64    pub client_version: String,
65    pub gateway_version: Option<String>,
66    pub source: String,
67    pub status: String,
68    pub features: Vec<GatewayFeatureCompatibilityResponse>,
69}
70
71impl From<&OpcDaGatewayCompatibility> for GatewayCompatibilityResponse {
72    fn from(report: &OpcDaGatewayCompatibility) -> Self {
73        Self {
74            client_version: report.client_version.clone(),
75            gateway_version: report.gateway_version.clone(),
76            source: report.source.as_str().to_string(),
77            status: report.status.as_str().to_string(),
78            features: report
79                .features
80                .iter()
81                .map(GatewayFeatureCompatibilityResponse::from)
82                .collect(),
83        }
84    }
85}
86
87impl From<OpcDaGatewayCompatibility> for GatewayCompatibilityResponse {
88    fn from(report: OpcDaGatewayCompatibility) -> Self {
89        Self::from(&report)
90    }
91}
92
93/// Query parameters for `GET /api/opc/servers`.
94#[derive(Debug, Deserialize, IntoParams)]
95#[into_params(parameter_in = Query)]
96pub struct OpcServersQuery {
97    /// Overrides the configured/default bridge host for this one request, matching `bhtune
98    /// opc servers --bridge-host`.
99    pub bridge_host: Option<String>,
100}
101
102/// Response body of `GET /api/opc/servers`.
103#[derive(Debug, Serialize, ToSchema)]
104pub struct OpcServersResponse {
105    pub servers: Vec<String>,
106    /// Compatibility of the gateway that answered this discovery call. Discovery itself is
107    /// not refused when the gateway is partial, unknown, or incompatible.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub gateway_compatibility: Option<GatewayCompatibilityResponse>,
110}
111
112/// Query parameters for `GET /api/opc/capabilities`.
113#[derive(Debug, Deserialize, IntoParams)]
114#[into_params(parameter_in = Query)]
115pub struct OpcServerQuery {
116    pub bridge_host: Option<String>,
117    pub opc_server: Option<String>,
118}
119
120#[derive(Debug, Serialize, ToSchema)]
121pub struct OpcCapabilitiesResponse {
122    pub application_version: String,
123    pub protocol_version: String,
124    pub max_page_size: u32,
125    pub supports_browse_sessions: bool,
126    pub supports_search: bool,
127    pub organization: String,
128    pub source: String,
129    pub supports_indexed_search: bool,
130    pub indexed_search_protocol_version: String,
131    pub max_indexed_search_results: u32,
132    pub search_index_state: String,
133    /// Compatibility observed while discovering capabilities. Present even when capability
134    /// discovery itself is unsupported and the rest of this response is degraded.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub gateway_compatibility: Option<GatewayCompatibilityResponse>,
137}
138
139impl From<DriverCapabilities> for OpcCapabilitiesResponse {
140    fn from(capabilities: DriverCapabilities) -> Self {
141        Self {
142            application_version: capabilities.application_version,
143            protocol_version: capabilities.protocol_version,
144            max_page_size: capabilities.max_page_size,
145            supports_browse_sessions: capabilities.supports_browse_sessions,
146            supports_search: capabilities.supports_search,
147            organization: organization_name(capabilities.organization).to_string(),
148            source: source_name(capabilities.source).to_string(),
149            supports_indexed_search: capabilities.supports_indexed_search,
150            indexed_search_protocol_version: capabilities.indexed_search_protocol_version,
151            max_indexed_search_results: capabilities.max_indexed_search_results,
152            search_index_state: capabilities.search_index_state.to_string(),
153            gateway_compatibility: None,
154        }
155    }
156}
157
158/// Query parameters shared by indexed-search status and search-index actions.
159#[derive(Debug, Deserialize, IntoParams)]
160#[into_params(parameter_in = Query)]
161pub struct OpcSearchIndexServerQuery {
162    pub bridge_host: Option<String>,
163    pub opc_server: Option<String>,
164}
165
166#[derive(Debug, Serialize, ToSchema)]
167pub struct OpcIndexedSearchProgressResponse {
168    pub branches_visited: u64,
169    pub entries_seen: u64,
170    pub unique_items: u64,
171    pub active_time_ms: u64,
172    pub paused_time_ms: u64,
173    pub items_per_second: f64,
174    pub estimated_remaining_ms: Option<u64>,
175}
176
177impl From<IndexedSearchProgress> for OpcIndexedSearchProgressResponse {
178    fn from(progress: IndexedSearchProgress) -> Self {
179        Self {
180            branches_visited: progress.branches_visited,
181            entries_seen: progress.entries_seen,
182            unique_items: progress.unique_items,
183            active_time_ms: progress.active_time_ms,
184            paused_time_ms: progress.paused_time_ms,
185            items_per_second: progress.items_per_second,
186            estimated_remaining_ms: progress.estimated_remaining_ms,
187        }
188    }
189}
190
191#[derive(Debug, Serialize, ToSchema)]
192pub struct OpcSearchIndexStatusResponse {
193    pub server: String,
194    pub state: String,
195    pub auto_refresh_enabled: bool,
196    pub active_generation: u64,
197    pub entry_count: u64,
198    pub unique_item_count: u64,
199    pub started_at: Option<String>,
200    pub completed_at: Option<String>,
201    pub last_error: Option<String>,
202    pub database_bytes: u64,
203    pub organization: String,
204    pub source: String,
205    pub progress: Option<OpcIndexedSearchProgressResponse>,
206    pub scheduler: OpcIndexSchedulerResponse,
207}
208
209#[derive(Debug, Serialize, ToSchema)]
210pub struct OpcIndexSchedulerResponse {
211    pub next_refresh_at: Option<String>,
212    pub last_attempt_at: Option<String>,
213    pub last_success_at: Option<String>,
214    pub last_success_duration_ms: Option<u64>,
215    pub retry_after: Option<String>,
216    pub consecutive_failures: u32,
217    pub circuit_open: bool,
218}
219
220impl From<SearchIndexStatus> for OpcSearchIndexStatusResponse {
221    fn from(status: SearchIndexStatus) -> Self {
222        Self {
223            server: status.server,
224            state: status.state.to_string(),
225            auto_refresh_enabled: status.auto_refresh_enabled,
226            active_generation: status.active_generation,
227            entry_count: status.entry_count,
228            unique_item_count: status.unique_item_count,
229            started_at: status.started_at,
230            completed_at: status.completed_at,
231            last_error: status.last_error,
232            database_bytes: status.database_bytes,
233            organization: organization_name(status.organization).to_string(),
234            source: source_name(status.source).to_string(),
235            progress: status.progress.map(Into::into),
236            scheduler: OpcIndexSchedulerResponse {
237                next_refresh_at: status.scheduler.next_refresh_at,
238                last_attempt_at: status.scheduler.last_attempt_at,
239                last_success_at: status.scheduler.last_success_at,
240                last_success_duration_ms: status.scheduler.last_success_duration_ms,
241                retry_after: status.scheduler.retry_after,
242                consecutive_failures: status.scheduler.consecutive_failures,
243                circuit_open: status.scheduler.circuit_open,
244            },
245        }
246    }
247}
248
249#[derive(Debug, Serialize, ToSchema)]
250pub struct OpcIndexedSearchMatchResponse {
251    pub item_id: String,
252    pub display_name: String,
253    pub kind: OpcBrowseNodeKind,
254    pub breadcrumbs: Vec<String>,
255}
256
257impl From<IndexedSearchMatch> for OpcIndexedSearchMatchResponse {
258    fn from(found: IndexedSearchMatch) -> Self {
259        Self {
260            item_id: found.item_id,
261            display_name: found.display_name,
262            kind: found.kind.into(),
263            breadcrumbs: found.breadcrumbs,
264        }
265    }
266}
267
268#[derive(Debug, Serialize, ToSchema)]
269pub struct OpcSearchIndexResponse {
270    pub matches: Vec<OpcIndexedSearchMatchResponse>,
271    pub has_more: bool,
272    pub status: OpcSearchIndexStatusResponse,
273}
274
275impl From<SearchIndexResponse> for OpcSearchIndexResponse {
276    fn from(response: SearchIndexResponse) -> Self {
277        Self {
278            matches: response.matches.into_iter().map(Into::into).collect(),
279            has_more: response.has_more,
280            status: response.status.into(),
281        }
282    }
283}
284
285/// Query the gateway-owned persistent namespace index. This is a bounded unary request and
286/// never falls back to the legacy live traversal search.
287#[derive(Debug, Deserialize, IntoParams)]
288#[into_params(parameter_in = Query)]
289pub struct OpcSearchIndexQuery {
290    pub bridge_host: Option<String>,
291    pub opc_server: Option<String>,
292    pub query: String,
293    #[serde(default = "default_search_match_mode")]
294    pub match_mode: String,
295    #[serde(default = "default_index_search_max_results")]
296    #[param(minimum = 1)]
297    pub max_results: u32,
298}
299
300/// Query parameters for `POST /api/opc/search-index/refresh`.
301#[derive(Debug, Deserialize, IntoParams)]
302#[into_params(parameter_in = Query)]
303pub struct OpcSearchIndexRefreshQuery {
304    pub bridge_host: Option<String>,
305    pub opc_server: Option<String>,
306    pub force: Option<bool>,
307}
308
309/// Query parameters for `POST /api/opc/search-index/auto-refresh`.
310#[derive(Debug, Deserialize, IntoParams)]
311#[into_params(parameter_in = Query)]
312pub struct OpcSearchIndexAutoRefreshQuery {
313    pub bridge_host: Option<String>,
314    pub opc_server: Option<String>,
315    pub enabled: bool,
316}
317
318/// Query parameters for `POST /api/opc/search-index/control`.
319#[derive(Debug, Deserialize, IntoParams)]
320#[into_params(parameter_in = Query)]
321pub struct OpcSearchIndexControlQuery {
322    pub bridge_host: Option<String>,
323    pub opc_server: Option<String>,
324    pub action: String,
325}
326
327/// Query parameters for `GET /api/opc/browse`.
328#[derive(Debug, Deserialize, IntoParams)]
329#[into_params(parameter_in = Query)]
330pub struct OpcBrowseQuery {
331    pub bridge_host: Option<String>,
332    pub opc_server: Option<String>,
333    pub session_id: Option<String>,
334    pub parent_node_key: Option<String>,
335    pub page_token: Option<String>,
336    #[serde(default = "default_page_size")]
337    pub page_size: u32,
338    pub refresh: Option<bool>,
339}
340
341#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
342#[serde(rename_all = "snake_case")]
343pub enum OpcBrowseNodeKind {
344    Unspecified,
345    Branch,
346    Item,
347    BranchAndItem,
348}
349
350impl From<BrowseNodeKind> for OpcBrowseNodeKind {
351    fn from(kind: BrowseNodeKind) -> Self {
352        match kind {
353            BrowseNodeKind::Unspecified => Self::Unspecified,
354            BrowseNodeKind::Branch => Self::Branch,
355            BrowseNodeKind::Item => Self::Item,
356            BrowseNodeKind::BranchAndItem => Self::BranchAndItem,
357        }
358    }
359}
360
361/// One node returned by `GET /api/opc/browse`. `node_key` and `item_id` must remain separate:
362/// the former is an opaque navigation key, while the latter is the exact selectable OPC DA
363/// ItemID and may contain namespace punctuation with no relationship to hierarchy.
364#[derive(Debug, Serialize, ToSchema)]
365pub struct OpcBrowseNodeResponse {
366    pub node_key: String,
367    pub display_name: String,
368    pub kind: OpcBrowseNodeKind,
369    pub item_id: Option<String>,
370}
371
372impl From<BrowseNode> for OpcBrowseNodeResponse {
373    fn from(node: BrowseNode) -> Self {
374        Self {
375            node_key: node.node_key,
376            display_name: node.display_name,
377            kind: node.kind.into(),
378            item_id: node.item_id,
379        }
380    }
381}
382
383#[derive(Debug, Serialize, ToSchema)]
384pub struct OpcBrowseResponse {
385    pub session_id: String,
386    pub nodes: Vec<OpcBrowseNodeResponse>,
387    pub next_page_token: Option<String>,
388    pub complete: bool,
389    pub organization: String,
390    pub source: String,
391    pub warning: Option<String>,
392    /// Compatibility observed for this browse. An unsupported page still returns this field
393    /// with an empty, explicitly degraded page instead of failing the request.
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub gateway_compatibility: Option<GatewayCompatibilityResponse>,
396}
397
398impl From<BrowsePage> for OpcBrowseResponse {
399    fn from(page: BrowsePage) -> Self {
400        Self {
401            session_id: page.session_id,
402            nodes: page.nodes.into_iter().map(Into::into).collect(),
403            next_page_token: page.next_page_token,
404            complete: page.complete,
405            organization: organization_name(page.organization).to_string(),
406            source: source_name(page.source).to_string(),
407            warning: page.warning,
408            gateway_compatibility: None,
409        }
410    }
411}
412
413#[derive(Debug, Serialize, ToSchema)]
414pub struct OpcCloseBrowseSessionResponse {
415    pub closed: bool,
416}
417
418/// Query parameters for the progressive `GET /api/opc/search` SSE endpoint.
419#[derive(Debug, Deserialize, IntoParams)]
420#[into_params(parameter_in = Query)]
421pub struct OpcSearchQuery {
422    pub bridge_host: Option<String>,
423    pub opc_server: Option<String>,
424    pub query: String,
425    #[serde(default = "default_search_match_mode")]
426    pub match_mode: String,
427    pub session_id: Option<String>,
428    pub scope_node_key: Option<String>,
429    #[serde(default = "default_search_max_results")]
430    #[param(minimum = 1)]
431    pub max_results: u32,
432    pub include_branches: Option<bool>,
433    pub refresh: Option<bool>,
434}
435
436/// Query parameters for `GET /api/opc/read`.
437#[derive(Debug, Deserialize, IntoParams)]
438#[into_params(parameter_in = Query)]
439pub struct OpcReadQuery {
440    pub bridge_host: Option<String>,
441    pub opc_server: Option<String>,
442    /// The fully qualified tag to read (e.g. `"Unit1.LIC101.PV"`). Required -- unlike the
443    /// other two fields, there is no configured default for "which tag".
444    pub tag: Option<String>,
445}
446
447/// Response body of `GET /api/opc/read`.
448///
449/// `quality` reuses [`SampleQuality`] rather than a third quality representation --
450/// `bhtune-db`'s `SampleQuality` (mapped from the driver's live [`bhtune_driver::Quality`] by
451/// [`bhtune_runtime::tune::sample_quality_from_driver`]) is already exposed directly over HTTP in
452/// `GET /api/runs/{id}`'s `SampleResponse::pv_quality` (see `routes::history`), so this
453/// follows that same precedent instead of inventing a parallel `OpcQualityResponse` enum.
454#[derive(Debug, Serialize, ToSchema)]
455pub struct OpcReadResponse {
456    pub tag: String,
457    pub value: String,
458    pub quality: SampleQuality,
459    /// Always `null` for the OPC DA driver today: the gateway's last-change time is a
460    /// *local*, offset-less string with no reliable way to convert it to a trustworthy
461    /// `DateTime<Utc>` (see `bhtune_driver::opcda::tag_value_from_raw`'s doc comment) -- kept
462    /// as a field rather than dropped entirely so a future driver that *can* supply a
463    /// trustworthy instant (or a bridge protocol revision that reports the gateway's own
464    /// timezone) doesn't need an API shape change to start populating it.
465    pub timestamp: Option<DateTime<Utc>>,
466    /// Compatibility of the gateway that served this diagnostic read. A partial or unknown
467    /// gateway still returns the tag value.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub gateway_compatibility: Option<GatewayCompatibilityResponse>,
470}