Skip to main content

rs_teststand_bridge/
command.rs

1//! What a user interface asks a host to do.
2//!
3//! The inbound half of the bridge. [`crate::MessageEvent`] travels out from the
4//! engine; this travels back, and both transports carry both, so a front end
5//! written against one works against the other unchanged.
6//!
7//! # Why an enum rather than a free-form call
8//!
9//! A remote caller cannot be handed the engine, and should not be handed a way
10//! to invoke arbitrary members of it either: that is a remote-code-execution
11//! surface on a machine wired to hardware. A closed set of commands is what a
12//! host can actually reason about, and what it can refuse.
13//!
14//! Serialized externally tagged, so the wire form is
15//! `{"command":"run","sequence_file":"...","sequence":"MainSequence"}`, one
16//! obvious discriminant, readable by a reader with a fixed schema.
17
18use serde::{Deserialize, Serialize};
19
20/// What to do to a running execution.
21///
22/// Named for what the engine calls each operation, so somebody reading the
23/// vendor documentation finds the verb they already know.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum ExecutionControl {
28    /// Stop at the next step and wait. The run keeps its place.
29    Break,
30    /// Carry on from a break. This is also what releases a breakpoint stop.
31    Resume,
32    /// Run the next step, then stop again without descending into it.
33    StepOver,
34    /// Stop at the first step inside whatever the next step calls.
35    StepInto,
36    /// Carry on until the current sequence returns, then stop.
37    StepOut,
38    /// Stop the run, letting cleanup run so hardware is left safe.
39    Terminate,
40    /// Stop the run without cleanup.
41    ///
42    /// Blunter than [`Terminate`](Self::Terminate) and rarely what a host
43    /// wants: anything a sequence would have switched off stays on.
44    Abort,
45}
46
47/// A request from a front end, addressed to the host that owns the engine.
48///
49/// Every variant is something the engine can do on its own thread, because that
50/// is where the host will run it. Nothing here returns a COM reference: a
51/// command's answer is a [`Response`](crate::Response), which is data.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "command", rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum Command {
56    /// Ask the engine what it is.
57    ///
58    /// Answers with `Engine.VersionString` and `Engine.Is64Bit`, which are the
59    /// members it reads. Named for them rather than for a greeting: a command
60    /// called `hello` describes the conversation, not the engine operation, and
61    /// somebody reading the vendor documentation would not find it.
62    ///
63    /// Cheap, and worth sending first: it distinguishes "the socket connected"
64    /// from "the engine is up", which are not the same and fail differently.
65    VersionString,
66
67    /// Log a user in, by name.
68    ///
69    /// The host looks the account up, checks the password, and makes it
70    /// current. An account with no password takes an empty string, which is
71    /// ordinary on a station where the operator is identified but not
72    /// authenticated.
73    Login {
74        /// The account's login name.
75        user_name: String,
76        /// The password to check. Empty when the account has none.
77        #[serde(default)]
78        password: String,
79    },
80
81    /// Log the current user out, leaving nobody logged in.
82    Logout,
83
84    /// Open a sequence file and hold it, without running anything.
85    ///
86    /// Separate from [`Start`](Self::Start) because loading is where a bad path
87    /// or a missing dependency shows up, and a panel wants that answer before
88    /// it offers a run button.
89    LoadFile {
90        /// Path to the sequence file, as the host's filesystem sees it.
91        path: String,
92    },
93
94    /// Start a sequence in the file already loaded.
95    Start {
96        /// Which sequence to run.
97        #[serde(default = "main_sequence")]
98        sequence: String,
99    },
100
101    /// Load a sequence file and start one of its sequences in one step.
102    Run {
103        /// Path to the sequence file, as the host's filesystem sees it.
104        sequence_file: String,
105        /// Which sequence to run. `MainSequence` unless the file says otherwise.
106        #[serde(default = "main_sequence")]
107        sequence: String,
108    },
109
110    /// Ask a running execution to stop.
111    ///
112    /// Termination, not abort: cleanup still runs, so hardware is left safe.
113    Terminate {
114        /// Which execution, as reported by
115        /// [`MessageEvent::execution_id`](crate::MessageEvent::execution_id).
116        execution_id: i32,
117    },
118
119    /// Change what a running execution is doing.
120    ///
121    /// One command carrying a verb rather than six commands, so the wire shape
122    /// stays stable as more controls land, and so a panel acting on whatever is
123    /// running can leave `execution_id` out instead of tracking it.
124    ///
125    /// The engine binding has had these members for a while. Without this
126    /// variant none were reachable from a client, which left a panel able to
127    /// start a run and stop it and do nothing in between.
128    Control {
129        /// Which execution. Absent means whatever the host has running.
130        #[serde(default, skip_serializing_if = "Option::is_none")]
131        execution_id: Option<i32>,
132        /// What to do to it.
133        control: ExecutionControl,
134    },
135
136    /// Read a value out of a running execution's context.
137    ///
138    /// `lookup` is an ordinary property path, `Locals.Result` or
139    /// `FileGlobals.SerialNumber`. This is how a panel gets data it was not
140    /// pushed: the host resolves the path and answers with the subtree as data,
141    /// because the reference itself could never leave the process.
142    ReadValue {
143        /// Which execution to read from.
144        execution_id: i32,
145        /// The property path to resolve.
146        lookup: String,
147    },
148
149    /// Stop the host after the current run finishes.
150    Shutdown,
151}
152
153/// The default sequence name, so a caller may omit it.
154fn main_sequence() -> String {
155    "MainSequence".to_owned()
156}
157
158impl Command {
159    /// A short name for logging, without rendering the whole command.
160    #[must_use]
161    pub const fn name(&self) -> &'static str {
162        match self {
163            Self::VersionString => "version_string",
164            Self::Login { .. } => "login",
165            Self::Logout => "logout",
166            Self::LoadFile { .. } => "load_file",
167            Self::Start { .. } => "start",
168            Self::Run { .. } => "run",
169            Self::Terminate { .. } => "terminate",
170            Self::Control { .. } => "control",
171            Self::ReadValue { .. } => "read_value",
172            Self::Shutdown => "shutdown",
173        }
174    }
175
176    /// Whether this command changes what the station is doing.
177    ///
178    /// A host that serves more than one panel wants to treat these differently
179    /// from reads: two observers are fine, two controllers are a decision
180    /// somebody has to make.
181    #[must_use]
182    pub const fn is_control(&self) -> bool {
183        matches!(
184            self,
185            Self::Run { .. }
186                | Self::Start { .. }
187                | Self::Terminate { .. }
188                | Self::Login { .. }
189                | Self::Logout
190                | Self::Shutdown
191        )
192    }
193}
194
195#[cfg(test)]
196mod tests {
197
198    #[test]
199    fn a_control_command_names_its_verb_on_the_wire() {
200        // What a panel sends. The verb is a field rather than part of the tag,
201        // so adding a control later does not change the shape a client parses.
202        let text = serde_json::to_string(&Command::Control {
203            execution_id: Some(4),
204            control: ExecutionControl::StepOver,
205        })
206        .unwrap_or_default();
207        assert_eq!(
208            text,
209            r#"{"command":"control","execution_id":4,"control":"step_over"}"#
210        );
211    }
212
213    #[test]
214    fn a_control_without_an_execution_omits_the_field() {
215        // Absent, not null. A panel with one run in view should not have to
216        // track its id, and a reader with a fixed schema must not meet a null.
217        let text = serde_json::to_string(&Command::Control {
218            execution_id: None,
219            control: ExecutionControl::Resume,
220        })
221        .unwrap_or_default();
222        assert_eq!(text, r#"{"command":"control","control":"resume"}"#);
223    }
224
225    #[test]
226    fn every_control_verb_round_trips() {
227        for control in [
228            ExecutionControl::Break,
229            ExecutionControl::Resume,
230            ExecutionControl::StepOver,
231            ExecutionControl::StepInto,
232            ExecutionControl::StepOut,
233            ExecutionControl::Terminate,
234            ExecutionControl::Abort,
235        ] {
236            let command = Command::Control {
237                execution_id: None,
238                control,
239            };
240            let text = serde_json::to_string(&command).unwrap_or_default();
241            let back: Command = serde_json::from_str(&text).unwrap_or(Command::Shutdown);
242            assert_eq!(back, command, "{text}");
243        }
244    }
245    use super::{Command, ExecutionControl};
246
247    #[test]
248    fn the_wire_form_is_tagged_by_a_command_field() {
249        // A front end in another language reads this discriminant first, so its
250        // spelling is part of the contract rather than an implementation detail.
251        let text = serde_json::to_string(&Command::VersionString).unwrap_or_default();
252        assert_eq!(text, r#"{"command":"version_string"}"#);
253    }
254
255    #[test]
256    fn a_run_may_omit_the_sequence_name() {
257        // Almost every file uses MainSequence, and requiring it of every caller
258        // is friction with no benefit.
259        let parsed: Command =
260            serde_json::from_str(r#"{"command":"run","sequence_file":"C:\\x.seq"}"#)
261                .unwrap_or(Command::Shutdown);
262        assert_eq!(
263            parsed,
264            Command::Run {
265                sequence_file: r"C:\x.seq".to_owned(),
266                sequence: "MainSequence".to_owned(),
267            }
268        );
269    }
270
271    #[test]
272    fn commands_round_trip() {
273        for command in [
274            Command::VersionString,
275            Command::Terminate { execution_id: 1 },
276            Command::ReadValue {
277                execution_id: 1,
278                lookup: "Locals.Result".to_owned(),
279            },
280            Command::Shutdown,
281        ] {
282            let text = serde_json::to_string(&command).unwrap_or_default();
283            let back: Command = serde_json::from_str(&text).unwrap_or(Command::VersionString);
284            assert_eq!(back, command, "round trip failed for {}", command.name());
285        }
286    }
287
288    #[test]
289    fn reads_are_not_control_but_runs_are() {
290        assert!(!Command::VersionString.is_control());
291        assert!(
292            !Command::ReadValue {
293                execution_id: 1,
294                lookup: "Locals".to_owned()
295            }
296            .is_control()
297        );
298        assert!(Command::Terminate { execution_id: 1 }.is_control());
299        assert!(Command::Shutdown.is_control());
300    }
301}