STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ScriptStates.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <fstream>
5#include <set>
6#include <sstream>
7
9
10namespace sequencer {
11
12bool is_valid_script_filename(const std::string& name) {
13 // Bare filename only. This value comes from operator-editable config and the web backend
14 // writes files at it, so anything that could escape the scripts/ directory is refused rather
15 // than sanitised — a sanitiser that silently rewrites a path is a rule nobody can see.
16 static const std::string kSuffix = ".script";
17 if (name.size() <= kSuffix.size())
18 return false;
19 if (name.compare(name.size() - kSuffix.size(), kSuffix.size(), kSuffix) != 0)
20 return false;
21 const std::string stem = name.substr(0, name.size() - kSuffix.size());
22 if (stem.empty())
23 return false;
24 for (char c : stem) {
25 const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
26 (c >= '0' && c <= '9') || c == '_' || c == '-';
27 if (!ok)
28 return false;
29 }
30 return true;
31}
32
33namespace {
34
35bool readFile(const std::string& path, std::string& out) {
36 std::ifstream f(path, std::ios::binary);
37 if (!f.is_open())
38 return false;
39 std::ostringstream ss;
40 ss << f.rdbuf();
41 out = ss.str();
42 return true;
43}
44
45std::string joinPath(const std::string& dir, const std::string& leaf) {
46 if (dir.empty())
47 return leaf;
48 if (dir.back() == '/')
49 return dir + leaf;
50 return dir + "/" + leaf;
51}
52
53/** One diagnostic rendered the way an operator should see it on the panel. */
54std::string renderDiag(const std::string& file, const fsw::script::Diagnostic& d) {
55 return file + " line " + std::to_string(d.line) + ": " + d.message;
56}
57
58} // namespace
59
60ScriptLoadResult load_dynamic_states(const fsw::config::Config& cfg, const std::string& config_text,
61 const ScriptLoadContext& ctx) {
63 (void)config_text; // states already parsed into cfg; kept for symmetry with other loaders
64
65 // Slug tables are built once and shared: every script validates against the same view of the
66 // rig that the pressure subscriber will later resolve roles through.
67 auto build = fsw::script::build_slug_tables(cfg);
68
69 // A name that cannot become a slug, or two names in one namespace that collide, makes EVERY
70 // script unresolvable rather than just one. Refuse every dynamic state with that reason rather
71 // than letting scripts fail one at a time with a confusing message apiece.
72 std::string config_level_problem;
73 for (const auto& b : build.bad)
74 config_level_problem += (config_level_problem.empty() ? "" : "; ") + b.where + " \"" +
75 b.canonical + "\" has no usable slug (got \"" + b.slug + "\")";
76 for (const auto& c : build.collisions)
77 config_level_problem += (config_level_problem.empty() ? "" : "; ") + c.where + "s \"" +
78 c.first + "\" and \"" + c.second + "\" both slug to " + c.slug;
79
80 for (const auto& sd : cfg.states) {
81 if (sd.script_file.empty())
82 continue; // not dynamic
83 if (sd.id < 0 || sd.id > 255 || sd.name.empty())
84 continue; // malformed entry; loadStatesFromConfig already skipped it
85 const State self = static_cast<State>(static_cast<uint8_t>(sd.id));
86
87 auto refuse = [&](const std::string& why) {
88 out.refused[self] = sd.name + ": " + why;
89 };
90
91 if (!config_level_problem.empty()) {
92 refuse("config cannot be resolved to script names — " + config_level_problem);
93 continue;
94 }
95
96 // ── 11 / 12: a script must not share a state with the other two owners of its lifetime ──
97 if (sd.is_flow) {
98 refuse(
99 "carries both is_flow and a script — the characterization hold and a script "
100 "would both own this state's timer");
101 continue;
102 }
103 if (self == ctx.fire_state) {
104 refuse("is the [fire] state and cannot also run a script");
105 continue;
106 }
107 if (sd.is_abort) {
108 refuse(
109 "is an abort state and cannot run a script — an abort must reach the valves "
110 "immediately, never behind an interpreter");
111 continue;
112 }
113
114 // ── 6: the filename, before anything touches the filesystem ─────────────────────────────
115 if (!is_valid_script_filename(sd.script_file)) {
116 refuse("script_file \"" + sd.script_file +
117 "\" is not a bare <name>.script filename (letters, digits, _ and - only)");
118 continue;
119 }
120
121 // ── 1 / 13: a bounded lifetime ──────────────────────────────────────────────────────────
122 if (sd.script_timeout_ms == 0) {
123 refuse(
124 "has a script but no script_timeout_ms — an unbounded script has no safe "
125 "degraded mode");
126 continue;
127 }
128 if (sd.script_timeout_ms > kMaxScriptTimeoutMs) {
129 refuse("script_timeout_ms is " + std::to_string(sd.script_timeout_ms) +
130 " ms, above the " + std::to_string(kMaxScriptTimeoutMs) +
131 " ms ceiling — a typo must not arm a long valve-open window");
132 continue;
133 }
134
135 // ── 2-5: both landing states, each resolved and each reachable ──────────────────────────
136 if (sd.script_return_target.empty()) {
137 refuse(
138 "has a script but no script_return_target — where it lands when the script "
139 "runs off its end must be stated, not defaulted");
140 continue;
141 }
142 if (sd.script_timeout_target.empty()) {
143 refuse(
144 "has a script but no script_timeout_target — where it lands when the timeout "
145 "expires must be stated, not defaulted");
146 continue;
147 }
148 struct Target {
149 const char* key;
150 const std::string& name;
151 State resolved;
152 };
153 Target targets[] = {
154 {"script_return_target", sd.script_return_target,
155 StateMachine::fromName(sd.script_return_target)},
156 {"script_timeout_target", sd.script_timeout_target,
157 StateMachine::fromName(sd.script_timeout_target)},
158 };
159 bool target_bad = false;
160 for (const Target& t : targets) {
161 if (t.resolved == State::UNKNOWN) {
162 refuse(std::string(t.key) + " \"" + t.name + "\" is not a known state");
163 target_bad = true;
164 break;
165 }
166 if (t.resolved == self) {
167 // [flow]'s ret == flow_state branch, and for the same reason: a state that lands
168 // in itself re-arms forever with its valves wherever the script left them, and
169 // there is no close anywhere in that loop.
170 refuse(std::string(t.key) + " is this state itself — it would re-arm forever");
171 target_bad = true;
172 break;
173 }
174 if (ctx.state_machine != nullptr && !ctx.state_machine->isAllowed(self, t.resolved)) {
175 refuse(std::string(t.key) + " \"" + t.name +
176 "\" is not an allowed transition from here — the state would have no way "
177 "out");
178 target_bad = true;
179 break;
180 }
181 }
182 if (target_bad)
183 continue;
184
185 // ── 10: the CSV column the script layers onto ───────────────────────────────────────────
186 if (ctx.has_actuator_column && !ctx.has_actuator_column(sd.name)) {
187 refuse(
188 "has no column in state_machine_actuators.csv — a dynamic state still needs "
189 "one, because entry applies it first to put every valve in a defined position "
190 "before the script runs");
191 continue;
192 }
193
194 // ── 6: the file itself ──────────────────────────────────────────────────────────────────
195 const std::string path = joinPath(joinPath(ctx.config_dir, "scripts"), sd.script_file);
196 std::string text;
197 if (!readFile(path, text)) {
198 refuse("cannot read " + path +
199 " — the script did not deploy with the profile, or the name is wrong");
200 continue;
201 }
202
203 // ── 7: parse, with every cap ────────────────────────────────────────────────────────────
205 if (!parsed.ok()) {
206 refuse(renderDiag(sd.script_file, parsed.diagnostics.front()));
207 continue;
208 }
209 if (parsed.program.empty()) {
210 refuse(sd.script_file + " is empty — a dynamic state must actually do something");
211 continue;
212 }
213
214 // ── 8 / 9: names, and every transition_to target against the matrix ─────────────────────
215 fsw::script::SlugTables tables = build.tables;
216 if (ctx.state_machine != nullptr) {
217 for (const auto& other : cfg.states) {
218 if (other.name.empty())
219 continue;
220 const State to = StateMachine::fromName(other.name);
221 if (to != State::UNKNOWN && ctx.state_machine->isAllowed(self, to))
222 tables.allowed_transitions.insert(fsw::script::slugify(other.name));
223 }
224 }
225 const auto issues = fsw::script::validate(parsed.program, tables);
226 if (!issues.empty()) {
227 refuse(renderDiag(sd.script_file, issues.front()));
228 continue;
229 }
230
231 // ── Usable. Collect the PT roles it reads, for the subscribe list. ──────────────────────
232 DynamicState ds;
233 ds.state = self;
234 ds.name = sd.name;
235 ds.program = std::move(parsed.program);
236 ds.timeout_ms = sd.script_timeout_ms;
237 ds.return_target = targets[0].resolved;
238 ds.timeout_target = targets[1].resolved;
239
240 std::set<std::string> roles;
241 ds.slug_names.resize(ds.program.slugs.size());
242 ds.slug_states.assign(ds.program.slugs.size(), State::UNKNOWN);
243 for (size_t i = 0; i < ds.program.slugs.size(); ++i) {
244 const auto& ref = ds.program.slugs[i];
245 switch (ref.ns) {
247 auto it = tables.actuators.find(ref.slug);
248 if (it != tables.actuators.end())
249 ds.slug_names[i] = it->second;
250 break;
251 }
253 auto it = tables.sensors.find(ref.slug);
254 if (it != tables.sensors.end()) {
255 ds.slug_names[i] = it->second; // canonical config name, not the slug
256 roles.insert(it->second);
257 }
258 break;
259 }
262 tables.states.count(ref.slug) ? tables.states.at(ref.slug) : std::string());
263 break;
264 }
265 }
266 ds.pressure_roles.assign(roles.begin(), roles.end());
267
268 out.dynamic[self] = std::move(ds);
269 }
270
271 return out;
272}
273
274} // namespace sequencer
static State fromName(const std::string &name)
Translate a CSV name (e.g.
bool isAllowed(State from, State to) const
Returns true if transitioning from→to is allowed.
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
std::string slugify(const std::string &name)
Canonical name -> slug: trim, uppercase, collapse internal whitespace runs to a single '_'.
Definition Slug.cpp:8
ParseResult parse(const std::string &source)
Lex and parse, applying every check that does not need config.
Definition Parser.cpp:813
TableBuild build_slug_tables(const fsw::config::Config &cfg)
Build the actuator / sensor / state slug tables from a loaded config.
std::vector< Diagnostic > validate(const ScriptProgram &program, const SlugTables &tables)
Config-dependent checks.
Definition Validate.cpp:69
std::string renderDiag(const std::string &file, const fsw::script::Diagnostic &d)
One diagnostic rendered the way an operator should see it on the panel.
std::string joinPath(const std::string &dir, const std::string &leaf)
bool is_valid_script_filename(const std::string &name)
True when name is a safe bare script filename: ^[A-Za-z0-9_-]+.script$, no separators.
static std::string readFile(const std::string &path)
constexpr uint32_t kMaxScriptTimeoutMs
Hard ceiling on script_timeout_ms, independent of per-state config.
ScriptLoadResult load_dynamic_states(const fsw::config::Config &cfg, const std::string &config_text, const ScriptLoadContext &ctx)
Resolve, load and validate every [[states]] entry carrying a script_file.
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
std::string message
Human wording, including the offending text.
std::vector< Diagnostic > diagnostics
std::vector< SlugRef > slugs
Every slug reference in SOURCE ORDER, with spans.
What a script's slugs are allowed to name, built from the loaded config.
One dynamic state, resolved and proven usable.
State return_target
Where the state lands when the script runs off its end.
std::vector< std::string > pressure_roles
Canonical PT role names this script reads, deduplicated.
std::vector< std::string > slug_names
Parallel to program.slugs — what each slug reference resolves to, decided at load.
State timeout_target
Where it lands when timeout_ms expires.
fsw::script::ScriptProgram program
std::vector< State > slug_states
Everything load_dynamic_states needs that it cannot get from Config alone.
const StateMachine * state_machine
Transition matrix, already loaded.
State fire_state
The [fire] state, so a script cannot be attached to the burn.
std::function< bool(const std::string &state_name)> has_actuator_column
True when the named state has a column in state_machine_actuators.csv.
std::string config_dir
Directory holding config.toml; scripts resolve to <config_dir>/scripts/<script_file>.
What a config load made of its dynamic states.
static SlugTables tables()
The tables every case validates against.