SharpKey Multi-HID Interface Developer's Guide

Overview

SharpKey is a multi-host keyboard interface based on the ESP32 microcontroller. It translates modern PS/2 and Bluetooth keyboards into the native keyboard protocols of vintage Sharp and NEC computers, enabling the use of contemporary input devices with classic hardware. The supported host machines are:
  • Sharp MZ-2500 — SuperMZ series
  • Sharp MZ-2800 — 16-bit SuperMZ
  • Sharp MZ-5500/5600/6500 — MZ business series
  • Sharp X1 — X1 series personal computers
  • Sharp X68000 — 68000-based personal workstation
  • NEC PC-9801 — NEC 98 architecture
The same codebase also builds the simpler mz25key variant which targets only the MZ-2500 and MZ-2800 machines, producing a smaller binary with reduced feature set.
SharpKey is designed around the dual-core ESP-32S AI Thinker module. Core 1 is dedicated to the timing-critical host interface, while Core 0 handles FreeRTOS system tasks, WiFi, Bluetooth, and HID input processing. This separation ensures that nanosecond-level protocol timing on the host side is never disrupted by operating system overhead.
The firmware includes a built-in WiFi web interface for configuration, key mapping customisation, and over-the-air (OTA) firmware updates. Bluetooth HID support allows wireless keyboards and mice to be used with vintage hardware.
SharpKey is licensed under the GNU General Public License v3. Author: Philip Smart.

Prerequisites

Before building and working with the SharpKey firmware, the following tools and components are required:

Software Prerequisites

  • ESP-IDF v4.4 — Espressif IoT Development Framework. This is the official development framework for the ESP32 family. Version 4.4 is required; newer versions may introduce breaking API changes.
  • Python 3.8+ — Required by the IDF toolchain for build scripts, menuconfig, and the eFuse table generator.
  • Git — For cloning the repository and initialising submodules (arduino-esp32 and esp_littlefs).
  • Docker — Optional. The Espressif IDF Docker image (espressif/idf:v4.4) provides a pre-configured build environment, useful for CI/CD pipelines and reproducible builds.
  • Linux or macOS — Recommended host operating system. Windows users should use WSL2 (Windows Subsystem for Linux 2) for full compatibility with the IDF toolchain and shell scripts.

Hardware Prerequisites

  • SharpKey PCB — The custom PCB designed for the SharpKey project, fitted with an ESP-32S AI Thinker module.
  • PS/2 keyboard — For wired keyboard input testing. Any standard PS/2 keyboard using Scan Code Set 2 is compatible.
  • Bluetooth HID keyboard — Optional. For testing wireless keyboard support. Both BT Classic HID and BLE HID devices are supported.
  • USB-to-serial adapter — For flashing firmware and serial monitor debugging (typically /dev/ttyUSB0 on Linux).
  • Target vintage machine — One of the supported host machines for end-to-end testing.

Repository Structure

The SharpKey repository is organised into a standard ESP-IDF project layout with additional directories for the web interface and external components. The complete tree is shown below:
SharpKey/
├── main/
│   ├── SharpKey.cpp              — Entry point, initialisation, FreeRTOS task creation
│   ├── MZ2528.cpp                — MZ-2500/2800 host interface (Core 1, timing-critical)
│   ├── MZ5665.cpp                — MZ-5500/5600/6500 host interface
│   ├── X1.cpp                    — Sharp X1 host interface
│   ├── X68K.cpp                  — Sharp X68000 host interface
│   ├── PC9801.cpp                — NEC PC-9801 host interface
│   ├── HID.cpp                   — HID device manager (multiplexes PS/2 and BT)
│   ├── BTHID.cpp                 — Bluetooth HID keyboard/mouse support
│   ├── BT.cpp                    — Low-level Bluetooth stack management
│   ├── WiFi.cpp                  — WiFi AP/Client, web server, OTA updates, REST API
│   ├── PS2KeyAdvanced.cpp        — PS/2 keyboard protocol (Scan Code Set 2)
│   ├── PS2Mouse.cpp              — PS/2 mouse protocol handler
│   ├── Mouse.cpp                 — Mouse protocol abstraction
│   ├── KeyInterface.cpp          — Virtual base class for host interfaces
│   ├── LED.cpp                   — Status LED control
│   ├── NVS.cpp                   — Non-Volatile Storage (config persistence)
│   ├── SWITCH.cpp                — Configuration switch (WiFi/BT mode)
│   ├── esp_efuse_custom_table.c  — Custom eFuse field definitions (generated)
│   ├── esp_efuse_custom_table.csv — eFuse field CSV source
│   ├── Kconfig.projbuild         — Menuconfig options (~310 lines)
│   ├── CMakeLists.txt            — Component registration
│   └── include/
│       ├── KeyInterface.h        — Base class definition
│       ├── MZ2528.h              — MZ key matrix mapping (165+ entries)
│       ├── MZ5665.h              — MZ-5600 series mappings
│       ├── X1.h                  — X1 key mappings
│       ├── X68K.h                — X68000 key mappings
│       ├── PC9801.h              — PC-9801 key mappings
│       ├── HID.h                 — HID manager header
│       ├── BTHID.h               — Bluetooth HID header
│       ├── BT.h                  — Bluetooth stack header
│       ├── PS2KeyAdvanced.h      — PS/2 keyboard header
│       ├── PS2KeyCode.h          — PS/2 key code definitions
│       ├── PS2KeyTable.h         — PS/2 key lookup tables
│       ├── WiFi.h                — WiFi and web server header
│       ├── LED.h                 — LED control header
│       ├── NVS.h                 — NVS storage header
│       ├── SWITCH.h              — Mode switch header
│       ├── Mouse.h               — Mouse abstraction header
│       ├── PS2Mouse.h            — PS/2 mouse header
│       └── esp_efuse_custom_table.h  (generated from CSV)
├── webserver/
│   ├── index.html                — Main web UI dashboard
│   ├── keymap.html               — Key mapping editor
│   ├── mouse.html                — Mouse settings page
│   ├── ota.html                  — OTA firmware upload page
│   ├── wifimanager.html          — WiFi configuration page
│   ├── version.txt               — Firmware version string
│   ├── css/                      — Bootstrap and custom stylesheets
│   ├── js/                       — jQuery, Bootstrap, keymap and OTA scripts
│   └── font-awesome/             — Icon fonts for web UI
├── components/
│   ├── arduino-esp32/            — Arduino compatibility layer (submodule, v2.0.3)
│   └── esp_littlefs/             — LittleFS filesystem driver (submodule, v1.3.1)
├── build_webfs.sh                — Web filesystem build script
├── sharpkey_partition_table.csv  — Flash partition layout
├── sdkconfig                     — Current build configuration
├── version.txt                   — Release version
└── CMakeLists.txt                — Top-level project CMake configuration
The main/ directory contains all firmware source code. Each host interface is implemented as a separate compilation unit with its own header file containing the key mapping tables. The webserver/ directory holds the web interface assets that are compiled into a LittleFS filesystem image. The components/ directory contains two Git submodules providing Arduino compatibility and LittleFS filesystem support.

Software Architecture

The SharpKey firmware is structured as a layered architecture running on FreeRTOS, leveraging both cores of the ESP32 dual-core processor to achieve the timing precision required by vintage keyboard protocols.

Architecture Overview

The architecture is divided into three distinct layers:
  • Input Layer — Handles PS/2 keyboard and mouse protocols (PS2KeyAdvanced, PS2Mouse) and Bluetooth HID devices (BTHID, BT). These components run on Core 0 alongside the FreeRTOS scheduler and WiFi/BT stacks.
  • Processing Layer — The HID abstraction layer (HID.cpp) multiplexes input from PS/2 and Bluetooth sources into a unified key event stream. Key mapping tables translate HID scan codes into host-specific matrix positions.
  • Output Layer — Host-specific interface implementations (MZ2528, MZ5665, X1, X68K, PC9801) convert mapped key events into the electrical signals expected by each vintage machine. These run on Core 1 with real-time priority.
The KeyInterface virtual base class defines the contract that all host interface implementations must fulfil. Each host machine has a concrete implementation that inherits from KeyInterface and is instantiated as a singleton. At boot, SharpKey.cpp detects which host is connected by reading GPIO configuration lines and instantiates the appropriate interface class.
┌─────────────────────────────────────────────────────────────────┐
│                        Input Layer (Core 0)                     │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ PS2KeyAdvanced│  │  PS2Mouse    │  │  BTHID / BT          │  │
│  │ (Scan Code 2) │  │ (3-button)   │  │  (Classic + BLE)     │  │
│  └──────┬───────┘  └──────┬───────┘  └──────────┬───────────┘  │
│         │                 │                      │              │
│  ┌──────┴─────────────────┴──────────────────────┴───────────┐  │
│  │                    HID.cpp (Multiplexer)                   │  │
│  └────────────────────────────┬──────────────────────────────┘  │
│                               │                                 │
├───────────────────────────────┼─────────────────────────────────┤
│                     Processing Layer                            │
│  ┌────────────────────────────┴──────────────────────────────┐  │
│  │              Key Mapping Tables (t_keyMap[])               │  │
│  │         PS/2 scan code → host matrix row/column            │  │
│  └────────────────────────────┬──────────────────────────────┘  │
│                               │                                 │
├───────────────────────────────┼─────────────────────────────────┤
│                       Output Layer (Core 1)                     │
│  ┌─────────┐ ┌─────────┐ ┌────┐ ┌──────┐ ┌────────┐          │
│  │ MZ2528  │ │ MZ5665  │ │ X1 │ │ X68K │ │ PC9801 │          │
│  └─────────┘ └─────────┘ └────┘ └──────┘ └────────┘          │
│         GPIO register writes → Host machine connector          │
└─────────────────────────────────────────────────────────────────┘

FreeRTOS Task Model

