[Перевод] Sherbet: эргономичная игровая клавиатура

Перевод статьи из блога самодельщика Billiam

Через некоторое время после того, как мою Logitech G13 перестали выпускать, она у меня сломалась, и я решил разработать для неё замену, которую назвал Sherbet.

Сначала — что получилось:

5fe9be90508fd2dc4fca7ae27a53485c.jpg
Клавиатура с джойстиком

Файлы для печати и инструкция по сборке: www.prusaprinters.org/prints/5072-sherbet-gaming-keypad

Проектирование


Мне хотелось сделать аналоговый джойстик под большой палец, как и у G13, а ещё я решил включить в проект несколько эргономических улучшений от других клавиатур — Dactyl keyboard, Dactyl Manuform, Kinesis Advantage и Ergodox. Конкретно — смещение клавиш от вертикали, смещения по высоте, кривизна столбцов и более удобный наклон.
Я выбрал переключатели клавиатуры с малым ходом (линейные Kailh Choc от NovelKeys), чтобы уменьшить высоту клавиатуры — в частности потому, что мой стол выше, чем нужно для комфортабельного клавиатурного набора, а под ним расположена большая полка. Между полкой и столешницей остаётся порядка 10 см, как для клавиатуры, так и для кисти. Если с местом у вас проблем нет, рекомендую более совместимый переключатель — тогда у вас не будет проблем с выбором кнопок для него. Также рекомендую начать с Dactyl или Dactyl-Manuform, поскольку этот проект отнял у меня гораздо больше времени и сил, чем я мог бы предположить.

Я начал с моделирования клавиш на основе спецификаций от Kailh для переключателей ещё до того, как они ко мне пришли, потом попытался найти удобную кривизну столбца, распечатал пару тестовых версий, а потом распечатал ещё несколько для проверки отступов по вертикали. Вот, что у меня получилось:

a9caca4f772cdeb5d31b65295d4292fa.jpg
Подбор радиусов изгиба колонок

53ae4ccb6f44518c645658e3f0178c8e.jpg
Подбор раскладки и высоты колонки

9a6390d2852d97f1ccfcbb4ba324395d.png
Проектирование клавиш, вид на ¾

3b78d19dd6d244f1203571677d9fc8ec.png
Вид сверху, видно сдвиг колонок

15738362be99cba91001aa60f72428ef.png
Вид спереди, видно разброс колонок по высоте

Выбрав схему, я начал проектировать разъёмы для переключателей, из которых должна получится главная опорная пластина.

3c243b7deb0904664f2a1f88d248bb44.png
Модель пластины для клавиш

41a67494c3724c0b5fe4c63ff33ff6e8.jpg
Первая печать после удаления и очистки опор

c16a716ca177c4bd116bfdc04a1378ce.jpg
Пластина с переключателями

5670b337a8e3d762decae74de75c30a8.jpg
После добавления клавиш

Я подбирал различные углы наклона клавиатуры, и остановился на угле порядка 20 градусов. И это опять было сделано для того, чтобы над клавиатурой осталось место. Удобнее было бы сделать угол чуть побольше, однако это всё равно удобнее плоской G13 и моей текущей эргономической клавиатуры. В Fusion 360 я сделал угол наклона изменяемым, так, чтобы весь остальной проект подстраивался под него. После усложнения проекта этот параметр уже нельзя было настраивать, не поломав других.

0bc59fd91d64431fcfe883f20090274e.jpg
Пластина с распечатанной опорой для выбора угла наклона

Подставка для кисти


Затем я начал работу над подставкой для кисти. Мне нужна была удобная подставка, подходящая для моей руки, и я хотел сделать для неё литейную форму. Я слепил подставку из полимерной глины, потом использовал фотограмметрический пакет Meshroom (и кучу фотографий) для создания 3D-модели, а потом масштабировал её так, чтобы её размер совпал с оригиналом.

032b1a914fff07c3cf4b62f0659cf938.jpg
Грубое приближение к подставке при помощи полимерной глины

716b1ddc2138be5c425d37a68737e0b8.gif
Много фоток

06b9126eef78fe036679db727e99b980.png
Подель подставки с текстурами из Meshroom

3ef3875fc3cdf50aa014b7fdd399c620.png
Модель, импортированная в Fusion 360

Затем я просто прошёлся по главным контурам модели, сгладил её и получил нужный оттиск:

d91201053695fca1c28db81484dcfb7c.png
Отсканированная модель с наложенной 3D-моделью

