STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_fire_lifecycle.cpp
Go to the documentation of this file.
1/**
2 * FIRE lifecycle: configured duration, expiry target, extend, and the controller gate.
3 *
4 * This path had no test coverage at all, which is how `fire_duration_ms` went unread for so long —
5 * the keys lived in [controller_service] while the sequencer looked in [state_machine], so every
6 * burn silently ran the timer's 6000 ms default no matter what the config or GUI said. Nothing
7 * asserted a burn duration, so nothing noticed.
8 *
9 * Covers:
10 * 1. The configured duration is what actually runs (not the built-in default).
11 * 2. Expiry lands in the configured [fire] expiry_target, and does so by State rather than by
12 * name — renaming the state must not strand the system in fire.
13 * 3. FIRE_START on entry and FIRE_STOP on expiry reach controller_service, exactly once each.
14 * (The backend used to send these too; the sequencer is now the only writer to that gate.)
15 * 4. EXTEND_FIRE restarts the countdown at extended_ms.
16 */
17#include <arpa/inet.h>
18#include <netinet/in.h>
19#include <sys/socket.h>
20#include <unistd.h>
21
22#include <atomic>
23#include <chrono>
24#include <filesystem>
25#include <fstream>
26#include <iostream>
27#include <mutex>
28#include <set>
29#include <string>
30#include <thread>
31#include <vector>
32
33#include "DiabloPacketUtils.h"
35#include "control/HoldTimer.hpp"
37
38namespace fs = std::filesystem;
39namespace fs_alias = std::filesystem;
45using Clock = std::chrono::steady_clock;
46
47static int g_failures = 0;
48
49static void check(bool ok, const std::string& what) {
50 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
51 if (!ok)
52 g_failures++;
53}
54
55int main() {
56 std::cout << "=== FIRE lifecycle ===" << std::endl;
57
58 // ── 1. The configured duration is the one that runs ───────────────────────────────────────
59 {
60 const uint32_t kDuration = 700;
61 const uint32_t kExtended = 1400;
62 HoldTimer fm;
63
64 std::vector<std::pair<bool, long long>> notices; // (active, ms since start)
65 std::mutex m;
66 const auto t0 = Clock::now();
67 std::atomic<long long> expired_at{-1};
68 fm.start(HoldSpec{
69 kDuration, kExtended,
70 [&]() {
71 expired_at =
72 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0)
73 .count();
74 },
75 [&](bool active) {
76 std::lock_guard<std::mutex> lk(m);
77 notices.emplace_back(
78 active, std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0)
79 .count());
80 },
81 "FIRE"});
82 std::this_thread::sleep_for(std::chrono::milliseconds(kDuration + 400));
83
84 const long long at = expired_at.load();
85 check(at >= 0, "fire timer expired");
86 // The regression this guards: a 700 ms configured burn must not run for 6000 ms.
87 // This window used to be [-100, +250]. It was that loose because runTimer() counted sleep
88 // *calls* (`elapsed_ms += 50`) rather than elapsed time, so every iteration overshot and
89 // the error only ever accumulated one way. Against a steady_clock deadline the only slack
90 // left is scheduler wake latency, so the window can be tight — while staying generous
91 // enough on the upper side for a loaded CI box, and nowhere near the 6000 ms default.
92 check(at >= static_cast<long long>(kDuration) - 5 &&
93 at <= static_cast<long long>(kDuration) + 50,
94 "expiry honoured the CONFIGURED duration, not the built-in 6000 ms default");
95
96 std::lock_guard<std::mutex> lk(m);
97 check(notices.size() == 2, "exactly two controller notifications (start, stop)");
98 check(!notices.empty() && notices[0].first, "first notification is FIRE_START");
99 check(notices.size() > 1 && !notices[1].first, "second notification is FIRE_STOP");
100 check(notices.size() > 1 && notices[1].second >= static_cast<long long>(kDuration) - 5,
101 "FIRE_STOP is sent at expiry, not early");
102 }
103
104 // ── 2. EXTEND restarts the countdown at extended_ms ────────────────────────────────────────
105 {
106 const uint32_t kDuration = 500;
107 const uint32_t kExtended = 1200;
108 HoldTimer fm;
109
110 const auto t0 = Clock::now();
111 std::atomic<long long> expired_at{-1};
112 fm.start(HoldSpec{kDuration, kExtended,
113 [&]() {
114 expired_at = std::chrono::duration_cast<std::chrono::milliseconds>(
115 Clock::now() - t0)
116 .count();
117 },
118 nullptr, "FIRE"});
119 std::this_thread::sleep_for(std::chrono::milliseconds(300)); // inside the original window
120 fm.extend();
121 std::this_thread::sleep_for(std::chrono::milliseconds(kExtended + 500));
122
123 const long long at = expired_at.load();
124 check(at >= 0, "extended fire still expires");
125 // extend() restarts at extended_ms FROM NOW, so expiry is ~300 + 1200.
126 check(at > static_cast<long long>(kDuration) + 150,
127 "extend pushed expiry past the original duration");
128 // Tightened with the deadline timer. The remaining lower slack is the 300 ms sleep_for
129 // before extend(), not the countdown.
130 check(at >= 300 + static_cast<long long>(kExtended) - 20 &&
131 at <= 300 + static_cast<long long>(kExtended) + 60,
132 "extended window is extended_ms measured from the extend call");
133 }
134
135 // ── 3. stop() cancels without firing the expiry callback ──────────────────────────────────
136 {
137 HoldTimer fm;
138 std::atomic<int> notify_count{0};
139 std::atomic<bool> expired{false};
140 fm.start(HoldSpec{600, 1200,
141 [&]() {
142 expired = true;
143 },
144 [&](bool) {
145 notify_count++;
146 },
147 "FIRE"});
148 std::this_thread::sleep_for(std::chrono::milliseconds(150));
149 fm.stop(); // leaving the fire state early
150 std::this_thread::sleep_for(std::chrono::milliseconds(800));
151 check(!expired.load(), "leaving fire early cancels the expiry transition");
152 check(notify_count.load() == 2, "early exit still notifies the controller (start + stop)");
153 }
154
155 // ── 3b. extended_ms = 0 refuses extend(), so a fixed-window hold stays fixed ─────────────
156 {
157 // A characterization pulse is a duration the operator asked for and then weighed a mass
158 // against. Stretching it would make that mass describe a different window, so the flow
159 // hold sets extended_ms = 0 and extend() must decline rather than quietly restart.
160 HoldTimer fm;
161 std::atomic<long long> expired_at{-1};
162 const auto t0 = Clock::now();
163 fm.start(HoldSpec{300, 0,
164 [&]() {
165 expired_at = std::chrono::duration_cast<std::chrono::milliseconds>(
166 Clock::now() - t0)
167 .count();
168 },
169 nullptr, "Flow Test"});
170 std::this_thread::sleep_for(std::chrono::milliseconds(100));
171 check(fm.extend() == false, "extend() is refused when extended_ms is 0");
172 std::this_thread::sleep_for(std::chrono::milliseconds(400));
173 const long long at = expired_at.load();
174 check(at >= 295 && at <= 350, "the refused extend left the original window intact");
175 check(fm.extend() == false, "extend() is refused once the hold is over");
176 }
177
178 // ── 4. The expiry target is resolved as a State, so a rename cannot strand the system ─────
179 {
180 // This is the failure the refactor removes: expiry used to go through
181 // transitionTo(StateMachine::name(State::ARMED)) -> fromName(). If the name in the CSV /
182 // config no longer resolves, fromName() returns UNKNOWN and the transition is refused —
183 // while the timer has already stopped. A State carries no such ambiguity.
184 check(StateMachine::fromName("Armed") == State::ARMED, "a known name still resolves");
185 check(StateMachine::fromName("Burn Baby Burn") == State::UNKNOWN,
186 "an unknown name resolves to UNKNOWN — the hazard a State-typed target avoids");
187 check(StateMachine::stateId("Fire") == static_cast<uint8_t>(State::FIRE),
188 "stateId() gives the controller the fire id without a second copy of the enum");
189 check(StateMachine::stateId("Not A State") == 255,
190 "stateId() reports unknown rather than guessing an id");
191 }
192
193 // ── 5. The system STAYS in fire for the whole window, then transitions once ───────────────
194 {
195 // Sampling the reported state across the burn: it must remain the fire state throughout
196 // and change exactly once, at the end. A timer that fires early or an expiry that runs
197 // twice both show up here.
198 const uint32_t kDuration = 800;
199 HoldTimer fm;
200
201 std::atomic<bool> in_fire{true};
202 std::atomic<int> expiries{0};
203 const auto t0 = Clock::now();
204 fm.start(HoldSpec{kDuration, 2000,
205 [&]() {
206 expiries++;
207 in_fire = false; // stands in for transitionTo(fire_expiry_state_)
208 },
209 nullptr, "FIRE"});
210
211 bool left_early = false;
212 long long left_at = -1;
213 while (std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count() <
214 static_cast<long long>(kDuration) + 400) {
215 const long long now =
216 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
217 if (!in_fire.load()) {
218 if (left_at < 0)
219 left_at = now;
220 if (now < static_cast<long long>(kDuration) - 100)
221 left_early = true;
222 }
223 std::this_thread::sleep_for(std::chrono::milliseconds(20));
224 }
225 check(!left_early, "stayed in fire for the whole configured window (no early exit)");
226 check(left_at >= 0, "left fire at the end of the window");
227 check(expiries.load() == 1, "the expiry transition ran exactly once, not repeatedly");
228 check(fm.isActive() == false, "the hold timer is inactive once the burn has ended");
229 }
230
231 // ── 6. The sequencer hands PWM control to controller_service during fire ──────────────────
232 {
233 // In the fire state ActuatorCommander must stop commanding PWM roles entirely, so the
234 // controller is the only thing driving them. Outside fire it must command them again.
235 // If both drove them at once a burn would have two writers on the same valves.
236 const uint16_t port = 45913;
237 fs_alias::path dir = fs_alias::temp_directory_path() / "daq_fire_pwm_test";
238 fs_alias::remove_all(dir);
239 fs_alias::create_directories(dir);
240 {
241 std::ofstream act(dir / "state_machine_actuators.csv");
242 act << ",Idle,Fire\n";
243 act << "Plain Valve,OPEN,OPEN\n";
244 act << "Throttle,OPEN,OPEN\n";
245 }
246 const std::string cfg =
247 "[network]\nactuator_cmd_port = " + std::to_string(port) +
248 "\n\n[actuator_service]\nbind_address = \"0.0.0.0\"\n\n"
249 "[boards.act_board]\ntype = \"ACTUATOR\"\nip = \"127.0.0.1\"\nboard_id = 12\n"
250 "enabled = true\nnum_actuators = 10\n\n"
251 // The 4th element is what assigns an actuator to controller_service. It used to be a
252 // third `kind` value ("PWM"), but kind also carries NC/NO polarity, so an actuator
253 // could not be both PWM-driven and normally open — and since every shipped config
254 // needed NC/NO, nothing was ever marked "PWM" and this skip never engaged on a real
255 // rig. Note Throttle is "NO" here precisely to pin that the two are independent.
256 "[actuator_roles]\n\"Plain Valve\" = [\"NC\", 1, 12]\n"
257 "\"Throttle\" = [\"NO\", 2, 12, \"pwm_fuel\"]\n";
258
259 int sock = socket(AF_INET, SOCK_DGRAM, 0);
260 int reuse = 1;
261 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
262 struct sockaddr_in addr{};
263 addr.sin_family = AF_INET;
264 addr.sin_port = htons(port);
265 inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
266 bind(sock, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
267 struct timeval tv{.tv_sec = 0, .tv_usec = 300000};
268 setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
269
270 auto channelsFor = [&](State st) {
272 ac.setFireState(State::FIRE);
273 ac.load(cfg, (dir / "state_machine_actuators.csv").string());
274 ac.applyForState(st, /*is_transition=*/true);
275 std::set<uint8_t> seen;
276 uint8_t buf[1024];
277 for (int i = 0; i < 6; ++i) {
278 ssize_t n = recv(sock, buf, sizeof(buf), 0);
279 if (n <= 0)
280 break;
281 daq::PacketHeader hdr;
282 std::vector<daq::ActuatorCommand> cmds;
283 if (daq::parse_actuator_command_packet(buf, static_cast<size_t>(n), hdr, cmds))
284 for (const auto& c : cmds)
285 seen.insert(c.actuator_id);
286 }
287 return seen;
288 };
289
290 const auto idle = channelsFor(State::IDLE);
291 check(idle.count(1) && idle.count(2),
292 "outside fire the sequencer commands BOTH the plain valve and the assigned one");
293
294 const auto fire = channelsFor(State::FIRE);
295 check(fire.count(1), "in fire the sequencer still commands its own valves");
296 check(!fire.count(2),
297 "in fire the sequencer stops commanding the controller-assigned valve — one "
298 "writer drives it during a burn");
299
300 close(sock);
301 fs_alias::remove_all(dir);
302 }
303
304 std::cout << (g_failures == 0 ? "\nPASSED" : "\nFAILED") << std::endl;
305 return g_failures == 0 ? 0 : 1;
306}
static uint8_t stateId(const std::string &name)
Numeric id for a state name, or 255 if unknown.
static State fromName(const std::string &name)
Translate a CSV name (e.g.
Sends UDP actuator commands based on state transitions.
void setFireState(State s)
Set the Elodin client for publishing commanded state [0x32, ch] to the DB.
void applyForState(State state, bool is_transition=false)
Send actuator commands for the given state (one shot).
bool load(const std::string &config_content, const std::string &csv_path, const std::string &delay_csv_path="")
Load actuator roles from config content and state→actuator map from CSV.
A cancellable hold of an exact length, and nothing else.
Definition HoldTimer.hpp:60
bool isActive() const
Definition HoldTimer.hpp:89
void start(HoldSpec spec)
Begin a hold.
Definition HoldTimer.cpp:12
void stop()
Cancel the hold and reap the timer thread.
Definition HoldTimer.cpp:47
bool extend()
Restart the countdown at spec.extended_ms, measured from now.
Definition HoldTimer.cpp:88
Parses state_transitions.csv and validates state transitions.
static const emmc_port_t port
Definition emmc_stm32.c:210
State
Definition main.cpp:72
static bool ok
Definition flight_e2e.c:435
static idle_t idle
Definition idle_task.c:26
size_type count(const key_type &key) const
Definition json.hpp:19264
T & at(const key_type &key)
Definition json.hpp:19110
bool parse_actuator_command_packet(const uint8_t *buffer, size_t buffer_size, PacketHeader &header_out, std::vector< ActuatorCommand > &commands_out)
Parses an Actuator Command packet from buffer.
std::chrono::steady_clock Clock
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
What one hold is: how long, and who to tell.
Definition HoldTimer.hpp:22
static int g_failures
static void check(bool ok, const std::string &what)
int main()