Embedded SwiftとRaspberry Pi Picoでキーボードを作る
Appleにより、Embedded Swiftを使ってRaspberry Pi Picoなどのマイコンボードを動かすサンプルが公開されています
ということで、SwiftからTinyUSBを呼んで、Raspberry Pi Picoでキーボードを作ってみました
ボタンを押下すると、「command + B」を発行します
これにより、一発でXcodeのビルドを実行できます
SwiftでRaspberry Pi Picoをキーボードにして、Command + Bのショートカットキー作った!
— ふじき (@fzkqi) August 11, 2024
これでXcodeのビルドも一発! pic.twitter.com/ITcjDDJMN8
必要パーツ
部品名 | 購入リンク |
---|---|
Raspberry Pi Pico W | https://akizukidenshi.com/catalog/g/g117947/ |
ブレッドボード 6穴版 | https://akizukidenshi.com/catalog/g/g112366/ |
ジャンパーワイヤ | https://akizukidenshi.com/catalog/g/g100288/ |
抵抗内蔵5mmLED | https://akizukidenshi.com/catalog/g/g117554/ |
タクトスイッチ | https://akizukidenshi.com/catalog/g/g109827/ |
- 手元で動作確認したパーツの同等品を含みます
- おそらく、Raspberry Pi Picoでも同様に動作するはずです
SwiftからTinyUSBを呼んでRaspberry Pi Picoをキーボードにする実装
Dockerの準備
環境をイケてる感じにしたいので、Dockerでビルドします
$ docker build --platform linux/amd64 -t swift-build-env-amd64 .
Raspberry Pi Picoのスタートガイドを参考に必要なツールをインストールします
Swiftをビルドするために追加でNinjaとPython3が必要でした
FROM swiftlang/swift:nightly-main-jammy
RUN apt-get update && apt-get install -y \
gcc \
cmake \
gcc-arm-none-eabi \
libnewlib-arm-none-eabi \
build-essential \
git \
ninja-build \
python3 \
&& apt-get clean
RUN git clone -b master https://github.com/raspberrypi/pico-sdk.git \
&& cd pico-sdk \
&& git submodule update --init
RUN git clone -b master https://github.com/raspberrypi/pico-examples.git
ENV PICO_SDK_PATH=/pico-sdk
ENV PICO_TOOLCHAIN_PATH=/usr/bin/arm-none-eabi-gcc
WORKDIR /workspace
ENTRYPOINT ["/bin/bash"]
TinyUSBを使えるようにする
swift-embedded-examplesをベースにTinyUSBを使えるように諸々追加します
BridgingHeader
BridgingHeader.h
に必要なヘッダを追加します
#pragma once
#ifndef CFG_TUSB_MCU
#define CFG_TUSB_MCU OPT_MCU_RP2040
#endif
// for malloc
#include <stdlib.h>
#include "pico/stdlib.h"
// For HID
#include "bsp/board.h"
#include "usb_descriptors.h"
// board_millis
#include "pico/time.h"
static inline uint32_t get_board_millis(void)
{
return to_ms_since_boot(get_absolute_time());
}
TinyUSBの設定系
pico-examplesのdev_hid_compositeからTinyUSBの設定系ファイルをコピペして持ってきます
tusb_config.h
、usb_descriptors.c
、usb_descriptors.h
の3つのファイルです
キーボード以外にも、マウスやゲームパッドの機能も含まれているので、キーボード以外は削除しました
CMake
コピペしてきたusb_descriptors.c
もビルドされ、tusb_config.h
、usb_descriptors.h
が見つかるように諸々設定します
また、pico_unique_id tinyusb_device tinyusb_board
などの依存ライブラリを追加しました
cmake_minimum_required(VERSION 3.13)
include($ENV{PICO_SDK_PATH}/external/pico_sdk_import.cmake)
project(swift-app C CXX ASM)
pico_sdk_init()
if(APPLE)
execute_process(COMMAND xcrun -f swiftc OUTPUT_VARIABLE SWIFTC OUTPUT_STRIP_TRAILING_WHITESPACE)
else()
execute_process(COMMAND which swiftc OUTPUT_VARIABLE SWIFTC OUTPUT_STRIP_TRAILING_WHITESPACE)
endif()
add_executable(swift-app)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/_swiftcode.o
COMMAND
${SWIFTC}
-target armv6m-none-none-eabi -Xcc -mfloat-abi=soft -Xcc -fshort-enums
-Xfrontend -function-sections -enable-experimental-feature Embedded -wmo -parse-as-library
$$\( echo '$<TARGET_PROPERTY:swift-app,INCLUDE_DIRECTORIES>' | tr '\;' '\\n' | sed -e 's/\\\(.*\\\)/-Xcc -I\\1/g' \)
$$\( echo '${CMAKE_C_IMPLICIT_INCLUDE_DIRECTORIES}' | tr ' ' '\\n' | sed -e 's/\\\(.*\\\)/-Xcc -I\\1/g' \)
-Xcc -I${CMAKE_CURRENT_LIST_DIR}/usb_descriptors.c
-import-bridging-header ${CMAKE_CURRENT_LIST_DIR}/BridgingHeader.h
${CMAKE_CURRENT_LIST_DIR}/Main.swift
-c -o ${CMAKE_CURRENT_BINARY_DIR}/_swiftcode.o
DEPENDS
${CMAKE_CURRENT_LIST_DIR}/BridgingHeader.h
${CMAKE_CURRENT_LIST_DIR}/Main.swift
${CMAKE_CURRENT_LIST_DIR}/usb_descriptors.c
)
add_custom_target(swift-app-swiftcode DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/_swiftcode.o)
target_sources(swift-app PUBLIC ${CMAKE_CURRENT_LIST_DIR}/usb_descriptors.c)
# For search tusb_config.h
target_include_directories(swift-app PUBLIC ${CMAKE_CURRENT_LIST_DIR})
target_link_libraries(swift-app
pico_stdlib pico_unique_id tinyusb_device tinyusb_board
${CMAKE_CURRENT_BINARY_DIR}/_swiftcode.o
)
add_dependencies(swift-app swift-app-swiftcode)
pico_add_extra_outputs(swift-app)
Swiftの実装
Mainプログラム
入力ボタンとステータス表示用のLEDの入出力とTinyUSB系を設定し、TinyUSBなどのイベント処理をループ実行するようになっています
enum Blink: UInt32 {
case notMounted = 250
case mounted = 1000
case suspended = 2500
}
var blink_interval_ms: UInt32 = Blink.notMounted.rawValue
let button_cmd_b_pin: UInt32 = 16
let led_pin: UInt32 = 15
@main
struct Main {
static func main() {
board_init() // ボードの初期化
tusb_init() // TinyUSBの初期化
gpio_init(button_cmd_b_pin) // 入力ボタンの設定
gpio_set_dir(button_cmd_b_pin, false)
gpio_pull_up(button_cmd_b_pin)
gpio_init(led_pin) // ステータス表示用のLEDの設定
gpio_set_dir(led_pin, true)
while true {
tud_task() // TinyUSBの処理
hid_task() // キーボードのイベント送信(後述)
led_blinking_task() // ステータス表示用の更新(後述)
}
}
}
USBデバイスのコールバック
USBデバイスのコールバックを受け取ります
シンボル名を指定して呼び出されるようなので、@._cdeclで引っかかるようにしておきます
USBのイベントを受け取るとステータスを更新します
led_blinking_taskでblink_interval_msの間隔でLEDをON/OFFします
// Invoked when device is mounted
@_cdecl("tud_mount_cb")
func tud_mount_cb() {
blink_interval_ms = Blink.mounted.rawValue
}
// Invoked when device is unmounted
@_cdecl("tud_umount_cb")
func tud_umount_cb() {
blink_interval_ms = Blink.notMounted.rawValue
}
// Invoked when usb bus is suspended
// remote_wakeup_en : if host allow us to perform remote wakeup
// Within 7ms, device must draw an average of current less than 2.5 mA from bus
@_cdecl("tud_suspend_cb")
func tud_suspend_cb(_ remote_wakeup_en: Bool) {
blink_interval_ms = Blink.suspended.rawValue
}
// Invoked when usb bus is resumed
@_cdecl("tud_resume_cb")
func tud_resume_cb() {
blink_interval_ms = Blink.mounted.rawValue
}
キーボードの処理
10ミリ秒ごとにキーボード処理を実行します
ボタンが押下されているのを検出すると、tud_hid_keyboard_report
でキーボードイベントを発行します
Embedded Swiftでは配列を利用できなかったため、mallocでメモリを確保して諸々しています
KEYBOARD_MODIFIER_LEFTGUIで、Commandの装飾をつけています
キーボード系のイベントのコールバックを受け取れるように、こちらでもコールバック系の関数に@._cdeclをつけています
が、今回は特に何もしていません
var start_ms_for_hid_task: UInt32 = 0
// use to avoid send multiple consecutive zero report for keyboard
var has_keyboard_key: Bool = false
// Every 10ms, we will sent 1 report for each HID profile (keyboard, mouse etc ..)
// tud_hid_report_complete_cb() is used to send the next report after previous one is complete
func hid_task() {
// Poll every 10ms
let interval_ms: UInt32 = 10
if get_board_millis() - start_ms_for_hid_task < interval_ms { return } // not enough time
start_ms_for_hid_task += interval_ms
let button_cmd_b: Bool = !gpio_get(button_cmd_b_pin)
// Remote wakeup
if tud_suspended() && button_cmd_b {
// Wake up host if we are in suspend mode
// and REMOTE_WAKEUP feature is enabled by host
tud_remote_wakeup()
} else {
// Send the 1st of report chain, the rest will be sent by tud_hid_report_complete_cb()
// skip if hid is not ready yet
if !tud_hid_ready() { return }
if button_cmd_b {
let keycode: UnsafeMutablePointer<UInt8> = malloc(6 * MemoryLayout<UInt8>.size)!
.assumingMemoryBound(to: UInt8.self)
memset(keycode, 0, 6 * MemoryLayout<UInt8>.size)
keycode.advanced(by: 0).pointee = UInt8(HID_KEY_B)
tud_hid_keyboard_report(UInt8(REPORT_ID_KEYBOARD), UInt8(KEYBOARD_MODIFIER_LEFTGUI.rawValue), keycode)
free(keycode)
has_keyboard_key = true
} else {
// send empty key report if previously has key pressed
if has_keyboard_key {
tud_hid_keyboard_report(UInt8(REPORT_ID_KEYBOARD), 0, nil)
}
has_keyboard_key = false
}
}
}
// Invoked when sent REPORT successfully to host
// Application can use this to send the next report
// Note: For composite reports, report[0] is report ID
@_cdecl("tud_hid_report_complete_cb")
func tud_hid_report_complete_cb(
_ instance: UInt8,
_ report: UnsafeMutablePointer<UInt8>,
_ len: UInt16
) {}
// Invoked when received GET_REPORT control request
// Application must fill buffer report's content and return its length.
// Return zero will cause the stack to STALL request
@_cdecl("tud_hid_get_report_cb")
func tud_hid_get_report_cb(
_ instance: UInt8,
_ report_id: UInt8,
_ report_type: hid_report_type_t,
_ buffer: UnsafeMutablePointer<UInt8>,
_ reqlen: UInt16
) -> UInt16 { 0 }
// Invoked when received SET_REPORT control request or
// received data on OUT endpoint ( Report ID = 0, Type = 0 )
@_cdecl("tud_hid_set_report_cb")
func tud_hid_set_report_cb(
_ instance: UInt8,
_ report_id: Int8,
_ report_type: hid_report_type_t,
_ buffer: UnsafeMutablePointer<UInt8>,
_ bufsize: UInt16
) {}
LEDのON/OFF処理
デバイスのステータスに応じて、LEDのON/OFFを切り替えます
func led_blinking_task() {
// blink is disabled
if blink_interval_ms == 0 { return }
// Blink every interval ms
if get_board_millis() - start_ms_for_led_blinking < blink_interval_ms { return } // not enough time
start_ms_for_led_blinking += blink_interval_ms
gpio_put(led_pin, led_state)
led_state.toggle()
}
ビルド
ビルドします
ビルドに成功すると、build/swift-app.uf2が作成されています
$ export PICO_BOARD=pico
$ export PICO_SDK_PATH='/pico-sdk'
$ export PICO_TOOLCHAIN_PATH='/usr/bin/arm-none-eabi-gcc'
$ cmake -B build -G Ninja .
$ cmake --build build
配線
GPIOの16番をボタンに、15番をLEDに接続します
お手軽に使うために、抵抗入りLEDを利用しています
普通のLEDを利用する場合は、適切な制限抵抗を入れてください
(wokwiで作成しました)
(配線図はRaspbery Pi Picoになっています)
動作確認
Raspberry Pi Pico上のBOOTSEL(多分Boot Select)ボタンを押下しながら、PCと接続します
RPI-RP2というUSBデバイスが接続されるので、作成したuf2ファイルをドラッグ&ドロップします
Xcodeを開いて、ブレッドボード上のボタンを押下して、ビルドのショートカットが実行されていれば成功です!
キーマトリクスを検出する
ブレッドボード上の1スイッチだと少し寂しかったので、マイクロパッドも作成してみました
基板はpro micro用のマイクロパッドです
Raspberry Pi Picoと同じRP2040を採用し、自作キーボードで有名なpro microと互換pinのKB2040を利用しています
GitHubでコード全文公開してます
Embedded SwiftとKB2040(RP2040のpro micro互換ピンのやつ)でキーボードできた! pic.twitter.com/Ezt0jQjh8v
— ふじき (@fzkqi) August 22, 2024
パーツリスト
キーマトリクスの検出
使った基板はキーマトリクスと呼ばれる配線になっています
2 + 3 = 5個の入出力で 2 x 3 = 6個のスイッチを検出できます (入出力1個分お得!)
これはスイッチ数が多くなればなるほど有利で、例えば9 + 9 = 18個の入出力で 9 x 9 = 81個のスイッチを検出できます
利用するピンの入出力を設定します
let left_pin: UInt32 = 8
let center_pin: UInt32 = 7
let right_pin: UInt32 = 6
let upper_pin: UInt32 = 2
let bottom_pin: UInt32 = 3
func setInput(pin: UInt32) {
gpio_init(pin)
gpio_set_dir(pin, false)
gpio_pull_up(pin)
}
func setOutput(pin: UInt32) {
gpio_init(pin)
gpio_set_dir(pin, true)
}
setInput(pin: left_pin)
setInput(pin: center_pin)
setInput(pin: right_pin)
setOutput(pin: upper_pin)
setOutput(pin: bottom_pin)
同時押しには対応せず、上段の左端から押下されているか順番にチェックして、最初にヒットしたスイッチと対応するキーを返しています
func getPressKey() -> UInt8? {
// 上段の検出
gpio_put(upper_pin, false)
gpio_put(bottom_pin, true)
sleep_us(30)
if !gpio_get(left_pin) { return UInt8(HID_KEY_S) }
else if !gpio_get(center_pin) { return UInt8(HID_KEY_W) }
else if !gpio_get(right_pin) { return UInt8(HID_KEY_I) }
// 下段の検出
gpio_put(upper_pin, true)
gpio_put(bottom_pin, false)
sleep_us(30)
if !gpio_get(left_pin) { return UInt8(HID_KEY_F) }
else if !gpio_get(center_pin) { return UInt8(HID_KEY_T) }
else if !gpio_get(right_pin) { return UInt8(HID_KEY_SPACE) }
return nil
}
まとめ
Embedded SwiftとRaspberry Pi Picoでキーボードを作ってみました
自作キーボードをプログラムから作成したなと思っていた、iOSアプリのエンジニアさんなどいらっしゃいましたら、ぜひチャレンジして頂けると嬉しいです