804f861f4c44c87139755fbbb61b467b.jpg
Модель из полимерной глины рядом со сглаженной и распечатанной версией

Этим было интересно заниматься, и результат получился удобным, однако потом я обнаружил, что во время печати все эти изгибы мешают двигаться руке, поэтому более поздние варианты все плоские. Ну, что ж…

Корпус


Затем я начал работать над общим корпусом устройства, и это оказалось наиболее трудным этапом из всех. Я до сих пор неуверенно работаю в CAD, и ничего такого сложного ранее не делал. Попытки найти способы описать составные кривые и соединить две поверхности оказались очень сложными, и заняли большую часть времени в этом проекте.

4268c460505c8418b5dc6f2714ce5133.jpg
Компьютерная модель корпуса вместе с джойстиком от Arduino и кнопками под большой палец.

Также я нашёл для себя более удобное окружение для рендеринга, в результате чего изображения стали лучше.

377416165dba6fe31639706e58668f04.jpg
Повторно сделанная компьютерная модель корпуса

f67d026b0f028c20643d02ed2e2a1a16.jpg
Компьютерная модель корпуса, ракурс в ¾

Я распечатал только область для большого пальца, чтобы проверить её удобство и расположение. Всё оказалось нормально, однако модуль джойстика оказался слишком объёмным, чтобы его можно было разместить именно там, где это было бы наилучшим с точки зрения эргономики.

a90bccaf58b1ee665453369a43c5055e.jpg
Распечатанный джойстик с кнопками

Вместо него я приобрёл гораздо меньший по размеру контроллер Joy-Con для Nintendo Switch от стороннего производителя, который можно разместить гораздо ближе к клавишам, и ещё останется место для подсоединения. Он соединяется с (менее удобным) плоским кабелем на 5 проводов 0,5 мм. Интерфейсную плату на 6 контактов я взял на Amazon, но гораздо дешевле брать их с eBay или AliExpress.

d60e9ba5d34936b32a772cde4c56bf14.jpg
Сравнение джойстиков по размеру

683ee00692c1faa16648961a4fa2d27a.jpg
Джойстик от Joy-Con с переключателями

b65d3ab402644fb0b669ffaee067a341.jpg
Изменённый корпус для маленького джойстика

Закончив с оболочкой, я добавил поддержку для электронных компонентов. Для джойстика нужно было сделать небольшую защитную панель, вкручивающуюся сбоку в корпус. Для микроконтроллера Teensy я сделал держатель, в который он просто плотно входит, и винты для него. Добавил места для пластиковых хомутов и отверстия на 4 мм для крепления подставки для кисти.

Также я использую интерфейс micro USB для главного USB-контроллера, чтобы микроконтроллер не изнашивался и не повреждался.

65d36bfc3323afd5c75ca7322bf624fd.png
Внутренности корпуса

Думаю, на всю эту фазу проектирования ушёл в общей сложности месяц. Не буду пытаться угадать количество ушедших на неё часов — скажем, что их было «много».

Печать


После того, как я потратил так много времени на проектирование и испытания, мне показалось, что окончательная печать проекта прошла скучно и без происшествий.

61b4ddaee9f35360c5138142a9ed4f84.jpg
Печать с разрешением в 0,2 мм на Maker Select Plus заняла 15 часов

16eab0459d1192864fbae29e6c08781b.jpg
Постобработка: удаление подпорок и очистка. Повреждения оказались небольшими.

7456b1a4d8d987a557735a4ef6d673c9.jpg
Нижняя часть корпуса с установленной электроникой

Крышку я тоже напечатал белым пластиком, и приклеил к ней пробковое покрытие. Для соединения крышки с корпусом использую винты M3 и ответную резьбовую муфту, вставляемую в пластик при помощи разогрева.

de4ae5d592abc9997a2fcfb993ab7f30.jpg
Крышка с нескользящим пробковым покрытием

Покраска


Во время проектирования я перебрал несколько цветовых решений, и в итоге остановился на подобном. Вариантов цветов не очень много, поскольку клавиши бывают только чёрными и белыми.

994fa855fc81963bdb1860cf0b3dc07c.jpg
Проект раскраски

Для финальной отделки я сначала отшлифовал деталь наждачкой на 220, чтобы сгладить полоски от слоёв и другие проблемы, а потом покрыл всё грунтовкой.

2f2946923720feca63e29f332dc791ac.jpg
Первый слой грунта

После грунтовки я использовал шпаклёвку от Bondo, отшлифовал деталь (наждачка 220 и 600), потом опять покрыл грунтом, шпаклёвкой, и снова отшлифовал.

