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
23 changes: 14 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ honestly**, because the backend needs the live message to keep trusting the sens
| ESP-AT modem | UART1, TX=GP8 RX=GP9, 9600 baud | Wired ethernet; reports `+ETH_GOT_IP` when ready |
| LM-iD.1 detectors | board 1 (`0x20`) inputs | Current sensing. Open-drain, **5 V if Input A is ever powered** — `docs/block-detector-wiring.md` |
| Waveshare IR reflective | board 2 (`0x21`) inputs | LM393, VCC 3.0–5.3 V, run at 3.3 V. Open-collector + on-board pull-up, so its high is asserted at the sensor |
| Cobalt iP Digital points | board 3 (`0x22`), **planned** | Commanded over **DCC**, not MQTT — no feedback or query of its own. Position is read from its `S2` changeover only. `docs/point-position-feedback.md` |
| Cobalt iP Digital points | board 3 (`0x22`), **planned** | Commanded over the **accessory DCC bus**, not MQTT, with no feedback or query of its own. Both of its changeovers are taken (`S2` powers the frog), so the feedback source is undecided. `docs/point-position-feedback.md` |

## Commands

Expand All @@ -112,8 +112,8 @@ The IO node is **deployed and working end to end** (#1, #2, #3). Both Goods Shed
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,
expander answers on the bus but `POINTS_INSTALLED` is empty, and **the feedback source is
undecided**. The design assumed a free Cobalt `S2`, and there isn't one. 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.

```
Expand All @@ -123,7 +123,7 @@ src/lib/ -> device /lib
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`.
point_wiring.py Point allocation, and the feedback input 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.
Expand Down Expand Up @@ -168,15 +168,20 @@ directory on the path for the same reason.
a modem still booting when the Pico reached `AT+RST` killed the node outright, with the LED
never lighting — the intermittent "doesn't come up after a power cycle".
- **A point is commanded and read by two devices that know nothing about each other.** The
Cobalt takes DCC accessory commands and can report nothing; position comes only from its
`S2` contacts on board 3. So `points.dcc_address` (orchestrator) and `pointId` → pins (here)
Cobalt takes DCC accessory commands and can report nothing; position comes only from a
feedback pair 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`). 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.
- **The Cobalt has no free contact for feedback.** `S1` is tied to the motor's power input,
which is the accessory DCC bus, so it can't feed the frogs. `S2` therefore powers the frogs
from the track bus. **Never wire `S2` to the expander**: its common is the frog, and taking
it to 0 V shorts a stock rail to logic ground. The candidate feedback sources, and why
sensing the frog dies with track power, are in `docs/point-position-feedback.md`.
- **Which feedback input is `normal` is unverifiable authored data.** Nothing in the wiring
reveals which way round a point is fitted, or which input the orchestrator calls `normal`.
It has to be established per point by throwing it and looking. Assume nothing from 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.
Expand Down
24 changes: 14 additions & 10 deletions docs/pin-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,21 @@ position that nothing is corroborating.
| 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
Each input is pulled to 0 V when its side is made, so it reads 0 against the expander's
internal pull-up. That is the same polarity as the sensor boards, by coincidence rather than
by rule, which is why it has its own `POINTS_ACTIVE_LOW` flag.

**What drives these pins is undecided.** Neither Cobalt changeover is free: `S1` is tied to
the accessory bus and `S2` powers the frogs. **Never wire `S2` here**, because its common is
the frog. The candidates (tie-bar sensors, optos across the frog, an add-on changeover) all
present as closures to 0 V, so this allocation holds whichever is chosen. See
`point-position-feedback.md`, *The Cobalt has no free contact*.

**Which input is `normal` cannot be determined from the wiring.** Nothing reveals which way
round a point is fitted, or which input 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 @@ -152,8 +156,8 @@ 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
**No points.** Board 3 is fitted and answers on the bus, but no feedback source has been
chosen or wired, 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.
Expand Down
103 changes: 84 additions & 19 deletions docs/point-position-feedback.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# Point position feedback — board 3

Design note for the third MCP23017 (`0x22`), which reads the Cobalt iP Digital point motors'
auxiliary switch contacts and publishes `point/{pointId}/reading`.
Design note for the third MCP23017 (`0x22`), which reads two feedback inputs per point motor
and publishes `point/{pointId}/reading`.

**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*.
**Status (2026-09-22): the firmware is built; the feedback source is undecided.** The expander
is fitted and answers on the bus, `POINTS_INSTALLED` in `config.py` is empty, and nothing is
published. The design as built assumed the Cobalt's `S2` changeover was free to read. **It is
not: `S2` powers the frogs**, so there is currently nothing to wire. See *The Cobalt has no
free contact*. 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 All @@ -19,9 +21,9 @@ This is the fact to hold on to, because almost every hazard below follows from i
```
command feedback
─────── ────────
orchestrator Cobalt iP S2 contacts
orchestrator feedback pair (undecided)
│ │
│ setPoint(dccAddress, position) │ dry changeover
│ setPoint(dccAddress, position) │ two closures to 0 V
▼ ▼
PicoDCC ──<a ADDR SUBADDR ACTIVATE>──► MCP23017 0x22
│ │
Expand All @@ -32,7 +34,8 @@ This is the fact to hold on to, because almost every hazard below follows from i

The Cobalt iP is commanded over **DCC accessory addresses** and has **no feedback or query
mechanism of its own**. It cannot be asked anything. The only way its position is ever known
is the `S2` changeover contacts wired into this node.
is a pair of feedback inputs wired into this node, and the motor itself has none left to give
(*The Cobalt has no free contact*).

So the contract's single logical "point controller" is physically **two unrelated devices on
two different transports, which do not know about each other.** The thing that receives the
Expand Down Expand Up @@ -62,22 +65,84 @@ the exact failure `point-feedback.md` D3 warns about:
> author who reports `"sensor"` out of optimism — or who never wired the feedback switch at
> all — silently defeat the whole feature.

`S1` on the Cobalt is spoken for by frog polarity. `S2` is a free SPDT changeover — common
plus two throws, break-before-make. Wire `S2-C` to 0 V and each throw to its own input:
Whatever the source, each point presents two inputs, each pulled to 0 V when its side is
made: a contact closing, an opto conducting, a Hall switch operating.

| `S2-L` | `S2-R` | Reading | Meaning |
| Normal input | Reverse input | Reading | Meaning |
|---|---|---|---|
| closed | open | `normal` | corroborated |
| open | closed | `reverse` | corroborated |
| **open** | **open** | **`unknown`** | mid-travel, broken wire, or lost supply |
| closed | closed | `unknown` + fault | impossible on an SPDT — cross-wiring |
| closed | closed | `unknown` + fault | impossible if both sense one mechanism — cross-wiring |

The fourth row is the whole argument for two inputs. It is a wiring error that announces
itself at commissioning instead of six months later, and it costs one expander pin.

**Two inputs is the ceiling, not a compromise.** The Cobalt exposes one free changeover, so
more pins per point cannot extract more truth from the motor. There is no version of this
design that benefits from a third input.
## The Cobalt has no free contact

**Found 2026-09-22, after the firmware was built.** The Cobalt iP Digital has two
break-before-make changeovers, and neither is available to this node:

- **`S1` is tied to the motor's own power input.** DCC Concepts describe it as "directly
linked to the power input wires", intended to feed the frog. On this layout the motors run
from a **separate accessory DCC bus**, deliberately: a loco running into a wrongly set
point shorts the track bus, and the fix for that short is to throw the point, so the motor
must stay powered through it. `S1` would therefore feed the frog from the wrong bus. It is
also DCC-referenced, so it could never be a 3.3 V feedback contact either.
- **`S2`, the independent changeover, powers the frogs** from the track bus. It is the only
way to do that correctly here, and it is spoken for.

So the design's premise, "`S2` is free", was wrong. The truth table, the pair allocation and
every line of firmware are unaffected **as long as the replacement presents two independent
closures to 0 V**, and all the candidates below can. `S2` itself must **never** be wired to
the expander: its common is the frog, and tying it to 0 V shorts a stock rail to logic
ground.

### Candidate feedback sources

Undecided. The choice depends on what the physical layout allows under each point.

**A. Tie-bar or blade sensors, two per point.** Two microswitches, or two Hall-effect
switches with a magnet on the tie-bar, one at each end of travel.

- Reads **the blades**, which is what the backend's `confirmed` actually asserts. A dropped
linkage or a blade held off by debris shows as `unknown` or `mismatch`; the other options
report the motor or the frog and would call it `confirmed`.
- Fully isolated from both DCC buses, and reads with track power off.
- Cost: mechanical fitting per point, under the baseboard. A Hall part must be rated for a
3.3 V supply (the common A3144 needs 4.5 V) and be open-collector, so it looks like a
contact to 0 V.

**B. Optos across the frog, sensing `S2`'s output.** One opto from the frog to each stock
rail. The rail the frog is bonded to has no voltage across its opto; the other sees full
track DCC and conducts. The phototransistor pulls its expander input to 0 V.

- No mechanical work, and it fits the truth table directly: the frog is dead mid-throw
(break-before-make), so both go dark, which is the honest `unknown`.
- DCC is bipolar, so the opto needs an AC input (H11AA1 or PC814 class) or an anti-parallel
diode, plus a series resistor sized for track voltage.
- Reads **frog polarity, not blades**. It confirms `S2` moved, not that the point did.
- **It reads nothing without track power.** Every point goes `unknown` whenever the track bus
is off, and the accessory bus exists for exactly that case: a loco shorts the track bus at
a wrongly set point, the booster cuts out, and the point is thrown to clear it. The throw
then cannot be confirmed, because the feedback died with the track bus. On a point at
`positionFeedback: 'required'` that is a confirmation `timeout`, which Safe-Stops. How the
backend treats a Safe Stop, a track power-off and a point `unknown` together has to be
checked in `bazauto/layout-orchestration` before this option is safe to rely on.

**C. An add-on changeover driven by the Cobalt's linkage.** A separate microswitch or slide
switch actuated by the motor's output. Isolated and power-independent like A, but reports the
motor rather than the blades, and needs a mount per point.

**Two inputs is the ceiling, not a compromise.** Whatever the source, two independent
closures give every state the contract defines. There is no version of this design that
benefits from a third input.

References: DCC Concepts' [Cobalt iP Digital product page](https://www.dccconcepts.com/products/cobalt-ip-digital/)
for the `S1`/`S2` description; a [DCC Concepts forum thread](https://www.dccconceptsforum.com/post/what-triggers-the-cobalt-ip-digital-frog-connector-to-switch-polarity-11281491)
confirming `S1`'s frog polarity follows the motor's DCC input wires; and
[US 9,662,591](https://patents.justia.com/patent/9662591), which describes Hall switches and a
tie-bar magnet for exactly option A.

## Both inputs of a point stay on the same expander

Expand Down Expand Up @@ -114,8 +179,8 @@ installation* rule as the sensors: only points listed as installed are published
unwired input pair reads both-open, which would publish a confident `unknown` for a point
nothing is watching.

**Which throw is `normal` is unverifiable authored data.** Nothing can determine from the
wiring which way round a physical point is fitted, or which `S2` terminal corresponds to the
**Which input is `normal` is unverifiable authored data.** Nothing can determine from the
wiring which way round a physical point is fitted, or which feedback input corresponds to the
orchestrator's `normal`. It must be established per point by throwing it and looking, exactly
as `../layout-orchestration/docs/track-grid.md` D9 says of a point tile's leg mapping. Assume
nothing from the terminal labels.
Expand Down Expand Up @@ -173,7 +238,7 @@ Every one of the five point fault kinds — `timeout`, `mismatch`, `indeterminat
point no route holds (D4). All six points are `position_feedback: 'none'` today, so wiring the
switches changes nothing until they are flipped to `'required'`.

Flipping all six at once, on freshly wired microswitches, produces a layout that halts on the
Flipping all six at once, on freshly wired feedback, produces a layout that halts on the
first flaky contact with five other unproven points to rule out. Flip one, run it, leave it a
while. The escape hatch is per point and immediate: setting a point back to `'none'` clears
its latched fault.
Expand Down
12 changes: 7 additions & 5 deletions src/apps/io-node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,16 @@
# --- 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.
# their own — they cannot be asked anything. Position is read only from a pair of
# feedback inputs per point here. Neither Cobalt changeover is free (`S2` powers the
# frog; never wire it here), so the source of that pair is still undecided.
#
# 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
# Each input is pulled to 0 V against the expander's pull-up when its side is made, so
# a made input 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.
Expand All @@ -126,7 +128,7 @@
# 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
# which way round a point is fitted or which feedback input the orchestrator calls
# `normal`. It must be established per point by throwing it and looking — assume nothing
# from the terminal labels.
POINTS = (
Expand All @@ -146,7 +148,7 @@
)

# 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.**
# fitted and answers on the bus, but no feedback source is chosen or wired 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'`
Expand Down
Loading
Loading