first commit

This commit is contained in:
2026-05-27 18:50:35 +09:00
commit d80de116c6
17 changed files with 1381 additions and 0 deletions

123
internal/hhkb/device.go Normal file
View File

@@ -0,0 +1,123 @@
// Package hhkb talks to the vendor HID interface of an HHKB Professional
// keyboard (PFU, USB 04fe:0020-0022) over /dev/hidraw, the same channel the
// official Keymap Tool uses. It needs no cgo: just file I/O on the device node.
package hhkb
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const reportLen = 64
// Device is an open handle to the keyboard's vendor HID interface.
type Device struct {
f *os.File
product uint16
}
// Open finds the HHKB vendor interface and opens it for reading and writing.
//
// It identifies the interface from sysfs alone: the right hidraw node belongs
// to USB 04fe:0020 and carries a report descriptor that begins with
// 06 00 ff (Usage Page = Vendor-Defined 0xFF00). The keyboard exposes three
// hidraw nodes; only this one accepts keymap commands.
func Open() (*Device, error) {
nodes, err := filepath.Glob("/sys/class/hidraw/hidraw*")
if err != nil {
return nil, err
}
for _, node := range nodes {
name := filepath.Base(node)
uevent, err := os.ReadFile(filepath.Join(node, "device/uevent"))
if err != nil {
continue
}
product, ok := parseProduct(string(uevent))
if !ok {
continue
}
desc, err := os.ReadFile(filepath.Join(node, "device/report_descriptor"))
if err != nil || len(desc) < 3 || desc[0] != 0x06 || desc[1] != 0x00 || desc[2] != 0xff {
continue
}
f, err := os.OpenFile("/dev/"+name, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("found %s but cannot open it: %w", name, err)
}
return &Device{f: f, product: product}, nil
}
return nil, fmt.Errorf("HHKB vendor interface (04fe:0020-0022) not found")
}
// Close releases the device.
func (d *Device) Close() error { return d.f.Close() }
// Product is the USB product id of the open device: 0x0020 Classic,
// 0x0021 Hybrid, 0x0022 Hybrid Type-S.
func (d *Device) Product() uint16 { return d.product }
// parseProduct reads the USB product id from a hidraw uevent and reports
// whether the node is a supported HHKB (vendor 04fe, product 0020-0022).
func parseProduct(uevent string) (uint16, bool) {
for _, line := range strings.Split(uevent, "\n") {
v, ok := strings.CutPrefix(line, "HID_ID=")
if !ok {
continue
}
parts := strings.Split(v, ":") // bus:vendor:product
if len(parts) != 3 || !strings.EqualFold(parts[1], "000004FE") {
return 0, false
}
var p uint32
if _, err := fmt.Sscanf(parts[2], "%x", &p); err != nil {
return 0, false
}
if p < 0x0020 || p > 0x0022 {
return 0, false
}
return uint16(p), true
}
return 0, false
}
// request builds a 64-byte report: AA AA <cmd> followed by args, zero-padded.
func request(cmd byte, args ...byte) []byte {
b := make([]byte, reportLen)
b[0], b[1], b[2] = 0xAA, 0xAA, cmd
copy(b[3:], args)
return b
}
// send writes one report. hidraw takes a leading report-number byte, which the
// kernel discards because this device uses unnumbered reports.
func (d *Device) send(report []byte) error {
_, err := d.f.Write(append([]byte{0x00}, report...))
return err
}
// recv reads one 64-byte report. In a reply, the payload starts at byte 6;
// bytes 0..1 are an acknowledgement header (55 55).
func (d *Device) recv() ([]byte, error) {
b := make([]byte, reportLen)
n, err := d.f.Read(b)
if err != nil {
return nil, err
}
if n != reportLen {
return nil, fmt.Errorf("short read: %d/%d bytes", n, reportLen)
}
return b, nil
}
// do sends a command and returns its single reply.
func (d *Device) do(cmd byte, args ...byte) ([]byte, error) {
if err := d.send(request(cmd, args...)); err != nil {
return nil, err
}
return d.recv()
}

