Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ One line per area: enough to know whether it is what you are touching. The long
| **One renderer, live mimic** (#75, #63, #82, #129) | `TrackDiagram` is the **only** thing that draws the railway. State wins the colour channel; a route is a halo drawn from a **walk** along held track; three global layers, not per-tile groups. | `liveness.md` |
| **The control plane** (#165) | `ControlView` (was `MonitorView`) is the mimic **and** the screen the layout is driven from: `ThrottleCard` overlays per loco, `Normal`/`Reverse` on each point-key row. **Track is never a button**; a card for an `auto`-route loco opens *armed*; a refused command is shown, not just logged. Tab reads *Control*, or *Monitor* for the `monitor` role, which gets no controls. | `liveness.md` M10–M16 |
| **Control view e2e** | `tests/e2e/control-view.spec.ts` — the one spec that mounts this view, reading the rendered SVG back in one `evaluate`, plus which controls each role is offered. | `liveness.md` |
| **Point position feedback** (#25) | `commandedPosition` / `confirmedPosition` / six-state `confirmation`; **`effectivePosition` decides which is trusted**. Nothing is persisted. Every fault kind Safe-Stops; the escape hatch is `positionFeedback: 'none'`. | `point-feedback.md` |
| **Point position feedback** (#25, #167) | `commandedPosition` / `confirmedPosition` / seven-state `confirmation`; **`effectivePosition` decides which is trusted**. Nothing is persisted. Every *fault* kind Safe-Stops; the escape hatch is `positionFeedback: 'none'`. A controller must **re-assert every 30 s** — silence past the window degrades the point to `stale`, which latches nothing. | `point-feedback.md` |
| **Per-loco braking** (#6) | `domain/braking.ts` is pure; `BrakingService` plans and `LayoutService` executes one chained timer on `IClock`. Unmeasured intermediate track **refuses** — never guess a stopping distance. | `braking.md` |
| **Sub-block sensor position** (#77) | `position_toward_block_id` + `position_offset_mm`, anchored to an operator-owned **block id**. A **rising edge** sets the fix and it decays by a worst-case travel allowance; **a clear beam still clears nothing.** Consumed as the `lead` term in `remainingRouteDistanceMm`. | `sensor-position.md` |
| **Automation engine** (#7) | Drives an `auto`-authority route: departs, runs, brakes, crawls, berths. The invariant is that **a train under automation never passes the end of its authority**. A 250 ms sweep, not an occupancy hook; the stop target is a **berthing beam**. | `automation.md` |
Expand Down Expand Up @@ -260,6 +260,13 @@ the id (`grep -n "^### D9" docs/sensor-trust.md`) before concluding any of these
- A point `command` arms the confirmation deadline; a **`query` deliberately does not** —
arming on query would halt the layout at boot for any instrumented point whose controller
was powered off (`point-feedback.md` D6).
- **A `stale` point latches no `PointFault` and does not Safe-Stop, unlike every other
non-confirmed state** — silence is a device dying, not lying, so it degrades like a stale
sensor rather than faulting like a timeout. It still suspends any route holding it, which
is the *route's* fault, not the point's (`point-feedback.md` D11, D12).
- **`resumeRoute` deliberately has no staleness precondition** — resuming re-commands every
held point, so the resume itself probes whether the controller is alive and settles it in
8 s. Adding one would block the probe (`point-feedback.md` D12).
- A **`retained` point reading is dropped with a warn, not faulted** — it confirms nothing
and arms nothing. A malformed or id-mismatched payload still faults, retained or not
(`point-feedback.md`).
Expand Down
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ Implemented:
- Latched route faults (`SystemHealth.routeFaults`) with per-route operator acknowledge,
covering an unexpected occupancy, a rejected point command, a reserved block whose
occupancy stops being determinable mid-route, and a held point that fails to confirm
- Point position confirmation (#25) — a point controller reports its observed position on
`point/{pointId}/reading` (never retained; restart recovery is a live `point/*/query`),
and the backend keeps *commanded* and *confirmed* position as separate fields. Opt in per
point: `positionFeedback: 'required'` means commanded position is no longer trusted on its
own, and a point that fails to confirm within 8 seconds latches a fault and Safe-Stops —
see `docs/point-feedback.md`
- Point position confirmation (#25, #167) — a point controller reports its observed
position on `point/{pointId}/reading` (never retained; restart recovery is a live
`point/*/query`), and the backend keeps *commanded* and *confirmed* position as separate
fields. Opt in per point: `positionFeedback: 'required'` means commanded position is no
longer trusted on its own, and a point that fails to confirm within 8 seconds latches a
fault and Safe-Stops. The controller must also **re-assert every 30 s**; one that goes
quiet degrades the point to `stale`/`unknown` past a 90 s window, which refuses routes
over it without faulting anything — see `docs/point-feedback.md`
- WebSocket state streaming to the frontend
- Local username/password authentication with role-based access (`admin` /
`operator` / `monitor`) — see `docs/auth.md` for the scheme and its threat model
Expand Down Expand Up @@ -586,7 +588,13 @@ feedback hardware is fitted yet. For those the old statement stands exactly: the
promises no other software authority will command the point, not that the blades have
moved, and the position drawn is commanded. The Layout panel and the point key say which
kind each point is rather than the system claiming one uniform level of trust it does not
have. The firmware side is not written yet — see `docs/project-plan.md`.
have.

A controller that dies quietly is caught too, as of #167: a `'required'` point whose
controller stops re-asserting inside 90 s degrades to `stale`, its position becomes
`unknown`, and routes over it are refused. That is a **degrade, not a fault** — nothing is
latched and nothing Safe-Stops, because silence is a device dying rather than lying. The
firmware side is designed and unblocked but not built — `bazauto/layout-feedback#15`.

**A failed point names the route it invalidated, and that route cannot be resumed until the
point is dealt with.** A held point that times out, reports the wrong position, or reports
Expand Down Expand Up @@ -737,10 +745,11 @@ preamble in `docs/sensor-simulation.md`.
2. ESP firmware, as **one** flash-and-test cycle rather than three. Three obligations are
now waiting on the same boards, and all three are specified against a stable contract:
the WiThrottle→MQTT migration (#9); answering `point/*/query` for point position
feedback (#50 — #25 is complete in this repo, but nothing answers a query yet and no
point has a feedback switch wired); and #28's **30 s re-assert** on
`sensor/*/reading`, which is roughly ten lines and without which every sensor-backed
block reads `unknown`
feedback (#50 — #25 and #167 are complete in this repo, but nothing answers a query yet
and no point has a feedback switch wired); and the **30 s re-assert** now required on
*both* `sensor/*/reading` (#28) and `point/*/reading` (#167), which is roughly ten lines
and without which every sensor-backed block reads `unknown`. The point-side node is
`bazauto/layout-feedback#15`, whose publish behaviour #167 unblocked
3. **The rest of the command-station work** (#153 is the index). #147 and #148 have landed,
and #150's guard with them. Next is #149 — track power: `<1>` on connect, refusing routes
over a dark layout, and an operator control to turn power back on after a cutoff, which
Expand Down
2 changes: 1 addition & 1 deletion docs/current-state.md

Large diffs are not rendered by default.

90 changes: 62 additions & 28 deletions docs/mqtt-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ layout/{layoutId}/system/heartbeat
| `sensor/{sensorId}/reading` | Sensor HW (or backend sim) → Backend | 1 | YES | Current sensor occupancy. Published on change **and** re-asserted at least every 30 s |
| `point/{pointId}/command` | Backend → DCC | 1 | **NO** | Point position command |
| `point/{pointId}/state` | Backend → Subscribers | 1 | YES | Broadcast current point state |
| `point/{pointId}/reading` | Point Controller → Backend | 1 | **NO** | Physical position as observed by the point controller |
| `point/{pointId}/reading` | Point Controller → Backend | 1 | **NO** | Physical position as observed by the point controller. Published on change, in answer to a `query`, **and** re-asserted at least every 30 s |
| `point/{pointId}/query` | Backend → Point Controller | 1 | **NO** | Request an immediate `reading` for this point |
| `block/{blockId}/state` | Backend → Subscribers | 1 | YES | Block occupancy broadcast |
| `system/status` | Backend → Subscribers | 1 | YES | System status (also used as LWT) |
Expand All @@ -42,14 +42,17 @@ layout/{layoutId}/system/heartbeat
> `loco/*/command` and `point/*/command` MUST NOT be retained. A retained throttle command would trigger a ghost movement immediately on any new subscriber connecting to the broker (e.g., after an ESP controller reboot). This is a safety requirement.
>
> **`point/*/reading` is also NOT retained**, for a different reason from the
> control topics. Occupancy is continuously re-asserted by a live sensor and
> self-corrects on the next movement, which is why `sensor/*/reading` **is**
> retained. A point's position is re-asserted by nothing, and it can change while
> control topics. Both `reading` topics now carry the same 30 s re-assert
> obligation (#167), so re-assertion is no longer what separates them: retention
> is decided by whether a stale copy can **mislead**, not by whether the publisher
> repeats itself. A sensor's occupancy self-corrects on the next movement, which
> is why `sensor/*/reading` **is** retained. A point's position can change while
> its controller is offline — hand-thrown during a shutdown, power lost
> mid-travel, a linkage dropped. A retained point reading is therefore a
> confident assertion with no correction path, and believing a stale point
> position is the direct cause of a wrong-route movement. Restart recovery is
> provided instead by `point/*/query`, which recovers position **live**.
> mid-travel, a linkage dropped — and nothing on the layout corrects it
> afterwards. A retained point reading is therefore a confident assertion with no
> correction path, and believing a stale point position is the direct cause of a
> wrong-route movement. Restart recovery is provided instead by
> `point/*/query`, which recovers position **live**.

> **Critical — Retention Is Not Evidence of Liveness:**
> A retained message tells a new subscriber what was last published. It says nothing
Expand All @@ -62,9 +65,12 @@ layout/{layoutId}/system/heartbeat
>
> `sensor/*/reading` is retained *and* its publisher MUST re-assert it at least every
> 30 s (see its payload section below), so the retained copy is a bootstrap for a value
> that is about to be reconfirmed. `point/*/reading` is **not** retained, because
> nothing re-asserts a point: its position can change while its controller is offline,
> so a retained position is an archived belief with nothing behind it.
> that is about to be reconfirmed. `point/*/reading` carries the same 30 s obligation
> (#167) but is **not** retained: re-assertion is **necessary** for retention and not
> **sufficient** for it. A point's position can change while its controller is offline,
> so a retained position is an archived belief with nothing behind it — a re-assert
> says the controller is alive *now*, which is a different claim from the archived
> value still being true.
>
> The backend distinguishes a message delivered because of a **new subscription**
> (RETAIN flag set, [MQTT-3.3.1-8]) from one delivered on an **established
Expand Down Expand Up @@ -194,8 +200,25 @@ Sent by the backend to set a point position. NOT retained.
---

### `point/{pointId}/reading`
Published by the point controller whenever its observed position changes, and in
response to a `point/{pointId}/query`. NOT retained — see the retention callout.
Published by the point controller whenever its observed position changes, in
response to a `point/{pointId}/query`, **and** re-published unchanged at least every
**30 seconds** as a liveness assertion. NOT retained — see the retention callout.

The re-assertion is a hard requirement, not an optimisation (#167). Command and
feedback are **two unrelated devices on two transports**: the point motors are
commanded over DCC accessory addresses and have no feedback path of their own, while
position is read from their changeover contacts by a separate MQTT node. Nothing about
a command succeeding tells you the reporting device is still alive — there is no
shared component whose failure would show up in both — so a fire-and-forget accessory
command goes on succeeding long after the feedback node has stopped.

A point configured `positionFeedback: "required"` from which the backend has received
no reading inside its freshness window (`POINT_FRESHNESS_TIMEOUT_MS`, default 90 s =
3 × the re-assert interval) degrades to `confirmation: "stale"` with
`confirmedPosition: "unknown"`, and every edge through it becomes untraversable. That
is a **degradation, not a fault**: it latches no `PointFault` and does not Safe-Stop.
A point configured `positionFeedback: "none"` has nothing reporting on it at all and
never goes stale.

```json
{
Expand Down Expand Up @@ -313,6 +336,7 @@ Published by the backend on startup and on any status change. Also configured as
| Backend restarts, sensor alive | Retained readings arrive with the RETAIN flag set and are provisional. Those blocks read `unknown` until the first re-assert, i.e. within 30 s. |
| Backend restarts, sensor dead | The same retained readings arrive, but no re-assert ever follows, so those blocks stay `unknown` indefinitely. Retention alone got this case wrong — it reported a dead sensor's last `clear` as live track (#28). |
| Point controller restarts | The backend receives NO retained point reading. Every point configured `positionFeedback: "required"` remains or becomes `"unknown"` until it answers a live `point/*/query`. |
| Point controller dies mid-session | No further reading arrives. Every `positionFeedback: "required"` point it reports degrades to `"stale"`/`"unknown"` inside the freshness window, and a route holding one is suspended. Before #167 no clock anywhere expected to hear from a point controller again, so its last reading stood indefinitely. |
| New frontend client connects | Receives all retained `block/*/state`, `point/*/state`, `loco/*/state`, and `system/status` immediately without needing a REST poll. |

---
Expand All @@ -332,23 +356,33 @@ The following MQTT conditions MUST trigger a Safe-Stop in the backend:

## Degradation Triggers

The following conditions degrade **specific track** to `"unknown"` — which the domain
layer already treats as occupied, so routes over it cannot be granted and a live route
holding it is suspended — rather than triggering a system-wide Safe-Stop. The failure is
scoped to the track the failing device actually observes, so a layout that is otherwise
fully observed keeps running.
The following conditions degrade **specific track** — a block, or a point — to
`"unknown"`, which the domain layer already treats as unusable, so routes over it cannot
be granted and a live route holding it is suspended. None of them triggers a system-wide
Safe-Stop. The failure is scoped to what the failing device actually observes, so a
layout that is otherwise fully observed keeps running.

1. No **live** sensor reading received inside the sensor freshness window
(`SENSOR_FRESHNESS_TIMEOUT_MS`, default 90 s = 3 × the 30 s re-assert interval).
2. A sensor reading known only from a **retained** message, with no live reading since
the backend connected.

Both set every affected block to `"unknown"` and emit a `BLOCK_STATE` event, so the
condition is visible to the operator rather than silent.

> **A malformed sensor payload is NOT in this list.** It remains Fail-Safe Trigger 3
> above — a system-wide Safe-Stop, on the first message, with no tolerance. The two
> failures are different and are answered differently: a malformed payload is a device
> **lying**, which is immediate and sharp; an unrefreshed reading is a device **dying**,
> which is a freshness window and a scoped degrade. Do not merge them into one rule.
> See `docs/sensor-trust.md` and `docs/sensor-fault-recovery.md`.
3. No `point/*/reading` received inside the point freshness window
(`POINT_FRESHNESS_TIMEOUT_MS`, default 90 s = 3 × the 30 s re-assert interval), for
a point configured `positionFeedback: "required"` that had previously confirmed
(#167).

1 and 2 set every affected block to `"unknown"` and emit a `BLOCK_STATE` event; 3 sets
the point to `confirmation: "stale"` with `confirmedPosition: "unknown"` and emits a
`POINT_STATE` event. Every one of them is visible to the operator rather than silent,
and none of them latches a fault.

> **A malformed payload is NOT in this list**, on either `reading` topic. It remains
> Fail-Safe Trigger 3 above — a system-wide Safe-Stop, on the first message, with no
> tolerance. The two failures are different and are answered differently: a malformed
> payload is a device **lying**, which is immediate and sharp; an unrefreshed reading is
> a device **dying**, which is a freshness window and a scoped degrade. Do not merge
> them into one rule.
>
> The same split governs the point side, which is why a stale point degrades while every
> *fault* kind Safe-Stops (`docs/point-feedback.md` D4, D11). See `docs/sensor-trust.md`,
> `docs/sensor-fault-recovery.md` and `docs/point-feedback.md`.
Loading
Loading