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>,
87 pub source_direction: Option<ControllerDirection>,
90 pub pv_range_high: Option<f32>,
91 pub pv_range_low: Option<f32>,
92 pub mv_range_high: Option<f32>,
93 pub mv_range_low: Option<f32>,
94 pub source_pv_range_high: Option<f32>,
95 pub source_pv_range_low: Option<f32>,
96 pub source_mv_range_high: Option<f32>,
97 pub source_mv_range_low: Option<f32>,
98 pub tag_sources: Option<DraftTagSources>,
100 pub value_sources: Option<DraftValueSources>,
101 pub sim_gain: Option<f32>,
102 pub sim_tau: Option<f32>,
103 pub sim_dead_time: Option<f32>,
104 pub sim_noise: Option<f32>,
105 pub sim_seed: Option<u64>,
106 pub sim_initial_pv: Option<f32>,
107 pub sim_initial_mv: Option<f32>,
108 pub write_pid: Option<ResponseLevel>,
109 pub yes: Option<bool>,
110}
111
112fn invalid_stored_draft(error: serde_json::Error) -> ApiError {
113 ApiError::Internal(anyhow::anyhow!(
114 "saved New Tune draft has an invalid shape: {error}"
115 ))
116}
117
118#[utoipa::path(
120 get,
121 path = "/api/runs/draft",
122 tag = "runs",
123 responses(
124 (status = 200, description = "The saved New Tune draft, or null when none exists.", body = Option<NewRunDraft>),
125 (status = 500, description = "The stored draft is malformed or the database failed.", body = ErrorBody),
126 ),
127)]
128pub(crate) async fn get_draft(
129 axum::extract::State(state): axum::extract::State<AppState>,
130) -> Result<Json<Option<NewRunDraft>>, ApiError> {
131 let Some(setting) = SettingRow::get(&state.pool, NEW_RUN_DRAFT_KEY).await? else {
132 return Ok(Json(None));
133 };
134 let draft = serde_json::from_value(setting.value).map_err(invalid_stored_draft)?;
135 Ok(Json(Some(draft)))
136}
137
138#[utoipa::path(
140 put,
141 path = "/api/runs/draft",
142 tag = "runs",
143 request_body = NewRunDraft,
144 responses(
145 (status = 200, description = "The draft was saved.", body = NewRunDraft),
146 (status = 500, description = "The database failed or the draft could not be stored.", body = ErrorBody),
147 ),
148)]
149pub(crate) async fn put_draft(
150 axum::extract::State(state): axum::extract::State<AppState>,
151 Json(draft): Json<NewRunDraft>,
152) -> Result<Json<NewRunDraft>, ApiError> {
153 let value = encode_draft(&draft, |value| serde_json::to_value(value))?;
154 let stored = SettingRow::upsert(&state.pool, NEW_RUN_DRAFT_KEY, &value, Utc::now()).await?;
155 let persisted = serde_json::from_value(stored.value).map_err(invalid_stored_draft)?;
156 Ok(Json(persisted))
157}
158
159fn encode_draft<E>(
160 draft: &NewRunDraft,
161 encode: impl FnOnce(&NewRunDraft) -> Result<serde_json::Value, E>,
162) -> Result<serde_json::Value, ApiError>
163where
164 E: std::fmt::Display,
165{
166 encode(draft)
167 .map_err(|error| ApiError::Internal(anyhow::anyhow!("failed to encode draft: {error}")))
168}
169
170pub fn router() -> Router<AppState> {
171 Router::new().route("/api/runs/draft", get(get_draft).put(put_draft))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use axum::body::{Body, to_bytes};
178 use axum::http::{Request, StatusCode};
179 use bhtune_core::ControllerDirection;
180 use bhtune_db::models::SettingRow;
181 use serde_json::json;
182 use tower::ServiceExt;
183
184 #[test]
185 fn draft_encoding_failure_is_an_internal_error() {
186 let error = encode_draft(
187 &NewRunDraft {
188 driver: None,
189 template: None,
190 tagname: None,
191 server: None,
192 bridge_host: None,
193 process_type: None,
194 controller_type: None,
195 relay_amp: None,
196 cycles_skip: None,
197 cycles_count: None,
198 noise_protection_secs: None,
199 mrft_delay: None,
200 poll_interval_ms: None,
201 timeout_secs: None,
202 op_timeout_secs: None,
203 restore_timeout_secs: None,
204 direction: None,
205 tag_overrides: None,
206 source_driver: None,
207 source_direction: None,
208 pv_range_high: None,
209 pv_range_low: None,
210 mv_range_high: None,
211 mv_range_low: None,
212 source_pv_range_high: None,
213 source_pv_range_low: None,
214 source_mv_range_high: None,
215 source_mv_range_low: None,
216 tag_sources: None,
217 value_sources: None,
218 sim_gain: None,
219 sim_tau: None,
220 sim_dead_time: None,
221 sim_noise: None,
222 sim_seed: None,
223 sim_initial_pv: None,
224 sim_initial_mv: None,
225 write_pid: None,
226 yes: None,
227 },
228 |_| Err::<serde_json::Value, _>("injected encoding failure"),
229 )
230 .unwrap_err();
231 assert!(
232 matches!(error, ApiError::Internal(message) if message.to_string().contains("encoding failure"))
233 );
234 }
235
236 #[tokio::test]
237 async fn missing_draft_is_null() {
238 let app = crate::build_router(crate::test_support::in_memory_state().await);
239 let response = app
240 .oneshot(
241 Request::get("/api/runs/draft")
242 .body(Body::empty())
243 .expect("request"),
244 )
245 .await
246 .expect("response");
247
248 assert_eq!(response.status(), StatusCode::OK);
249 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
250 assert_eq!(
251 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
252 json!(null)
253 );
254 }
255
256 #[tokio::test]
257 async fn put_replaces_draft_and_never_persists_notes() {
258 let state = crate::test_support::in_memory_state().await;
259 let pool = state.pool.clone();
260 let app = crate::build_router(state);
261 let request_body = json!({
262 "driver": "opcda",
263 "template": "Yokogawa CentumVP",
264 "bridge_host": "localhost:7600",
265 "notes": "transient operator context"
266 });
267
268 let response = app
269 .clone()
270 .oneshot(
271 Request::put("/api/runs/draft")
272 .header("content-type", "application/json")
273 .body(Body::from(request_body.to_string()))
274 .expect("request"),
275 )
276 .await
277 .expect("response");
278 assert_eq!(response.status(), StatusCode::OK);
279
280 let response = app
281 .oneshot(
282 Request::get("/api/runs/draft")
283 .body(Body::empty())
284 .expect("request"),
285 )
286 .await
287 .expect("response");
288 assert_eq!(response.status(), StatusCode::OK);
289 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
290 let saved: serde_json::Value = serde_json::from_slice(&body).unwrap();
291 assert_eq!(saved["driver"], "opcda");
292 assert_eq!(saved["bridge_host"], "localhost:7600");
293 assert!(saved.get("notes").is_none());
294
295 let raw = SettingRow::get(&pool, NEW_RUN_DRAFT_KEY)
296 .await
297 .unwrap()
298 .unwrap();
299 assert!(raw.value.get("notes").is_none());
300 }
301
302 #[tokio::test]
303 async fn malformed_saved_draft_is_an_explicit_server_error() {
304 let state = crate::test_support::in_memory_state().await;
305 SettingRow::upsert(
306 &state.pool,
307 NEW_RUN_DRAFT_KEY,
308 &json!({"driver": 42}),
309 Utc::now(),
310 )
311 .await
312 .unwrap();
313 let app = crate::build_router(state);
314
315 let response = app
316 .oneshot(
317 Request::get("/api/runs/draft")
318 .body(Body::empty())
319 .expect("request"),
320 )
321 .await
322 .expect("response");
323
324 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
325 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
326 assert_eq!(
327 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
328 json!({"error": "internal server error"})
329 );
330 }
331
332 #[tokio::test]
333 async fn draft_round_trips_source_metadata_and_omits_notes() {
334 let state = crate::test_support::in_memory_state().await;
335 let app = crate::build_router(state.clone());
336 let request_body = json!({
337 "driver": "opcda",
338 "template": "Yokogawa CentumVP",
339 "tagname": "Loop3.PV",
340 "server": "Sim.Server",
341 "bridge_host": "localhost:7600",
342 "process_type": "flow",
343 "controller_type": "pi",
344 "relay_amp": 5.0,
345 "cycles_skip": 1,
346 "cycles_count": 3,
347 "noise_protection_secs": 0,
348 "mrft_delay": 0,
349 "poll_interval_ms": 800,
350 "timeout_secs": 3600,
351 "op_timeout_secs": 30,
352 "restore_timeout_secs": 30,
353 "allow_uncertain_quality": true,
354 "direction": "reverse",
355 "tag_overrides": {
356 "pv": "Loop3.PV",
357 "mv": "Loop3.MV"
358 },
359 "source_driver": "simulator",
360 "source_direction": "direct",
361 "pv_range_high": 100.0,
362 "pv_range_low": 0.0,
363 "mv_range_high": 100.0,
364 "mv_range_low": 0.0,
365 "source_pv_range_high": 200.0,
366 "source_pv_range_low": 10.0,
367 "source_mv_range_high": 75.0,
368 "source_mv_range_low": 5.0,
369 "sim_gain": 1.0,
370 "sim_tau": 2.0,
371 "sim_dead_time": 5.0,
372 "sim_noise": 0.0,
373 "sim_seed": 123,
374 "sim_initial_pv": 50.0,
375 "sim_initial_mv": 50.0,
376 "write_pid": "moderate",
377 "yes": true,
378 "notes": "transient operator context"
379 });
380
381 let response = app
382 .clone()
383 .oneshot(
384 Request::put("/api/runs/draft")
385 .header("content-type", "application/json")
386 .body(Body::from(request_body.to_string()))
387 .expect("request"),
388 )
389 .await
390 .expect("response");
391 assert_eq!(response.status(), StatusCode::OK);
392
393 let response = app
394 .oneshot(
395 Request::get("/api/runs/draft")
396 .body(Body::empty())
397 .expect("request"),
398 )
399 .await
400 .expect("response");
401 assert_eq!(response.status(), StatusCode::OK);
402 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
403 let saved: NewRunDraft = serde_json::from_slice(&body).unwrap();
404 assert_eq!(saved.source_driver, Some(TuneDriver::Simulator));
405 assert_eq!(saved.source_direction, Some(ControllerDirection::Direct));
406 assert_eq!(saved.source_pv_range_high, Some(200.0));
407 assert_eq!(saved.source_pv_range_low, Some(10.0));
408 assert_eq!(saved.source_mv_range_high, Some(75.0));
409 assert_eq!(saved.source_mv_range_low, Some(5.0));
410 assert!(
411 serde_json::from_slice::<serde_json::Value>(&body)
412 .unwrap()
413 .get("notes")
414 .is_none()
415 );
416 }
417}