43
internal/hhkb/keymap.go Normal file
View File

@@ -0,0 +1,43 @@
package hhkb
// Mode is the keyboard personality selected by the DIP switches.
type Mode byte
const (
ModeHHK Mode = iota
ModeMac
ModeLite
ModeSecret
)
func (m Mode) String() string {
switch m {
case ModeHHK:
return "HHK"
case ModeMac:
return "Mac"
case ModeLite:
return "Lite"
case ModeSecret:
return "Secret"
}
return "unknown"
}
// LayerLen is the size of one key layer. A key's number indexes directly into
// the layer to give its scancode.
const LayerLen = 128
// Layer holds the scancode assigned to every key on one layer.
type Layer [LayerLen]byte
// Key describes one physical key: its protocol key number, factory default
// scancode, visual row, and position/width in key units (as in QMK layouts,
// where 1.0 is a normal keycap). The key tables live in models.go.
type Key struct {
Num int
Def byte
Row int
X float64
W float64
}

110
internal/hhkb/models.go Normal file
View File

@@ -0,0 +1,110 @@
package hhkb
import "strings"
// Variant is the physical key layout of a model.
type Variant int
const (
US Variant = iota
JIS
)
func (v Variant) String() string {
if v == JIS {
return "JIS"
}
return "US"
}
// Model is a keyboard this tool understands. Keys is its physical layout, in
// reading order; Hybrid marks the Hybrid/Type-S boards, which reserve a few Fn
// keys in firmware.
type Model struct {
Name string
Product uint16
Variant Variant
Hybrid bool
Keys []Key
}
// Models lists every supported model. The US/JIS split mirrors the official
// tool; the product id groups the Classic, Hybrid, and Hybrid Type-S families.
var Models = []Model{
{"HHKB Professional Classic (US)", 0x0020, US, false, ansiKeys},
{"HHKB Professional Classic (JIS)", 0x0020, JIS, false, jisKeys},
{"HHKB Professional Hybrid (US)", 0x0021, US, true, ansiKeys},
{"HHKB Professional Hybrid (JIS)", 0x0021, JIS, true, jisKeys},
{"HHKB Professional Hybrid Type-S (US)", 0x0022, US, true, ansiKeys},
{"HHKB Professional Hybrid Type-S (JIS)", 0x0022, JIS, true, jisKeys},
}
// MatchModel picks the model for a USB product id and reported type number.
// JIS boards carry "20" in their type number (PD-KB420, PD-KB820); the rest
// are US. Falls back to any model sharing the product id.
func MatchModel(product uint16, typeNumber string) (Model, bool) {
want := US
if strings.Contains(typeNumber, "20") {
want = JIS
}
for _, m := range Models {
if m.Product == product && m.Variant == want {
return m, true
}
}
for _, m := range Models {
if m.Product == product {
return m, true
}
}
return Model{}, false
}
// DetectModel identifies the connected keyboard from its product id and info.
func (d *Device) DetectModel() (Model, bool, error) {
info, err := d.Info()
if err != nil {
return Model{}, false, err
}
m, ok := MatchModel(d.product, info.TypeNumber)
return m, ok, nil
}
// ansiKeys is the US (60-key) layout. Geometry matches QMK's LAYOUT_60_hhkb:
// {Num, Def, Row, X, W}, in key units.
var ansiKeys = []Key{
{60, 0x29, 0, 0, 1}, {59, 0x1e, 0, 1, 1}, {58, 0x1f, 0, 2, 1}, {57, 0x20, 0, 3, 1}, {56, 0x21, 0, 4, 1},
{55, 0x22, 0, 5, 1}, {54, 0x23, 0, 6, 1}, {53, 0x24, 0, 7, 1}, {52, 0x25, 0, 8, 1}, {51, 0x26, 0, 9, 1},
{50, 0x27, 0, 10, 1}, {49, 0x2d, 0, 11, 1}, {48, 0x2e, 0, 12, 1}, {47, 0x31, 0, 13, 1}, {46, 0x35, 0, 14, 1},
{45, 0x2b, 1, 0, 1.5}, {44, 0x14, 1, 1.5, 1}, {43, 0x1a, 1, 2.5, 1}, {42, 0x08, 1, 3.5, 1}, {41, 0x15, 1, 4.5, 1},
{40, 0x17, 1, 5.5, 1}, {39, 0x1c, 1, 6.5, 1}, {38, 0x18, 1, 7.5, 1}, {37, 0x0c, 1, 8.5, 1}, {36, 0x12, 1, 9.5, 1},
{35, 0x13, 1, 10.5, 1}, {34, 0x2f, 1, 11.5, 1}, {33, 0x30, 1, 12.5, 1}, {32, 0x2a, 1, 13.5, 1.5},
{31, 0xe0, 2, 0, 1.75}, {30, 0x04, 2, 1.75, 1}, {29, 0x16, 2, 2.75, 1}, {28, 0x07, 2, 3.75, 1}, {27, 0x09, 2, 4.75, 1},
{26, 0x0a, 2, 5.75, 1}, {25, 0x0b, 2, 6.75, 1}, {24, 0x0d, 2, 7.75, 1}, {23, 0x0e, 2, 8.75, 1}, {22, 0x0f, 2, 9.75, 1},
{21, 0x33, 2, 10.75, 1}, {20, 0x34, 2, 11.75, 1}, {19, 0x28, 2, 12.75, 2.25},
{18, 0xe1, 3, 0, 2.25}, {17, 0x1d, 3, 2.25, 1}, {16, 0x1b, 3, 3.25, 1}, {15, 0x06, 3, 4.25, 1}, {14, 0x19, 3, 5.25, 1},
{13, 0x05, 3, 6.25, 1}, {12, 0x11, 3, 7.25, 1}, {11, 0x10, 3, 8.25, 1}, {10, 0x36, 3, 9.25, 1}, {9, 0x37, 3, 10.25, 1},
{8, 0x38, 3, 11.25, 1}, {7, 0xe5, 3, 12.25, 1.75}, {6, 0x01, 3, 14, 1},
// row 4 — key5/key1 are the narrow outer keys, key4/key2 the wide ones next to
// Space. Which sends Alt vs GUI(◇) depends on the keyboard's mode (HHK vs Mac),
// so the labels come from the live read; the Def values here are the HHK defaults.
{5, 0xe3, 4, 1.5, 1}, {4, 0xe2, 4, 2.5, 1.5}, {3, 0x2c, 4, 4, 7}, {2, 0xe6, 4, 11, 1.5}, {1, 0xe7, 4, 12.5, 1},
}
// jisKeys is the Japanese (69-key) layout. Key numbers and rows are fixed; the
// geometry is a uniform grid for now (exact JIS widths are a later refinement),
// and default scancodes are read from the device when connected.
var jisKeys = buildJIS()
func buildJIS() []Key {
rows := [...][2]int{{69, 55}, {54, 41}, {40, 28}, {27, 14}, {13, 1}}
var keys []Key
for row, span := range rows {
x := 0.0
for n := span[0]; n >= span[1]; n-- {
keys = append(keys, Key{Num: n, Row: row, X: x, W: 1})
x++
}
}
return keys
}

