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
17 changes: 9 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,11 @@ directory on the path for the same reason.
- **Any `mpremote` command stops the running node**, dropping it to the raw REPL until the
next reset.
- `AT+MQTTPUB` honours backslash escaping, so JSON needs no `AT+MQTTPUBRAW`.
- **Both boards read active low** — occupied/triggered is 0 — but they are different devices
and only agree by coincidence. `ACTIVE_LOW = True` is one global flag covering both
(`main.py` passes `config.ACTIVE_LOW` for every sensor); the day a batch disagrees, it has
to become per-sensor. An earlier "active high" note here was #8's, superseded by #11; see
`docs/block-detector-wiring.md`.
- **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
"active high" note here was #8's, superseded by #11; see `docs/block-detector-wiring.md`.

### Traps

Expand Down Expand Up @@ -187,9 +187,10 @@ directory on the path for the same reason.
`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
the closed-circuit fix on a premise since shown false, so it is reopened
(`docs/block-detector-wiring.md`).
cannot catch it: the node is alive and republishing. The fix for board 1 is 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.
- **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
Expand Down
17 changes: 11 additions & 6 deletions docs/block-detector-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ transistor — a switch to ground and nothing else. With `A` powered, the same p
5 V from an internal pull-up. The vendor's *"the LM-iD will act without outside power, but
powering external devices needs a power source added"* is describing exactly this.

**Occupied reads 0 in both modes**, which is why `config.py`'s `ACTIVE_LOW = True` is correct
**Occupied reads 0 in both modes**, which is why `config.py`'s `LM_ID_ACTIVE_LOW = True` is correct
regardless of how `A` is wired, and why the node has never mis-reported occupancy over this.

## Why this repo held two contradictory conclusions
Expand Down Expand Up @@ -142,7 +142,7 @@ LM-iD B ──┬── 74LVC14A input ──► MCP23017 input

That is the closed-circuit principle #9 was reaching for, achieved with one 14-pin package
per six channels and no change to the detectors. It requires `A` to be powered, and it
requires `ACTIVE_LOW` to be flipped to `False`, because the inverter has moved the polarity.
requires those sensors' `active_low` to be `False`, because the inverter has moved the polarity.

**The important half is not the circuit, it is what it means:** powering `A` is what turns
`clear` from an *absence of signal* into an *assertion*. With `A` unpowered, clear and
Expand All @@ -155,6 +155,12 @@ Current position is unchanged — `A` stays unpowered while the layout is being
the broken-wire limitation stands as #9 describes it. This document records that the door #9
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 IR sensors are not these

Board 2 carries [Waveshare Infrared Reflective Sensor](https://www.waveshare.com/wiki/Infrared_Reflective_Sensor)
Expand All @@ -172,10 +178,9 @@ which means, unlike an unpowered LM-iD, **its high level is asserted at the sens
than being an absence. There is no 5 V hazard and no mode to get wrong. The module also brings
out `AOUT` and a sensitivity potentiometer; neither is used.

Both boards happen to read **active low**, so the single global `ACTIVE_LOW = True` is correct
for both. That is a coincidence of two unrelated devices, not a property of the design —
`main.py` passes `config.ACTIVE_LOW` for every sensor, so the first batch that disagrees makes
it a per-sensor field.
Both boards happen to read **active low**. That is a coincidence of two unrelated devices, not
a property of the design, which is why polarity is a per-sensor field in `config.py` with one
constant per device (`docs/pin-allocation.md`).

### The broken-wire exposure is still there, and it fails the other way

Expand Down
28 changes: 21 additions & 7 deletions docs/pin-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,29 @@ If in doubt, do not count — measure. `mpremote run` a scan of all 32 pins with
triggered and see which one moves. That took under a minute and settled it definitively both
times counting got it wrong.

## Sensor polarity: active LOW
## Sensor polarity: per sensor

Both boards 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 `config.py` has
`ACTIVE_LOW = True`.
Polarity is a property of the device on the far end of the wire, not of the board or the
node, so every `SENSORS` entry in `config.py` carries its own `active_low`, set from a
per-device constant. There is no default: startup refuses an entry without one, or with
anything other than `True` or `False`.

**The two boards are different devices that agree by coincidence, not by design.** `main.py`
passes one global `config.ACTIVE_LOW` for every sensor, so the first batch that disagrees
makes it a per-sensor field rather than a config edit.
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.

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.

| Constant | Device | Occupied reads |
|---|---|---|
| `LM_ID_ACTIVE_LOW = True` | Legacy Models LM-iD.1 | 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 |
|---|---|---|
Expand Down
74 changes: 50 additions & 24 deletions src/apps/io-node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,43 +40,69 @@
EXPANDER_IR = 0x21
EXPANDER_POINTS = 0x22

# The sensors are switches to ground: closed (conducting) when the block is occupied,
# 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.
#
# **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. Accepted knowingly for now;
# see #9 for the closed-circuit alternatives.
ACTIVE_LOW = True
# **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
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).
BAZAUTO_BD_ACTIVE_LOW = False

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},
{"sensor_id": "cs---fiddle-yard-2", "expander": EXPANDER_CS, "pin": 1},
{"sensor_id": "cs---siding-1", "expander": EXPANDER_CS, "pin": 2},
{"sensor_id": "cs---siding-2", "expander": EXPANDER_CS, "pin": 3},
{"sensor_id": "cs---siding-3", "expander": EXPANDER_CS, "pin": 4},
{"sensor_id": "cs---engine-goods-transfer", "expander": EXPANDER_CS, "pin": 5},
{"sensor_id": "cs---engine-shed-1", "expander": EXPANDER_CS, "pin": 6},
{"sensor_id": "cs---engine-shed-2", "expander": EXPANDER_CS, "pin": 7},
{"sensor_id": "cs---goods-shed", "expander": EXPANDER_CS, "pin": 8},
{"sensor_id": "cs---fiddle-yard-1", "expander": EXPANDER_CS, "pin": 0,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---fiddle-yard-2", "expander": EXPANDER_CS, "pin": 1,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---siding-1", "expander": EXPANDER_CS, "pin": 2,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---siding-2", "expander": EXPANDER_CS, "pin": 3,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---siding-3", "expander": EXPANDER_CS, "pin": 4,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---engine-goods-transfer", "expander": EXPANDER_CS, "pin": 5,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---engine-shed-1", "expander": EXPANDER_CS, "pin": 6,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---engine-shed-2", "expander": EXPANDER_CS, "pin": 7,
"active_low": LM_ID_ACTIVE_LOW},
{"sensor_id": "cs---goods-shed", "expander": EXPANDER_CS, "pin": 8,
"active_low": LM_ID_ACTIVE_LOW},
# --- board 2, IR (ir_position) ---
{"sensor_id": "ir---fiddle-yard-1", "expander": EXPANDER_IR, "pin": 0},
{"sensor_id": "ir---fiddle-yard-2", "expander": EXPANDER_IR, "pin": 1},
{"sensor_id": "ir---siding-1", "expander": EXPANDER_IR, "pin": 2},
{"sensor_id": "ir---siding-2", "expander": EXPANDER_IR, "pin": 3},
{"sensor_id": "ir---siding-3", "expander": EXPANDER_IR, "pin": 4},
{"sensor_id": "ir---fiddle-yard-1", "expander": EXPANDER_IR, "pin": 0,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---fiddle-yard-2", "expander": EXPANDER_IR, "pin": 1,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---siding-1", "expander": EXPANDER_IR, "pin": 2,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---siding-2", "expander": EXPANDER_IR, "pin": 3,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---siding-3", "expander": EXPANDER_IR, "pin": 4,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
# pin 5 reserved — no IR beam on Engine / Goods Transfer
{"sensor_id": "ir---engine-shed-1", "expander": EXPANDER_IR, "pin": 6},
{"sensor_id": "ir---engine-shed-2", "expander": EXPANDER_IR, "pin": 7},
{"sensor_id": "ir---goods-shed", "expander": EXPANDER_IR, "pin": 8},
{"sensor_id": "ir---engine-shed-1", "expander": EXPANDER_IR, "pin": 6,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---engine-shed-2", "expander": EXPANDER_IR, "pin": 7,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
{"sensor_id": "ir---goods-shed", "expander": EXPANDER_IR, "pin": 8,
"active_low": WAVESHARE_IR_ACTIVE_LOW},
)

# Physically wired, and therefore the only sensors published. Both on Goods Shed, which
Expand Down Expand Up @@ -104,7 +130,7 @@
#
# 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
# rather than the sensor polarities 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
Expand Down
18 changes: 11 additions & 7 deletions src/apps/io-node/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ def get_expander(expanders, i2c, available, address, what):


def build_sensors(i2c, available, expanders, wiring):
"""Configure one input pin per installed sensor."""
"""Configure one input pin per installed sensor, returned with its polarity.

The pull-up stays on for every sensor, whatever its polarity. For an active-high
device it is what makes a broken wire read `occupied` rather than float (#9).
"""
sensors = []

for entry in wiring:
Expand All @@ -60,7 +64,7 @@ def build_sensors(i2c, available, expanders, wiring):
pin = expander.create_pin(
entry["pin"], mode="input", debounce_ms=config.DEBOUNCE_MS)
pin.setup()
sensors.append((entry["sensor_id"], pin))
sensors.append((entry["sensor_id"], pin, entry["active_low"]))

return sensors

Expand Down Expand Up @@ -171,7 +175,7 @@ def bring_up():
on_publish_failure=lambda topic_id, exc: print(
"io-node: publish failed for %s (%s)" % (topic_id, exc)),
)
for sensor_id, _pin in sensors:
for sensor_id, _pin, _active_low in sensors:
layout.register_sensor(sensor_id)
for point_id, _normal, _reverse in points:
layout.register_point(point_id)
Expand All @@ -185,8 +189,8 @@ def bring_up():
# Publish what is on the track right now. Without this the first reading waits for
# an edge, and a block occupied since before boot would never be reported at all.
now = ticks_ms()
for sensor_id, pin in sensors:
layout.publish_sensor(sensor_id, is_occupied(pin.state, config.ACTIVE_LOW), now)
for sensor_id, pin, active_low in sensors:
layout.publish_sensor(sensor_id, is_occupied(pin.state, active_low), now)
for point_id, normal_pin, reverse_pin in points:
position, cross_wired = read_point(normal_pin, reverse_pin)
if cross_wired:
Expand All @@ -201,10 +205,10 @@ def run(sensors, points, layout, client):
while True:
now = ticks_ms()

for sensor_id, pin in sensors:
for sensor_id, pin, active_low in sensors:
if pin.update_state():
layout.publish_sensor(
sensor_id, is_occupied(pin.state, config.ACTIVE_LOW), now)
sensor_id, is_occupied(pin.state, active_low), now)

for point_id, normal_pin, reverse_pin in points:
# Both, every pass, and never short-circuited: a throw moves one contact
Expand Down
2 changes: 1 addition & 1 deletion src/lib/point_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def is_closed(level, active_low):

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
than the sensors' per-device polarity: 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.
"""
Expand Down
19 changes: 14 additions & 5 deletions src/lib/sensor_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ def select_installed(sensors, installed):
sensor_id = entry["sensor_id"]
expander = entry["expander"]
pin = entry["pin"]
active_low = entry["active_low"]
except (KeyError, TypeError):
raise ValueError(
"every SENSORS entry needs sensor_id, expander and pin — got %r" % (entry,))
"every SENSORS entry needs sensor_id, expander, pin and active_low — got %r"
% (entry,))

validate_sensor_id(sensor_id)

# No default, and nothing truthy standing in for a bool. Polarity is a property of
# the device on the far end of the wire, and a guessed one publishes `clear` for
# every occupied block that sensor watches.
if active_low is not True and active_low is not False:
raise ValueError(
"%s: active_low must be True or False — got %r" % (sensor_id, active_low))

if sensor_id in by_id:
raise ValueError("sensor id %r appears twice in SENSORS" % sensor_id)
by_id[sensor_id] = entry
Expand Down Expand Up @@ -69,9 +78,9 @@ def is_occupied(level, active_low):

One line, and it has its own name because getting it backwards publishes `clear`
for an occupied block — which for a block_detection sensor is enough on its own to
let the orchestrator clear the block. Westgate Hollow's block detectors are active
**low** — occupied reads 0 (measured, see docs/pin-allocation.md). The polarity stays
configurable because the next batch of hardware may not be, and because fitting the
inverting stage in docs/block-detector-wiring.md would flip this one.
let the orchestrator clear the block. `active_low` comes from the sensor's own
SENSORS entry, because it is a property of the device on the far end of the wire:
the LM-iD.1 detectors and the IR modules are active low, the bazauto/block-detection
board is active high (see docs/pin-allocation.md).
"""
return (level == 0) if active_low else (level == 1)
23 changes: 20 additions & 3 deletions tests/test_io_node_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,31 @@ 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_sensors_are_active_low():
"""Switches to ground: closed when occupied, so occupied reads 0.
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.
"""
assert config.ACTIVE_LOW is True
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["ir---goods-shed"]["active_low"] is config.WAVESHARE_IR_ACTIVE_LOW is True


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


def test_every_ir_sensor_uses_the_ir_polarity():
"""The bazauto/block-detection board replaces current sensing only. An IR entry
given a detector's polarity would read every beam upside down."""
for entry in config.SENSORS:
if entry["expander"] == config.EXPANDER_IR:
assert entry["active_low"] is config.WAVESHARE_IR_ACTIVE_LOW, entry["sensor_id"]


def test_no_outputs_are_configured():
Expand Down
Loading
Loading