Added ability to have arbitrary state transitions through psuedo coding - #69
Open
swissskimmilk wants to merge 23 commits into
Open
swissskimmilk wants to merge 23 commits into
swissskimmilk wants to merge 23 commits into
Conversation
…timing-sensitive ctests
…d for state scripts
… valve polarities the stand actually wires The load-cell half is a prerequisite for exercising the LC tare: the profile declared [boards.lc_board] and [boards.lc_board_2] but mapped no roles onto them, so digital-twin surfaced no load-cell channels at all and there was nothing to tare. Adds Fuel Scale on lc_board and LOX Scale on lc_board_2, both cubic. Alongside that, the profile had drifted from the stand: - LOX Upstream PT was missing entirely -- added on pt_board channel 5 with a cubic model and a pressure bar. - Dome CTRL and Ox Upstream were declared NO but are NC valves; fuel vent and lox vent were declared NC but are NO. Every one of those four was inverted, so the state machine drove them backwards. - fuel vent and lox vent had no rows in the actuator tables, leaving them unowned by the state machine. Added, with Vent opening both and Fuel Tank Vent opening only the fuel side. - Ox Upstream now opens in LOX Press, which is the point of that state. - Dome Vent, High Press Vent, Fuel Tank Vent and Ready could not reach Press Standby, so the only way out of a vent state was back through Vent.
…he selection
Every edit the editor made went through onSource(), i.e. React writing the
textarea's value. That replaces text from OUTSIDE the browser's editing pipeline:
no undo entry is recorded and most engines discard the stack they had, so Ctrl+Z
would not undo an autocomplete or a Tab at all. Edits now go through
execCommand('insertText'), deprecated and still the only way to join a textarea's
undo history; it also fires a normal input event, so React state keeps itself in
step. A false return falls back to the old write -- an edit that cannot be undone
beats an edit that does not happen.
Tab with a selection was worse than an undo problem. It built the next source as
slice(0, start) + " " + slice(end), which drops slice(start, end): indenting a
highlighted block DELETED it, unrecoverably. Tab now shifts every line the
selection touches and Shift+Tab outdents. A selection dragged to the start of the
next line does not indent that line, blank lines are not padded, and outdent stops
at column zero.
Also, from operating it:
- Enter carries the current line's indentation onto the new line.
- Backspace inside the leading indent takes a whole level, snapping to the
previous boundary (8 -> 4, 5 -> 4, 3 -> 0) instead of one space at a time.
- Tab is the only accept key, so Enter always means newline and the Shift+Enter
escape hatch -- which existed solely because the popup swallowed Enter -- is
gone.
- The popup opens with nothing typed, because it is how you learn what the valves
are called, but with NO row selected: with no prefix every name matches equally.
Tab is only claimed once a row is actually selected, by typing or with the
arrows, so an open reference list can never turn an indent into an insertion of
whatever sorted first.
The new tests cover the text arithmetic, including a brute-force case asserting no
selection can lose text. What they cannot cover is the undo integration itself:
jsdom does not implement execCommand, so that part is verified by hand.
…ts script The first scripted state on this profile: id 15, 30 s ceiling, returning to Press Standby on both a clean finish and a timeout. Its script cycles DOME_CTRL open and shut on a one-second beat for ten seconds -- enough to watch a script drive real hardware without committing the stand to anything. Its actuator, delay and transition columns come with it, so the state is complete in all three tables rather than existing in [[states]] alone; the sequencer resolves those by name and silently drops a state that is missing a column. config/scripts/ joins the ignore list beside config/config.toml and the deployed CSVs. It is the same kind of artifact -- written out of the active profile's scripts/ dir by the same deploy -- and the tracked source is the profile copy.
…scriptions # Conflicts: # daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv # daq-server/config/profiles/digital-twin/state_machine_actuators.csv # daq-server/config/profiles/digital-twin/state_transitions.csv # daq-server/test/test_integration.sh
…actually did Four failures found operating the stand today, all of the same shape: the sequencer knew exactly what had happened and told nobody. PressureFeed went blind for the life of the process. subscribe_tables() reports only whether the SOCKET WRITE succeeded -- it sends request id 0 and never reads a reply, and the C++ client has no ErrorResponse handling at all -- so a refused subscription is silent. On a cold start it is refused routinely: measured 2026-09-16, PressureFeed subscribed at 16:45:08.348 and calibration_service registered the calibrated VTables at 16:45:08.494, 146 ms later. The db answered "invalid msg id", nothing retried, and every dynamic state reading a pressure was refused entry with "has produced no reading yet" for the whole session -- decided by 150 ms of startup ordering. Tables that have never delivered are now re-asked for every second. Only those: a table that HAS delivered is demonstrably subscribed, and re-sending it would spawn a second stream task and double its rate. ScriptRunner emitted "start" and "stopped" and nothing else, so a loop that never ran and one that ran and timed out were indistinguishable -- reconstructing a single run took four journal queries across two services. It now reports the entry decision with the operands that decided it, numbers each pass, and every exit carries how far it got: Dyn State: while false at entry (117.7 < 82.1) -- body never runs, 0 iterations Dyn State: timeout after 4 iteration(s), condition 61.0 < 82.1 -- going to ... The condition text is a probe: it re-evaluates both sides and RESTORES `error`, so a failing read while logging cannot invent a run-ending error the real evaluation never hit. Safe to repeat because expressions here have no side effects. Re-entering a scripted state now says so. On 2026-09-16 the backend sent TRANSITION:Dynamic Test twelve times in 216 ms; each restarted the script from line 1 and fired a fresh OPEN -- twelve valve opens in a fifth of a second, with the discarded runs leaving no trace. Refusal reasons reached the caller flattened. A script-load failure replied "state script rejected at load" while the specific fault sat unused in refused_states_, and the sensor gate replied "<role> has no fresh calibrated reading" for all four of NoReading / Stale / Uncalibrated / NotSubscribed -- three different things to go and fix, and only the log knew which. Both now send what the log sends. publishState() also clears states the sensor gate would refuse RIGHT NOW, not just load refusals, recomputed every publish so a button greys and un-greys on its own; scriptStatusReport() reports those as BLOCKED beside the fixed REFUSED ones. Finally, a script waits 100 ms before its first statement. Both it and the state's actuator column go to the same board as UDP, but the boards poll one datagram per loop() and hotfire_config.h sets LOOP_DELAY_MS = 10, so the column's three retransmits take ~30 ms to drain and the script's first command queued behind them -- measured at 4 ms between the two without this. The wait is spent on the script's own thread, never in doTransitionTo: that runs on the command worker, and sleeping there would put 100 ms into the abort budget test_abort_ordering pins at 200 ms. A new test aborts 30 ms into the lead-in to prove it does not.
…, and stop discarding refusals Two independent mechanisms, both severed one step short of the operator. THE RACE. A session would occasionally start with no data in the GUI. Captured live 2026-09-16: boards -> db healthy (DB: 11293ok/0drop), calibration_service registered and publishing, raw entities reaching the browser, and ZERO calibrated entities for the whole session. The wire request id is one byte -- elodin-client writes `requestId & 0xff` at offset 7 -- so 1..255 is the entire space and cannot be widened. registerVTables sent 4485 subscriptions per pass while rotating ids over that space, so pendingSubscriptionReqIds was overwritten ~17x and, after a pass, described only the LAST 255 pairs sent. Reproduced exactly: [0x20,0x11] (PT1_Cal.CH1) went out with reqId 2, and after the pass reqId 2 resolved to [0x65,0xfe], a self-test table. So every rejection was attributed to the wrong pair -- the genuinely refused table stayed marked subscribed and was NEVER retried, while an innocent delivering table was un-marked and re-subscribed. [0x46,0x00] was re-sent 849 times in four hours, spawning a db stream task each time, and the loop never converged: 254 new every 5 s and 1.13M refusal lines in the journal. A pass is now capped at the id space with ids pinned to their pairs for its whole duration, so an id in flight names exactly one pair; the remainder waits for the next pass rather than reusing a live id. The list also shrank 4485 -> 279: the blanket 1..255 x 17 sweep for heartbeat/self-test now runs over CONFIGURED board ids only, because daq_bridge registers those two families only for boards in the config, and the dev fallbacks no longer run alongside a good config (they were inventing LC2 CH2/CH6 where the config declares active_connectors = [1]). A pair the db is delivering can no longer be un-marked at all, which enforces the duplicate-delivery invariant structurally. Retries back off and park. elodin-relay never listened for dbError at all, so every refusal there was silent and permanent -- the same failure the thin backend already had a handler for. THE SILENCE. The backend has always sent MessageType.ERROR for every rejection the sequencer reports, and nothing on the dashboard subscribed, so websocket.ts dropped the payload. refusalText() strips the wire "ERR:" prefix so a notification reads "State transition failed: GN2 High has no fresh calibrated reading" rather than leaking the prefix the caller branches on. It lives in its own module because server.ts opens sockets at import, so a function defined inside it can only be tested by copying it -- and a copy passes forever while the original drifts. allowedBitmask is no longer discarded. The sequencer computes it and masks out everything it would refuse, explicitly so the GUI can grey those states; the backend decoded it and dropped it. StateUpdate now carries it, plus per-state reasons parsed from the SCRIPTS report -- a channel that has existed since dynamic states shipped with no consumer anywhere.
…reys out
Pressing Press Standby -> Dynamic Test did nothing on 2026-09-16. No error, no
toast, no notification -- the sequencer had refused it for a good reason ("GN2
High has produced no reading yet") and that sentence went to stderr and nowhere
else. Finding it took a journal dive.
The backend had been reporting it the whole time. It sends MessageType.ERROR for
every rejection -- refused transitions, actuator commands, extend-fire, invalid
hold durations, controls locked -- and nothing on the dashboard subscribed, so
websocket.ts looked up listeners for 'error', found none, and silently dropped the
payload. One handler in GlobalStateSubscriber now feeds those into the existing
notification store, which covers every one of those paths without touching a
single call site. One-shot, not ongoing: a rejected click is an event that
happened, not a condition that persists, so it carries no key and nothing needs to
clear it.
States the sequencer will not accept now render greyed with the reason on hover,
from the allowedBitmask it has always published and the backend now forwards. An
absent mask means "no opinion", never "nothing allowed" -- a client that connects
before the sequencer's first publish must not find every button dead. The mask
covers the live sensor gate too, so a greyed state un-greys on its own when the
feed returns.
A greyed node still explains itself when clicked rather than being inert, which is
the whole point: being unable to press it and being told nothing are the same
failure from the operator's side.
The window.alert for a CSV-invalid transition goes through the same path. A modal
stole focus mid-procedure and read nothing like the refusals the sequencer sends
for the same class of mistake, so one rejection looked like two different failures
depending on which side caught it. Its existing test now pins the notification
instead of the alert.
…-press supply
Replaces the Dome CTRL blink with something that exercises the parts of the
scripting language that matter on a stand: a live sensor read, arithmetic on it,
and a loop that ends on a measured condition rather than a stopwatch.
start = pressure(HIGH_PRESS)
while pressure(GN2_HIGH) < start * 0.7:
open_valve(HIGH_PRESS_CTRL)
delay(0.4)
close_valve(HIGH_PRESS_CTRL)
delay(2.5)
The supply is latched once rather than read in the condition. pressure() is live
-- re-evaluated on every pass -- so leaving it inline would let the target fall as
the supply droops each time the valve cracks, and the loop could satisfy itself by
lowering the bar instead of raising the tank.
Timeout drops 30 s -> 10 s. Both targets stay Press Standby, whose column is
all-CLOSE: the landing state's column is the ONLY thing that closes a valve a
script left open, and load-time validation does not check that it is safe.
Whitespace only — `./format.sh` output taken verbatim, produced with clang-format 19.1.7, the version daq-server-ci.yml pins. Build clean and ctest 20/20 after it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.