STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_pressure_feed.cpp
Go to the documentation of this file.
1/**
2 * PressureFeed: the sequencer's one reader of live calibrated pressure.
3 *
4 * Driven against a fake elodin-db so the whole path is exercised — connect, subscribe, decode,
5 * key by table, gate — with no real database and no boards.
6 *
7 * The two failures this is really here to prevent are both silent:
8 *
9 * 1. Reading the WRONG SENSOR. ControllerService keys its measurements by channel number, which
10 * is how its `P_copv` came to be assigned from channel 6 — "GN2 Regulated" on this rig, the
11 * regulated downstream pressure, not the COPV. Here the table id is computed from the role, so
12 * the test asserts that a packet on board 2's table lands under the board-2 role and nowhere
13 * else.
14 *
15 * 2. Acting on a number that must not be acted on. A PT whose model is "cubic" with no captured
16 * points publishes a smooth, plausible 0.0 PSI with calibration_status = 0. A script taking
17 * 90% of that computes a target of zero and then does nothing at all, successfully, while an
18 * operator watches the state enter and cleanly exit. Both that and a stale reading must be
19 * refused rather than returned.
20 */
21#include <arpa/inet.h>
22#include <netinet/in.h>
23#include <sys/socket.h>
24#include <unistd.h>
25
26#include <atomic>
27#include <chrono>
28#include <cstring>
29#include <iostream>
30#include <string>
31#include <thread>
32#include <vector>
33
35#include "config/Config.hpp"
37
39
40static int g_failures = 0;
41
42static void check(bool ok, const std::string& what) {
43 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
44 if (!ok)
45 g_failures++;
46}
47
48/** One calibrated-PT frame for table [hi, lo] (0x20 an ESP32 PT, 0x26 a LabJack T7 input). */
49static std::vector<uint8_t> ptFrame(uint8_t lo, uint8_t channel, float psi, uint8_t cal_status,
50 uint8_t hi = 0x20) {
52 /*timestamp_ns=*/1, channel, std::array<uint8_t, 3>{0, 0, 0}, psi,
53 /*raw_adc=*/12345u, cal_status);
54
56 msg.serialize(payload.data());
57
58 const uint32_t len = static_cast<uint32_t>(payload.size() + 4);
59 std::vector<uint8_t> f(8 + payload.size());
60 std::memcpy(f.data(), &len, 4);
61 f[4] = 0x01; // ty = TABLE
62 f[5] = hi; // calibrated PT table, high byte
63 f[6] = lo;
64 f[7] = 0x00;
65 std::memcpy(f.data() + 8, payload.data(), payload.size());
66 return f;
67}
68
69/**
70 * Fake elodin-db: accept one connection, drain whatever the client subscribes with, then write
71 * `blob` and hold the socket open so the feed stays connected.
72 */
73struct FakeDb {
74 std::thread th;
75 std::atomic<bool> run{true};
76 std::atomic<uint16_t> port{0};
77
78 void start(std::vector<uint8_t> blob) {
79 th = std::thread([this, blob = std::move(blob)]() {
80 int srv = socket(AF_INET, SOCK_STREAM, 0);
81 int opt = 1;
82 setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
83 struct sockaddr_in a{};
84 a.sin_family = AF_INET;
85 a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
86 a.sin_port = 0; // OS-assigned, so parallel runs cannot collide
87 if (bind(srv, reinterpret_cast<struct sockaddr*>(&a), sizeof(a)) < 0) {
88 close(srv);
89 return;
90 }
91 socklen_t sl = sizeof(a);
92 getsockname(srv, reinterpret_cast<struct sockaddr*>(&a), &sl);
93 port = ntohs(a.sin_port);
94 listen(srv, 1);
95
96 int c = accept(srv, nullptr, nullptr);
97 if (c < 0) {
98 close(srv);
99 return;
100 }
101 // Let the subscribe messages land before answering.
102 std::this_thread::sleep_for(std::chrono::milliseconds(50));
103 ssize_t unused = write(c, blob.data(), blob.size());
104 (void)unused;
105 while (run)
106 std::this_thread::sleep_for(std::chrono::milliseconds(10));
107 close(c);
108 close(srv);
109 });
110 for (int i = 0; i < 500 && port.load() == 0; i++)
111 std::this_thread::sleep_for(std::chrono::milliseconds(2));
112 }
113
114 void stop() {
115 run = false;
116 if (th.joinable())
117 th.join();
118 }
119};
120
121/**
122 * Fake elodin-db that REFUSES the first subscription, the way the real one does when the
123 * calibrated VTable does not exist yet.
124 *
125 * It says nothing back — which is the whole problem: a refusal is invisible to the C++ client,
126 * which sends request id 0 and never reads a reply. Data is published only once a RE-subscribe
127 * arrives, so this passes if and only if PressureFeed asks again.
128 */
130 std::thread th;
131 std::atomic<bool> run{true};
132 std::atomic<uint16_t> port{0};
133 std::atomic<int> subscribes{0};
134
135 void start(std::vector<uint8_t> blob, int publishAfterSubscribes) {
136 th = std::thread([this, blob = std::move(blob), publishAfterSubscribes]() {
137 int srv = socket(AF_INET, SOCK_STREAM, 0);
138 int opt = 1;
139 setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
140 struct sockaddr_in a{};
141 a.sin_family = AF_INET;
142 a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
143 a.sin_port = 0;
144 if (bind(srv, reinterpret_cast<struct sockaddr*>(&a), sizeof(a)) < 0) {
145 close(srv);
146 return;
147 }
148 socklen_t sl = sizeof(a);
149 getsockname(srv, reinterpret_cast<struct sockaddr*>(&a), &sl);
150 port = ntohs(a.sin_port);
151 listen(srv, 1);
152
153 int c = accept(srv, nullptr, nullptr);
154 if (c < 0) {
155 close(srv);
156 return;
157 }
158 // Each subscribe_tables() entry is a 10-byte MSG. Count them; publish only once
159 // enough have arrived that at least one must have been a RE-subscribe.
160 bool published = false;
161 uint8_t buf[512];
162 while (run) {
163 const ssize_t n = recv(c, buf, sizeof(buf), MSG_DONTWAIT);
164 if (n > 0)
165 subscribes += static_cast<int>(n / 10);
166 if (!published && subscribes.load() >= publishAfterSubscribes) {
167 ssize_t unused = write(c, blob.data(), blob.size());
168 (void)unused;
169 published = true;
170 }
171 std::this_thread::sleep_for(std::chrono::milliseconds(5));
172 }
173 close(c);
174 close(srv);
175 });
176 for (int i = 0; i < 500 && port.load() == 0; i++)
177 std::this_thread::sleep_for(std::chrono::milliseconds(2));
178 }
179
180 void stop() {
181 run = false;
182 if (th.joinable())
183 th.join();
184 }
185};
186
187/** Two PT boards laid out as the server profile lays them out. */
188static const char* kConfig = R"TOML(
189[boards.pt_board]
190type = "PT"
191ip = "127.0.0.1"
192board_id = 21
193enabled = true
194
195[boards.pt_board_2]
196type = "PT"
197ip = "127.0.0.1"
198board_id = 22
199enabled = true
200
201[sensor_roles_pt_board]
202"GN2 Regulated" = 6
203
204[sensor_roles_pt_board_2]
205"GN2 High" = 4
206)TOML";
207
208int main() {
209 std::cout << "=== PressureFeed ===" << std::endl;
210
212
213 // GN2 Regulated -> board 1 ch 6 -> 0x16. GN2 High -> board 2 ch 4 -> 0x34.
214 // 0x34 is outside the 0x11..0x1A window ControllerService filters on, which is exactly how it
215 // loses every board-2 sensor.
216 {
217 std::vector<uint8_t> blob;
218 const auto a = ptFrame(0x16, 6, 250.0f, /*cal=*/1);
219 const auto b = ptFrame(0x34, 4, 4321.0f, /*cal=*/1);
220 blob.insert(blob.end(), a.begin(), a.end());
221 blob.insert(blob.end(), b.begin(), b.end());
222
223 FakeDb db;
224 db.start(blob);
225 check(db.port.load() != 0, "fake elodin-db is listening");
226
228 feed.start(cfg, {"GN2 Regulated", "GN2 High"}, "127.0.0.1", db.port.load());
229 std::this_thread::sleep_for(std::chrono::milliseconds(400));
230
231 double psi = 0.0;
232 check(feed.read("GN2 Regulated", psi) == PressureFeed::Status::Ok && psi == 250.0,
233 "a board-1 reading lands under its own role");
234
235 psi = 0.0;
236 check(feed.read("GN2 High", psi) == PressureFeed::Status::Ok && psi == 4321.0,
237 "a BOARD-2 reading lands too — table 0x34, which a board-1 filter would drop");
238
239 check(feed.read("Nonexistent", psi) == PressureFeed::Status::NotSubscribed,
240 "a role nothing subscribed to is NotSubscribed, not a stale zero");
241
242 feed.stop();
243 db.stop();
244 }
245
246 // ── A LabJack T7 PT, beside an ESP32 PT with the same table low byte ──────────────────────
247 //
248 // "T7 Tank" is AIN6 on slot 1: {0x26, 0x16}. GN2 Regulated is {0x20, 0x16}. Keyed by the low
249 // byte alone, the two would land on one role; each must arrive under its own.
250 {
251 const fsw::config::Config c = fsw::config::load_from_string(std::string(kConfig) + R"TOML(
252[boards.labjack_1]
253type = "T7"
254ip = "127.0.0.1"
255board_id = 81
256
257[[boards.labjack_1.channels]]
258ain = 6
259kind = "PT"
260role = "T7 Tank"
261signal = "0-5V"
262full_scale = 1000.0
263)TOML");
264 check(c.t7_errors.empty(), "T7 fixture parses");
265 std::vector<uint8_t> blob;
266 const auto esp = ptFrame(0x16, 6, 250.0f, 1, 0x20);
267 const auto t7 = ptFrame(0x16, 6, 812.5f, 1, 0x26);
268 blob.insert(blob.end(), esp.begin(), esp.end());
269 blob.insert(blob.end(), t7.begin(), t7.end());
270
271 FakeDb db;
272 db.start(blob);
274 feed.start(c, {"GN2 Regulated", "T7 Tank"}, "127.0.0.1", db.port.load());
275 std::this_thread::sleep_for(std::chrono::milliseconds(400));
276
277 double psi = 0.0;
278 check(feed.read("T7 Tank", psi) == PressureFeed::Status::Ok && psi == 812.5,
279 "a T7 PT reading lands under its role, from table {0x26, 0x16}");
280 psi = 0.0;
281 check(feed.read("GN2 Regulated", psi) == PressureFeed::Status::Ok && psi == 250.0,
282 "and the ESP32 PT on {0x20, 0x16} keeps its own value");
283 feed.stop();
284 db.stop();
285 }
286
287 // ── An uncalibrated reading is refused, not returned ──────────────────────────────────────
288 {
289 FakeDb db;
290 db.start(ptFrame(0x16, 6, 0.0f, /*cal=*/0)); // the plausible-looking 0.0 PSI
291
293 feed.start(cfg, {"GN2 Regulated"}, "127.0.0.1", db.port.load());
294 std::this_thread::sleep_for(std::chrono::milliseconds(400));
295
296 double psi = -1.0;
297 const auto s = feed.read("GN2 Regulated", psi);
298 check(s == PressureFeed::Status::Uncalibrated,
299 "calibration_status = 0 is refused even though a number arrived");
300 check(psi == -1.0, "and no value is handed back to be acted on");
301 check(PressureFeed::explain("GN2 Regulated", s).find("UNCALIBRATED") != std::string::npos,
302 "the refusal says why, in words an operator can act on");
303
304 feed.stop();
305 db.stop();
306 }
307
308 // ── A reading that stops arriving goes stale ──────────────────────────────────────────────
309 {
310 FakeDb db;
311 db.start(ptFrame(0x16, 6, 250.0f, /*cal=*/1));
312
314 feed.start(cfg, {"GN2 Regulated"}, "127.0.0.1", db.port.load());
315 std::this_thread::sleep_for(std::chrono::milliseconds(300));
316
317 double psi = 0.0;
318 check(feed.read("GN2 Regulated", psi) == PressureFeed::Status::Ok, "fresh at first");
319
320 // Nothing more is published; the socket stays open, so this is the "db alive but this
321 // sensor stopped" case rather than a disconnect.
322 std::this_thread::sleep_for(std::chrono::milliseconds(PressureFeed::kMaxAgeMs + 300));
323 check(feed.read("GN2 Regulated", psi) == PressureFeed::Status::Stale,
324 "and stale once it stops arriving — a press loop must not cycle against a frozen "
325 "number");
326
327 feed.stop();
328 db.stop();
329 }
330
331 // ── A refused subscription is asked for again ────────────────────────────────────────────
332 //
333 // The stand, 2026-09-16: PressureFeed subscribed to the calibrated PT tables 146 ms BEFORE
334 // calibration_service registered them. The db answered "invalid msg id", subscribe_tables
335 // reported success because the socket write worked, and nothing ever retried — so the feed
336 // was blind for the life of the process and every dynamic state reading a pressure was
337 // refused entry with "has produced no reading yet" for the whole session.
338 {
339 std::vector<uint8_t> blob;
340 const auto a = ptFrame(0x16, 6, 250.0f, /*cal=*/1);
341 blob.insert(blob.end(), a.begin(), a.end());
342
344 // Two roles subscribe on connect (2 messages); publishing only at 3 means the feed must
345 // have re-sent at least one of them.
346 db.start(blob, /*publishAfterSubscribes=*/3);
347 check(db.port.load() != 0, "refusing fake db is listening");
348
350 feed.start(cfg, {"GN2 Regulated", "GN2 High"}, "127.0.0.1", db.port.load());
351
352 double psi = 0.0;
353 bool got = false;
354 for (int i = 0; i < 400; i++) { // up to ~4 s; the resubscribe interval is 1 s
355 if (feed.read("GN2 Regulated", psi) == PressureFeed::Status::Ok) {
356 got = true;
357 break;
358 }
359 std::this_thread::sleep_for(std::chrono::milliseconds(10));
360 }
361 check(got, "a table refused on first subscribe is re-subscribed and starts delivering");
362 check(got && psi > 249.0 && psi < 251.0, "and the value that arrives is the right one");
363 check(db.subscribes.load() >= 3, "the feed really did send a second subscribe");
364
365 feed.stop();
366 db.stop();
367 }
368
369 // ── Subscribing to nothing opens no socket at all ─────────────────────────────────────────
370 {
372 feed.start(cfg, {}, "127.0.0.1", 1); // port 1: nothing is there, and nothing should try
373 std::this_thread::sleep_for(std::chrono::milliseconds(100));
374 check(feed.subscribedRoles().empty(), "no roles, no subscription, no thread");
375 double psi = 0.0;
376 check(feed.read("GN2 High", psi) == PressureFeed::Status::NotSubscribed,
377 "and every read is NotSubscribed");
378 feed.stop();
379 }
380
381 // ── A role config does not declare is skipped rather than guessed at ──────────────────────
382 {
384 feed.start(cfg, {"Not On Any Board"}, "127.0.0.1", 1);
385 std::this_thread::sleep_for(std::chrono::milliseconds(100));
386 check(feed.subscribedRoles().empty(), "an unresolvable role subscribes to nothing");
387 feed.stop();
388 }
389
390 std::cout << (g_failures == 0 ? "\nAll pressure-feed checks passed.\n"
391 : "\nFAILURES: " + std::to_string(g_failures) + "\n");
392 return g_failures == 0 ? 0 : 1;
393}
static route_t * find(uint16_t pin)
Definition board_exti.c:20
static std::string explain(const std::string &role, Status s)
Human phrasing for a refusal, e.g.
static constexpr int kMaxAgeMs
How old a reading may be and still be acted on.
Live calibrated pressures, by ROLE NAME, for the sequencer.
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
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedPTMessage
Calibrated Pressure Transducer message for Elodin.
Config load_from_string(const std::string &text)
Parse config from an in-memory TOML string (SequencerService / ActuatorCommander hold it so).
Definition Config.cpp:458
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
Definition run.py:1
Fake elodin-db that REFUSES the first subscription, the way the real one does when the calibrated VTa...
void start(std::vector< uint8_t > blob, int publishAfterSubscribes)
std::atomic< uint16_t > port
std::atomic< int > subscribes
Fake elodin-db: accept one connection, write blob, then hold the socket open.
void start(std::vector< uint8_t > blob, bool close_after_write)
void start(std::vector< uint8_t > blob)
std::atomic< uint16_t > port
std::atomic< bool > run
The whole parsed config.toml, restricted to what C++ consumes.
Definition Config.hpp:264
std::vector< std::string > t7_errors
Definition Config.hpp:277
static int g_failures
static ArmAction feed(const Frame &f, uint32_t t)
Definition test_main.cpp:48
static std::vector< uint8_t > ptFrame(uint8_t lo, uint8_t channel, float psi, uint8_t cal_status, uint8_t hi=0x20)
One calibrated-PT frame for table [hi, lo] (0x20 an ESP32 PT, 0x26 a LabJack T7 input).
static void check(bool ok, const std::string &what)
int main()
static std::string t7(const std::string &channels, const std::string &extra="", const std::string &name="lj", int board_id=81)
Definition test_t7.cpp:93
static const uint8_t kConfig[]
Definition test_ubx.c:21