Как стать автором
Обновить
134.44
Beget
Beget — международный облачный провайдер

Своя эргономичная клавиатура: 3D-печать, пайка и немного самописной прошивки

Уровень сложностиПростой
Время на прочтение24 мин
Количество просмотров3.8K

Зима 2024, в процессе думскроллинга фида реддита я в очередной раз натыкаюсь на пост про hand‑wired эргономичную клавиатуру. В целом я не испытывал особого дискомфорта при использовании обычных клавиатур, но сама концепция привлекала — возможно, своей относительной экзотичностью на фоне «традиционных» клавиатур. Готовые клавиатуры такого формата есть в продаже, но эта опция была быстро отброшена — банально дорого — дешевых вариантов на тот момент особо не было. Прикинув, что до отпуска осталось не так и долго я загорелся идеей — а почему бы, собственно, не попробовать сделать подобную клавиатуру в свободное время?

Выбор формата и закупка комплектующих

Для создания корпуса решил обратиться к 3D‑печати. При выборе модели было два варианта — пытаться придумать что‑то самому, либо обратиться к готовым вариантам. В целом существует большое количество вариаций эрго клавиатур — Dactyl Manuform, Corne, Ferris Sweep, Skeletyl и не только — в общем, есть где разгуляться. Решив, что для первого эксперимента новых ощущений и шишек будет достаточно и без разработки 3D‑модели самому, я выбрал Dactyl Manuform в формате 5×6 — вогнутая структура обещала быть удобной в использовании, а достаточное количество клавиш — избежать излишних танцев со слоями, упрощая процесс адаптации к клавиатуре.

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

По итогу прошлых двух пунктов была найдена подходящая 3D‑модель на thingiverse.

Поскольку в рамках этого проекта мне также хотелось попробовать Rust, имело смысл выбирать MCU с хорошей поддержкой — выбор пал на STM32F411CEU6. Для stm32 есть как обычные HAL, так и готовая библиотека embassy‑stm32, да и ценник у них приемлемый. Но стоит оговориться, что скорее всего выбранные платы — оверкилл и STM32F401 было бы более чем достаточно.

В плане свичей долго выбирать не пришлось — в моей клавиатуре на тот момент стояли Outemu Cream Yellow Pro V2 — тихие тактильные свичи, которые меня полностью устраивали, так что они же и отправились в новую клавиатуру.

Ну и также нужны были диоды, провода, а также винты M3 и втулки к ним. Итого были заказаны:

  • 3 платы с STM32F411CEU6 от WeAct — одна про запас на всякий случай;

  • ST‑LINK;

  • 10 метров провода двух цветов;

  • 100 диодов 1N4148;

  • 2 коннектора GX-16;

  • набор втулок M3;

  • набор винтов M3.

Также этот проект стал для меня оправданием наконец купить 3D‑принтер — выбор пал на Anycubic Kobra 2 Neo. Вдобавок был куплен PETG‑пластик двух цветов — прозрачного и черного. В ретроспективе — обычный PLA вполне подошел бы, при этом не пришлось бы так сильно заморачиваться с просушкой, ну да ладно.

Печать и пайка

Первыми приехали принтер и пластик, что мне было на руку — все‑таки сам процесс печати крупных моделей не быстрый. Собрав и откалибровав принтер, нарезал модели в Cura и запустил печать одной из сторон.

Из‑за того, что пластик не был достаточно просушен, вышло то еще гнездо Шелоб, на зачистку которого пришлось потратить немало времени. Первый блин, все дела. За время печати успели дойти остальные детали, так что, закончив с зачисткой до состояния «и тааак сойдет» я установил свичи и припаял диоды:

После чего соединяю ряды и столбцы матрицы, получившееся чудо припаивается к тестовому контроллеру, на который были припаяны «ноги» для «пробы пера»:

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

Трансплантация свичей и прочей электроники была выполнена успешно, сразу же были припаяны коннекторы для соединения частей. В процессе сборки понимаю, что удобно использовать USB‑коннектор на плате не удастся, так что докупил небольшие удлинители USB‑C, а также смоделировал переходники для них и отправил в печать:

После небольшого напилинга всех участников процесса удлинители USB‑C были успешно установлены, также были припаяны оставшиеся 2 контроллера уже без ног (нумерацию пинов матрицы выписываю в заметки — эта инфа еще понадобится потом). Ну и вплавляю втулки для соединения корпуса с дном:

Некое подобие выводов

Не могу сказать, что итоговый результат получился сильно элегантным, но как минимум он уже походил на клавиатуру. Безусловно, результат вышел бы красивее, если бы я потратил больше времени на обработку пластика после печати, да и в целом выбрал более податливый в этом плане материал по сравнению с PETG. Но энтузиазм на тот момент победил терпение — хотелось как можно быстрее перейти к самому интересному — заставить кусок кремния делать то, что ты от него хочешь. В ретроспективе — стоило не торопиться и уделить обработке модели больше времени.

