An INPUT Server.Pin whose hardware GPIO line was HIGH rendered in the UI as
“LOW” (dim dot) even though the server’s persisted meta.state was ON and a
direct REST call to /node/{id} returned state == "ON". A downstream
Executor.LogicGate (NOT) was producing the correct relay output, confirming
that server-side state was right — only the client display was stale.
PinReconciliationTask corrects mismatches via ServerNodeManager.updatePinState
(direct DB write) by design, to bypass the EventTracker 1-second debounce so
corrections cannot be dropped. But that path only emits a STATE_CHANGE
event, which carries Node.state (NodeState) — not meta.state (DigitalState).
The client’s EventClient PIN_CHANGED handler is the only path that updates
PinMetaData.state; without a PIN_CHANGED event firing, /events SSE
subscribers never learned about the corrected digital level.
OUTPUT pins are unaffected because ServerPinProcessor.process cascades into
PiManager.setHigh/setLow, which posts PIN_CHANGED. INPUT pins fall through
the Mode.IN -> { } empty branch, so reconciliation was the only path that
touched their persisted state — and it skipped the event.
After nodeManager.updatePinState(node, hardwareState) in
PinReconciliationTask.reconcile, also post
Event(node.id, EventType.PIN_CHANGED, PinEventPayload(hardwareState)) on
EventFlowContainer. EventMonitor’s server-side handler is idempotent for
this case (it saves the same state again) and the redundant executeSources
is harmless because the LogicGate re-entrancy guard already deduplicates.
A regression test (PinReconciliationTaskTest) now collects emissions on
EventFlowContainer and asserts that a PIN_CHANGED event is posted whenever
reconciliation actually corrects state. The class of bug — “server mutates
meta but only fires STATE_CHANGE” — is wider than this single call site; any
future code path that bypasses setHigh/setLow for a Pin should explicitly
post PIN_CHANGED, and the contract is now documented inline at the
reconciliation call site.