hardware/board_scanner.h

Hall effect sensor board scanning via multiplexed GPIO.

This module reads 96 A3144 hall effect sensors through 6x 16:1 multiplexers to detect piece presence on the physical chess board and in the two capture/storage zones flanking it.

Hardware Layout: ─────────────── 6x CD74HC4067 (or equivalent) 16:1 digital mux:

Board sensors (64 total, one per square): MUX 1: files a-b → 16 sensors MUX 2: files c-d → 16 sensors MUX 3: files e-f → 16 sensors MUX 4: files g-h → 16 sensors

Storage zone sensors (32 total, 16 per side): MUX 0: black storage (assumed left side) → 16 sensors MUX 5: white storage (right side of board) → 16 sensors

Each storage mux has 16 channels mapped to slots: Channels 0-7: left column, top to bottom Channels 8-15: right column, top to bottom

The two sides use different left/right labels: MUX 0 (black storage): ch0-7 = P1-P8, ch8-15 = R1,R2,B1,B2,N1,N2,Q,K MUX 5 (white storage): ch0-7 = R1,R2,B1,B2,N1,N2,Q,K, ch8-15 = P1-P8

Wiring: ─────── 4 shared select lines (S0-S3) → Pi GPIO, drive all 6 muxes 6 mux output lines (SIG) → Pi GPIO, one per mux

Sensor mapping within each board mux (channel → square): For mux 1 (files a-b): Channel 0 = a8, 1 = a7, …, 7 = a1 Channel 8 = b8, 9 = b7, …, 15 = b1 The same pattern repeats for muxes 2-4 covering c-d, e-f, g-h.

A3144 sensor output: LOW = magnet detected (piece present) HIGH = no magnet (square empty) (active-low with 10K pull-up resistor per sensor)

Variables

constexpr int DEFAULT_PIN_S0 = 17
constexpr int DEFAULT_PIN_S1 = 27
constexpr int DEFAULT_PIN_S2 = 22
constexpr int DEFAULT_PIN_S3 = 23
constexpr int DEFAULT_PIN_MUX0 = 4
constexpr int DEFAULT_PIN_MUX1 = 7
constexpr int DEFAULT_PIN_MUX2 = 8
constexpr int DEFAULT_PIN_MUX3 = 9
constexpr int DEFAULT_PIN_MUX4 = 10
constexpr int DEFAULT_PIN_MUX5 = 11
constexpr int BOARD_MUX_COUNT = 4
constexpr int STORAGE_MUX_COUNT = 2
constexpr int TOTAL_MUX_COUNT = 6
constexpr int CHANNELS_PER_MUX = 16
constexpr int TOTAL_SQUARES = 64
constexpr int STORAGE_SLOTS_PER_SIDE = 16
constexpr int MUX_SETTLE_US = 1000
constexpr int DEBOUNCE_SCANS = 3
constexpr int DEBOUNCE_DELAY_MS = 50
constexpr int DIAGNOSTIC_CHANNEL_HOLD_MS = 1000
constexpr int POLL_INTERVAL_MS = 20
constexpr int ILLEGAL_STATE_ALERT_MS = 10000
struct ScannerMoveResult
#include <board_scanner.h>

Public Functions

inline ScannerMoveResult()

Public Members

int engineMove
bool success
bool timeout
bool illegalAlert
bool wrongSlot
bool cancelled
char uciString[6]
struct ScannerObserver
#include <board_scanner.h>

Public Members

void *context = nullptr
void (*onPieceLifted)(void *ctx, int square) = nullptr
void (*onBoardSettled)(void *ctx) = nullptr
class BoardScanner
#include <board_scanner.h>

Public Functions

BoardScanner()

Construct with default GPIO pin assignments

BoardScanner(const int sPins[4], const int mPins[TOTAL_MUX_COUNT])

Construct with custom GPIO pin assignments

Parameters:
  • sPins – Array of 4 select-line GPIO pins (BCM)

  • mPins – Array of 6 mux-output GPIO pins (BCM)

~BoardScanner()
bool init()

Initialize GPIO and configure pins. Must be called before scanning.

Returns:

true if GPIO setup succeeded

void enableSimulation()

