A keyboard firmware for the CH55x series.
🥳 Come join our Discord server!
FAK stands for F.A. Keyboard. F.A. are the initials of a person who silently crashed into my party and did not even make their presence known to me or my family. You know who you are.
I live in a country where Pi Picos and Pro Micros are not considered cheap. When I began the search for making keyboards as cheap as possible, I discovered CH55x chips which are much cheaper. RP2040 needs an external QSPI flash. ATmega32U4 needs an external clock. As for the CH55x however, all you need to get it running is the chip itself and two capacitors. You can get a complete MCU for less than a dollar.
Besides that, I want to be able to make keyboard configurations declaratively. ZMK already does this to an extent, but FAK is way more flexible as it uses a purely1 functional programming language designed for configs called Nickel. This means you can do crazy things like layout parameterization, keycodes as variables, reusing and parameterizing parts of your keymap, and much more. In fact, Nickel is responsible for turning your config into C code!
Chip | Status |
---|---|
CH552T | ✅ Fully supported and tested |
CH552G | ✅ Fully supported and tested |
CH552E | ✅ Fully supported and tested |
CH559L | 🚧 Partially working, WIP |
CH558L | ❓ Not tested but should be the same as CH559L |
We have now moved to user configuration repositories: fak-config. Refer to fak-config
on how to get started and build keyboards with FAK. You will also find full keyboard and keymap example definitions there.
This repo is for the development of FAK firmware itself where the core functionality and features are implemented.
Check out the examples and see how keyboards and keymaps are defined in FAK, and how powerful and crazy it can get.
Let me know if you're using FAK on a project and I'd be happy to add it here!
- MIAO by kilipan. Drop-in replacement CH552T MCU for the Seeed Studio XIAO series. Keeb Supply and Kiser Designs sell a production version of the Miao. (Not affiliated/sponsored)
- Partycrasher Micro by semickolon. Drop-in replacement CH558L/CH559L MCU for the Pro Micro.
- Partycrasher Xiao by semickolon. Drop-in replacement CH552T MCU for the Seeed Studio XIAO series.
- CH552-44, CH552-48, CH552-48-LPR by rgoulter. Ortholinears built on WeAct board and PCBA'd onboard CH552T.
- CH552-36 by rgoulter. 36-key split built on a sub-100mm PCB. Onboard CH552T.
- Ch55p34 by doesntfazer. 34-key column-staggered unibody. Onboard CH552T.
- idawgz32 by ChrisChrisLo. 32-key ultra-portable and ultra-affordable pocket keyboard. Onboard CH552T.
- Hexatana by Purox. 36-key Katana-inspired keyboard designed around hexagonal keycaps. Onboard CH552T.
- 0xPM by llmerlos. 3x6+4 split ortholinear with USB-C interconnect. Onboard CH552T.
- The Endgame by OldMan6955. 36-key column-staggered unibody. Miao dev board.
- wahoo30 by rqcoon. 30% ortholinear unibody. Onboard CH552T.
Keycodes are in 32 bits. 16 for the hold portion. 16 for the tap portion. If both portions exist, you get a hold-tap.
# A
tap.reg.kc.A
# Ctrl-A
tap.reg.kc.A & tap.reg.mod.lctl
# Ctrl-A when tapped, Layer 1 (like MO(1) in QMK) when held
# Both portions exist. This is a hold-tap.
tap.reg.kc.A & tap.reg.mod.lctl & hold.reg.layer 1 & hold.reg.behavior {}
# Ctrl-Shift-A when tapped, Layer 1 with Alt and Shift when held
tap.reg.kc.A & tap.reg.mod.lctl & tap.reg.mod.lsft & hold.reg.layer 1 & hold.reg.mod.lalt & hold.reg.mod.lsft & hold.reg.behavior {}
# Layer 1 when pressed/held
# Only hold portion exists. This is not a hold-tap.
hold.reg.layer 1
Either one or both of the tap and hold portions can be transparent. Full transparency is equivalent to KC_TRNS
in QMK.
let kc = tap.reg.kc in
let mod = hold.reg.mod in
{
layers = [
[ # Layer 0
kc.A & mod.lctl, kc.B & mod.lsft, kc.C & mod.lalt, hold.reg.layer 1
],
[ # Layer 1
tap.trans & mod.lsft, kc.J & hold.trans, tap.trans & hold.trans, tap.trans & hold.trans
]
]
}
Yep. Layers. Up to 32.
# Momentary layer (like MO in QMK)
hold.reg.layer [0-31]
# TG, like in QMK, toggles the layer on or off
tap.layer.TG [0-31]
# DF, roughly like in QMK, clears all layers except for the new specified default layer
tap.layer.DF [0-31]
# TO, like in QMK, turns the specified layer on, and clears all others except for the default layer
tap.layer.TO [0-31]
FAK supports both col-to-row and row-to-col matrix scanning with ColToRowKey
and RowToColKey
respectively. Now, it's also possible to mix both of these two in one, resulting in a duplex matrix. There is an example keyboard definition for the Kazik, a duplex matrix keyboard, for your reference. But basically, to get a duplex matrix keyboard working, you just mix both ColToRowKey
s and RowToColKey
s like so in your keyboard.ncl
.
let { ColToRowKey, RowToColKey, .. } = import "fak/keyboard.ncl" in
# (snip)
# Here we have a 12-key layout that only uses 5 pins
keys =
let C = ColToRowKey in
let R = RowToColKey in
[
R 0 0, C 0 0, R 1 0, C 1 0, R 2 0, C 2 0,
R 0 1, C 0 1, R 1 1, C 1 1, R 2 1, C 2 1,
]
Inspired by and building on top of ZMK.
- You can choose a flavor per key. Tap-preferred on key 3, hold-preferred on key 42, and balanced on key 69? No problem!
- Quick tap. Allows hold-taps to remain resolved as a tap if tapped then held quickly.
- Quick tap interrupt. Allows a quick tap to re-resolve to a hold if interrupted by another key press.
- Global quick tap. Allows hold-taps to resolve as a tap during continuous typing.
- Global quick tap ignore consecutive. Ignores global quick tap if the same key is pressed at least twice in a row.
- Eager decision. Allows hold-taps to pre-resolve as a hold or a tap until the actual decision is made. If the actual decision doesn't match the eager decision, the eager is reversed then the actual is applied. Otherwise, if the decisions match, nothing else happens because it's as if the hold-tap predicted the future.
let my_crazy_behavior = {
timeout_ms = 200, # Known as tapping term in QMK and ZMK. 200 by default.
timeout_decision = 'hold, # Can be set to 'hold (default) or 'tap
eager_decision = 'tap, # Can be set to 'hold, 'tap, or 'none (default)
key_interrupts = [ # Size of this must match key count. This assumes we have 5 keys.
{ decision = 'hold, trigger_on = 'press }, # Similar to ZMK's hold-preferred / QMK's HOLD_ON_OTHER_KEY_PRESS
{ decision = 'tap, trigger_on = 'press },
{ decision = 'hold, trigger_on = 'release }, # Similar to ZMK's balanced / QMK's PERMISSIVE_HOLD
{ decision = 'tap, trigger_on = 'release },
{ decision = 'none }, # Similar to ZMK's tap-preferred
],
quick_tap_ms = 150, # 0 (disabled) by default
quick_tap_interrupt_ms = 500, # 0 (disabled) by default
global_quick_tap_ms = 120, # 0 (disabled) by default
global_quick_tap_ignore_consecutive = true, # false by default
} in
# Behaviors are bound to hold-taps like so
let my_crazy_key =
tap.reg.kc.A
& hold.reg.layer 1
& hold.reg.behavior my_crazy_behavior
in
Things like retro-tap and ZMK's tap-unless-interrupted
are not defined as-is. They don't need to be because you can make behaviors that emulate them. The exercise of doing so is left to the reader.
Similar to ZMK, the bindings can be hold-taps! This means in the following example, if you do a tap-tap-hold, you'll momentarily access layer 2.
# 200 is the tapping_term_ms
td.make 200 [
tap.reg.kc.F,
tap.reg.kc.A & hold.reg.layer 1,
tap.reg.kc.K & hold.reg.layer 2,
]
Central and peripheral sides are fully independently defined, so no considerations need to be made about symmetry, pin placement, and whatnot. They can be two entirely different keyboards connected together for all FAK cares.
# This is an example of how a 10-key "split macropad" would be defined in keyboard.ncl
let { DirectPinKey, PeripheralSideKey, .. } = import "fak/keyboard.ncl" in
let { CH552T, .. } = import "fak/mcus.ncl" in
let D = DirectPinKey in
let S = PeripheralSideKey in
let side_periph = {
mcu = CH552T,
split.channel = CH552T.features.uart_30_31,
keys = [
D 13, D 14, D 15,
D 32, D 33, D 12,
]
} in
# The central side has two fields that aren't in the peripheral:
# `split.peripheral` and `usb_dev`
{
mcu = CH552T,
split.channel = CH552T.features.uart_12_13,
split.peripheral = side_periph,
usb_dev = {
# Nickel doesn't support hex literals yet
vendor_id = 2023,
product_id = 69,
product_ver = 420,
},
keys = [
D 14, D 15, S 0, S 1, S 2,
D 30, D 11, S 3, S 4, S 5,
]
}
Limitations:
- Only UART0 is supported. UART1 is not yet supported.
- Central and peripheral sides are fixed. That is, you can't plug it in on the peripheral side. Well, you can, but of course it won't work as a USB keyboard.
This is similar to QMK's SOFT_SERIAL_PIN
(bitbanged half-duplex UART). FAK offers this in order to preserve compatibility with split keyboards designed for QMK + Pro Micro. You may also use this to be able to use one more pin for keys, since hardware UART uses two pins, but this, only one pin.
For example, to use soft serial on pin 16: split.channel = SoftSerialPin 16
. See also the provided example keyboard definition for KLOR.
Combos are implemented as virtual keys. They're like regular keys but they are activated by pressing multiple physical keys at the same time. And since they're like regular keys, they're just like any other key in your keymap with full support for all the features. They can even have different keycodes across layers.
let kc = tap.reg.kc in
let mod = hold.reg.mod in
let my_tap_dance = td.make 200 [kc.SPC, kc.ENT, hold.reg.layer 1] in
{
virtual_keys = [
# The first argument is the timeout_ms (up to 255)
# The second argument is the key indices/positions (min 2, max 9 keys)
combo.make 50 [2, 3],
# By default, the combo is released once *any one* of the key indices is released
# Merge with `combo.slow_release` if you want the combo to release once *all* of the key indices are released instead
combo.make 30 [0, 2, 5] & combo.slow_release,
# During fast typing, you might want to temporarily ignore combos to prevent triggering them
# To do this, merge with `combo.require_prior_idle_ms [ms]`
# For example, the following combo is ignored if the last keypress happened no more than 180ms ago.
combo.make 60 [0, 1] & combo.require_prior_idle_ms 180
# You can't use virtual key indices. Just physical keys.
],
# Assuming a 6-key macropad + 3 virtual keys, our layers need to have 9 keycodes.
layers = [
[
kc.A, kc.B, kc.C,
kc.D, kc.E, kc.F,
# After physical keycodes, our virtual keycodes begin:
kc.N1 & mod.lctl,
kc.N2 & mod.lalt,
kc.N3,
],
[
kc.G, kc.H, kc.I,
kc.J, kc.K, kc.L,
# Like physical keys, they can be different across layers and use transparency
my_tap_dance,
tap.trans & mod.lgui,
kc.N4,
],
],
}
Limitations:
- Fully overlapping combos (e.g.,
[2, 3]
and[2, 3, 4]
) are not supported yet. Partially overlapping combos (e.g.,[2, 3]
and[3, 4, 5]
) are supported though, as shown in the example above.
Yep. Encoders. To use them, first, add encoders to your keyboard definition. PhysicalEncoder
takes in the following arguments: pin A, pin B, encoder resolution.
let { DirectPinKey, PhysicalEncoder, .. } = import "fak/keyboard.ncl" in
let { CH552T, .. } = import "fak/mcus.ncl" in
{
mcu = CH552T,
usb_dev = { ... },
encoders = [
PhysicalEncoder 10 11 4,
PhysicalEncoder 16 14 5,
],
keys = [
D 30, D 31, D 32,
],
}
Then, similar to combos, add encoders to your virtual keys in your keymap with encoder.cw
(clockwise) and encoder.ccw
(counter-clockwise). They take in one argument, the encoder index. Encoder index 0
would be the first encoder in your keyboard definition. Encoder index 1
would be the second, and so on.
# We can arrange them however we like. They can even be mixed with combos like so.
{
virtual_keys = [
combo.make 50 [0, 1],
encoder.ccw 1,
encoder.cw 0,
combo.make 60 [1, 2],
encoder.cw 1,
],
layers = [
# (snip)
],
}
It's possible to have encoders on both sides of a split keyboard with PeripheralSideEncoder
. You may refer to the provided KLOR example here.
Tired of holding shift for just one key? What about tapping shift then only the next key press gets shifted? That's what sticky mods are all about and more.
let sticky_shift = tap.sticky.mod.lsft in
# It doesn't have to be just shift. You can combine mods as shown below.
# When you press two sticky mods in a row, they get stacked or combined, waiting for the next key press.
let sticky_gui_alt = tap.sticky.mod.lgui & tap.sticky.mod.lalt in
let sticky_ctrl_shift = tap.sticky.mod.lctl & tap.sticky.mod.lsft in
Another use case for sticky mods that I find very useful and personally use is this:
# Normal shift on hold, sticky shift on tap!
let best_shift_ever =
tap.sticky.mod.lsft
& hold.reg.mod.lsft
& hold.reg.behavior { ... }
Temporarily activates a layer until the next key press. Unlike sticky mods, sticky layers cannot be combined. That means you cannot press two sticky layers in a row and have both layers activate. Only the last pressed sticky layer takes effect. However, you can combine sticky mods and layer into one key.
# Activates layer 2 until next key press
tap.sticky.layer 2
# Activates layer 3 with Ctrl-Alt until next key press
tap.sticky.layer 3 & tap.sticky.mod.lsft & tap.sticky.mod.lalt
# Layer 4 on hold; Sticky layer 5 on tap
hold.reg.layer 4 & tap.sticky.layer 5 & hold.reg.behavior { ... }
Limitations:
- You cannot use layer 0 as sticky. Nickel won't stop you, but it won't work during operation. This shouldn't be a dealbreaker though. We normally use sticky layers whose layer index is higher than that of the default/base layer.
Yep. Mouse keys. Constant speed.
# Mouse buttons
tap.custom.mouse.BTN1 # Left mouse button
tap.custom.mouse.BTN2 # Right mouse button
tap.custom.mouse.BTN3 # Middle mouse button
# This goes up to BTN8, if you have a use case for that
# Mouse movement
tap.custom.mouse.UP
tap.custom.mouse.DOWN
tap.custom.mouse.LEFT
tap.custom.mouse.RGHT
# Mouse wheel
tap.custom.mouse.WH_U # Scroll up
tap.custom.mouse.WH_D # Scroll down
You can customize mouse settings in your keymap defintion.
{
mouse = {
move_speed = 4, # Higher = faster move
scroll_interval_ms = 20, # Lower = faster scroll
},
layers = ...
}
Yep. Caps word.
- When active, tapping any key except the alphabetical keys, numbers, backspace, delete deactivates caps word state.
- When active, tapping alphabetical keys sends the shifted alphabetical keys; tapping
-
sends_
. - Caps word state only remains active if a key was tapped in the previous 5 seconds.
tap.custom.fak.CWTG # Caps word toggle
tap.custom.fak.CWON # Caps word on
tap.custom.fak.CWOF # Caps word off
Yep. Macros.
# Macro for word selection
# Ctrl+Right, Ctrl+Left, Ctrl+Shift+Right
let kc = tap.reg.kc in
let tm = tap.reg.mod in
let md = hold.reg.mod in
let word_select = macro.make [
macro.press md.lctl,
macro.tap kc.RGHT,
macro.tap kc.LEFT,
macro.tap (kc.RGHT & tm.lsft),
macro.release md.lctl,
] in
Here are the following possible steps or instructions that can go into macro.make [...]
.
macro.press [keycode]
macro.release [keycode]
macro.tap [keycode]
macro.wait [duration in ms, up to 65535]
macro.pause_for_release
tap
does apress
then arelease
condensed into one step.pause_for_release
waits for the macro key to be released then runs the steps after it. There can be at most one of this in a macro. Two or more will lead to heat death of the universe.
Parameterizing macros is immediately possible thanks to Nickel, so there is no need to learn any other constructs. The following is an example that emulates2 SEND_STRING
from QMK. Unlike QMK, this is not a C macro. It's simply a Nickel function that takes in a string and returns a macro.
let macro_send_string = fun str =>
let steps =
std.string.uppercase str
|> std.string.characters
|> std.array.map (fun char => macro.tap tap.reg.kc."%{char}")
in
macro.make steps
in
let my_macro_1 = macro_send_string "fak yeah" in
let my_macro_2 = macro_send_string "gmail.com" in
As of writing, there are no checks enforced in Nickel to check if all your press
es are eventually release
d. That is, it's possible to leave your press
es pressed even after the macro is fully done. Take note of this, especially if you have weird behavior after activating a macro. This is all because I honestly don't know if checks should even be enforced or if there are actual use cases for leaving keys pressed after a macro.
Inspired by ZMK, this is a generalization of what's usually known as "tri-layers" where activating at least two specified layers (commonly known as "lower" and "raise") activate another layer ("adjust").
{
conditional_layers = {
# The key ("3") specifies the layer that will be activated when the
# specified layers ([1, 2]) are all activated
"3" = [1, 2],
# Nickel does not support integers as keys, as far as I know
# So that's why we have to use a string for now
# There can be more than two activating layers. Go crazy.
"9" = [3, 5, 7, 8],
},
layers = ...
}
Now, conditional layers (such as 3 and 9 in the example above) are fully controlled by the firmware and FAK doesn't want your grubby little fingers on them. Checks are enforced in Nickel, so you cannot activate conditional layers yourself any other way, like hold.reg.layer X
where X is a conditional layer.
tap.custom.fak.REP
repeats the last reported non-modifier keycode including modifiers when it was pressed. Basically, tapping a modifier like Ctrl alone will not be repeated by the repeat key, but Ctrl-A will be, since it has a non-modifier keycode (A).
A version of the transparent key that (1) deactivates the layer it is in, then (2) registers the key press. This is an alternative implementation to what's usually known as smart layers. There exists a tap variant tap.tlex
and also a hold variant hold.tlex { ... }
that takes in a hold-tap behavior. tap.tlex
is fine for simple use cases, but for more advanced use cases, hold.tlex
allows you to only exit the layer when held and do something else when tapped.
let XXXX = tap.none & hold.none in
let kc = tap.reg.kc in
let htb = { timeout_ms = 200 } in
let layers = [
[ # Layer 0
kc.A, kc.B, kc.C, hold.reg.mod.lsft & tap.layer.TG 1 & hold.reg.behavior htb
],
[ # Layer 1
tap.tlex, kc.N1, kc.N2, hold.tlex htb & kc.Z
]
] in
In the example above, we start with layer 0. We toggle layer 1 by tapping the last key. Pressing the first key invokes tap.tlex
which results to the typing of "a". Pressing second and third keys after that will type "bc", not "12" which is what would happen if you used tap.trans
instead that doesn't deactivate the layer it's in.
Let's start over. We start with layer 0. Tap last key to toggle layer 1. Tapping last key will type "z", but holding it for at least 200ms will invoke hold.tlex
which deactivates layer 1 and registers the same hold, resulting to holding of shift. While still keeping the last key held, pressing other keys will type "ABC" since layer 1 was deactivated and shift became held at the same time.
If you do something illegal like hold.reg.layer 2
but you don't even have a layer 2, you'll get an error. It won't let you compile. Same thing if you try to mix incompatible building blocks like tap.reg.kc.A & tap.trans & tap.custom.fak.BOOT
. Basically, assuming there's nothing wrong with your config's syntax, if you get an error from Nickel, then it's likely you did something that doesn't make sense or you've hit a hard limit (like defining layer 33).