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
22 changes: 18 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Reasoning belongs in `docs/`.
| `../layout-orchestration/docs/mqtt-contract.md` | **Binding.** Topics, payloads, QoS, retention, the 30 s re-assert. This repo does not own it and must never amend it. |
| `docs/pin-allocation.md` | Which sensor is on which expander pin, and why allocation is not installation |
| `docs/block-detector-wiring.md` | The LM-iD output stage: its two modes, why Input A is unpowered, the 3.3 V hazard if it is not |
| `docs/point-position-feedback.md` | Board 3 (`0x22`): why two inputs per point, the `0x22` allocation, and why command and feedback being different devices is the hazard. **Not built** |
| `docs/point-position-feedback.md` | Board 3 (`0x22`): why two inputs per point, the `0x22` allocation, and why command and feedback being different devices is the hazard. **Firmware built; no `S2` wiring landed** |
| `docs/startup-and-status-led.md` | The power-on race with the modem, the retry-then-reset policy, and **what the LED flash codes mean** |
| `docs/DEVELOPMENT_NOTES.md` | Module conventions, the MCP23017 pin map, hardware wiring |
| `docs/PN7150_STATE_MACHINE_PLAN.md` | The NCI state machine the RFID reader implements |
Expand Down Expand Up @@ -111,19 +111,26 @@ bench check that covers it instead. Do not leave it silently untested.
The IO node is **deployed and working end to end** (#1, #2, #3). Both Goods Shed sensors
publish contract readings that the orchestrator trusts, and the block follows them.

Point position feedback on board 3 (`0x22`) is **built but publishes nothing yet** (#15): the
expander answers on the bus, no `S2` contacts are landed, so `POINTS_INSTALLED` is empty.
Bring points up **one at a time** — every point fault kind Safe-Stops the whole layout,
including a fault on a point no route holds.

```
src/lib/ -> device /lib
mqtt_at.py ESP-AT transport. Length-aware frames, reconnect, failures returned.
Waits for the modem to answer before commanding it.
layout_mqtt.py The contract: topics, payloads, QoS, retention, the 25 s re-assert.
layout_mqtt.py The contract: sensor and point topics, payloads, QoS, retention,
the 25 s re-assert, and point query bookkeeping.
sensor_wiring.py Allocation vs installed, and the occupancy polarity. No `machine`.
point_wiring.py Point allocation, and the S2 contact pair -> position. No `machine`.
node_startup.py Retry-then-reset startup policy, and the LED code table. No `machine`.
status_led.py The onboard LED as a countable flash code. No `machine`.
mcp23017_io.py I2C expander, in and out. Poll-only.
input_pin_monitor.py Native Pico GPIO, IRQ + timer debounce. Currently unused.
pcf8591_adc.py I2C ADC. Wired to nothing; no contract topic for analog yet.
pn7150.py PN7150 + TCA9548A mux, callback-based. Awaiting #4.
src/apps/io-node/ -> device / main.py + config.py. Deployed.
src/apps/io-node/ -> device / main.py + config.py. Deployed. Sensors + points.
tools/ host-side, never deployed
uart_bridge.py Talk to the ESP-AT modem by hand.
mqtt_example.py Minimal publish/subscribe against a real modem.
Expand Down Expand Up @@ -165,7 +172,14 @@ directory on the path for the same reason.
`S2` contacts on board 3. So `points.dcc_address` (orchestrator) and `pointId` → pins (here)
are independent mappings that nothing cross-checks — get one wrong and the system commands
one motor while reading another, with both ends looking healthy
(`docs/point-position-feedback.md`).
(`docs/point-position-feedback.md`). The commissioning check is to throw each point
**individually** and confirm the expected pair, and only that pair, moves.
- **Which `S2` throw is `normal` is unverifiable authored data.** Nothing in the wiring reveals
which way round a point is fitted, or which terminal the orchestrator calls `normal`. It has
to be established per point by throwing it and looking. Assume nothing from terminal labels.
- **A point's `unknown` is real and must not be debounced away.** A break-before-make
changeover passes through both-open on every throw, so the honest sequence is
`normal` → `unknown` → `reverse`. The backend expects it; its confirmation timeout is 8 s.
- **A broken sensor wire reads as `clear`, not `occupied`** (#9). Both sensor types switch to
ground, so an open circuit floats up to the pull-up — the permissive state. The re-assert
cannot catch it: the node is alive and republishing. Accepted knowingly — but #9 rejected
Expand Down
44 changes: 44 additions & 0 deletions docs/pin-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,44 @@ three-line config change. It costs the alignment.
| 8 | GPB0 | 1 | Goods Shed | `ir---goods-shed` |
| 9–15 | GPB1–7 | 2–8 | *spare* | |

## Board 3 — MCP23017 `0x22` — point position feedback

**Two pins per point, not one.** A single input would infer `reverse` from the absence of
`normal`, and an absence is equally a broken wire, a lost supply, or a point sitting
mid-throw. The design and the hazards are in `docs/point-position-feedback.md`.

The pair for a point always stays on this one chip, so a chip that drops off the bus fails
both halves together and the point reads `unknown` — the correct answer. Split across two
chips, a half failure leaves exactly one closed reading: a confident, corroborated-looking
position that nothing is corroborating.

| Pin | Port | Chip pin | Point | Throw | `pointId` |
|---|---|---|---|---|---|
| 0 | GPA0 | 21 | P1 Fiddle Yard | normal | `88d5527d-c7db-40ab-b27b-532503487c32` |
| 1 | GPA1 | 22 | P1 Fiddle Yard | reverse | " |
| 2 | GPA2 | 23 | P2 Layout Entry | normal | `acc2150b-ffa1-4733-b2ff-1e4d2147dfe8` |
| 3 | GPA3 | 24 | P2 Layout Entry | reverse | " |
| 4 | GPA4 | 25 | P3 Siding 1 | normal | `8ccb1cf8-b9ce-448e-bb97-c56a886f46c3` |
| 5 | GPA5 | 26 | P3 Siding 1 | reverse | " |
| 6 | GPA6 | 27 | P4 Siding 2 | normal | `0e615557-a952-41ed-a5f2-c43d9eefa78f` |
| 7 | GPA7 | 28 | P4 Siding 2 | reverse | " |
| 8 | GPB0 | 1 | P5 Goods Shed | normal | `720bde49-5e2e-47f9-b691-1391e0195240` |
| 9 | GPB1 | 2 | P5 Goods Shed | reverse | " |
| 10 | GPB2 | 3 | P6 Engine Shed | normal | `210cf5c3-150a-4ef5-99b0-4a494eb48a4f` |
| 11 | GPB3 | 4 | P6 Engine Shed | reverse | " |
| 12–15 | GPB4–7 | 5–8 | *spare* | | |

Wiring is `S2-C` to 0 V and each throw to its own pin, so a closed contact reads 0 against
the expander's internal pull-up — the same polarity as the sensor boards, by coincidence
rather than by rule, which is why it has its own `POINTS_ACTIVE_LOW` flag.

**Which throw is `normal` cannot be determined from the wiring.** Nothing reveals which way
round a point is fitted, or which `S2` terminal the orchestrator calls `normal`. It has to be
established per point by throwing it and looking. The `normal`/`reverse` columns above are the
allocation's *intent*; commissioning confirms or corrects them.

`S1` is not available — it is spoken for by frog polarity.

## Pin numbering is the thing that goes wrong

The numbers in these tables are **logical pins, 0-based**: 0–7 are GPA0–7, 8–15 are GPB0–7.
Expand Down Expand Up @@ -114,6 +152,12 @@ Two, both on the **Goods Shed** block:
| 1 (`0x20`) | 8 | GPB0 | 1 | `cs---goods-shed` |
| 2 (`0x21`) | 8 | GPB0 | 1 | `ir---goods-shed` |

**No points.** Board 3 is fitted and answers on the bus, but no `S2` contacts are landed, so
`POINTS_INSTALLED` is empty and nothing is published on any `point/*/reading`. Bring points up
**one at a time**: every point fault kind Safe-Stops the whole layout, including a fault on a
point no route holds, so flipping all six to `positionFeedback: "required"` at once produces a
layout that halts on the first flaky contact with five other unproven points to rule out.

Two things follow from this being the pair brought up first, both of which paid off:

- **Goods Shed is the only block with both a CS and an IR sensor installed**, so the bring-up
Expand Down
97 changes: 57 additions & 40 deletions docs/point-position-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
Design note for the third MCP23017 (`0x22`), which reads the Cobalt iP Digital point motors'
auxiliary switch contacts and publishes `point/{pointId}/reading`.

**Nothing here is built yet.** This is the design agreed before code, so that the wiring, the
allocation and the contract questions are settled while they are still cheap. The node work is
#15; the one genuinely blocking question is
[`bazauto/layout-orchestration#167`](https://github.com/bazauto/layout-orchestration/issues/167)
and is explained at the end.
**Status (2026-08-27): the firmware is built; the wiring is not landed.** The expander is
fitted and answers on the bus, `POINTS_INSTALLED` in `config.py` is empty, and nothing is
published until an `S2` pair is wired and its point added. The blocking question below settled
before the code was written — see *The blocking question, which was not ours*.

The orchestrator's decision record for the feature is
`../layout-orchestration/docs/point-feedback.md` (D1–D10), and
Expand Down Expand Up @@ -179,38 +178,56 @@ first flaky contact with five other unproven points to rule out. Flip one, run i
while. The escape hatch is per point and immediate: setting a point back to `'none'` clears
its latched fault.

## The blocking question, which is not ours

**Nothing checks that a point controller is still alive.** The pieces compound:

- `point/*/reading` is not retained and has **no periodic re-assert**, unlike `sensor/*/reading`
with its 30 s rule. It is published on change and on query only.
- The confirmation deadline arms on a **`command`**; a **`query` deliberately does not** arm it.
- The backend queries on startup, on broker reconnect and on operator request — **not
periodically**.

So once points are `'required'`, a node that dies quietly leaves the last `confirmedPosition`
standing indefinitely, and the layout keeps setting roads over points whose position nothing
has observed since it stopped. A dead *sensor* node degrades its blocks to `unknown` inside
the freshness window. A dead *point* node degrades nothing.

#25 left this open deliberately rather than by omission:

> Whether `sensor/*/reading` additionally needs its own liveness/staleness check remains #28's
> call, not this document's.

That is the sensor side. There is no equivalent on the point side at all.

**A periodic point re-assert is a `docs/mqtt-contract.md` amendment, and the contract changes
before the code.** It is not this repo's to make — raised as
[`bazauto/layout-orchestration#167`](https://github.com/bazauto/layout-orchestration/issues/167).

This node should not be built against the current shape until #167 settles, because the two
candidate answers are *different firmware*: "republish every N seconds" is a timer in the
publish loop, "answer a periodic query" is subscription handling and nothing else. Wiring and
bench work are not blocked; only the publish behaviour is.

The topology sharpens the problem rather than softening it. Because the commanded device is
not the reporting device, silence after a command carries **no signal at all** — a DCC
accessory command is fire-and-forget and will keep succeeding long after the feedback node has
stopped. On a self-reporting controller, silence after a command would at least be suspicious.
## The blocking question, which was not ours — settled

**Nothing checked that a point controller was still alive.** `point/*/reading` was not
retained and had no periodic re-assert, the confirmation deadline arms on a `command` and
never on a `query`, and the backend queried only on startup, on reconnect and on operator
request. So a node that died quietly left the last `confirmedPosition` standing indefinitely
while the layout kept setting roads over points nothing had observed since.

The topology sharpened that rather than softening it: because the commanded device is not the
reporting device, silence after a command carried **no signal at all** — a DCC accessory
command is fire-and-forget and keeps succeeding long after the feedback node has stopped.

This was a `docs/mqtt-contract.md` amendment and not this repo's to make. Raised as
[`bazauto/layout-orchestration#167`](https://github.com/bazauto/layout-orchestration/issues/167),
**closed 2026-08-24**. It settled as the first of the two candidate answers — republish on a
timer, which is the same shape this repo already runs for sensors, rather than a periodic
query. The contract now reads:

> 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.

with `POINT_FRESHNESS_TIMEOUT_MS` at 90 s — three missed re-asserts. A `required` point that
goes quiet degrades to `confirmation: "stale"` with `confirmedPosition: "unknown"` and its
edges become untraversable. That is a **degradation, not a fault**: it latches no `PointFault`
and does not Safe-Stop. A point left at `positionFeedback: "none"` has nothing reporting on it
and never goes stale.

So this node re-asserts every point on the same 25 s timer as its sensors, through the same
`LayoutMQTT.tick()`. One rule, one loop — the re-assert is the single easiest thing here to
forget and the most expensive to get wrong, and it should not be possible to implement it for
one topic and miss it for the other.

## Where this lives in the code

| Piece | File |
|---|---|
| Contact pair → position, and the allocation checks | `src/lib/point_wiring.py` |
| Topics, payload, QoS/retention, the re-assert, query bookkeeping | `src/lib/layout_mqtt.py` |
| The `0x22` allocation and the installed allow-list | `src/apps/io-node/config.py` |
| Pin setup, query subscription, the read loop | `src/apps/io-node/main.py` |

Two things worth knowing about the implementation:

- **A query is noted, never answered from the callback.** `poll()` dispatches the handler, so
publishing from inside it would re-enter the modem in the middle of whatever command
`poll()` was called from. `note_query()` sets a flag and `tick()` answers on the next pass,
a few tens of milliseconds later. Safe to be late, because a query does not arm the
confirmation deadline.
- **A failed subscribe refuses to start.** Unlike a failed publish, it is not survivable: the
node would come up healthy, re-assert happily, and never answer a query — leaving every
queried point at `unknown` with nothing reporting a fault. It raises, the supervisor retries,
and the LED flashes code 5.
70 changes: 69 additions & 1 deletion src/apps/io-node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@
I2C_SDA = 4
I2C_SCL = 5

# Board 1 is current sensing, board 2 is IR.
# Board 1 is current sensing, board 2 is IR, board 3 is point position feedback.
EXPANDER_CS = 0x20
EXPANDER_IR = 0x21
EXPANDER_POINTS = 0x22

# The sensors are switches to ground: closed (conducting) when the block is occupied,
# open otherwise, with the MCP23017's internal pull-up holding the line high when open.
Expand Down Expand Up @@ -90,6 +91,73 @@
# topics. The expander's output support is kept in lib/ for when that changes.
OUTPUTS = ()

# --- board 3 (0x22): point position feedback ------------------------------
#
# The Cobalt iP Digital motors are commanded over **DCC** and have no feedback path of
# their own — they cannot be asked anything. Position is read only from each motor's
# free `S2` changeover, wired `S2-C` to 0 V with each throw to its own input here.
#
# Two inputs per point, never one. A single input would infer `reverse` from the absence
# of `normal`, and an absence is equally a broken wire, a lost supply, or a point sitting
# mid-throw. See docs/point-position-feedback.md.
#
# `S2-C` to 0 V against the expander's pull-up means a closed throw reads 0. Its own flag
# rather than `ACTIVE_LOW` above: this is a third device that happens to agree with the
# other two, and treating that coincidence as a rule is a mistake this repo has already
# made once.
POINTS_ACTIVE_LOW = True

# Contact bounce on the changeover. Safe at this value because a Cobalt iP takes seconds
# to travel, so the genuine both-open window during a throw is far wider than the
# debounce and is still reported. It is a separate knob from DEBOUNCE_MS so that tuning
# the sensors cannot silently swallow the `normal` -> `unknown` -> `reverse` sequence the
# backend expects — suppressing that transient is the mistake to avoid here.
POINT_DEBOUNCE_MS = 200

# The full allocation, from docs/point-position-feedback.md. Pairs are adjacent and stay
# on one expander, so a chip that drops off the bus fails both halves together and the
# point reads `unknown` — the correct answer. Split across two chips, a half failure
# would leave exactly one closed reading: a confident, corroborated-looking position that
# nothing is corroborating.
#
# The `dcc_address` comment on each row is **not used by this node**. It is here only so
# the two halves of the system can be checked against each other by eye: which motor gets
# commanded lives in the orchestrator's `points.dcc_address`, which motor gets read lives
# here, and nothing cross-checks them.
#
# **Which throw is `normal` is unverifiable authored data.** Nothing in the wiring reveals
# which way round a point is fitted or which `S2` terminal the orchestrator calls
# `normal`. It must be established per point by throwing it and looking — assume nothing
# from the terminal labels.
POINTS = (
# point_id (orchestrator points.id) dcc normal reverse
{"point_id": "88d5527d-c7db-40ab-b27b-532503487c32", # P1 Fiddle Yard, dcc 11
"expander": EXPANDER_POINTS, "normal_pin": 0, "reverse_pin": 1},
{"point_id": "acc2150b-ffa1-4733-b2ff-1e4d2147dfe8", # P2 Layout Entry, dcc 12
"expander": EXPANDER_POINTS, "normal_pin": 2, "reverse_pin": 3},
{"point_id": "8ccb1cf8-b9ce-448e-bb97-c56a886f46c3", # P3 Siding 1, dcc 13
"expander": EXPANDER_POINTS, "normal_pin": 4, "reverse_pin": 5},
{"point_id": "0e615557-a952-41ed-a5f2-c43d9eefa78f", # P4 Siding 2, dcc 14
"expander": EXPANDER_POINTS, "normal_pin": 6, "reverse_pin": 7},
{"point_id": "720bde49-5e2e-47f9-b691-1391e0195240", # P5 Goods Shed, dcc 15
"expander": EXPANDER_POINTS, "normal_pin": 8, "reverse_pin": 9},
{"point_id": "210cf5c3-150a-4ef5-99b0-4a494eb48a4f", # P6 Engine Shed, dcc 16
"expander": EXPANDER_POINTS, "normal_pin": 10, "reverse_pin": 11},
)

# Physically wired, and therefore the only points published. **Empty: the expander is
# fitted and answers on the bus, but no `S2` contacts are landed yet.**
#
# Bring one up at a time. Every point fault kind Safe-Stops the whole layout, including a
# fault on a point no route holds, so flipping all six to `positionFeedback: 'required'`
# in the orchestrator at once produces a layout that halts on the first flaky contact with
# five other unproven points to rule out.
#
# The commissioning check that catches the mapping hazard: throw each point individually
# and confirm the expected pair — and only that pair — moves. Throwing them together
# proves nothing, and neither does testing one while the others sit in matching positions.
POINTS_INSTALLED = ()

# How long to wait between main-loop passes. Sensor edges are caught by polling the
# expanders, which are poll-only devices.
LOOP_SLEEP_MS = 50
Loading