SharpKey creates dedicated FreeRTOS tasks pinned to specific cores. The task allocation is designed to isolate the timing-critical host interface on Core 1, free from interruptions by WiFi, Bluetooth, or FreeRTOS system tasks which all run on Core 0.
Task Core Priority Stack (bytes) Purpose
mz25Interface Core 1 25 4096 MZ-2500 timing-critical GPIO loop (spinlock protected)
mz28Interface Core 1 MAX-1 2048 MZ-2800 timing-critical GPIO loop
hidInterface Core 0 0 4096 HID input polling (PS/2 scan + Bluetooth events)
WiFi/BT tasks Core 0 various ESP-IDF managed system tasks
Core 1 is dedicated exclusively to the host interface task. This task runs in a tight loop reading row select signals from the host and writing column data back, with timing requirements as tight as 650 nanoseconds for the MZ-2800 protocol. A portMUX_TYPE spinlock protects the shared key matrix data structure that is written by the HID task on Core 0 and read by the host interface task on Core 1.
The HID interface task on Core 0 runs at the lowest priority, polling PS/2 and Bluetooth inputs and updating the key matrix. Because this task shares Core 0 with the WiFi and Bluetooth stacks, it uses cooperative scheduling and does not require real-time guarantees — the key matrix is simply a snapshot of current key states that the Core 1 task reads when the host requests it.

KeyInterface Base Class

The KeyInterface class is the abstract base that defines the contract for all host interface implementations. It is defined in main/include/KeyInterface.h and implemented in main/KeyInterface.cpp. The class hierarchy is:
KeyInterface (abstract base)
├── MZ2528     — Sharp MZ-2500 and MZ-2800
├── MZ5665     — Sharp MZ-5500, MZ-5600, MZ-6500
├── X1         — Sharp X1 series
├── X68K       — Sharp X68000
└── PC9801     — NEC PC-9801
The key virtual methods that each host interface must implement are:
  • init() — Initialise GPIO pins, configure interrupts, set up the key matrix, and create the FreeRTOS task pinned to Core 1. Called once at startup after host detection.
  • mapKey(uint16_t scanCode, uint8_t keyState) — Map an incoming PS/2 scan code (with modifier flags) to the host key matrix. This method looks up the scan code in the t_keyMap[] table and sets or clears the appropriate bits in the keyMatrix[] array.
  • createKeyMapFile() — Serialise the current key mapping table to NVS storage for persistence across reboots. Used by the web interface key mapping editor.
  • getKeyMapData() — Return the current key mapping data as a JSON structure for the web interface.
  • suspend() — Suspend the host interface task. Used when switching between WiFi and Bluetooth modes or during OTA updates.
  • resume() — Resume a suspended host interface task.
The base class also provides a yield() inline method that wraps vTaskDelay(1) for use in non-timing-critical sections, and utility methods for LED control, NVS access, and WiFi state management. Each host interface is instantiated as a singleton — only one interface is active at any time, determined by the host detection logic at boot.

Host Detection

At startup, SharpKey.cpp reads a set of GPIO configuration lines to determine which host machine is connected to the SharpKey interface. The detection logic examines the electrical state of specific pins that differ between host connectors — each vintage machine presents a unique signature on these lines due to differences in connector wiring and active signals.
Once the host is identified, SharpKey instantiates the appropriate interface class (e.g., MZ2528 for an MZ-2500, X68K for an X68000) and calls its init() method. If the build target is set to MZ25KEY_MZ2500 or MZ25KEY_MZ2800 in menuconfig, the detection step is skipped and the corresponding interface is instantiated directly, reducing code size for single-target builds.
Feature security eFuse bits can further restrict which interfaces are available at runtime. If ENABLE_FEATURE_SECURITY is set in the eFuse, only interfaces whose corresponding enable bit is programmed will be instantiated, even if the GPIO detection identifies the host. See the eFuse Custom Fields section for details.

Key Mapping System

The key mapping system is the core of SharpKey's functionality. It translates modern keyboard scan codes into the matrix positions expected by vintage host machines, handling modifier keys, multi-key combinations, and keyboard-model-specific variations.

Scan Code Pipeline

The key event pipeline processes input through several stages:
PS/2 Keyboard
    │
    ▼
PS2KeyAdvanced (Scan Code Set 2 → ASCII + modifier flags)
    │
    ▼
HID.cpp (multiplex with Bluetooth input)
    │
    ▼
KeyInterface::mapKey() (lookup in t_keyMap[] table)
    │
    ▼
keyMatrix[16] (set/clear matrix bits)
    │
    ▼
keyMatrixAsGPIO[16] (pre-shifted for GPIO registers)
    │
    ▼
GPIO.out_w1ts / GPIO.out_w1tc (atomic register writes to host)
The PS2KeyAdvanced library handles the low-level PS/2 protocol, decoding Scan Code Set 2 sequences (including extended keys with E0 prefix and break codes with F0 prefix) into a 16-bit value containing the ASCII key code in the lower byte and modifier flags (SHIFT, CTRL, ALT, GUI, CAPS, NUM, SCROLL) in the upper byte.
This 16-bit value is then passed to the active host interface's mapKey() method, which searches the t_keyMap[] table for a matching entry. When a match is found, the corresponding matrix row and column bits are set (for key press) or cleared (for key release) in the keyMatrix[] array.

Mapping Table Structure

Each host interface header file (e.g., MZ2528.h) contains a t_keyMap[] array of key mapping entries. Each entry in the table has the following fields:
Field Type Description
ps2KeyCode uint8_t ASCII key code from PS2KeyAdvanced (lower byte of scan result)
ps2Ctrl uint8_t Modifier flag mask (SHIFT, CTRL, ALT, CAPS, etc.)
keyboardModel uint8_t PS/2 keyboard model identifier (0 = all models)
machine uint8_t Target machine (MZ_ALL, MZ_80B, MZ_2000, MZ_2500, MZ_2800)
MK_ROW1 uint8_t Make key 1: matrix row number
MK_KEY1 uint8_t Make key 1: column bit position
MK_ROW2 uint8_t Make key 2: matrix row number (for simultaneous keys)
MK_KEY2 uint8_t Make key 2: column bit position
MK_ROW3 uint8_t Make key 3: matrix row number (for triple-key combos)
MK_KEY3 uint8_t Make key 3: column bit position
BRK_ROW1 uint8_t Break key 1: matrix row to release
BRK_KEY1 uint8_t Break key 1: column bit to release
BRK_ROW2 uint8_t Break key 2: matrix row to release
BRK_KEY2 uint8_t Break key 2: column bit to release
The make (MK) fields support up to three simultaneous key presses, allowing a single PS/2 key to generate multi-key combinations on the host. For example, a character that requires SHIFT+key on the host can be generated from an unshifted PS/2 key by setting MK_ROW1/KEY1 to the SHIFT matrix position and MK_ROW2/KEY2 to the character position. The break (BRK) fields support up to two simultaneous key releases.
The MZ-2500/2800 uses a 16-row by 8-column key matrix (16 rows, 8 column bits per row). The matrix layout for key groups is:
Row(s) Key Group
0 F1, F2, F3, F4, F5, F6, F7, F8
1 F9, F10, KP_*, KP_+, KP_=, HELP, COPY, ARGO
2 CLR, HOME, INST, DEL, BS, 前行, ESC, SCROLL
3 BREAK, RIGHT, LEFT, DOWN, UP, RETURN, SPACE, TAB
4-7 Letters A-Z (distributed across 4 rows)
8-9 Numbers 0-9, minus, caret, yen, at
10 Brackets, semicolon, colon, comma, period, slash, underscore
11 CTRL, KANA, SHIFT (left), LOCK, GRAPH, — , SHIFT (right), —
12-13 Keypad digits 0-9, KP_period, KP_comma, KP_minus, KP_enter
14-15 Extended function keys, additional specials

Supported Keyboard Models

SharpKey supports multiple PS/2 keyboard models, each identified by a model number in the mapping table. This allows keyboard-model-specific mappings to handle variations in key layout and scan code assignments. A keyboardModel value of 0 indicates the mapping applies to all models.
Model ID Keyboard Notes
0 All / Generic Default mappings for any PS/2 keyboard
1 Wyse KB3926 Wyse terminal keyboard, compact layout
2 OADG109 Japanese 109-key layout (JIS)
3 Sanwa SKBL1 Sanwa Supply backlit keyboard
4 Periboard 810 Perixx wireless with PS/2 adapter
5 Omoton K8508 Compact Bluetooth keyboard
6 Dell KB212-B Standard Dell USB keyboard (with PS/2 adapter)
7 Logitech K120 Common Logitech wired keyboard
8 Cherry G80 Cherry mechanical keyboard series
The keyboard model is detected automatically where possible (via the PS/2 keyboard ID command) or can be set manually through the web interface. Model-specific mappings override generic mappings when the keyboard model matches.

Matrix Output to Host

The key matrix is maintained as two parallel arrays:
  • keyMatrix[16] — A uint8_t array where each element represents one matrix row. Each bit corresponds to a column: bit set = key pressed, bit clear = key released.
  • keyMatrixAsGPIO[16] — A pre-computed uint32_t array where the 8 column bits have been shifted into the GPIO output positions expected by the hardware. This eliminates bit-shifting in the timing-critical output loop.
When the host scans a matrix row (by driving the row select lines KDB0-KDB3), the interface task reads the selected row number, looks up keyMatrixAsGPIO[row], and writes the value directly to the ESP32's GPIO output registers using atomic set/clear operations:
// Clear all column output bits
GPIO.out_w1tc = KDO_ALL_MASK;

// Set the bits for pressed keys in the selected row
GPIO.out_w1ts = keyMatrixAsGPIO[selectedRow];
The out_w1ts (write-1-to-set) and out_w1tc (write-1-to-clear) registers provide atomic bit manipulation without read-modify-write cycles, which is essential for meeting the sub-microsecond timing requirements of the MZ-2500/2800 protocol.

Adding a New Host Interface

One of SharpKey's key design goals is extensibility. If you want to connect SharpKey to a retro computer that is not currently supported, you can add a new host interface by creating a pair of source files (a class that inherits from KeyInterface), wiring the host's keyboard connector to the SharpKey PCB, and registering the new host in the detection logic. This section covers both the electrical requirements and the software steps in detail.

Electrical Considerations

