├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── lint.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── cmd └── streamdeck-cli │ ├── brightness.go │ ├── clear.go │ ├── image.go │ ├── main.go │ └── reset.go ├── go.mod ├── go.sum └── streamdeck.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: muesli 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | go-version: [~1.11, ^1] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | 22 | - name: Download Go modules 23 | run: go mod download 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test ./... 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v2 14 | with: 15 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 16 | version: v1.31 17 | # Optional: golangci-lint command line arguments. 18 | args: --issues-exit-code=0 19 | # Optional: working directory, useful for monorepos 20 | # working-directory: somedir 21 | # Optional: show only new issues if it's a pull request. The default value is `false`. 22 | only-new-issues: true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # IDE 15 | .idea 16 | 17 | cmd/streamdeck-cli/streamdeck-cli 18 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | max-issues-per-linter: 0 6 | max-same-issues: 0 7 | 8 | linters: 9 | enable: 10 | - bodyclose 11 | - dupl 12 | - exportloopref 13 | - goconst 14 | - godot 15 | - godox 16 | - goimports 17 | - goprintffuncname 18 | - gosec 19 | - misspell 20 | - prealloc 21 | - rowserrcheck 22 | - sqlclosecheck 23 | - unconvert 24 | - unparam 25 | - whitespace 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Christian Muehlhaeuser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamdeck 2 | 3 | [![Latest Release](https://img.shields.io/github/release/muesli/streamdeck.svg)](https://github.com/muesli/streamdeck/releases) 4 | [![Build Status](https://github.com/muesli/streamdeck/workflows/build/badge.svg)](https://github.com/muesli/streamdeck/actions) 5 | [![Go ReportCard](https://goreportcard.com/badge/muesli/streamdeck)](https://goreportcard.com/report/muesli/streamdeck) 6 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/muesli/streamdeck) 7 | 8 | A CLI application and Go library to control your Elgato Stream Deck on Linux. 9 | 10 | If you're looking for a complete Linux service to control your StreamDeck, check 11 | out [Deckmaster](https://github.com/muesli/deckmaster), which is based on this 12 | library. 13 | 14 | ## Installation 15 | 16 | Make sure you have a working Go environment (Go 1.11 or higher is required). 17 | See the [install instructions](http://golang.org/doc/install.html). 18 | 19 | To install streamdeck, simply run: 20 | 21 | go get github.com/muesli/streamdeck 22 | 23 | ## Configuration 24 | 25 | On Linux you need to set up some udev rules to be able to access the device as a 26 | regular user. Edit `/etc/udev/rules.d/99-streamdeck.rules` and add these lines: 27 | 28 | ``` 29 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", GROUP="plugdev" 30 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", GROUP="plugdev" 31 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="plugdev" 32 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="666", GROUP="plugdev" 33 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="666", GROUP="plugdev" 34 | ``` 35 | 36 | Make sure your user is part of the `plugdev` group and reload the rules with 37 | `sudo udevadm control --reload-rules`. Unplug and replug the device and you 38 | should be good to go. 39 | 40 | ## Usage 41 | 42 | Control the brightness, in percent between 0 and 100: 43 | 44 | ``` 45 | streamdeck-cli brightness 50 46 | ``` 47 | 48 | Set an image on the first key (from the top-left): 49 | 50 | ``` 51 | streamdeck-cli image 0 image.png 52 | ``` 53 | 54 | Clear all images: 55 | 56 | ``` 57 | streamdeck-cli clear 58 | ``` 59 | 60 | Reset the device: 61 | 62 | ``` 63 | streamdeck-cli reset 64 | ``` 65 | -------------------------------------------------------------------------------- /cmd/streamdeck-cli/brightness.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | brightnessCmd = &cobra.Command{ 12 | Use: "brightness ", 13 | Short: "controls the brightness of the keys (in percent)", 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | if len(args) < 1 { 16 | return fmt.Errorf("brightness requires a percentage") 17 | } 18 | 19 | brightness, err := strconv.ParseInt(args[0], 10, 8) 20 | if err != nil { 21 | return fmt.Errorf("supplied parameter is not a valid number") 22 | } 23 | return d.SetBrightness(uint8(brightness)) 24 | }, 25 | } 26 | ) 27 | 28 | func init() { 29 | RootCmd.AddCommand(brightnessCmd) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/streamdeck-cli/clear.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | clearCmd = &cobra.Command{ 9 | Use: "clear", 10 | Short: "clears all images", 11 | RunE: func(cmd *cobra.Command, args []string) error { 12 | return d.Clear() 13 | }, 14 | } 15 | ) 16 | 17 | func init() { 18 | RootCmd.AddCommand(clearCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/streamdeck-cli/image.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "os" 7 | "strconv" 8 | 9 | _ "image/gif" 10 | _ "image/jpeg" 11 | _ "image/png" 12 | 13 | "github.com/nfnt/resize" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var ( 18 | imageCmd = &cobra.Command{ 19 | Use: "image ", 20 | Short: "sets an image on a key", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | if len(args) < 2 { 23 | return fmt.Errorf("image requires the key-index and an image") 24 | } 25 | 26 | key, err := strconv.ParseInt(args[0], 10, 8) 27 | if err != nil { 28 | return fmt.Errorf("supplied parameter is not a valid number") 29 | } 30 | 31 | f, err := os.Open(args[1]) 32 | if err != nil { 33 | return err 34 | } 35 | defer f.Close() 36 | 37 | img, _, err := image.Decode(f) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return d.SetImage(uint8(key), resize.Resize(72, 72, img, resize.Lanczos3)) 43 | }, 44 | } 45 | ) 46 | 47 | func init() { 48 | RootCmd.AddCommand(imageCmd) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/streamdeck-cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/unix-streamdeck/streamdeck" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | // RootCmd is the core command used for cli-arg parsing. 14 | RootCmd = &cobra.Command{ 15 | Use: "streamdeck-cli", 16 | Short: "streamdeck-cli lets you control your Elgato Stream Deck", 17 | SilenceErrors: true, 18 | SilenceUsage: true, 19 | } 20 | 21 | d streamdeck.Device 22 | ) 23 | 24 | func main() { 25 | devs, err := streamdeck.Devices() 26 | if err != nil { 27 | panic(err) 28 | } 29 | if len(devs) == 0 { 30 | log.Fatalln("No Stream Deck devices found.") 31 | } 32 | d = devs[0] 33 | 34 | err = d.Open() 35 | if err != nil { 36 | panic(err) 37 | } 38 | defer d.Close() 39 | 40 | ver, err := d.FirmwareVersion() 41 | if err != nil { 42 | panic(err) 43 | } 44 | fmt.Printf("Found device with serial %s (firmware %s)\n", 45 | d.Serial, ver) 46 | 47 | if err := RootCmd.Execute(); err != nil { 48 | fmt.Println(err) 49 | os.Exit(-1) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cmd/streamdeck-cli/reset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | resetCmd = &cobra.Command{ 9 | Use: "reset", 10 | Short: "resets the device, clears all images and shows the default logo", 11 | RunE: func(cmd *cobra.Command, args []string) error { 12 | return d.Reset() 13 | }, 14 | } 15 | ) 16 | 17 | func init() { 18 | RootCmd.AddCommand(resetCmd) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/unix-streamdeck/driver 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8 7 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 8 | github.com/spf13/cobra v0.0.7 9 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 4 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 5 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 6 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 7 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 8 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 9 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 10 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 11 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 12 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 13 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 14 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 15 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 19 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 20 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 21 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 22 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 23 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 24 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 25 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 26 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 27 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 28 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 29 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 30 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 31 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 32 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 33 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 34 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 35 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 36 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 37 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 38 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 39 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 40 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 41 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 42 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 43 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 44 | github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8 h1:AP5krei6PpUCFOp20TSmxUS4YLoLvASBcArJqM/V+DY= 45 | github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8/go.mod h1:Vr51f8rUOLYrfrWDFlV12GGQgM5AT8sVh+2fY4MPeu8= 46 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 47 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 48 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 49 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 50 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 51 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 52 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 53 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 54 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 55 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 56 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 57 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 58 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 59 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 60 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 61 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 62 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 65 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 66 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 67 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 68 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 69 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 70 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 71 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 72 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 73 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 74 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 75 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 76 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 77 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 78 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 79 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 80 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 81 | github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU= 82 | github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= 83 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 84 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 85 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 86 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 87 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 88 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 89 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 90 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 91 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 92 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 93 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 94 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 95 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 96 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 97 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 98 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 99 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= 100 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 101 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 102 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 103 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 104 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 105 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 106 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 107 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 108 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 109 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 113 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 114 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 115 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 116 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 117 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 118 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 119 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 121 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 122 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 123 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 124 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 125 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 126 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 129 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 130 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 131 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 132 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 133 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 134 | -------------------------------------------------------------------------------- /streamdeck.go: -------------------------------------------------------------------------------- 1 | package streamdeck 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/jpeg" 9 | 10 | "github.com/karalabe/hid" 11 | "golang.org/x/image/draw" 12 | ) 13 | 14 | const ( 15 | VID_ELGATO = 0x0fd9 16 | PID_STREAMDECK = 0x0060 17 | PID_STREAMDECK_V2 = 0x006d 18 | PID_STREAMDECK_MK2 = 0x0080 19 | PID_STREAMDECK_MINI = 0x0063 20 | PID_STREAMDECK_XL = 0x006c 21 | ) 22 | 23 | var ( 24 | c_REV1_FIRMWARE = []byte{0x04} 25 | c_REV1_RESET = []byte{0x0b, 0x63} 26 | c_REV1_BRIGHTNESS = []byte{0x05, 0x55, 0xaa, 0xd1, 0x01} 27 | 28 | c_REV2_FIRMWARE = []byte{0x05} 29 | c_REV2_RESET = []byte{0x03, 0x02} 30 | c_REV2_BRIGHTNESS = []byte{0x03, 0x08} 31 | ) 32 | 33 | // Device represents a single Stream Deck device. 34 | type Device struct { 35 | ID string 36 | Serial string 37 | 38 | Columns uint8 39 | Rows uint8 40 | Keys uint8 41 | Pixels uint 42 | DPI uint 43 | Padding uint 44 | 45 | featureReportSize int 46 | firmwareOffset int 47 | keyStateOffset int 48 | translateKeyIndex func(index, columns uint8) uint8 49 | imagePageSize int 50 | imagePageHeaderSize int 51 | toImageFormat func(image.Image) ([]byte, error) 52 | imagePageHeader func(pageIndex int, keyIndex uint8, payloadLength int, lastPage bool) []byte 53 | 54 | getFirmwareCommand []byte 55 | resetCommand []byte 56 | setBrightnessCommand []byte 57 | 58 | keyState []byte 59 | 60 | device *hid.Device 61 | info hid.DeviceInfo 62 | } 63 | 64 | // Key holds the current status of a key on the device. 65 | type Key struct { 66 | Index uint8 67 | Pressed bool 68 | } 69 | 70 | // Devices returns all attached Stream Decks. 71 | func Devices() ([]Device, error) { 72 | dd := []Device{} 73 | 74 | devs := hid.Enumerate(VID_ELGATO, 0) 75 | for _, d := range devs { 76 | var dev Device 77 | 78 | switch { 79 | case d.VendorID == VID_ELGATO && d.ProductID == PID_STREAMDECK: 80 | dev = Device{ 81 | ID: d.Path, 82 | Serial: d.Serial, 83 | Columns: 5, 84 | Rows: 3, 85 | Keys: 15, 86 | Pixels: 72, 87 | DPI: 124, 88 | Padding: 16, 89 | featureReportSize: 17, 90 | firmwareOffset: 5, 91 | keyStateOffset: 1, 92 | translateKeyIndex: translateRightToLeft, 93 | imagePageSize: 7819, 94 | imagePageHeaderSize: 16, 95 | imagePageHeader: rev1ImagePageHeader, 96 | toImageFormat: toBMP, 97 | getFirmwareCommand: c_REV1_FIRMWARE, 98 | resetCommand: c_REV1_RESET, 99 | setBrightnessCommand: c_REV1_BRIGHTNESS, 100 | } 101 | case d.VendorID == VID_ELGATO && d.ProductID == PID_STREAMDECK_MINI: 102 | dev = Device{ 103 | ID: d.Path, 104 | Serial: d.Serial, 105 | Columns: 3, 106 | Rows: 2, 107 | Keys: 6, 108 | Pixels: 80, 109 | DPI: 138, 110 | Padding: 16, 111 | featureReportSize: 17, 112 | firmwareOffset: 5, 113 | keyStateOffset: 1, 114 | translateKeyIndex: translateRightToLeft, 115 | imagePageSize: 1024, 116 | imagePageHeaderSize: 16, 117 | imagePageHeader: rev1ImagePageHeader, 118 | toImageFormat: toBMP, 119 | getFirmwareCommand: c_REV1_FIRMWARE, 120 | resetCommand: c_REV1_RESET, 121 | setBrightnessCommand: c_REV1_BRIGHTNESS, 122 | } 123 | case d.VendorID == VID_ELGATO && (d.ProductID == PID_STREAMDECK_V2 || d.ProductID == PID_STREAMDECK_MK2): 124 | dev = Device{ 125 | ID: d.Path, 126 | Serial: d.Serial, 127 | Columns: 5, 128 | Rows: 3, 129 | Keys: 15, 130 | Pixels: 72, 131 | DPI: 124, 132 | Padding: 16, 133 | featureReportSize: 32, 134 | firmwareOffset: 6, 135 | keyStateOffset: 4, 136 | translateKeyIndex: identity, 137 | imagePageSize: 1024, 138 | imagePageHeaderSize: 8, 139 | imagePageHeader: rev2ImagePageHeader, 140 | toImageFormat: toJPEG, 141 | getFirmwareCommand: c_REV2_FIRMWARE, 142 | resetCommand: c_REV2_RESET, 143 | setBrightnessCommand: c_REV2_BRIGHTNESS, 144 | } 145 | case d.VendorID == VID_ELGATO && d.ProductID == PID_STREAMDECK_XL: 146 | dev = Device{ 147 | ID: d.Path, 148 | Serial: d.Serial, 149 | Columns: 8, 150 | Rows: 4, 151 | Keys: 32, 152 | Pixels: 96, 153 | DPI: 166, 154 | Padding: 16, 155 | featureReportSize: 32, 156 | firmwareOffset: 6, 157 | keyStateOffset: 4, 158 | translateKeyIndex: identity, 159 | imagePageSize: 1024, 160 | imagePageHeaderSize: 8, 161 | imagePageHeader: rev2ImagePageHeader, 162 | toImageFormat: toJPEG, 163 | getFirmwareCommand: c_REV2_FIRMWARE, 164 | resetCommand: c_REV2_RESET, 165 | setBrightnessCommand: c_REV2_BRIGHTNESS, 166 | } 167 | } 168 | 169 | if dev.ID != "" { 170 | dev.keyState = make([]byte, dev.Columns*dev.Rows) 171 | dev.info = d 172 | dd = append(dd, dev) 173 | } 174 | } 175 | 176 | return dd, nil 177 | } 178 | 179 | // Open the device for input/output. This must be called before trying to 180 | // communicate with the device. 181 | func (d *Device) Open() error { 182 | var err error 183 | d.device, err = d.info.Open() 184 | return err 185 | } 186 | 187 | // Close the connection with the device. 188 | func (d Device) Close() error { 189 | return d.device.Close() 190 | } 191 | 192 | // FirmwareVersion returns the firmware version of the device. 193 | func (d Device) FirmwareVersion() (string, error) { 194 | result, err := d.getFeatureReport(d.getFirmwareCommand) 195 | if err != nil { 196 | return "", err 197 | } 198 | return string(result[d.firmwareOffset:]), nil 199 | } 200 | 201 | // Resets the Stream Deck, clears all button images and shows the standby image. 202 | func (d Device) Reset() error { 203 | return d.sendFeatureReport(d.resetCommand) 204 | } 205 | 206 | // Clears the Stream Deck, setting a black image on all buttons. 207 | func (d Device) Clear() error { 208 | img := image.NewRGBA(image.Rect(0, 0, int(d.Pixels), int(d.Pixels))) 209 | draw.Draw(img, img.Bounds(), image.NewUniform(color.RGBA{0, 0, 0, 255}), image.Point{}, draw.Src) 210 | for i := uint8(0); i <= d.Columns*d.Rows; i++ { 211 | err := d.SetImage(i, img) 212 | if err != nil { 213 | fmt.Println(err) 214 | return err 215 | } 216 | } 217 | 218 | return nil 219 | } 220 | 221 | // ReadKeys returns a channel, which it will use to emit key presses/releases. 222 | func (d Device) ReadKeys() (chan Key, error) { 223 | kch := make(chan Key) 224 | keyBuffer := make([]byte, d.keyStateOffset+len(d.keyState)) 225 | go func() { 226 | for { 227 | copy(d.keyState, keyBuffer[d.keyStateOffset:]) 228 | 229 | _, err := d.device.Read(keyBuffer) 230 | if err != nil { 231 | close(kch) 232 | return 233 | } 234 | 235 | for i := d.keyStateOffset; i < len(keyBuffer); i++ { 236 | keyIndex := uint8(i - d.keyStateOffset) 237 | if keyBuffer[i] != d.keyState[keyIndex] { 238 | kch <- Key{ 239 | Index: d.translateKeyIndex(keyIndex, d.Columns), 240 | Pressed: keyBuffer[i] == 1, 241 | } 242 | } 243 | } 244 | } 245 | }() 246 | 247 | return kch, nil 248 | } 249 | 250 | // SetBrightness sets the background lighting brightness from 0 to 100 percent. 251 | func (d Device) SetBrightness(percent uint8) error { 252 | if percent > 100 { 253 | percent = 100 254 | } 255 | 256 | report := make([]byte, len(d.setBrightnessCommand)+1) 257 | copy(report, d.setBrightnessCommand) 258 | report[len(report)-1] = percent 259 | 260 | return d.sendFeatureReport(report) 261 | } 262 | 263 | // SetImage sets the image of a button on the Stream Deck. The provided image 264 | // needs to be in the correct resolution for the device. The index starts with 265 | // 0 being the top-left button. 266 | func (d Device) SetImage(index uint8, img image.Image) error { 267 | if img.Bounds().Dy() != int(d.Pixels) || 268 | img.Bounds().Dx() != int(d.Pixels) { 269 | return fmt.Errorf("supplied image has wrong dimensions, expected %[1]dx%[1]d pixels", d.Pixels) 270 | } 271 | 272 | imageBytes, err := d.toImageFormat(img) 273 | if err != nil { 274 | return fmt.Errorf("cannot convert image data: %v", err) 275 | } 276 | imageData := imageData{ 277 | image: imageBytes, 278 | pageSize: d.imagePageSize - d.imagePageHeaderSize, 279 | } 280 | 281 | data := make([]byte, d.imagePageSize) 282 | 283 | var page int 284 | var lastPage bool 285 | for !lastPage { 286 | var payload []byte 287 | payload, lastPage = imageData.Page(page) 288 | header := d.imagePageHeader(page, d.translateKeyIndex(index, d.Columns), len(payload), lastPage) 289 | 290 | copy(data, header) 291 | copy(data[len(header):], payload) 292 | 293 | _, err := d.device.Write(data) 294 | if err != nil { 295 | return fmt.Errorf("cannot write image page %d of %d (%d image bytes) %d bytes: %v", page, imageData.PageCount(), imageData.Length(), len(data), err) 296 | } 297 | 298 | page++ 299 | } 300 | 301 | return nil 302 | } 303 | 304 | // getFeatureReport from the device without worries about the correct payload size. 305 | func (d Device) getFeatureReport(payload []byte) ([]byte, error) { 306 | b := make([]byte, d.featureReportSize) 307 | copy(b, payload) 308 | _, err := d.device.GetFeatureReport(b) 309 | if err != nil { 310 | return nil, err 311 | } 312 | return b, nil 313 | } 314 | 315 | // sendFeatureReport to the device without worries about the correct payload size. 316 | func (d Device) sendFeatureReport(payload []byte) error { 317 | b := make([]byte, d.featureReportSize) 318 | copy(b, payload) 319 | _, err := d.device.SendFeatureReport(b) 320 | return err 321 | } 322 | 323 | // translateRightToLeft translates the given key index from right-to-left to left-to-right, based on the given number of columns. 324 | func translateRightToLeft(index, columns uint8) uint8 { 325 | keyCol := index % columns 326 | return (index - keyCol) + (columns - 1) - keyCol 327 | } 328 | 329 | // identity returns the given key index as it is. 330 | func identity(index, _ uint8) uint8 { 331 | return index 332 | } 333 | 334 | // toRGBA converts an image.Image to an image.RGBA. 335 | func toRGBA(img image.Image) *image.RGBA { 336 | switch img := img.(type) { 337 | case *image.RGBA: 338 | return img 339 | } 340 | out := image.NewRGBA(img.Bounds()) 341 | draw.Copy(out, image.Pt(0, 0), img, img.Bounds(), draw.Src, nil) 342 | return out 343 | } 344 | 345 | // toBMP returns the raw bytes of the given image in BMP format, flipped horizontally. 346 | func toBMP(img image.Image) ([]byte, error) { 347 | rgba := toRGBA(img) 348 | 349 | // this is a BMP file header followed by a BPM bitmap info header 350 | // find more information here: https://en.wikipedia.org/wiki/BMP_file_format 351 | header := []byte{ 352 | 0x42, 0x4d, 0xf6, 0x3c, 0x00, 0x00, 0x00, 0x00, 353 | 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, 354 | 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x48, 0x00, 355 | 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, 356 | 0x00, 0x00, 0xc0, 0x3c, 0x00, 0x00, 0xc4, 0x0e, 357 | 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0x00, 0x00, 358 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 359 | } 360 | 361 | buffer := make([]byte, len(header)+rgba.Bounds().Dx()*rgba.Bounds().Dy()*3) 362 | copy(buffer, header) 363 | 364 | i := len(header) 365 | for y := 0; y < rgba.Bounds().Dy(); y++ { 366 | // flip image horizontally 367 | for x := rgba.Bounds().Dx() - 1; x >= 0; x-- { 368 | c := rgba.RGBAAt(x, y) 369 | buffer[i] = c.B 370 | buffer[i+1] = c.G 371 | buffer[i+2] = c.R 372 | i += 3 373 | } 374 | } 375 | return buffer, nil 376 | } 377 | 378 | // toJPEG returns the raw bytes of the given image in JPEG format, flipped horizontally and vertically. 379 | func toJPEG(img image.Image) ([]byte, error) { 380 | // flip image horizontally and vertically 381 | flipped := image.NewRGBA(img.Bounds()) 382 | draw.Copy(flipped, image.Point{}, img, img.Bounds(), draw.Src, nil) 383 | for y := 0; y < flipped.Bounds().Dy()/2; y++ { 384 | yy := flipped.Bounds().Max.Y - y - 1 385 | for x := 0; x < flipped.Bounds().Dx(); x++ { 386 | xx := flipped.Bounds().Max.X - x - 1 387 | 388 | c := flipped.RGBAAt(x, y) 389 | flipped.SetRGBA(x, y, flipped.RGBAAt(xx, yy)) 390 | flipped.SetRGBA(xx, yy, c) 391 | } 392 | } 393 | 394 | buffer := bytes.NewBuffer([]byte{}) 395 | opts := jpeg.Options{ 396 | Quality: 100, 397 | } 398 | err := jpeg.Encode(buffer, flipped, &opts) 399 | if err != nil { 400 | return nil, err 401 | } 402 | return buffer.Bytes(), err 403 | } 404 | 405 | // rev1ImagePageHeader returns the image page header sequence used by the Stream Deck v1 and Stream Deck Mini. 406 | func rev1ImagePageHeader(pageIndex int, keyIndex uint8, payloadLength int, lastPage bool) []byte { 407 | var lastPageByte byte 408 | if lastPage { 409 | lastPageByte = 1 410 | } 411 | return []byte{ 412 | 0x02, 0x01, 413 | byte(pageIndex + 1), 0x00, 414 | lastPageByte, 415 | keyIndex + 1, 416 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 417 | } 418 | } 419 | 420 | // rev2ImagePageHeader returns the image page header sequence used by Stream Deck XL and Stream Deck v2. 421 | func rev2ImagePageHeader(pageIndex int, keyIndex uint8, payloadLength int, lastPage bool) []byte { 422 | var lastPageByte byte 423 | if lastPage { 424 | lastPageByte = 1 425 | } 426 | return []byte{ 427 | 0x02, 0x07, keyIndex, lastPageByte, 428 | byte(payloadLength), byte(payloadLength >> 8), 429 | byte(pageIndex), byte(pageIndex >> 8), 430 | } 431 | } 432 | 433 | // imageData allows to access raw image data in a byte array through pages of a given size. 434 | type imageData struct { 435 | image []byte 436 | pageSize int 437 | } 438 | 439 | // Page returns the page with the given index and an indication if this is the last page. 440 | func (d imageData) Page(pageIndex int) ([]byte, bool) { 441 | offset := pageIndex * d.pageSize 442 | if offset >= len(d.image) { 443 | return []byte{}, true 444 | } 445 | 446 | length := d.pageLength(pageIndex) 447 | if offset+length > len(d.image) { 448 | length = len(d.image) - offset 449 | } 450 | 451 | return d.image[offset : offset+length], pageIndex == d.PageCount()-1 452 | } 453 | 454 | func (d imageData) pageLength(pageIndex int) int { 455 | remaining := len(d.image) - (pageIndex * d.pageSize) 456 | if remaining > d.pageSize { 457 | return d.pageSize 458 | } 459 | if remaining > 0 { 460 | return remaining 461 | } 462 | return 0 463 | } 464 | 465 | // PageCount returns the total number of pages. 466 | func (d imageData) PageCount() int { 467 | count := len(d.image) / d.pageSize 468 | if len(d.image)%d.pageSize != 0 { 469 | return count + 1 470 | } 471 | return count 472 | } 473 | 474 | // Length of the raw image data in bytes. 475 | func (d imageData) Length() int { 476 | return len(d.image) 477 | } 478 | --------------------------------------------------------------------------------