Skip to content

Read point position from board 3, and answer the backend's queries - #21

Merged
bazauto merged 1 commit into
mainfrom
feat/point-position-feedback
Aug 27, 2026
Merged

bazauto merged 1 commit into
mainfrom
feat/point-position-feedback

Conversation

@bazauto

@bazauto bazauto commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Board 3 is built and answers at 0x22, so the node can now read the Cobalt iP motors'
S2 changeover contacts and publish point/{pointId}/reading.

Closes #15.

The blocker cleared first

The design note said this should not be built until layout-orchestration#167 settled,
because the two candidate answers were different firmware. #167 closed on 2026-08-24,
and it settled as the first of them — republish on a timer, which is the shape this repo
already runs for sensors, rather than answer a periodic query. The contract now carries
the same 30 s re-assert on point/*/reading that sensors have, with
POINT_FRESHNESS_TIMEOUT_MS at 90 s.

So points re-assert on the same 25 s tick as sensors, through the same LayoutMQTT.tick().
One rule, one loop — the re-assert is the easiest thing in this repo to forget and the most
expensive to get wrong, and it should not be possible to implement it for one topic and
miss it for the other.

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.

normal reverse Reading
closed open normal corroborated
open closed reverse corroborated
open open unknown mid-travel, broken wire, lost supply
closed closed unknown impossible on an SPDT — cross-wiring, reported

Both-open is a real reading and is published, not suppressed: a break-before-make
changeover passes through it on every throw, so the honest sequence is
normal → unknown → reverse. The backend expects it and its confirmation timeout is
8000 ms, so a real throw lands well inside. POINT_DEBOUNCE_MS is its own knob for exactly
this reason — tuning the sensors' debounce must not be able to swallow that transient.

Both-closed cannot happen on an SPDT, so it reads unknown — the safe answer, not a guess
between the two — and prints a cross-wiring warning rather than being averaged away.

Subscribing, which is new here

The node only published before. Two decisions worth knowing:

A query is noted, never answered from the callback. poll() dispatches the handler, so
publishing from inside it would re-enter the modem in the middle of whatever command
poll() was called from. note_query() sets a flag and tick() answers on the next pass,
a few tens of milliseconds later. Safe to be late, because a query deliberately does not
arm the backend's confirmation deadline the way a command does.

A failed subscribe refuses to start. Unlike a failed publish it is not survivable: the
node would come up healthy, re-assert happily, and never answer a query — leaving every
queried point at unknown, every edge through it untraversable, and nothing anywhere
reporting a fault. It raises, the supervisor retries, and the LED flashes code 5.

A query for a point this node does not report is ignored rather than answered, and so is one
arriving before the first read. Replying about a point we cannot see is exactly the
fabricated reading the installed allow-list exists to prevent.

Nothing is published yet

POINTS_INSTALLED is empty. The expander answers on the bus but no S2 contacts are landed,
and an unwired pair reads both-open — which would publish a confident unknown for a point
nothing is watching. Same allocation is not installation rule as the sensors.

This is also why landing it is safe: the backend only queries points configured required,
and all six are none, so nothing changes on the layout until the first point is commissioned.

Bring them up one at a time. Every point fault kind Safe-Stops the whole layout, including
a fault on a point no route holds. The commissioning check that catches the mapping hazard is
to throw each point individually and confirm the expected pair — and only that pair — moves.
Throwing them together proves nothing, and neither does testing one while the others sit in
matching positions.

Which throw is normal is unverifiable authored data — nothing in the wiring reveals it, so
the normal/reverse columns are the allocation's intent and commissioning confirms or
corrects them.

Verification

Host suite:

177 passed in 0.10s

49 of those are new: the decode table including both failure rows, the allocation checks, the
payload against the backend's .strict() schema, retention, the re-assert, and the query path.

/mqtt-check run against the diff — point/*/reading at QoS 1 and retain 0, payload
carrying exactly pointId/position/source, source never driver, subscribe return
value checked, no logic in the message callback.

On the bench, after deploying:

--- sensors (should publish + re-assert) ---
13:08:49  sensor/cs---goods-shed/reading {"state": "occupied"}
13:09:13  sensor/cs---goods-shed/reading {"state": "occupied"}
--- point readings (should be SILENT: nothing installed) ---
Timed out

What is not verified, and cannot be until the contacts are landed: that a real throw
produces normal → unknown → reverse on the wire, and that each point's pair is the one
the allocation says it is. Both are commissioning checks on hardware, not host tests.

Docs

docs/pin-allocation.md gains board 3 and records that no points are installed.
docs/point-position-feedback.md loses its Not built marker, its stale "blocking
question" section is rewritten as settled, and it gains a map of where each piece landed.
CLAUDE.md picks up point_wiring.py, the board 3 status, and three new traps — the
commissioning check, the unverifiable normal, and not debouncing the transient away.

Board 3 is fitted and answers at 0x22, so the node can now read the Cobalt iP
motors' S2 changeover contacts and publish `point/{pointId}/reading`.

`layout-orchestration#167` — the blocker the design note called out — closed on
2026-08-24. It settled as the first of its two candidate shapes: republish on a
timer, the same thing this repo already does for sensors, rather than answer a
periodic query. So points re-assert on the same 25 s tick as the sensors, through
the same `LayoutMQTT.tick()`. One rule, one loop.

Two inputs per point, never one. A single input infers `reverse` from the absence
of `normal`, and an absence is equally a broken wire, a lost supply, or a point
sitting mid-throw. Both-open is a real reading and is published: a break-before-make
changeover passes through it on every throw, so the honest sequence is
`normal` -> `unknown` -> `reverse`. Both-closed is impossible on an SPDT, so it
reads `unknown` and is reported as cross-wiring rather than guessed at.

Subscribing is new for this repo — the node only published before. A query is
noted, never answered from the callback: `poll()` dispatches the handler, so
publishing from there would re-enter the modem mid-command. `tick()` answers on
the next pass, which is safe to be late because a query does not arm the
confirmation deadline. A failed subscribe refuses to start, because unlike a
failed publish it is not survivable — the node would look healthy and never answer.

`POINTS_INSTALLED` is empty: no S2 contacts are landed yet, and an unwired pair
reads both-open, which would publish a confident `unknown` for a point nothing is
watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bazauto
bazauto enabled auto-merge (squash) August 27, 2026 12:11
@bazauto
bazauto merged commit 907d07b into main Aug 27, 2026
1 check passed
@bazauto
bazauto deleted the feat/point-position-feedback branch August 27, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Point position feedback node on board 3 (0x22)

1 participant