The SharpKey PCB uses an ESP-32S AI Thinker module whose GPIO pins are brought out to a host connector via a 74HCT257 quad 2-to-1 multiplexer. The available signals and their electrical characteristics are summarised below. Any new host interface must work within these constraints.
Available I/O Lines
The host connector exposes the following GPIO groups. All active pins are directly connected to the ESP32 and operate at 3.3V logic levels. If the target host uses 5V TTL logic, the existing 74HCT257 level-shifting circuitry handles the translation (HCT inputs accept 3.3V as logic HIGH; outputs are 5V-tolerant). For hosts with different voltage requirements you may need to add external level shifters on the host cable.
Group Signals Default GPIOs Direction Description
KDO[7:0] 8 data lines 14, 15, 16, 17, 18, 19, 21, 22 Output Primary data bus. On existing hosts these carry the key matrix column data driven by the ESP32. Active-low convention: ‘1’ = key released, ‘0’ = key pressed. Directly mapped to GPIO.out_w1ts / GPIO.out_w1tc for atomic sub-microsecond writes.
KDB[3:0] 4 address lines 23, 25, 26, 27 Input (default) / Output Row-select inputs on existing hosts (host sends a 4-bit row number). Can be reconfigured as outputs via reconfigADC2Ports() for hosts that need a bidirectional bus. On the X1 interface these are temporarily set to output mode for loopback detection.
RTSNI 1 strobe line 35 Input only Active-low strobe from the host. GPIO 35 is in the ESP32 input-only bank (GPIO_IN1_REG, bit 3). On the MZ-2500/2800 this is the row-transfer-strobe; on other hosts it may be unused (directly tied HIGH or LOW on the cable).
MPXI 1 multiplex line 12 Input Multiplex / mode select from the host. Used on MZ machines to distinguish MZ-2500 from MZ-2800 timing. Available for any purpose on a new host.
KDI4 1 extra input 13 Input Auxiliary data input. On MZ hosts this indicates single-row vs STROBEALL mode. Available for any purpose.
PS/2 DATA 1 bidirectional 14 Bidirectional PS/2 keyboard data line (directly to the mini-DIN connector).
PS/2 CLK 1 interrupt-capable 13 Bidirectional PS/2 keyboard clock line. Must be interrupt-capable for Scan Code Set 2 reception.
PWRLED 1 output 25 Output Power/status LED.
WIFI_EN 1 input 34 Input only WiFi enable switch (active-low, GPIO 34 is input-only).
Important: Note that some GPIO pins are shared between groups. GPIO 14 is the default for both PS2_HW_DATAPIN and HOST_KDO0; GPIO 13 is shared between PS2_HW_CLKPIN and HOST_KDI4; GPIO 25 is shared between HOST_KDB1 and PWRLED. In practice the PS/2 and host interfaces operate at different times (PS/2 input is processed on Core 0, host output on Core 1), and the KeyInterface::reconfigADC2Ports() method handles switching GPIO direction between input and output modes when needed (particularly for WiFi, which conflicts with ADC2-capable GPIOs 0, 2, 4, 12, 13, 14, 15, 25, 26, 27).
Electrical Signal Budget
In total, the SharpKey PCB provides up to 15 GPIO lines available for host interfacing (8 × KDO, 4 × KDB, RTSNI, MPXI, KDI4). This is sufficient for virtually any vintage keyboard matrix protocol:
  • Matrix-scanned keyboards (most common) — The host sends a row address (typically 4-6 bits) and reads back column data (typically 8 bits). This maps directly to KDB[3:0] as row input and KDO[7:0] as column output. The MZ-2500/2800, X1, and PC-9801 all use this pattern.
  • Serial keyboards — Some hosts (eg. certain MSX machines, early Apple) use a serial protocol. You can use any single GPIO as a serial data line, and another as clock. The KDO or KDB lines can be individually addressed.
  • Active-output keyboards — Hosts like the X68000 expect the keyboard to actively send key-down/key-up scan codes (rather than presenting a matrix). SharpKey supports this model — the X68K interface uses KDO lines as a serial transmitter and ignores KDB inputs entirely.
  • Mixed protocols — If the host uses both a matrix scan and additional control signals (eg. CAPS LOCK LED feedback, keyboard reset), the MPXI, KDI4, and RTSNI lines can be repurposed. All GPIO assignments are configurable through Kconfig.
