100def frame(hi: int, lo: int, body: bytes) -> bytes:
101 """ElodinClient framing: u32 len (body + 4) | ty | id hi | id lo | req | body."""
102 return struct.pack(
"<IBBBB", 4 + len(body), 1, hi, lo, 0) + body
105def raw_body(lo: int, value: float, ms: int = 0) -> bytes:
106 return struct.pack(
"<QB3xfIB", time.time_ns(), lo & 0x1F, value, ms, 0)
206def start_cal(cal: str, work: Path, port: int) -> subprocess.Popen:
207 return subprocess.Popen(
208 [cal,
"--config",
"config/config.toml",
"--elodin-port", str(port),
"--adjustments",
"adj.json"],
209 cwd=work, stdout=open(work /
"cal.log",
"a"), stderr=subprocess.STDOUT)
239 print(
"=== chain: t7_bridge --fake -> calibration_service ===", flush=
True)
241 work =
setup(root /
"chain", db.port)
243 check(db.wait_clients(1),
"calibration_service connects")
244 br = subprocess.Popen([bridge,
"--config", str(work /
"config/config.toml"),
"--fake"],
245 stdout=open(work /
"bridge.log",
"w"), stderr=subprocess.STDOUT)
247 check(db.wait_clients(2),
"t7_bridge connects")
249 base = (SLOT - 1) * 0x20
250 check({base + 1, base + 2, base + 3, base + 4, base + 14, base + CJC_OFFSET} <= db.raw_seen,
251 "bridge publishes AIN1/2/3/4, AIN14 and the cold junction (0x0F) raw")
252 check(
not any(lo & 0x1F >= 0x10
for lo
in db.raw_seen),
253 "bridge publishes no calibrated table (that is calibration_service's)")
256 pt, tc, lc = db.latest(1), db.latest(2), db.latest(4)
257 check(len(pt) > 0
and all(260 <= v <= 340
for v
in values(pt))
and all(r[3] == 1
for r
in pt),
258 f
"PT 4-20 mA -> psi, physics (default model): {values(pt)[-1:] if pt else 'none'}")
259 check(len(tc) > 0
and all(48 <= v <= 72
for v
in values(tc)),
260 f
"TC volts + cold junction -> °C: {values(tc)[-1:] if tc else 'none'}")
261 check(len(lc) > 0
and all(85 <= v <= 115
for v
in values(lc)),
262 f
"LC volts -> kg: {values(lc)[-1:] if lc else 'none'}")
265 check(len(rtd) > 0
and all(-171 <= v <= -129
for v
in values(rtd))
and all(r[3] == 1
for r
in rtd),
266 f
"RTD divider volts -> ohms -> °C (IEC 60751): {values(rtd)[-1:] if rtd else 'none'}")
269 check(len(pt) > 0
and abs((pt[-1][2] / 1e6 - 1.0) / 4.0 * 1000.0 - pt[-1][1]) < 15.0,
270 f
"calibrated table carries the µV code it was converted from: "
271 f
"{(pt[-1][2], round(pt[-1][1], 1)) if pt else 'none'}")
273 db.command(0, 0, 0.0)
275 db.command(8, 8104, 0.0)
281 cs =
store(work,
"cubic_calibration.json").get(
"cubic_state", {})
282 check(set(cs) == {
"8101",
"8104"}, f
"store holds the T7 PT and LC by uid: {sorted(cs)}")
283 check(all(cs[u].get(
"rawUnit") ==
"uV" for u
in cs),
"filed as microvolt channels")
284 check(cs.get(
"8101", {}).get(
"logicalCh") == 129
and cs.get(
"8104", {}).get(
"logicalCh") == 132,
285 "on logical channels 128 + ain, clear of the ESP32 boards")
286 check(all(cs[u].get(
"numPoints") == 1
for u
in cs),
"Zero All captured a point on each")
287 tares =
store(work,
"lc_tare.json").get(
"tares", [])
288 check(len(tares) == 1
and tares[0].get(
"entity") ==
"T71_Cal.AIN4" and 85 <= tares[0].get(
"offset_kg", 0) <= 115,
289 f
"tare filed under the T7 entity at the load it held: {tares}")
293 print(
"=== cubic: captured points on T7 inputs ===", flush=
True)
294 models =
'\n[calibration_model_labjack_1]\n"T7 PT" = "cubic"\n"T7 LC" = "cubic"\n'
295 signal = {1: 2.5 * 0.4, 2: 0.005259, 4: 0.005}
296 stop_stream = threading.Event()
298 def stream(db: FakeElodin) ->
None:
300 while not stop_stream.is_set():
301 pkts =
frame(T7, CJC_OFFSET,
raw_body(CJC_OFFSET, 22.0, k * 10))
302 for ain, v
in list(signal.items()):
303 pkts +=
frame(T7, (SLOT - 1) * 0x20 + ain,
raw_body(ain, v, k * 10))
308 def run(db: FakeElodin, work: Path, steps) ->
None:
311 check(db.wait_clients(1),
"calibration_service connects")
313 t = threading.Thread(target=stream, args=(db,), daemon=
True)
322 work =
setup(root /
"cubic", db.port, models)
324 def captures(db: FakeElodin) ->
None:
327 check(len(pt) > 0
and pt[-1][1] == 0.0
and pt[-1][3] == 0,
328 "an uncalibrated cubic T7 PT reads 0 with cal_status 0, like an ESP32 one")
330 signal[1], signal[4] = 1.0, 0.0
332 db.command(5, 8101, 0.0)
333 db.command(5, 8104, 0.0)
335 signal[1], signal[4] = 5.0, 0.020
337 db.command(5, 8101, 1000.0)
338 db.command(5, 8104, 500.0)
340 signal[1], signal[4] = 3.0, 0.010
342 pt, lc, tc = db.latest(1, 5), db.latest(4, 5), db.latest(2, 5)
343 check(len(pt) > 0
and abs(pt[-1][1] - 500.0) < 1.0
and pt[-1][3] == 1,
344 f
"PT captured at 1 V->0, 5 V->1000 reads 3 V as 500 psi: {values(pt)[-1:]}")
345 check(len(lc) > 0
and abs(lc[-1][1] - 250.0) < 0.5
and lc[-1][3] == 1,
346 f
"LC captured at 0->0, 20 mV->500 reads 10 mV as 250 kg: {values(lc)[-1:]}")
347 check(len(tc) > 0
and abs(tc[-1][1] - 150.0) < 0.5,
348 f
"TC at a 22 °C junction reads 150 °C: {values(tc)[-1:]}")
350 run(db, work, captures)
351 cs =
store(work,
"cubic_calibration.json").get(
"cubic_state", {})
352 check([round(p[
"adc"])
for p
in cs.get(
"8101", {}).get(
"points", [])] == [1_000_000, 5_000_000],
353 "the PT's points are stored in µV")
355 def after_restart(db: FakeElodin) ->
None:
358 check(len(pt) > 0
and abs(pt[-1][1] - 500.0) < 1.0
and pt[-1][3] == 1,
359 f
"the fit survives a calibration_service restart: {values(pt)[-1:]}")
361 run(db, work, after_restart)
406def export_rows(elodin: str, db: Path, out: Path, component: str) -> int:
407 """Rows elodin-db holds for one component, via its own CSV export."""
408 subprocess.run([elodin,
"export", str(db),
"-o", str(out),
"--format",
"csv",
"--flatten",
409 "--pattern", component], capture_output=
True, check=
False)
410 files = list(out.glob(f
"{component}*.csv"))
413 return max(0, len(files[0].read_text().splitlines()) - 1)
416def scenario_mixed(bridge: str, cal: str, daq_bridge: str, simulator: str, elodin: str,
418 print(
"=== mixed: real elodin-db, ESP32 streaming, T7 registering late ===", flush=
True)
420 work =
setup(root /
"mixed", port, MIXED_EXTRA.format(udp=udp))
421 cfg = str(work /
"config/config.toml")
423 procs: list[subprocess.Popen] = []
425 def spawn(args, log, cwd=work):
426 p = subprocess.Popen(args, cwd=cwd, stdout=open(work / log,
"w"), stderr=subprocess.STDOUT)
431 spawn([elodin,
"run", f
"127.0.0.1:{port}", str(db_dir)],
"elodin.log")
433 spawn([daq_bridge, cfg],
"daq_bridge.log")
434 spawn([sys.executable, simulator,
"--config", cfg,
"--port", str(udp),
"--only-type",
"PT",
435 "--skip-startup",
"--sensor-hz",
"50",
"--duration",
"30"],
"simulator.log")
439 spawn([bridge,
"--config", cfg,
"--fake"],
"bridge.log")
442 for p
in reversed(procs):
444 out = work /
"export"
445 esp =
export_rows(elodin, db_dir, out,
"PT1_Cal.CH1.pressure_psi")
446 t7_raw =
export_rows(elodin, db_dir, out,
"T71.AIN1.volts")
447 t7_cal =
export_rows(elodin, db_dir, out,
"T71_Cal.AIN1.pressure_psi")
448 cjc =
export_rows(elodin, db_dir, out,
"T71.CJC.temperature_c")
449 check(esp > 100, f
"ESP32 PT calibrated rows recorded: {esp}")
450 check(t7_raw > 500
and cjc > 500, f
"T7 raw and cold-junction rows recorded: {t7_raw}, {cjc}")
452 f
"T7 calibrated rows recorded though the T7 registered after the subscribe: {t7_cal}")
469 """A T7 keeps streaming after the bridge that started it dies, and refuses configuration
470 until that stream is stopped. --fake-state makes the stand-in T7 a file that outlives the
471 process, so kill -9 leaves it streaming exactly as a server torn down mid-run leaves a real
472 one, and the next bridge has to take it over."""
473 print(
"=== restart: kill -9 the bridge, start another on the same T7 ===", flush=
True)
475 work =
setup(root /
"restart", db.port)
476 devdir = work /
"t7state"
478 unit = devdir /
"boards.labjack_1.state"
479 cfg = str(work /
"config/config.toml")
481 def spawn(log: str) -> subprocess.Popen:
482 return subprocess.Popen([bridge,
"--config", cfg,
"--fake",
"--fake-state", str(devdir)],
483 stdout=open(work / log,
"w"), stderr=subprocess.STDOUT)
485 def flowing(after: int, timeout: float = 8.0) -> bool:
489 if db.raw_count > after + 50:
498 procs: list[subprocess.Popen] = []
500 a = spawn(
"bridge_a.log")
502 check(flowing(0),
"first bridge streams")
506 check(st.get(
"streaming") ==
"1" and st.get(
"stops") ==
"0",
507 f
"killed with -9, the T7 is left streaming to nobody ({st})")
510 b = spawn(
"bridge_b.log")
512 check(flowing(n),
"the next bridge streams from the same T7")
513 log_b = (work /
"bridge_b.log").read_text(errors=
"replace")
514 check(
"stopped a stream already running" in log_b,
515 "and says it stopped the stream the killed one left")
516 check(
"retrying" not in log_b,
"without a failed start first")
518 check(st.get(
"starts") ==
"2" and st.get(
"stops") ==
"1" and st.get(
"streaming") ==
"1",
519 f
"one stop, a second start ({st})")
523 check(
state(unit).get(
"streaming") ==
"0",
"a clean shutdown leaves the T7 idle")
526 c = spawn(
"bridge_c.log")
528 check(flowing(n),
"a bridge after a clean shutdown streams")
529 check(
"stopped a stream already running" not in
530 (work /
"bridge_c.log").read_text(errors=
"replace"),
531 "and finds nothing to stop")
540 ap = argparse.ArgumentParser()
541 ap.add_argument(
"--bridge", required=
True)
542 ap.add_argument(
"--cal", required=
True)
543 ap.add_argument(
"--daq-bridge", help=
"daq_bridge, for the mixed (real elodin-db) scenario")
544 ap.add_argument(
"--simulator", help=
"sim/board_simulator.py, for the mixed scenario")
545 ap.add_argument(
"--elodin-db", help=
"elodin-db binary (default: ~/.cargo/bin, then PATH)")
547 bridge, cal = os.path.abspath(a.bridge), os.path.abspath(a.cal)
548 with tempfile.TemporaryDirectory(prefix=
"t7_integration_")
as d:
554 if elodin
and a.daq_bridge
and a.simulator:
555 scenario_mixed(bridge, cal, os.path.abspath(a.daq_bridge), os.path.abspath(a.simulator),
558 print(
"=== mixed: SKIPPED (needs elodin-db, --daq-bridge and --simulator) ===", flush=
True)
560 for log
in sorted(root.glob(
"*/*.log")):
561 print(f
"--- {log.relative_to(root)} (tail) ---")
562 print(
"\n".join(log.read_text(errors=
"replace").splitlines()[-15:]))
563 print(
"FAILED: " + str(len(failures))
if failures
else "PASSED", flush=
True)
564 return 1
if failures
else 0
static ActuatorControllerState state