├── client ├── utils.go ├── go.mod ├── swap_device.go ├── websocket_client.go ├── main.go ├── go.sum └── icon │ └── icon.go ├── server ├── go.mod ├── drivers │ ├── blustream │ │ ├── status-response.txt │ │ ├── bluestream_status_reader.go │ │ └── bluestream.go │ ├── drivers.go │ └── startech_kvm │ │ └── startech_kvm.go ├── messages.go ├── go.sum ├── layout_test.go ├── http.go ├── hub.go ├── home.html ├── main.go ├── layout.go └── client.go ├── docs └── API_Endpoints.md └── README.md /client/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | func doEvery(d time.Duration, f func(time.Time)) { 6 | for x := range time.Tick(d) { 7 | f(x) 8 | } 9 | } -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/timgws/kvm-switch/server 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.0 7 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 8 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /client/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/timgws/kvm-switch/client 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/getlantern/systray v1.2.1 7 | github.com/go-vgo/robotgo v1.0.0-beta5.1 8 | github.com/jpillora/backoff v1.0.0 // indirect 9 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /server/drivers/blustream/status-response.txt: -------------------------------------------------------------------------------- 1 | ================================================================ 2 | HDMI CMX44AB Status 3 | FW Version: 2.22 4 | 5 | Power IR Key Beep 6 | On On On Off 7 | 8 | Input Edid HDMIcon 9 | 01 Force___11 On 10 | 02 Force___11 On 11 | 03 Force___11 Off 12 | 04 Force___11 Off 13 | 14 | Output FromIn HDMIcon OutputEn OSP Mute 15 | 01 01 Off Yes SNK Off 16 | 02 02 On Yes SNK Off 17 | ================================================================ 18 | -------------------------------------------------------------------------------- /server/messages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Direction int64 4 | const ( 5 | Left Direction = iota 6 | Right 7 | Top 8 | Bottom 9 | ) 10 | 11 | // IncomingAction is what is received by a client, connecting to a server. 12 | // Server is to determine what actions (if any) are to be performed after receiving an action/command. 13 | // { "action_name": "switch_direction", "action_direction": Left } 14 | type IncomingAction struct { 15 | ActionName string 16 | ActionDirection Direction `json:"optional"` 17 | } 18 | 19 | // BroadcastAction is what will be sent to all connected clients when an operation has been performed by the server 20 | // { "action_name": "active_computer", "value": "pc1" } 21 | type BroadcastAction struct { 22 | ActionName string 23 | } -------------------------------------------------------------------------------- /server/go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 2 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 4 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= 6 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 7 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= 8 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 9 | -------------------------------------------------------------------------------- /client/swap_device.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | // SwapDevice is the action to broadcast to the server, requesting for actions to be performed. 11 | type SwapDevice struct { 12 | Device string `json:"device"` 13 | Direction string `json:"direction"` 14 | } 15 | 16 | // Unmarshal reads in SwapDevice commands that may have been sent from other clients. 17 | func (sd *SwapDevice) Unmarshal(data []byte) error { 18 | err := json.Unmarshal(data, sd) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | fields := reflect.ValueOf(sd).Elem() 24 | for i := 0; i < fields.NumField(); i++ { 25 | 26 | dsTags := fields.Type().Field(i).Tag.Get("kvm") 27 | if strings.Contains(dsTags, "required") && fields.Field(i).IsZero() { 28 | return errors.New("required field is missing") 29 | } 30 | 31 | } 32 | return nil 33 | } -------------------------------------------------------------------------------- /server/layout_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/timgws/kvm-switch/server/drivers/blustream" 6 | "github.com/timgws/kvm-switch/server/drivers/startech_kvm" 7 | "log" 8 | "testing" 9 | ) 10 | 11 | func TestLayoutDirection(t *testing.T) { 12 | layout := BuildLayout() 13 | actions, err := layout.FindActions("home-computer", "left") 14 | 15 | if len(*actions) > 0 { 16 | t.Logf("Found %d actions...", len(*actions)) 17 | } 18 | 19 | if err != nil { 20 | t.Fatalf("There was an error: %s", err) 21 | } 22 | } 23 | 24 | func TestLayoutWithDriver(t *testing.T) { 25 | Drivers.Drivers = append(Drivers.Drivers, blustream.NewInstance()) 26 | Drivers.Drivers = append(Drivers.Drivers, startech_kvm.NewInstance()) 27 | 28 | layout := BuildLayout() 29 | 30 | actions, _ := layout.FindActions("home-computer", "left") 31 | k, _ := json.Marshal(actions) 32 | log.Printf("%s", k) 33 | 34 | actions, err := layout.FindActions("home-computer", "right") 35 | k, _ = json.Marshal(actions) 36 | log.Printf("%s %s", k, err) 37 | 38 | //layout.Effect(actions) 39 | } -------------------------------------------------------------------------------- /server/http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func serveHome(w http.ResponseWriter, r *http.Request) { 12 | log.Println(r.URL) 13 | if r.URL.Path != "/" { 14 | http.Error(w, "Not found", http.StatusNotFound) 15 | return 16 | } 17 | if r.Method != http.MethodGet { 18 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 19 | return 20 | } 21 | http.ServeFile(w, r, "home.html") 22 | } 23 | 24 | func serveLayout(w http.ResponseWriter, r *http.Request) { 25 | log.Println(r.URL) 26 | w.Header().Add("Content-Type", "application/json") 27 | _, err := w.Write(JSONLayout) 28 | if err != nil { 29 | fmt.Println(err) 30 | } 31 | } 32 | 33 | func serveDriverStatus(w http.ResponseWriter, r *http.Request) { 34 | log.Println(r.URL) 35 | w.Header().Add("Content-Type", "application/json") 36 | 37 | drivers, _ := json.Marshal(Drivers) 38 | 39 | _, err := w.Write(drivers) 40 | if err != nil { 41 | fmt.Println(err) 42 | } 43 | } 44 | 45 | func serveRefreshStatus(w http.ResponseWriter, r *http.Request) { 46 | log.Println(r.URL) 47 | for _, driver := range Drivers.Drivers { 48 | driver.GetStatus() 49 | } 50 | } 51 | 52 | func serveSwap(w http.ResponseWriter, r *http.Request) { 53 | actions, _ := TheLayout.FindActions("home-computer", "left") 54 | TheLayout.Effect(actions) 55 | 56 | time.Sleep(500 * time.Millisecond) 57 | } -------------------------------------------------------------------------------- /server/hub.go: -------------------------------------------------------------------------------- 1 | 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | // Hub maintains the set of active clients and broadcasts messages to the 9 | // clients. 10 | type Hub struct { 11 | // Registered clients. 12 | clients map[*Client]bool 13 | 14 | // Inbound messages from the clients. 15 | broadcast chan []byte 16 | 17 | // Register requests from the clients. 18 | register chan *Client 19 | 20 | // Unregister requests from clients. 21 | unregister chan *Client 22 | } 23 | 24 | func newHub() *Hub { 25 | return &Hub{ 26 | broadcast: make(chan []byte), 27 | register: make(chan *Client), 28 | unregister: make(chan *Client), 29 | clients: make(map[*Client]bool), 30 | } 31 | } 32 | 33 | func (h *Hub) run() { 34 | for { 35 | select { 36 | case client := <-h.register: 37 | h.clients[client] = true 38 | case client := <-h.unregister: 39 | if _, ok := h.clients[client]; ok { 40 | delete(h.clients, client) 41 | close(client.send) 42 | } 43 | case message := <-h.broadcast: 44 | 45 | var sd SwapDevice 46 | if err := sd.Unmarshal(message); err == nil { 47 | actions, _ := TheLayout.FindActions(sd.Device, sd.Direction) 48 | TheLayout.Effect(actions) 49 | //d, _ := json.Marshal(actions) 50 | //log.Printf("%s", d) 51 | } 52 | 53 | fmt.Printf("Clients: %d", len(h.clients)) 54 | for client := range h.clients { 55 | select { 56 | case client.send <- message: 57 | default: 58 | close(client.send) 59 | delete(h.clients, client) 60 | } 61 | } 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /server/drivers/drivers.go: -------------------------------------------------------------------------------- 1 | package drivers 2 | 3 | type Driver struct { 4 | DriverInterface 5 | Name string 6 | ShortName string 7 | 8 | Inputs []Input 9 | Outputs []Output 10 | } 11 | 12 | type Input struct { 13 | DriverInput 14 | InputName string 15 | Active bool 16 | } 17 | 18 | type Output struct { 19 | DriverOutput 20 | OutputName string 21 | Active bool 22 | Input DriverInput 23 | } 24 | 25 | type DriverInput interface { 26 | 27 | } 28 | 29 | type DriverOutput interface { 30 | 31 | } 32 | 33 | type DriverInterface interface { 34 | DriverName() string 35 | GetShortName() string 36 | 37 | // IsRunning will query if a driver is currently operational. 38 | // If a driver is not running, it might mean that the device that it was connected to is gone (eg, USB Serial device) 39 | IsRunning() bool 40 | 41 | // SupportsInitState determines if a driver supports telling us about the state of the controlling device. 42 | // If we support knowing the state, we will query it and update our own state about the device. 43 | // If the device/driver does not implement querying state, we will simply reset all devices to the initial state. 44 | SupportsInitState() bool 45 | 46 | // Start running a driver. Attempt to Dial the device, and determine the state (if possible) 47 | Start() bool 48 | 49 | // Shutdown disconnects from a controlling device. 50 | Shutdown() bool 51 | 52 | GetStatus() 53 | 54 | LastError() error 55 | 56 | IsMatrix() bool 57 | } 58 | 59 | type OutputMatrix interface { 60 | SetOutput(outputName string, inputName string) 61 | } 62 | 63 | type OutputSingle interface { 64 | SetOutput(inputName string) 65 | } -------------------------------------------------------------------------------- /client/websocket_client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jpillora/backoff" 6 | "golang.org/x/net/websocket" 7 | "time" 8 | ) 9 | 10 | var ( 11 | websocketConnected = false 12 | websocketError = make (chan error) 13 | ) 14 | 15 | func connectWebsocket() { 16 | b := &backoff.Backoff{ 17 | Min: 100 * time.Millisecond, 18 | Max: 10 * time.Second, 19 | Factor: 2, 20 | Jitter: false, 21 | } 22 | 23 | debugLog("Starting Client") 24 | 25 | for { 26 | err := initWebsocketClient() 27 | if err != nil { 28 | d := b.Duration() 29 | debugLog("%s, reconnecting in %s", err, d) 30 | time.Sleep(d) 31 | continue 32 | } else { 33 | b.Reset() 34 | } 35 | 36 | 37 | select { 38 | case wserr := <-websocketError: 39 | if websocketConnected { 40 | debugLog("[Websocket]: error: %s... will reconnect", wserr) 41 | websocketConnected = false 42 | } 43 | } 44 | } 45 | } 46 | 47 | func initWebsocketClient() error { 48 | websocketServer := fmt.Sprintf("ws://%s/ws", *addr) 49 | ws, err := websocket.Dial(websocketServer, "", fmt.Sprintf("http://%s/", *addr)) 50 | if err != nil { 51 | debugLog("Dial failed: %s\n", err.Error()) 52 | return err 53 | } 54 | 55 | incomingMessages := make(chan string) 56 | go readClientMessages(ws, incomingMessages) 57 | i := 0 58 | 59 | websocketConnected = true 60 | debugLog("Connected to server: %s", websocketServer) 61 | for { 62 | select { 63 | case send := <-outgoingChanges: 64 | if websocketConnected { 65 | err = websocket.Message.Send(ws, send) 66 | } 67 | case <-time.After(time.Duration(2e9)): 68 | i++ 69 | case message, ok := <-incomingMessages: 70 | if !ok { 71 | incomingMessages = nil 72 | outgoingChanges = nil 73 | } 74 | fmt.Println(`[websocket] Message Received:`, message) 75 | 76 | var sd SwapDevice 77 | if err := sd.Unmarshal([]byte(message)); err == nil { 78 | switched = false 79 | switching = false 80 | } 81 | } 82 | 83 | if incomingMessages == nil && outgoingChanges == nil { 84 | break 85 | } 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func readClientMessages(ws *websocket.Conn, incomingMessages chan string) { 92 | for { 93 | var message string 94 | err := websocket.Message.Receive(ws, &message) 95 | if err != nil { 96 | debugLog("Websocket Error: %s\n", err.Error()) 97 | 98 | //close(outgoingChanges) 99 | close(incomingMessages) 100 | websocketError <- err 101 | return 102 | } 103 | 104 | if message != "" { 105 | incomingMessages <- message 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /server/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | KVM Websocket Viewer 5 | 53 | 90 | 91 | 92 |
93 |
94 | 95 | 96 |
97 | 98 | 99 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "flag" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/timgws/kvm-switch/server/drivers" 11 | "github.com/timgws/kvm-switch/server/drivers/blustream" 12 | "github.com/timgws/kvm-switch/server/drivers/startech_kvm" 13 | ) 14 | 15 | const ( 16 | maxBufferSize = 1024 17 | EnableDebugMode = true 18 | ) 19 | var doneChan = make(chan error, 1) 20 | var buffer = make([]byte, maxBufferSize) 21 | 22 | var serverContext context.Context 23 | var serverContextCtx context.Context 24 | var serverContextCancel context.Context 25 | 26 | var addr = flag.String("addr", ":8787", "http service address") 27 | 28 | type allDrivers struct { 29 | Drivers []drivers.DriverInterface 30 | } 31 | var Drivers allDrivers 32 | var TheLayout *Layout 33 | var JSONLayout []byte 34 | 35 | 36 | func main() { 37 | generateLayout() 38 | registerDrivers() 39 | startDrivers() 40 | 41 | flag.Parse() 42 | 43 | hub := newHub() 44 | go hub.run() 45 | 46 | http.HandleFunc("/", serveHome) 47 | http.HandleFunc("/layout", serveLayout) 48 | http.HandleFunc("/driverStatus", serveDriverStatus) 49 | http.HandleFunc("/refreshStatus", serveRefreshStatus) 50 | http.HandleFunc("/swap/:driver/:input/:output", serveSwap) 51 | http.HandleFunc("/swap", serveSwap) 52 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 53 | serveWs(hub, w, r) 54 | }) 55 | 56 | err := http.ListenAndServe(*addr, nil) 57 | if err != nil { 58 | log.Printf("Failed to start websocket: ListenAndServe: %s", err) 59 | } 60 | } 61 | 62 | // generateLayout should generate a layout from configuration. 63 | // TODO: fix the should. 64 | func generateLayout() { 65 | TheLayout = BuildLayout() 66 | if (EnableDebugMode) { 67 | JSONLayout, _ = json.Marshal(TheLayout) 68 | log.Printf("Layout JSON: %s", string(JSONLayout)) 69 | } 70 | } 71 | 72 | // registerDrivers should go through config, and init the correct drivers required for the layout. 73 | // TODO: fix the 'should' 74 | func registerDrivers() { 75 | stkvm := startech_kvm.NewInstance() 76 | Drivers.Drivers = append(Drivers.Drivers, stkvm) 77 | bsmatrix := blustream.NewInstance() 78 | Drivers.Drivers = append(Drivers.Drivers, bsmatrix) 79 | } 80 | 81 | // startDrivers will start all registered drivers. 82 | func startDrivers() { 83 | for _, driver := range Drivers.Drivers { 84 | starting := driver.Start() 85 | if EnableDebugMode { 86 | log.Printf("Started driver: %s (did it attempt to start? %b)", driver.DriverName(), starting) 87 | } 88 | if driver.LastError() != nil { 89 | log.Printf("[%s]: ERROR: %e", driver.DriverName(), driver.LastError()) 90 | } 91 | } 92 | } 93 | 94 | // findDriver will return an instance of a driver with the given shortName that the driver was configured with. 95 | func findDriver(shortName string) drivers.DriverInterface { 96 | for _, driver := range Drivers.Drivers { 97 | if driver.GetShortName() == shortName { 98 | return driver 99 | } 100 | 101 | switch d := driver.(type) { 102 | default: 103 | case drivers.Driver: 104 | if d.ShortName == shortName { 105 | return &d 106 | } 107 | } 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /server/layout.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/timgws/kvm-switch/server/drivers" 6 | "strings" 7 | ) 8 | 9 | // Layout defines where the machines are, and what the actions that will be performed when 10 | // the mouse is moved around different areas. 11 | type Layout struct { 12 | Computers []Computer 13 | } 14 | 15 | // Computer is a computer (or device) that will be swapped on the matrix. 16 | type Computer struct { 17 | Name string 18 | Directions Directions 19 | } 20 | 21 | // Directions for computers were actions will be performed when moving the mouse between different areas. 22 | type Directions struct { 23 | Left []Action 24 | Right []Action 25 | Top []Action 26 | Bottom []Action 27 | } 28 | 29 | // Action defines an individual _thing_ that will happen after an action is performed. 30 | type Action struct { 31 | DriverName string 32 | PerformAction string 33 | } 34 | 35 | // BuildLayout should build a layout that is usable by the clients. 36 | func BuildLayout() *Layout { 37 | return &Layout{ 38 | Computers: []Computer{{ 39 | Name: "work-computer", 40 | Directions: Directions{ 41 | Right: []Action{{ 42 | DriverName: "kvm", 43 | PerformAction: "1", 44 | }}, 45 | }, 46 | }, { 47 | Name: "home-computer", 48 | Directions: Directions{ 49 | Left: []Action{{ 50 | DriverName: "kvm", 51 | PerformAction: "2", 52 | }}, 53 | Right: []Action{{ 54 | DriverName: "matrix", 55 | PerformAction: "01-03", 56 | }, { 57 | DriverName: "matrix", 58 | PerformAction: "02-04", 59 | }, { 60 | DriverName: "kvm", 61 | PerformAction: "4", 62 | }}, 63 | }, 64 | }, { 65 | Name: "streaming-computer", 66 | Directions: Directions{ 67 | Left: []Action{{ 68 | DriverName: "matrix", 69 | PerformAction: "01-01", 70 | }, { 71 | DriverName: "matrix", 72 | PerformAction: "02-02", 73 | }, { 74 | DriverName: "kvm", 75 | PerformAction: "2", 76 | }}, 77 | }, 78 | }}, 79 | } 80 | } 81 | 82 | // FindActions with a given computer/device, will find the actions that will be performed. 83 | func (l *Layout) FindActions(name string, direction string) (*[]Action, error) { 84 | var ourDevice *Computer 85 | for i, devices := range l.Computers { 86 | if devices.Name == name { 87 | ourDevice = &l.Computers[i] 88 | } 89 | } 90 | 91 | if ourDevice == nil { 92 | return nil, fmt.Errorf("device [%s] was not found", name) 93 | } 94 | 95 | var actions *[]Action 96 | switch direction { 97 | case "left": 98 | actions = &ourDevice.Directions.Left 99 | case "right": 100 | actions = &ourDevice.Directions.Right 101 | case "top": 102 | actions = &ourDevice.Directions.Top 103 | case "bottom": 104 | actions = &ourDevice.Directions.Bottom 105 | } 106 | 107 | if actions == nil { 108 | return nil, nil 109 | } 110 | 111 | if len(*actions) > 0 { 112 | return actions, nil 113 | } 114 | 115 | return nil, nil 116 | } 117 | 118 | // Effect tells the drivers to perform the actions for a given device in the matrix. 119 | func (l *Layout) Effect (actions *[]Action) { 120 | if actions == nil || len(*actions) < 1 { 121 | return 122 | } 123 | for _, item := range *actions { 124 | driver := findDriver(item.DriverName) 125 | action := item.PerformAction 126 | 127 | switch d := driver.(type) { 128 | case drivers.OutputSingle: 129 | d.SetOutput(action) 130 | case drivers.OutputMatrix: 131 | if strings.Contains(action, "-") { 132 | inOut := strings.Split(action, "-") 133 | d.SetOutput(inOut[0], inOut[1]) 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /docs/API_Endpoints.md: -------------------------------------------------------------------------------- 1 | # /driverStatus 2 | ``` 3 | curl http://localhost:8787/driverStatus | jq 4 | ``` 5 | 6 | ```json 7 | { 8 | "Drivers": [ 9 | { 10 | "DriverInterface": null, 11 | "Name": "Startech.com SV431DVIUDDMH2K B4.1", 12 | "ShortName": "kvm", 13 | "Inputs": null, 14 | "Outputs": null, 15 | "OutputSingle": null, 16 | "StartAttempted": true, 17 | "HasError": false, 18 | "Error": null, 19 | "NumOfInputs": 4, 20 | "NumOfOutputs": 1 21 | }, 22 | { 23 | "DriverInterface": null, 24 | "Name": "Blustream CMX44AB v2.22", 25 | "ShortName": "matrix", 26 | "OutputMatrix": null, 27 | "StartAttempted": true, 28 | "HasError": false, 29 | "Error": null, 30 | "NumOfInputs": 4, 31 | "NumOfOutputs": 4, 32 | "Inputs": [ 33 | { 34 | "DriverInput": null, 35 | "InputName": "01", 36 | "Active": true, 37 | "Edid": "Force___11" 38 | }, 39 | { 40 | "DriverInput": null, 41 | "InputName": "02", 42 | "Active": true, 43 | "Edid": "Force___11" 44 | }, 45 | { 46 | "DriverInput": null, 47 | "InputName": "03", 48 | "Active": false, 49 | "Edid": "Force___11" 50 | }, 51 | { 52 | "DriverInput": null, 53 | "InputName": "04", 54 | "Active": false, 55 | "Edid": "Force___11" 56 | } 57 | ], 58 | "Outputs": [ 59 | { 60 | "DriverOutput": null, 61 | "OutputName": "01", 62 | "Active": true, 63 | "Input": { 64 | "DriverInput": null, 65 | "InputName": "01", 66 | "Active": true, 67 | "Edid": "Force___11" 68 | }, 69 | "Edid": "01" 70 | }, 71 | { 72 | "DriverOutput": null, 73 | "OutputName": "02", 74 | "Active": true, 75 | "Input": { 76 | "DriverInput": null, 77 | "InputName": "02", 78 | "Active": true, 79 | "Edid": "Force___11" 80 | }, 81 | "Edid": "02" 82 | }, 83 | { 84 | "DriverOutput": null, 85 | "OutputName": "03", 86 | "Active": true, 87 | "Input": { 88 | "DriverInput": null, 89 | "InputName": "02", 90 | "Active": true, 91 | "Edid": "Force___11" 92 | }, 93 | "Edid": "03" 94 | }, 95 | { 96 | "DriverOutput": null, 97 | "OutputName": "04", 98 | "Active": true, 99 | "Input": { 100 | "DriverInput": null, 101 | "InputName": "02", 102 | "Active": true, 103 | "Edid": "Force___11" 104 | }, 105 | "Edid": "04" 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | ``` 112 | 113 | `Drivers` contains an array of all the loaded drivers that the Fence server is using to communicate. 114 | 115 | The `Inputs` array describes an input on the matrix (ie, a HDMI connection). `InputName` and `OutputName` are the 116 | names of the inputs/outputs. 117 | 118 | `HasError` and `Error` describes the current state of the driver. 119 | 120 | The `Output` describes what `Input` (if any) the output is currently connected to. 121 | 122 | In the above example: 123 | * Output `HDMI 1` is showing Input `HDMI 1`. 124 | * Output `HDMI 2`, `HDMI 3` and `HDMI 4` is showing Input `HDMI 2`. 125 | 126 | An input not being `Active` may mean that the device is not currently plugged in or powered on. 127 | 128 | # /layout 129 | This endpoint dumps a JSON representation of the computed layout. 130 | 131 | # /refreshStatus 132 | For any device (currently just the Blustream) that is supported, we will pull the latest output information from the 133 | device. 134 | -------------------------------------------------------------------------------- /client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "time" 10 | 11 | "github.com/timgws/kvm-switch/client/icon" 12 | 13 | "github.com/getlantern/systray" 14 | "github.com/go-vgo/robotgo" 15 | ) 16 | 17 | const ( 18 | DisplayRefreshRate = 60 19 | EnableDebugMode = true 20 | ) 21 | 22 | var messages chan string 23 | var outgoingChanges = make(chan string) 24 | 25 | var addr = flag.String("addr", "127.0.0.1:8787", "http service address") 26 | var deviceName = flag.String("name", "home-computer", "the name of the device in the layout") 27 | 28 | var cQuit <- chan bool 29 | 30 | var maxX, maxY int 31 | var lastX, lastY = 0, 0 32 | var switched = false 33 | var switching = false 34 | 35 | func main() { 36 | flag.Parse() 37 | systray.Run(onReady, onExit) 38 | go PollMousePosition() 39 | 40 | go func() { 41 | for { 42 | select { 43 | case quit := <-cQuit: 44 | if quit { 45 | os.Exit(1) 46 | } 47 | } 48 | } 49 | }() 50 | } 51 | 52 | func PollMousePosition() { 53 | go doEvery(time.Second/DisplayRefreshRate, pollMousePosition) 54 | maxX, maxY = robotgo.GetScreenSize() 55 | fmt.Println("[Glide] get screen size: ", maxX, maxY) 56 | } 57 | 58 | func deviceHasAtLeastOneDisplay() bool { 59 | numDisplays := robotgo.DisplaysNum() 60 | // macOS will segfault when finding the mouse position if there are no displays attached. 61 | // TODO: If this does not crash on Windows, maybe only run on macOS 62 | if numDisplays == 0 { 63 | // TODO: Fix me, we should only show this once every $X minutes. This will flood the console. 64 | debugLog("[WARNING]: SKIPPING GET PIXEL/MOUSE POS BECAUSE THERE ARE NO SCREENS ATTACHED.") 65 | return false 66 | } 67 | 68 | return true 69 | } 70 | 71 | func pollMousePosition(t time.Time) { 72 | if !deviceHasAtLeastOneDisplay() { 73 | return 74 | } 75 | 76 | x, y := robotgo.GetMousePos() 77 | 78 | if !(x != lastX || y != lastY) { 79 | return 80 | } 81 | 82 | mouseHasMoved(x, y) 83 | } 84 | 85 | func mouseHasMoved(x int, y int) { 86 | if x == 0 && lastX > 0 && switched == false { 87 | switched = true 88 | switchInput("left") 89 | } else if x == maxX-1 && lastX < maxX-2 && switched == false { 90 | switched = true 91 | switchInput("right") 92 | } else if y == 0 && lastY > 0 && switched == false { 93 | switched = true 94 | switchInput("top") 95 | } else if y == maxY-1 && lastY < maxY-2 && switched == false { 96 | switched = true 97 | switchInput("bottom") 98 | } 99 | 100 | /* 101 | color := robotgo.GetPixelColor(100, 200) 102 | log.Printf("(mouse pos: %d x %d) pixel color @ 100x200: #%s", x, y, color) 103 | */ 104 | 105 | if EnableDebugMode { 106 | debugLog("[kvm-client] mouse pos (current: %d x %d, previous: %d x %d, max: %d x %d)", x, y, lastX, lastY, maxX, maxY) 107 | } 108 | 109 | lastX = x 110 | lastY = y 111 | } 112 | 113 | func switchInput(direction string) { 114 | if switching == false { 115 | debugLog("[Fence]: 📺 Mouse has moved to the %s desktop.", direction) 116 | switching = true 117 | } 118 | 119 | outgoingChanges <- makeSwap(SwapDevice{ 120 | Device: *deviceName, 121 | Direction: direction, 122 | }) 123 | } 124 | 125 | func makeSwap(swap SwapDevice) string { 126 | j, _ := json.Marshal(swap) 127 | debugLog("--> Sending to kvm-server %s", j) 128 | return string(j) 129 | } 130 | 131 | func onReady() { 132 | log.Printf("[GUI] The application has booted") 133 | 134 | go connectWebsocket() 135 | go PollMousePosition() 136 | //go processInput() 137 | 138 | systray.SetIcon(icon.Data) 139 | systray.SetTitle("Fence") 140 | systray.SetTooltip("Fence: Move to corner, swap your input") 141 | mRefresh := systray.AddMenuItem("Refresh", "Refresh the state") 142 | mQuit := systray.AddMenuItem("Quit", "Quit the whole app") 143 | 144 | // Sets the icon of a menu item. Only available on Mac and Windows. 145 | mQuit.SetIcon(icon.Data) 146 | go func() { 147 | <-mQuit.ClickedCh 148 | systray.Quit() 149 | }() 150 | go func() { 151 | <-mRefresh.ClickedCh 152 | systray.SetTitle("Refreshing...") 153 | fmt.Println("Refreshing..") 154 | }() 155 | } 156 | 157 | // onExit runs after the GUI has asked to exit. 158 | // TODO: clean up some sockets here. 159 | func onExit() { 160 | // clean up here 161 | } 162 | 163 | // debugLog will output something only if EnableDebugMode is true. 164 | func debugLog(msg string, v ...interface{}) { 165 | if EnableDebugMode { 166 | log.Printf(msg, v...) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /server/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "github.com/gorilla/websocket" 8 | "log" 9 | "net/http" 10 | "reflect" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const ( 16 | // Time allowed to write a message to the peer. 17 | writeWait = 10 * time.Second 18 | 19 | // Time allowed to read the next pong message from the peer. 20 | pongWait = 60 * time.Second 21 | 22 | // Send pings to peer with this period. Must be less than pongWait. 23 | pingPeriod = (pongWait * 9) / 10 24 | 25 | // Maximum message size allowed from peer. 26 | maxMessageSize = 512 27 | ) 28 | 29 | var ( 30 | newline = []byte{'\n'} 31 | space = []byte{' '} 32 | ) 33 | 34 | var upgrader = websocket.Upgrader{ 35 | ReadBufferSize: 1024, 36 | WriteBufferSize: 1024, 37 | } 38 | 39 | type SwapDevice struct { 40 | Device string `json:"device" kvm:"required"` 41 | Direction string `json:"direction" kvm:"required"` 42 | } 43 | 44 | // Client is a middleman between the websocket connection and the hub. 45 | type Client struct { 46 | hub *Hub 47 | 48 | // The websocket connection. 49 | conn *websocket.Conn 50 | 51 | // Buffered channel of outbound messages. 52 | send chan []byte 53 | } 54 | 55 | // readPump pumps messages from the websocket connection to the hub. 56 | // 57 | // The application runs readPump in a per-connection goroutine. The application 58 | // ensures that there is at most one reader on a connection by executing all 59 | // reads from this goroutine. 60 | func (c *Client) readPump() { 61 | defer func() { 62 | c.hub.unregister <- c 63 | c.conn.Close() 64 | }() 65 | c.conn.SetReadLimit(maxMessageSize) 66 | c.conn.SetReadDeadline(time.Now().Add(pongWait)) 67 | c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 68 | for { 69 | _, message, err := c.conn.ReadMessage() 70 | if err != nil { 71 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 72 | log.Printf("error: %v", err) 73 | } 74 | break 75 | } 76 | 77 | message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) 78 | c.hub.broadcast <- message 79 | } 80 | } 81 | 82 | // writePump pumps messages from the hub to the websocket connection. 83 | // 84 | // A goroutine running writePump is started for each connection. The 85 | // application ensures that there is at most one writer to a connection by 86 | // executing all writes from this goroutine. 87 | func (c *Client) writePump() { 88 | ticker := time.NewTicker(pingPeriod) 89 | defer func() { 90 | ticker.Stop() 91 | c.conn.Close() 92 | }() 93 | for { 94 | select { 95 | case message, ok := <-c.send: 96 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 97 | if !ok { 98 | // The hub closed the channel. 99 | c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 100 | return 101 | } 102 | 103 | w, err := c.conn.NextWriter(websocket.TextMessage) 104 | if err != nil { 105 | return 106 | } 107 | w.Write(message) 108 | 109 | // Add queued chat messages to the current websocket message. 110 | n := len(c.send) 111 | for i := 0; i < n; i++ { 112 | w.Write(newline) 113 | w.Write(<-c.send) 114 | } 115 | 116 | if err := w.Close(); err != nil { 117 | return 118 | } 119 | case <-ticker.C: 120 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 121 | if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { 122 | return 123 | } 124 | } 125 | } 126 | } 127 | 128 | // serveWs handles websocket requests from the peer. 129 | func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { 130 | conn, err := upgrader.Upgrade(w, r, nil) 131 | if err != nil { 132 | log.Println(err) 133 | return 134 | } 135 | client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} 136 | client.hub.register <- client 137 | 138 | // Allow collection of memory referenced by the caller by doing all work in 139 | // new goroutines. 140 | go client.writePump() 141 | go client.readPump() 142 | } 143 | 144 | func (sd *SwapDevice) Unmarshal(data []byte) error { 145 | err := json.Unmarshal(data, sd) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | fields := reflect.ValueOf(sd).Elem() 151 | for i := 0; i < fields.NumField(); i++ { 152 | 153 | dsTags := fields.Type().Field(i).Tag.Get("kvm") 154 | if strings.Contains(dsTags, "required") && fields.Field(i).IsZero() { 155 | return errors.New("required field is missing") 156 | } 157 | 158 | } 159 | return nil 160 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fence 2 | ## What is Fence? 3 | Fence allows you to automate switching hardware devices when the mouse moves 4 | to the edge of the screen. The functionality is similar to Synergy, but we don't 5 | pass through mouse clicks and key presses. 6 | 7 | Sometimes also known as 'Glide and Switch', the Fence client runs on each PC connected to external 8 | devices through the Fence server. When the mouse moves to the edge of the screen, the client tells 9 | the server the name of the device (ie, the computer name), and the direction of the switch 10 | (left, right, top, bottom). The server then calculates the actions that needs to be performed. 11 | 12 | Both the server and the client are cross-platform. 13 | 14 | This gives you the convenience of Synergy (move your mouse to the edge of the screen 15 | to swap the computer receiving the inputs) with the stability of using USB DDM 16 | to redirect input devices. Lower latency, better(ish) security and much better 17 | application compatibility. 18 | 19 | Fence allows you to design your switching on a per-direction, per-device level, 20 | allowing full control over your switching layout. 21 | 22 | ## Where does it run? 23 | * macOS 11 24 | * Windows 10 25 | * Archlinux (with X11 - not Wayland) 26 | 27 | ## What devices can be controlled? 28 | * KVM controllers (such as the Startech SV431DVIUDDM or ConnectPRO UDP2-14AP) 29 | * HDMI matrix switches (such as the Blustream CMX44AB) 30 | 31 | ## How do I get up and running? 32 | Currently, the configuration is hardcoded into the `server` binary at build time. 33 | 34 | ### Step 1: Configure a server 35 | A Fence server is required to run on a machine that has access to all of the devices that 36 | are wanted to be controlled. 37 | 38 | A Raspberry Pi or similar low-powered device is ideal for this. 39 | 40 | For my setup, I use a SV431DVIUDDM and CMX44AB. 41 | 42 | * Update the `registerDrivers()` function inside `server/main.go` to register drivers for the devices you want to 43 | control. 44 | * Update the serial port in `server/drivers/startech_kvm/startech_kvm.go` 45 | and/or `server/drivers/blustream/blustream.go` 46 | * Define the correct layout in `server/layout.go` describing what you want performed when the mouse moves between 47 | screens 48 | 49 | Note that multiple instances of the matrix and KVM drivers can be started at the same time (see `server/main.go`), 50 | allowing for chains if control of a larger range of devices at once is desired. 51 | 52 | Start the server: 53 | ```shell 54 | # cd server 55 | # go build 56 | # ./server -addr :8787 57 | 2022/05/29 13:03:23 Started driver: Startech SV431DVIUDDM 58 | 2022/05/29 13:03:23 Started driver: Blustream 59 | 2022/05/29 13:03:23 [startech_kvm] Command #1/1: ERROR 60 | 2022/05/29 13:03:23 Ignore the first error, we are just initializing our state - looks like this device is correct 61 | 2022/05/29 13:03:23 [blustream]: New driver name is: Blustream CMX44AB 62 | 2022/05/29 13:03:23 [blustream]: New driver name is: Blustream CMX44AB v2.22 63 | 2022/05/29 13:03:24 [startech_kvm] Command #1/1: SV431DVIUDDM F/W Version :H2K B4.1 64 | 2022/05/29 13:03:24 [startech_kvm]: New driver name is: Startech.com SV431DVIUDDMH2K B4.1 65 | ``` 66 | 67 | To see if the server is running successfully, you can use one of the [API Endpoints](docs/API_Endpoints.md) 68 | 69 | ### Step 2: Install client on all machines 70 | ```shell 71 | # cd client 72 | # go build 73 | # ./client -addr 10.2.2.2:8787 -name left 74 | 2022/05/29 13:06:23 [GUI] The application has booted 75 | 2022/05/29 13:06:23 [Websocket] Starting Client 76 | 2022/05/29 13:06:23 [Glide] get screen size: 3360x1890 77 | ``` 78 | 79 | ## TODO/Upcoming Features 80 | * [x] Improve switching solution for Blustream devices. 81 | * [x] [#1](https://github.com/timgws/kvm-switch/issues/1) 82 | Fix the client, so that it reconnects when the server's connection goes away. 83 | * [ ] [#2](https://github.com/timgws/kvm-switch/issues/2) 84 | Enable using hotkeys to lock the current screen/not send glide commands to the server. 85 | * [ ] [#3](https://github.com/timgws/kvm-switch/issues/3) 86 | Use DDC/CI to control the monitor inputs, so a hardware matrix is not required. 87 | * [ ] [#3](https://github.com/timgws/kvm-switch/issues/3) 88 | Support Synergy, so DDC/CI commands can be issued for additional hardware-free solution. 89 | * [ ] [#4](https://github.com/timgws/kvm-switch/issues/4) 90 | Web interface to define the edges of different devices. 91 | * [ ] [#5](https://github.com/timgws/kvm-switch/issues/5) 92 | Implement SSL for connections. 93 | * [ ] [#6](https://github.com/timgws/kvm-switch/issues/6) 94 | Implement authentication for connections. 95 | * [ ] [#7](https://github.com/timgws/kvm-switch/issues/7) 96 | Investigate whats required to pass keypresses and mouse movements to the client. 97 | -------------------------------------------------------------------------------- /server/drivers/blustream/bluestream_status_reader.go: -------------------------------------------------------------------------------- 1 | package blustream 2 | 3 | import ( 4 | "github.com/timgws/kvm-switch/server/drivers" 5 | "log" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // readStatus will read each line from the serial console after the `status` command is issued. 11 | func (d *BlustreamMatrix) readStatus(_msg string) { 12 | msg := strings.TrimSpace(_msg) 13 | 14 | if !d.statusIncoming { 15 | d.NumOfOutputs = len(d.Outputs) 16 | } 17 | 18 | // If our line contains the 'Status' word, it also contains the model name. 19 | // Replace the name in the driver. 20 | if d.statusReading == ReadingModel && strings.Contains(msg, "Status") { 21 | d.setModelState(msg) 22 | } 23 | 24 | if d.statusReading == ReadingVersion && strings.Contains(msg, "Version") { 25 | d.setFirmwareState(msg) 26 | d.statusReading = WaitingInput 27 | return 28 | } 29 | 30 | // Input Edid HDMIcon 31 | if d.statusReading == WaitingInput && strings.Contains(msg, "Input") { 32 | res := strings.Fields(msg) 33 | if strings.ToTitle(res[0]) == "INPUT" { 34 | d.statusReading = ReadingInput 35 | d.state.readingInputNumber = 0 36 | return 37 | } 38 | } 39 | 40 | if d.statusReading == ReadingInput { 41 | res := strings.Fields(msg) 42 | 43 | // Output FromIn HDMIcon OutputEn OSP Mute 44 | if strings.ToTitle(res[0]) == "OUTPUT" { 45 | d.statusReading = ReadingOutput 46 | d.state.readingOutputNumber = 0 47 | d.NumOfInputs = len(d.Inputs) 48 | return 49 | } 50 | 51 | // 01 Force___11 On 52 | if len(msg) > 0 { 53 | res := strings.Fields(msg) 54 | d.readInputLine(res) 55 | } 56 | } 57 | 58 | if d.statusReading == ReadingOutput { 59 | // 01 01 Off Yes SNK Off 60 | if len(msg) > 0 { 61 | res := strings.Fields(msg) 62 | d.readOutputLine(res) 63 | } 64 | } 65 | } 66 | 67 | // setModelState will attempt to set the model of the device 68 | func (d *BlustreamMatrix) setModelState(msg string) { 69 | modelSplit := strings.Split(msg, "Status") 70 | model := strings.TrimSpace(modelSplit[0]) 71 | model = strings.Replace(model, "HDMI ", "", 1) 72 | 73 | d.state.ModelName = model 74 | if !strings.Contains(model, "Blustream") { 75 | d.state.Model = "Blustream " + model 76 | } else { 77 | d.state.Model = model 78 | } 79 | 80 | d.statusReading = ReadingVersion 81 | d.Driver.Name = d.state.Model 82 | d.setModel() 83 | } 84 | 85 | // setFirmwareState will attempt to find the FW Version of the device. 86 | func (d *BlustreamMatrix) setFirmwareState(msg string) { 87 | versionSplit := strings.Split(msg, ": ") 88 | if len(versionSplit) > 1 { 89 | version := strings.TrimSpace(versionSplit[1]) 90 | d.state.CurrentVersion = version 91 | d.setModel() 92 | } 93 | 94 | d.statusReading = WaitingInput 95 | } 96 | 97 | // setModel will change the driverName based on the model retrieved from the status 98 | func (d *BlustreamMatrix) setModel() { 99 | if d.modelSet { 100 | return 101 | } 102 | 103 | if len(d.state.Model) > 0 { 104 | d.Driver.Name = d.state.Model 105 | if len(d.state.CurrentVersion) > 0 { 106 | d.Driver.Name = d.state.Model + " v" + d.state.CurrentVersion 107 | d.modelSet = true 108 | } 109 | 110 | log.Println("[blustream]: New driver name is: " + d.Driver.Name) 111 | } 112 | } 113 | 114 | // isActive is a helper function to turn "yes", "on" to true when reading status. 115 | func isActive(a string) bool { 116 | status := strings.ToLower(a) 117 | if status == "on" || status == "yes" { 118 | return true 119 | } 120 | return false 121 | } 122 | 123 | // readInputLine creates multiple BlustreamInput from inputs that are retrieved from the status. 124 | func (d *BlustreamMatrix) readInputLine(res []string) { 125 | i, err := strconv.Atoi(res[0]) 126 | if err == nil { 127 | if d.state.readingInputNumber == 0 { 128 | d.state.readingInputNumber++ 129 | } 130 | 131 | if i == d.state.readingInputNumber { 132 | if len(res) != 3 { 133 | return 134 | } 135 | 136 | if d.NumOfInputs < d.state.readingInputNumber { 137 | d.state.readingInputNumber++ 138 | newInput := BlustreamInput{ 139 | Input: &drivers.Input{ 140 | InputName: res[0], 141 | Active: isActive(res[2]), 142 | }, 143 | Edid: res[1], 144 | } 145 | 146 | d.Inputs = append(d.Inputs, newInput) 147 | } else { 148 | for k, input := range d.Inputs { 149 | if input.InputName == res[0] { 150 | updatedInput := false 151 | isSourceActive := isActive(res[2]) 152 | 153 | if input.Active != isSourceActive { 154 | updatedInput = true 155 | input.Active = isSourceActive 156 | } 157 | 158 | if input.Edid != res[1] { 159 | updatedInput = true 160 | input.Edid = res[1] 161 | } 162 | 163 | if updatedInput { 164 | debugLog(input.InputName, "has changed") 165 | d.updatedInputs = append(d.updatedInputs, k) 166 | } 167 | } 168 | } 169 | 170 | d.state.readingInputNumber++ 171 | } 172 | } 173 | } 174 | } 175 | 176 | func (d *BlustreamMatrix) readOutputLine(res []string) { 177 | i, err := strconv.Atoi(res[0]) 178 | if err == nil { 179 | if d.state.readingOutputNumber == 0 { 180 | d.state.readingOutputNumber++ 181 | } 182 | 183 | if i == d.state.readingOutputNumber { 184 | if len(res) != 6 { 185 | return 186 | } 187 | 188 | if d.NumOfOutputs < d.state.readingOutputNumber { 189 | d.state.readingOutputNumber++ 190 | input := d.GetInput(res[1]) 191 | newOutput := BlustreamOutput{ 192 | Output: &drivers.Output{ 193 | OutputName: res[0], 194 | Active: isActive(res[2]) && isActive(res[3]), 195 | Input: input, 196 | }, 197 | Edid: res[1], 198 | } 199 | 200 | d.Outputs = append(d.Outputs, newOutput) 201 | } else { 202 | for k, output := range d.Outputs { 203 | if output.OutputName == res[0] { 204 | updatedInput := false 205 | isSourceActive := isActive(res[2]) 206 | 207 | if output.Active != isSourceActive { 208 | updatedInput = true 209 | output.Active = isSourceActive 210 | } 211 | 212 | if output.Edid != res[1] { 213 | updatedInput = true 214 | output.Edid = res[1] 215 | } 216 | 217 | if updatedInput { 218 | log.Println(output.OutputName, "has changed") 219 | d.updatedInputs = append(d.updatedInputs, k) 220 | } 221 | } 222 | } 223 | 224 | d.state.readingOutputNumber++ 225 | } 226 | } 227 | } 228 | } -------------------------------------------------------------------------------- /server/drivers/startech_kvm/startech_kvm.go: -------------------------------------------------------------------------------- 1 | package startech_kvm 2 | 3 | import ( 4 | "bytes" 5 | "github.com/tarm/serial" 6 | d "github.com/timgws/kvm-switch/server/drivers" 7 | "log" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const EnableDebugMode = false 14 | 15 | type StartechConfig struct { 16 | SerialDevice string 17 | SerialBaud int 18 | } 19 | 20 | type StartechState struct { 21 | CurrentDevice int 22 | } 23 | 24 | // StartechKvm has been developed with a SV431DVIUDDM 25 | // https://www.startech.com/en-au/server-management/sv431dviuddm 26 | // should work for a few other similar devices, but YMMV. 27 | type StartechKvm struct { 28 | d.Driver 29 | d.OutputSingle 30 | 31 | config StartechConfig 32 | isRunning bool 33 | 34 | StartAttempted bool 35 | HasError bool 36 | Error error 37 | 38 | NumOfInputs int 39 | NumOfOutputs int 40 | 41 | messages chan string 42 | serialResponse chan string 43 | 44 | state StartechState 45 | switching bool 46 | switched bool 47 | firstError bool 48 | } 49 | 50 | func NewInstance() *StartechKvm { 51 | return &StartechKvm{ 52 | isRunning: false, 53 | Driver: d.Driver{ 54 | Name: "Startech SV431DVIUDDM", 55 | ShortName: "kvm", 56 | }, 57 | config: StartechConfig{ 58 | SerialDevice: "/dev/tty.usbserial-141140", 59 | SerialBaud: 115200, 60 | }, 61 | NumOfInputs: 4, 62 | NumOfOutputs: 1, 63 | firstError: true, 64 | state: StartechState{}, 65 | } 66 | } 67 | 68 | // SupportsInitState is always false because these (and most other devices) do not support querying state. 69 | // A boy can dream. 70 | func (d *StartechKvm) SupportsInitState() bool { 71 | return false 72 | } 73 | 74 | // IsRunning will show if the device is being read from or not. 75 | func (d *StartechKvm) IsRunning() bool { 76 | return d.isRunning 77 | } 78 | 79 | func (d *StartechKvm) GetShortName() string { 80 | return d.ShortName 81 | } 82 | 83 | // Start will initialize the connection... 84 | func (d *StartechKvm) Start() bool { 85 | d.StartAttempted = true 86 | s_dev := d.config.SerialDevice 87 | s_baud := d.config.SerialBaud 88 | 89 | c := &serial.Config{Name: s_dev, Baud: s_baud} 90 | s, err := serial.OpenPort(c) 91 | if err != nil { 92 | d.Error = err 93 | return false 94 | } 95 | 96 | go d.readPort(s) 97 | go d.init(s) 98 | 99 | go d.processResponses() 100 | 101 | go d.writePort(s) 102 | 103 | return true 104 | } 105 | 106 | // init the device. 107 | func (d *StartechKvm) init(port *serial.Port) { 108 | // (Either) the startech is a bit dodge, or my USB->RS232 is a bit dodge. 109 | // let's send a fake command and wait for the error response. 110 | d.StartAttempted = true 111 | time.Sleep(time.Millisecond * 500) 112 | n, err := port.Write([]byte("HI!\r\n")) 113 | if err != nil { 114 | d.Error = err 115 | d.HasError = true 116 | log.Printf("Could not send %d bytes for driver.", n) 117 | } 118 | port.Flush() 119 | } 120 | 121 | func (d *StartechKvm) DriverName() string { 122 | return d.Name 123 | } 124 | 125 | // GetStatus does nothing here, because the method does not exist. 126 | func (d *StartechKvm) GetStatus() { 127 | } 128 | 129 | 130 | // writePort manages a channel that allows us to send & receive data to this serial connection. 131 | func (d *StartechKvm) writePort(port *serial.Port) { 132 | d.messages = make(chan string) 133 | log.Printf("WRITE PORT STARTED") 134 | 135 | go func() { 136 | for { 137 | select { 138 | case msg := <-d.messages: 139 | log.Printf("==> WRITE PORT MSG: %s", msg) 140 | n, err := port.Write([]byte(msg + "\r\n")) 141 | if err != nil { 142 | log.Printf("Error writing %d bytes: %s", n, err) 143 | d.HasError = true 144 | d.Error = err 145 | } 146 | } 147 | } 148 | }() 149 | } 150 | 151 | // readPort continuously reads data from the serial port, checks if there is some new data received 152 | // and sends it down to the serialResponse channel when we are g2g with a response from a command. 153 | // 154 | // TODO: This needs a big refactor. It's a little dodgy, but it does the trick. 155 | func (d *StartechKvm) readPort(port *serial.Port) { 156 | var command string 157 | for { 158 | buf := make([]byte, 60) 159 | n, err := port.Read(buf) 160 | if err != nil { 161 | log.Fatal(n, err) 162 | } 163 | 164 | 165 | b := bytes.Trim(buf, "\x00") 166 | tmpString := string(b) 167 | command = command + tmpString 168 | 169 | commands := strings.Split(command, "\r\n") 170 | 171 | if EnableDebugMode { 172 | log.Printf("BUFFER CONTENTS: %s tmpString %s", buf, tmpString) 173 | log.Printf("commands: %s", commands) 174 | } 175 | 176 | numCommands := len(commands) 177 | if numCommands > 1 { 178 | for i := range commands { 179 | // if we are at the last command, check that we have an empty command... 180 | if i == numCommands-1 { 181 | if commands[i] == "" { 182 | buf = []byte("") 183 | log.Printf("BUFFER: %s %q", buf, buf) 184 | continue 185 | } 186 | } else if i != numCommands { 187 | currentCommand := commands[0] 188 | if len(commands) > 1 { 189 | numCommands-- 190 | commands = commands[1:] 191 | 192 | //buf = []byte(string(commands.joi)) 193 | command = strings.Join(commands, "\r\n") 194 | buf = []byte(command) 195 | } 196 | 197 | d.serialResponse <- currentCommand 198 | 199 | log.Printf("[startech_kvm] Command #%d/%d: %s", i+1, len(commands), currentCommand) 200 | } 201 | } 202 | } else { 203 | if EnableDebugMode { 204 | log.Printf("Incomplete command from serial... expected newline") 205 | } 206 | } 207 | 208 | if EnableDebugMode { 209 | log.Printf("*-*-*-**-*-**-*-*-**-*-**-*-*-**-*-**-*-*-**-*-**-*-*-**-*-**-*-*-**-*-**-*-*-**-*-*") 210 | log.Printf("BUFFER CONTENTS: %s tmpString %s", buf, tmpString) 211 | log.Printf("commands: %s", commands) 212 | log.Printf("%q", buf) 213 | log.Printf("---------------------------") 214 | } 215 | 216 | //log.Printf("%s %q", buf, buf) 217 | } 218 | } 219 | 220 | // processResponses reads serial commands that have been fully read from the serial connection. 221 | func (d *StartechKvm) processResponses() { 222 | d.serialResponse = make(chan string) 223 | 224 | go func() { 225 | for { 226 | select { 227 | case msg := <-d.serialResponse: 228 | if EnableDebugMode { 229 | log.Printf("<== [STARTECH] READ SERIAL COMMAND: %s %q", msg, msg) 230 | } 231 | 232 | if msg == "ERROR" { 233 | if !d.firstError { 234 | d.HasError = true 235 | } else { 236 | log.Printf("Ignore the first error, we are just initializing our state - looks like this device is correct") 237 | d.firstError = false 238 | d.isRunning = true 239 | } 240 | continue 241 | } 242 | 243 | if strings.Contains(msg, "F/W Version") { 244 | // unlike Blustream, we only get to know the device when it boots. 245 | // SV431DVIUDDM F/W Version :H2K B4.1 246 | version := strings.Split(msg, " ") 247 | if len(version) == 5 { 248 | fwVersion := strings.Replace(strings.Join(version[3:], " "), ":", "", 1) 249 | d.Driver.Name = "Startech.com " + version[0] + fwVersion 250 | log.Println("[startech_kvm]: New driver name is: " + d.Driver.Name) 251 | } 252 | } 253 | 254 | if len(msg) == 3 { 255 | if msg[:2] == "CH" { 256 | log.Println(msg, msg[2:]) 257 | chn, err := strconv.Atoi(msg[2:]) 258 | if err != nil { 259 | d.HasError = true 260 | d.Error = err 261 | } 262 | 263 | d.state.CurrentDevice = chn 264 | } 265 | } 266 | } 267 | } 268 | }() 269 | } 270 | 271 | func (d *StartechKvm) SetOutput(inputName string) { 272 | //d.messages <- "CH" + inputName 273 | d.messages <- "K1P" + inputName 274 | } 275 | 276 | func (d *StartechKvm) LastError() error { 277 | return d.Error 278 | } 279 | 280 | func quickLog(str string) { 281 | log.Println(str) 282 | } -------------------------------------------------------------------------------- /client/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= 2 | github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= 7 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= 8 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= 9 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= 10 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= 11 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= 12 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= 13 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= 14 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= 15 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= 16 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= 17 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= 18 | github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI= 19 | github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM= 20 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 21 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 22 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 23 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 24 | github.com/go-vgo/robotgo v1.0.0-beta5.1 h1:5Lj9cViKleLP1m8SFkwljuaOKcQ9kWSwAeE8Xw9cpvE= 25 | github.com/go-vgo/robotgo v1.0.0-beta5.1/go.mod h1:Qrtib2lSWYnVZKEmt5K6TjXl5I2JAGQ4nbC6Ow6NFic= 26 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 27 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 28 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 29 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 30 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 31 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 32 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= 33 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= 34 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 35 | github.com/otiai10/gosseract v2.2.1+incompatible h1:Ry5ltVdpdp4LAa2bMjsSJH34XHVOV7XMi41HtzL8X2I= 36 | github.com/otiai10/gosseract v2.2.1+incompatible/go.mod h1:XrzWItCzCpFRZ35n3YtVTgq5bLAhFIkascoRo8G32QE= 37 | github.com/otiai10/mint v1.3.0 h1:Ady6MKVezQwHBkGzLFbrsywyp09Ah7rkmfjV3Bcr5uc= 38 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 39 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 40 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 44 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 45 | github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934 h1:2lhSR8N3T6I30q096DT7/5AKEIcf1vvnnWAmS0wfnNY= 46 | github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934/go.mod h1:SxQhJskUJ4rleVU44YvnrdvxQr0tKy5SRSigBrCgyyQ= 47 | github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770 h1:2uX8QRLkkxn2EpAQ6I3KhA79BkdRZfvugJUzJadiJwk= 48 | github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770/go.mod h1:svkDXUDQjUiWzLrA0OZgHc4lbOts3C+uRfP6/yjwYnU= 49 | github.com/shirou/gopsutil/v3 v3.21.12 h1:VoGxEW2hpmz0Vt3wUvHIl9fquzYLNpVpgNNB7pGJimA= 50 | github.com/shirou/gopsutil/v3 v3.21.12/go.mod h1:BToYZVTlSVlfazpDDYFnsVZLaoRG+g8ufT6fPQLdJzA= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 53 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 54 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 55 | github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= 56 | github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= 57 | github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= 58 | github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= 59 | github.com/vcaesar/gops v0.22.0 h1:aWHWxY3fvUuaJGph+LegYUSEU/874WJT7MInd8OUmP0= 60 | github.com/vcaesar/gops v0.22.0/go.mod h1:GFNGo9xpCfjcfd/Hovi9RSrpd4FdaQt8V92TzpU22w4= 61 | github.com/vcaesar/imgo v0.30.1 h1:B7QMm2mZY+SGoEvvJwNi+eAv21ptvk2YT1IbP2OzyLE= 62 | github.com/vcaesar/imgo v0.30.1/go.mod h1:n4EluJIN/0UYYGPHBCY/BWlIjdRUdY5U6obtP2lrgdM= 63 | github.com/vcaesar/keycode v0.10.0 h1:Qx5QE8ZXHyRyjoA2QOxBp25OKMKB+zxMVqm0FWGV0d4= 64 | github.com/vcaesar/keycode v0.10.0/go.mod h1:JNlY7xbKsh+LAGfY2j4M3znVrGEm5W1R8s/Uv6BJcfQ= 65 | github.com/vcaesar/tt v0.20.0 h1:9t2Ycb9RNHcP0WgQgIaRKJBB+FrRdejuaL6uWIHuoBA= 66 | github.com/vcaesar/tt v0.20.0/go.mod h1:GHPxQYhn+7OgKakRusH7KJ0M5MhywoeLb8Fcffs/Gtg= 67 | github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= 68 | github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 69 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 70 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 71 | golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= 72 | golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 73 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 74 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= 75 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 76 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 77 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 79 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 | golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0= 86 | golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 88 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 89 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 90 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 91 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 92 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 93 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 96 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | -------------------------------------------------------------------------------- /server/drivers/blustream/bluestream.go: -------------------------------------------------------------------------------- 1 | package blustream 2 | 3 | import ( 4 | "bytes" 5 | "github.com/tarm/serial" 6 | d "github.com/timgws/kvm-switch/server/drivers" 7 | "log" 8 | "regexp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const EnableDebugMode = false 14 | 15 | // Reading keeps track of where the statemachine is in reading the `status` command from the BlueStream. 16 | type Reading int 17 | const ( 18 | NotReadingStatus Reading = iota 19 | ReadingModel 20 | ReadingVersion 21 | WaitingInput 22 | ReadingInput 23 | ReadingOutput 24 | ) 25 | 26 | // BlustreamConfig holds the device-specific configuration. 27 | type BlustreamConfig struct { 28 | SerialDevice string 29 | SerialBaud int 30 | } 31 | 32 | // BlustreamInput represents a HDMI/DVI/USB-C input on a given Blustream matrix 33 | type BlustreamInput struct { 34 | *d.Input 35 | Edid string 36 | } 37 | 38 | // BlustreamOutput represents a HDMI/DVI/USB-C input on a given Blustream matrix 39 | type BlustreamOutput struct { 40 | *d.Output 41 | Edid string 42 | } 43 | 44 | // BlustreamState holds state about the current instance of a Blustream device. 45 | type BlustreamState struct { 46 | CurrentDevice int 47 | 48 | Model string 49 | ModelName string 50 | CurrentVersion string 51 | 52 | readingInputNumber int 53 | readingOutputNumber int 54 | } 55 | 56 | // BlustreamMatrix has been developed with a cmx44ab 57 | // https://www.blustream.com.au/cmx44ab 58 | // should work for a few other similar devices, but YMMV. 59 | type BlustreamMatrix struct { 60 | *d.Driver 61 | d.OutputMatrix 62 | 63 | config BlustreamConfig 64 | // isRunning defines whether we are connected to the rs232 port from the device, and it is working as expected. 65 | isRunning bool 66 | 67 | // StartAttempted defines if we have attempted to start connecting to the serial port, but the device has not init. 68 | StartAttempted bool 69 | 70 | // HasError is true if the device and/or connection has an error. 71 | HasError bool 72 | 73 | // Error contains the error of the device 74 | // WARNING: Error may not contain text! Check HasError. 75 | Error error 76 | 77 | // NumOfInputs: The number of inputs present on the device 78 | NumOfInputs int 79 | // NumOfOutputs: The number of outputs present on the device 80 | NumOfOutputs int 81 | 82 | // messages are commands that need to be sent down the channel to the serial device. 83 | messages chan string 84 | 85 | // serialResponse contains text that is coming inbound from the Blustream device. 86 | serialResponse chan string 87 | 88 | // finishedSwap makes sure that matrix swaps are synchronous 89 | finishedSwap chan bool 90 | 91 | state BlustreamState 92 | switching bool 93 | switched bool 94 | statusIncoming bool 95 | statusConfirmed bool 96 | statusStarted bool 97 | statusReading Reading 98 | modelSet bool 99 | 100 | // port contains the RS232 connection 101 | port *serial.Port 102 | 103 | // updatedInputs show inputs that have recently been updated (eg, new HDMI device coming online). 104 | updatedInputs []int 105 | 106 | // Inputs are a list of all the Driver.Inputs that are present on the matrix. 107 | Inputs []BlustreamInput 108 | 109 | // Outputs are a list of all the Driver.Outputs that are present on the matrix. 110 | Outputs []BlustreamOutput 111 | } 112 | 113 | // NewInstance create a new instance of a Blustream device. 114 | func NewInstance() *BlustreamMatrix { 115 | return &BlustreamMatrix{ 116 | isRunning: false, 117 | Driver: &d.Driver{ 118 | Name: "Blustream", 119 | ShortName: "matrix", 120 | }, 121 | config: BlustreamConfig{ 122 | SerialDevice: "/dev/tty.usbserial-141130", 123 | SerialBaud: 57600, 124 | }, 125 | state: BlustreamState{}, 126 | } 127 | } 128 | 129 | // SupportsInitState is always false because these (and most other devices) do not support querying state. 130 | // A boy can dream. 131 | func (d *BlustreamMatrix) SupportsInitState() bool { 132 | return true 133 | } 134 | 135 | func (d *BlustreamMatrix) GetShortName() string { 136 | return d.ShortName 137 | } 138 | 139 | func (d *BlustreamMatrix) DriverName() string { 140 | return d.Name 141 | } 142 | 143 | // IsRunning will show if the device is being read from or not. 144 | func (d *BlustreamMatrix) IsRunning() bool { 145 | return d.isRunning 146 | } 147 | 148 | // Start initializes the connection, sends first status command. 149 | func (d *BlustreamMatrix) Start() bool { 150 | d.StartAttempted = true 151 | s_dev := d.config.SerialDevice 152 | s_baud := d.config.SerialBaud 153 | 154 | c := &serial.Config{Name: s_dev, Baud: s_baud} 155 | s, err := serial.OpenPort(c) 156 | if err != nil { 157 | d.Error = err 158 | return false 159 | } 160 | 161 | d.port = s 162 | 163 | go d.readPort() 164 | go d.init() 165 | 166 | //go d.pingStatus(s) 167 | 168 | go d.processResponses() 169 | 170 | go d.writePort(s) 171 | 172 | return true 173 | } 174 | 175 | // GetStatus ask the Blustream matrix what the current state of the device is. 176 | // Call me to see if devices have changes (without notifying the switch) 177 | func (d *BlustreamMatrix) GetStatus() { 178 | d.pingStatus() 179 | } 180 | 181 | // SetOutput will change the output of a port to the given input port. 182 | func (d *BlustreamMatrix) SetOutput(outputName string, inputName string) { 183 | if d.switching { 184 | return 185 | } 186 | debugLog("OUTPUT MATRIX %s -> %s", outputName, inputName) 187 | 188 | d.finishedSwap = make(chan bool) 189 | 190 | var matrixOutput *BlustreamOutput 191 | var matrixInput *BlustreamInput 192 | 193 | debugLog("Out/In: %s, %s", d.Outputs, d.Inputs) 194 | 195 | for i, output := range d.Outputs { 196 | if output.OutputName == outputName { 197 | debugLog("<- OUTPUT %s == %s", output.OutputName, outputName) 198 | matrixOutput = &d.Outputs[i] 199 | } 200 | } 201 | for i, input := range d.Inputs { 202 | if input.InputName == inputName { 203 | debugLog("<- INPUT %s == %s", input.InputName, inputName) 204 | matrixInput = &d.Inputs[i] 205 | } 206 | } 207 | 208 | if matrixInput != nil && matrixOutput != nil { 209 | if !d.switching { 210 | d.switching = true 211 | d.messages <- "OUT" + outputName + "FR" + inputName 212 | } 213 | } 214 | 215 | select { 216 | case _, ok := <-d.finishedSwap: 217 | if ok { 218 | return 219 | } 220 | } 221 | } 222 | 223 | // init the device. 224 | func (d *BlustreamMatrix) init() { 225 | port := d.port 226 | // We are going to ask the device for the current status. 227 | time.Sleep(time.Millisecond * 500) 228 | d.statusIncoming = true 229 | d.statusReading = ReadingModel 230 | n, err := port.Write([]byte("STATUS\r\n")) 231 | if err != nil { 232 | d.statusReading = WaitingInput 233 | d.Error = err 234 | d.HasError = true 235 | debugLog("Could not send %d bytes for driver.", n) 236 | } 237 | port.Flush() 238 | } 239 | 240 | // init the device. 241 | func (d *BlustreamMatrix) pingStatus() { 242 | port := d.port 243 | // We are going to ask the device for the current status. 244 | time.Sleep(time.Millisecond * 500) 245 | d.statusIncoming = true 246 | n, err := port.Write([]byte("STATUS\r\n")) 247 | if err != nil { 248 | d.statusReading = WaitingInput 249 | d.Error = err 250 | d.HasError = true 251 | debugLog("Could not send %d bytes for driver.", n) 252 | } 253 | port.Flush() 254 | } 255 | 256 | 257 | // writePort manages a channel that allows us to send & receive data to this serial connection. 258 | func (d *BlustreamMatrix) writePort(port *serial.Port) { 259 | d.messages = make(chan string) 260 | 261 | go func() { 262 | for { 263 | select { 264 | case msg := <-d.messages: 265 | debugLog("==> WRITE PORT MSG: %s", msg) 266 | n, err := port.Write([]byte(msg + "\r\n")) 267 | if err != nil { 268 | debugLog("Error writing %d bytes: %s", n, err) 269 | d.HasError = true 270 | d.Error = err 271 | } 272 | } 273 | } 274 | }() 275 | } 276 | 277 | // readPort continuously reads data from the serial port, checks if there is some new data received 278 | // and sends it down to the serialResponse channel when we are g2g with a response from a command. 279 | // 280 | // TODO: This needs a big refactor. It's a little dodgy, but it does the trick. 281 | func (d *BlustreamMatrix) readPort() { 282 | port := *d.port 283 | var command string 284 | for { 285 | buf := make([]byte, 820) 286 | n, err := port.Read(buf) 287 | if err != nil { 288 | log.Fatal(n, err) 289 | } 290 | 291 | 292 | b := bytes.Trim(buf, "\x00") 293 | tmpString := string(b) 294 | command = command + tmpString 295 | 296 | commands := strings.Split(command, "\r\n") 297 | 298 | numCommands := len(commands) 299 | if numCommands > 1 { 300 | for i := range commands { 301 | // if we are at the last command, check that we have an empty command... 302 | if i == numCommands-1 { 303 | if commands[i] == "" { 304 | buf = []byte("") 305 | debugLog("BUFFER: %s %q", buf, buf) 306 | continue 307 | } 308 | } else if i != numCommands { 309 | currentCommand := commands[0] 310 | if len(commands) > 1 { 311 | numCommands-- 312 | commands = commands[1:] 313 | 314 | //buf = []byte(string(commands.joi)) 315 | command = strings.Join(commands, "\r\n") 316 | buf = []byte(command) 317 | } 318 | 319 | d.serialResponse <- currentCommand 320 | 321 | debugLog("==> Command #%d/%d: %s", i+1, len(commands), currentCommand) 322 | } 323 | } 324 | 325 | if EnableDebugMode == false{ 326 | debugLog2("🧐 Inspecting the buffer contents") 327 | debugLog2("BUFFER CONTENTS: %s tmpString: %s", buf, tmpString) 328 | debugLog2("commands: %s - %q", commands, buf) 329 | } 330 | } else { 331 | //debugLog("Incomplete command from serial... expected newline") 332 | } 333 | } 334 | } 335 | 336 | // processResponses reads serial commands that have been fully read from the serial connection. 337 | func (d *BlustreamMatrix) processResponses() { 338 | d.serialResponse = make(chan string) 339 | 340 | go func() { 341 | for { 342 | select { 343 | case msg := <-d.serialResponse: 344 | debugLog("<== READ SERIAL COMMAND: %s %q", msg, msg) 345 | 346 | // Sometimes the output contains the model name. Hope we have it! 347 | if strings.Contains(msg, "> ") && len(d.state.ModelName) > 0 { 348 | s := strings.SplitN(msg, "> ", 2) 349 | if s[0] == d.state.ModelName { 350 | msg = strings.Join(s[1:], "> ") 351 | } 352 | } 353 | 354 | // NOTE status-response.txt to see what we are parsing. 355 | // If we see that the STATUS command is incoming, then we need to hold some state: 356 | if d.statusIncoming && msg == "STATUS" { 357 | // State 1: starting to read the status, but the command itself has not started to be received. 358 | // State 2: skip normal command processing, and parse the status command. 359 | // State 3: Finished reading the status command, go back to reading the command output as normal. 360 | debugLog("🍔 Eating the status command from Blustream") 361 | // Here we enter state 1. 362 | d.statusReading = ReadingModel 363 | d.statusStarted = true // get ready for state 2. 364 | d.statusIncoming = false 365 | } else if d.statusReading > NotReadingStatus && len(msg) > 2 { 366 | if msg[:2] == "==" { 367 | // for the first set of "==", we need to stay in the status incoming state. 368 | // for the second, we can leave this special state. 369 | if d.statusStarted { 370 | // Here we enter state 2. 371 | debugLog("🥇 The next line of should be the start of our statuses.") 372 | d.statusStarted = false 373 | continue 374 | } 375 | 376 | // Here we enter state 3. 377 | debugLog("🥇 We have finished reading the status.") 378 | d.statusStarted = true 379 | d.statusReading = NotReadingStatus // leave our status state 380 | continue 381 | } 382 | 383 | debugLog("😰 Status confirmed. Should it be?") 384 | d.readStatus(msg) 385 | continue 386 | } 387 | 388 | if len(msg) > 10 { 389 | // [SUCCESS]Set output 01 connect from input 02. 390 | if msg[:9] == "[SUCCESS]" { 391 | //match := []byte(msg[9:]) 392 | r, _ := regexp.Compile(`Set output (\d*) connect from input (\d*)`) 393 | f := r.FindAllStringSubmatch(msg[9:], 12) 394 | if len(f[0]) == 3 { 395 | debugLog("Swapped input %s to output %s", f[0][2], f[0][1]) 396 | } 397 | d.finishedSwap <- true 398 | d.switching = false 399 | d.switched = true 400 | } 401 | } 402 | } 403 | } 404 | }() 405 | } 406 | 407 | // LastError return the last 408 | func (d *BlustreamMatrix) LastError() error { 409 | return d.Error 410 | } 411 | 412 | // GetInput will return an input with a given name 413 | func (d *BlustreamMatrix) GetInput(inputName string) *BlustreamInput { 414 | for _, input := range d.Inputs { 415 | if input.InputName == inputName { 416 | return &input 417 | } 418 | } 419 | return nil 420 | } 421 | 422 | // debugLog will output something only if EnableDebugMode is true. 423 | func debugLog(msg string, v ...interface{}) { 424 | if EnableDebugMode { 425 | log.Printf("[blustream]: " + msg, v...) 426 | } 427 | } -------------------------------------------------------------------------------- /client/icon/icon.go: -------------------------------------------------------------------------------- 1 | //+build linux darwin 2 | 3 | // File generated by 2goarray (http://github.com/cratonica/2goarray) 4 | 5 | package icon 6 | 7 | var Data []byte = []byte { 8 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 9 | 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 10 | 0x08, 0x06, 0x00, 0x00, 0x00, 0x73, 0x7a, 0x7a, 0xf4, 0x00, 0x00, 0x00, 11 | 0x19, 0x74, 0x45, 0x58, 0x74, 0x53, 0x6f, 0x66, 0x74, 0x77, 0x61, 0x72, 12 | 0x65, 0x00, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x49, 0x6d, 0x61, 0x67, 13 | 0x65, 0x52, 0x65, 0x61, 0x64, 0x79, 0x71, 0xc9, 0x65, 0x3c, 0x00, 0x00, 14 | 0x03, 0x66, 0x69, 0x54, 0x58, 0x74, 0x58, 0x4d, 0x4c, 0x3a, 0x63, 0x6f, 15 | 0x6d, 0x2e, 0x61, 0x64, 0x6f, 0x62, 0x65, 0x2e, 0x78, 0x6d, 0x70, 0x00, 16 | 0x00, 0x00, 0x00, 0x00, 0x3c, 0x3f, 0x78, 0x70, 0x61, 0x63, 0x6b, 0x65, 17 | 0x74, 0x20, 0x62, 0x65, 0x67, 0x69, 0x6e, 0x3d, 0x22, 0xef, 0xbb, 0xbf, 18 | 0x22, 0x20, 0x69, 0x64, 0x3d, 0x22, 0x57, 0x35, 0x4d, 0x30, 0x4d, 0x70, 19 | 0x43, 0x65, 0x68, 0x69, 0x48, 0x7a, 0x72, 0x65, 0x53, 0x7a, 0x4e, 0x54, 20 | 0x63, 0x7a, 0x6b, 0x63, 0x39, 0x64, 0x22, 0x3f, 0x3e, 0x20, 0x3c, 0x78, 21 | 0x3a, 0x78, 0x6d, 0x70, 0x6d, 0x65, 0x74, 0x61, 0x20, 0x78, 0x6d, 0x6c, 22 | 0x6e, 0x73, 0x3a, 0x78, 0x3d, 0x22, 0x61, 0x64, 0x6f, 0x62, 0x65, 0x3a, 23 | 0x6e, 0x73, 0x3a, 0x6d, 0x65, 0x74, 0x61, 0x2f, 0x22, 0x20, 0x78, 0x3a, 24 | 0x78, 0x6d, 0x70, 0x74, 0x6b, 0x3d, 0x22, 0x41, 0x64, 0x6f, 0x62, 0x65, 25 | 0x20, 0x58, 0x4d, 0x50, 0x20, 0x43, 0x6f, 0x72, 0x65, 0x20, 0x35, 0x2e, 26 | 0x30, 0x2d, 0x63, 0x30, 0x36, 0x30, 0x20, 0x36, 0x31, 0x2e, 0x31, 0x33, 27 | 0x34, 0x37, 0x37, 0x37, 0x2c, 0x20, 0x32, 0x30, 0x31, 0x30, 0x2f, 0x30, 28 | 0x32, 0x2f, 0x31, 0x32, 0x2d, 0x31, 0x37, 0x3a, 0x33, 0x32, 0x3a, 0x30, 29 | 0x30, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x22, 0x3e, 0x20, 30 | 0x3c, 0x72, 0x64, 0x66, 0x3a, 0x52, 0x44, 0x46, 0x20, 0x78, 0x6d, 0x6c, 31 | 0x6e, 0x73, 0x3a, 0x72, 0x64, 0x66, 0x3d, 0x22, 0x68, 0x74, 0x74, 0x70, 32 | 0x3a, 0x2f, 0x2f, 0x77, 0x77, 0x77, 0x2e, 0x77, 0x33, 0x2e, 0x6f, 0x72, 33 | 0x67, 0x2f, 0x31, 0x39, 0x39, 0x39, 0x2f, 0x30, 0x32, 0x2f, 0x32, 0x32, 34 | 0x2d, 0x72, 0x64, 0x66, 0x2d, 0x73, 0x79, 0x6e, 0x74, 0x61, 0x78, 0x2d, 35 | 0x6e, 0x73, 0x23, 0x22, 0x3e, 0x20, 0x3c, 0x72, 0x64, 0x66, 0x3a, 0x44, 36 | 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x72, 37 | 0x64, 0x66, 0x3a, 0x61, 0x62, 0x6f, 0x75, 0x74, 0x3d, 0x22, 0x22, 0x20, 38 | 0x78, 0x6d, 0x6c, 0x6e, 0x73, 0x3a, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3d, 39 | 0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, 40 | 0x64, 0x6f, 0x62, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, 41 | 0x2f, 0x31, 0x2e, 0x30, 0x2f, 0x6d, 0x6d, 0x2f, 0x22, 0x20, 0x78, 0x6d, 42 | 0x6c, 0x6e, 0x73, 0x3a, 0x73, 0x74, 0x52, 0x65, 0x66, 0x3d, 0x22, 0x68, 43 | 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, 0x64, 0x6f, 44 | 0x62, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, 0x2f, 0x31, 45 | 0x2e, 0x30, 0x2f, 0x73, 0x54, 0x79, 0x70, 0x65, 0x2f, 0x52, 0x65, 0x73, 46 | 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x66, 0x23, 0x22, 0x20, 0x78, 47 | 0x6d, 0x6c, 0x6e, 0x73, 0x3a, 0x78, 0x6d, 0x70, 0x3d, 0x22, 0x68, 0x74, 48 | 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, 0x64, 0x6f, 0x62, 49 | 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, 0x2f, 0x31, 0x2e, 50 | 0x30, 0x2f, 0x22, 0x20, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x4f, 0x72, 51 | 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 52 | 0x6e, 0x74, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, 53 | 0x64, 0x3a, 0x36, 0x37, 0x32, 0x34, 0x42, 0x45, 0x31, 0x35, 0x45, 0x44, 54 | 0x32, 0x30, 0x36, 0x38, 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, 55 | 0x38, 0x31, 0x35, 0x44, 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x20, 56 | 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 57 | 0x6e, 0x74, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, 58 | 0x64, 0x3a, 0x41, 0x33, 0x42, 0x34, 0x46, 0x42, 0x36, 0x36, 0x33, 0x41, 59 | 0x41, 0x38, 0x31, 0x31, 0x45, 0x32, 0x42, 0x32, 0x43, 0x41, 0x39, 0x37, 60 | 0x42, 0x44, 0x33, 0x34, 0x34, 0x31, 0x45, 0x46, 0x33, 0x32, 0x22, 0x20, 61 | 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 62 | 0x63, 0x65, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x69, 0x69, 63 | 0x64, 0x3a, 0x41, 0x33, 0x42, 0x34, 0x46, 0x42, 0x36, 0x35, 0x33, 0x41, 64 | 0x41, 0x38, 0x31, 0x31, 0x45, 0x32, 0x42, 0x32, 0x43, 0x41, 0x39, 0x37, 65 | 0x42, 0x44, 0x33, 0x34, 0x34, 0x31, 0x45, 0x46, 0x33, 0x32, 0x22, 0x20, 66 | 0x78, 0x6d, 0x70, 0x3a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x54, 67 | 0x6f, 0x6f, 0x6c, 0x3d, 0x22, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x50, 68 | 0x68, 0x6f, 0x74, 0x6f, 0x73, 0x68, 0x6f, 0x70, 0x20, 0x43, 0x53, 0x35, 69 | 0x20, 0x4d, 0x61, 0x63, 0x69, 0x6e, 0x74, 0x6f, 0x73, 0x68, 0x22, 0x3e, 70 | 0x20, 0x3c, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x44, 0x65, 0x72, 0x69, 71 | 0x76, 0x65, 0x64, 0x46, 0x72, 0x6f, 0x6d, 0x20, 0x73, 0x74, 0x52, 0x65, 72 | 0x66, 0x3a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x44, 73 | 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x69, 0x69, 0x64, 0x3a, 0x45, 0x36, 74 | 0x38, 0x31, 0x34, 0x43, 0x36, 0x41, 0x45, 0x45, 0x32, 0x30, 0x36, 0x38, 75 | 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, 0x38, 0x31, 0x35, 0x44, 76 | 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x20, 0x73, 0x74, 0x52, 0x65, 77 | 0x66, 0x3a, 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x44, 78 | 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, 0x64, 0x3a, 0x36, 0x37, 79 | 0x32, 0x34, 0x42, 0x45, 0x31, 0x35, 0x45, 0x44, 0x32, 0x30, 0x36, 0x38, 80 | 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, 0x38, 0x31, 0x35, 0x44, 81 | 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x2f, 0x3e, 0x20, 0x3c, 0x2f, 82 | 0x72, 0x64, 0x66, 0x3a, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 83 | 0x69, 0x6f, 0x6e, 0x3e, 0x20, 0x3c, 0x2f, 0x72, 0x64, 0x66, 0x3a, 0x52, 84 | 0x44, 0x46, 0x3e, 0x20, 0x3c, 0x2f, 0x78, 0x3a, 0x78, 0x6d, 0x70, 0x6d, 85 | 0x65, 0x74, 0x61, 0x3e, 0x20, 0x3c, 0x3f, 0x78, 0x70, 0x61, 0x63, 0x6b, 86 | 0x65, 0x74, 0x20, 0x65, 0x6e, 0x64, 0x3d, 0x22, 0x72, 0x22, 0x3f, 0x3e, 87 | 0x5d, 0xed, 0x35, 0xe2, 0x00, 0x00, 0x04, 0xee, 0x49, 0x44, 0x41, 0x54, 88 | 0x78, 0xda, 0xc4, 0x57, 0xcf, 0x6f, 0x55, 0x45, 0x18, 0x3d, 0xf3, 0xe3, 89 | 0xfe, 0xea, 0x7b, 0xaf, 0xa5, 0x6d, 0x0a, 0xd8, 0x34, 0xbe, 0x16, 0x83, 90 | 0x69, 0x8c, 0x2e, 0x04, 0xe2, 0x86, 0xb8, 0x70, 0xe1, 0x06, 0x35, 0x18, 91 | 0x13, 0x5d, 0x60, 0x8c, 0xd1, 0x68, 0xe2, 0xca, 0xb8, 0x33, 0x31, 0xf1, 92 | 0x6f, 0x70, 0x67, 0x5c, 0xb1, 0x62, 0xe1, 0x46, 0x42, 0x8c, 0x0b, 0xe3, 93 | 0x46, 0x34, 0x25, 0x11, 0x41, 0x14, 0xa4, 0x24, 0xa4, 0x08, 0x58, 0x0a, 94 | 0x29, 0x14, 0x0a, 0x6d, 0xe9, 0xeb, 0xbb, 0xef, 0xce, 0x9d, 0xf1, 0xcc, 95 | 0xbd, 0xaf, 0xa5, 0x44, 0x63, 0x49, 0xee, 0x4b, 0x78, 0xc9, 0xf7, 0xee, 96 | 0x9d, 0x3b, 0x33, 0x77, 0xce, 0x77, 0xbe, 0xf3, 0x7d, 0x33, 0x57, 0x38, 97 | 0xe7, 0xf0, 0x38, 0x7f, 0x7a, 0xab, 0x01, 0xe2, 0xd9, 0x37, 0xff, 0xeb, 98 | 0xb1, 0xa2, 0x7d, 0x46, 0xdb, 0xeb, 0x87, 0xd0, 0x8e, 0xd3, 0xbe, 0xf8, 99 | 0xd7, 0x28, 0x6b, 0xe1, 0x2e, 0x7c, 0xf3, 0xbf, 0xef, 0x97, 0x5b, 0x42, 100 | 0x34, 0x06, 0xf0, 0x2c, 0x6d, 0x36, 0xe0, 0x43, 0xda, 0x88, 0x90, 0xf2, 101 | 0x90, 0xea, 0x6f, 0xbc, 0x0b, 0x21, 0x9e, 0x67, 0xfb, 0x8d, 0xa2, 0xcf, 102 | 0x76, 0xc7, 0x70, 0x5e, 0xff, 0x40, 0x7f, 0x75, 0x06, 0xc6, 0x27, 0x9a, 103 | 0xb8, 0x76, 0x63, 0xbe, 0x70, 0x53, 0xf0, 0x2f, 0xe7, 0x02, 0xd6, 0xda, 104 | 0x71, 0x36, 0xaf, 0xd1, 0x0e, 0xcb, 0x38, 0x16, 0x5c, 0xf4, 0x7a, 0xbe, 105 | 0xbc, 0xd2, 0x14, 0x71, 0x04, 0x19, 0x68, 0xf8, 0xb0, 0x4a, 0xda, 0x2e, 106 | 0xce, 0xad, 0x0c, 0x60, 0xf7, 0x53, 0xbb, 0x90, 0x87, 0x11, 0x76, 0x8f, 107 | 0xee, 0x40, 0xa8, 0x35, 0xfe, 0x9a, 0xbf, 0x35, 0x36, 0x73, 0xf9, 0xea, 108 | 0x24, 0xd2, 0xf4, 0x20, 0x5d, 0x2d, 0xbc, 0x15, 0x49, 0x0c, 0x1d, 0x86, 109 | 0xdf, 0x09, 0x25, 0x7f, 0x80, 0x14, 0xd3, 0x8e, 0x20, 0x93, 0x30, 0x44, 110 | 0xa3, 0xd1, 0xd8, 0x12, 0x80, 0xdc, 0x3a, 0x02, 0x06, 0xa4, 0xba, 0xb0, 111 | 0x5a, 0x12, 0x2b, 0x2e, 0xf4, 0x35, 0xb4, 0x3e, 0x58, 0x84, 0xde, 0xb3, 112 | 0x91, 0xa6, 0x64, 0x86, 0xf7, 0x4a, 0xbe, 0x4a, 0xcf, 0x8f, 0xe4, 0x26, 113 | 0x0f, 0x42, 0xa5, 0xf0, 0xcc, 0xe8, 0x13, 0x50, 0xfe, 0x79, 0x65, 0x11, 114 | 0xf2, 0x1d, 0x86, 0x62, 0x9a, 0x9a, 0xbe, 0x88, 0xa7, 0x77, 0x8e, 0x04, 115 | 0x9d, 0x34, 0x1b, 0x45, 0xda, 0x81, 0xf7, 0xde, 0x53, 0x9d, 0x77, 0x32, 116 | 0x04, 0x49, 0x82, 0x88, 0x1e, 0x67, 0xb9, 0xa9, 0x37, 0xe2, 0x44, 0x3c, 117 | 0x39, 0x3c, 0x84, 0xa8, 0x1b, 0x8a, 0xca, 0x00, 0xba, 0xbf, 0x28, 0x35, 118 | 0xe6, 0xc0, 0x9f, 0x17, 0x2f, 0x7d, 0x20, 0xad, 0x6d, 0xc2, 0xe4, 0xd8, 119 | 0x10, 0x45, 0x10, 0x20, 0xd0, 0x01, 0xfa, 0x09, 0xa2, 0x16, 0x85, 0x13, 120 | 0xf5, 0x28, 0x3a, 0x1a, 0x28, 0x75, 0x98, 0x4b, 0x7f, 0xcf, 0xde, 0x56, 121 | 0xe5, 0x10, 0xf0, 0xf7, 0x36, 0x6d, 0x4a, 0x0a, 0x71, 0x14, 0x4a, 0x1d, 122 | 0xb0, 0x7e, 0xce, 0x3a, 0xb5, 0xbc, 0x2a, 0xea, 0x22, 0x50, 0x92, 0x11, 123 | 0x90, 0xd0, 0x8a, 0xdc, 0x0b, 0xbc, 0xc2, 0x1e, 0x9f, 0x7b, 0xbf, 0xd0, 124 | 0x3e, 0xea, 0x05, 0x80, 0x8f, 0x69, 0xfb, 0x7c, 0x76, 0x81, 0x8e, 0x23, 125 | 0x75, 0xa5, 0x75, 0x31, 0x68, 0x0f, 0x80, 0x66, 0xac, 0x44, 0x9a, 0x09, 126 | 0x38, 0x5b, 0xbe, 0x92, 0xdd, 0xcf, 0xd1, 0xde, 0xab, 0x1c, 0x82, 0x95, 127 | 0x54, 0xdf, 0x58, 0x5a, 0xe3, 0xab, 0x3a, 0x0e, 0xf5, 0xc8, 0x61, 0x72, 128 | 0x4c, 0x53, 0x5c, 0x0e, 0x27, 0x67, 0xb2, 0x62, 0x76, 0x28, 0x55, 0x51, 129 | 0x97, 0xf6, 0x8c, 0x4b, 0xf4, 0x45, 0xc0, 0xad, 0x7b, 0xe4, 0xbd, 0x23, 130 | 0xb0, 0xda, 0x21, 0x51, 0x10, 0x57, 0x2a, 0x03, 0x38, 0xb4, 0xf7, 0xef, 131 | 0x99, 0xe0, 0x85, 0x35, 0x34, 0x87, 0x15, 0x26, 0x77, 0x0e, 0xa3, 0x39, 132 | 0x5e, 0x73, 0xc7, 0x7e, 0x5a, 0xc5, 0x5b, 0x9f, 0xcf, 0x09, 0xd4, 0x15, 133 | 0x19, 0x50, 0x94, 0x84, 0xc4, 0xfb, 0x2f, 0x25, 0x78, 0x6d, 0x9f, 0xc4, 134 | 0xed, 0x85, 0x0c, 0x73, 0xf7, 0x52, 0xcc, 0x2e, 0x38, 0x5c, 0x5f, 0x74, 135 | 0xe7, 0x2a, 0x03, 0xf8, 0xe4, 0xe5, 0x9b, 0xe7, 0xa0, 0xb7, 0xd1, 0xc9, 136 | 0x41, 0x0a, 0xbf, 0x8f, 0x2e, 0xf7, 0x09, 0xa9, 0x38, 0x4d, 0xcc, 0x16, 137 | 0x9e, 0xfb, 0xb0, 0x07, 0x5a, 0x50, 0x8b, 0x75, 0x48, 0x1d, 0x61, 0x64, 138 | 0xb0, 0x8d, 0xed, 0x43, 0x6d, 0xec, 0x99, 0xa0, 0xfe, 0x4c, 0x6b, 0xba, 139 | 0x32, 0x80, 0xd4, 0xec, 0xb8, 0x2c, 0x5c, 0x9d, 0x45, 0xbd, 0x21, 0x21, 140 | 0x6b, 0x2c, 0x46, 0x35, 0xa6, 0x9d, 0x7d, 0xa0, 0x01, 0x0a, 0x30, 0x24, 141 | 0x0b, 0x51, 0x5c, 0x67, 0x23, 0x41, 0x26, 0x42, 0x6a, 0xc5, 0x9b, 0x36, 142 | 0x70, 0xe1, 0xb5, 0xb0, 0x72, 0x1d, 0x08, 0x86, 0x66, 0xa1, 0xe2, 0x25, 143 | 0xe8, 0x81, 0x41, 0xa1, 0x6a, 0x64, 0xa0, 0x1f, 0x41, 0xdc, 0x59, 0xef, 144 | 0xa5, 0xfa, 0x15, 0x42, 0x0f, 0xa2, 0x00, 0xe0, 0x59, 0x08, 0xe0, 0xf8, 145 | 0xcc, 0x09, 0x71, 0x9b, 0x20, 0x66, 0xab, 0xd7, 0x01, 0x99, 0xdc, 0xa4, 146 | 0xe7, 0x57, 0x84, 0x6e, 0x0c, 0xd2, 0x98, 0xf7, 0x83, 0xf4, 0x76, 0x6d, 147 | 0x23, 0x7f, 0x7c, 0x0a, 0xfa, 0x10, 0xc4, 0x31, 0xfb, 0x14, 0x37, 0x1f, 148 | 0x4d, 0xf1, 0x51, 0x13, 0xac, 0x42, 0x97, 0x9d, 0x50, 0x8b, 0xd5, 0x2b, 149 | 0x61, 0x30, 0x90, 0x41, 0x86, 0x97, 0xe8, 0xfd, 0x9e, 0x02, 0x80, 0xda, 150 | 0x46, 0x6f, 0x57, 0x8b, 0x52, 0xe0, 0x33, 0xd3, 0xe7, 0x3f, 0x0b, 0x0f, 151 | 0xa2, 0x64, 0x80, 0x8d, 0x3a, 0x84, 0x66, 0x85, 0x2c, 0xc2, 0x93, 0xcf, 152 | 0x08, 0x17, 0xd9, 0xea, 0x75, 0x40, 0x51, 0x78, 0x32, 0xfa, 0x83, 0x61, 153 | 0x80, 0xf0, 0xf7, 0x5c, 0x24, 0x8c, 0x06, 0x20, 0x65, 0x39, 0xd5, 0xd7, 154 | 0xfb, 0x52, 0x03, 0x04, 0xa7, 0xfd, 0xd8, 0x84, 0xe0, 0x22, 0xee, 0x1d, 155 | 0xd1, 0x19, 0x7f, 0xad, 0xce, 0x80, 0xf2, 0x2f, 0x91, 0x67, 0x79, 0xe3, 156 | 0xe9, 0xf0, 0x60, 0x10, 0x84, 0x0d, 0x36, 0x65, 0xb1, 0x1f, 0x48, 0xd1, 157 | 0x65, 0x20, 0x68, 0xa0, 0x18, 0x23, 0x03, 0x7f, 0x65, 0x47, 0x78, 0x1e, 158 | 0xc2, 0x55, 0xdf, 0x0d, 0xfd, 0x82, 0x7c, 0xe9, 0x79, 0xde, 0x2d, 0x95, 159 | 0xdb, 0xaf, 0x45, 0x18, 0xf8, 0xf4, 0x2b, 0xa7, 0x7a, 0x22, 0x22, 0xb6, 160 | 0x83, 0x90, 0x0b, 0x93, 0xfb, 0x32, 0x39, 0xe4, 0x02, 0x41, 0x9c, 0x2f, 161 | 0xc0, 0xf4, 0xa0, 0x14, 0x7b, 0x4f, 0xe7, 0xe0, 0xb2, 0x0b, 0xb0, 0x54, 162 | 0x7f, 0xbe, 0x46, 0x00, 0xe9, 0x03, 0x00, 0x3e, 0x04, 0xdc, 0xf9, 0x22, 163 | 0xc5, 0xca, 0xc8, 0x7e, 0xe7, 0x7c, 0xbd, 0xce, 0x09, 0x58, 0x2c, 0x6c, 164 | 0xe4, 0x6a, 0x25, 0x00, 0x8e, 0x2f, 0x76, 0xc6, 0x72, 0xe3, 0x3f, 0xed, 165 | 0xf2, 0x55, 0x96, 0xe4, 0x65, 0xc4, 0xaa, 0xc5, 0xb8, 0xcb, 0x82, 0x10, 166 | 0x81, 0x32, 0x0b, 0x22, 0xc1, 0xcc, 0x30, 0x34, 0xdb, 0x26, 0x88, 0xec, 167 | 0xd7, 0x62, 0x2f, 0x76, 0xb6, 0x3a, 0x00, 0x97, 0x67, 0xa5, 0x99, 0xd6, 168 | 0x94, 0x33, 0xcb, 0x04, 0x70, 0x17, 0x7d, 0x62, 0x85, 0x27, 0x9e, 0x2e, 169 | 0x80, 0x42, 0x84, 0xac, 0x07, 0xee, 0x3e, 0x01, 0xac, 0x70, 0x6c, 0xcb, 170 | 0x33, 0x71, 0x82, 0x13, 0x50, 0x58, 0xe5, 0x3a, 0x60, 0x56, 0xd7, 0xa1, 171 | 0x9c, 0x76, 0xb6, 0x73, 0x1f, 0xc2, 0xd4, 0x7d, 0x75, 0xeb, 0x5b, 0x07, 172 | 0x00, 0x0f, 0x80, 0xd9, 0x60, 0xb9, 0xb8, 0x3f, 0xc0, 0x9a, 0xb5, 0x3b, 173 | 0x44, 0x71, 0xa6, 0x67, 0xc7, 0x72, 0x97, 0xaf, 0xac, 0xdf, 0x5e, 0x45, 174 | 0xee, 0xce, 0x5a, 0xb4, 0xf7, 0x2b, 0xab, 0x91, 0x6c, 0xe8, 0x8b, 0x00, 175 | 0x28, 0x7a, 0x91, 0x2f, 0x77, 0x99, 0x4a, 0x7f, 0x23, 0xb1, 0x37, 0x7a, 176 | 0x06, 0x00, 0xd9, 0xbd, 0x8d, 0x53, 0xbe, 0x3f, 0x98, 0x38, 0xdb, 0xda, 177 | 0xaf, 0x84, 0x46, 0xac, 0x5d, 0xa9, 0x7a, 0x86, 0x40, 0x4b, 0x02, 0x30, 178 | 0x4b, 0x3c, 0x2d, 0xb3, 0xfc, 0x5b, 0x73, 0xbc, 0xa7, 0x1f, 0x26, 0x96, 179 | 0x9e, 0x6d, 0xa2, 0xe3, 0x47, 0x61, 0xe5, 0xa7, 0x42, 0x05, 0x3c, 0x03, 180 | 0x5a, 0x94, 0x25, 0xcf, 0x33, 0xc0, 0xfb, 0x9c, 0x59, 0x9a, 0x53, 0xac, 181 | 0xce, 0xfd, 0xfc, 0x28, 0xea, 0x7f, 0xf4, 0x10, 0x64, 0xf3, 0x9b, 0x9b, 182 | 0xa7, 0xb8, 0xc9, 0xcc, 0x91, 0x81, 0xb1, 0x7a, 0x68, 0x4a, 0x00, 0xc2, 183 | 0xef, 0x86, 0x06, 0x8a, 0xa1, 0xe2, 0xfa, 0x97, 0xa8, 0x86, 0xdf, 0x7b, 184 | 0xca, 0x80, 0xcb, 0x16, 0x36, 0x37, 0x17, 0x73, 0x67, 0x4e, 0xf2, 0x14, 185 | 0x34, 0x56, 0x8f, 0x92, 0xf2, 0x7c, 0x40, 0x6f, 0x7d, 0x0d, 0x10, 0xf9, 186 | 0x5d, 0x8e, 0x75, 0x27, 0x18, 0x8c, 0x56, 0x6f, 0x01, 0x98, 0xf9, 0x87, 187 | 0xdb, 0xce, 0x1c, 0x81, 0x69, 0xbf, 0x58, 0x8b, 0x9b, 0xdb, 0x81, 0x7a, 188 | 0x91, 0xc9, 0xa1, 0x6c, 0x51, 0x03, 0x77, 0xce, 0xb8, 0x5c, 0x7f, 0xe5, 189 | 0x8a, 0xcf, 0xc6, 0x1e, 0x02, 0x78, 0x38, 0x9e, 0xfe, 0xde, 0x1c, 0x83, 190 | 0x5d, 0x38, 0x55, 0x0b, 0x87, 0x5f, 0x67, 0xfb, 0x1d, 0x3e, 0x68, 0x2b, 191 | 0x61, 0xbe, 0x84, 0x6b, 0x7f, 0xeb, 0x50, 0x6b, 0x97, 0x63, 0x1e, 0xfd, 192 | 0x8b, 0x5b, 0x3c, 0xee, 0xcf, 0xf3, 0x7f, 0x04, 0x18, 0x00, 0xe0, 0x6e, 193 | 0xdd, 0x63, 0x24, 0x57, 0x80, 0x6f, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 194 | 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 195 | } 196 | --------------------------------------------------------------------------------