Designing the Host Cable
Each host machine requires a dedicated cable that maps the SharpKey host connector pins to the target computer's keyboard connector. When designing a new cable:
  1. Obtain the keyboard connector pinout from the host's service manual or by reverse-engineering the original keyboard.
  2. Identify which signals are row-select (address), which are column-data (key status), and which are control/strobe signals.
  3. Map the host signals to the most appropriate SharpKey GPIO group (KDO for outputs, KDB for inputs, RTSNI/MPXI/KDI4 for strobes and control signals).
  4. If the host uses active-low signals where SharpKey uses active-high (or vice versa), inverters may be needed on the cable — though in most cases the software can handle polarity inversion.
  5. If the host requires 5V output drive (and the SharpKey's 3.3V via 74HCT257 is insufficient), add a buffer IC (eg. 74HCT245) on the cable PCB or inline adapter.
  6. Ensure power and ground are connected. The SharpKey can be powered from the host's 5V supply if available on the keyboard connector, or independently via the USB programming port.
See the Technical Guide — Host Interface Cables section for detailed examples of existing cable designs (MZ-2500, MZ-2800, X1, X68000, Mouse).

Software Implementation

The software side involves creating a new class that inherits from KeyInterface, defining the key mapping table, implementing the host-side protocol, and registering the new host in the detection and instantiation logic. The following steps walk through the entire process.

Step 1 — Create the Header File
Create main/include/NewHost.h. This file defines the class, control structures, constants, and the PS/2-to-host key mapping table. Study an existing header (eg. MZ2528.h or X1.h) as a template. The key elements are:
  • Class declaration inheriting from KeyInterface
  • Matrix dimensions — define the number of rows and columns for the target host's keyboard matrix
  • Control structure (t_newHostControl) — holds the key matrix arrays, GPIO bitmasks, mode flags, and any protocol-specific state
  • Key mapping table (t_keyMap keyMap[]) — maps each PS/2 key code + modifier combination to one or more host matrix row/column positions. Each entry can encode up to 3 simultaneous "make" keys and 2 "break" keys
  • PS/2 control flags — use the standard PS2CTRL_* constants for modifier matching (SHIFT, CTRL, ALT, CAPS, etc.)
  • Machine model tags — if the host has sub-models with different key layouts, define model constants (analogous to MZ_80B, MZ_2000, MZ_2500, MZ_2800 in MZ2528.h)
// NewHost.h — Header for a hypothetical new host interface
#ifndef NEWHOST_H
#define NEWHOST_H

#include "KeyInterface.h"
#include "NVS.h"
#include "LED.h"
#include "HID.h"
#include <vector>
#include <map>

class NewHost : public KeyInterface {

    // Constants
    #define NEWHOST_VERSION             1.00
    #define NEWHOST_KEYMAP_FILE         "NewHost_KeyMap.BIN"
    #define NEWHOST_MATRIX_ROWS         12       // Number of rows in the host key matrix
    #define NEWHOST_MATRIX_COLS         8        // Number of columns (bits per row)

    // PS/2 → Host mapping table dimensions
    #define PS2TBL_NEWHOST_MAXROWS      128      // Maximum entries in the mapping table
    #define PS2TBL_NEWHOST_MAX_MKROW    3        // Max simultaneous make keys per entry
    #define PS2TBL_NEWHOST_MAX_BRKROW   2        // Max simultaneous break keys per entry

    // Machine model tags (if the host has sub-models)
    #define NEWHOST_ALL                 0xFF
    #define NEWHOST_MODEL_A             0x01
    #define NEWHOST_MODEL_B             0x02

    // The PS/2 to host mapping table type (same structure as other interfaces)
    typedef struct {
        uint16_t    ps2KeyCode;                                  // PS/2 key code from PS2KeyAdvanced
        uint8_t     ps2Ctrl;                                     // Modifier flags (PS2CTRL_SHIFT, etc.)
        uint8_t     keyboardModel;                               // 0 = all keyboards, or specific model ID
        uint8_t     machine;                                     // NEWHOST_ALL or specific model
        struct {
            uint8_t row;
            uint8_t col;                                         // Bitmask: bit position in the row
        }           mk[PS2TBL_NEWHOST_MAX_MKROW];               // Make (key press) targets
        struct {
            uint8_t row;
            uint8_t col;
        }           brk[PS2TBL_NEWHOST_MAX_BRKROW];             // Break (key release) targets
    } t_newHostKeyMap;

    // Control structure holding all runtime state
    typedef struct {
        uint8_t     keyMatrix[NEWHOST_MATRIX_ROWS];              // Virtual key matrix (1 = released, 0 = pressed)
        uint32_t    keyMatrixAsGPIO[NEWHOST_MATRIX_ROWS];        // Pre-shifted GPIO bitmasks for atomic writes
        uint8_t     strobeAll;                                   // AND of all rows (used by some protocols)
        uint32_t    strobeAllAsGPIO;
        bool        noKeyPressed;
    } t_newHostControl;

    public:
        // Constructors matching the KeyInterface pattern
                     NewHost(void) : KeyInterface() {};
                     NewHost(uint32_t ifMode, NVS *hdlNVS, LED *hdlLED, HID *hdlHID, const char *fsPath);
                     NewHost(NVS *hdlNVS, HID *hdlHID);
                    ~NewHost();

        // Overridden virtual methods
        void         init(uint32_t ifMode, NVS *hdlNVS, LED *hdlLED, HID *hdlHID);
        void         init(NVS *hdlNVS, HID *hdlHID);
        IRAM_ATTR uint32_t mapKey(uint16_t scanCode);

        // Key map file I/O (for web interface persistence)
        bool         createKeyMapFile(std::fstream &outFile);
        bool         getKeyMapData(std::vector<uint32_t>& dataArray, int *row, bool start);
        void         getKeyMapHeaders(std::vector<std::string>& headerList);
        std::string  getKeyMapFileName(void);

        // FreeRTOS task functions — static so they can be passed to xTaskCreatePinnedToCore
        static void IRAM_ATTR hostInterfaceTask(void *pvParameters);
        static void           hidInterface(void *pvParameters);

    private:
        t_newHostControl      hostCtrl;
        TaskHandle_t          TaskHostIF = NULL;
        TaskHandle_t          TaskHIDIF  = NULL;
        portMUX_TYPE          hostMux    = portMUX_INITIALIZER_UNLOCKED;
};

// ─── Key Mapping Table ──────────────────────────────────────────────────────
// Each row maps one PS/2 key (+ optional modifier) to host matrix position(s).
// Fields: ps2KeyCode, ps2Ctrl, kbModel, machine,
//         mk[0].row, mk[0].col, mk[1].row, mk[1].col, mk[2].row, mk[2].col,
//         brk[0].row, brk[0].col, brk[1].row, brk[1].col
//
// Example: map PS/2 'A' (0x1C) to host row 4, column bit 0
static const t_newHostKeyMap newHostKeyMap[] = {
    { PS2_KEY_A,     PS2CTRL_NONE, 0, NEWHOST_ALL, {{4,0x01},{0,0},{0,0}}, {{4,0x01},{0,0}} },
    { PS2_KEY_B,     PS2CTRL_NONE, 0, NEWHOST_ALL, {{4,0x02},{0,0},{0,0}}, {{4,0x02},{0,0}} },
    { PS2_KEY_ENTER, PS2CTRL_NONE, 0, NEWHOST_ALL, {{3,0x04},{0,0},{0,0}}, {{3,0x04},{0,0}} },
    // ... complete mapping for all host keys ...
};

#endif // NEWHOST_H

Step 2 — Create the Source File
Create main/NewHost.cpp. This file implements the three core responsibilities: initialisation (GPIO setup + task creation), key mapping (PS/2 → host matrix translation), and the host-side interface task (timing-critical GPIO loop). Study MZ2528.cpp (1244 lines) for a matrix-scanned host or X1.cpp (1124 lines) for an output-only serial host as a reference.
// NewHost.cpp — Implementation of a new host interface
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "soc/gpio_struct.h"
#include "soc/gpio_reg.h"
#include "NewHost.h"

#define MAINTAG "NewHost"

// ─── Constructor (with hardware) ────────────────────────────────────────────
NewHost::NewHost(uint32_t ifMode, NVS *hdlNVS, LED *hdlLED, HID *hdlHID, const char *fsPath)
{
    init(ifMode, hdlNVS, hdlLED, hdlHID);
}

// ─── Constructor (without hardware, for WiFi probing) ───────────────────────
NewHost::NewHost(NVS *hdlNVS, HID *hdlHID)
{
    init(hdlNVS, hdlHID);
}

// ─── Destructor ─────────────────────────────────────────────────────────────
NewHost::~NewHost() { }

// ─── init() with hardware ───────────────────────────────────────────────────
// Called when the host is detected. Configures GPIO and launches FreeRTOS tasks.
void NewHost::init(uint32_t ifMode, NVS *hdlNVS, LED *hdlLED, HID *hdlHID)
{
    // Initialise control variables (key matrix to all-released state).
    init(hdlNVS, hdlHID);

    // Call the base class init (stores NVS, LED, HID handles, sets LED on).
    KeyInterface::init(getClassName(__PRETTY_FUNCTION__), hdlNVS, hdlLED, hdlHID, ifMode);

    // ── GPIO Configuration ──────────────────────────────────────────────
    // The base class KeyInterface::reconfigADC2Ports() already configures
    // KDB[3:0] as inputs, KDI4/MPXI as inputs, and RTSNI as input.
    // Here, configure any host-specific GPIO requirements:
    gpio_config_t io_conf = {};

    // KDO[7:0] as outputs (directly driven by the ESP32)
    io_conf.intr_type    = GPIO_INTR_DISABLE;
    io_conf.mode         = GPIO_MODE_OUTPUT;
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    io_conf.pull_up_en   = GPIO_PULLUP_DISABLE;
    for(int pin : {CONFIG_HOST_KDO0, CONFIG_HOST_KDO1, CONFIG_HOST_KDO2, CONFIG_HOST_KDO3,
                   CONFIG_HOST_KDO4, CONFIG_HOST_KDO5, CONFIG_HOST_KDO6, CONFIG_HOST_KDO7})
    {
        io_conf.pin_bit_mask = (1ULL << pin);
        gpio_config(&io_conf);
    }

    // Set all KDO outputs HIGH (all keys released)
    GPIO.out_w1ts = (1 << CONFIG_HOST_KDO7) | (1 << CONFIG_HOST_KDO6) |
                    (1 << CONFIG_HOST_KDO5) | (1 << CONFIG_HOST_KDO4) |
                    (1 << CONFIG_HOST_KDO3) | (1 << CONFIG_HOST_KDO2) |
                    (1 << CONFIG_HOST_KDO1) | (1 << CONFIG_HOST_KDO0);

    // ── Create FreeRTOS Tasks ───────────────────────────────────────────
    // Host interface task on Core 1 (timing-critical)
    ESP_LOGW(MAINTAG, "Starting NewHost interface thread on Core 1...");
    ::xTaskCreatePinnedToCore(&NewHost::hostInterfaceTask, "newHostIf",
                              4096, this, 25, &this->TaskHostIF, 1);
    vTaskDelay(500);

    // HID input polling task on Core 0
    ESP_LOGW(MAINTAG, "Starting HID interface thread on Core 0...");
    ::xTaskCreatePinnedToCore(&NewHost::hidInterface, "hidIf",
                              4096, this, 0, &this->TaskHIDIF, 0);
}

// ─── init() without hardware (for WiFi parameter probing) ───────────────────
void NewHost::init(NVS *hdlNVS, HID *hdlHID)
{
    // Initialise key matrix to all-released state
    hostCtrl.strobeAll       = 0xFF;
    hostCtrl.strobeAllAsGPIO = 0x00000000;
    hostCtrl.noKeyPressed    = true;
    for(int i = 0; i < NEWHOST_MATRIX_ROWS; i++)
    {
        hostCtrl.keyMatrix[i]       = 0xFF;         // All keys released
        hostCtrl.keyMatrixAsGPIO[i] = 0x00000000;   // No GPIO bits to clear
    }

    // Call base class init (no hardware)
    KeyInterface::init(getClassName(__PRETTY_FUNCTION__), hdlNVS, hdlHID);
}

// ─── mapKey() — Translate PS/2 scan code to host matrix ─────────────────────
// Called from the HID interface task on Core 0. Must use the spinlock when
// writing to the shared keyMatrix/keyMatrixAsGPIO arrays.
IRAM_ATTR uint32_t NewHost::mapKey(uint16_t scanCode)
{
    uint8_t  keyCode  = scanCode & 0xFF;
    uint8_t  ctrlKeys = (scanCode >> 8) & 0xFF;
    bool     isBreak  = (ctrlKeys & PS2_BREAK) ? true : false;

    // Search the mapping table
    for(int idx = 0; idx < sizeof(newHostKeyMap)/sizeof(newHostKeyMap[0]); idx++)
    {
        if(newHostKeyMap[idx].ps2KeyCode == keyCode)
        {
            // Apply the key to the matrix
            portENTER_CRITICAL(&hostMux);
            for(int mk = 0; mk < PS2TBL_NEWHOST_MAX_MKROW; mk++)
            {
                uint8_t row = newHostKeyMap[idx].mk[mk].row;
                uint8_t col = newHostKeyMap[idx].mk[mk].col;
                if(col == 0) break;

                if(isBreak)
                    hostCtrl.keyMatrix[row] |= col;    // Set bit = key released
                else
                    hostCtrl.keyMatrix[row] &= ~col;   // Clear bit = key pressed
            }
            // Recompute strobeAll (AND of all rows)
            hostCtrl.strobeAll = 0xFF;
            for(int r = 0; r < NEWHOST_MATRIX_ROWS; r++)
                hostCtrl.strobeAll &= hostCtrl.keyMatrix[r];
            portEXIT_CRITICAL(&hostMux);
            break;
        }
    }
    return(0);
}

// ─── hostInterfaceTask() — Core 1 timing-critical loop ──────────────────────
// This is the main host-side protocol handler. It runs in a tight loop on
// Core 1, responding to the host's row-select strobes with column data.
// Mark IRAM_ATTR to keep the function in instruction RAM for deterministic
// execution timing.
void IRAM_ATTR NewHost::hostInterfaceTask(void *pvParameters)
{
    NewHost *pThis = (NewHost *)pvParameters;

    // Pre-compute GPIO bitmasks for the KDO output lines
    const uint32_t KDO_ALL_MASK = (1 << CONFIG_HOST_KDO7) | (1 << CONFIG_HOST_KDO6) |
                                  (1 << CONFIG_HOST_KDO5) | (1 << CONFIG_HOST_KDO4) |
                                  (1 << CONFIG_HOST_KDO3) | (1 << CONFIG_HOST_KDO2) |
                                  (1 << CONFIG_HOST_KDO1) | (1 << CONFIG_HOST_KDO0);

    // Pre-compute per-bit GPIO masks for each column bit
    const uint32_t colGPIO[8] = {
        (1 << CONFIG_HOST_KDO0), (1 << CONFIG_HOST_KDO1),
        (1 << CONFIG_HOST_KDO2), (1 << CONFIG_HOST_KDO3),
        (1 << CONFIG_HOST_KDO4), (1 << CONFIG_HOST_KDO5),
        (1 << CONFIG_HOST_KDO6), (1 << CONFIG_HOST_KDO7)
    };

    while(true)
    {
        // ── Check for suspend request (needed for WiFi mode switch) ─────
        pThis->yield(0);

        // ── Your host protocol goes here ────────────────────────────────
        // Example: matrix-scanned protocol (similar to MZ-2500)
        //
        // 1. Wait for strobe signal from host
        //    while((REG_READ(GPIO_IN1_REG) & RTSNI_MASK) == 0) { }
        //
        // 2. Read row number from KDB[3:0]
        //    uint32_t gpioIn = REG_READ(GPIO_IN_REG);
        //    uint8_t row = ((gpioIn >> CONFIG_HOST_KDB3) & 1) << 3 |
        //                  ((gpioIn >> CONFIG_HOST_KDB2) & 1) << 2 |
        //                  ((gpioIn >> CONFIG_HOST_KDB1) & 1) << 1 |
        //                  ((gpioIn >> CONFIG_HOST_KDB0) & 1);
        //
        // 3. Set all KDO HIGH (inactive), then clear pressed-key bits
        //    GPIO.out_w1ts = KDO_ALL_MASK;
        //    uint8_t rowData = pThis->hostCtrl.keyMatrix[row];
        //    uint32_t clearMask = 0;
        //    for(int b = 0; b < 8; b++)
        //        if(!(rowData & (1 << b)))  clearMask |= colGPIO[b];
        //    GPIO.out_w1tc = clearMask;
        //
        // 4. Wait for strobe to deassert (host latches data)
        //    while((REG_READ(GPIO_IN1_REG) & RTSNI_MASK) != 0) { }
        //
        // For serial-output hosts (like X68000), the loop would instead
        // check for pending key events and transmit scan codes via
        // bit-banging on KDO lines.

        vTaskDelay(1);  // Placeholder — remove when implementing real protocol
    }
}

// ─── hidInterface() — Core 0 HID input polling ─────────────────────────────
void NewHost::hidInterface(void *pvParameters)
{
    NewHost *pThis = (NewHost *)pvParameters;

    while(true)
    {
        // Poll for PS/2 or Bluetooth key events
        uint16_t scanCode = pThis->hid->read();
        if(scanCode > 0)
        {
            pThis->mapKey(scanCode);
        }
        vTaskDelay(1);
    }
}

Step 3 — Register in the Build System
Add the new source file to the CMake build and update the detection logic in SharpKey.cpp:
a) Update CMakeLists.txt — Add NewHost.cpp to the COMPONENT_SRCS list in main/CMakeLists.txt:
set(COMPONENT_SRCS SharpKey.cpp NVS.cpp LED.cpp SWITCH.cpp KeyInterface.cpp
    MZ2528.cpp X1.cpp X68K.cpp Mouse.cpp MZ5665.cpp PC9801.cpp
    HID.cpp WiFi.cpp PS2KeyAdvanced.cpp PS2Mouse.cpp BT.cpp BTHID.cpp
    esp_efuse_custom_table.c
    NewHost.cpp)