86266a807a16d878d64564d5a2d41637.jpg
После двух проходов грунтом и шпаклёвкой, и упорной шлифовки

b4a9211ebb575abda6789040fbac84a2.jpg
Ещё один слой, белым грунтом

Полоску я сделал при помощи тонкой виниловой плёнки, а потом покрыл это место розовым цветом из распылителя.

b936233872bdce5af77c8a9e3756104f.jpg
Корпус и остатки краски

После покраски я покрыл деталь 4–5 слоями глянцевого покрытия, а потом шлифанул наждачкой на 1200, чтобы убрать пыль, пух и жучков, а потом снова покрыл лаком.

Выглядит неплохо, однако видны шороховатости и пятна излишков лака. Самые плохие места я немного шлифанул наждачкой на 1200, а потом отполировал специальным составом.

344295c1290a422d2ae4ac78b9ed67c8.jpg
После шлифовки и полировки

Сборка


Пупырышки для ориентации вслепую я сделал, вдавив в клавиши керамические шарики от подшипников на 1,5 мм. На фото видно одно слишком крупное отверстие и одно слишком мелкое, в которое я вдавил шарик (без клея). Не знаю, было бы лучше вставить его в крупное отверстие с клеем, чем деформировать пластик, впихивая туда шарик.

74af158a097bd1b6a8cc2a0afa972d0e.jpg

Когда я исчерпал все задачи, которые помогли бы мне заниматься прокрастинацией и далее, я занялся подключением проводов, начав с рядов и столбцов клавиш.

Тоньше проводов в местных магазинах я не нашёл, и единственный провод с одним сердечником, который продавался, был на 22 awg [сечение 0,325 мм.кв. / прим. перев.], который было бы слишком тяжело загибать вокруг смещений столбцов и запихивать в небольшое пространство между корпусом и переключателями. Вместо него я использовал многожильный провод на 28 awg [сечение 0,089 мм.кв. / прим. перев.], который надёргал из плоского кабеля, зачистил при помощи инструмента для зачистки проводов, а потом сделал петельки на концах. С более тонким одножильным кабелем всё было бы проще.

05fe2380755538ccde6f4e23cb3117e4.jpg
Переключатели клавиш со спаянными рядами и столбцами

938f91ed1b41695eea20df2d9139bd7f.jpg
Ряды и столбцы соединены с плоским кабелем

5910e7077b3bfc2c14cc4a75c21a35a5.jpg
Подсоединены ряды, столбцы, джойстик и интерфейсная плата USB

Подставку под кисть я сделал крепящейся на два винта М4, которые ввинчиваются в муфту, вставленную в пластик основного корпуса разогревом. После установки муфт получилось, что отверстия не очень совпадают, поэтому пока я их собрать не могу. Планирую перепечатать корпус с отверстием, в которое будет вставляться гайка М4, её будет проще выровнять.

На практике требуется как-то прочнее закрепить клавиатуру на столе. Даже с пробковым дном и дополнительным весом клавиатура сдвигается при использовании джойстика.

PS: Я переделал и перепечатал подставку так, чтобы она использовала две гайки М4, и всё работает нормально.

Готово!


9deaace036b1d73e1c1ae1333c5fc3b6.jpg
Готовый корпус и временная подставка под кисть

c70a5c32eb4c9c7c6515609b454cbb0e.jpg
Корпус, вид снизу

Прошивка


Изначально я планировал использовать в качестве прошивки QMK, используя пока ещё не вошедший в основную ветку пул-реквест с поддержкой джойстика. Однако QMK не очень хорошо поддерживает новые контроллеры ARM Teensy (версии больше 3.2). Следующий патч пока не поддерживает контроллеры ARM.

Если этот патч будет реализован, как и поддержка ARM, я закончу и опубликую вариант с QMK. А пока я набросал скетч для Arduino, на базе чьей-то работы.

У него есть два режима, один — стандартная раскладка QWERTY и джойстик с одной кнопкой, а второй — где все клавиши назначены на кнопки джойстика. В итоге хватает кнопок для настройки конфигуратора контроллера Steam, и его можно использовать как устройство XInput с поддержкой более широкого спектра игр.

Необязательный код, позволяющий дать устройству имя
// Необязательный код, позволяющий дать устройству имя. Поместите в той же директории, что и sherbet.ino.
#include «usb_names.h»

#define PRODUCT_NAME {'s', 'h', 'e', 'r', 'b', 'e', 't'}
#define PRODUCT_NAME_LEN 7

