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 thekeyMatrix[]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_tarray 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_tarray 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:
- Obtain the keyboard connector pinout from the host's service manual or by reverse-engineering the original keyboard.
- Identify which signals are row-select (address), which are column-data (key status), and which are control/strobe signals.
- 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).
- 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.
- 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.
- 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
Step 1 — Create the Header File
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.
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_2800in 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
Step 5 — Build and Test
esp_efuse_custom_table.csv and update the sharpkeyEfuses structure in
SharpKey.cpp.
- Run
idf.py menuconfigand ensure the new host's Kconfig options are visible and correctly configured. - Build:
idf.py build - Flash to the ESP32:
idf.py -p /dev/ttyUSB0 flash - Connect the SharpKey to the new host via the custom cable.
- Monitor serial output:
idf.py -p /dev/ttyUSB0 monitor— check that the host detection prints the correctifModevalue. - Enable
CONFIG_DEBUG_SERIALin menuconfig for verbose key event logging. Each PS/2 scan code, modifier state, and resulting host matrix row/column should be printed. - Use
CONFIG_DEBUG_DISABLE_KDOto disable output to the host while testing detection and key mapping logic in isolation. - Once key mapping is confirmed, enable output and verify the host computer accepts keystrokes correctly.
| 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:
- Identify scan codes — Connect the new keyboard and enable
CONFIG_DEBUG_SERIALin menuconfig. The serial monitor will output raw scan code data for each key press. Note any keys that produce non-standard scan codes. - Assign a model ID — Choose the next available model number and define it as a constant in
PS2KeyTable.h. - Add model-specific entries — In the host interface header (e.g.,
MZ2528.h), addt_keyMap[]entries with the new model ID in thekeyboardModelfield for any keys that differ from the generic mapping. - 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.
- Update the web interface — Add the new model name to the keyboard model dropdown in
webserver/keymap.htmlso 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:
- Host drives KDB0-KDB3 with the row address (0-13)
- Host asserts RTSN low (approximately 600ns)
- SharpKey reads the row address from KDB0-KDB3
- SharpKey presents the corresponding column data on KDO0-KDO7
- Host reads the column data
- Host deasserts RTSN high
- 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_ATTRto keep all code in instruction RAM (no flash cache misses) - Use a
portMUX_TYPEspinlock (not a mutex) for shared data access - Access GPIO registers directly via
GPIO.out_w1ts,GPIO.out_w1tc, andREG_READ(GPIO_IN1_REG) - Avoid any FreeRTOS API calls in the hot loop (no
vTaskDelay, noxSemaphoreTake) - 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:
- SharpKey enters discoverable mode when no bonded device is connected
- The status LED flashes at 8 Hz to indicate pairing mode is active
- The keyboard initiates pairing by scanning for HID hosts
- If a PIN is requested, the default PIN is "1234"
- Upon successful pairing, the device bond is stored in NVS
- The LED switches to steady-on to indicate a connected device
- 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 tohttp://192.168.4.1to 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.htmlpage 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:
- The
build_webfs.shscript copies HTML files fromwebserver/to a staging directorywebfs/. - CSS, JavaScript, and font files are gzip-compressed during the copy to reduce flash usage and enable compressed serving.
- The CMake build system uses the esp_littlefs component to create a LittleFS partition image from the
webfs/directory. - The partition image is written to the
filesyspartition (640KB, typespiffs) during flashing. - At runtime, the ESP32 mounts the LittleFS partition and serves files directly. Compressed files are served with
Content-Encoding: gzipheaders, 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:
- Upload — The firmware binary is uploaded via the
/ota.htmlweb page (drag-and-drop) or by posting the binary to the/api/updateendpoint. - Header validation — The first bytes of the uploaded binary are checked for the
ESP_APP_DESC_MAGIC_WORDheader to verify it is a valid ESP32 application image. - 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.
- 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.
- 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.
- 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.
Menuconfig Options
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 onlyMZ25KEY_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:
- Checkout — Clean the workspace, clone the SharpKey repository from Gitea, and initialise all Git submodules (arduino-esp32 and esp_littlefs).
- Determine Version — Read
version.txtfrom the repository. If the version has not changed since the last commit, auto-increment the version number from the latest Gitea release tag. - Build Web Filesystem — Execute
build_webfs.shto copy and compress web interface assets into thewebfs/staging directory. - Generate eFuse Table — Run
efuse_table_gen.pyinside the IDF Docker container to generateesp_efuse_custom_table.cand.hfrom the CSV source. - Build Firmware — Compile the firmware using
idf.py buildinside the IDF Docker container. - Package Release — Create three release archives: FW (firmware binary), FilePack (web filesystem image), and FlashPack (complete flash image).
- 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 URL —
https://<jenkins-host>/generic-webhook-trigger/invoke?token=sharpkey-build-trigger - Token —
sharpkey-build-trigger - Content Type —
application/json - Branch Filter — Only triggers on pushes to
refs/heads/mainorrefs/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.txthas been modified in the triggering commit, the version specified in the file is used as-is for the release. - If
version.txtis 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.txtfor 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 thatefuse_table_gen.pyruns before the main build. In CI, this is a separate pipeline stage. For local builds,idf.py buildshould handle this automatically, but a manual run may be needed on first build. - Submodule errors — Build failures related to missing
arduino-esp32oresp_littlefscomponents indicate that Git submodules were not initialised. Rungit submodule update --init --recursiveto 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_TYPEspinlock 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)
- The interface task is pinned to Core 1 (
- 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.shwas run before building, and that thefilesyspartition 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 Repository — https://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
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.
- 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.
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.