Enable simulation mode: no GPIO access, scanBoard() returns the value set by setSimulatedOccupancy(). Use this for testing on machines without GPIO or without sensor hardware attached.

inline bool isSimulation() const
inline void setSimulatedOccupancy(Bitboard occ)

Set the simulated occupancy (only used in simulation mode)

inline void setSimulatedStorage(uint16_t blackStorage, uint16_t whiteStorage)

Set simulated storage zone occupancy (only in simulation mode) Each uint16_t is a bitmask: bit 0-7 = piece slots, 8-15 = pawn slots

Bitboard scanBoard()

Perform a single raw scan of all 64 board squares. Cycles through all 16 mux channels, reading 4 board mux outputs per channel.

Returns:

64-bit occupancy bitboard (bit 0 = a8, bit 63 = h1)

Bitboard scanBoardDebounced()

Perform a debounced board scan: repeats scanBoard() until the result is stable for DEBOUNCE_SCANS consecutive reads.

Returns:

Stable 64-bit occupancy bitboard

uint16_t scanStorage(bool isWhiteSide)

Scan one side’s storage zone (16 slots).

Parameters:

isWhiteSide – true for white storage (MUX 5), false for black (MUX 0)

Returns:

16-bit bitmask of occupied slots

uint16_t scanStorageDebounced(bool isWhiteSide)

Scan storage with debouncing.

ScannerMoveResult waitForLegalMove(Bitboard currentOccupancy, uint16_t currentBlackStorage, uint16_t currentWhiteStorage, const PieceTracker &tracker, int timeoutMs = 0, void (*illegalStateCallback)() = nullptr, void (*wrongSlotCallback)() = nullptr, const std::atomic<bool> *cancelFlag = nullptr, const ScannerObserver *observer = nullptr)

Wait for the human player to make a legal move on the physical board.

This is the primary interface for human move input. It works by:

  1. Polling until the board OR storage state changes

  2. Waiting for both board and storage to stabilize (debounce)

  3. Diffing the new state against the baselines

  4. Checking if the diff matches exactly one legal engine move, using the specific storage slot to disambiguate captures:

    • If a storage slot gained a piece → identify which piece was captured via PieceTracker, match the capture

    • If storage is unchanged → this is a non-capture move

    • If the wrong slot was used → reject and alert the player

  5. If no legal move matches, going back to step 1 (the move isn’t finished yet — the player is still mid-move)

  6. If the board sits in an unrecognized state for too long, calling illegalStateCallback so the caller can alert the player

This approach cleanly handles:

  • Long pauses (piece held in the air — board unstable or unchanged, storage unchanged → keep waiting)

  • Captures (source square clears on board, storage slot gains a piece → unambiguous via slot identity)

  • Take-backs (piece lifted and put back → board returns to original, storage unchanged → keep waiting)

  • Castling (intermediate state doesn’t match; final state with both king and rook in new positions does, storage unchanged)

  • En passant (board shows 2 cleared + 1 set, storage gains a piece)

Parameters:
  • currentOccupancy – The board occupancy before the expected move

  • currentBlackStorage – Black storage zone baseline

  • currentWhiteStorage – White storage zone baseline

  • trackerPieceTracker for mapping storage slots to pieces

  • timeoutMs – Maximum time to wait (0 = wait forever)

  • illegalStateCallback – Optional callback invoked when the board has been in an unrecognized state for ILLEGAL_STATE_ALERT_MS. Called once per unrecognized-state episode. Can be nullptr.

  • wrongSlotCallback – Optional callback invoked when a captured piece is placed in the wrong storage slot. Can be nullptr.

Returns:

ScannerMoveResult with the matched engine move, or failure info

inline Bitboard getLastScan() const

Get the last stable scan result

inline void setLastScan(Bitboard occ)

Update the last known stable state (call after engine processes a move)

inline uint16_t getLastBlackStorage() const

Get/set storage zone baselines. These track what the storage zones looked like before the human’s turn so we can detect when a new piece arrives (capture).

inline uint16_t getLastWhiteStorage() const
inline void setLastStorage(uint16_t blackSt, uint16_t whiteSt)
inline bool isInitialized() const

Check if the scanner has been initialized