struct usb_string_descriptor_struct usb_string_product_name = {
2 + PRODUCT_NAME_LEN * 2,
3,
PRODUCT_NAME
};

Прошивка
/*
Original programming by Stefan Jakobsson, 2019
Released to public domain
forum.pjrc.com/threads/55395-Keyboard-simple-firmware
*/
/*
Copyright 2019 Colin Fein
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the «Software»), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED «AS IS», WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

// Use USB Type: Keybord+Mouse+Joystick

#include

const int ROW_COUNT = 4; //Number of rows in the keyboard matrix
const int COL_COUNT = 6; //Number of columns in the keyboard matrix

const int DEBOUNCE = 5; //Adjust as needed: increase if bouncing problem, decrease if not all keypresses register; not less than 2
const int SCAN_DELAY = 5; //Delay between scan cycles in ms

const int JOYSTICK_X_PIN = 14; // Analog pin used for the X axis
const int JOYSTICK_Y_PIN = 15; // Analog pin used for the Y axis
const bool REVERSE_X = true; // Reverses X axis input
const bool REVERSE_Y = true; // Reverses Y axis input
const int MIN_X = 215; // Minimum range for the X axis
const int MAX_X = 780; // Maxixmum range for the X axis
const int MIN_Y = 280; // Minimum range for the Y axis
const int MAX_Y = 815; // Maximum range for the Y axis
const int BUTTON_COUNT = 1; // Number of joystick buttons

const int JOY_MIN = 0;
const int JOY_MAX = 1023;

Bounce buttons[BUTTON_COUNT];
Bounce switches[ROW_COUNT * COL_COUNT];

boolean buttonStatus[ROW_COUNT * COL_COUNT + BUTTON_COUNT]; //store button status so that inputs can be released
boolean keyStatus[ROW_COUNT * COL_COUNT]; //store keyboard status so that keys can be released

const int rowPins[] = {3, 2, 1, 0}; //Teensy pins attached to matrix rows
const int colPins[] = {11, 10, 9, 8, 7, 6}; //Teensy pins attached to matrix columns
const int buttonPins[] = {12}; //Teensy pins attached directly to switches

int axes[] = {512, 512};

int keyMode = true; // Whether to begin in standard qwerty mode or joystick button mode

// Keycodes for qwerty input
const int layer_rows[] = {
KEY_ESC, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5,
KEY_TAB, KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T,
KEY_CAPS_LOCK, KEY_A, KEY_S, KEY_D, KEY_F, KEY_G,
MODIFIERKEY_SHIFT, KEY_Z, KEY_X, KEY_C, KEY_V, KEY_B
};
// keystroke to use (counting from top left to top right of keypad) to switch between standard qwerty input and joystick buttons
// default uses B+5
const int mode_swap_keystroke[2] = {23, 5};
int pivoted_keystroke[2]; //rows to columns
boolean keystrokeModifier = false; //whether beginning of keystroke is active

// rows to columns
int layer[ROW_COUNT * COL_COUNT];

void setup () {
int i;
//pivot key array for row-to-column diodes
for (i = 0; i < ROW_COUNT * COL_COUNT; i++) {
layer[rotateIndex (i)] = layer_rows[i];

// create debouncers for row pins
Bounce debouncer = Bounce ();
debouncer.attach (rowPins[i % ROW_COUNT]);
debouncer.interval (DEBOUNCE);
switches[i] = debouncer;
}

//convert keystroke to (pivoted) indexes
for (i = 0; i < 2; i++) {
pivoted_keystroke[i] = rotateIndex (mode_swap_keystroke[i]);
}

// create debouncers for non-matrix input pins
for (i = 0; i < BUTTON_COUNT; i++) {
Bounce debouncer = Bounce ();

debouncer.attach (buttonPins[i], INPUT_PULLUP);
debouncer.interval (DEBOUNCE);
buttons[i] = debouncer;
}

// Ground first column pin
pinMode (colPins[0], OUTPUT);
digitalWrite (colPins[0], LOW);

for (i = 1; i < COL_COUNT; i++) {
pinMode (colPins[i], INPUT);
}

//Row pins
for (i = 0; i < ROW_COUNT; i++) {
pinMode (rowPins[i], INPUT_PULLUP);
}
}

void loop () {
scanMatrix ();
scanJoy ();
delay (SCAN_DELAY);
}

/*
Scan keyboard matrix, triggering press and release events
*/
void scanMatrix () {
int i;

for (i = 0; i < ROW_COUNT * COL_COUNT; i++) {
prepareMatrixRead (i);
switches[i].update ();

if (switches[i].fell ()) {
matrixPress (i);
} else if (switches[i].rose ()) {
matrixRelease (i);
}
}
}