Большого опыта пайки у меня нет, что не сильно упрощалось купленными проводами — скорее всего собирать матрицу на одножильном кабеле было бы проще как с точки зрения его зачистки, так и самой пайки. А вот припаивание диодов хоть и стало испытанием терпения, оказалось не так сложно, как ожидалось.

Теперь для работы клавиатуры не хватало только одного — прошивки, а значит пришло время потрогать ручками embedded‑программирование. В качестве подопытного языка был выбран Rust — просто потому что хотелось попробовать, да и готовый HAL для STM32 уже имеется.

Дисклеймер

Автор не является профессиональным разработчиком, показанный далее код может вызвать дискомфорт. Я вас предупредил. И да, хоть я и выбрал Rust с целью «погружения», в коде будут использоваться готовые библиотеки. В основном из‑за того, что мне все же хотелось закончить проект за 2 недели отпуска.

Ну что же, начнем окисление.

Подготовка среды

«За кадром» я уже успел немного поиграться с контроллером, успешно запустив «мигающую лампочку», но все же пройдусь по сетапу. Разработка ведется на Linux, инструменты будут использоваться соответствующие.

Для дебага на тестовом чипе будет использоваться ST‑LINK в комбинации с probe-rs, на «продовую» клавиатуру уже будем заливать прошивку по dfu. Соответственно, помимо rust потребовалось установить:

  • probe-rs — для работы с STM‑Link

  • dfu-utils — для заливки по DFU

  • cargo-binutils — для преобразования прошивки из ELF в бинарный формат

  • target компиляции thumbv7em-none-eabihf

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

Клонирую репозиторий шаблона, немного прибираюсь и делаю init-commit.

Также я решил сразу поискать библиотеку для работы с USB — выбор пал на usbd_human_interface_device, поскольку у нее достаточно понятная документация и есть уже готовые коды клавиш.

Добавляю в Cargo.toml зависимости:

stm32f4xx-hal = { version = "0.21.0", features = ["stm32f411", "usb_fs"] }
usbd-human-interface-device = "0.5.0"
rtic = { version = "2.0.0", features = ["thumbv7-backend"] }

На этом можно начать программировать логику.

Подготовка почвы

В первую очередь я решил описать структуру модели данных клавиши. Для этого нужно подумать, а какой функционал нам вообще нужен? Поскольку клавиш меньше, чем на 100% клавиатуре, как минимум потребуется еще один слой, а значит нужно заложить альтернативное значение клавиши. Также нам потребуется хранить состояние клавиши (активна она или нет) и счетчик отскока (debounce). Создаю key.rs и задаю структуру:

#![deny(unsafe_code)]

use usbd_human_interface_device::page::Keyboard;

pub struct Key {
    keycode: Keyboard,
    keycode_alt: Keyboard,
    is_active: bool,
    debounce_count: u8,
}

Также нам понадобятся методы для задания состояния клавиши и управления счетчиком отскока:

impl Key {
    pub fn set_active(&mut self) {
        self.is_active = true;
    }
    pub fn set_inactive(&mut self) {
        self.is_active = false;
    }
    pub fn set_debounce(&mut self, count: u8) {
        self.debounce_count = count;
    }
    pub fn tick_debounce(&mut self) {
        self.debounce_count = self.debounce_count.saturating_sub(1)
    }
    pub fn get_state(&self) -> bool {
        self.is_active
    }
}

Также создаю layout.rs, в котором будет содержаться раскладка, пока что просто с TODO:

// TODO: Define a layout when the struct is ready

Оба модуля добавляю в lib.rs:

mod key;
mod layout;

На этом я решаю закончить, закоммитить уже написанное и пойти отдыхать.

Расширяю библиотеку

На следующий день я решил начать с описания структуры матрицы, которая будет хранить в себе клавиши. Для обхода матрицы будет удобно иметь вложенные массивы — массив рядов, каждый из которых будет содержать в себе набор клавиш. Создаю matrix.rs, в нем задаю константы количества строк и столбцов, а также тип Layer:

#![deny(unsafe_code)]
use crate::key::Key;

pub const ROWS: usize = 6;
pub const COLS: usize = 12;

pub type Layer = [[Key; COLS]; ROWS];

Немного поразмыслив, я прихожу к выводу, что гораздо удобнее будет иметь массив возможных значений клавиши в ее структуре, нежели создавать поля под альтернативные значения. Поэтому в matrix.rs добавляю константу количества слоев:

pub const LAYERS: usize = 2;

А в key.rs изменяю структуру, заменяя поля keycode и keycode_alt на массив:

pub struct Key {
    pub keycodes: [Keyboard; crate::matrix::LAYERS],
    pub is_active: bool,
    pub debounce_count: u8,
}

Теперь можно составить саму матрицу — в ней должен быть Layer и номер активного слоя:

pub struct Matrix {
    layout: Layer,
    active_layer: usize,
}

Также добавляю методы для работы с клавишами на основе координат и переключение активного слоя. Ну и заготовку для получения итератора активных клавиш:

