Racket and Keyboard Shenanigans


Background

Look, I know I have a lot of unfinished posts. But this thing is pretty interesting to me. First, I have always wanted to do some LISP or Scheme. Second, I am really into keyboard and ergo shit since last year. Third, the keyboard I'm trying to fuck up is akin to my juvenile love or something along that line. Fourth, believe it or not, KiCad's pcb file format and component footprints seem really like S-Expr which is really neat.

KiCad's kicad_mod overview

To give you an example, the following snippet is from ai03's repo:

(module SOT143B (layer F.Cu) (tedit 5C42C5FB)

Which means this module is named SOT143B and will be placed at the front layer,

  (attr smd)

is a footprint for an SMD component,

  (fp_text reference REF** (at 0 2.45) (layer F.SilkS) (effects (font (size 1 1) (thickness 0.15))))
  (fp_text value SOT143B (at 0 -2.3) (layer F.Fab) (effects (font (size 1 1) (thickness 0.15))))

will have REF** with 0.15 mm thickness printed on the front layer silk,

  (fp_line (start 0.55 -1.45) (end 0.55 -0.65) (layer F.Fab) (width 0.15))
  ; snip
  (fp_line (start -1.45 -0.65) (end 1.45 -0.65) (layer F.SilkS) (width 0.15))

will have some lines on the front layer, especially as fabrication and silks, according to the start and end expression,

  (pad 3 smd rect (at 0.95 -1) (size 0.6 0.7) (layers F.Cu F.Paste F.Mask))
  (pad 2 smd rect (at 0.95 1) (size 0.6 0.7) (layers F.Cu F.Paste F.Mask))
  (pad 4 smd rect (at -0.95 -1) (size 0.6 0.7) (layers F.Cu F.Paste F.Mask))
  (pad 1 smd rect (at -0.75 1) (size 1 0.7) (layers F.Cu F.Paste F.Mask))
)

and have 4 smd pads shaped as rectangles on front cuprum layer. As you can see, however you look at it, that's an S-Expr!

Doing Something

Well, whatever, let's get weird.

I am going to write some short and simple explanation on a modified atreus codebase by replacing Pololu's A-Star with common Promicro, swapping MX-Alps switch style footprint with MX-Alps-Choc switch style footprint.

; atreus.rkt
#lang racket

Well, because Racket has so many things, the line above tells that I am specifically using racket dialect.

(define cols 12)
(define rows 4)

(define x-offset 20)
(define y-offset 20)

(define spacing 19)
(define angle 10)

