STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_script_states.cpp
Go to the documentation of this file.
1/**
2 * Dynamic-state refusals: a script that does not hold up makes its state NOT ENTERABLE, and the
3 * refusal happens before anything moves.
4 *
5 * The second half is the part a boolean cannot prove. `transitionTo() == false` would also pass an
6 * implementation that applied the state's actuator column, armed a timer, and only then noticed
7 * the script was broken — which is exactly the failure [flow]'s refusal branches exist to prevent
8 * ("Its valves cannot be stranded open because the transition into it is now refused",
9 * SequencerService.cpp). So every refusal case here asserts on the wire via BoardListener that the
10 * boards received NOTHING.
11 *
12 * Hermetic: loopback UDP on a port of its own, [database].port = 2 so no Elodin is needed, and
13 * [controller_service].host on TEST-NET-1 so nothing can reach a real controller.
14 */
15#include <atomic>
16#include <chrono>
17#include <filesystem>
18#include <fstream>
19#include <iostream>
20#include <string>
21#include <thread>
22
23#include "BoardListener.hpp"
27
28namespace fs = std::filesystem;
32
33static int g_failures = 0;
34
35static void check(bool ok, const std::string& what) {
36 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
37 if (!ok)
38 g_failures++;
39}
40
42static constexpr uint16_t kActPort = 45917;
43
44/** Everything a case may vary. Defaults describe a perfectly good dynamic state. */
45struct Cfg {
46 std::string script_file = "good.script";
47 std::string script_body =
48 "open_valve(VENT_VALVE)\n"
49 "delay(0.5)\n"
50 "close_valve(VENT_VALVE)\n"
51 "transition_to(PRESS_STANDBY)\n";
52 bool write_script = true;
53 uint32_t timeout_ms = 5000;
54 std::string return_target = "Press Standby";
55 std::string timeout_target = "Idle";
56 bool is_flow = false;
57 bool is_abort = false;
58 /** Put the script on the Fire state instead of Dyn State. */
59 bool on_fire_state = false;
60 /** Omit the dynamic state's column from state_machine_actuators.csv. */
62};
63
64static std::string writeConfig(const Cfg& f) {
65 const std::string dyn = f.on_fire_state ? "Fire" : "Dyn State";
66
67 {
68 std::ofstream t(g_dir / "state_transitions.csv");
69 t << ",Idle,Armed,Press Standby,Fire,Dyn State\n";
70 t << "Idle,1,1,1,0,1\n";
71 t << "Armed,1,1,1,0,1\n";
72 t << "Press Standby,1,1,1,1,1\n";
73 t << "Fire,0,1,0,1,0\n";
74 // Dyn State may reach Idle and Press Standby, but NOT Armed — so a script naming Armed is
75 // a transition the matrix forbids, which is its own case below.
76 t << "Dyn State,1,0,1,0,1\n";
77 }
78 {
79 std::ofstream a(g_dir / "state_machine_actuators.csv");
81 a << ",Idle,Armed,Press Standby,Fire\n";
82 a << "Vent Valve,CLOSE,CLOSE,CLOSE,CLOSE\n";
83 a << "Main Valve,CLOSE,CLOSE,CLOSE,OPEN\n";
84 } else {
85 // Main Valve is OPEN in the Dyn State column and CLOSED everywhere else. That is the
86 // tell: nothing but entering Dyn State can command channel 2 open, so "did the
87 // transition move anything?" is answerable without having to separate the attempt from
88 // the 1 Hz republish of the state the rig is already sitting in.
89 a << ",Idle,Armed,Press Standby,Fire,Dyn State\n";
90 a << "Vent Valve,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE\n";
91 a << "Main Valve,CLOSE,CLOSE,CLOSE,OPEN,OPEN\n";
92 }
93 }
94 {
95 std::ofstream d(g_dir / "state_machine_actuator_delays.csv");
97 d << ",Idle,Armed,Press Standby,Fire\n";
98 d << "Vent Valve,0,0,0,0\n";
99 d << "Main Valve,0,0,0,0\n";
100 } else {
101 d << ",Idle,Armed,Press Standby,Fire,Dyn State\n";
102 d << "Vent Valve,0,0,0,0,0\n";
103 d << "Main Valve,0,0,0,0,0\n";
104 }
105 }
106
107 fs::create_directories(g_dir / "scripts");
108 fs::remove(g_dir / "scripts" / "good.script");
109 if (f.write_script && !f.script_file.empty()) {
110 // A traversal-shaped name must be refused before anything opens it, so do not try to
111 // create a file for one.
112 if (f.script_file.find('/') == std::string::npos &&
113 f.script_file.find("..") == std::string::npos) {
114 std::ofstream s(g_dir / "scripts" / f.script_file);
115 s << f.script_body;
116 }
117 }
118
119 const fs::path cfg_path = g_dir / "config.toml";
120 std::ofstream c(cfg_path);
121 c << "[network]\nactuator_cmd_port = " << kActPort << "\n\n"
122 << "[database]\nport = 2\n\n"
123 // TEST-NET-1: SYNs are dropped rather than refused, so nothing here can reach a controller
124 // and a stray notify cannot masquerade as success.
125 << "[controller_service]\nhost = \"192.0.2.1\"\nport = 9999\n\n"
126 << "[boards.act_board]\ntype = \"ACTUATOR\"\nip = \"127.0.0.1\"\nboard_id = 12\n"
127 "enabled = true\nnum_actuators = 10\n\n"
128 << "[boards.pt_board]\ntype = \"PT\"\nip = \"127.0.0.1\"\nboard_id = 21\nenabled = true\n\n"
129 << "[sensor_roles_pt_board]\n\"Tank Pressure\" = 1\n\"Bottle Pressure\" = 2\n\n"
130 << "[actuator_roles]\n\"Vent Valve\" = [\"NC\", 1, 12]\n"
131 "\"Main Valve\" = [\"NC\", 2, 12]\n\n"
132 << "[state_machine]\n"
133 << "transitions_csv = \"" << (g_dir / "state_transitions.csv").string() << "\"\n"
134 << "actuator_csv = \"" << (g_dir / "state_machine_actuators.csv").string() << "\"\n"
135 << "actuator_delay_csv = \"" << (g_dir / "state_machine_actuator_delays.csv").string()
136 << "\"\n\n"
137 << "[fire]\nstate = \"Fire\"\nexpiry_target = \"Armed\"\nduration_ms = 800\n\n"
138 << "[[states]]\nid = 1\nname = \"Idle\"\nis_boot = true\n\n"
139 << "[[states]]\nid = 2\nname = \"Armed\"\n\n"
140 << "[[states]]\nid = 3\nname = \"Press Standby\"\n\n";
141
142 auto scriptKeys = [&](std::ofstream& o) {
143 if (!f.script_file.empty())
144 o << "script_file = \"" << f.script_file << "\"\n";
145 if (f.timeout_ms != 0)
146 o << "script_timeout_ms = " << f.timeout_ms << "\n";
147 if (!f.return_target.empty())
148 o << "script_return_target = \"" << f.return_target << "\"\n";
149 if (!f.timeout_target.empty())
150 o << "script_timeout_target = \"" << f.timeout_target << "\"\n";
151 };
152
153 c << "[[states]]\nid = 12\nname = \"Fire\"\n";
154 if (f.on_fire_state)
155 scriptKeys(c);
156 c << "\n";
157
158 c << "[[states]]\nid = 13\nname = \"Dyn State\"\n";
159 if (!f.on_fire_state)
160 scriptKeys(c);
161 if (f.is_flow)
162 c << "is_flow = true\n";
163 if (f.is_abort)
164 c << "is_abort = true\n";
165 c << "\n";
166
167 // [flow] needs a return target when is_flow is set, or the flow branch refuses first and the
168 // case would be measuring the wrong refusal.
169 if (f.is_flow)
170 c << "[flow]\nreturn_target = \"Idle\"\nduration_ms = 500\n\n";
171
172 return cfg_path.string();
173}
174
175/**
176 * Bring a service up on `cfg`, try to enter Dyn State, and report what happened.
177 * `moved` is true if ANY actuator command reached the boards during the attempt.
178 */
179struct Attempt {
180 bool accepted = false;
181 bool moved = false;
183 std::string reason;
184};
185
186static Attempt attemptEntry(const std::string& cfg_path, const char* target = "Dyn State") {
187 Attempt a;
189 if (!svc.init(cfg_path)) {
190 a.reason = "init failed";
191 return a;
192 }
193
194 // The listener starts AFTER init, so the boot state's own republish is outside the window and
195 // "moved" means exactly "the attempt to enter this state commanded something". BoardListener
196 // has no reset(), and adding one would be a second way to express the same thing.
197 //
198 // The 1 Hz republish of the state the rig is already in would eventually land here too, so the
199 // observation window is kept well under a second.
201 listener.start();
202
203 a.accepted = svc.transitionTo(std::string(target), 0, &a.reason);
204 std::this_thread::sleep_for(std::chrono::milliseconds(250));
205 a.landed = svc.currentState();
206
207 listener.stop();
208 // Channel 2 (Main Valve) is OPEN only in the target state's column, so seeing it commanded
209 // open is proof the transition got as far as applying actuators. Asserting "no bursts at all"
210 // would be wrong: the republish of the state the rig is ALREADY in keeps ticking throughout,
211 // and a refusal is not supposed to stop that.
212 a.moved = daqtest::firstCommand(listener.bursts(), /*ch=*/2, /*hw=*/1) >= 0;
213 return a;
214}
215
216/** A refusal case: the state must not be enterable AND no board may hear anything. */
217static void refuses(const Cfg& f, const std::string& what) {
218 const std::string path = writeConfig(f);
219 const Attempt a = attemptEntry(path);
220 const bool ok = !a.accepted && a.landed == State::IDLE && !a.moved;
221 check(ok, what);
222 if (!ok) {
223 std::cout << " accepted=" << a.accepted
224 << " landed=" << StateMachine::name(a.landed) << " moved=" << a.moved
225 << " reason=\"" << a.reason << "\"" << std::endl;
226 }
227}
228
229int main() {
230 std::cout << "=== Dynamic-state refusals ===" << std::endl;
231
232 g_dir = fs::temp_directory_path() / "daq_script_states_test";
233 fs::remove_all(g_dir);
234 fs::create_directories(g_dir);
235
236 // ── The control: a good dynamic state IS enterable ────────────────────────────────────────
237 {
238 const std::string path = writeConfig(Cfg{});
239 const Attempt a = attemptEntry(path);
240 // By name, never by enumerator: id 13 is State::VENT in the compiled enum, which is a
241 // different state from the "Dyn State" this config declares at that id. Resolving through
242 // the config's own table is the whole reason ids are not the authority.
243 const State dyn = StateMachine::fromName("Dyn State");
244 check(a.accepted && a.landed == dyn, "a valid dynamic state is enterable");
245 check(a.moved,
246 "entering it applies its actuator column (the baseline the script layers on)");
247 if (!a.accepted)
248 std::cout << " reason=\"" << a.reason << "\"" << std::endl;
249 }
250
251 // ── Bounded lifetime ──────────────────────────────────────────────────────────────────────
252 {
253 Cfg f;
254 f.timeout_ms = 0;
255 refuses(f, "no script_timeout_ms — refused, nothing moved");
256 }
257 {
258 Cfg f;
260 refuses(f, "script_timeout_ms above the ceiling — refused, nothing moved");
261 }
262
263 // ── Both landing states, each resolved and each reachable ─────────────────────────────────
264 {
265 Cfg f;
266 f.return_target = "";
267 refuses(f, "no script_return_target — refused, nothing moved");
268 }
269 {
270 Cfg f;
271 f.timeout_target = "";
272 refuses(f, "no script_timeout_target — refused, nothing moved");
273 }
274 {
275 Cfg f;
276 f.return_target = "Nowhere";
277 refuses(f, "script_return_target names no known state — refused, nothing moved");
278 }
279 {
280 Cfg f;
281 f.timeout_target = "Dyn State";
282 refuses(f, "script_timeout_target is the state itself — refused, nothing moved");
283 }
284 {
285 // Dyn State -> Armed is a 0 in the matrix.
286 Cfg f;
287 f.return_target = "Armed";
288 refuses(f, "script_return_target is not an allowed transition — refused, nothing moved");
289 }
290
291 // ── The script file ───────────────────────────────────────────────────────────────────────
292 {
293 Cfg f;
294 f.write_script = false;
295 refuses(f, "script file missing (did not deploy) — refused, nothing moved");
296 }
297 {
298 Cfg f;
299 f.script_file = "../../etc/passwd";
300 refuses(f, "script_file with path separators — refused, nothing moved");
301 }
302 {
303 Cfg f;
304 f.script_file = "notes.txt";
305 refuses(f, "script_file without the .script suffix — refused, nothing moved");
306 }
307 {
308 Cfg f;
309 f.script_body = "";
310 refuses(f, "empty script — refused, nothing moved");
311 }
312
313 // ── The script's content ──────────────────────────────────────────────────────────────────
314 {
315 Cfg f;
316 f.script_body = "open_valve(VENT_VALVE)\ndelay(0.5\n";
317 refuses(f, "script does not parse — refused, nothing moved");
318 }
319 {
320 Cfg f;
321 f.script_body = "open_valve(VNET_VALVE)\ndelay(0.5)\n";
322 refuses(f, "script names an unknown valve — refused, nothing moved");
323 }
324 {
325 Cfg f;
326 f.script_body = "x = pressure(NO_SUCH_SENSOR)\ndelay(x)\n";
327 refuses(f, "script names an unknown sensor — refused, nothing moved");
328 }
329 {
330 // Dyn State -> Armed is forbidden by the matrix, and this one is buried in an if so it is
331 // only found by checking EVERY transition_to target, not just the configured fallbacks.
332 Cfg f;
333 f.script_body =
334 "if pressure(TANK_PRESSURE) > 100:\n"
335 " transition_to(ARMED)\n"
336 "delay(1)\n";
337 refuses(f, "a nested transition_to the matrix forbids — refused, nothing moved");
338 }
339 {
340 Cfg f;
341 f.script_body = "while pressure(TANK_PRESSURE) < 100:\n open_valve(VENT_VALVE)\n";
342 refuses(f, "a loop that never delays — refused, nothing moved");
343 }
344
345 // ── One state, one owner ──────────────────────────────────────────────────────────────────
346 {
347 Cfg f;
348 f.is_flow = true;
349 refuses(f, "is_flow and a script on one state — refused, nothing moved");
350 }
351 {
352 Cfg f;
353 f.is_abort = true;
354 refuses(f, "an abort state with a script — refused, nothing moved");
355 }
356 {
357 Cfg f;
358 f.on_fire_state = true;
359 const std::string path = writeConfig(f);
360 const Attempt a = attemptEntry(path, "Fire");
361 check(!a.accepted && !a.moved, "the fire state with a script — refused, nothing moved");
362 }
363
364 // ── The CSV column the script layers onto ─────────────────────────────────────────────────
365 {
366 Cfg f;
367 f.drop_actuator_column = true;
368 refuses(f, "no actuator column for the dynamic state — refused, nothing moved");
369 }
370
371 // ── The refusal is reported, with a reason an operator can act on ─────────────────────────
372 {
373 Cfg f;
374 f.script_body = "open_valve(VNET_VALVE)\ndelay(0.5)\n";
375 const std::string path = writeConfig(f);
377 svc.init(path);
378 const std::string report = svc.scriptStatusReport();
379
380 check(report.find("REFUSED") != std::string::npos, "SCRIPTS reports the state as REFUSED");
381 check(report.find("VNET_VALVE") != std::string::npos,
382 "the reason names the offending slug, so the panel can show it on hover");
383 check(report.find("line 1") != std::string::npos, "the reason carries the line number");
384 check(report.find("END") != std::string::npos, "the report is terminated");
385 }
386 {
387 const std::string path = writeConfig(Cfg{});
389 svc.init(path);
390 const std::string report = svc.scriptStatusReport();
391 check(report.find(":OK:") != std::string::npos, "SCRIPTS reports a good script as OK");
392 }
393
394 fs::remove_all(g_dir);
395 std::cout << (g_failures == 0 ? "\nAll dynamic-state refusal checks passed.\n"
396 : "\nFAILURES: " + std::to_string(g_failures) + "\n");
397 return g_failures == 0 ? 0 : 1;
398}
static State fromName(const std::string &name)
Translate a CSV name (e.g.
static std::string name(State s)
Human-readable name for a State (e.g.
Collects UDP actuator packets, folding the 3x retransmit of one batch into a single burst.
std::vector< Burst > bursts() const
Top-level sequencer service.
bool init(const std::string &config_path="config/config.toml")
Load config, CSVs, connect to Elodin.
bool transitionTo(const std::string &state_name)
Request a state transition by name (e.g.
std::string scriptStatusReport() const
What this process made of its dynamic states at startup, as newline-terminated text.
Parses state_transitions.csv and validates state transitions.
State
Definition main.cpp:72
long target
Definition main.cpp:38
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
return o
Definition json.hpp:13897
long long firstCommand(const std::vector< Burst > &bursts, uint8_t ch, uint8_t hw, long long after_ms=-1)
When ch was first commanded to hw at or after after_ms.
constexpr uint32_t kMaxScriptTimeoutMs
Hard ceiling on script_timeout_ms, independent of per-state config.
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
Bring a service up on cfg, try to enter Dyn State, and report what happened.
std::string reason
Everything a case may vary.
bool on_fire_state
Put the script on the Fire state instead of Dyn State.
uint32_t timeout_ms
bool write_script
std::string return_target
std::string timeout_target
std::string script_file
bool drop_actuator_column
Omit the dynamic state's column from state_machine_actuators.csv.
std::string script_body
std::string string() const
static int g_failures
static fs::path g_dir
static constexpr uint16_t kActPort
static std::string writeConfig(const Cfg &f)
static void refuses(const Cfg &f, const std::string &what)
A refusal case: the state must not be enterable AND no board may hear anything.
static void check(bool ok, const std::string &what)
static Attempt attemptEntry(const std::string &cfg_path, const char *target="Dyn State")
int main()