commit 993d3f99f32d5b37a1e8367db9a0556cd161eb6d Author: Hojun-Cho Date: Wed May 27 18:30:14 2026 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bae665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# build artifacts +/hhkb-web + +# cloned reference repositories, not part of this project +/research/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e739c6b --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# hkkb + +A keymap editor for the HHKB Professional on Linux — what PFU's Windows/Mac-only +tool does, in a browser. It reads and rewrites the keyboard over the keyboard's +own USB-HID protocol; no firmware patching. + +![editor](doc/base.png) + +## Requires + +- **Go** 1.20+ to build — standard library only (no cgo, no Node). +- A **browser**. +- `doas` (or `sudo`) **once**, to let your user open `/dev/hidraw*`. + +## Run + + ./run.sh + +Builds, grants device access the first time (one `doas` prompt), serves +, and opens it. Later runs need no password. + +Change the port with `-port` (e.g. `./run.sh -port 9000`). + +By hand, if you prefer: `go build -o hhkb-web ./cmd/hhkb-web && ./hhkb-web`. + +## Device access + +The vendor interface is root-only by default; `run.sh` installs this rule for you +(use `sudo` if you have no `doas`): + + doas mkdir -p /etc/udev/rules.d + echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="04fe", MODE="0660", GROUP="input"' | doas tee /etc/udev/rules.d/70-hhkb.rules + doas udevadm control --reload-rules && doas udevadm trigger + +You must be in the `input` group. Then `ls -l /dev/hidraw3` reads `root input`. + +## Use + +It auto-detects the model and reads your keymap on connect. Click a key, pick a +keycode, then **Write**. The **Fn** tab edits the Fn layer (`Fn + [` → Up). +**Read** reloads, **Reset** restores defaults, **Save/Load** are JSON, **Key +test** highlights presses while the page is focused. Bottom-row Alt/GUI follow +the keyboard's HHK/Mac mode and come from the live read. + +## Layout + + internal/hhkb/ protocol + data, no UI, no cgo + cmd/hhkb-web/ web server + embedded UI + doc/protocol.md the wire protocol + +Protocol reverse-engineered by happy-hacking-gnu +(). diff --git a/cmd/hhkb-web/main.go b/cmd/hhkb-web/main.go new file mode 100644 index 0000000..9d1e42a --- /dev/null +++ b/cmd/hhkb-web/main.go @@ -0,0 +1,237 @@ +// Command hhkb-web serves a browser-based HHKB keymap editor, styled after the +// QMK Configurator. The Go backend reuses internal/hhkb for all device I/O; the +// browser is just the renderer. +package main + +import ( + "embed" + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "sync" + "time" + + "hkkb/internal/hhkb" +) + +//go:embed web +var webFS embed.FS + +// devMu serializes access: only one request may touch the keyboard at a time. +var devMu sync.Mutex + +func withDevice(fn func(*hhkb.Device) error) error { + devMu.Lock() + defer devMu.Unlock() + d, err := hhkb.Open() + if err != nil { + return err + } + defer d.Close() + return fn(d) +} + +type keyDTO struct { + Num int `json:"num"` + Row int `json:"row"` + X float64 `json:"x"` + W float64 `json:"w"` + Def int `json:"def"` + DefName string `json:"defname"` +} + +type modelDTO struct { + Name string `json:"name"` + Variant string `json:"variant"` + Hybrid bool `json:"hybrid"` + Keys []keyDTO `json:"keys"` +} + +type codeDTO struct { + Code int `json:"code"` + Name string `json:"name"` +} + +type groupDTO struct { + Name string `json:"name"` + Keys []codeDTO `json:"keys"` +} + +type deviceDTO struct { + Connected bool `json:"connected"` + Model string `json:"model"` + Type string `json:"type"` + Serial string `json:"serial"` + Mode string `json:"mode"` + Error string `json:"error"` +} + +func modelsDTO() []modelDTO { + out := make([]modelDTO, 0, len(hhkb.Models)) + for _, m := range hhkb.Models { + md := modelDTO{Name: m.Name, Variant: m.Variant.String(), Hybrid: m.Hybrid} + for _, k := range m.Keys { + w := k.W + if w == 0 { + w = 1 + } + md.Keys = append(md.Keys, keyDTO{ + Num: k.Num, Row: k.Row, X: k.X, W: w, + Def: int(k.Def), DefName: hhkb.KeyName(k.Def), + }) + } + out = append(out, md) + } + return out +} + +func paletteDTO() []groupDTO { + out := make([]groupDTO, 0, len(hhkb.Palette)) + for _, g := range hhkb.Palette { + gd := groupDTO{Name: g.Name} + for _, c := range g.Codes { + gd.Keys = append(gd.Keys, codeDTO{Code: int(c), Name: hhkb.KeyName(c)}) + } + out = append(out, gd) + } + return out +} + +func deviceStatus() deviceDTO { + var out deviceDTO + err := withDevice(func(d *hhkb.Device) error { + out.Connected = true + if m, ok, e := d.DetectModel(); e == nil && ok { + out.Model = m.Name + } + if i, e := d.Info(); e == nil { + out.Type, out.Serial = i.TypeNumber, i.Serial + } + if md, e := d.Mode(); e == nil { + out.Mode = md.String() + } + return nil + }) + if err != nil { + out.Connected = false + out.Error = friendlyErr(err) + } + return out +} + +func friendlyErr(err error) string { + if errors.Is(err, os.ErrPermission) { + return "No access to /dev/hidraw. Run once as root:\n" + + `echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="04fe", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/70-hhkb.rules` + + "\nsudo udevadm control --reload-rules && sudo udevadm trigger" + } + return err.Error() +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(v) +} + +func handleInit(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{ + "models": modelsDTO(), + "palette": paletteDTO(), + "device": deviceStatus(), + }) +} + +// handleDevice reports just the current device status, for lightweight polling. +func handleDevice(w http.ResponseWriter, r *http.Request) { + writeJSON(w, deviceStatus()) +} + +func handleKeymap(w http.ResponseWriter, r *http.Request) { + fn := r.URL.Query().Get("layer") == "fn" + keys := []map[string]any{} + err := withDevice(func(d *hhkb.Device) error { + lay, err := d.ReadLayer(fn) + if err != nil { + return err + } + for num := 1; num < hhkb.LayerLen; num++ { + code := lay[num] + keys = append(keys, map[string]any{"num": num, "code": int(code), "name": hhkb.KeyName(code)}) + } + return nil + }) + if err != nil { + writeJSON(w, map[string]any{"error": friendlyErr(err)}) + return + } + writeJSON(w, map[string]any{"keys": keys}) +} + +func handleRemap(w http.ResponseWriter, r *http.Request) { + var req struct { + Num int `json:"num"` + Code int `json:"code"` + Fn bool `json:"fn"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + err := withDevice(func(d *hhkb.Device) error { + return d.Remap(req.Num, byte(req.Code), req.Fn) + }) + if err != nil { + writeJSON(w, map[string]any{"error": friendlyErr(err)}) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +func handleReset(w http.ResponseWriter, r *http.Request) { + err := withDevice(func(d *hhkb.Device) error { return d.Reset() }) + if err != nil { + writeJSON(w, map[string]any{"error": friendlyErr(err)}) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +func main() { + port := flag.Int("port", 8080, "port to listen on (localhost)") + open := flag.Bool("open", true, "open the editor in a browser") + flag.Parse() + + static, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.FS(static))) + mux.HandleFunc("/api/init", handleInit) + mux.HandleFunc("/api/device", handleDevice) + mux.HandleFunc("/api/keymap", handleKeymap) + mux.HandleFunc("/api/remap", handleRemap) + mux.HandleFunc("/api/reset", handleReset) + + addr := fmt.Sprintf("127.0.0.1:%d", *port) + url := "http://" + addr + log.Printf("hkkb editor on %s", url) + if *open { + go openBrowser(url) + } + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func openBrowser(url string) { + time.Sleep(300 * time.Millisecond) + if err := exec.Command("xdg-open", url).Start(); err != nil { + fmt.Println("open", url, "in your browser") + } +} diff --git a/cmd/hhkb-web/web/app.js b/cmd/hhkb-web/web/app.js new file mode 100644 index 0000000..da57110 --- /dev/null +++ b/cmd/hhkb-web/web/app.js @@ -0,0 +1,360 @@ +"use strict"; + +const $ = (sel, el = document) => el.querySelector(sel); +const api = async (path, opts) => (await fetch(path, opts)).json(); + +const U = 54; // pixels per key unit +const GAP = 4; // gap between keycaps + +let DATA; // /api/init payload +const modelsByName = {}; +const names = {}; // scancode -> label +let model; // selected modelDTO +let layer = "base"; // "base" | "fn" +let selected = null; // selected key number +const live = { base: {}, fn: {} }; // num -> code, from device/defaults +const edits = { base: {}, fn: {} };// num -> pending code + +let testMode = false; // key-test: highlight presses (focus-gated, no device needed) +const pressed = new Set(); // key numbers currently held +const keyEls = {}; // num -> DOM element +let codeToNum = {}; // KeyboardEvent.code -> key number +let defByNum = {}; // key number -> default (base) scancode +let deviceReady = false; // auto-read already done since the device appeared + +// HID usage -> KeyboardEvent.code (physical position, OS-layout independent). +const USAGE_TO_CODE = (() => { + const m = {}; + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c, i) => (m[0x04 + i] = "Key" + c)); + for (let i = 0; i < 9; i++) m[0x1e + i] = "Digit" + (i + 1); + m[0x27] = "Digit0"; + for (let i = 0; i < 12; i++) m[0x3a + i] = "F" + (i + 1); + Object.assign(m, { + 0x28: "Enter", 0x29: "Escape", 0x2a: "Backspace", 0x2b: "Tab", 0x2c: "Space", + 0x2d: "Minus", 0x2e: "Equal", 0x2f: "BracketLeft", 0x30: "BracketRight", 0x31: "Backslash", + 0x33: "Semicolon", 0x34: "Quote", 0x35: "Backquote", 0x36: "Comma", 0x37: "Period", + 0x38: "Slash", 0x39: "CapsLock", + 0x46: "PrintScreen", 0x47: "ScrollLock", 0x48: "Pause", + 0x49: "Insert", 0x4a: "Home", 0x4b: "PageUp", 0x4c: "Delete", 0x4d: "End", 0x4e: "PageDown", + 0x4f: "ArrowRight", 0x50: "ArrowLeft", 0x51: "ArrowDown", 0x52: "ArrowUp", + 0xe0: "ControlLeft", 0xe1: "ShiftLeft", 0xe2: "AltLeft", 0xe3: "MetaLeft", + 0xe4: "ControlRight", 0xe5: "ShiftRight", 0xe6: "AltRight", 0xe7: "MetaRight", + }); + return m; +})(); + +// inverse: KeyboardEvent.code -> HID usage (names the key the keyboard sent). +const CODE_TO_USAGE = Object.fromEntries( + Object.entries(USAGE_TO_CODE).map(([usage, code]) => [code, Number(usage)]) +); + +function codeName(code) { + return names[code] ?? ("0x" + code.toString(16).padStart(2, "0")); +} +function shown(num) { + if (num in edits[layer]) return edits[layer][num]; + if (num in live[layer]) return live[layer][num]; + return layer === "base" ? (defByNum[num] ?? 0) : 0; +} + +async function boot() { + DATA = await api("/api/init"); + DATA.models.forEach(m => (modelsByName[m.name] = m)); + DATA.palette.forEach(g => g.keys.forEach(k => (names[k.code] = k.name))); + DATA.models.forEach(m => m.keys.forEach(k => (names[k.def] = k.defname))); + + buildModelSelect(); + buildLayers(); + buildPalette(); + + const want = DATA.device.model && modelsByName[DATA.device.model] + ? DATA.device.model : DATA.models[0].name; + $("#model").value = want; + selectModel(want); + + // Auto-read on connect: read now if the keyboard is ready, otherwise keep + // checking so it reads by itself the moment you plug in / grant access. + if (!(await refreshDevice())) { + const timer = setInterval(async () => { + if (await refreshDevice()) clearInterval(timer); + }, 2500); + } +} + +// refreshDevice updates the banner and, the first time the keyboard becomes +// available, selects the detected model and reads its keymap. Returns whether +// the device is currently connected. +async function refreshDevice() { + let dev; + try { dev = await api("/api/device"); } catch { return false; } + showBanner(dev); + if (dev.connected && !deviceReady) { + deviceReady = true; + if (dev.model && modelsByName[dev.model] && $("#model").value !== dev.model) { + $("#model").value = dev.model; + selectModel(dev.model); + } + await readAll(); + } + return dev.connected; +} + +function buildModelSelect() { + const sel = $("#model"); + sel.innerHTML = ""; + DATA.models.forEach(m => { + const o = document.createElement("option"); + o.value = m.name; + o.textContent = m.name; + sel.appendChild(o); + }); + sel.onchange = () => selectModel(sel.value); +} + +function selectModel(name) { + model = modelsByName[name]; + defByNum = {}; + model.keys.forEach(k => (defByNum[k.num] = k.def)); + codeToNum = buildCodeMap(); + selected = null; + renderBoard(); +} + +function buildLayers() { + const box = $("#layers"); + box.innerHTML = ""; + [["base", "Base"], ["fn", "Fn"]].forEach(([id, label]) => { + const b = document.createElement("button"); + b.textContent = label; + b.className = "layer" + (id === layer ? " active" : ""); + b.onclick = () => { layer = id; selected = null; buildLayers(); renderBoard(); }; + box.appendChild(b); + }); +} + +function renderBoard() { + const board = $("#board"); + board.innerHTML = ""; + Object.keys(keyEls).forEach(k => delete keyEls[k]); + let maxX = 0, maxRow = 0; + model.keys.forEach(k => { + const d = document.createElement("div"); + d.className = "key"; + keyEls[k.num] = d; + if (k.num === selected) d.classList.add("selected"); + if (k.num in edits[layer]) d.classList.add("changed"); + if (pressed.has(k.num)) d.classList.add("pressed"); + d.style.left = (k.x * U) + "px"; + d.style.top = (k.row * U) + "px"; + d.style.width = (k.w * U - GAP) + "px"; + d.style.height = (U - GAP) + "px"; + d.textContent = codeName(shown(k.num)); + d.title = "key " + k.num; + d.onclick = () => { selected = selected === k.num ? null : k.num; renderBoard(); }; + board.appendChild(d); + maxX = Math.max(maxX, k.x + k.w); + maxRow = Math.max(maxRow, k.row); + }); + board.style.width = (maxX * U) + "px"; + board.style.height = ((maxRow + 1) * U) + "px"; + updateHint(); +} + +function buildPalette() { + const pal = $("#palette"); + pal.innerHTML = ""; + DATA.palette.forEach(g => { + const sec = document.createElement("div"); + sec.className = "group"; + const h = document.createElement("div"); + h.className = "ghead"; + h.textContent = g.name; + sec.appendChild(h); + const row = document.createElement("div"); + row.className = "gkeys"; + g.keys.forEach(c => { + const b = document.createElement("button"); + b.className = "cap"; + b.textContent = c.name; + b.title = "0x" + c.code.toString(16).padStart(2, "0"); + b.onclick = () => assign(c.code); + row.appendChild(b); + }); + sec.appendChild(row); + pal.appendChild(sec); + }); +} + +function assign(code) { + if (selected == null) { toast("Select a key first"); return; } + if ((live[layer][selected] ?? 0) === code) delete edits[layer][selected]; + else edits[layer][selected] = code; + renderBoard(); +} + +function buildCodeMap() { + const m = {}; + model.keys.forEach(k => { + const code = USAGE_TO_CODE[k.def]; + if (code) m[code] = k.num; + }); + return m; +} + +// Focus-gated: we listen only while this page has focus, never in the background. +function setTest(on) { + testMode = on; + $("#test").classList.toggle("active", on); + const recv = $("#recv"); + if (on) { + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + recv.hidden = false; + recv.textContent = "Press a key. (The Fn key is handled inside the keyboard and never reaches the computer.)"; + toast("Key test ON — only while this page is focused. Click again to stop."); + } else { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + pressed.forEach(n => keyEls[n] && keyEls[n].classList.remove("pressed")); + pressed.clear(); + recv.hidden = true; + } +} + +function onKeyDown(e) { + const usage = CODE_TO_USAGE[e.code]; + if (usage != null && !(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault(); + $("#recv").textContent = "Keyboard sent: " + (usage != null ? codeName(usage) : e.code); + const num = codeToNum[e.code]; + if (num == null) return; // e.g. an Fn-layer result like Up has no base position + pressed.add(num); + if (keyEls[num]) keyEls[num].classList.add("pressed"); +} + +function onKeyUp(e) { + const num = codeToNum[e.code]; + if (num == null) return; + pressed.delete(num); + if (keyEls[num]) keyEls[num].classList.remove("pressed"); +} + +// baseLabel is what's printed on the physical key (its base-layer assignment). +function baseLabel(num) { + return codeName(live.base[num] ?? defByNum[num] ?? 0); +} + +function updateHint() { + const h = $("#hint"); + const n = changedNums().length; + const pend = n ? ` • ${n} unsaved (press Write)` : ""; + if (selected == null) { + h.textContent = (layer === "fn" + ? "Fn layer — click a key to set what “Fn + that key” sends." + : "Click a key, then pick a keycode below.") + pend; + return; + } + const lhs = layer === "fn" ? `Fn + ${baseLabel(selected)}` : baseLabel(selected); + h.textContent = `${lhs} → ${codeName(shown(selected))}${pend}`; +} + +function changedNums() { return Object.keys(edits[layer]).map(Number); } + +async function write() { + const nums = changedNums(); + if (nums.length === 0) { toast("No changes to write"); return; } + for (const num of nums) { + const res = await api("/api/remap", { + method: "POST", + body: JSON.stringify({ num, code: edits[layer][num], fn: layer === "fn" }), + }); + if (res.error) { toast(res.error); return; } + live[layer][num] = edits[layer][num]; + delete edits[layer][num]; + renderBoard(); + } + toast("Written to keyboard"); +} + +async function readAll() { + for (const lyr of ["base", "fn"]) { + const res = await api("/api/keymap?layer=" + lyr); + if (res.error) { toast(res.error); return; } + live[lyr] = {}; + res.keys.forEach(k => { live[lyr][k.num] = k.code; names[k.code] = k.name; }); + } + edits.base = {}; + edits.fn = {}; + renderBoard(); + toast("Read from keyboard"); +} + +async function reset() { + if (!confirm("Restore the factory default keymap on the keyboard?")) return; + const res = await api("/api/reset", { method: "POST" }); + if (res.error) { toast(res.error); return; } + await readAll(); + toast("Reset to factory defaults"); +} + +function save() { + const data = { model: model.name, base: {}, fn: {} }; + ["base", "fn"].forEach(l => + model.keys.forEach(k => { + const e = edits[l], li = live[l]; + data[l][k.num] = (k.num in e) ? e[k.num] : (li[k.num] ?? 0); + })); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "hkkb-keymap.json"; + a.click(); +} + +function load(file) { + const r = new FileReader(); + r.onload = () => { + try { + const data = JSON.parse(r.result); + ["base", "fn"].forEach(l => { + for (const num in (data[l] || {})) { + if (!(num in defByNum)) continue; // ignore keys not on this model + const code = data[l][num]; + const cur = live[l][num] ?? (l === "base" ? defByNum[num] : 0); + if (cur !== code) edits[l][num] = code; + } + }); + renderBoard(); + toast("Loaded — review the changes, then Write"); + } catch (e) { + toast("Bad file: " + e); + } + }; + r.readAsText(file); +} + +let toastTimer; +function toast(msg) { + const t = $("#toast"); + t.textContent = msg; + t.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => (t.hidden = true), 3500); +} + +function showBanner(dev) { + const b = $("#banner"); + if (dev.connected) { b.hidden = true; return; } + b.hidden = false; + b.textContent = dev.error || "No keyboard connected — showing factory defaults."; +} + +window.addEventListener("DOMContentLoaded", () => { + $("#read").onclick = readAll; + $("#write").onclick = write; + $("#reset").onclick = reset; + $("#save").onclick = save; + $("#load").onclick = () => $("#file").click(); + $("#file").onchange = e => e.target.files[0] && load(e.target.files[0]); + $("#test").onclick = () => setTest(!testMode); + boot().catch(e => toast(String(e))); +}); diff --git a/cmd/hhkb-web/web/index.html b/cmd/hhkb-web/web/index.html new file mode 100644 index 0000000..aba19d3 --- /dev/null +++ b/cmd/hhkb-web/web/index.html @@ -0,0 +1,36 @@ + + + + + +hkkb — HHKB Keymap + + + +
+ hkkb + +
+ + + + + + + + +
+ + +
+ + +
+
+
+
+ + + + + diff --git a/cmd/hhkb-web/web/style.css b/cmd/hhkb-web/web/style.css new file mode 100644 index 0000000..1c6d093 --- /dev/null +++ b/cmd/hhkb-web/web/style.css @@ -0,0 +1,88 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font: 14px/1.4 -apple-system, "Segoe UI", Roboto, sans-serif; + background: #eceff1; + color: #263238; +} + +/* toolbar */ +.toolbar { + display: flex; align-items: center; gap: 14px; + background: #263238; color: #eceff1; + padding: 0 16px; height: 52px; +} +.brand { font-weight: 700; color: #26c6da; letter-spacing: .5px; font-size: 16px; } +.toolbar label { font-size: 13px; color: #b0bec5; display: flex; gap: 6px; align-items: center; } +.toolbar select { + background: #37474f; color: #fff; border: 1px solid #455a64; + border-radius: 4px; padding: 5px 8px; font-size: 13px; +} +.layers { display: flex; gap: 4px; } +.layer { + background: #37474f; color: #cfd8dc; border: 1px solid #455a64; + border-radius: 4px; padding: 5px 14px; cursor: pointer; font-size: 13px; +} +.layer.active { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; } +.spacer { flex: 1; } +.toolbar button { + background: #37474f; color: #eceff1; border: 1px solid #455a64; + border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 13px; +} +.toolbar button:hover { background: #455a64; } +.toolbar button.primary { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; } +.toolbar button.primary:hover { background: #4dd0e1; } + +/* banners */ +.banner { + background: #fff3cd; border: 1px solid #ffe69c; color: #664d03; + padding: 10px 16px; margin: 12px 16px; border-radius: 6px; + white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 12px; +} +.hint { margin: 14px 16px 6px; color: #546e7a; font-size: 13px; min-height: 18px; } +.recv { margin: 0 16px 8px; color: #00695c; font-size: 13px; font-weight: 600; } + +/* keyboard */ +.board { + position: relative; margin: 4px 16px 24px; + background: #cfd8dc; border-radius: 10px; padding: 10px; + box-shadow: inset 0 0 0 1px #b0bec5; +} +.key { + position: absolute; + background: linear-gradient(#ffffff, #eceff1); + border: 1px solid #b0bec5; border-bottom-width: 2px; + border-radius: 5px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 11px; color: #37474f; text-align: center; + overflow: hidden; user-select: none; padding: 0 2px; +} +.key:hover { border-color: #26c6da; } +.key.selected { outline: 2px solid #26c6da; outline-offset: -1px; z-index: 2; box-shadow: 0 0 0 3px rgba(38,198,218,.25); } +.key.changed { background: linear-gradient(#fff6e6, #ffe2b3); border-color: #e0a040; } + +/* palette */ +.palette { margin: 0 16px 48px; display: flex; flex-wrap: wrap; gap: 18px; align-items: flex-start; } +.group { background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; padding: 10px 12px; } +.ghead { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: #78909c; margin-bottom: 8px; } +.gkeys { display: flex; flex-wrap: wrap; gap: 5px; max-width: 330px; } +.cap { + min-width: 38px; height: 34px; padding: 0 7px; + background: linear-gradient(#ffffff, #eceff1); + border: 1px solid #b0bec5; border-bottom-width: 2px; + border-radius: 5px; cursor: pointer; font-size: 11px; color: #37474f; +} +.cap:hover { background: #e0f7fa; border-color: #26c6da; } + +/* key-test highlight + toggled buttons */ +.key.pressed { background: linear-gradient(#b9f6ca, #69f0ae); border-color: #00c853; color: #064d2e; box-shadow: 0 0 0 3px rgba(0,200,83,.25); z-index: 1; } +.toolbar button.active { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; } + +/* toast */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: #263238; color: #fff; padding: 10px 18px; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,.25); white-space: pre-wrap; max-width: 80vw; +} + +[hidden] { display: none !important; } diff --git a/doc/base.png b/doc/base.png new file mode 100644 index 0000000..c14b5b3 Binary files /dev/null and b/doc/base.png differ diff --git a/doc/fn.png b/doc/fn.png new file mode 100644 index 0000000..2ece0fb Binary files /dev/null and b/doc/fn.png differ diff --git a/doc/protocol.md b/doc/protocol.md new file mode 100644 index 0000000..945a693 --- /dev/null +++ b/doc/protocol.md @@ -0,0 +1,91 @@ +# HHKB Professional vendor HID protocol + +Reverse-engineered notes for the configuration channel used by PFU's Keymap +Tool, as implemented in `internal/hhkb`. Verified against the HHKB Professional +Classic (`04fe:0020`); the Hybrid (`0021`/`0022`) is the same. + +## Transport + +The keyboard exposes three USB-HID interfaces. Interface 2 is vendor-defined and +carries the config protocol. Its report descriptor begins: + +``` +06 00 ff Usage Page (Vendor-Defined 0xFF00) +09 01 Usage 1 +a1 01 Collection (Application) +09 02 ... 75 08 95 40 81 02 Input report, 64 bytes +09 03 ... 75 08 95 40 91 02 Output report, 64 bytes +c0 +``` + +We find it by scanning `/sys/class/hidraw/*`: the node whose `device/uevent` +contains HID id `…04FE:00000020` and whose `device/report_descriptor` starts +with `06 00 ff`. + +Reports are 64 bytes with no report id. A `write(2)` to the hidraw node takes a +leading `0x00` report-number byte (65 bytes total); the kernel strips it. + +## Framing + +Request (64 bytes): + +``` +byte 0 0xAA +byte 1 0xAA +byte 2 command +byte 3+ arguments +``` + +Reply (64 bytes): + +``` +byte 0 0x55 acknowledgement +byte 1 0x55 +byte 2+ status / payload (data fields start at byte 6) +``` + +## Commands + +| id | name | request args | reply payload (from byte 6) | +|-----|-----------------|-----------------------------|------------------------------------| +| 1 | notify app | `00 01 ` | — | +| 2 | get info | — | type[20] rev[4] serial[16] fw… | +| 3 | factory reset | — | header `55 55 03 00` on success | +| 4 | confirm keymap | — | — | +| 5 | get DIP | — | 6 bytes, one per switch | +| 6 | get mode | — | 1 byte: 0 HHK, 1 Mac, 2 Lite, 3 Secret | +| 7 | reset DIP | `00 01` | — | +| 134 | write keymap | see below | — | +| 135 | get keymap | `00 02 ` | streamed, see below | + +Firmware commands (208, 224–231) exist but are deliberately unimplemented here. + +## Keymap layout + +A layer is 128 bytes: `layer[keyNumber] = scancode`. Key numbers and the US +(ANSI) geometry live in `keymap.go` (`ANSI`). `` selects base (0) or Fn (1). + +### Read (command 135) + +One request, then three input reports tiling the layer (data at byte 6 of each): + +``` +report 1 -> layer[0:58] +report 2 -> layer[58:116] +report 3 -> layer[116:128] +``` + +### Write (command 134) + +Three requests. The pair after the command id marks offset/length; the first +pass also carries mode and fn before the key bytes: + +``` +AA AA 86 41 3B layer[0:57] +AA AA 86 82 3B layer[57:116] +AA AA 86 C3 0C layer[116:128] +``` + +A full remap is: notify-app(open) → get mode → read layer → set the byte → +write (3 passes) → confirm (4) → reset DIP (7) → notify-app(closed). Reversible +via factory reset (3). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ab75f44 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module hkkb + +go 1.25.4 diff --git a/internal/hhkb/device.go b/internal/hhkb/device.go new file mode 100644 index 0000000..45f7253 --- /dev/null +++ b/internal/hhkb/device.go @@ -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 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() +} diff --git a/internal/hhkb/keymap.go b/internal/hhkb/keymap.go new file mode 100644 index 0000000..9f93133 --- /dev/null +++ b/internal/hhkb/keymap.go @@ -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 +} diff --git a/internal/hhkb/models.go b/internal/hhkb/models.go new file mode 100644 index 0000000..9ee6fea --- /dev/null +++ b/internal/hhkb/models.go @@ -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 +} diff --git a/internal/hhkb/palette.go b/internal/hhkb/palette.go new file mode 100644 index 0000000..73ff191 --- /dev/null +++ b/internal/hhkb/palette.go @@ -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 +} diff --git a/internal/hhkb/proto.go b/internal/hhkb/proto.go new file mode 100644 index 0000000..12b4086 --- /dev/null +++ b/internal/hhkb/proto.go @@ -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)) +} diff --git a/internal/hhkb/scancode.go b/internal/hhkb/scancode.go new file mode 100644 index 0000000..130dd52 --- /dev/null +++ b/internal/hhkb/scancode.go @@ -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) +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..5db915a --- /dev/null +++ b/run.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# run.sh — build, ensure the keyboard is accessible, and launch the web editor. +# +# On the first run it asks for your doas password once to install a udev rule +# (and fix the current device node). After that, access is persistent and no +# password is needed. Pass extra flags through to hhkb-web, e.g. ./run.sh -open=false +set -u +cd "$(dirname "$0")" + +echo "[1/3] building hhkb-web…" +go build -o hhkb-web ./cmd/hhkb-web || { echo "build failed"; exit 1; } + +# Locate the HHKB (USB vendor 04fe) hidraw nodes. +node="" +for n in /dev/hidraw*; do + [ -e "$n" ] || continue + if grep -q 000004FE "/sys/class/hidraw/${n##*/}/device/uevent" 2>/dev/null; then + node="$n" + break + fi +done + +if [ -z "$node" ]; then + echo "[2/3] no HHKB detected (is it plugged in?) — starting anyway." +elif [ -r "$node" ] && [ -w "$node" ]; then + echo "[2/3] keyboard already accessible — no password needed." +else + echo "[2/3] granting access (doas password needed once)…" + doas sh -c ' + mkdir -p /etc/udev/rules.d + printf "%s\n" "KERNEL==\"hidraw*\", ATTRS{idVendor}==\"04fe\", MODE=\"0660\", GROUP=\"input\"" \ + > /etc/udev/rules.d/70-hhkb.rules + udevadm control --reload-rules + udevadm trigger + for n in /dev/hidraw*; do + grep -q 000004FE "/sys/class/hidraw/${n##*/}/device/uevent" 2>/dev/null \ + && chgrp input "$n" && chmod g+rw "$n" + done + true + ' && echo " done — rule installed, future runs need no password." \ + || echo " setup failed; the editor will show the access banner." +fi + +pkill -x hhkb-web 2>/dev/null || true # stop any previous instance so the new build is served +echo "[3/3] http://127.0.0.1:8080" +exec ./hhkb-web "$@"