integration / firmware

MCU & code.

How core2key fits into Arduino, ESP32, cyberdeck, and custom-device projects.

The keyboard side ships ready.

The keyboard MCU on core2key is preloaded. The goal is to let you integrate physical typing into a build without first turning the keyboard itself into another firmware project. Your Arduino, ESP32, or other project controller remains the place where you define the behavior of the device you are building.

Exposed connections.

The board exposes PRO, GND, 3V3, SDA, and SCL. The production documentation will define the exact electrical requirements and role of the PRO line before release. Do not assume voltage tolerance or alternate pin functions beyond the published specification.

Programming model.

A typical project flow is: power and connect core2key, initialize the input interface in your project controller, read key events, and map those events to whatever your device should do. That can mean text entry, menu navigation, macros, commands, shortcuts, serial actions, or completely custom logic.

// CardKB-compatible 4-key keypad for CH32V003F4P6 (ch32fun)
//
// Acts like an M5Stack CardKB: I2C slave at address 0x5F.
// Every time the ESP32 reads 1 byte, it gets the next key press
// (or 0 if no key was pressed).
//
// Extra (not in the real CardKB): if the ESP32 reads 2 bytes, the second
// byte tells which buttons are held down right now:
//   bit0 = SW1, bit1 = SW2, bit2 = SW3, bit3 = SW4  (1 = held)
// CardKB code that only reads 1 byte keeps working unchanged.
//
// Wiring (matches the PCB):
//   PC1 (pin 11) = SDA
//   PC2 (pin 12) = SCL
//   PC3 (pin 13) = SW1 -> GND
//   PC4 (pin 14) = SW2 -> GND
//   PC5 (pin 15) = SW3 -> GND
//   PC6 (pin 16) = SW4 -> GND

#include "ch32fun.h"

#define CARDKB_ADDR 0x5F

// CardKB key codes
#define KEY_LEFT   0xB4
#define KEY_UP     0xB5
#define KEY_DOWN   0xB6
#define KEY_RIGHT  0xB7
#define KEY_ENTER  0x0D
#define KEY_ESC    0x1B
#define KEY_BKSP   0x08

// Change these to send different keys (any ASCII character works too, e.g. 'a')
static const uint8_t key_pins[4]  = { PC3,    PC4,      PC5,      PC6       };
static const uint8_t key_codes[4] = { '1',    '2',      '3',      '4'       };

#define RELEASE_MS 20   // button must be released this long before a new press counts

// ---------- Key queue (main loop pushes, I2C interrupt pops) ----------
#define QUEUE_SIZE 16
static volatile uint8_t queue[QUEUE_SIZE];
static volatile uint8_t q_head = 0;
static volatile uint8_t q_tail = 0;

static void queue_push(uint8_t c)
{
	uint8_t next = (q_head + 1) % QUEUE_SIZE;
	if (next != q_tail) {          // drop key if queue is full
		queue[q_head] = c;
		q_head = next;
	}
}

static uint8_t queue_pop(void)
{
	if (q_tail == q_head) return 0; // no key -> CardKB returns 0
	uint8_t c = queue[q_tail];
	q_tail = (q_tail + 1) % QUEUE_SIZE;
	return c;
}

// ---------- I2C slave ----------
static volatile uint8_t tx_byte   = 0;
static volatile uint8_t tx_index  = 0;
static volatile uint8_t held_mask = 0;   // live state of all buttons

