From 9ac5ebb39ab7135da76283be04f4e5d50360e8f3 Mon Sep 17 00:00:00 2001 From: Paul Barrett Date: Tue, 22 Sep 2026 18:16:59 +0100 Subject: [PATCH] docs: the Cobalt has no free contact for point feedback The board 3 design assumed each Cobalt iP Digital's S2 changeover was free to read. It is not. S1 is tied to the motor's power input, which on this layout is the accessory DCC bus (kept separate so a point can still be thrown to clear a track short), so S1 cannot feed the frogs. S2 therefore powers them from the track bus, and nothing is left for feedback. Record that, warn against ever wiring S2 to the expander (its common is the frog), and list the candidate feedback sources with their trade-offs: tie-bar sensors, optos across the frog, or an add-on changeover. The firmware is unchanged because every candidate presents as two closures to 0 V; only comments and test docstrings that named S2 are generalised. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 23 ++++--- docs/pin-allocation.md | 24 ++++---- docs/point-position-feedback.md | 103 ++++++++++++++++++++++++++------ src/apps/io-node/config.py | 12 ++-- src/lib/point_wiring.py | 10 ++-- tests/test_io_node_config.py | 4 +- tests/test_point_wiring.py | 2 +- 7 files changed, 127 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aad3168..f6ddb12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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. ``` @@ -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. @@ -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. diff --git a/docs/pin-allocation.md b/docs/pin-allocation.md index 9b42a3d..270e964 100644 --- a/docs/pin-allocation.md +++ b/docs/pin-allocation.md @@ -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. @@ -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. diff --git a/docs/point-position-feedback.md b/docs/point-position-feedback.md index 7de4e45..dd60cef 100644 --- a/docs/point-position-feedback.md +++ b/docs/point-position-feedback.md @@ -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 @@ -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 ────► MCP23017 0x22 │ │ @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/src/apps/io-node/config.py b/src/apps/io-node/config.py index 5aae3df..a38e063 100644 --- a/src/apps/io-node/config.py +++ b/src/apps/io-node/config.py @@ -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. @@ -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 = ( @@ -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'` diff --git a/src/lib/point_wiring.py b/src/lib/point_wiring.py index bb0aa46..d0ccba4 100644 --- a/src/lib/point_wiring.py +++ b/src/lib/point_wiring.py @@ -1,4 +1,4 @@ -"""Resolve a node's point allocation table, and decode a pair of S2 contacts. +"""Resolve a node's point allocation table, and decode a pair of feedback inputs. Pure — no `machine` import — so every check below runs in the host suite. They all fail at startup rather than at runtime, because a point mis-wired or mis-allocated produces a @@ -9,7 +9,7 @@ - **A point is commanded and read by two devices that know nothing about each other.** The Cobalt iP takes DCC accessory commands and can report nothing; position comes only - from its `S2` changeover wired into an MCP23017 here. So `points.dcc_address` in the + from a separate feedback pair wired into an MCP23017 here. So `points.dcc_address` in the orchestrator and `point_id` -> pins here are independent mappings that nothing cross-checks. - **Two inputs per point, not one.** A single input infers `reverse` from the absence of @@ -113,10 +113,10 @@ def assert_no_pin_overlap(sensor_entries, point_entries): def is_closed(level, active_low): - """Whether one throw of an `S2` changeover is closed. + """Whether one feedback input of a point is made. - `S2-C` is wired to 0 V and each throw to its own expander input, so a closed contact - pulls its input down and reads 0 against the internal pull-up. Its own flag rather + Each input is pulled to 0 V when its side is made, whether by a contact, an opto or a + Hall switch, so a made input reads 0 against the internal pull-up. Its own flag rather than the sensors' `ACTIVE_LOW`: this is a third device that happens to agree with the other two, and the repo has already been bitten once by treating a coincidence as a rule. diff --git a/tests/test_io_node_config.py b/tests/test_io_node_config.py index 35926ab..4a4a718 100644 --- a/tests/test_io_node_config.py +++ b/tests/test_io_node_config.py @@ -82,7 +82,7 @@ def test_the_real_point_config_resolves(): def test_no_points_are_installed_yet(): - """The expander is fitted and answers on the bus, but no S2 contacts are landed. + """The expander is fitted and answers on the bus, but no feedback source is wired. An unwired pair reads both-open and would publish a confident `unknown` for a point nothing is watching — the same *allocation is not installation* rule as the sensors. @@ -132,7 +132,7 @@ def test_no_sensor_and_point_share_a_pin(): def test_point_contacts_are_active_low(): - """S2-C wired to 0 V against the expander pull-up, so a closed throw reads 0. + """A made input is pulled to 0 V against the expander pull-up, so it reads 0. Its own flag, not `ACTIVE_LOW`: a third device agreeing with the other two is a coincidence, and this repo has been bitten once by treating that as a rule. diff --git a/tests/test_point_wiring.py b/tests/test_point_wiring.py index 0e4bd44..6d2d5dc 100644 --- a/tests/test_point_wiring.py +++ b/tests/test_point_wiring.py @@ -53,7 +53,7 @@ def test_a_position_is_never_inferred_from_a_single_absence(): def test_a_closed_contact_reads_low(): - """S2-C is wired to 0 V, so a closed throw pulls its input down.""" + """A made input is pulled to 0 V, so it reads 0 against the pull-up.""" assert is_closed(0, active_low=True) is True assert is_closed(1, active_low=True) is False assert is_closed(1, active_low=False) is True