Skip to main content

bhtune_cli/
driver.rs

1//! Constructs the selected [`bhtune_driver::Driver`] implementation from a [`TuneArgs`].
2
3use bhtune_driver::{Driver, FopdtConfig, OpcDaDriver, SimulatorDriver};
4
5use crate::args::{DriverKindArg, TuneArgs};
6
7/// The two tag names [`SimulatorDriver`] is configured with — fixed rather than derived
8/// from `--tagname`/a template, since the simulator has no DCS suffix convention at all (see
9/// `driver-simulator`'s two-tag-only contract).
10pub const SIMULATOR_PV_TAG: &str = "Sim.PV";
11pub const SIMULATOR_MV_TAG: &str = "Sim.MV";
12
13/// Builds the driver `args` selects. For `--driver simulator`, `args.tagname` is ignored;
14/// the caller must build its [`bhtune_core::LoopTags`] using [`SIMULATOR_PV_TAG`]/
15/// [`SIMULATOR_MV_TAG`] instead of deriving from a template.
16pub async fn build_with_poll_interval(
17    args: &TuneArgs,
18    poll_interval_ms: u64,
19) -> anyhow::Result<Box<dyn Driver>> {
20    match args.driver {
21        DriverKindArg::Opcda => {
22            let server = args
23                .server
24                .clone()
25                .ok_or_else(|| anyhow::anyhow!("--server is required with --driver opcda"))?;
26            // By the time `build` runs, `commands::tune::run` has already resolved
27            // `args.bridge_host` through `crate::config::resolve_bridge_host` (CLI > env >
28            // config file > default), so this `unwrap_or` is a defensive fallback for
29            // direct/test callers that bypass that resolution step, not the primary
30            // precedence mechanism.
31            let bridge_host = args
32                .bridge_host
33                .as_deref()
34                .unwrap_or(crate::config::DEFAULT_BRIDGE_HOST);
35            tracing::info!(bridge_host, server = %server, "connecting to opcda-bridge gateway");
36            let driver = OpcDaDriver::connect(bridge_host, server).await?;
37            Ok(Box::new(driver))
38        }
39        DriverKindArg::Simulator => {
40            tracing::info!(
41                gain = args.sim_gain,
42                tau = args.sim_tau,
43                dead_time = args.sim_dead_time,
44                "constructing simulator driver"
45            );
46            let config = FopdtConfig::new(
47                args.sim_gain,
48                args.sim_tau,
49                args.sim_dead_time,
50                poll_interval_ms as f32 / 1000.0,
51            )
52            .with_noise_amplitude(args.sim_noise);
53            let driver = SimulatorDriver::new(
54                SIMULATOR_PV_TAG,
55                SIMULATOR_MV_TAG,
56                config,
57                args.sim_initial_pv,
58                args.sim_initial_mv,
59                args.sim_seed,
60            );
61            Ok(Box::new(driver))
62        }
63    }
64}
65
66#[cfg(test)]
67pub async fn build(args: &TuneArgs) -> anyhow::Result<Box<dyn Driver>> {
68    build_with_poll_interval(args, args.poll_interval_ms).await
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::args::DirectionArg;
75    use bhtune_driver::TagWrite;
76
77    fn sim_args() -> TuneArgs {
78        TuneArgs {
79            tagname: "ignored".to_string(),
80            template: "Yokogawa CentumVP".to_string(),
81            process_type: crate::args::ProcessTypeArg::Flow,
82            controller_type: crate::args::ControllerTypeArg::Pi,
83            relay_amp: 10.0,
84            cycles_skip: None,
85            cycles_count: None,
86            noise_protection_secs: None,
87            mrft_delay: 0,
88            driver: DriverKindArg::Simulator,
89            bridge_host: None,
90            server: None,
91            sim_gain: 1.0,
92            sim_tau: 2.0,
93            sim_dead_time: 5.0,
94            sim_noise: 0.0,
95            sim_seed: 0,
96            sim_initial_pv: 50.0,
97            sim_initial_mv: 50.0,
98            pv_range_high: Some(100.0),
99            pv_range_low: Some(0.0),
100            mv_range_high: Some(100.0),
101            mv_range_low: Some(0.0),
102            direction: Some(DirectionArg::Reverse),
103            tag_overrides: None,
104            poll_interval_ms: 800,
105            timeout_secs: 3600,
106            notes: None,
107            yes: false,
108            write_pid: None,
109            op_timeout_secs: 30,
110            restore_timeout_secs: 30,
111            output: crate::output::OutputFormat::Table,
112        }
113    }
114
115    #[tokio::test]
116    async fn builds_a_working_simulator_driver() {
117        let driver = build(&sim_args()).await.unwrap();
118        let values = driver.read(&[SIMULATOR_MV_TAG.to_string()]).await.unwrap();
119        assert_eq!(values[0].value, "50");
120    }
121
122    #[tokio::test]
123    async fn simulator_uses_poll_interval_in_seconds_for_process_dynamics() {
124        let mut args = sim_args();
125        args.sim_tau = 1_000.0;
126        args.sim_dead_time = 0.0;
127        args.sim_initial_pv = 0.0;
128        args.sim_initial_mv = 0.0;
129        args.poll_interval_ms = 800;
130
131        let driver = build(&args).await.unwrap();
132        let outcome = driver
133            .write(&SIMULATOR_MV_TAG.to_string(), TagWrite::Float(100.0))
134            .await
135            .unwrap();
136        assert!(outcome.success);
137
138        let values = driver.read(&[SIMULATOR_PV_TAG.to_string()]).await.unwrap();
139        let pv = values[0].value.parse::<f32>().unwrap();
140        let expected = 100.0 * (1.0 - (-0.8_f32 / 1_000.0).exp());
141        assert!(
142            (pv - expected).abs() < 0.001,
143            "expected one 800 ms process step ({expected}), got {pv}"
144        );
145    }
146
147    #[tokio::test]
148    async fn opcda_driver_requires_a_server_flag() {
149        let mut args = sim_args();
150        args.driver = DriverKindArg::Opcda;
151        args.bridge_host = Some("127.0.0.1:1".to_string());
152        args.server = None;
153        let result = build(&args).await;
154        assert!(result.is_err());
155        assert!(result.err().unwrap().to_string().contains("--server"));
156    }
157
158    #[tokio::test]
159    async fn opcda_driver_falls_back_to_the_default_bridge_host_when_unset() {
160        // `build()` is normally only reached after `commands::tune::run` has already
161        // resolved `bridge_host` via `crate::config::resolve_bridge_host`, so a `None` here
162        // only happens for a direct/test caller -- confirms the fallback constant is used
163        // rather than e.g. an empty host string.
164        let mut args = sim_args();
165        args.driver = DriverKindArg::Opcda;
166        args.bridge_host = None;
167        args.server = Some("MockServer".to_string());
168        let err = build(&args).await.err().unwrap();
169        // `DEFAULT_BRIDGE_HOST` ("localhost:7600") has nothing listening in CI, so this
170        // still fails -- what matters is that it attempted the default host, not a blank one.
171        assert!(!err.to_string().is_empty());
172    }
173
174    #[tokio::test]
175    async fn opcda_driver_connects_and_reads_through_a_mock_bridge() {
176        use crate::test_support::{MockBridgeService, start_mock_server};
177        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue};
178
179        let (host, server) = start_mock_server(MockBridgeService {
180            read_response: ReadResponse {
181                values: vec![ProtoTagValue {
182                    tag_id: "Sim.MV".to_string(),
183                    value: "50".to_string(),
184                    quality: "Good".to_string(),
185                    timestamp: "2024-01-15 10:23:45".to_string(),
186                }],
187            },
188            ..Default::default()
189        })
190        .await;
191
192        let mut args = sim_args();
193        args.driver = DriverKindArg::Opcda;
194        args.bridge_host = Some(host);
195        args.server = Some("MockServer".to_string());
196
197        // Reaching a real read confirms `build()`'s OPC DA branch actually returned a
198        // connected, working `OpcDaDriver`, not just that `connect()` didn't error.
199        let driver = build(&args).await.unwrap();
200        let values = driver.read(&["Sim.MV".to_string()]).await.unwrap();
201        assert_eq!(values[0].value, "50");
202
203        server.shutdown().await;
204    }
205}