impl Matrix {
    pub fn get_key(&self, row: usize, col: usize) -> &Key {
        &self.layout[row][col]
    }

    pub fn get_key_mut(&mut self, row: usize, col: usize) -> &mut Key {
        &mut self.layout[row][col]
    }

    pub fn set_active_layer(&mut self, layer_number: usize) {
        if layer_number < LAYERS {
            self.active_layer = layer_number;
        }
    }

    // TODO: Implement a function that returns IntoIterator<Item = Keyboard>
    pub fn report_active(&self) -> &[Keyboard] {
        unimplemented!();
    }
}

Для хоть какого‑то упрощения описания матрицы добавляю 2 макроса, пусть они и не особо элегантны. Я вполне допускаю, что есть гораздо более простой способ, но на тот момент я был уставший и ничего лучше в голову уже не шло. В key.rs превращаю массив возможных послойных значений клавиши в структуру Key:

#[macro_export]
macro_rules! create_key {
    ([ $($key:ident),+ ]) => {
        $crate::key::Key {
            keycodes: [$($key),+]
            is_active: false,
            debounce_count: 0,
        }
    };
}

В matrix.rs создаю Matrix из двумерного массив структур Key:

#[macro_export]
macro_rules! create_matrix {
    ( $([ $( [$(keycode:ident),+] ),+ ]),+ ) => {
        [$([$(create_key!([$($keycode),+])),+]),+]
    };
}

Ну и под конец добавляется вот такое непотребство в качестве раскладки клавиатуры в layout.rs. Особо впечатлительным не рекомендую смотреть.

