commit cffc6fd992adecece5a21c48f322653b4a7c5615 Author: Alexandre Bourget Date: Fri Jul 14 02:41:48 2017 -0400 First draft. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..92d6c8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Alexandre Bourget + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb5bbe6 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +Linux driver for Contour Design Shuttle Pro V2 +============================================== + +My goal is to set it up for the Lightworks Non-Linear Editor. + + + + +Buttons layout on the Contour Design Shuttle Pro v2: + + + F1 F2 F3 F4 + + F5 F6 F7 F8 F9 + + + (Shuttle) + S-7 .. S-1 S0 S1 .. S7 + + M1 JogL JogR M2 + + + + B2 B3 + B1 B4 + + +See diff --git a/config.go b/config.go new file mode 100644 index 0000000..d2ffd28 --- /dev/null +++ b/config.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "regexp" + "strings" +) + +var loadedConfiguration = &Config{} +var currentConfiguration *AppConfig + +type Config struct { + Apps []*AppConfig `json:"apps"` +} + +type AppConfig struct { + Name string `json:"name"` + MatchWindowTitles []string `json:"match_window_titles"` + windowTitleRegexps []*regexp.Regexp + Bindings map[string]string `json:"bindings"` + bindings []*deviceBinding +} + +func (ac *AppConfig) parse() error { + if len(ac.MatchWindowTitles) == 0 { + ac.windowTitleRegexps = []*regexp.Regexp{ + regexp.MustCompile(`.*`), + } + return nil + } + + for _, window := range ac.MatchWindowTitles { + re, err := regexp.Compile(window) + if err != nil { + return fmt.Errorf("Invalid regexp in window match %q: %s", window, err) + } + + ac.windowTitleRegexps = append(ac.windowTitleRegexps, re) + } + + return nil +} + +type deviceBinding struct { + // Input + heldButtons map[int]bool + buttonDown int + otherKey string + + // Output + holdButtons []string + pressButton string +} + +func (ac *AppConfig) parseBindings() error { + for key, value := range ac.Bindings { + newBinding := &deviceBinding{heldButtons: make(map[int]bool)} + + // Input + input := strings.Split(key, "+") + for idx, part := range input { + cleanPart := strings.TrimSpace(part) + key := strings.ToUpper(cleanPart) + if shuttleKeys[key] == 0 && !otherShuttleKeysUpper[key] { + return fmt.Errorf("invalid shuttle device key map: %q doesn't exist", cleanPart) + } + if idx == len(input)-1 { + if shuttleKeys[key] != 0 { + newBinding.buttonDown = shuttleKeys[key] + } else { + newBinding.otherKey = key + } + } else { + keyID := shuttleKeys[key] + if keyID == 0 { + return fmt.Errorf("binding %q, expects a button press, not a shuttle or jog movement") + } + newBinding.heldButtons[keyID] = true + } + } + + // Output + output := strings.Split(value, "+") + for idx, part := range output { + cleanPart := strings.TrimSpace(part) + buttonName := strings.ToUpper(cleanPart) + if keyboardKeysUpper[buttonName] == 0 { + return fmt.Errorf("keyboard key unknown: %q", cleanPart) + } + if idx == len(output)-1 { + newBinding.pressButton = buttonName + } else { + newBinding.holdButtons = append(newBinding.holdButtons, buttonName) + } + } + + ac.bindings = append(ac.bindings, newBinding) + + fmt.Printf("BINDING: %#v\n", newBinding) + } + + return nil +} + +func LoadConfig(filename string) error { + cnt, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + newConfig := &Config{} + err = json.Unmarshal(cnt, &newConfig) + if err != nil { + return err + } + + for _, app := range newConfig.Apps { + if err := app.parse(); err != nil { + return fmt.Errorf("Error parsing app %q's matchers: %s", app.Name, err) + } + + if err := app.parseBindings(); err != nil { + return fmt.Errorf("Error parsing app %q's bindings: %s", app.Name, err) + } + + } + + loadedConfiguration = newConfig + + return nil +} diff --git a/definitions.go b/definitions.go new file mode 100644 index 0000000..fc840ed --- /dev/null +++ b/definitions.go @@ -0,0 +1,297 @@ +package main + +import "strings" + +var shuttleKeys = map[string]int{ + "F1": 256, + "F2": 257, + "F3": 258, + "F4": 259, + "F5": 260, + "F6": 261, + "F7": 262, + "F8": 263, + "F9": 264, + "B1": 267, + "B2": 265, + "B3": 266, + "B4": 268, + "M1": 269, + "M2": 270, +} + +var otherShuttleKeys = map[string]bool{ + "S-7": true, + "S-6": true, + "S-5": true, + "S-4": true, + "S-3": true, + "S-2": true, + "S-1": true, + "S0": true, + "S1": true, + "S2": true, + "S3": true, + "S4": true, + "S5": true, + "S6": true, + "S7": true, + "JogL": true, + "JogR": true, +} + +var keyboardKeys = map[string]int{ + "Esc": 1, + "1": 2, + "2": 3, + "3": 4, + "4": 5, + "5": 6, + "6": 7, + "7": 8, + "8": 9, + "9": 10, + "0": 11, + "Minus": 12, + "-": 12, + "Equal": 13, + "=": 13, + "Backspace": 14, + "Tab": 15, + "Q": 16, + "W": 17, + "E": 18, + "R": 19, + "T": 20, + "Y": 21, + "U": 22, + "I": 23, + "O": 24, + "P": 25, + "LeftBrace": 26, + "RightBrace": 27, + "{": 26, + "}": 27, + "Enter": 28, + "LeftCtrl": 29, + "Ctrl": 29, + "A": 30, + "S": 31, + "D": 32, + "F": 33, + "G": 34, + "H": 35, + "J": 36, + "K": 37, + "L": 38, + "Semicolon": 39, + ";": 39, + "Apostrophe": 40, + "'": 40, + "Grave": 41, + "LeftShift": 42, + "Shift": 42, + "Backslash": 43, + "\\": 43, + "Z": 44, + "X": 45, + "C": 46, + "V": 47, + "B": 48, + "N": 49, + "M": 50, + "Comma": 51, + ",": 51, + "Dot": 52, + ".": 52, + "Slash": 53, + "/": 53, + "RightShift": 54, + "RShift": 54, + "KPAsterisk": 55, + "*": 55, + "LeftAlt": 56, + "Alt": 56, + "Space": 57, + "CapsLock": 58, + "F1": 59, + "F2": 60, + "F3": 61, + "F4": 62, + "F5": 63, + "F6": 64, + "F7": 65, + "F8": 66, + "F9": 67, + "F10": 68, + "NumLock": 69, + "ScrollLock": 70, + "KP7": 71, + "KP8": 72, + "KP9": 73, + "KPMinus": 74, + "KP4": 75, + "KP5": 76, + "KP6": 77, + "KPPlus": 78, + "KP1": 79, + "KP2": 80, + "KP3": 81, + "KP0": 82, + "KPDot": 83, + "F11": 87, + "F12": 88, + "KPEnter": 96, + "RightCtrl": 97, + "RCtrl": 97, + "RightAlt": 100, + "RAlt": 100, + "Linefeed": 101, + "Home": 102, + "Up": 103, + "PageUp": 104, + "PgUp": 104, + "Left": 105, + "Right": 106, + "End": 107, + "Down": 108, + "PageDown": 109, + "PgDown": 109, + "PgDn": 109, + "Insert": 110, + "Delete": 111, + "Macro": 112, + "Mute": 113, + "VolumeDown": 114, + "VolumeUp": 115, + "Power": 116, /*ScSystemPowerDown*/ + "KPEqual": 117, + "KPPlusMinus": 118, + "Pause": 119, + "Scale": 120, /*AlCompizScale(Expose)*/ + "KPComma": 121, + "LeftMeta": 125, + "Meta": 125, + "RightMeta": 126, + "RMeta": 126, + "Compose": 127, + "Stop": 128, /*AcStop*/ + "Again": 129, + "Props": 130, /*AcProperties*/ + "Undo": 131, /*AcUndo*/ + "Front": 132, + "Copy": 133, /*AcCopy*/ + "Open": 134, /*AcOpen*/ + "Paste": 135, /*AcPaste*/ + "Find": 136, /*AcSearch*/ + "Cut": 137, /*AcCut*/ + "Help": 138, /*AlIntegratedHelpCenter*/ + "Menu": 139, /*Menu(ShowMenu)*/ + "Calc": 140, /*AlCalculator*/ + "Setup": 141, + "Sleep": 142, /*ScSystemSleep*/ + "Wakeup": 143, /*SystemWakeUp*/ + "File": 144, /*AlLocalMachineBrowser*/ + "SendFile": 145, + "DeleteFile": 146, + "Xfer": 147, + "Prog1": 148, + "Prog2": 149, + "WWW": 150, /*AlInternetBrowser*/ + "Coffee": 152, /*AlTerminalLock/Screensaver*/ + "Direction": 153, + "CycleWindows": 154, + "Mail": 155, + "Bookmarks": 156, /*AcBookmarks*/ + "Computer": 157, + "Back": 158, /*AcBack*/ + "Forward": 159, /*AcForward*/ + "CloseCD": 160, + "EjectCD": 161, + "EjectCloseCD": 162, + "NextSong": 163, + "PlayPause": 164, + "PreviousSong": 165, + "StopCD": 166, + "Record": 167, + "Rewind": 168, + "Phone": 169, /*MediaSelectTelephone*/ + "ISO": 170, + "Config": 171, /*AlConsumerControlConfiguration*/ + "Homepage": 172, /*AcHome*/ + "Refresh": 173, /*AcRefresh*/ + "Exit": 174, /*AcExit*/ + "Move": 175, + "Edit": 176, + "ScrollUp": 177, + "ScrollDown": 178, + "KPLeftParen": 179, + "(": 179, + "KPRightParen": 180, + ")": 180, + "New": 181, /*AcNew*/ + "Redo": 182, /*AcRedo/Repeat*/ + "F13": 183, + "F14": 184, + "F15": 185, + "F16": 186, + "F17": 187, + "F18": 188, + "F19": 189, + "F20": 190, + "F21": 191, + "F22": 192, + "F23": 193, + "F24": 194, + "PlayCD": 200, + "PauseCD": 201, + "Prog3": 202, + "Prog4": 203, + "Dashboard": 204, /*AlDashboard*/ + "Suspend": 205, + "Close": 206, /*AcClose*/ + "Play": 207, + "FastForward": 208, + "Print": 210, /*AcPrint*/ + "Camera": 212, + "Sound": 213, + "Question": 214, + "Email": 215, + "Chat": 216, + "Search": 217, + "Connect": 218, + "Finance": 219, /*AlCheckbook/Finance*/ + "Sport": 220, + "Shop": 221, + "AltErase": 222, + "Cancel": 223, /*AcCancel*/ + "BrightnessDown": 224, + "BrightnessUp": 225, + "Media": 226, + "Send": 231, /*AcSend*/ + "Reply": 232, /*AcReply*/ + "ForwardMail": 233, /*AcForwardMsg*/ + "Save": 234, /*AcSave*/ + "Documents": 235, + "BrightnessCycle": 243, /*BrightnessUp,AfterMaxIsMin*/ + "BrightnessZero": 244, /*BrightnessOff,UseAmbient*/ + "DisplayOff": 245, /*DisplayDeviceToOffState*/ + "Rfkill": 247, /*KeyThatControlsAllRadios*/ + "Micmute": 248, /*Mute/UnmuteTheMicrophone*/ +} + +//var reverseShuttleKeys map[int]string +var keyboardKeysUpper = map[string]int{} +var otherShuttleKeysUpper = map[string]bool{} + +func init() { + // for k, v := range shuttleKeys { + // reverseShuttleKeys[v] = k + // } + for k, v := range keyboardKeys { + keyboardKeysUpper[strings.ToUpper(k)] = v + } + for k, v := range otherShuttleKeys { + otherShuttleKeysUpper[strings.ToUpper(k)] = v + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..39bc1e6 --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/bendahl/uinput" + "github.com/gvalkov/golang-evdev" +) + +var configFile = flag.String("config", filepath.Join(os.Getenv("HOME"), ".shuttle-go.json"), "Location to the .shuttle-go.json configuration") + +func main() { + flag.Parse() + + if len(flag.Args()) != 1 { + fmt.Println("Missing device name as parameter.\nExample: [program] /dev/input/by-id/usb-Contour_Design_ShuttlePRO_v2-event-if00\n") + os.Exit(1) + } + + err := LoadConfig(*configFile) + if err != nil { + fmt.Println("Error reading configuration:", err) + os.Exit(10) + } + + // X-window title change watcher + watcher := NewWindowWatcher() + if err := watcher.Setup(); err != nil { + fmt.Println("Error watching X window:", err) + os.Exit(3) + } + + go watcher.Run() + + // Virtual keyboard + vk, err := uinput.CreateKeyboard("/dev/uinput", []byte("Shuttle Pro V2")) + if err != nil { + log.Println("Can't open dev:", err) + } + + // Shuttle device event receiver + dev, err := evdev.Open(flag.Arg(0)) + if err != nil { + fmt.Println("Couldn't open Shuttle device:", err) + os.Exit(2) + } + + fmt.Println("ready") + mapper := NewMapper(vk, dev) + for { + if err := mapper.Process(); err != nil { + fmt.Println("Error processing input events (continuing):", err) + } + } + +} diff --git a/mapper.go b/mapper.go new file mode 100644 index 0000000..077451f --- /dev/null +++ b/mapper.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + "reflect" + "strings" + + "github.com/bendahl/uinput" + evdev "github.com/gvalkov/golang-evdev" +) + +// Mapper receives events from the Shuttle devices, and maps (through +// configuration) to the Virtual Keyboard events. +type Mapper struct { + virtualKeyboard uinput.Keyboard + inputDevice *evdev.InputDevice + state buttonsState +} + +type buttonsState struct { + jog int + shuttle int + buttonsHeld map[int]bool +} + +func NewMapper(virtualKeyboard uinput.Keyboard, inputDevice *evdev.InputDevice) *Mapper { + m := &Mapper{ + virtualKeyboard: virtualKeyboard, + inputDevice: inputDevice, + } + m.state.buttonsHeld = make(map[int]bool) + m.state.jog = -1 + return m +} + +func (m *Mapper) Process() error { + evs, err := m.inputDevice.Read() + if err != nil { + return err + } + + fmt.Println("---") + m.dispatch(evs) + + return nil +} + +func (m *Mapper) dispatch(evs []evdev.InputEvent) { + newJogVal := jogVal(evs) + if m.state.jog != newJogVal { + if m.state.jog != -1 { + // Trigger JL or JR if we're advancing or not.. + delta := newJogVal - m.state.jog + if (delta > 0 || delta < -200) && (delta < 200) { + if err := m.EmitOther("JogR"); err != nil { + fmt.Println("Jog right:", err) + } + } else { + if err := m.EmitOther("JogL"); err != nil { + fmt.Println("Jog left:", err) + } + } + } + m.state.jog = newJogVal + } + + newShuttleVal := shuttleVal(evs) + if m.state.shuttle != newShuttleVal { + keyName := fmt.Sprintf("S%d", newShuttleVal) + if err := m.EmitOther(keyName); err != nil { + fmt.Println("Shuttle movement %q: %s\n", keyName, err) + } + m.state.shuttle = newShuttleVal + } + + for _, ev := range evs { + if ev.Type != 1 { + continue + } + + heldButtons, lastDown := buttonVals(m.state.buttonsHeld, ev) + if lastDown != 0 { + modifiers := buttonsToModifiers(heldButtons, lastDown) + if err := m.EmitKeys(modifiers, lastDown); err != nil { + fmt.Println("Button press:", err) + } + // fmt.Printf("OUTPUT: Modifiers: %v, Just pressed: %d\n", modifiers, lastDown) + } + m.state.buttonsHeld = heldButtons + } + + //fmt.Printf("TYPE: %d\tCODE: %d\tVALUE: %d\n", ev.Type, ev.Code, ev.Value) + // TODO: Lock on configuration changes + + return +} + +func (m *Mapper) EmitOther(key string) error { + conf := currentConfiguration + if conf == nil { + return fmt.Errorf("No configuration for this Window") + } + + upperKey := strings.ToUpper(key) + + for _, binding := range conf.bindings { + if binding.otherKey == upperKey { + return m.executeBinding(binding.holdButtons, binding.pressButton) + } + } + + return fmt.Errorf("No bindings for those movements") +} + +func (m *Mapper) EmitKeys(modifiers map[int]bool, keyDown int) error { + conf := currentConfiguration + if conf == nil { + return fmt.Errorf("No configuration for this Window") + } + + for _, binding := range conf.bindings { + if reflect.DeepEqual(binding.heldButtons, modifiers) && binding.buttonDown == keyDown { + return m.executeBinding(binding.holdButtons, binding.pressButton) + } + } + + return fmt.Errorf("No binding for these keys") +} + +func (m *Mapper) executeBinding(holdButtons []string, pressButton string) error { + fmt.Println("Executing bindings:", holdButtons, pressButton) + for _, button := range holdButtons { + if err := m.virtualKeyboard.KeyDown(keyboardKeysUpper[button]); err != nil { + return err + } + } + + if err := m.virtualKeyboard.KeyPress(keyboardKeysUpper[pressButton]); err != nil { + return err + } + + for _, button := range holdButtons { + if err := m.virtualKeyboard.KeyUp(keyboardKeysUpper[button]); err != nil { + return err + } + } + + return nil +} + +func jogVal(evs []evdev.InputEvent) int { + for _, ev := range evs { + if ev.Type == 2 && ev.Code == 7 { + return int(ev.Value) + } + } + return 0 +} + +func shuttleVal(evs []evdev.InputEvent) int { + for _, ev := range evs { + if ev.Type == 2 && ev.Code == 8 { + return int(ev.Value) + } + } + return 0 +} + +func buttonVals(current map[int]bool, ev evdev.InputEvent) (out map[int]bool, lastDown int) { + out = current + + if ev.Value == 1 { + current[int(ev.Code)] = true + } else { + delete(current, int(ev.Code)) + } + + if ev.Value == 1 { + lastDown = int(ev.Code) + } + + return +} + +func buttonsToModifiers(held map[int]bool, buttonDown int) (out map[int]bool) { + out = make(map[int]bool) + for k := range held { + if k == buttonDown { + continue + } + out[k] = true + } + return +} diff --git a/sample_config.json b/sample_config.json new file mode 100644 index 0000000..123c721 --- /dev/null +++ b/sample_config.json @@ -0,0 +1,32 @@ +{ + "apps": [ + { + "name": "Lightworks", + "match_window_titles": [ + "^Lightworks$", ".*" + ], + "bindings": { + "F5": "a", + "F6": "s", + "F7": "i", + "JogL": "KPLeftParen", + "JogR": "KPRightParen", + "S-7": "KP1+KP2", + "S-6": "KP1+KP3", + "S-5": "KP1+KP4", + "S-4": "KP1+KP5", + "S-3": "j", + "S-2": "KP1+KP7", + "S-1": "KP1+KP8", + "S0": "k", + "S1": "KP2+KP3", + "S2": "KP2+KP4", + "S3": "l", + "S4": "KP2+KP6", + "S5": "KP2+KP7", + "S6": "KP2+KP8", + "S7": "KP2+KP9" + } + } + ] +} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..86e750d --- /dev/null +++ b/watch.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/BurntSushi/xgb" + "github.com/BurntSushi/xgb/xproto" +) + +type watcher struct { + conn *xgb.Conn + root xproto.Window + activeAtom, nameAtom xproto.Atom + prevWindowName string +} + +func NewWindowWatcher() *watcher { + return &watcher{} +} + +func (w *watcher) Setup() error { + X, err := xgb.NewConn() + if err != nil { + return err + } + + // Get the window id of the root window. + setup := xproto.Setup(X) + + w.conn = X + w.root = setup.DefaultScreen(X).Root + + // Get the atom id (i.e., intern an atom) of "_NET_ACTIVE_WINDOW". + aname := "_NET_ACTIVE_WINDOW" + activeAtom, err := xproto.InternAtom(X, true, uint16(len(aname)), + aname).Reply() + if err != nil { + return fmt.Errorf("Couldn't get _NET_ACTIVE_WINDOW atom: %s", err) + } + + // Get the atom id (i.e., intern an atom) of "_NET_WM_NAME". + aname = "_NET_WM_NAME" + nameAtom, err := xproto.InternAtom(X, true, uint16(len(aname)), + aname).Reply() + if err != nil { + return fmt.Errorf("Couldn't get _NET_WM_NAME atom: %s", err) + } + + w.activeAtom = activeAtom.Atom + w.nameAtom = nameAtom.Atom + + return nil +} + +func (w *watcher) Run() { + for { + w.watch() + time.Sleep(2 * time.Second) + } +} + +func (w *watcher) watch() { + // From github.com/BurntSushi/xgb's examples. + reply, err := xproto.GetProperty(w.conn, false, w.root, w.activeAtom, + xproto.GetPropertyTypeAny, 0, (1<<32)-1).Reply() + if err != nil { + log.Fatal(err) + } + windowID := xproto.Window(xgb.Get32(reply.Value)) + + reply, err = xproto.GetProperty(w.conn, false, windowID, w.nameAtom, + xproto.GetPropertyTypeAny, 0, (1<<32)-1).Reply() + if err != nil { + log.Fatal(err) + } + + windowName := string(reply.Value) + if w.prevWindowName != windowName { + w.prevWindowName = windowName + + w.loadWindowConfiguration(windowName) + } +} + +func (w *watcher) loadWindowConfiguration(windowName string) { + if loadedConfiguration == nil { + fmt.Println("Window name switched, but no configuration:", windowName) + return + } + + for _, conf := range loadedConfiguration.Apps { + for _, re := range conf.windowTitleRegexps { + if re.MatchString(windowName) { + currentConfiguration = conf + fmt.Printf("Applying configuration for app %q\n", conf.Name) + return + } + } + } + currentConfiguration = nil +}