set(COMPONENT_ADD_INCLUDEDIRS "." "include")
register_component()
b) Add host detection in SharpKey.cpp — The getHostType() function in SharpKey.cpp (around line 505) samples the GPIO lines at boot to determine which host is connected. Each host has a unique electrical signature based on the idle state and behaviour of RTSNI, MPXI, KDB[3:0], and KDI4. Add detection logic for the new host:
// In getHostType(), after existing detection cases:

// Check for NewHost — describe the electrical signature
// e.g., KDI4 = high, MPXI = high, RTSNI = low, KDB[3:0] = 1100
if(tKDI4 == 1 && tMPXI == 1 && tRTSNI == 0 &&
   tKDB3 == 1 && tKDB2 == 1 && tKDB1 == 0 && tKDB0 == 0 &&
   eFuseInvalid == false &&
   (sharpkeyEfuses.disableRestrictions == true || sharpkeyEfuses.enableNewHost == true))
{
    ifMode = 12345;  // Choose a unique ID number for the new host
}
c) Add instantiation in setup() — In the setup() function's switch(ifMode) block (around line 975), add a case for the new host:
#include "NewHost.h"    // Add at the top of SharpKey.cpp

// In setup(), within the switch(ifMode) block:
case 12345:
{
    ESP_LOGW(SETUPTAG, "Detected NewHost.");
    keyIf = new NewHost(ifMode, &nvs, led, hid, LITTLEFS_DEFAULT_PATH);
    break;
}

Step 4 — Add Kconfig Options (Optional)
If the new host requires configurable GPIO pins or build-time feature toggles, add entries to main/Kconfig.projbuild. Follow the pattern of existing entries. For example, to add an eFuse feature-enable bit:
// In Kconfig.projbuild, under the "Host Interface" menu:
config ENABLE_NEWHOST
    bool "Enable NewHost support"
    default true
    help
        Enable support for the NewHost retro computer interface.
If using feature security (eFuse-based feature gating), add a corresponding bit in the esp_efuse_custom_table.csv and update the sharpkeyEfuses structure in SharpKey.cpp.

Step 5 — Build and Test
  1. Run idf.py menuconfig and ensure the new host's Kconfig options are visible and correctly configured.
  2. Build: idf.py build
  3. Flash to the ESP32: idf.py -p /dev/ttyUSB0 flash
  4. Connect the SharpKey to the new host via the custom cable.
  5. Monitor serial output: idf.py -p /dev/ttyUSB0 monitor — check that the host detection prints the correct ifMode value.
  6. Enable CONFIG_DEBUG_SERIAL in menuconfig for verbose key event logging. Each PS/2 scan code, modifier state, and resulting host matrix row/column should be printed.
  7. Use CONFIG_DEBUG_DISABLE_KDO to disable output to the host while testing detection and key mapping logic in isolation.
  8. Once key mapping is confirmed, enable output and verify the host computer accepts keystrokes correctly.

Summary of Files to Create/Modify
Action File What to do
Create main/include/NewHost.h Class declaration, key mapping table, constants
Create main/NewHost.cpp init(), mapKey(), hostInterfaceTask(), hidInterface()
Modify main/CMakeLists.txt Add NewHost.cpp to COMPONENT_SRCS
Modify main/SharpKey.cpp Add #include "NewHost.h", detection logic in getHostType(), instantiation in setup() switch block
Modify main/Kconfig.projbuild (Optional) Add GPIO pin and feature-enable options
Modify main/esp_efuse_custom_table.csv (Optional) Add feature-security eFuse bit
Create Host cable Physical wiring from SharpKey connector to target host keyboard connector

Updating Key Mappings

Key mappings can be customised either through the web interface at runtime or by modifying the source code for permanent changes.

Via Web Interface

The key mapping editor is accessible at http://<sharpkey-ip>/keymap.html when WiFi is enabled. The editor provides a visual representation of both the PS/2 source keyboard and the target host keyboard matrix. Users can:
  • Browse the current key mapping table with all entries displayed in a sortable grid
  • Select a PS/2 key and assign it to one or more host matrix positions
  • Configure modifier key requirements (SHIFT, CTRL, ALT must be held for the mapping to activate)
  • Set keyboard-model-specific overrides
  • Save changes to NVS, which persist across reboots and firmware updates
  • Reset to factory defaults by clearing NVS key mapping data
Changes made through the web interface are stored in the ESP32's Non-Volatile Storage (NVS) partition and take effect immediately without requiring a reboot. The NVS mappings override the compiled-in defaults from the source code.

Via Source Code

To permanently modify the key mappings, edit the t_keyMap[] array in the appropriate host interface header file. For the MZ-2500/2800, this is main/include/MZ2528.h.
Each entry follows this format:
// Map PS/2 'A' key (ASCII 0x61) to MZ matrix row 4, column bit 0
// No modifiers required, all keyboard models, all MZ machines
{ 0x61, 0x00, 0, MZ_ALL,
  4, 0x01,    // Make key 1: row 4, bit 0
  0, 0x00,    // Make key 2: unused
  0, 0x00,    // Make key 3: unused
  4, 0x01,    // Break key 1: row 4, bit 0
  0, 0x00     // Break key 2: unused
},

// Map PS/2 SHIFT+'a' to MZ SHIFT + 'A' (two simultaneous make keys)
{ 0x61, PS2CTRL_SHIFT, 0, MZ_ALL,
  11, 0x04,   // Make key 1: row 11 (SHIFT), bit 2
  4,  0x01,   // Make key 2: row 4 ('A'), bit 0
  0,  0x00,   // Make key 3: unused
  11, 0x04,   // Break key 1: release SHIFT
  4,  0x01    // Break key 2: release 'A'
},
The ps2Ctrl modifier flags are defined as bitmask constants:
Flag Value Description
PS2CTRL_SHIFT 0x01 Shift key must be held
PS2CTRL_CTRL 0x02 Ctrl key must be held
PS2CTRL_CAPS 0x04 Caps Lock must be active
PS2CTRL_ALT 0x08 Alt key must be held
PS2CTRL_ALTGR 0x10 AltGr (right Alt) must be held
PS2CTRL_GUI 0x20 Windows/Super key must be held
PS2CTRL_FUNC 0x40 Function key must be held
PS2CTRL_EXACT 0x80 Exact modifier match required (no additional modifiers)

Adding a New Keyboard Model

To add support for a new PS/2 keyboard model:
  1. Identify scan codes — Connect the new keyboard and enable CONFIG_DEBUG_SERIAL in menuconfig. The serial monitor will output raw scan code data for each key press. Note any keys that produce non-standard scan codes.
  2. Assign a model ID — Choose the next available model number and define it as a constant in PS2KeyTable.h.
  3. Add model-specific entries — In the host interface header (e.g., MZ2528.h), add t_keyMap[] entries with the new model ID in the keyboardModel field for any keys that differ from the generic mapping.
  4. Test thoroughly — Verify all keys produce the correct output on the host machine. Pay special attention to modifier keys, special characters, and any keys unique to the keyboard layout.
  5. Update the web interface — Add the new model name to the keyboard model dropdown in webserver/keymap.html so users can select it manually.

GPIO Pin Configuration

The ESP32 GPIO assignments are defined as defaults in main/Kconfig.projbuild and can be modified through idf.py menuconfig. The following tables list all GPIO assignments with their default values.

PS/2 Interface

Signal GPIO Direction Purpose
PS2_DATA 14 Bidirectional PS/2 keyboard data line (open-collector)
PS2_CLK 13 Bidirectional PS/2 keyboard clock line (interrupt-driven)

Host Interface — Row Select (Input from Host)