Когда-нибудь я это переделаю
#[rustfmt::skip]
const LAYOUT: crate::matrix::Layer = crate::create_matrix!(
[[Grave,ErrorUndefine],[Keyboard1,F1],[Keyboard2,F2],[Keyboard3,F3],[Keyboard4,F4],[Keyboard5,F5],
    [Keyboard6,F6],[Keyboard7,F7],[Keyboard8, F8],[Keyboard9,F9],[Keyboard0,F10],[Minus,F11]],
[[Tab, ErrorUndefine],[Q,ErrorUndefine],[W,ErrorUndefine],[E,ErrorUndefine],[R,ErrorUndefine],[T,ErrorUndefine],
    [Y,ErrorUndefine],[U,ErrorUndefine],[I,ErrorUndefine],[O,ErrorUndefine],[P,ErrorUndefine],[Equal,F12]],
[[Escape,ErrorUndefine],[A,ErrorUndefine],[S,ErrorUndefine],[D,ErrorUndefine],[F,ErrorUndefine],[G,ErrorUndefine],
        [H,ErrorUndefine],[J,ErrorUndefine],[K,ErrorUndefine],[L,ErrorUndefine],[Semicolon,ErrorUndefine],[Apostrophe,ErrorUndefine]],
[[CapsLock,ErrorUndefine],[Z,ErrorUndefine],[X,ErrorUndefine],[C,ErrorUndefine],[V,ErrorUndefine],[B,ErrorUndefine],
        [N,ErrorUndefine],[M,ErrorUndefine],[Comma,ErrorUndefine],[Dot,ErrorUndefine],[ForwardSlash,ErrorUndefine],[Backslash,ErrorUndefine]],
[[PageDown, End],[PageUp,Home],[LeftShift,ErrorUndefine],[Space,ErrorUndefine],[LeftControl,ErrorUndefine],[F24,ErrorUndefine],
        [RightGUI,ErrorUndefine],[RightControl,ErrorUndefine],[ReturnEnter,ErrorUndefine],[RightShift,ErrorUndefine],[LeftBrace,ErrorUndefine],[RightBrace,ErrorUndefine]],
[[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[LeftAlt,ErrorUndefine],[DeleteBackspace,ErrorUndefine],
        [DeleteForward,ErrorUndefine],[RightAlt,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine],[ErrorUndefine,ErrorUndefine]]
);

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

Заканчиваю с библиотекой и перехожу к rtic

Следующие несколько дней я гулял по городу, — погода была хорошая, а желания притрагиваться к коду особо не было, но периодически все же обдумывал, куда двигаться дальше. Спустя почти неделю, я решил, что надо все же добить проект и сел за работу. Основной задачей на данный момент было придумать, как заставить две половины клавиатуры общаться между собой, а точнее — в какой форме передавать информацию. Немного поразмыслив, я решил что проще всего будет отправлять сообщения со слейва на мастер — это позволит, помимо передачи нажатия обычных клавиш, передавать и другие ивенты — например, изменение слоя. Пока решил ограничиться тремя — собственно, нажатие клавиши, зажатие кнопки переключения слоя и отсутствие действия — в качестве плейсхолдера. Для этого в key.rs добавляю enum ивентов, а также реализацию трейта From из ивента в Keyboard и обратно:

#[derive(Debug, Format, Clone)]
pub enum KeyEvent {
    LayerShiftHold,
    KeyCode(Keyboard),
    None,
}

impl From<Keyboard> for KeyEvent {
    fn from(value: Keyboard) -> Self {
        Self::Keycode(value)
    }
}

impl From<KeyEvent> for Keyboard {
    fn from(value: KeyEvent) -> Self {
        match value {
            KeyEvent::KeyCode(k) => k,
            KeyEvent::LayerShiftHold => Keyboard::NoEventIndicated,
            KeyEvent::None => Keyboard::ErrorUndefine
        }
    }
}

Структуру Key изменяю, заменяя Keyboard на KeyEvent:

pub struct Key {
    pub keycodes: [KeyEvent; crate::matrix::LAYERS],
    pub is_active: bool,
    pub debounce_count: u8,
}

Также добавляю enum StateChange, в котором будут возможные изменения состояния:

#[derive(Format)]
pub enum StateChange {
    SetActive,
    SetInactive,
    DebounceTick,
    LayerUp,
    LayerDown,
}

Они будут использоваться позже при передаче изменения состояния от слейва к мастеру.

И наконец, нужна функция для обновления состояния клавиши — в ней состояние будет изменяться в зависимости от состояния счетчика отскока, возвращая сообщение об изменении, обернутое в Some(), если таковое было, либо None:

pub fn sync_state(
    &mut self,
    to_active: bool,
    debounce_limit: u8,
    active_layer: usize,
) -> Option<StateChange> {
    self.tick_debounce();
    if self.debounce_count > 0 {
        Some(StateChange::DebounceTick)
    } else if to_active {
        if self.is_active {
            None
        } else {
            self.set_active();
            self.set_debounce(debounce_limit);
            match self.keycodes[active_layer] {
                KeyEvent::LayerShiftHold => Some(StateChange::LayerUp),
                _ => Some(StateChange::SetActive),
            }
        }
    } else if self.is_active {
        self.set_inactive();
        self.set_debounce(debounce_limit);
        match self.keycodes[active_layer] {
            KeyEvent::LayerShiftHold => Some(StateChange::LayerDown),
            _ => Some(StateChange::SetInactive),
        }
    } else {
        None
    }
}

В структуре Matrix также произошли некоторые изменения — поскольку у нас есть процесс переключения между слоями, были добавлены функции повышения/понижения значения активного слоя:

pub fn increment_layer(&mut self) {
    if self.active_layer < LAYERS {
        self.active_layer += 1;
    }
}

pub fn decrement_layer(&mut self) {
    self.active_layer = self.active_layer.saturating_sub(1);
}

Добавил функцию инициализации матрицы на основе раскладки LAYOUT:

pub fn new() -> Matrix {
    Matrix {
        layout: crate::layout::LAYOUT,
        active_layer: 0,
    }
}

И трейт Default на ее основе:

impl Default for Matrix {
    fn default() -> Self {
        Self::new()
    }
}

И, наконец, пришло время вернуться к TODO из прошлой части — а именно функции report_active, которая должна возвращать итератор, содержащий Keyboard — он нужен для отправки отчета функцией usbd_human_interface_device::device::keyboard::NKROBootKeyboard::write_report(). В качестве итератора использован heapless::Vec — с ним достаточно легко работать и он не требует аллокатора, а создание вектора длиной в количество клавиш — приемлемо:

pub fn report_active(&self) -> Vec<Keyboard, KEYS> {
    self.layout
        .iter()
        .flatten()
        .map(|key| {
            if key.is_active() {
                key.keycodes[self.active_layer].clone().into()
            } else {
                Keyboard::NoEventIndicated
            }
        })
        .collect()
}

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

const OUT_PINS: usize = 6;
const IN_PINS: usize = 6;
const DEBOUNCE_CYCLE_COUNT: u8 = 3;

// Scan offset should be equal to the amount of column pins in the left half
#[cfg(feature = "left")]
const SCAN_OFFSET: usize = 0;
#[cfg(feature = "right")]
const SCAN_OFFSET: usize = 6;

Затем добавляю в структуры Shared и Local ресурсы, которые нужно делить между задачами и нет соотвественно. В общие попали девайсы USB и UsbHidClass, а также сама матрица — их будут использовать несколько задач. В локальные — массивы с пинами ввода/вывода.

#[shared]
struct Shared {
    keyboard: UsbHidClass<
        'static,
        hal::otg_fs::UsbBus<USB>,
        HList!(NKROBootKeyboard<'static, hal::otg_fs::UsbBus<USB>),
    >,
    usb_dev: UsbDevice<'static, hal::otg_fs::UsbBus<USB>>,
    matrix: Matrix,
}

#[local]
struct Local {
    outputs: [EPin<Output>; OUT_PINS],
    inputs: [EPin<Input>; OUT_PINS],
}

Дальше задаю функцию init(), в которой происходит инициализация ресурсов — частоты (тут я немного ошибся, но это всплывет позже), периферии, локальных и общих данных:

#[init(local = [
    ep_memory: [u32; 1024] = [0; 1024],
    usb_bus: Option<UsbBusAllocator<UsbBus<USB>>> = None
])]
fn init(cx: init::Context) -> (Shared, Local) {
    defmt::info!("init");
    Mono::start(cx.core.SYST, 80_000_000);

    let dp = cx.device;
    let rcc = dp.RCC.constrain();

    let clocks = rcc
        .cfgr
        .use_hse(25.MHz())
        .sysclk(80.MHz())
        .use_hse(25.MHz())
        .freeze();

    let gpioa = dp.GPIOA.split();
    let gpiob = dp.GPIOB.split();
    ...
}

