Skip to main content

bhtune_server/routes/
draft.rs

1//! `GET`/`PUT /api/runs/draft` for the app-wide New Tune form draft.
2//!
3//! This is deliberately separate from `tune_runs.request_json`: a draft is mutable, may be
4//! incomplete while someone is editing it, and is not historical run data. Notes are omitted
5//! from the DTO so transient operator context is never persisted as a form preference.
6
7use axum::routing::get;
8use axum::{Json, Router};
9use bhtune_core::{ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides};
10use bhtune_db::models::{SettingRow, TuneDriver};
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use utoipa::ToSchema;
14
15use crate::error::{ApiError, ErrorBody};
16use crate::state::AppState;
17
18const NEW_RUN_DRAFT_KEY: &str = "new_run_draft";
19
20/// The source selector state used only by the mutable New Tune draft.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum DraftTagSource {
24    Template,
25    Custom,
26}
27
28/// The source selector state used only by the mutable New Tune draft.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum DraftValueSource {
32    Tag,
33    Custom,
34    Fixed,
35}
36
37/// Per-tag source choices in the New Tune mapping editor.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
39pub struct DraftTagSources {
40    pub process_variable: DraftTagSource,
41    pub manipulated_variable: DraftTagSource,
42    pub setpoint_variable: DraftTagSource,
43    pub controller_mode: DraftTagSource,
44    pub mode_attribute: DraftTagSource,
45    pub proportional_constant: DraftTagSource,
46    pub integral_constant: DraftTagSource,
47    pub derivative_constant: DraftTagSource,
48}
49
50/// Per-direction/range source choices in the New Tune mapping editor.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
52pub struct DraftValueSources {
53    pub direction: DraftValueSource,
54    pub pv_range_high: DraftValueSource,
55    pub pv_range_low: DraftValueSource,
56    pub mv_range_high: DraftValueSource,
57    pub mv_range_low: DraftValueSource,
58}
59
60/// The editable state of the New Tune form.
61///
62/// Fields are optional because the form is allowed to be incomplete while it is being edited.
63/// The frontend sends the complete shape on each save, using `null` for a cleared numeric or
64/// enum field. Notes are intentionally absent: they describe one run, not a reusable draft.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
66pub struct NewRunDraft {
67    pub driver: Option<TuneDriver>,
68    pub template: Option<String>,
69    pub tagname: Option<String>,
70    pub server: Option<String>,
71    pub bridge_host: Option<String>,
72    pub process_type: Option<ProcessType>,
73    pub controller_type: Option<ControllerType>,
74    pub relay_amp: Option<f32>,
75    pub cycles_skip: Option<u32>,
76    pub cycles_count: Option<u32>,
77    pub noise_protection_secs: Option<u32>,
78    pub mrft_delay: Option<u32>,
79    pub poll_interval_ms: Option<u64>,
80    pub timeout_secs: Option<u64>,
81    pub op_timeout_secs: Option<u64>,
82    pub restore_timeout_secs: Option<u64>,
83    pub direction: Option<ControllerDirection>,
84    pub tag_overrides: Option<TagOverrides>,
85    /// Driver context for the saved direction/range values. New drafts keep this alongside
86    /// source metadata; older drafts use it to infer whether legacy values are OPC fixed
87    /// overrides or simulator values.
88    pub source_driver: Option<TuneDriver>,
89    /// Simulator-specific direction/range values. These are draft-only and never go into a
90    /// run request when the OPC DA driver is active.
91    pub source_direction: Option<ControllerDirection>,
92    pub pv_range_high: Option<f32>,
93    pub pv_range_low: Option<f32>,
94    pub mv_range_high: Option<f32>,
95    pub mv_range_low: Option<f32>,
96    pub source_pv_range_high: Option<f32>,
97    pub source_pv_range_low: Option<f32>,
98    pub source_mv_range_high: Option<f32>,
99    pub source_mv_range_low: Option<f32>,
100    /// Draft-only source selectors for tag names and direction/range values.
101    pub tag_sources: Option<DraftTagSources>,
102    pub value_sources: Option<DraftValueSources>,
103    pub sim_gain: Option<f32>,
104    pub sim_tau: Option<f32>,
105    pub sim_dead_time: Option<f32>,
106    pub sim_noise: Option<f32>,
107    pub sim_seed: Option<u64>,
108    pub sim_initial_pv: Option<f32>,
109    pub sim_initial_mv: Option<f32>,
110    pub write_pid: Option<ResponseLevel>,
111    pub yes: Option<bool>,
112}
113
114fn invalid_stored_draft(error: serde_json::Error) -> ApiError {
115    ApiError::Internal(anyhow::anyhow!(
116        "saved New Tune draft has an invalid shape: {error}"
117    ))
118}
119
120/// Returns the saved New Tune draft, or `null` when no draft has been saved yet.
121#[utoipa::path(
122    get,
123    path = "/api/runs/draft",
124    tag = "runs",
125    responses(
126        (status = 200, description = "The saved New Tune draft, or null when none exists.", body = Option<NewRunDraft>),
127        (status = 500, description = "The stored draft is malformed or the database failed.", body = ErrorBody),
128    ),
129)]
130pub(crate) async fn get_draft(
131    axum::extract::State(state): axum::extract::State<AppState>,
132) -> Result<Json<Option<NewRunDraft>>, ApiError> {
133    let Some(setting) = SettingRow::get(&state.pool, NEW_RUN_DRAFT_KEY).await? else {
134        return Ok(Json(None));
135    };
136    let draft = serde_json::from_value(setting.value).map_err(invalid_stored_draft)?;
137    Ok(Json(Some(draft)))
138}
139
140/// Replaces the saved New Tune draft and returns the stored value.
141#[utoipa::path(
142    put,
143    path = "/api/runs/draft",
144    tag = "runs",
145    request_body = NewRunDraft,
146    responses(
147        (status = 200, description = "The draft was saved.", body = NewRunDraft),
148        (status = 500, description = "The database failed or the draft could not be stored.", body = ErrorBody),
149    ),
150)]
151pub(crate) async fn put_draft(
152    axum::extract::State(state): axum::extract::State<AppState>,
153    Json(draft): Json<NewRunDraft>,
154) -> Result<Json<NewRunDraft>, ApiError> {
155    let value = encode_draft(&draft, |value| serde_json::to_value(value))?;
156    let stored = SettingRow::upsert(&state.pool, NEW_RUN_DRAFT_KEY, &value, Utc::now()).await?;
157    let persisted = serde_json::from_value(stored.value).map_err(invalid_stored_draft)?;
158    Ok(Json(persisted))
159}
160
161fn encode_draft<E>(
162    draft: &NewRunDraft,
163    encode: impl FnOnce(&NewRunDraft) -> Result<serde_json::Value, E>,
164) -> Result<serde_json::Value, ApiError>
165where
166    E: std::fmt::Display,
167{
168    encode(draft)
169        .map_err(|error| ApiError::Internal(anyhow::anyhow!("failed to encode draft: {error}")))
170}
171
172pub fn router() -> Router<AppState> {
173    Router::new().route("/api/runs/draft", get(get_draft).put(put_draft))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use axum::body::{Body, to_bytes};
180    use axum::http::{Request, StatusCode};
181    use bhtune_core::ControllerDirection;
182    use bhtune_db::models::SettingRow;
183    use serde_json::json;
184    use tower::ServiceExt;
185
186    #[test]
187    fn draft_encoding_failure_is_an_internal_error() {
188        let error = encode_draft(
189            &NewRunDraft {
190                driver: None,
191                template: None,
192                tagname: None,
193                server: None,
194                bridge_host: None,
195                process_type: None,
196                controller_type: None,
197                relay_amp: None,
198                cycles_skip: None,
199                cycles_count: None,
200                noise_protection_secs: None,
201                mrft_delay: None,
202                poll_interval_ms: None,
203                timeout_secs: None,
204                op_timeout_secs: None,
205                restore_timeout_secs: None,
206                direction: None,
207                tag_overrides: None,
208                source_driver: None,
209                source_direction: None,
210                pv_range_high: None,
211                pv_range_low: None,
212                mv_range_high: None,
213                mv_range_low: None,
214                source_pv_range_high: None,
215                source_pv_range_low: None,
216                source_mv_range_high: None,
217                source_mv_range_low: None,
218                tag_sources: None,
219                value_sources: None,
220                sim_gain: None,
221                sim_tau: None,
222                sim_dead_time: None,
223                sim_noise: None,
224                sim_seed: None,
225                sim_initial_pv: None,
226                sim_initial_mv: None,
227                write_pid: None,
228                yes: None,
229            },
230            |_| Err::<serde_json::Value, _>("injected encoding failure"),
231        )
232        .unwrap_err();
233        assert!(
234            matches!(error, ApiError::Internal(message) if message.to_string().contains("encoding failure"))
235        );
236    }
237
238    #[tokio::test]
239    async fn missing_draft_is_null() {
240        let app = crate::build_router(crate::test_support::in_memory_state().await);
241        let response = app
242            .oneshot(
243                Request::get("/api/runs/draft")
244                    .body(Body::empty())
245                    .expect("request"),
246            )
247            .await
248            .expect("response");
249
250        assert_eq!(response.status(), StatusCode::OK);
251        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
252        assert_eq!(
253            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
254            json!(null)
255        );
256    }
257
258    #[tokio::test]
259    async fn put_replaces_draft_and_never_persists_notes() {
260        let state = crate::test_support::in_memory_state().await;
261        let pool = state.pool.clone();
262        let app = crate::build_router(state);
263        let request_body = json!({
264            "driver": "opcda",
265            "template": "Yokogawa CentumVP",
266            "bridge_host": "localhost:7600",
267            "notes": "transient operator context"
268        });
269
270        let response = app
271            .clone()
272            .oneshot(
273                Request::put("/api/runs/draft")
274                    .header("content-type", "application/json")
275                    .body(Body::from(request_body.to_string()))
276                    .expect("request"),
277            )
278            .await
279            .expect("response");
280        assert_eq!(response.status(), StatusCode::OK);
281
282        let response = app
283            .oneshot(
284                Request::get("/api/runs/draft")
285                    .body(Body::empty())
286                    .expect("request"),
287            )
288            .await
289            .expect("response");
290        assert_eq!(response.status(), StatusCode::OK);
291        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
292        let saved: serde_json::Value = serde_json::from_slice(&body).unwrap();
293        assert_eq!(saved["driver"], "opcda");
294        assert_eq!(saved["bridge_host"], "localhost:7600");
295        assert!(saved.get("notes").is_none());
296
297        let raw = SettingRow::get(&pool, NEW_RUN_DRAFT_KEY)
298            .await
299            .unwrap()
300            .unwrap();
301        assert!(raw.value.get("notes").is_none());
302    }
303
304    #[tokio::test]
305    async fn malformed_saved_draft_is_an_explicit_server_error() {
306        let state = crate::test_support::in_memory_state().await;
307        SettingRow::upsert(
308            &state.pool,
309            NEW_RUN_DRAFT_KEY,
310            &json!({"driver": 42}),
311            Utc::now(),
312        )
313        .await
314        .unwrap();
315        let app = crate::build_router(state);
316
317        let response = app
318            .oneshot(
319                Request::get("/api/runs/draft")
320                    .body(Body::empty())
321                    .expect("request"),
322            )
323            .await
324            .expect("response");
325
326        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
327        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
328        assert_eq!(
329            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
330            json!({"error": "internal server error"})
331        );
332    }
333
334    #[tokio::test]
335    async fn draft_round_trips_source_metadata_and_omits_notes() {
336        let state = crate::test_support::in_memory_state().await;
337        let app = crate::build_router(state.clone());
338        let request_body = json!({
339            "driver": "opcda",
340            "template": "Yokogawa CentumVP",
341            "tagname": "Loop3.PV",
342            "server": "Sim.Server",
343            "bridge_host": "localhost:7600",
344            "process_type": "flow",
345            "controller_type": "pi",
346            "relay_amp": 5.0,
347            "cycles_skip": 1,
348            "cycles_count": 3,
349            "noise_protection_secs": 0,
350            "mrft_delay": 0,
351            "poll_interval_ms": 800,
352            "timeout_secs": 3600,
353            "op_timeout_secs": 30,
354            "restore_timeout_secs": 30,
355            "allow_uncertain_quality": true,
356            "direction": "reverse",
357            "tag_overrides": {
358                "pv": "Loop3.PV",
359                "mv": "Loop3.MV"
360            },
361            "source_driver": "simulator",
362            "source_direction": "direct",
363            "pv_range_high": 100.0,
364            "pv_range_low": 0.0,
365            "mv_range_high": 100.0,
366            "mv_range_low": 0.0,
367            "source_pv_range_high": 200.0,
368            "source_pv_range_low": 10.0,
369            "source_mv_range_high": 75.0,
370            "source_mv_range_low": 5.0,
371            "sim_gain": 1.0,
372            "sim_tau": 2.0,
373            "sim_dead_time": 5.0,
374            "sim_noise": 0.0,
375            "sim_seed": 123,
376            "sim_initial_pv": 50.0,
377            "sim_initial_mv": 50.0,
378            "write_pid": "moderate",
379            "yes": true,
380            "notes": "transient operator context"
381        });
382
383        let response = app
384            .clone()
385            .oneshot(
386                Request::put("/api/runs/draft")
387                    .header("content-type", "application/json")
388                    .body(Body::from(request_body.to_string()))
389                    .expect("request"),
390            )
391            .await
392            .expect("response");
393        assert_eq!(response.status(), StatusCode::OK);
394
395        let response = app
396            .oneshot(
397                Request::get("/api/runs/draft")
398                    .body(Body::empty())
399                    .expect("request"),
400            )
401            .await
402            .expect("response");
403        assert_eq!(response.status(), StatusCode::OK);
404        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
405        let saved: NewRunDraft = serde_json::from_slice(&body).unwrap();
406        assert_eq!(saved.source_driver, Some(TuneDriver::Simulator));
407        assert_eq!(saved.source_direction, Some(ControllerDirection::Direct));
408        assert_eq!(saved.source_pv_range_high, Some(200.0));
409        assert_eq!(saved.source_pv_range_low, Some(10.0));
410        assert_eq!(saved.source_mv_range_high, Some(75.0));
411        assert_eq!(saved.source_mv_range_low, Some(5.0));
412        assert!(
413            serde_json::from_slice::<serde_json::Value>(&body)
414                .unwrap()
415                .get("notes")
416                .is_none()
417        );
418    }
419
420    #[tokio::test]
421    async fn legacy_saved_draft_without_source_fields_still_deserializes() {
422        let state = crate::test_support::in_memory_state().await;
423        SettingRow::upsert(
424            &state.pool,
425            NEW_RUN_DRAFT_KEY,
426            &json!({
427                "driver": "opcda",
428                "template": "Yokogawa CentumVP",
429                "tagname": "Loop3.PV",
430                "server": "Sim.Server",
431                "bridge_host": "localhost:7600",
432                "process_type": "flow",
433                "controller_type": "pi",
434                "relay_amp": 5.0,
435                "cycles_skip": 1,
436                "cycles_count": 3,
437                "noise_protection_secs": 0,
438                "mrft_delay": 0,
439                "poll_interval_ms": 800,
440                "timeout_secs": 3600,
441                "op_timeout_secs": 30,
442                "restore_timeout_secs": 30,
443                "allow_uncertain_quality": false,
444                "direction": "reverse",
445                "tag_overrides": null,
446                "pv_range_high": 100.0,
447                "pv_range_low": 0.0,
448                "mv_range_high": 100.0,
449                "mv_range_low": 0.0,
450                "sim_gain": 1.0,
451                "sim_tau": 2.0,
452                "sim_dead_time": 5.0,
453                "sim_noise": 0.0,
454                "sim_seed": 123,
455                "sim_initial_pv": 50.0,
456                "sim_initial_mv": 50.0,
457                "write_pid": "moderate",
458                "yes": true
459            }),
460            Utc::now(),
461        )
462        .await
463        .unwrap();
464
465        let app = crate::build_router(state);
466        let response = app
467            .oneshot(
468                Request::get("/api/runs/draft")
469                    .body(Body::empty())
470                    .expect("request"),
471            )
472            .await
473            .expect("response");
474        assert_eq!(response.status(), StatusCode::OK);
475        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
476        let saved: NewRunDraft = serde_json::from_slice(&body).unwrap();
477        assert_eq!(saved.source_driver, None);
478        assert_eq!(saved.source_direction, None);
479        assert_eq!(saved.source_pv_range_high, None);
480        assert_eq!(saved.source_mv_range_low, None);
481        assert!(
482            serde_json::from_slice::<serde_json::Value>(&body)
483                .unwrap()
484                .get("notes")
485                .is_none()
486        );
487    }
488}