Signal GPIO Direction Purpose
KDB0 23 Input Row select bit 0 (active during host scan)
KDB1 25 Input Row select bit 1
KDB2 26 Input Row select bit 2
KDB3 27 Input Row select bit 3
The four KDB lines form a 4-bit row address (0-15) driven by the host machine during keyboard scanning. The host cycles through all rows, reading column data for each selected row.

Host Interface — Scan Data (Output to Host via 74HCT257)

Signal GPIO Direction Purpose
KDO0 14 Output Matrix column data bit 0
KDO1 15 Output Matrix column data bit 1
KDO2 16 Output Matrix column data bit 2
KDO3 17 Output Matrix column data bit 3
KDO4 18 Output Matrix column data bit 4
KDO5 19 Output Matrix column data bit 5
KDO6 21 Output Matrix column data bit 6
KDO7 22 Output Matrix column data bit 7
The eight KDO lines carry the column data for the currently selected row. These pass through a 74HCT257 multiplexer on the SharpKey PCB before reaching the host connector. Each bit represents one key in the selected row: logic high indicates the key is pressed.

Control Signals

Signal GPIO Direction Purpose
RTSNI 35 Input Row strobe from host (active-low, read via GPIO_IN1_REG)
MPXI 12 Input Multiplex select (active during host read cycle)
KDI4 13 Input Additional keyboard data input line
PWRLED 25 Output Power/status LED indicator
WIFI_EN 34 Input WiFi enable switch (active-low, input-only GPIO)
RTSNI (Row Strobe Not Active) is a critical timing signal. On the MZ-2500, the host pulses RTSNI low for approximately 1.2 microseconds per row during scanning. The SharpKey interface must read the row address and present valid column data within this window. GPIO 35 is used because it is an input-only pin connected to GPIO_IN1_REG (for GPIOs 32-39), which can be read with a single register access.

ADC2/WiFi GPIO Conflict

The ESP32 has a hardware limitation where GPIOs assigned to the ADC2 peripheral cannot be used for digital I/O while the WiFi radio is active. The affected pins are:
ADC2 GPIOs: 0, 2, 4, 12, 13, 14, 15, 25, 26, 27
Several of these pins are used by SharpKey for both the PS/2 interface (GPIO 13, 14) and host row select (GPIO 25, 26, 27). The reconfigADC2Ports() method in the host interface classes handles this conflict by reconfiguring the affected GPIOs after WiFi initialisation. When WiFi is active, the ADC2 peripheral is disabled and the pins are forced back to their digital I/O function. This is called during init() and again after any WiFi state change.

MZ-2500/2800 Protocol

The MZ-2500 and MZ-2800 use a scanning keyboard protocol where the host drives row select lines and reads back column data. The timing requirements are extremely tight, necessitating direct GPIO register access and Core 1 dedication.

MZ-2500 Timing

The MZ-2500 scans 14 keyboard matrix rows with a cycle time of approximately 1.2 microseconds per RTSN strobe. The complete scan sequence is:
  1. Host drives KDB0-KDB3 with the row address (0-13)
  2. Host asserts RTSN low (approximately 600ns)
  3. SharpKey reads the row address from KDB0-KDB3
  4. SharpKey presents the corresponding column data on KDO0-KDO7
  5. Host reads the column data
  6. Host deasserts RTSN high
  7. SharpKey clears the column data outputs

MZ-2800 Timing

The MZ-2800 has even tighter timing with a 1.78 microsecond RTSN period and only 650 nanoseconds available to read the row address and present valid column data. This is why the interface task must:
  • Run on Core 1 with maximum priority
  • Use IRAM_ATTR to keep all code in instruction RAM (no flash cache misses)
  • Use a portMUX_TYPE spinlock (not a mutex) for shared data access
  • Access GPIO registers directly via GPIO.out_w1ts, GPIO.out_w1tc, and REG_READ(GPIO_IN1_REG)
  • Avoid any FreeRTOS API calls in the hot loop (no vTaskDelay, no xSemaphoreTake)
  • Use pre-computed keyMatrixAsGPIO[] to eliminate bit-shifting during the strobe window
// Simplified MZ-2800 hot loop (IRAM_ATTR, Core 1)
void IRAM_ATTR MZ2528::mz28InterfaceTask(void *pvParameters) {
    while (1) {
        // Wait for RTSN strobe (active low)
        while (REG_READ(GPIO_IN1_REG) & RTSNI_MASK) { }

        // Read row address from KDB0-KDB3
        uint32_t gpioIn = REG_READ(GPIO_IN_REG);
        uint8_t row = (gpioIn >> KDB_SHIFT) & 0x0F;

        // Present column data
        GPIO.out_w1tc = KDO_ALL_MASK;          // Clear all columns
        GPIO.out_w1ts = keyMatrixAsGPIO[row];   // Set pressed keys

        // Wait for RTSN deassert
        while (!(REG_READ(GPIO_IN1_REG) & RTSNI_MASK)) { }

        // Clear outputs
        GPIO.out_w1tc = KDO_ALL_MASK;
    }
}
For full protocol documentation including oscilloscope traces and detailed timing analysis, refer to the SharpKey Technical Guide.

Bluetooth Integration

SharpKey supports Bluetooth Classic HID and Bluetooth Low Energy (BLE) HID keyboards and mice, enabling wireless input devices to be used with vintage machines.

Architecture

The Bluetooth subsystem is split across two classes:
  • BT.cpp — Manages the low-level Bluetooth stack, including GAP (Generic Access Profile) event handling, SDP (Service Discovery Protocol) registration, and HID device callbacks. Handles the ESP-IDF Bluetooth controller and Bluedroid host stack initialisation.
  • BTHID.cpp — Provides the high-level Bluetooth HID interface. Translates HID reports from Bluetooth keyboards and mice into the same scan code format used by the PS/2 path, allowing the rest of the pipeline (HID.cpp, mapKey) to process them identically.
The Bluetooth stack supports up to 5 bonded devices stored in NVS. On boot, SharpKey initiates an auto-scan to reconnect with previously paired devices. If no bonded device is found, a 60-second pairing window opens automatically.

Pairing Process

The Bluetooth pairing process operates as follows:
  1. SharpKey enters discoverable mode when no bonded device is connected
  2. The status LED flashes at 8 Hz to indicate pairing mode is active
  3. The keyboard initiates pairing by scanning for HID hosts
  4. If a PIN is requested, the default PIN is "1234"
  5. Upon successful pairing, the device bond is stored in NVS
  6. The LED switches to steady-on to indicate a connected device
  7. The pairing window closes after 60 seconds if no device connects
To force re-pairing (e.g., when changing keyboards), clear the NVS bond data through the web interface or by erasing the NVS partition with idf.py erase-flash.

HID Multiplexing

HID.cpp serves as the multiplexer between PS/2 and Bluetooth input sources. The multiplexing logic follows these rules:
  • PS/2 has priority — If a PS/2 keyboard is detected on the data/clock lines, Bluetooth scanning is disabled and all input is taken from the PS/2 source.
  • Bluetooth fallback — If no PS/2 keyboard is detected at boot, Bluetooth is initialised and input is taken from the Bluetooth HID source.
  • Hot-plug detection — If a PS/2 keyboard is connected after boot while Bluetooth is active, the system switches to PS/2 input. However, switching back to Bluetooth requires a reboot due to ESP-IDF stack limitations.
  • Mouse input — PS/2 and Bluetooth mice are multiplexed independently of keyboard input. Both can be active simultaneously.
Note that WiFi and Bluetooth cannot be active simultaneously. Both use the ESP32's shared radio antenna and have conflicting IDF stack requirements. A full reboot is required to switch between WiFi and Bluetooth modes. The mode selection is controlled by the WIFI_EN switch (GPIO 34) or through NVS configuration.

WiFi and Web Interface

SharpKey includes a built-in WiFi access point and web server for configuration, key mapping customisation, and firmware updates. The WiFi subsystem is implemented in main/WiFi.cpp.

WiFi Modes

SharpKey supports two WiFi operating modes:
  • Access Point Mode (default) — SharpKey creates its own WiFi network. Default SSID: sharpkey, default password: sharpkey. Connect to this network from a laptop or phone, then browse to http://192.168.4.1 to access the web interface. This mode works without any existing network infrastructure.
  • Station Client Mode — SharpKey connects to an existing WiFi network as a client. The SSID and password are configured through the wifimanager.html page or via menuconfig. SharpKey obtains an IP address via DHCP. This mode allows access from any device on the same network.
The active WiFi mode is stored in NVS and persists across reboots. Switching between WiFi and Bluetooth requires a full reboot because the ESP-IDF WiFi and Bluetooth stacks share the radio hardware and cannot coexist.

Web Server Endpoints

The built-in web server provides the following endpoints:
Endpoint Method Purpose
/index.html GET Main dashboard showing firmware version, host type, WiFi status, connected devices
/keymap.html GET Key mapping editor — visual editor for PS/2 to host key translations
/mouse.html GET Mouse settings — sensitivity, acceleration, button mapping
/ota.html GET OTA firmware upload page — drag-and-drop firmware binary upload
/wifimanager.html GET WiFi configuration — switch between AP and client modes, set SSID/password
/version.txt GET Firmware version string (plain text)
/api/update POST OTA firmware upload endpoint — accepts binary firmware data
All pages use Bootstrap for responsive design and work on both desktop and mobile browsers. Static assets (CSS, JavaScript, fonts) are served with gzip Content-Encoding for bandwidth efficiency.

Web Filesystem

The web interface files are stored in a LittleFS filesystem partition on the ESP32's flash memory. The build process for the web filesystem is:
  1. The build_webfs.sh script copies HTML files from webserver/ to a staging directory webfs/.
  2. CSS, JavaScript, and font files are gzip-compressed during the copy to reduce flash usage and enable compressed serving.
  3. The CMake build system uses the esp_littlefs component to create a LittleFS partition image from the webfs/ directory.
  4. The partition image is written to the filesys partition (640KB, type spiffs) during flashing.
  5. At runtime, the ESP32 mounts the LittleFS partition and serves files directly. Compressed files are served with Content-Encoding: gzip headers, so the browser decompresses them transparently.
