first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# build artifacts
|
||||
/hhkb-web
|
||||
|
||||
# cloned reference repositories, not part of this project
|
||||
/research/
|
||||
52
README.md
Normal file
52
README.md
Normal file
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
<http://127.0.0.1:8080>, 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
|
||||
(<https://gitlab.com/dom/happy-hacking-gnu>).
|
||||
237
cmd/hhkb-web/main.go
Normal file
237
cmd/hhkb-web/main.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
360
cmd/hhkb-web/web/app.js
Normal file
360
cmd/hhkb-web/web/app.js
Normal file
@@ -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)));
|
||||
});
|
||||
36
cmd/hhkb-web/web/index.html
Normal file
36
cmd/hhkb-web/web/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hkkb — HHKB Keymap</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="toolbar">
|
||||
<span class="brand">hkkb</span>
|
||||
<label>Model <select id="model"></select></label>
|
||||
<div class="layers" id="layers"></div>
|
||||
<span class="spacer"></span>
|
||||
<button id="test">Key test</button>
|
||||
<button id="read">Read</button>
|
||||
<button id="write" class="primary">Write</button>
|
||||
<button id="reset">Reset…</button>
|
||||
<button id="save">Save</button>
|
||||
<button id="load">Load</button>
|
||||
<input type="file" id="file" accept=".json" hidden>
|
||||
</header>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="hint" class="hint"></div>
|
||||
<div id="recv" class="recv" hidden></div>
|
||||
|
||||
<main>
|
||||
<section id="board" class="board"></section>
|
||||
<section id="palette" class="palette"></section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
88
cmd/hhkb-web/web/style.css
Normal file
88
cmd/hhkb-web/web/style.css
Normal file
@@ -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; }
|
||||
BIN
doc/base.png
Normal file
BIN
doc/base.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
doc/fn.png
Normal file
BIN
doc/fn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
91
doc/protocol.md
Normal file
91
doc/protocol.md
Normal file
@@ -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 <closed>` | — |
|
||||
| 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 <mode> <fn>` | 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`). `<fn>` 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 <mode> <fn> 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).
|
||||
123
internal/hhkb/device.go
Normal file
123
internal/hhkb/device.go
Normal 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
43
internal/hhkb/keymap.go
Normal 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
110
internal/hhkb/models.go
Normal 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
29
internal/hhkb/palette.go
Normal 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
168
internal/hhkb/proto.go
Normal 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
39
internal/hhkb/scancode.go
Normal 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)
|
||||
}
|
||||
46
run.sh
Executable file
46
run.sh
Executable file
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user