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
21 changes: 10 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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/block-detector-wiring.md` | The retired LM-iD output stage: its two modes, the 5 V hazard if Input A is powered. Read before refitting one |
| `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/broker-auth.md` | Broker identities, the ACL, where the node's password lives, and why a denied publish is silent |
| `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** |
Expand Down Expand Up @@ -85,7 +85,7 @@ honestly**, because the backend needs the live message to keep trusting the sens
| PN7150 NFC | I2C1, SDA=GP2 SCL=GP3, addr `0x28` | NCI + IRQ. IRQ means "a message is ready", **not** "a tag is present". |
| TCA9548A mux | I2C1, addr `0x70` | Fans I2C1 out to up to 8 PN7150 readers |
| 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` |
| `bazauto/block-detection` | board 1 (`0x20`) inputs | Current sensing. Push-pull 3.3 V, **active high**: a broken signal wire reads occupied. Replaced the LM-iD.1 |
| 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 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` |

Expand Down Expand Up @@ -158,8 +158,8 @@ directory on the path for the same reason.
- `AT+MQTTPUB` honours backslash escaping, so JSON needs no `AT+MQTTPUBRAW`.
- **Polarity is per sensor**, because it belongs to the device, not the board. Every
`SENSORS` entry names its device's constant (`LM_ID_ACTIVE_LOW`, `WAVESHARE_IR_ACTIVE_LOW`,
`BAZAUTO_BD_ACTIVE_LOW`), and startup refuses an entry without a real bool. Both fitted
devices read active low; the `bazauto/block-detection` board is active high. An earlier
`BAZAUTO_BD_ACTIVE_LOW`), and startup refuses an entry without a real bool. Board 1 is all
`bazauto/block-detection`, active high (2026-09-25); board 2's IR is active low. An earlier
"active high" note here was #8's, superseded by #11; see `docs/block-detector-wiring.md`.

