GPIO output pin state events (PIN_CHANGED) were emitted to SSE subscribers even when the
underlying gRPC call to the krill-pi4j daemon threw an exception — clients believed the pin
changed state when the hardware never moved. Similarly, a SerialDevice WRITE that failed
(sendCommand() returning false) did not propagate the failure to nodeManager.failed();
the node settled to NONE instead of FAILED, hiding the error from operators.
In ServerPiManager.setHigh() and setLow(), the scope.launch { EventFlowContainer.postEvent(...) }
call was placed after the try/catch, not inside the try block. The exception was caught and
logged, but execution continued and posted the event unconditionally. Any hardware failure (gRPC error,
channel unavailable) silently told clients the pin changed state.
In ServerSerialDeviceManager.post() (WRITE path), sendCommand() returning false was handled
only with logger.w. Execution fell through to resetWatchdog() and nodeManager.setStateToNone(),
making the failed write look like a successful idle state to the node system and to MCP clients.
ServerPiManager.setHigh() / setLow(): moved scope.launch { postEvent(...) } inside the try
block, after the client.gpio.setOutput() call. Event is now only posted when hardware confirms
success; exceptions still land in the catch for logging.SerialDeviceConnection.kt WRITE path: added a var writeSucceeded = true flag, set to false
when sendCommand() returns false. After the when block, a failed write calls
nodeManager.failed(node, ...) and returns early (return) — skipping resetWatchdog() and
nodeManager.setStateToNone().internal secondary constructor to ServerPiManager for test injection of a pre-built
Pi4jClient mock (bypassing the Pi4j gRPC init), following the “parameterise the platform seam”
pattern from workflow.md.ServerSerialDeviceManager.deviceMap internal so tests can pre-populate a mock port.ServerPiManagerEventGatingTest verifies PIN_CHANGED is gated by hardware success
for both setHigh() and setLow(). ServerSerialWriteFailureTest verifies WRITE failure
calls nodeManager.failed() and skips setStateToNone().try block, not after the catch. The catch proves failure; anything reachable after it
must be genuinely unconditional. Reviewers should flag any postEvent or nodeManager.succeeded
call placed after a try/catch that swallows exceptions.