void printScan(Bitboard scan)

Print a visual representation of the current scan

void printStorageScan(uint16_t storage, bool isWhiteSide)

Print a visual representation of a storage zone scan

void diagnosticScan(int channelHoldMs = DIAGNOSTIC_CHANNEL_HOLD_MS)

Scan and print each sensor individually (for hardware debugging). Slowly cycles through every channel and prints the raw reading.

Public Static Functions

static int detectLiftedSquare(Bitboard before, Bitboard after)

Pure lift-detection helper (unit-testable without the poll loop): if after differs from before by exactly one occupied→empty square and nothing was newly occupied, returns that square (a piece in the air); otherwise -1. A completed move (one clear + one set) is deliberately not a lift. This is the LED subsystem’s onPieceLifted trigger (PLAN §6).

static bool inferMove(Bitboard before, Bitboard after, int &from, int &to)

Determine the source and destination squares from an occupancy change. This is a raw diff-based inference — it does NOT validate against legal moves. Prefer waitForLegalMove() for game play.

For a normal move: one bit clears (source), one bit sets (destination) For a capture: one bit clears (source), destination stays set For castling: two bits clear, two bits set (king + rook) For en passant: two bits clear, one bit sets

Parameters:
  • before – Occupancy before the move

  • after – Occupancy after the move

  • from[out] Source square index (0-63)

  • to[out] Destination square index (0-63)

Returns:

true if a valid move pattern was detected

static void squaresToMoveString(int from, int to, char *moveStr, char promotion = '\0')

Build a UCI move string (e.g. “e2e4”) from source and destination squares.

Parameters:
  • from – Source square index (0-63, engine encoding)

  • to – Destination square index (0-63, engine encoding)

  • moveStr[out] Buffer for the move string (at least 6 chars)

  • promotion – Promotion character (‘q’,’r’,’b’,’n’) or ‘\0’ for none

Private Functions

void setMuxChannel(int channel)

Set the mux address (0-15) on the shared select lines

bool readMuxOutput(int muxIndex)

Read a single mux output pin

Returns:

true if piece is present (sensor active-low, so LOW = present)

int readMuxRawLevel(int muxIndex)

Read a single mux output pin as a raw digital level.

Returns:

0 for LOW, 1 for HIGH, -1 if unavailable/error.

int readSelectRawLevel(int selectIndex)

Read back one select line as a raw digital level.

Returns:

0 for LOW, 1 for HIGH, -1 if unavailable/error.

int toSquareIndex(int muxIndex, int channel)

Map a physical board mux/channel pair to a bitboard square index.

Mux 1, channel 0 → square 0 (a8) Mux 1, channel 15 → square 57 (b1) Mux 4, channel 15 → square 63 (h1)

int toStorageSlot(bool isWhiteSide, int channel)

Map a storage mux channel to the normalized StartingSlot index used by PieceTracker and move matching.

bool matchLegalMove(Bitboard before, Bitboard after, int captureSlot, bool capturedIsWhite, const PieceTracker &tracker, int &move)

Try to match an observed board + storage state against the engine’s legal moves.

Capture disambiguation uses storage slot identity:

  • When a storage slot gains a piece, the slot tells us which specific piece was captured (by its starting position).

  • The PieceTracker tells us where that piece currently sits.

  • We only match captures whose target is that square.

For non-capture moves, no storage slot should have changed.

Parameters:
  • before – Board occupancy before the move

  • after – Board occupancy after the move

  • captureSlot – The specific storage slot that gained a piece, or SLOT_NONE if no storage change detected

  • capturedIsWhite – True if the captured piece is white (i.e., it appeared in white’s storage zone)

  • trackerPieceTracker for mapping slot → current square

  • move[out] The matched engine move (if exactly one matches)

Returns:

true if exactly one legal move matches the observed change

Private Members

int selectPins[4]
int muxPins[TOTAL_MUX_COUNT]
bool initialized
bool simulationMode
Bitboard simulatedOccupancy
uint16_t simulatedBlackStorage
uint16_t simulatedWhiteStorage
Bitboard lastStableScan
uint16_t lastBlackStorage
uint16_t lastWhiteStorage

Friends

friend class TestableScanner