Skip to content

Command track power on, and refuse to route over a dark layout (#149) - #176

Merged
bazauto merged 1 commit into
mainfrom
dcc-track-power
Aug 24, 2026
Merged

bazauto merged 1 commit into
mainfrom
dcc-track-power

Conversation

@bazauto

@bazauto bazauto commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #149.

PicoDCC's tracks come up unpowered — PicoDccTrack's constructor drives the power pin
low, and power turns on only in response to <1>. Nothing ever sent one. So a cold start
connected, 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. An On / Off control on the Control view for admin and
operator. While the main track is observed dark, new routes are refused and automation
is stopped.

IDccController gains setTrackPower(on), domain/dccWireFormat.ts gains
formatTrackPower (a bare <1> / <0>, which is DCCEX_TRACK_ALL — per-track control is
deliberately not offered, since the operator-facing concept is "the layout is live" or "the
layout is dead"). SimulatedDccAdapter answers with the power frames a real station answers
with, 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:

  • Refuses new routes, with its own RouteRejection kind, track-power-off, whose
    operator-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 in
    ReservationService, which has no SystemHealth access and must not gain any.
  • Refuses on an observed false, never on null. Never-reported is already covered from
    the other side: if the station has not answered, responsive is false and the system is
    Safe-Stopped anyway. Refusing on null too would turn every start-up race into a rejection
    nobody could explain.
  • Keeps existing routes and their locks. The locks are what stop a second train being
    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 by SerialDccAdapter.connect(). Two reasons the
codebase 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.setTrackPower does both in the right order.

setTrackPower probes with <s> immediately afterwards, and that is the point rather
than belt and braces. The command resolving means the bytes went out, and dcc-link.md D12's
whole argument is that a command's success is not evidence of its effect. What moves
mainPowerOn is the <p1 MAIN> that comes back, so the operator sees the state the
station 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-movement
failure 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's adopted set already covers that, for the same reason it does
after 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 adopted structurally cannot cover, because it prunes on leaving active — a
suspended 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 same
placement 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/power behind a third role posture, requireNotMonitor. The two that
existed 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
monitor must not have it — energising the rails is the most literal form of the authority
that 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 unknown is 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/Off buttons rather than a toggle (M14's reason), with the current
state'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 monitor role gets the badge and no buttons. Reading "the rails are dead" is exactly what
that role is for.

Tested

npm test from the repo root, this session:

 Test Files  84 passed (84)
      Tests  1476 passed (1476)
 Test Files  27 passed (27)
      Tests  327 passed (327)

npm run test:e2e: 71 passed (21.9s). npm run lint clean.

New: tests/scenario/track-power.scenario.test.ts, 7 cases — power commanded on at connect
and 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 not
resume 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 name
appearing 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-view snapshot fixture gained the dccLink it was missing — it is not optional on
StateSnapshot, so a fixture without it did not resemble the wire.

Docs

docs/dcc-link.md — D10 rewritten (observed and gated), D12 rewritten (PicoDCC#4 is
closed, 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 two
automation jobs). docs/liveness.md M17 for the strip control. docs/auth.md for the third
role posture. CLAUDE.md index line, two new traps, and the open limit rewritten — a power
cutoff is no longer silent on the wire. docs/current-state.md and README.md (Known Limits,
Next Milestones) follow.

Upstream

bazauto/PicoDCC#59 (closing #4 and #42) is what makes a cutoff pushed rather than
discovered at the next <s> probe up to five seconds later. The probe stays as the backstop
for a station that goes dark without managing to say so — losing its UART, which is the one
case that cannot announce itself.

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>
@bazauto
bazauto merged commit de40096 into main Aug 24, 2026
1 check passed
@bazauto
bazauto deleted the dcc-track-power branch August 24, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track power is never commanded on, and a station power cutoff cannot be recovered from the UI

1 participant