### Traps
Expand Down Expand Up @@ -194,18 +194,17 @@ directory on the path for the same reason.
- **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. The fix for board 1 is the
- **A broken IR wire reads as `clear`, not `occupied`** (#9). The IR switches 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. Board 1 is fixed by the
`bazauto/block-detection` board, active high, so a broken signal wire reads occupied;
swap a `cs---` entry to `BAZAUTO_BD_ACTIVE_LOW` only after its wire-pull bench check.
Board 2 is still unfixed. Status in #9.
the supply- and ground-wire pulls are still to be recorded in #9. Board 2 is still
unfixed.
- **A dead IR beam fails toward overrun, not toward stopping.** Same broken wire, different
consequence: a `block_detection` sensor wrongly says empty track, an `ir_position` beam
wrongly says *not yet reached*, so a berthing run never gets its stop trigger. Cheaper to
supervise than the detectors — they already run at 3.3 V — but nothing is fitted (#9).
- **The block detectors' Input A is deliberately unpowered, and that is load-bearing.**
Powering it — which every manufacturer datasheet tells you to do — makes the output idle at
- **If an LM-iD.1 is ever refitted, its Input A must stay unpowered.** Powering it — which every manufacturer datasheet tells you to do — makes the output idle at
**5 V** into 3.3 V expander inputs. Do not wire it without reading
`docs/block-detector-wiring.md`.
- **You cannot determine this sensor's polarity by reading an expander pin.** A floating
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ exist yet; see [#4](https://github.com/bazauto/layout-feedback/issues/4).
| PN7150 NFC | I2C1, SDA=GP2 SCL=GP3, `0x28` | NCI + IRQ. The IRQ means "a message is ready", not "a tag is present" |
| TCA9548A mux | I2C1, `0x70` | Fans I2C1 out to up to 8 readers |
| ESP-AT modem | UART1, TX=GP8 RX=GP9, 9600 | Wired ethernet; reports `+ETH_GOT_IP` when ready |
| Legacy Models LM-iD.1 | board 1 inputs | The current-sensing detectors, active low. Output stage and its two modes: `docs/block-detector-wiring.md` |
| `bazauto/block-detection` rev 1.0 | board 1 inputs | The current-sensing detectors, push-pull, active high, so a broken signal wire reads occupied. Replaced the LM-iD.1 (`docs/block-detector-wiring.md`) |
| Waveshare IR reflective | board 2 inputs | LM393, run at 3.3 V, active low |
| Cobalt iP Digital points | board 3 (`0x22`), planned | Commanded over DCC, never MQTT, and can report nothing itself. Neither changeover is free (`S2` powers the frog), so the feedback source is undecided. See `docs/point-position-feedback.md` |

Expand Down
6 changes: 4 additions & 2 deletions docs/block-detector-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,10 @@ believed was shut is open, and what it costs to walk through it.
**Superseded as the plan for board 1 (2026-09).** Rather than powering `A` and adding an
inverter, the LM-iD.1 is being replaced by the `bazauto/block-detection` board: a CT detector
with a push-pull, **active-high** 3.3 V output, so a broken signal wire reads occupied against
the expander's pull-up, with no level shifting. It is under bench test and not yet fitted to
any sensor. The firmware side is ready: polarity is per sensor (`docs/pin-allocation.md`).
the expander's pull-up, with no level shifting. **Done 2026-09-25:** the LM-iD.1 on Goods Shed has been
removed and every board 1 entry is on `BAZAUTO_BD_ACTIVE_LOW` (`docs/pin-allocation.md`). The
rest of this document describes hardware no longer fitted, kept because refitting an LM-iD
without reading it reintroduces the 5 V hazard.

## The IR sensors are not these

Expand Down
25 changes: 13 additions & 12 deletions docs/pin-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,32 +114,33 @@ node, so every `SENSORS` entry in `config.py` carries its own `active_low`, set
per-device constant. There is no default: startup refuses an entry without one, or with
anything other than `True` or `False`.

Both fitted devices pull to ground when asserting — occupied on board 1, triggered on board
2 — with the internal pull-up holding the line high otherwise. **Occupied reads 0**, so both
constants are `True`. They are different devices that agree by coincidence, not by design.
**Board 1 is active high.** The `bazauto/block-detection` board replaced the LM-iD.1 on
2026-09-25, and every `cs---` entry uses `BAZAUTO_BD_ACTIVE_LOW = False`: it drives its output
push-pull, 3.3 V when occupied. Against the pull-up, a broken signal wire from it reads
**occupied**, which is the point of it (#9). The pull-up has to stay on for those inputs:
without it, the broken wire would float instead. Only installed entries are published, so the
unconnected detectors are silent until each is added to `INSTALLED`.

The `bazauto/block-detection` board, built to replace the LM-iD.1 on board 1, is the first
that disagrees. It drives its output push-pull, 3.3 V when occupied, so it is
`BAZAUTO_BD_ACTIVE_LOW = False`. Against the pull-up, a broken signal wire from it reads
**occupied**, which is the point of it (#9). Moving a `cs---` entry onto it is a one-line
change, to make only after that channel's wire-pull check on the bench. The pull-up has to
stay on for those inputs: without it, the broken wire would float instead.
**Board 2 is active low.** The Waveshare IR pulls to ground when triggered, with the internal
pull-up holding the line high otherwise, so a broken wire there still reads `clear`.

`LM_ID_ACTIVE_LOW` is kept for the record but is no longer used by any entry.

| Constant | Device | Occupied reads |
|---|---|---|
| `LM_ID_ACTIVE_LOW = True` | Legacy Models LM-iD.1 | 0 |
| `LM_ID_ACTIVE_LOW = True` | Legacy Models LM-iD.1 (no longer fitted) | 0 |
| `WAVESHARE_IR_ACTIVE_LOW = True` | Waveshare IR reflective | 0 |
| `BAZAUTO_BD_ACTIVE_LOW = False` | `bazauto/block-detection` rev 1.0 | 1 |

| Board | Device | Output |
|---|---|---|
| 1 (`0x20`) | Legacy Models LM-iD.1 | Open-drain. **Idles at 5 V if Input A is ever powered** — which is what every manufacturer datasheet tells you to do |
| 1 (`0x20`) | `bazauto/block-detection` rev 1.0 | Push-pull 3.3 V, active high. Replaced the LM-iD.1, which is open-drain and **idles at 5 V if Input A is ever powered** |
| 2 (`0x21`) | Waveshare IR reflective | LM393 open-collector with an on-board pull-up to its own 3.3 V VCC. Safe on an expander input directly |

**`docs/block-detector-wiring.md` is the whole picture**, and the place to read before
changing anything about how either is wired.

**A broken wire therefore reads as `clear`, not `occupied`** — the failure lands on the
**On board 2, a broken wire therefore reads as `clear`, not `occupied`** — the failure lands on the
permissive state, and the 30 s re-assert cannot catch it because the node is alive and happily
republishing. Accepted knowingly; see #9 for why, and for the closed-circuit alternatives —
one of which `docs/block-detector-wiring.md` shows is available after all.
Expand Down
47 changes: 26 additions & 21 deletions src/apps/io-node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,48 +46,51 @@
# Output polarity, per device. Polarity belongs to the device on the far end of the wire,
# not to the board or the node, so every SENSORS entry names the device it is.
#
# The two fitted devices are switches to ground: closed (conducting) when asserting,
# open otherwise, with the MCP23017's internal pull-up holding the line high when open.
# So occupied reads 0. Confirmed on the bench 2026-08-23 by scanning all 32 pins with a
# loco in Goods Shed and again with it removed — pin 8 on both boards was the only one
# that moved.
# The Waveshare IR modules on board 2 are switches to ground: closed (conducting) when
# triggered, open otherwise, with the MCP23017's internal pull-up holding the line high
# when open. So triggered reads 0. Confirmed on the bench 2026-08-23 by scanning all 32
# pins with a loco in Goods Shed and again with it removed.
#
# **For both, a broken wire reads as `clear`, not `occupied`** (#9). The pull-up gives a
# defined level on an open circuit, but that level is the permissive one, so a severed
# wire asserts empty track and the re-assert keeps confirming it.
LM_ID_ACTIVE_LOW = True # Legacy Models LM-iD.1, board 1: pulls to 0 V occupied
# **For the IR, a broken wire reads as `clear`, not `occupied`** (#9). The pull-up gives a
# defined level on an open circuit, but that level is the permissive one.
WAVESHARE_IR_ACTIVE_LOW = True # Waveshare IR (LM393), board 2: pulls to 0 V triggered

# The bazauto/block-detection board drives its output push-pull: 3.3 V occupied, 0 V
# clear. Against the pull-up a broken signal wire therefore reads **occupied** — the fix
# for #9 on board 1. Not yet fitted to any sensor. Swap a board 1 entry to it only once
# that channel has passed its wire-pull check on the bench (see #9).
# for #9 on board 1. Every board 1 entry uses it as of 2026-09-25, when the LM-iD.1 on
# Goods Shed was removed. The pull-up must stay on for these inputs, or a broken wire
# floats instead of reading occupied.
BAZAUTO_BD_ACTIVE_LOW = False

# Legacy Models LM-iD.1: pulls to 0 V occupied. **No longer fitted** — board 1 is all
# bazauto/block-detection now. Kept so that refitting one is a visible, one-line change
# rather than a guess; see docs/block-detector-wiring.md before doing so.
LM_ID_ACTIVE_LOW = True

DEBOUNCE_MS = 200

# The full allocation. Pin N is the same block on both boards; board 2 pin 5 is reserved
# and empty because Engine / Goods Transfer has current sensing and no IR beam.
SENSORS = (
# --- board 1, current sensing (block_detection) ---
{"sensor_id": "cs---fiddle-yard-1", "expander": EXPANDER_CS, "pin": 0,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---fiddle-yard-2", "expander": EXPANDER_CS, "pin": 1,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---siding-1", "expander": EXPANDER_CS, "pin": 2,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---siding-2", "expander": EXPANDER_CS, "pin": 3,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---siding-3", "expander": EXPANDER_CS, "pin": 4,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---engine-goods-transfer", "expander": EXPANDER_CS, "pin": 5,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---engine-shed-1", "expander": EXPANDER_CS, "pin": 6,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---engine-shed-2", "expander": EXPANDER_CS, "pin": 7,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
{"sensor_id": "cs---goods-shed", "expander": EXPANDER_CS, "pin": 8,
"active_low": LM_ID_ACTIVE_LOW},
"active_low": BAZAUTO_BD_ACTIVE_LOW},
# --- board 2, IR (ir_position) ---
{"sensor_id": "ir---fiddle-yard-1", "expander": EXPANDER_IR, "pin": 0,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
Expand All @@ -110,7 +113,9 @@

# Physically wired, and therefore the only sensors published. Both on Goods Shed, which
# is the one block with both a detector and a beam — so the bring-up exercises the
# occupancy derivation rather than one sensor in isolation.
# occupancy derivation rather than one sensor in isolation. Every board 1 entry above is
# already on the bazauto/block-detection polarity, so bringing another detector online is
# only a matter of adding its id here.
INSTALLED = (
"cs---goods-shed",
"ir---goods-shed",
Expand Down
22 changes: 13 additions & 9 deletions tests/test_io_node_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,24 @@ def test_pin_n_is_the_same_block_on_both_boards():
assert cs[pin] == block, "pin %d names different blocks on the two boards" % pin


def test_the_installed_sensors_are_active_low():
"""Both Goods Shed devices are switches to ground: occupied reads 0.

Confirmed by scanning all 32 pins with a loco in the block and again with it
removed. Note the consequence recorded in #9: a broken wire floats to the pull-up
and therefore reads `clear`, which is the permissive state, not the safe one.
Swapping `cs---goods-shed` to the bazauto/block-detection board changes this.
"""
def test_the_installed_sensors_have_their_devices_polarity():
"""Goods Shed's detector is the bazauto/block-detection board, occupied reads 1; its
beam is a Waveshare IR switch to ground, triggered reads 0."""
by_id = {e["sensor_id"]: e for e in select_installed(config.SENSORS, config.INSTALLED)}

assert by_id["cs---goods-shed"]["active_low"] is config.LM_ID_ACTIVE_LOW is True
assert by_id["cs---goods-shed"]["active_low"] is config.BAZAUTO_BD_ACTIVE_LOW is False
assert by_id["ir---goods-shed"]["active_low"] is config.WAVESHARE_IR_ACTIVE_LOW is True


def test_every_current_sensor_uses_the_bazauto_detector_polarity():
"""Board 1 is all bazauto/block-detection. A `cs---` entry left on the LM-iD.1's
polarity would read upside down the moment it is added to INSTALLED: an empty block
published as occupied, and — worse — an occupied one as clear."""
for entry in config.SENSORS:
if entry["expander"] == config.EXPANDER_CS:
assert entry["active_low"] is config.BAZAUTO_BD_ACTIVE_LOW, entry["sensor_id"]


def test_the_bazauto_detector_is_active_high():
"""Push-pull, 3.3 V when occupied, so a broken wire into the pull-up reads occupied."""
assert config.BAZAUTO_BD_ACTIVE_LOW is False
Expand Down
Loading