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}