/*
Scan physical, non-matrix joystick buttons
*/
void scanJoy () {
int i;
boolean anyChange = false;

for (i=0; i < BUTTON_COUNT; i++) {
buttons[i].update ();
if (buttons[i].fell ()) {
buttonPress (i);
anyChange = true;
} else if (buttons[i].rose ()) {
buttonRelease (i);
anyChange = true;
}
}

int x = getJoyDeflection (JOYSTICK_X_PIN, REVERSE_X, MIN_X, MAX_X);
int y = getJoyDeflection (JOYSTICK_Y_PIN, REVERSE_Y, MIN_Y, MAX_Y);
Joystick.X (x);
Joystick.Y (y);

if (x!= axes[0] || y!= axes[y]) {
anyChange = true;

axes[0] = x;
axes[1] = y;
}

if (anyChange) {
Joystick.send_now ();
}
}

/*
Return a remapped and clamped analog value
*/
int getJoyDeflection (int pin, boolean reverse, int min, int max) {
int input = analogRead (pin);
if (reverse) {
input = JOY_MAX — input;
}

return map (constrain (input, min, max), min, max, JOY_MIN, JOY_MAX);
}
/*
Returns input pin to be read by keyScan method
Param key is the keyboard matrix scan code (col * ROW_COUNT + row)
*/
void prepareMatrixRead (int key) {
static int currentCol = 0;
int p = key / ROW_COUNT;

if (p!= currentCol) {
pinMode (colPins[currentCol], INPUT);
pinMode (colPins[p], OUTPUT);
digitalWrite (colPins[p], LOW);
currentCol = p;
}
}

/*
Sends key press event
Param keyCode is the keyboard matrix scan code (col * ROW_COUNT + row)
*/
void matrixPress (int keyCode) {
if (keyMode) {
keyPress (keyCode);
} else {
buttonPress (BUTTON_COUNT + keyCode);
}

keystrokePress (keyCode);
}

/*
Sends key release event
Param keyCode is the keyboard matrix scan code (col * ROW_COUNT + row)
*/
void matrixRelease (int keyCode) {
//TODO: Possibly do not trigger keyboard.release if key not already pressed (due to changing modes)
if (keyMode) {
keyRelease (keyCode);
} else {
buttonRelease (BUTTON_COUNT + keyCode);
}

keystrokeRelease (keyCode);
}

/*
Send key press event
*/
void keyPress (int keyCode) {
Keyboard.press (layer[keyCode]);
keyStatus[keyCode]=true;
}

/*
Send key release event
*/
void keyRelease (int keyCode) {
Keyboard.release (layer[keyCode]);
keyStatus[keyCode]=false;
}

/*
Send joystick button press event
Param buttonId 0-indexed button ID
*/
void buttonPress (int buttonId) {
Joystick.button (buttonId + 1, 1);
buttonStatus[buttonId] = true;
}

/*
Send joystick button release event
Param buttonId 0-indexed button ID
*/
void buttonRelease (int buttonId) {
Joystick.button (buttonId + 1, 0);
buttonStatus[buttonId] = false;
}

/*
Listen for keystroke keys, and change keyboard mode when condition is met
*/
void keystrokePress (int keyCode) {
if (keyCode == pivoted_keystroke[0]) {
keystrokeModifier = true;
} else if (keystrokeModifier && keyCode == pivoted_keystroke[1]) {
releaseLayer ();
keyMode = ! keyMode;
}
}

/*
Listen for keystroke key release, unsetting keystroke flag
*/
void keystrokeRelease (int keyCode) {
if (keyCode == pivoted_keystroke[0]) {
keystrokeModifier = false;
}
}

/*
Releases all matrix and non-matrix keys; called upon change of key mode
*/
void releaseLayer () {
int i;
for (i = 0; i < ROW_COUNT * COL_COUNT; i++) {
matrixRelease (i);
}

for (i=0; i < BUTTON_COUNT; i++) {
if (buttonStatus[i]) {
buttonRelease (i);
}
}
}

/*
Converts an index in a row-first sequence to column-first
[1, 2, 3] [1, 4, 7]
[4, 5, 6] => [2, 5, 8]
[7, 8, 9] [3, 6, 9]
*/
int rotateIndex (int index) {
return index % COL_COUNT * ROW_COUNT + index / COL_COUNT;
}

© Habrahabr.ru