Unlocking the Key to CircuitPython: Crafting Custom Keystrokes for a DIY Keyboard
- 5°人体工程倾角
- Gasket结构
- USB HUB
- 充电宝功能
- RGB灯珠
- IIC拓展接口
- 移位寄存器级联
- 热插拔轴座
- 螺丝孔
circuitpython在启动后,会依次执行:
- 初始化硬件,包括GPIO,串口,IIC,SPI等接口
- 运行boot.py,运行由python撰写的初始化代码
- 检测是否有通过usb连接主机设备,如果有,运行repl.py
- 运行用户代码code.py
根据移位寄存器原理,每次扫描
physical_key_name_map = {
"A": 45,
"B": 30,
"C": 38,
"D": 37,
"E": 36,
...
}
记录在config/physical_key_name_map.json
文件中。
由于物理按键不仅对应按键开关,还对应了键位上的RGB灯珠,因此建立一个物理按键类PhysicalKey
,用于维护开关self.pressed: bool
和颜色self.color: tuple(int, int, int)
信息。
class PhysicalKey:
def __init__(self, key_id: int, key_name: str, max_light_level: int = max_light_level) -> None:
self.physical_id = key_id
self.key_name = key_name
self.pressed = False
self.color = (max_light_level, max_light_level, max_light_level)
self.random_color(max_light_level)
def random_color(self, max_light_level):
self.color = (
random.randint(0, max_light_level),
random.randint(0, max_light_level),
random.randint(0, max_light_level)
)
对于不同工作需求,键盘需要改变其按键功能布局。为了便于改键,定义物理按键到虚拟按键的映射。物理按键用于记录开关状态,虚拟按键则包含了按键按下时发送的信号keycode
或者执行的函数pressed_function
。
class VirtualKey:
def __init__(self, key_name: str, keycode: int, bind_physical_key: PhysicalKey, pressed_function=None) -> None:
self.keycode = keycode
self.key_name = key_name
self.pressed_function = pressed_function
self.bind_physical_key = bind_physical_key
self.pressed = False
self.update_time = time.time()
def is_pressed(self):
pressed = self.bind_physical_key.pressed
return pressed
def press(self):
self.pressed = True
if self.pressed_function:
pressed_function_result = self.pressed_function()
if pressed_function_result is None: # TODO
return None
return pressed_function_result
return None
def release(self):
self.pressed = False
return None
VirtualKey.bind_physical_key
为虚拟按键绑定的物理按键。当物理按键处于按压状态时,扫描函数会调用VirtualKey.press
。如果虚拟按键有绑定函数,则执行绑定函数VirtualKey.pressed_function
,该函数可用于改变键盘转态(例如连接模式,灯光亮度)。根据VirtualKey.pressed_function
的返回值,决定是否发送键盘原本的键值。如果未绑定函数,则发送键盘原本的键值keycode
。
为了便于修改键位绑定,设置config/mapping.json
和config/fn_mapping.json
两个键位映射,分别用于修改默认层键位映射和Fn
层(按住Fn
)键位映射。
config/mapping.json
{
"PAGE_UP": "HOME",
"PAGE_DOWN": "END",
"ESCAPE": "GRAVE_ACCENT",
"INSERT": "ESCAPE"
}
config/fn_mapping.json
{
"ONE": "F1",
"TWO": "F2",
"THREE": "F3",
...
}
这里给出一个将按键绑定键盘控制函数的例子:将灯光亮度控制函数change_light_level
和change_light_mode
绑定到按键上。
virtual_key_layers[fn_key_layer_id][physical_key_map["UP_ARROW"].physical_id].pressed_function = partial(change_light_level, 32)
virtual_key_layers[fn_key_layer_id][physical_key_map["DOWN_ARROW"].physical_id].pressed_function = partial(change_light_level, -32)
virtual_key_layers[fn_key_layer_id][physical_key_map["TAB"].physical_id].pressed_function = change_light_mode
由于键盘有多种连接方式连接到主机(USB1, USB2, BLE),因此建立一个键盘类VirtualKeyBoard
,统一三种连接方式。
class VirtualKeyBoard:
def __init__(self, mode=on_start_keyboard_mode, usb_timeout=1):
self.mode = mode
self.adapter = _bleio.adapter
if not self.adapter.enabled:
self.adapter.enabled = True
self.mac_address = self.adapter.address
print("Bluetooth MAC Address:", self.mac_address)
self.ble = BLERadio()
self.ble_hid = HIDService()
self.advertisement = ProvideServicesAdvertisement(self.ble_hid)
self.advertisement.appearance = 961
self.advertisement.short_name = "s68k"
self.advertisement.complete_name = "s68k esp32s3 keyboard"
self.ble_keyboard = Keyboard(self.ble_hid.devices)
try:
self.usb_hid_keyboard = Keyboard(usb_hid.devices, timeout=usb_timeout)
except:
self.usb_hid_keyboard = None
self.ch9329_keyboard = CH9329(uart)
self.set_mode(self.mode)
self.reset()
def erase_bonding(self):
self.adapter.erase_bonding()
def set_mode(self, mode, usb_timeout=1):
if mode == "bluetooth" and self.mode != "bluetooth":
if not self.ble.advertising:
self.ble.start_advertising(self.advertisement)
elif self.mode == "bluetooth" and mode != "bluetooth":
if self.ble.advertising:
self.ble.stop_advertising()
self.mode = mode
if mode == "usb_hid" and self.usb_hid_keyboard is None:
try:
self.usb_hid_keyboard = Keyboard(usb_hid.devices, timeout=usb_timeout)
except:
self.usb_hid_keyboard = None
self.mode = "dummy"
def reset(self):
if self.usb_hid_keyboard is not None:
self.usb_hid_keyboard.release_all()
if self.ch9329_keyboard is not None:
self.ch9329_keyboard.keyboard_release_all()
if self.ble_keyboard is not None:
self.ble_keyboard.release_all()
def press(self, *keycodes: int) -> None:
if self.mode == "usb_hid":
if self.usb_hid_keyboard is not None:
try:
self.usb_hid_keyboard.press(*keycodes)
except:
self.set_mode("dummy")
else:
raise ValueError(f"self.usb_hid_keyboard is None")
elif self.mode == "ch9329":
if self.ch9329_keyboard is not None:
self.ch9329_keyboard.keyboard_press(*keycodes[:6])
else:
print(f"self.ch9329_keyboard is None")
elif self.mode == "bluetooth":
if self.ble_keyboard is not None:
self.ble_keyboard.press(*keycodes)
else:
raise ValueError(f"self.ble_keyboard is None")
elif self.mode == "dummy":
pass
else:
raise NotImplementedError(f"self.mode: {self.mode}")
def release(self, *keycodes: int) -> None:
if self.mode == "usb_hid":
if self.usb_hid_keyboard is not None:
try:
self.usb_hid_keyboard.release(*keycodes)
except:
self.set_mode("dummy")
else:
print(f"self.usb_hid_keyboard is None")
elif self.mode == "ch9329":
if self.ch9329_keyboard is not None:
self.ch9329_keyboard.keyboard_release(*keycodes)
else:
raise ValueError(f"self.ch9329_keyboard is None")
elif self.mode == "bluetooth":
if self.ble_keyboard is not None:
self.ble_keyboard.release(*keycodes)
else:
raise ValueError(f"self.ble_keyboard is None")
elif self.mode == "dummy":
pass
else:
raise NotImplementedError(f"self.mode: {self.mode}")
将切换连接模式的函数VirtualKeyBoard.set_mode
绑定到特定虚拟按键上:
virtual_key_layers[fn_key_layer_id][physical_key_map["Q"].physical_id].pressed_function = partial(kbd.set_mode, "usb_hid")
virtual_key_layers[fn_key_layer_id][physical_key_map["W"].physical_id].pressed_function = partial(kbd.set_mode, "ch9329")
virtual_key_layers[fn_key_layer_id][physical_key_map["E"].physical_id].pressed_function = partial(kbd.set_mode, "bluetooth")
virtual_key_layers[fn_key_layer_id][physical_key_map["R"].physical_id].pressed_function = partial(kbd.set_mode, "dummy")
对于蓝牙连接,需要VirtualKeyBoard.erase_bonding
函数用于删除过期蓝牙信息:
virtual_key_layers[fn_key_layer_id][physical_key_map["BACKSPACE"].physical_id].pressed_function = kbd.erase_bonding
尽管硬件支持全键无冲,但对于标准hid协议,同时只能向主机发送6个按下的按键。因此需要构建一个FIFO队列CH9329._pressed_keys
,记录最后处在按下状态的6个按键:
class CH9329:
def __init__(self, uart: UART, address: int = 0x00):
self._pressed_keys: list[int] = list()
self._pressed_modifier_keys: list[int] = list()
...
由于circuitpython不支持多线程,因此需要通过批处理在主循环中循环执行扫描、发送和灯光控制。
# scan loop
while running:
register_bits = read_shift_registers()
pressed_key_ids = get_pressed_key_ids(register_bits)
# physical scan
for key in physical_keys:
if key.physical_id in pressed_key_ids:
if key.pressed == False:
key.random_color(max_light_level)
pass
key.pressed = True
elif key.pressed == True:
key.pressed = False
# virtual key and send
virtual_key_layer_id = int(fn_key.pressed)
for key in virtual_key_layers[virtual_key_layer_id].values():
if key.is_pressed():
if key.pressed == False:
key.press()
if key.pressed_function is None:
kbd.press(key.keycode) # 可能出现阻塞
key.update_time = time.time()
else:
if key.pressed == True:
key.release()
kbd.release(key.keycode)
key.update_time = time.time()
# light control
light_key_ids = physical_key_ids if light_mode == "random_static" else pressed_key_ids
colors = [physical_key_id_map[pressed_key_id].color for pressed_key_id in light_key_ids]
light_keys(light_key_ids, colors=colors, refresh=True)
# sleep
time.sleep(scan_interval)
在循环中,在按键信号发送阶段,可能存在阻塞的现象。例如在使用串口转USB连接模式,由于串口波特率低(
circuitpython在esp32-s3上的运行时依托FreeRTOS。因此可以通过xTaskCreate运行两个circuitpython解释器,运行两个circuitpython线程,执行不同代码。但是由于不存在互斥锁,因此直接创建新circuitpython解释器,会引起硬件资源冲突。
main.c
if (pyexec_mode_kind == PYEXEC_MODE_FRIENDLY_REPL) {
// If code.py did a fake deep sleep, pretend that we
// are running code.py for the first time after a hard
// reset. This will preserve any alarm information.
// skip_repl = run_code_py(get_safe_mode(), &simulate_reset);
// 创建任务1
run_code_py_running = true;
serial_write("Preparing task_run_code_py. ");
xTaskCreate(&task_run_code_py, "task_run_code_py", 4096, NULL, 5, NULL);
serial_write("Running task_run_code_py. ");
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
serial_write("Finish task_run_code_py, blocking by run_code_py_running. ");
// while(run_code_py_running)
vTaskDelay(pdMS_TO_TICKS(1000));
serial_write("Finish run_code_py_running. ");
} else {
skip_repl = false;
}
相比于circuitpython,mircopython实现了_thread
与互斥锁。后续工作中,计划将键盘代码迁移到mircopython。
在本课程中,我们成功设计并实现了一款支持热插拔的机械键盘,涵盖了外壳设计、原理图、PCB布局以及基于CircuitPython的功能实现。该键盘具备USB HUB、充电宝功能、RGB灯珠和IIC扩展接口等硬件特性。在软件方面,我们使用CircuitPython进行开发,实现了物理按键扫描、键位映射和信号发送等功能。通过定义物理按键和虚拟按键类,我们实现了按键功能的灵活配置,并通过绑定函数实现了键盘的高级控制功能。此外,课程还探讨了多线程实现的可能性,并计划将代码迁移到MicroPython,以利用其多线程支持。