29
internal/hhkb/palette.go Normal file
View File

@@ -0,0 +1,29 @@
package hhkb
// KeycodeGroup is one labelled section of the keycode palette.
type KeycodeGroup struct {
Name string
Codes []byte
}
// Palette groups the assignable scancodes for the editor, roughly the way the
// official tool and QMK present them.
var Palette = []KeycodeGroup{
{"Letters", codeRange(0x04, 0x1d)},
{"Numbers", codeRange(0x1e, 0x27)},
{"Symbols", []byte{0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38}},
{"Editing", []byte{0x29, 0x2a, 0x2b, 0x28, 0x2c, 0x39}},
{"Navigation", codeRange(0x49, 0x52)},
{"Function", codeRange(0x3a, 0x45)},
{"Modifiers", []byte{0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7}},
{"Media", []byte{0xe8, 0xe9, 0xea, 0xeb}},
{"Special", []byte{0x00, 0x01, 0x46, 0x47, 0x48}},
}
func codeRange(lo, hi byte) []byte {
out := make([]byte, 0, int(hi-lo)+1)
for c := lo; c <= hi; c++ {
out = append(out, c)
}
return out
}

168
internal/hhkb/proto.go Normal file
View File

@@ -0,0 +1,168 @@
package hhkb
import (
"bytes"
"fmt"
"strings"
)
// Command ids — byte 3 of every request.
const (
cmdNotifyApp = 1
cmdGetInfo = 2
cmdFactoryReset = 3
cmdConfirmKeymap = 4
cmdGetMode = 6
cmdResetDIP = 7
cmdWriteKeymap = 134
cmdGetKeymap = 135
)
// Info describes the connected keyboard.
type Info struct {
TypeNumber string
Revision string
Serial string
AppFirmware string
BootFirmware string
}
// Info reads the keyboard's identification.
func (d *Device) Info() (Info, error) {
r, err := d.do(cmdGetInfo)
if err != nil {
return Info{}, err
}
p := r[6:]
return Info{
TypeNumber: cstr(p[0:20]),
Revision: cstr(p[20:24]),
Serial: cstr(p[24:40]),
AppFirmware: fmt.Sprintf("%X", p[40:48]),
BootFirmware: fmt.Sprintf("%X", p[48:56]),
}, nil
}
// Mode reads the active keyboard mode (set by DIP switches).
func (d *Device) Mode() (Mode, error) {
r, err := d.do(cmdGetMode)
if err != nil {
return 0, err
}
return Mode(r[6]), nil
}
// ReadLayer reads one full key layer: the base layer, or the Fn layer when
// fn is true. The reply arrives as three reports that tile the 128-byte layer.
func (d *Device) ReadLayer(fn bool) (Layer, error) {
mode, err := d.Mode()
if err != nil {
return Layer{}, err
}
return d.readLayer(mode, fn)
}
func (d *Device) readLayer(mode Mode, fn bool) (Layer, error) {
var lay Layer
if err := d.send(request(cmdGetKeymap, 0, 2, byte(mode), boolByte(fn))); err != nil {
return lay, err
}
for _, seg := range [...]struct{ off, n int }{{0, 58}, {58, 58}, {116, 12}} {
r, err := d.recv()
if err != nil {
return lay, err
}
copy(lay[seg.off:seg.off+seg.n], r[6:6+seg.n])
}
return lay, nil
}
// writeLayer uploads a full layer in the three passes the firmware expects.
// The leading byte pairs (65,59 / 130,59 / 195,12) are offset and length
// markers the controller verifies; the data window is mode+fn followed by the
// 128 key bytes.
func (d *Device) writeLayer(mode Mode, fn bool, lay Layer) error {
passes := []struct {
mark [2]byte
head []byte
data []byte
}{
{[2]byte{65, 59}, []byte{byte(mode), boolByte(fn)}, lay[0:57]},
{[2]byte{130, 59}, nil, lay[57:116]},
{[2]byte{195, 12}, nil, lay[116:128]},
}
for _, p := range passes {
args := append([]byte{p.mark[0], p.mark[1]}, p.head...)
args = append(args, p.data...)
if _, err := d.do(cmdWriteKeymap, args...); err != nil {
return err
}
}
return nil
}
// Remap assigns scancode to a key (by its key number) on the base or Fn layer.
// It mirrors the official tool's sequence: announce the tool, edit the layer,
// write it back, commit, and reset DIP state. The change is reversible with
// Reset or by remapping again.
func (d *Device) Remap(keyNum int, scancode byte, fn bool) error {
if keyNum < 1 || keyNum >= LayerLen {
return fmt.Errorf("key number %d out of range", keyNum)
}
if err := d.notifyApp(true); err != nil {
return err
}
defer d.notifyApp(false)
mode, err := d.Mode()
if err != nil {
return err
}
lay, err := d.readLayer(mode, fn)
if err != nil {
return err
}
lay[keyNum] = scancode
if err := d.writeLayer(mode, fn, lay); err != nil {
return err
}
if _, err := d.do(cmdConfirmKeymap); err != nil {
return err
}
_, err = d.do(cmdResetDIP, 0, 1)
return err
}
// Reset restores the factory default keymap.
func (d *Device) Reset() error {
r, err := d.do(cmdFactoryReset)
if err != nil {
return err
}
if !bytes.HasPrefix(r, []byte{0x55, 0x55, 0x03, 0x00}) {
return fmt.Errorf("unexpected factory-reset reply: % X", r[:6])
}
return nil
}
// notifyApp tells the keyboard whether the configuration tool is active.
func (d *Device) notifyApp(open bool) error {
_, err := d.do(cmdNotifyApp, 0, 1, boolByte(!open))
return err
}
func boolByte(b bool) byte {
if b {
return 1
}
return 0
}
// cstr converts a fixed-width, NUL-padded field into a Go string.
func cstr(b []byte) string {
if i := strings.IndexByte(string(b), 0); i >= 0 {
b = b[:i]
}
return strings.TrimSpace(string(b))
}

