1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum DraftTagSource {
24 Template,
25 Custom,
26}
27
28#[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#[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#[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#[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 pub source_driver: Option<TuneDriver>,
89 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 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#[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#[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}