[Перевод] Sherbet: эргономичная игровая клавиатура
Перевод статьи из блога самодельщика Billiam
Через некоторое время после того, как мою Logitech G13 перестали выпускать, она у меня сломалась, и я решил разработать для неё замену, которую назвал Sherbet.
Сначала — что получилось:
Клавиатура с джойстиком
Файлы для печати и инструкция по сборке: www.prusaprinters.org/prints/5072-sherbet-gaming-keypad
Проектирование
Мне хотелось сделать аналоговый джойстик под большой палец, как и у G13, а ещё я решил включить в проект несколько эргономических улучшений от других клавиатур — Dactyl keyboard, Dactyl Manuform, Kinesis Advantage и Ergodox. Конкретно — смещение клавиш от вертикали, смещения по высоте, кривизна столбцов и более удобный наклон.
Я выбрал переключатели клавиатуры с малым ходом (линейные Kailh Choc от NovelKeys), чтобы уменьшить высоту клавиатуры — в частности потому, что мой стол выше, чем нужно для комфортабельного клавиатурного набора, а под ним расположена большая полка. Между полкой и столешницей остаётся порядка 10 см, как для клавиатуры, так и для кисти. Если с местом у вас проблем нет, рекомендую более совместимый переключатель — тогда у вас не будет проблем с выбором кнопок для него. Также рекомендую начать с Dactyl или Dactyl-Manuform, поскольку этот проект отнял у меня гораздо больше времени и сил, чем я мог бы предположить.
Я начал с моделирования клавиш на основе спецификаций от Kailh для переключателей ещё до того, как они ко мне пришли, потом попытался найти удобную кривизну столбца, распечатал пару тестовых версий, а потом распечатал ещё несколько для проверки отступов по вертикали. Вот, что у меня получилось:
Подбор радиусов изгиба колонок
Подбор раскладки и высоты колонки
Проектирование клавиш, вид на ¾
Вид сверху, видно сдвиг колонок
Вид спереди, видно разброс колонок по высоте
Выбрав схему, я начал проектировать разъёмы для переключателей, из которых должна получится главная опорная пластина.
Модель пластины для клавиш
Первая печать после удаления и очистки опор
Пластина с переключателями
После добавления клавиш
Я подбирал различные углы наклона клавиатуры, и остановился на угле порядка 20 градусов. И это опять было сделано для того, чтобы над клавиатурой осталось место. Удобнее было бы сделать угол чуть побольше, однако это всё равно удобнее плоской G13 и моей текущей эргономической клавиатуры. В Fusion 360 я сделал угол наклона изменяемым, так, чтобы весь остальной проект подстраивался под него. После усложнения проекта этот параметр уже нельзя было настраивать, не поломав других.
Пластина с распечатанной опорой для выбора угла наклона
Подставка для кисти
Затем я начал работу над подставкой для кисти. Мне нужна была удобная подставка, подходящая для моей руки, и я хотел сделать для неё литейную форму. Я слепил подставку из полимерной глины, потом использовал фотограмметрический пакет Meshroom (и кучу фотографий) для создания 3D-модели, а потом масштабировал её так, чтобы её размер совпал с оригиналом.
Грубое приближение к подставке при помощи полимерной глины
Много фоток
Подель подставки с текстурами из Meshroom
Модель, импортированная в Fusion 360
Затем я просто прошёлся по главным контурам модели, сгладил её и получил нужный оттиск:
Отсканированная модель с наложенной 3D-моделью
Модель из полимерной глины рядом со сглаженной и распечатанной версией
Этим было интересно заниматься, и результат получился удобным, однако потом я обнаружил, что во время печати все эти изгибы мешают двигаться руке, поэтому более поздние варианты все плоские. Ну, что ж…
Корпус
Затем я начал работать над общим корпусом устройства, и это оказалось наиболее трудным этапом из всех. Я до сих пор неуверенно работаю в CAD, и ничего такого сложного ранее не делал. Попытки найти способы описать составные кривые и соединить две поверхности оказались очень сложными, и заняли большую часть времени в этом проекте.
Компьютерная модель корпуса вместе с джойстиком от Arduino и кнопками под большой палец.
Также я нашёл для себя более удобное окружение для рендеринга, в результате чего изображения стали лучше.
Повторно сделанная компьютерная модель корпуса
Компьютерная модель корпуса, ракурс в ¾
Я распечатал только область для большого пальца, чтобы проверить её удобство и расположение. Всё оказалось нормально, однако модуль джойстика оказался слишком объёмным, чтобы его можно было разместить именно там, где это было бы наилучшим с точки зрения эргономики.
Распечатанный джойстик с кнопками
Вместо него я приобрёл гораздо меньший по размеру контроллер Joy-Con для Nintendo Switch от стороннего производителя, который можно разместить гораздо ближе к клавишам, и ещё останется место для подсоединения. Он соединяется с (менее удобным) плоским кабелем на 5 проводов 0,5 мм. Интерфейсную плату на 6 контактов я взял на Amazon, но гораздо дешевле брать их с eBay или AliExpress.
Сравнение джойстиков по размеру
Джойстик от Joy-Con с переключателями
Изменённый корпус для маленького джойстика
Закончив с оболочкой, я добавил поддержку для электронных компонентов. Для джойстика нужно было сделать небольшую защитную панель, вкручивающуюся сбоку в корпус. Для микроконтроллера Teensy я сделал держатель, в который он просто плотно входит, и винты для него. Добавил места для пластиковых хомутов и отверстия на 4 мм для крепления подставки для кисти.
Также я использую интерфейс micro USB для главного USB-контроллера, чтобы микроконтроллер не изнашивался и не повреждался.
Внутренности корпуса
Думаю, на всю эту фазу проектирования ушёл в общей сложности месяц. Не буду пытаться угадать количество ушедших на неё часов — скажем, что их было «много».
Печать
После того, как я потратил так много времени на проектирование и испытания, мне показалось, что окончательная печать проекта прошла скучно и без происшествий.
Печать с разрешением в 0,2 мм на Maker Select Plus заняла 15 часов
Постобработка: удаление подпорок и очистка. Повреждения оказались небольшими.
Нижняя часть корпуса с установленной электроникой
Крышку я тоже напечатал белым пластиком, и приклеил к ней пробковое покрытие. Для соединения крышки с корпусом использую винты M3 и ответную резьбовую муфту, вставляемую в пластик при помощи разогрева.
Крышка с нескользящим пробковым покрытием
Покраска
Во время проектирования я перебрал несколько цветовых решений, и в итоге остановился на подобном. Вариантов цветов не очень много, поскольку клавиши бывают только чёрными и белыми.
Проект раскраски
Для финальной отделки я сначала отшлифовал деталь наждачкой на 220, чтобы сгладить полоски от слоёв и другие проблемы, а потом покрыл всё грунтовкой.
Первый слой грунта
После грунтовки я использовал шпаклёвку от Bondo, отшлифовал деталь (наждачка 220 и 600), потом опять покрыл грунтом, шпаклёвкой, и снова отшлифовал.
После двух проходов грунтом и шпаклёвкой, и упорной шлифовки
Ещё один слой, белым грунтом
Полоску я сделал при помощи тонкой виниловой плёнки, а потом покрыл это место розовым цветом из распылителя.
Корпус и остатки краски
После покраски я покрыл деталь 4–5 слоями глянцевого покрытия, а потом шлифанул наждачкой на 1200, чтобы убрать пыль, пух и жучков, а потом снова покрыл лаком.
Выглядит неплохо, однако видны шороховатости и пятна излишков лака. Самые плохие места я немного шлифанул наждачкой на 1200, а потом отполировал специальным составом.
После шлифовки и полировки
Сборка
Пупырышки для ориентации вслепую я сделал, вдавив в клавиши керамические шарики от подшипников на 1,5 мм. На фото видно одно слишком крупное отверстие и одно слишком мелкое, в которое я вдавил шарик (без клея). Не знаю, было бы лучше вставить его в крупное отверстие с клеем, чем деформировать пластик, впихивая туда шарик.
Когда я исчерпал все задачи, которые помогли бы мне заниматься прокрастинацией и далее, я занялся подключением проводов, начав с рядов и столбцов клавиш.
Тоньше проводов в местных магазинах я не нашёл, и единственный провод с одним сердечником, который продавался, был на 22 awg [сечение 0,325 мм.кв. / прим. перев.], который было бы слишком тяжело загибать вокруг смещений столбцов и запихивать в небольшое пространство между корпусом и переключателями. Вместо него я использовал многожильный провод на 28 awg [сечение 0,089 мм.кв. / прим. перев.], который надёргал из плоского кабеля, зачистил при помощи инструмента для зачистки проводов, а потом сделал петельки на концах. С более тонким одножильным кабелем всё было бы проще.
Переключатели клавиш со спаянными рядами и столбцами
Ряды и столбцы соединены с плоским кабелем
Подсоединены ряды, столбцы, джойстик и интерфейсная плата USB
Подставку под кисть я сделал крепящейся на два винта М4, которые ввинчиваются в муфту, вставленную в пластик основного корпуса разогревом. После установки муфт получилось, что отверстия не очень совпадают, поэтому пока я их собрать не могу. Планирую перепечатать корпус с отверстием, в которое будет вставляться гайка М4, её будет проще выровнять.
На практике требуется как-то прочнее закрепить клавиатуру на столе. Даже с пробковым дном и дополнительным весом клавиатура сдвигается при использовании джойстика.
PS: Я переделал и перепечатал подставку так, чтобы она использовала две гайки М4, и всё работает нормально.
Готово!
Готовый корпус и временная подставка под кисть
Корпус, вид снизу
Прошивка
Изначально я планировал использовать в качестве прошивки QMK, используя пока ещё не вошедший в основную ветку пул-реквест с поддержкой джойстика. Однако QMK не очень хорошо поддерживает новые контроллеры ARM Teensy (версии больше 3.2). Следующий патч пока не поддерживает контроллеры ARM.
Если этот патч будет реализован, как и поддержка ARM, я закончу и опубликую вариант с QMK. А пока я набросал скетч для Arduino, на базе чьей-то работы.
У него есть два режима, один — стандартная раскладка QWERTY и джойстик с одной кнопкой, а второй — где все клавиши назначены на кнопки джойстика. В итоге хватает кнопок для настройки конфигуратора контроллера Steam, и его можно использовать как устройство XInput с поддержкой более широкого спектра игр.
#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;
}