Далее задаю массивы со стертыми пинами ввода/вывода. Для правой половины клавиатуры порядок пинов в массиве выводов должен быть обратным:

...
// Mutability is necessary when building for right half to reverse an array of pins
#[allow(unused_mut)]
let mut outputs = [
    gpioa.pa5.into_push_pull_output().erase(),
    gpioa.pa4.into_push_pull_output().erase(),
    gpioa.pa3.into_push_pull_output().erase(),
    gpioa.pa2.into_push_pull_output().erase(),
    gpioa.pa1.into_push_pull_output().erase(),
    gpioa.pa0.into_push_pull_output().erase(),
];
let inputs = [
    gpiob.pb14.into_pull_down_input().erase(),
    gpiob.pb15.into_pull_down_input().erase(),
    gpioa.pa15.into_pull_down_input().erase(),
    gpiob.pb3.into_pull_down_input().erase(),
    gpiob.pb8.into_pull_down_input().erase(),
    gpiob.pb9.into_pull_down_input().erase(),
];

#[cfg(feature = "right")]
outputs.reverse();
...

Также нужно устройство USB и устройство клавиатуры на его основе. В настройках девайса USB необходимо указать vendor id и product id — в моем случае я использовал тестовые (можно получить собственный от pid.codes, но для пробы пера заморачиваться не хотелось), а также можно указать производителя, название устройства и серийный номер:

...
let usb = USB {
    usb_global: dp.OTG_FS_GLOBAL,
    usb_device: dp.OTG_FS_DEVICE,
    usb_pwrclk: dp.OTG_FS_PWRCLK,
    pin_dm: gpioa.pa11.into(),
    pin_dp: gpioa.pa12.into(),
    hclk: clocks.hclk(),
}

*cx.local.usb_bus = Some(hal::otg_fs::UsbBus::new(usb, cx.local.ep_memory));
let keyboard = UsbHidClassBuilder::new()
    .add_device(NKROBootKeyboardConfig::default())
    .build(cx.local.usb_bus.as_ref().unwrap());

let usb_dev = UsbDeviceBuilder::new(
    cx.local.usb_bus.as_ref().unwrap(),
    UsbVidPid(0x1209, 0x0001),
    )
    .strings(&[StringDescriptors::default()
    .manufacturer("pinklifeart")
    .product("The Mighty Pinktyl")
    .serial_number("0000")])
    .unwrap()
    .build();
...

После чего возвращаю кортеж со структурами с инициализированными данными:

...
(
    Shared {
        keyboard,
        usb_dev,
        matrix: Matrix::new(),
    },
    Local {
        outputs,
        inputs,
    },
)

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

По сути, алгоритм первого до смешного простой — обойти все пины, подавая на вход каждого напряжение, проверить активные выводы и сохранить информацию в памяти. Опрос раз в 10 миллисекунд звучит достаточно быстро. Так и запишем:

#[task(priority = 2, local = [outputs, inputs], shared = [matrix])]
async fn scan(mut cx: scan::Context) {
    loop {
        (0..OUT_PINS).for_each(|col| {
            cx.local.outputs[col].set_high();
            (0..IN_PINS).for_each(|row| {
                if let Some(m) = cx.shared.matrix.lock(|matrix| {
                    matrix.layout[row][col].sync_state(
                        cx.local.inputs[row].is_high(),
                        DEBOUNCE_CYCLE_COUNT,
                        matrix.active_layer,
                    )
                }) {
                    match m {
                        LayerUp => cx.shared.matrix.lock(|matrix| matrix.increment_layer()),
                        LayerDown => cx.shared.matrix.lock(|matrix| matrix.decrement_layer()),
                        SetActive => {
                            defmt::info!("{:?}", m)
                        }
                        SetInactive => {
                            defmt::info!("{:?}", m)
                        }
                        _ => {}
                    }
                }
                // TODO: Add USART message logic based on message variants
            });
            cx.local.outputs[col].set_low();
        });
        Mono::delay(10.millis()).await;
    }
}

Особо пытливые могут заметить место для бага, но к этому мы еще вернемся.

Согласно документации библиотеки, нам обязательно нужно выполнять функцию usbd_human_interface_device::usb_class::UsbHidClass::tick() раз в миллисекунду. Также при желании можно обрабатывать передаваемые с хоста ивенты — например, зажигать светодиод при включенном Caps Lock, но на этот раз обработкой решил не заморачиваться. Из ресурсов тут нужен только сам девайс клавиатуры.

