Feature
Add a true third ESP-NOW peer — a separate ESP32 with an attached physical keyboard — that joins the existing encrypted two-CYD mesh as a fully authenticated, immutable-input node.
Architecture: third ESP-NOW peer (not a USB-serial bridge)
- The two CYDs communicate over encrypted ESP-NOW, not TCP/IP Wi-Fi (
esp_now.h, esp_now_send, esp_now_set_pmk, encrypted peers in startEspNow()). A PC Wi-Fi card cannot join an ESP-NOW conversation, so the keyboard is its own ESP32 node on the same encrypted link the CYDs already use.
- A dedicated ESP32 + keyboard participates in the existing PMK/LMK encryption, peer binding, heartbeat, and replay infrastructure as a real wireless peer.
- ESP-NOW between the two game CYDs is otherwise unchanged.
Hardware
- A third ESP32 (bare ESP32-DevKitC, another CYD, or similar) with Wi-Fi.
- A keyboard attached to that ESP32. The keyboard interface (USB-HID via USB-host, BLE-HID, PS/2, or an I2C keypad such as M5Stack CardKB) is an open implementation decision for the first cut.
Protocol changes
Today the peer model is strictly two-device and singular:
kKnownBoards[] holds exactly two entries.
expectedPeerBoardId / expectedPeerAddress are single values.
gamePeerBoardId is singular; configureExpectedPeer() picks "the other" board from the 2-entry table.
isExpectedPeer() matches that one peer.
localIsHost() and turnBoardId logic assume two participants.
Required changes:
- Extend
kKnownBoards[] to carry >=3 entries (two game CYDs + one keyboard peer).
- Replace the singular
expectedPeer* / gamePeerBoardId model with a peer set that separates the game peer from the optional keyboard peer.
isExpectedPeer() accepts the game peer or the keyboard peer.
esp_now_add_peer() registers both the game peer and the keyboard peer with the existing LMK.
- Send path stays addressed (no broadcast): game-state packets go to the game peer; keyboard input/ACK/heartbeat traffic is routed to/from the keyboard peer.
- Add
MessageType::KeyboardInput. PacketHeader is unchanged — senderId already disambiguates the source.
- Add a
KeyboardInputPacket (header + scancode + modifier bitmask + repeat flag + reserved) with a matching static_assert on wire size, consistent with the existing offsetof/size pinning in protocol.h.
- Increment the protocol version so v5 firmware cleanly rejects the new packet type.
Immutable-input model (non-negotiable)
- A keyboard packet must never move a tile, submit a guess, advance turn ownership, or mutate authoritative game state directly.
- The two game CYDs remain the sole authorities over turn ownership, scoring, and EXIT.
- The receiving CYD validates keyboard packets: sender is the bound keyboard peer, valid session/epoch, not a replay, scancode in range.
- Validated scancodes are queued (lock-free ring buffer) and consumed by the active game's input handler exactly like a touch event — input, not authority.
Active-game routing
Backward compatibility (zero regression)
- With the keyboard peer absent or powered off, the two CYDs pair and play exactly as today: peer binding falls back to game-peer-only, and heartbeat/ACK logic skips the keyboard.
- Keyboard absence is logged over serial and never blocks gameplay.
Acceptance criteria
Feature
Add a true third ESP-NOW peer — a separate ESP32 with an attached physical keyboard — that joins the existing encrypted two-CYD mesh as a fully authenticated, immutable-input node.
Architecture: third ESP-NOW peer (not a USB-serial bridge)
esp_now.h,esp_now_send,esp_now_set_pmk, encrypted peers instartEspNow()). A PC Wi-Fi card cannot join an ESP-NOW conversation, so the keyboard is its own ESP32 node on the same encrypted link the CYDs already use.Hardware
Protocol changes
Today the peer model is strictly two-device and singular:
kKnownBoards[]holds exactly two entries.expectedPeerBoardId/expectedPeerAddressare single values.gamePeerBoardIdis singular;configureExpectedPeer()picks "the other" board from the 2-entry table.isExpectedPeer()matches that one peer.localIsHost()andturnBoardIdlogic assume two participants.Required changes:
kKnownBoards[]to carry >=3 entries (two game CYDs + one keyboard peer).expectedPeer*/gamePeerBoardIdmodel with a peer set that separates the game peer from the optional keyboard peer.isExpectedPeer()accepts the game peer or the keyboard peer.esp_now_add_peer()registers both the game peer and the keyboard peer with the existing LMK.MessageType::KeyboardInput.PacketHeaderis unchanged —senderIdalready disambiguates the source.KeyboardInputPacket(header + scancode + modifier bitmask + repeat flag + reserved) with a matchingstatic_asserton wire size, consistent with the existing offsetof/size pinning inprotocol.h.Immutable-input model (non-negotiable)
Active-game routing
Backward compatibility (zero regression)
Acceptance criteria
kKnownBoards[]holds >=3 entries; a peer set replaces the singularexpectedPeer*/gamePeerBoardIdfields.MessageType::KeyboardInputwire format defined, size-asserted, and rejected by non-keyboard handlers.-std=c++17 -Wall -Wextra -Werror.