Repository navigation
Command track power on, and refuse to route over a dark layout (#149) - #176
Merged
Merged
Conversation
PicoDCC's tracks come up unpowered - the constructor drives the power pin low and power turns on only for <1>. Nothing sent one, so a cold start connected, reported healthy, accepted routes and issued throttle commands into rails with no current on them. After a station cutoff the only recovery was power-cycling the command station. <1> now goes out when the link comes up, and an operator or admin can switch power from the Control view. Sent from LayoutService rather than from the adapter: powering the layout is a decision, and every command must be recorded before it is written or the reply correlates to the wrong one. setTrackPower probes afterwards, so what is believed is the state the station reported, not the state requested. Track power off is not a Safe-Stop. The layout is already stopped by the most complete means there is, and an operator who switched it off to re-rail a wagon must not return to a system needing acknowledgement. It refuses new routes with a track-power-off rejection, abandons automation and gates its sweep, and latches nothing. Existing routes keep their locks: those are what stop a second train being routed over track this one is standing on. The sweep gate is not what stops an abandoned run resuming - the adopted set already does. It stops a route automation never took from departing, including a suspended route resumed while the layout is dark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #149.
PicoDCC's tracks come up unpowered —
PicoDccTrack's constructor drives the power pinlow, and power turns on only in response to
<1>. Nothing ever sent one. So a cold startconnected, reported the DCC leg healthy, accepted route requests, and issued throttle
commands into rails with no current on them.
It is worse after a fault. The station cuts power itself on a timing violation, a PIO
failure, or a Core 1 heartbeat failure, and power then stays off until something sends
<1>.Since nothing did, the only recovery was power-cycling the command station — the one
thing an operator standing in front of the screen cannot do.
#148 made the orchestrator able to see track power. This makes it able to act.
What lands
<1>when the link comes up. AnOn/Offcontrol on the Control view foradminandoperator. While the main track is observed dark, new routes are refused and automationis stopped.
IDccControllergainssetTrackPower(on),domain/dccWireFormat.tsgainsformatTrackPower(a bare<1>/<0>, which isDCCEX_TRACK_ALL— per-track control isdeliberately not offered, since the operator-facing concept is "the layout is live" or "the
layout is dead").
SimulatedDccAdapteranswers with the power frames a real station answerswith, and already defaulted to powered, so no existing test had to opt in.
Track power off is not a Safe-Stop
The line every decision here sits on. The layout is already stopped, by the most complete
means available — there is no current on the rails. Declaring an emergency over a state that
is itself the emergency's remedy would mean an operator who switched power off for two
minutes to re-rail a wagon came back to a Safe-Stopped system needing an acknowledgement.
It latches nothing and clears itself the moment
<p1 MAIN>arrives. What it does instead:RouteRejectionkind,track-power-off, whoseoperator-facing text is phrased as an instruction rather than a diagnosis — unlike every
other rejection in that union, this one has a remedy the operator can carry out from the
screen they are already looking at. Refused in
LayoutService.requestRoute, never inReservationService, which has noSystemHealthaccess and must not gain any.false, never onnull. Never-reported is already covered fromthe other side: if the station has not answered,
responsiveis false and the system isSafe-Stopped anyway. Refusing on
nulltoo would turn every start-up race into a rejectionnobody could explain.
routed over track this one is standing on, and that is more true unpowered, not less — the
train cannot be moved off it.
Two deviations from the issue, both deliberate
<1>is sent by the service, not bySerialDccAdapter.connect(). Two reasons thecodebase acquired after #149 was written: "the layout should be live" is a decision and
decisions do not belong in an adapter (safety rule 2); and every command must be recorded
before it is written, or #148's positional correlation attributes the reply to whatever
else was outstanding.
LayoutService.setTrackPowerdoes both in the right order.setTrackPowerprobes with<s>immediately afterwards, and that is the point ratherthan belt and braces. The command resolving means the bytes went out, and
dcc-link.mdD12'swhole argument is that a command's success is not evidence of its effect. What moves
mainPowerOnis the<p1 MAIN>that comes back, so the operator sees the state thestation reported — a
<1>that vanished leaves the badge where it was.Automation is stopped twice, and they are not the same job
Abandoning stops the run in flight. Without it, automation keeps issuing speed commands into
dead rails and the train leaps into motion the moment
<1>is sent — the ghost-movementfailure in a different costume.
Gating the sweep stops a run starting. My first pass claimed this was what stopped an
abandoned run resuming; that was wrong, and the test I wrote for it passed with the gate
removed.
AutomationService'sadoptedset already covers that, for the same reason it doesafter an emergency stop. What the gate actually covers is a route automation has never taken:
one granted while the layout was live and not yet under way when the power went, and — the
case
adoptedstructurally cannot cover, because it prunes on leavingactive— asuspended route an operator resumes while the layout is dark. The comment and the test
were both rewritten to say that, and the replacement test fails with the gate removed.
The gate is ANDed in at the call site rather than folded into
canIssueAutoCommand, the sameplacement and reasoning as #103's compile-gap gate: that predicate is a pure function of
status and mode, and power is a live observation off the link.
The operator surface
POST .../dcc-link/powerbehind a third role posture,requireNotMonitor. The two thatexisted were "admin only" (topology and config) and "any authenticated role" (driving-adjacent
recovery). Track power is neither: it is an operating control, so not admin-only, and
monitormust not have it — energising the rails is the most literal form of the authoritythat role does not have, since it is what makes every other command capable of an effect.
Written as a deny-list of one so a role added later has to be considered explicitly.
On the Control view's status strip, next to connection freshness and deliberately not folded
into it — a station can be perfectly responsive and the rails still dead, which is exactly
what used to be invisible. Three states, because
unknownis neither on nor off: drawn as"off" it sends someone hunting a fault that does not exist, drawn as "on" it is this whole
issue again. Explicit
On/Offbuttons rather than a toggle (M14's reason), with the currentstate's button disabled rather than hidden so it does not move under a finger. A 403 or a
network failure is shown next to the buttons, not only logged (M15's rule).
The
monitorrole gets the badge and no buttons. Reading "the rails are dead" is exactly whatthat role is for.
Tested
npm testfrom the repo root, this session:npm run test:e2e:71 passed (21.9s).npm run lintclean.New:
tests/scenario/track-power.scenario.test.ts, 7 cases — power commanded on at connectand believed only from the station's answer; a station-side cutoff observed without a
Safe-Stop; a route refused while dark; an operator switching power back on and routing
working again with nothing to acknowledge; power off as an ordinary operating state; locks
retained in the dark; and the transition being edge-triggered so a repeat of the same state
reports nothing.
Two cases added to
automation.scenario.test.ts: a run under way is abandoned and does notresume when power returns, and a route granted before the power went never departs. The
second fails with the sweep gate removed — checked, after the first version of it did not.
Unit tests on
formatTrackPower, including that it carries no track argument (a track nameappearing there would silently stop the programming track being powered).
Two e2e cases: an operator switching power off, with the request body asserted and the badge
following the station's reply; and the monitor seeing the badge with neither button. The
control-viewsnapshot fixture gained thedccLinkit was missing — it is not optional onStateSnapshot, so a fixture without it did not resemble the wire.Docs
docs/dcc-link.md— D10 rewritten (observed and gated), D12 rewritten (PicoDCC#4isclosed, and it landed with the core-1-latches / core-0-drains design this document had
guessed at), plus new D14 (why the service sends
<1>, not the adapter) and D15 (the twoautomation jobs).
docs/liveness.mdM17 for the strip control.docs/auth.mdfor the thirdrole posture.
CLAUDE.mdindex line, two new traps, and the open limit rewritten — a powercutoff is no longer silent on the wire.
docs/current-state.mdandREADME.md(Known Limits,Next Milestones) follow.
Upstream
bazauto/PicoDCC#59(closing#4and#42) is what makes a cutoff pushed rather thandiscovered at the next
<s>probe up to five seconds later. The probe stays as the backstopfor a station that goes dark without managing to say so — losing its UART, which is the one
case that cannot announce itself.