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