(define column-offsets '(8 5 0 6 11 65 65 11 6 0 5 8))

There are some definitions here:

Now, the basic definitions have been set, now I will define a module for the switch footprint:

(define (switch-module x y rotation label net-pos net-neg)

First, I defined what a switch-module needs:

The snippet below is an incomplete version of a switch footprint by keeb.io the sake of briefness.

  `(module Keebio-Parts:MX-Alps-Choc-1U-NoLED (layer Front) (tedit 4FD81CDD) (tstamp 543EF801)

o The line above is a macro (you can see the tilde at the start of the line) that defines the start of a footprint library and its "properties", if you permit me to use that word. But, the first line of that definition also contains some information:

     (at ,x ,y ,rotation)

This is where we should put the footprint. As you can see above, each of token that is started with a comma, will be replaced by the provided parameter which has the same name.

     (path /543DB910)
     (fp_text reference ,label (at 0 3.302 ,rotation) (layer F.SilkS) (effects (font (size 1.524 1.778) (thickness 0.254))))
     (fp_line (start -9.525 9.525) (end -9.525 -9.525) (layer Dwgs.User) (width 0.15))
     (fp_line (start 5 -7) (end 7 -7) (layer Dwgs.User) (width 0.15))
     (pad "" np_thru_hole circle (at -5.22 4.2 ,(+ 48.1 rotation)) (size 1.2 1.2) (drill 1.2) (layers *.Cu *.Mask))
     (pad "" np_thru_hole circle (at -5.08 0 ,(+ 48.1 rotation)) (size 1.75 1.75) (drill 1.75) (layers *.Cu *.Mask))
     (pad 1 thru_hole circle (at 5 -3.8 ,rotation) (size 2 2) (drill 1.2) (layers *.Cu *.Mask) ,net-pos)
     (pad 2 thru_hole oval (at -3.81 -2.54 ,(+ 48.1 rotation)) (size 4.211556 2.25) (drill 1.47 (offset 0.980778 0)) (layers *.Cu *.Mask) ,net-neg)))

list of s-exp above should be clear based on what I've written at the first snippet.

(define (diode-module x y rotation label net-pos net-neg)
  ;skipped for brevity.
)

diode-module's definition should pretty much the same with switch-module above.

The interesting part is the next one, microcontroller-module.

(define microcontroller-module

Module definition without parameter, because I decided to hard wire it.

  `(module PROMICRO (layer Front) (tedit 4FDC31C8) (tstamp 543EF800)
     (at 134 45 270)
     (path /543EEB02)
     (fp_text reference Promicro (at 10 0) (layer F.SilkS) (effects (font (size 1 1) (thickness 0.15))))
     (fp_line (start -15.24 7.62) (end 15.9 7.62) (layer F.SilkS) (width 0.381))
     (fp_line (start 15.9 7.62) (end 15.9 -7.62) (layer F.SilkS) (width 0.381))
     (fp_line (start 15.9 -7.62) (end -15.24 -7.62) (layer F.SilkS) (width 0.381))

S-expr-s above just define where at the front layer it should be placed, what should be drawn on it. Not really interesting, to be quite honest.

     (pad B5  thru_hole circle (at 13.97 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 4 N-row-3))
     (pad B4  thru_hole circle (at 11.43 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 3 N-row-2))
     (pad E6  thru_hole circle (at 8.89 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 15 N-col-10))
     (pad D7  thru_hole circle (at 6.35 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 11 N-col-6))
     (pad C6  thru_hole circle (at 3.81 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad D4  thru_hole circle (at 1.27 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 13 N-col-8))
     (pad D0  thru_hole circle (at -1.27 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 12 N-col-7))
     (pad D1  thru_hole circle (at -3.81 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 14 N-col-9))
     (pad GND thru_hole circle (at -6.35 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad GND thru_hole circle (at -8.89 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad RX1 thru_hole circle (at -11.43 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad TX0 thru_hole rect (at -13.97 -6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))

Now this one is interesting enough. S-expr above define the left side of a promicro and which net, or "cable connection" if you will, each pin should be attached at. For example, N-row-3 (third row of the keyboard) should be connected to pin B5 (bottom left). But, when there's no s-expr that states which net it should receive, then there's no connection incoming to that legs. For example, TX0 doesn't get connected to any switches or diodes.

     (pad B6  thru_hole circle (at 13.97 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 2 N-row-1))
     (pad B2  thru_hole circle (at 11.43 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 1 N-row-0))
     (pad B3  thru_hole circle (at 8.89 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 10 N-col-5))
     (pad B1  thru_hole circle (at 6.35 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 6 N-col-1))
     (pad F7  thru_hole circle (at 3.81 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 5 N-col-0))
     (pad F6  thru_hole circle (at 1.27 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 9 N-col-4))
     (pad F5  thru_hole circle (at -1.27 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 8 N-col-3))
     (pad F4  thru_hole circle (at -3.81 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask) (net 7 N-col-2))
     (pad VCC thru_hole circle (at -6.35 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad RST thru_hole circle (at -8.89 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad GND thru_hole circle (at -11.43 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask))
     (pad RAW thru_hole circle (at -13.97 6.35 270) (size 1.7526 1.7526) (drill 1.0922) (layers *.Cu *.SilkS *.Mask)))
)

Same goes with this section. But the difference is it defines the right side of the promicro.

The next part is where the nets are defined.

(define nets
  `((net 0 "")
    (net 1 N-row-0)
    (net 2 N-row-1)
    (net 3 N-row-2)
    (net 4 N-row-3)

net takes two params. The first one defines the number of the net and the second one as the name of the net. The part above defines the number and the name of the nets for atreus' first to fourth row.

    (net 5  N-col-0)
    (net 6  N-col-1)
    (net 7  N-col-2)
    (net 8  N-col-3)
    (net 9  N-col-4)
    (net 10 N-col-5)
    (net 11 N-col-6)
    (net 12 N-col-7)
    (net 13 N-col-8)
    (net 14 N-col-9)
    (net 15 N-col-10)

While the snippet above defines the net numbers and names for atreus' columns. Why does it define them 11 times, though? Because on the left side, atreus has 5 columns, the right side 5 columns, and the thumb buttons are counted as 1 column.

    ,@(for/list ([s (in-range 42)])
        (list 'net (+ 16 s) (string->symbol (format "N-diode-~s" s))))))

The snippet above contains another macro and a list comprehension. For the list comprehension, for/list, the code thread through 1 to 42 and then, create a list which contains net, s + 16 because it already has 16 nets from the previous parts, and a formatted string N-diode-s.

(define (net-class nets)

Just a net-class definition that asks for one params. Though the param in question should be a list.

  (append '(net_class Default "This is the default net class."
                      (clearance 0.254)
                      (trace_width 0.2032)
                      (via_dia 0.889)
                      (via_drill 0.635)
                      (uvia_dia 0.508)
                      (uvia_drill 0.127))

The snippet above is the first part of a function where the nets parameter will be appended to the net_class.

          (for/list ([n nets])
            (list 'add_net (last n)))))

Now, for/list will iterate each and every element of nets will be used to create a list of list that contains add_net and the last part of the nets elements, so it will result (add_net N-col-4), for example.

The next part of the code is defining where a should be placed at, how should it be placed, etc, when somebody give some values of row and col.

(define (switch row col)

The code above is just function definition that takes two parameter, row and col.

  (let* ([left? (< col 6)]

In the body of the function, there are some value definitions. First one is whether the switch in question should be placed on the left side or not. If the col value is less than 6, then it means the switch should be on the left side of the keyboard.

         [rotation (if left? -10 10)]

The next value defines how much a switch should be rotated. Switches on the left should be rotated for -10 degrees (or 350 degrees) while switches on the right should be rotated 10 degrees. If somebody wants to change this value, they should also change the value of angle above.

         [x (* (+ 1 col) spacing)]

This x value defines where should a switch be put at in X coordinate in cartesian coordinate system. As you can see, it calculated the value by incrementing the col value and then multiply it by 19.

         [y (+ (list-ref column-offsets col) (* spacing row))]

This y value defines where a switch should be put at in Y coordinate in cartesian coordinate system. y value is calculated by accessing the col-th value of column-offsets and then adding it to the spacing multiplied by row. So, the for col equals to 1 and row equals to 1, y value should be 5 + 19 or 24.

         [hypotenuse (sqrt (+ (* x x) (* y y)))]

It's just a standard hypotenuse function.

         [Θ (atan (/ y x))]

theta function... I don't know perhaps wikipedia's arctangle knows...

         [Θ′ (- Θ (degrees->radians rotation))]

I don't know how do you call it. Can I say it as theta-prime? Anyway, this value just the difference between theta and radian value of rotation.

         [x′ (+ (if left? x-offset 5) (* hypotenuse (cos Θ′)))]

This x-prime is where the switch should be actually placed at after rotating it by rotation value.

         [y′ (+ (if left? y-offset (+ y-offset 42.885)) (* hypotenuse (sin Θ′)))]

Same goes with y-prime, where should a switch should be placed at after it is being rotated by rotation value by using some math and trigonometry. You wouldn't believe on how much trig and math actually being used when you're doing something like this, right? Yeah yeah, I can hear your laments over it. Students, you should pay attention to your classes. You cannot use what you don't have.

         [label (format "SW~a:~a" col row)]

This value is just what should the fab put on them. In this case, it should conform the format SWcol:row.

         [diode (+ row (* col 4))]

In keyboard, one of the switch's legs should be connected to a diode. This one, the value definition above, defines which diode it should connect to. The value is calculated by multiplying col by four and then adding it row.

         ;; if we try to number nets linearly, kicad segfaults; woo!
         ;; so we re-use the nets we skipped with the missing col 5/6 diodes
         [diode (cond [(> diode 44) (- diode 20)]
                      [(> diode 41) (- diode 21)]
                      [true diode])]

Just what the comment said.

         [net-col (if left? col (- col 1))]

If the switch is on the right side, just decrement the column.

         [diode-net `(net ,(+ 16 diode)
                          ,(string->symbol (format "N-diode-~s" diode)))]

Just like what I've said previously, one of the legs of any switch should be connected to the diode. That's why, the net for the diode should be defined here. But why the first parameter of net should be added by 16 you said? Well, because the first 16 nets were used for column and row nets.

         [column-net `(net ,(+ net-col 5)
                           ,(string->symbol (format "N-col-~s" net-col)))]

Now, in common mechanical keyboard diy project, every switch in a row should be connected by a single wire, or in this case, a net. Therefore, one of the legs should have a net connection to one of the column nets. Again, I heard you ask why the net-col added by 5. Well, because the first 5 nets were used for row nets.

         ;; rotate middle keys additional 90° after calculating position
         [rotation (cond [(= 5 col) 80]
                         [(= 6 col) 280]
                         [true rotation])])

This one redefines value when col value is either 5 or 6. When the col equals to 5, or the left thumb, rotate the switch by 80 degrees. When the col equals to 6, or the right thumb, rotate the switch by 280 degrees. This way, your keycaps won't be broken because of some differences between x and y axis of the switch's stem. You know, nerds are annoying when it comes to this kind of differences. You will be surprised on how much people raged on the net because of Kaihua's Box switches' stem's x and y axis has different thickness and crack nerds' waste of money overpriced plastics expensive keycaps.

    (switch-module x′ y′ rotation label
                   (if left? diode-net column-net)
                   (if left? column-net diode-net))))

Now, this is where the footprint library being used.

The next one is where diode placement being defined. Almost most identical to switch function, though.

(define (diode row col)
  (let* ([left? (< col 6)]
         [rotation (if left? -10 10)]
         [x (* (+ 1 col) spacing)]
         [y (+ (list-ref column-offsets col) (* spacing row))]
         [hypotenuse (sqrt (+ (* x x) (* y y)))]
         [Θ (atan (/ y x))]
         [Θ′ (- Θ (degrees->radians rotation))]
         [x′ (+ (if left? x-offset 5) (* hypotenuse (cos Θ′))
                (if left? 9 -9))]
         [y′ (+ (if left? y-offset (+ y-offset 42.885))
                (* hypotenuse (sin Θ′)))]
         [label (format "D~a:~a" col row)]
         [diode (+ row (* col 4))]
         ;; if we try to number nets linearly, kicad segfaults; woo!
         ;; so we re-use the nets we skipped with the missing col 5/6 diodes
         [diode (cond [(> diode 44) (- diode 20)]
                      [(> diode 41) (- diode 21)]
                      [true diode])]

See, practically the same as switch explanation.

         [net-row (cond [(= col 5) 2]
                        [(= col 6) 3]
                        [true row])])

The function above defines that the left thumb button should be considered as the third row while the right thumb button should be considered as the fourth row.

    (diode-module x′ y′ rotation label
                  `(net ,(+ 16 diode)
                        ,(string->symbol (format "N-diode-~s" diode)))
                  `(net ,(+ net-row 1)
                        ,(string->symbol (format "N-row-~s" net-row))))))

When actually using diode-module to use the library footprint, positive pad should be connected to diode nets while the negative pad should be connected to the row nets. As for why the diode added by 16 is because, like I've written before, there are 12 column nets and 4 row nets. But why it only increments net-row by 1, not some other? Well, because, you know, something something starts at 0 something something or 1.

The next part is where the switches and diodes are being joined together.

(define switches+diodes

Just a value definiton because it does not accept any parameters.

  (for/list ([col (in-range cols)]
             #:when true
             [row (if (or (= 5 col) (= 6 col))
                      '(0) (in-range rows))])

A list comprehension using 0 - cols (12) and 0 - rows (4). Except when col equals to 5 or 6, just use 0. Why? Thumb button is just one for each finger, right? You remember that, right?

    (list (switch row col) (diode row col))))

After that list comprehension, create a pair of switch and diode using the result of that list comprehension.

(define edge-cuts
  (for/list [(s '([31 22] [84 22]  [141 30] [127 30] [185 22] [237 22] [250 95]  [161 112] [107 112] [18 95]))
             (e '([84 22] [127 30] [185 22] [141 30] [237 22] [250 95] [161 112] [107 112] [18 95]   [31 22]))]
    `(gr_line (start ,@s) (end ,@e) (angle 90) (layer Edge.Cuts) (width 0.3))))

Now, edge cut is where the pcb ends. Somehow I feel so pedantic when I explain something that is so clear.

Anyway, this value definition uses, again, a list comprehension using a list named s which is a mnemonic for start (surprise!) and e which is a mnemonic for end. Those lists are used to create (gr_line) though I'm quite sure those values are created by manually put that in kicad. (because that's what i do)

(define board
  (apply append nets
         (list (net-class nets))
         (list microcontroller-module)
         edge-cuts
         switches+diodes))

Now, the board's definition is basically appending nets, edge-cuts, switches+diodes, and (net-class nets) and microcontroller-module that have been transformed into a list with a single element.

(define (write-placement filename)

Function definition above, write-placement, defines the main function where

  (when (file-exists? filename) (delete-file filename))

when a file with filename as its name exists, delete the file in question.

  (call-with-output-file filename

save the body's output to the filename,

    (λ (op)

by using a anonymous function op

      (display (call-with-input-file "header.rktd" (curry read-string 9999)) op)

to display the content of header.rktd (I'll talk about it later) by buffering the content in memory,

      ;; kicad has this terrible bug where it's whitespace-sensitive here =(
      (display "\n" op)
      (for ([f board]) (pretty-print f op 1))

then put the content of board list,

      (display (call-with-input-file "traces.rktd" (curry read-string 999999)) op)

followed by storing the content of traces.rktd,

      (display ")" op))))

and finally put a close bracket at the end of the file.

I want to talk about header.rktd and traces.rktd first.

(write-placement "atreus.kicad_pcb")

Then, the last part is where the pcb file being generated. Check the result by using pcbnew atreus.kicad_pcb.

Some Thoughts

I genuinely enjoyed the process modifying this codebase and learning Racket. After a while, you won't really mind the braces. That's it, basically. I have nothing to say about Racket and KiCad's pcb file format other than, "it's really nice experience to learn about it."

For my usage, I think I will use choc switches and chase the low-profile as the target. Though that means I won't be using any case.

And I will update this post when I print the pcb and have them on my hands.



This material is shared under the CC-BY License.