39
internal/hhkb/scancode.go Normal file
View File

@@ -0,0 +1,39 @@
package hhkb
import "fmt"
// scancodeName maps USB HID keyboard usage ids to short labels. 0x01 is the
// HHKB Fn key, which the firmware handles internally rather than reporting.
var scancodeName = map[byte]string{
0x00: "--", 0x01: "Fn",
0x04: "A", 0x05: "B", 0x06: "C", 0x07: "D", 0x08: "E", 0x09: "F",
0x0a: "G", 0x0b: "H", 0x0c: "I", 0x0d: "J", 0x0e: "K", 0x0f: "L",
0x10: "M", 0x11: "N", 0x12: "O", 0x13: "P", 0x14: "Q", 0x15: "R",
0x16: "S", 0x17: "T", 0x18: "U", 0x19: "V", 0x1a: "W", 0x1b: "X",
0x1c: "Y", 0x1d: "Z",
0x1e: "1", 0x1f: "2", 0x20: "3", 0x21: "4", 0x22: "5",
0x23: "6", 0x24: "7", 0x25: "8", 0x26: "9", 0x27: "0",
0x28: "Enter", 0x29: "Esc", 0x2a: "Bksp", 0x2b: "Tab", 0x2c: "Space",
0x2d: "-", 0x2e: "=", 0x2f: "[", 0x30: "]", 0x31: "\\",
0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",", 0x37: ".", 0x38: "/",
0x39: "Caps",
0x3a: "F1", 0x3b: "F2", 0x3c: "F3", 0x3d: "F4", 0x3e: "F5", 0x3f: "F6",
0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10", 0x44: "F11", 0x45: "F12",
0x46: "PrtSc", 0x47: "ScrLk", 0x48: "Pause",
0x49: "Ins", 0x4a: "Home", 0x4b: "PgUp", 0x4c: "Del", 0x4d: "End", 0x4e: "PgDn",
0x4f: "Right", 0x50: "Left", 0x51: "Down", 0x52: "Up",
0xe0: "LCtrl", 0xe1: "LShift", 0xe2: "LAlt", 0xe3: "LGUI",
0xe4: "RCtrl", 0xe5: "RShift", 0xe6: "RAlt", 0xe7: "RGUI",
0x53: "Numlk", 0x54: "KP/", 0x55: "KP*", 0x56: "KP-", 0x57: "KP+", 0x58: "KPEnt",
0x66: "Power",
// HHKB media keys (sent via the consumer interface; values are HHKB-internal).
0xe8: "Vol-", 0xe9: "Vol+", 0xea: "Mute", 0xeb: "Play",
}
// KeyName returns a label for a scancode, falling back to hex for unknowns.
func KeyName(code byte) string {
if name, ok := scancodeName[code]; ok {
return name
}
return fmt.Sprintf("0x%02x", code)
}