The web filesystem can be updated independently of the firmware via the FilePack OTA image, which contains only the LittleFS partition data.

OTA Firmware Updates

SharpKey supports over-the-air (OTA) firmware updates through the web interface, allowing firmware to be updated without physical access to the serial port.

Partition Scheme

The ESP32 flash is divided into dual OTA partitions for reliable updates with automatic rollback capability. The complete partition table is defined in sharpkey_partition_table.csv:
# Name,     Type,  SubType,  Offset,   Size
otadata,    data,  ota,      0x9000,   0x2000
nvs,        data,  nvs,      ,         0x4000
phy_init,   data,  phy,      ,         0x1000
ota_0,      app,   ota_0,    ,         0x1A0000
ota_1,      app,   ota_1,    ,         0x1A0000
filesys,    data,  spiffs,   ,         0xA0000
Partition Size Purpose
otadata 8 KB OTA selection data (which partition to boot from)
nvs 16 KB Non-Volatile Storage (key mappings, WiFi config, BT bonds)
phy_init 4 KB PHY calibration data (WiFi/BT radio)
ota_0 1.625 MB Application partition A
ota_1 1.625 MB Application partition B
filesys 640 KB LittleFS web filesystem

Update Process

The OTA update process uses the ESP-IDF OTA API with the dual-partition round-robin scheme:
  1. Upload — The firmware binary is uploaded via the /ota.html web page (drag-and-drop) or by posting the binary to the /api/update endpoint.
  2. Header validation — The first bytes of the uploaded binary are checked for the ESP_APP_DESC_MAGIC_WORD header to verify it is a valid ESP32 application image.
  3. Version check — The firmware version string in the application header is compared against the running version. Downgrades are prevented unless explicitly overridden via eFuse configuration.
  4. Stream to flash — The binary data is streamed to the currently inactive OTA partition (if ota_0 is running, data goes to ota_1, and vice versa). This preserves the running firmware until the update is complete.
  5. Switch active partition — After successful write and verification, the OTA data partition is updated to boot from the newly written partition on the next restart.
  6. Auto-reboot — The ESP32 automatically reboots into the new firmware. If the new firmware fails to start (e.g., crashes in early init), the bootloader rolls back to the previous partition after a configurable timeout.

eFuse Custom Fields

SharpKey uses custom eFuse fields in EFUSE_BLK3 to store hardware identification and feature security data. These fields are one-time programmable (OTP) — once written, they cannot be changed.
The custom eFuse fields are defined in main/esp_efuse_custom_table.csv and compiled into C source and header files by the ESP-IDF efuse_table_gen.py tool during the build.

Identification Fields

Field Bit Range Size (bits) Purpose
HARDWARE_REVISION 56-71 16 Hardware revision number of the SharpKey PCB
SERIAL_NO 72-87 16 Unique serial number for this unit
DISABLE_RESTRICTIONS 88 1 When set, disables all feature restrictions
RESERVED1 89-95 7 Reserved for future flag bits
BUILD_DATE 160-183 24 Build date encoded as a 24-bit value

Feature Security Bits

When ENABLE_FEATURE_SECURITY is configured in menuconfig, the firmware checks eFuse bits to determine which host interfaces and features are enabled. This allows production units to be shipped with specific feature sets based on the hardware variant or customer configuration.
Bit Field Name Purpose
8 ENABLE_BT Enable Bluetooth keyboard/mouse support
9 ENABLE_MZ5665 Enable MZ-5500/5600/6500 host interface
10 ENABLE_PC9801 Enable NEC PC-9801 host interface
11 ENABLE_MOUSE Enable mouse support (PS/2 and Bluetooth)
12 ENABLE_X68000 Enable Sharp X68000 host interface
13 ENABLE_X1 Enable Sharp X1 host interface
14 ENABLE_MZ2800 Enable Sharp MZ-2800 host interface
15 ENABLE_MZ2500 Enable Sharp MZ-2500 host interface
Setting the DISABLE_RESTRICTIONS bit (bit 88) overrides all feature security bits, enabling all features regardless of the individual enable bits. This is intended for development and debugging purposes.
The CSV source file is the authoritative definition. The generated C files (esp_efuse_custom_table.c and esp_efuse_custom_table.h) are excluded from version control via .gitignore (matching *efuse*) but the CSV source is explicitly included (!*efuse*.csv).

Build Environment

Native Build Setup

To set up a native ESP-IDF v4.4 build environment on Linux or macOS:
# Install prerequisites (Ubuntu/Debian)
sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv \
    cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0

# Clone ESP-IDF v4.4
mkdir -p ~/esp
cd ~/esp
git clone -b v4.4 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf

# Install toolchain and tools
./install.sh

# Set up environment variables (run in each new terminal session)
. ./export.sh
After installation, the idf.py command will be available in your PATH. You can add . ~/esp/esp-idf/export.sh to your shell profile for automatic setup.

Clone and Build

To clone the SharpKey repository and build the firmware:
# Clone the repository
git clone https://git.eaw.app/eaw/SharpKey.git
cd SharpKey

# Initialise submodules (arduino-esp32 and esp_littlefs)
git submodule update --init --recursive

# Configure build options (select target, GPIO pins, features)
idf.py menuconfig

# Build the web filesystem (compress CSS/JS/fonts, create LittleFS image)
./build_webfs.sh

# Compile the firmware
idf.py build

# Flash to ESP32 (connect via USB-to-serial adapter)
idf.py -p /dev/ttyUSB0 flash

# Open serial monitor for debug output
idf.py -p /dev/ttyUSB0 monitor
The build_webfs.sh script must be run before the first build and whenever web interface files are modified. The idf.py build command will automatically run the eFuse table generator and compile all source files. Use idf.py fullclean to remove all build artifacts and start fresh if you encounter build issues.

The idf.py menuconfig command opens a text-based configuration interface. SharpKey's options are defined in main/Kconfig.projbuild (approximately 310 lines). The key configuration categories are:
  • Build Target — Select the firmware variant:
    • SHARPKEY — Full multi-host build (MZ-2500, MZ-2800, MZ-5600, X1, X68000, PC-9801)
    • MZ25KEY_MZ2500 — Single-target build for MZ-2500 only
    • MZ25KEY_MZ2800 — Single-target build for MZ-2800 only
  • Feature Security — Enable eFuse-based feature locking (restricts which host interfaces and features are available based on programmed eFuse bits).
  • PS2 Keyboard — GPIO pin assignments for PS/2 DATA and CLK lines.
  • Host Interface — GPIO pin assignments for:
    • KDB0-KDB3 (row select inputs from host)
    • KDO0-KDO7 (column data outputs to host)
    • RTSNI (row strobe input)
    • MPXI (multiplex select input)
    • KDI4 (additional data input)
  • Mouse UART — Select between software bit-bang and hardware UART for mouse serial output to the host.
  • WiFi — Enable/disable WiFi, set default SSID and password, channel number, and maximum simultaneous connections.
  • Debug Options — Enable serial debug output and individually disable GPIO groups for hardware isolation testing (disable KDB, KDO, RTSNI, MPXI, KDI).
  • Power LED — GPIO pin assignment for the status LED.

Docker Build

For a containerised build without installing the IDF toolchain locally, use the official Espressif Docker image:
# Simple Docker build
docker run --rm -v $(pwd):/project -w /project espressif/idf:v4.4 idf.py build
For Jenkins CI pipelines where the build container is a sibling container (not nested Docker), the workspace path must be mapped from the Jenkins container path to the Docker host path:
# Jenkins sibling container build (host path mapping)
docker run --rm \
    -v "${hostWorkspace}:${workspace}" \
    -w "${workspace}" \
    espressif/idf:v4.4 \
    idf.py build
The Docker image includes all IDF tools, toolchain, and Python dependencies. No additional setup is required. Note that menuconfig requires a terminal, so it cannot be run non-interactively in Docker — use a pre-configured sdkconfig file instead.

Continuous Integration

SharpKey uses a Jenkins CI/CD pipeline for automated builds, testing, and release management. The pipeline is triggered by Gitea webhooks and produces versioned firmware packages for distribution.

Jenkins Pipeline Overview

The SharpKey CI/CD pipeline runs on a Jenkins instance hosted on the EaW VPS. The pipeline is triggered automatically by Gitea webhooks when commits are pushed to the main or master branch. The pipeline stages are:
  1. Checkout — Clean the workspace, clone the SharpKey repository from Gitea, and initialise all Git submodules (arduino-esp32 and esp_littlefs).
  2. Determine Version — Read version.txt from the repository. If the version has not changed since the last commit, auto-increment the version number from the latest Gitea release tag.
  3. Build Web Filesystem — Execute build_webfs.sh to copy and compress web interface assets into the webfs/ staging directory.
  4. Generate eFuse Table — Run efuse_table_gen.py inside the IDF Docker container to generate esp_efuse_custom_table.c and .h from the CSV source.
  5. Build Firmware — Compile the firmware using idf.py build inside the IDF Docker container.
  6. Package Release — Create three release archives: FW (firmware binary), FilePack (web filesystem image), and FlashPack (complete flash image).
  7. Create Gitea Release — Upload the release artifacts to a new Gitea release, tagged with the version number.

Webhook Configuration

The pipeline is triggered by a Gitea webhook that sends a POST request to the Jenkins Generic Webhook Trigger plugin. The configuration is:
  • Webhook URLhttps://<jenkins-host>/generic-webhook-trigger/invoke?token=sharpkey-build-trigger
  • Tokensharpkey-build-trigger
  • Content Typeapplication/json
  • Branch Filter — Only triggers on pushes to refs/heads/main or refs/heads/master. Pushes to feature branches, tags, and other refs are ignored.

Version Management

The firmware version is stored in version.txt at the repository root in the format MAJOR.MINOR (e.g., "1.05"). The version management follows these rules:
  • If version.txt has been modified in the triggering commit, the version specified in the file is used as-is for the release.
  • If version.txt is unchanged, the pipeline queries the Gitea API for the latest release tag and auto-increments the minor version number.
  • The minor version rolls over at 100: version 1.99 increments to 2.00 (major version increases by one, minor resets to 00).
  • The version string is embedded in the firmware binary header and is also written to webserver/version.txt for the web interface to display.