#[task(priority = 3, shared = [keyboard])]
async fn kb_tick(mut cx: kb_tick::Context) {
    loop {
        cx.shared.keyboard.lock(|keyboard| match keyboard.tick() {
            Err(UsbHidError::WouldBlock) => {}
            Err(_e) => {}
            Ok(_) => {}
            });
        Mono::delay(1.millis()).await;
    }
}

И наконец, нужна задача, которая будет отправлять информацию о нажатых клавишах. Для этого в usbd_human_interface_device есть специальная функция write_report, которой нужно передать итератор, содержащий элементы Keyboard. К счастью, функция, создающая подобный итератор уже написана — достаточно ее использовать. Из ресурсов потребуются: девайс клавиатуры и матрица.

#[task(priority = 2, shared = [keyboard, matrix])]
async fn kb_report(mut cx: kb_report::Context) {
    loop {
        cx.shared.keyboard.lock(|keyboard| {
            match keyboard
            .device()
            .write_report(cx.shared.matrix.lock(|matrix| matrix.report_active()))
            {
                Err(UsbHidError::WouldBlock) => {}
                Err(UsbHidError::Duplicate) => {}
                Err(_e) => {}
                Ok(_) => {}
            }
        });
        Mono::delay(10.millis()).await;
    }
}

На данном этапе уже можно проверить, работают ли половинки по отдельности — заливаю на тестовую клавиатуру прошивку с помощью cargo flash и подключаю к пк. К моему удивлению, все даже работает.

Видео первого теста

Почти победа! Осталось решить один мааааленький вопросец — синхронизацию между половинками клавиатуры.

Добавляю синхронизацию половин

Поискав вдохновения в проектах других людей, я остановился на UART для коммуникации между частями клавиатуры. Но что нам вообще нужно передавать? Ранее я добавлял в key.rs enum StateChange, содержащий возможные виды сообщений об изменении состояния. На данный момент описаны 5 возможных вариантов:

  1. SetActive — клавиша переведена в активное состояние

  2. SetInactive — клавиша переведена в неактивное состояние

  3. DebounceTick — счетчик отскока был понижен на 1

  4. LayerUp — номер активного слоя был увеличен на 1

  5. LayerDown — номер активного слоя был уменьшен на 1

Для полноты картины я решил добавить еще один вариант — NoChange, который бы просто сигнализировал об отсутствии изменений.

Помимо самих изменений в случае изменения состояния клавиши нужно передавать координаты клавиши, чтобы синхронизировать состояние на мастере. Создаю структуру, описывающую передаваемое сообщение и соответствующую функцию new:

#[derive(Debug, Format)]
pub struct Message {
    pub state_change: StateChange,
    pub row: usize,
    pub col: usize,
}

impl Message {
    pub fn new(state_change: StateChange, row: usize, col: usize) -> Self {
        Message {
            state_change,
            row,
            col,
        }
    }
}

KeyEvent решаю переименовать в KeyAction, а его вариант None - в NoAction, в основном чтобы не путать с обычным None.

Отлично, структура сообщения у нас есть, но для передачи нужно его как‑то закодировать. Если я правильно понимаю работу серийного протокола, он позволяет передавать данные фрагментами (словами) заданной длины. Наиболее частая — 8 байт. В таком случае мне нужно сериализовать структуру для передачи, после чего десериализовать ее при получении. Варианты Enum в Rust могут быть преобразованы в u8 (если нет вложенных данных, в противном случае все чуть сложнее, но это не мой случай). Номера столбца и строки у меня типа usize, но до тех пор, пока не используется больше 256 пинов ввода/вывода (представьте себе такую клавиатуру), можно спокойно преобразовывать их в u8. В качестве маркера конца сообщения буду использовать \n. В итоге выходит, что для передачи сообщения достаточно массива из четырех u8. Добавляю функцию сериализации в основном файле:

fn serialize(state: StateChange, row: usize, col: usize) -> [u8; 4] {
    [state as u8, row as u8, col as u8, b'\n']
}

С десериализацией все чуть сложнее — для преобразования u8 обратно в варианты enum нужно написать соответствующую функцию. Для этого я решил реализовать трейт TryFrom<u8>. В функции try_from описываю все возможные варианты enum, а в случае невалидного значения возвращаю ошибку:

impl TryFrom<u8> for StateChange {
    type Error = &'static str;
    fn try_from(value: u8) -> Result<StateChange, Self::Error> {
        match value {
            0 => Ok(Self::SetActive),
            1 => Ok(Self::SetInactive),
            2 => Ok(Self::DebounceTick),
            3 => Ok(Self::LayerUp),
            4 => Ok(Self::LayerDown),
            5 => Ok(Self::NoChange),
            _ => Err("No corresponding StateChange variant for the provided u8 value."),
        }
    }
}

Теперь можно написать функцию десериализации:

fn try_deserialize(message: &[u8; 4]) -> Option<Message> {
    if let Ok(sc) = StateChange::try_from(message[0]) {
        Some(Message::new(sc, message[1] as usize, message[2] as usize))
    } else {
        None
    }
}