static void i2c_slave_init(void)
{
	RCC->APB2PCENR |= RCC_APB2Periph_GPIOC | RCC_APB2Periph_AFIO;
	RCC->APB1PCENR |= RCC_APB1Periph_I2C1;

	// PC1 = SDA, PC2 = SCL, alternate function open-drain
	funPinMode(PC1, GPIO_Speed_10MHz | GPIO_CNF_OUT_OD_AF);
	funPinMode(PC2, GPIO_Speed_10MHz | GPIO_CNF_OUT_OD_AF);

	// Reset I2C peripheral
	RCC->APB1PRSTR |=  RCC_APB1Periph_I2C1;
	RCC->APB1PRSTR &= ~RCC_APB1Periph_I2C1;

	I2C1->CTLR2  = (FUNCONF_SYSTEM_CORE_CLOCK / 1000000) & I2C_CTLR2_FREQ;
	I2C1->CTLR2 |= I2C_CTLR2_ITEVTEN | I2C_CTLR2_ITBUFEN | I2C_CTLR2_ITERREN;
	I2C1->OADDR1 = CARDKB_ADDR << 1;

	I2C1->CTLR1  = I2C_CTLR1_PE;
	I2C1->CTLR1 |= I2C_CTLR1_ACK;   // ACK must be set after PE

	NVIC_EnableIRQ(I2C1_EV_IRQn);
	NVIC_EnableIRQ(I2C1_ER_IRQn);
}

void I2C1_EV_IRQHandler(void) __attribute__((interrupt));
void I2C1_EV_IRQHandler(void)
{
	uint16_t star1 = I2C1->STAR1;

	if (star1 & I2C_STAR1_ADDR) {
		uint16_t star2 = I2C1->STAR2;   // reading STAR2 clears ADDR
		if (star2 & I2C_STAR2_TRA) {    // ESP32 is reading from us
			tx_byte  = queue_pop();     // take exactly one key per read
			tx_index = 0;
		}
	}

	if (star1 & I2C_STAR1_TXE) {
		uint8_t out = 0;
		if (tx_index == 0)      out = tx_byte;    // byte 1: next key press
		else if (tx_index == 1) out = held_mask;  // byte 2: buttons held now
		I2C1->DATAR = out;
		if (tx_index < 2) tx_index++;
	}

	if (star1 & I2C_STAR1_RXNE) {
		(void)I2C1->DATAR;              // ignore anything written to us
	}

	if (star1 & I2C_STAR1_STOPF) {
		I2C1->CTLR1 |= I2C_CTLR1_PE;    // clears STOPF
	}
}

void I2C1_ER_IRQHandler(void) __attribute__((interrupt));
void I2C1_ER_IRQHandler(void)
{
	// AF is normal: the ESP32 NACKs the last byte of a read
	I2C1->STAR1 &= ~(I2C_STAR1_AF | I2C_STAR1_BERR | I2C_STAR1_ARLO | I2C_STAR1_OVR);
}

// ---------- Main ----------
int main(void)
{
	SystemInit();
	funGpioInitAll();

	for (int i = 0; i < 4; i++) {
		funPinMode(key_pins[i], GPIO_CNF_IN_PUPD);
		funDigitalWrite(key_pins[i], FUN_HIGH);    // enable internal pull-up
	}

	i2c_slave_init();

	uint8_t held[4]  = { 0, 0, 0, 0 };   // 1 = button is down
	uint8_t count[4] = { 0, 0, 0, 0 };

	while (1) {
		uint8_t mask = 0;

		for (int i = 0; i < 4; i++) {
			uint8_t down = funDigitalRead(key_pins[i]) ? 0 : 1;

			if (!held[i]) {
				// Register the press on the very first contact, so short
				// or scratchy presses are never missed
				if (down) {
					held[i]  = 1;
					count[i] = 0;
					queue_push(key_codes[i]);
				}
			} else {
				// Only count as released after RELEASE_MS of no contact,
				// so contact bounce can't create extra presses
				if (!down) {
					if (++count[i] >= RELEASE_MS) {
						held[i]  = 0;
						count[i] = 0;
					}
				} else {
					count[i] = 0;
				}
			}

			if (held[i]) mask |= (1 << i);
		}

		held_mask = mask;
		Delay_Ms(1);
	}
}

Firmware documentation area.

This page is now structured for the full MCU documentation: source overview, protocol details, pin behavior, examples, firmware versions, and downloadable code. Once the final keyboard MCU code and integration API are ready, those can be dropped into this section without changing the rest of the store.