Release Artifacts

Each pipeline run produces three release artifacts, all gzip-compressed:
Artifact Contents Use Case
SharpKey-FW-vX.XX.bin.gz Firmware application binary OTA update via web interface (writes to ota_0/ota_1)
SharpKey-FilePack-vX.XX.bin.gz LittleFS web filesystem image OTA update for web interface only (writes to filesys)
SharpKey-FlashPack-vX.XX.tar.gz Complete flash image: bootloader, partition table, OTA data, firmware, filesystem Initial flashing via esptool (serial)
The FW and FilePack artifacts can be uploaded through the /ota.html web page for wireless updates. The FlashPack is used for initial programming of new boards or recovery from a bricked state, and requires a serial connection:
# Extract and flash the FlashPack
tar xzf SharpKey-FlashPack-v1.05.tar.gz
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 \
    write_flash --flash_mode dio --flash_size 4MB \
    0x1000 bootloader.bin \
    0x8000 partition-table.bin \
    0x9000 ota_data_initial.bin \
    0x10000 sharpkey.bin \
    0x3A0000 filesys.bin

Pipeline Configuration

The Jenkins pipeline is defined as a Groovy script (Jenkinsfile or inline pipeline definition). The key environment variables used throughout the pipeline are:
Variable Purpose
GITEA_URL Base URL of the Gitea instance (e.g., https://git.eaw.app)
REPO_URL Full clone URL of the SharpKey repository
GITEA_TOKEN API token for creating releases and uploading assets
GITEA_OWNER Repository owner (e.g., “eaw”)
GITEA_REPO Repository name (e.g., “SharpKey”)
IDF_DOCKER ESP-IDF Docker image tag (e.g., “espressif/idf:v4.4”)
A critical detail is the host path mapping for Docker-in-Docker builds. Because Jenkins itself runs in a Docker container, and the IDF build runs in a sibling container (not a nested container), the workspace path must be translated from the Jenkins container path to the Docker host path:
// Groovy path mapping
def hostWorkspace = workspace.replace('/var/jenkins_home', '/srv/jenkins/data')

// The IDF container mounts the host path, not the Jenkins container path
docker run --rm \
    -v "${hostWorkspace}:${workspace}" \
    -w "${workspace}" \
    espressif/idf:v4.4 \
    idf.py build
Without this mapping, the IDF container would attempt to mount a path that only exists inside the Jenkins container, resulting in an empty workspace and build failure.

eFuse Table Generation

The pipeline generates the eFuse C source and header files from the CSV definition as a separate stage before the main build. This is necessary because the generated files are excluded from version control by .gitignore (matching *efuse*) while the CSV source is explicitly included (!*efuse*.csv).
# Run inside the IDF Docker container
python /opt/esp/idf/components/efuse/efuse_table_gen.py \
    --idf_target esp32 \
    /opt/esp/idf/components/efuse/esp32/esp_efuse_table.csv \
    main/esp_efuse_custom_table.csv
This command takes the standard ESP32 eFuse table as a base and merges SharpKey's custom field definitions from the CSV. The output files (esp_efuse_custom_table.c and esp_efuse_custom_table.h) are placed in the main/ directory where CMake expects them.

Debugging

Serial Monitor

The primary debugging tool is the ESP-IDF serial monitor, which provides real-time log output over the USB-to-serial connection at 115200 baud:
idf.py -p /dev/ttyUSB0 monitor
Enable CONFIG_DEBUG_SERIAL in menuconfig for verbose output. When enabled, the firmware logs:
  • PS/2 scan codes received (raw and decoded)
  • Key mapping lookups (which table entry was matched)
  • Matrix state changes (row and column for each key press/release)
  • Bluetooth pairing events and HID reports
  • WiFi connection state changes
  • Host detection results at boot
  • NVS read/write operations
Use Ctrl+] to exit the serial monitor. The monitor can be combined with flash in a single command: idf.py -p /dev/ttyUSB0 flash monitor.

GPIO Debug Flags

Individual GPIO groups can be disabled through menuconfig to isolate hardware issues during development and testing. This is particularly useful when debugging on a bare PCB without a host machine connected, or when troubleshooting signal integrity problems.
Kconfig Option Effect
DEBUG_DISABLE_KDB Disable host row select input (KDB0-KDB3). The interface task will not read row addresses.
DEBUG_DISABLE_KDO Disable host data output (KDO0-KDO7). No column data will be driven to the host.
DEBUG_DISABLE_RTSNI Disable row strobe input (RTSNI). The interface task will not wait for strobe signals.
DEBUG_DISABLE_MPXI Disable multiplex select input (MPXI).
DEBUG_DISABLE_KDI Disable additional data input (KDI4).
These flags are compile-time options. When a group is disabled, the corresponding GPIO pins are not initialised and the interface task skips the associated read/write operations. This allows the firmware to run and process HID input without requiring a host machine to be connected.

Common Issues

The following are common issues encountered during development and their solutions:
  • ADC2/WiFi GPIO conflict — GPIOs 0, 2, 4, 12, 13, 14, 15, 25, 26, 27 are shared with the ADC2 peripheral and cannot be used for digital I/O while WiFi is active. The reconfigADC2Ports() method handles this automatically, but if you add new GPIO assignments on these pins, ensure they are reconfigured after WiFi initialisation.
  • eFuse files missing — If the build fails with missing esp_efuse_custom_table.h, ensure the CSV source file (main/esp_efuse_custom_table.csv) is committed and that efuse_table_gen.py runs before the main build. In CI, this is a separate pipeline stage. For local builds, idf.py build should handle this automatically, but a manual run may be needed on first build.
  • Submodule errors — Build failures related to missing arduino-esp32 or esp_littlefs components indicate that Git submodules were not initialised. Run git submodule update --init --recursive to resolve.
  • Timing issues on MZ-2500/2800 — If the host machine does not recognise key presses or shows random characters, verify that:
    • The interface task is pinned to Core 1 (xTaskCreatePinnedToCore(..., 1))
    • The task function is marked with IRAM_ATTR
    • The portMUX_TYPE spinlock is used (not a FreeRTOS mutex)
    • No FreeRTOS API calls are made in the hot loop
    • GPIO register access is used (not gpio_set_level() which adds overhead)
  • WiFi/Bluetooth mode switching — The ESP-IDF WiFi and Bluetooth stacks cannot coexist. A full reboot is required to switch modes. If the firmware appears to hang after switching, ensure the mode selection is saved to NVS before calling esp_restart().
  • Web filesystem not found — If the web interface returns 404 errors, ensure build_webfs.sh was run before building, and that the filesys partition was flashed. The FilePack OTA image can be uploaded separately to update just the web filesystem.
  • PS/2 keyboard not detected — Verify the DATA (GPIO 14) and CLK (GPIO 13) lines are correctly connected. The PS/2 clock line is interrupt-driven — check that the interrupt is not being masked by another peripheral. Enable serial debug to see if scan codes are being received.

Reference Sites

The following external resources are useful when working with the SharpKey firmware:
  • ESP-IDF Programming Guide (v4.4)https://docs.espressif.com/projects/esp-idf/en/v4.4/
    Complete API reference and programming guide for the ESP-IDF framework, including FreeRTOS, GPIO, WiFi, Bluetooth, NVS, OTA, and partition table documentation.
  • SharpKey Repositoryhttps://git.eaw.app/eaw/SharpKey
    Source code, issues, releases, and CI/CD pipeline for the SharpKey project.
  • PS/2 Protocol Reference — The PS/2 keyboard protocol uses Scan Code Set 2 with an 11-bit serial frame (1 start bit, 8 data bits, 1 parity bit, 1 stop bit). Clock is driven by the keyboard at 10-16.7 kHz. The PS2KeyAdvanced library handles all protocol details including extended keys (E0 prefix), break codes (F0 prefix), and pause/break sequences.
  • ESP32 Technical Reference Manual — Espressif's hardware reference covering GPIO registers (GPIO_OUT_REG, GPIO_OUT_W1TS_REG, GPIO_OUT_W1TC_REG, GPIO_IN_REG, GPIO_IN1_REG), interrupt matrix, and peripheral assignments including the ADC2 conflict with WiFi.
  • FreeRTOS Reference — Task creation, spinlocks (portMUX_TYPE), core pinning (xTaskCreatePinnedToCore), and IRAM_ATTR usage for ESP32 dual-core applications.
  • GNU General Public License v3 — SharpKey is released under GPL v3. All derivative works must maintain the same license terms.


Wireless Regulatory Notice

This device incorporates a pre-certified ESP32-S wireless module (AI Thinker) that transmits in the 2.4 GHz ISM band, making it an intentional radiator under radio-frequency regulations worldwide (including FCC Part 15 Subpart C in the United States, and the Radio Equipment Directive 2014/53/EU in the European Union).
The ESP32-S module is used unmodified and carries its own regulatory certifications (FCC, CE, and others). However, those module-level certifications do not automatically extend to a finished product that incorporates the module. The pre-certified module exemption permits individual hobbyists to build a limited number of devices for personal, experimental, or educational use without obtaining separate equipment authorisation.
Important Limitations
  • Assembled devices must not be sold, offered for sale, gifted, or otherwise distributed to third parties unless the finished product has been independently tested and granted its own equipment authorisation (e.g. FCC ID, CE marking with a Notified Body assessment) in the relevant jurisdiction.
  • Building this project for personal use in limited quantities is generally permitted under hobbyist and experimental-use provisions (e.g. FCC § 15.23), provided the device does not cause harmful interference.
  • Regulatory requirements vary by country. Builders outside the United States should consult their national radio-frequency authority for applicable rules.
Builder’s Responsibility
It is the builder’s sole responsibility to ensure that any device constructed from these designs complies with all applicable radio-frequency regulations in their jurisdiction. The author provides these designs for personal, educational, and hobbyist use and makes no representation that a device built from them satisfies the regulatory requirements for commercial distribution.