При получении сообщения его нужно будет обработать — создаю задачу, использующую матрицу и полученное сообщение. Пока обрабатываю только ивенты включения, отключения клавиш и счетчика таймера отскока:

#[task(priority = 3, shared = [matrix])]
async fn handle_message(mut cx: handle_message::Context, message: Message) {
    cx.shared.matrix.lock(|matrix| match message.state_change {
        SetActive => matrix.layout[message.row][message.col].sync_state(
            true,
            DEBOUNCE_CYCLE_COUNT,
            matrix.active_layer,
        ),
        SetInactive => matrix.layout[message.row][message.col].sync_state(
            true,
            DEBOUNCE_CYCLE_COUNT,
            matrix.active_layer,
        ),
        DebounceTick => {
            matrix.layout[message.row][message.col].tick_debounce();
            Some(DebounceTick)
        }
        _ => None,
    });
}

Перед тем, как идти дальше, нужно добавить недостающие ресурсы в init(), а именно UART и буфер под сообщение. Для UART в соответствии с документацией платы выбрал пины pb6 и pb7, после чего инициализую серийное устройство, а полученные трансивер и ресивер сохраняю в tx и rx:

...
let usart_pins = (gpiob.pb6, gpiob.pb7);
let mut serial = Serial::new(
    dp.USART1,
    usart_pins,
    Config::default().baudrate(36_400.bps()).wordlength_8(),
    &clocks,
    )
    .unwrap()
    .with_u8_data();
serial.listen(serial::Event::RxNotEmpty);
let (tx, rx) = serial.split();
...

Под буфер создаю обычный массив u8 длиной 4 с 0 в качестве начального значения:

...
let buffer = [0_u8; 4];
...

Поскольку ресивер и буфер будут использоваться только при получении сообщения, а трансивер — только при отправке, добавляю поля в структуру Local:

...
#[local]
struct Local {
    outputs: [EPin<Output>; OUT_PINS],
    inputs: [EPin<Input>; OUT_PINS],
    tx: serial::Tx<hal::pac::USART1>,
    rx: serial::Rx<hal::pac::USART1>,
    buffer: [u8; 4],
}
...

И исправляю возвращаемое значение в init:

...
Local {
    outputs,
    inputs,
    tx,
    rx,
    buffer,
},
...

Дальше нужно изменить задачу сканирования матрицы, добавив отправку сообщений со слейва на мастер. Мастер решил определять проверкой состояния usb‑девайса — если состояние не является UsbDeviceState::Configured, девайс считается слейвом, сообщение сериализуется и отправляется в трансивер.

#[task(priority = 2, local = [outputs, inputs, tx], shared = [matrix, usb_dev])]
async fn scan(mut cx: scan::Context) {
    loop {
        (0..OUT_PINS).for_each(|col| {
            cx.local.outputs[col].set_high();
            (0..IN_PINS).for_each(|row| {
                #[cfg(any(feature = "right", feature = "left"))]
                let col = col + SCAN_OFFSET;
                if let Some(m) = cx.shared.matrix.lock(|matrix| {
                    matrix.layout[row][col].sync_state(
                        cx.local.inputs[row].is_high(),
                        DEBOUNCE_CYCLE_COUNT,
                        matrix.active_layer,
                    )
                }) {

                    if cx
                        .shared
                        .usb_dev
                        .lock(|usb_dev| usb_dev.state() != UsbDeviceState::Configured)
                    {
                        cx.local.tx.bwrite_all(&serialize(m, row, col)).unwrap();    
                        cx.local.tx.bflush().unwrap();
                    }
                    {
                        match m {
                            LayerUp => cx.shared.matrix.lock(|matrix| matrix.increment_layer()),
                            LayerDown => cx.shared.matrix.lock(|matrix| matrix.decrement_layer()),
                            _ => {}
                        }
                    }
                }
                // TODO: Add USART message logic based on message variants
            });
            cx.local.outputs[col].set_low();
        });
        Mono::delay(10.millis()).await;
    }
}

Ну и наконец, нужно добавить задание обработки сообщений. Если ресивер не пустой — читаем один байт, сдвигаем влево массив, после чего записываем полученный байт в последний слот массива. После чего проверяем последний слот, если значение равно b'\n', пытаемся десериализовать данные. В случае успеха запускается задание обработки полученного сообщения.

#[task(binds = USART1, priority = 3, local = [rx, buffer])]
fn usart_rx(cx: usart_rx::Context) {
    if let Ok(byte) = cx.local.rx.read() {
        cx.local.buffer.rotate_left(1);
        cx.local.buffer[3] = byte;

        if cx.local.buffer[3] == b'\n' {
            if let Some(message) = try_deserialize(cx.local.buffer) {
                handle_message::spawn(message).unwrap();
            }
        }
    }
}

На этом этапе можно попробовать проверить работу двух половинок вместе — переключение слоев работать полноценно не будет, но как минимум должны отправляться значения клавиш. Подключаю левую половину «продовой» клавиатуры в DFU‑режиме, компилирую код под нее и заливаю:

cargo objcopy --release --features left -- -O strip -- -O binary target/left.bin
sudo dfu-util -i 0 -a 0 -s 0x08000000 -D target/left.bin

Затем проворачиваю то же самое с правой половиной:

cargo objcopy --release --features right -- -O strip -- -O binary target/right.bin
sudo dfu-util -i 0 -a 0 -s 0x08000000 -D target/right.bin

Отключаю, соединяю их вместе и подключаю к ПК. И о чудо — оно печатает! Да, есть баги с переключением слоев, но в целом это ожидалось — код еще не до конца дописан. Облегченно вздохнув, коммичу код и отправляюсь отдыхать, оставив себе пару TODO.

Последний заход и исправление багов

По сути, все что осталось — дописать обработку клавиши fn, что на самом деле оказалось совсем несложно — достаточно добавить обработку этих сообщений в handle_message. Добавляю соответствующий код и вуаля, переключение слоев начинает работать:

#[task(priority = 3, shared = [matrix])]
async fn handle_message(mut cx: handle_message::Context, message: Message) {
    cx.shared.matrix.lock(|matrix| match message.state_change {
        SetActive => matrix.layout[message.row][message.col].sync_state(
            true,
            DEBOUNCE_CYCLE_COUNT,
            matrix.active_layer,
        ),
        SetInactive => matrix.layout[message.row][message.col].sync_state(
            true,
            DEBOUNCE_CYCLE_COUNT,
            matrix.active_layer,
        ),
        DebounceTick => {
            matrix.layout[message.row][message.col].tick_debounce();
            Some(DebounceTick)
        }
        LayerUp => {
            matrix.increment_layer();
            Some(LayerUp)
        }
        LayerDown => {
            matrix.decrement_layer();
            Some(LayerDown)
        }
        _ => None,
    });
}

И на самом деле об этом я бы додумался раньше, если бы не catch all условие в match — с ним компилятор не может заставить тебя подумать, как правильно обработать все варианты enum, а возвращаясь к этому потом, не сразу понимаешь контекст. Из этого для себя делаю вывод, что лучше не торопиться городить костыли с catch all, особенно когда вариантов enum не так уж и много.

Также решил немного изменить логику, обрабатывая переключение слоев в scan только на мастере, поскольку отчет формируется именно там:

...
    cx.local.tx.bflush().unwrap();
-    }
-    {
+    } else {
        match m {
...

Пришло время проверки — и да, оно работает! Клавиатура успешно переключает слои, обе стороны работают как положено. Ну, почти — до тех пор, пока я не попробовал подключить ее к USB‑хабу вместо порта на ПК. Оказалось, что я забыл одну маааленькую деталь — для корректной работы USB‑девайсу нужна определенная частота, а поскольку в моем случае она привязана к частоте чипа, ее значение — 80 MHz вместо положенных 48. Не беда, исправляем значение в конфигурации:

-   Mono::start(cx.core.SYST, 80_000_000);
+   Mono::start(cx.core.SYST, 48_000_000);
...
-       .sysclk(80.MHz())
+       .sysclk(48.MHz())

Перезаливаю прошивку и вуаля — вот уж теперь все работает как положено. Проект официально завершен, код закоммичен, отдых заслужен.

Итоги

В целом это был интересный опыт — как сама сборка, так и «разработка» прошивки под нее. За кадром статьи осталось много фрустраций на тему «да почему нет‑то», но они по большей части вызваны отсутствием опыта. Rust имеет крайне удобный инструментарий, а система типов помогает продумать, что должна выдавать та или иная функция на выходе, главное помнить, что при всем великолепии компилятора, он не всегда спасет от ошибок в логике, особенно если прибегать к использованию временных заплаток по типу _ => в match.

Но на самом деле приключения на этом не заканчивались — сама клавиатура‑то готова, а вот научиться ей пользоваться — отдельное приключение, которое требует определенного терпения и усидчивости. Скорость печати лично у меня стала невыносимо медленной на первые пару недель, пока потихоньку не выработалась новая мышечная память — тут поможет только практика, ну и сервисы для тренировки печати, как, например keybr.com. Но в то же время это отличная возможность избавиться от «дурных» привычек — например, нажимать Y указательным пальцем правой, а T — левой, ведь теперь у вас попросту не будет такой возможности :)

А еще этот проект стал для меня поводом для покупки 3D‑принтера, который сам по себе оказался достаточно полезной в хозяйстве вещью.

Вот таким получился итоговый результат - не слишком красиво, но функционально
Вот таким получился итоговый результат - не слишком красиво, но функционально

Крайне рекомендую попробовать подобное приключение тем, кто, возможно, боялся или ленился — для меня это был приятный способ попробовать что‑то новое и при этом получить вполне функциональную вещь на выходе — ведь весь этот текст написан на этой самой клавиатуре, ну а код доступен на github :)

Теги:
Хабы:
+19
Комментарии9

Публикации

Информация

Сайт
beget.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия

Истории