├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── example.jpg ├── gmail.go ├── nvidia.go ├── oled_controller.go ├── sysstats_linux.go ├── sysstats_windows.go ├── tag.go └── weather.go /.gitignore: -------------------------------------------------------------------------------- 1 | /go-oled-controller 2 | /go-oled-controller.exe 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Albert "Drauthius" Diserholt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJ := go-oled-controller 2 | SRC := $(wildcard *.go) 3 | 4 | CROSS_COMPILE_PREFIX ?= x86_64-w64-mingw32- 5 | 6 | build: $(PROJ) $(PROJ).exe 7 | 8 | $(PROJ): $(SRC) 9 | go build -o $@ 10 | 11 | # Cross-compile to Windows. 12 | # Note: 64 bit is required for NVIDIA library (NVML). 13 | $(PROJ).exe: $(SRC) 14 | GOOS=windows GOARCH=amd64 \ 15 | CGO_ENABLED=1 CC=$(CROSS_COMPILE_PREFIX)gcc CXX=$(CROSS_COMPILE_PREFIX)g++ \ 16 | go build -o $@ -a 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OLED controller 2 | 3 | This project includes a program to control the content of the OLED screen(s) accompanied with certain keyboards that use 4 | the [QMK firmware](https://github.com/Drauthius/qmk_firmware), such as the Lily58. Support has to be built into the 5 | firmware, and the firmware flashed to the keyboard, before this program can be used. 6 | 7 | In addition to proper support in the firmware, this program uses a custom 8 | [`glcdfont.c`](https://github.com/Drauthius/qmk_firmware/tree/master/keyboards/lily58/keymaps/albhen/glcdfont.c) file 9 | to show special icons for the bars, weather condition, fan, etc. 10 | 11 | The program comes pre-programmed with three different views (called tags), which can be shown on two OLED screens. 12 | Events from the keyboard can be sent to switch between the different tags. 13 | 14 | ![Example](example.jpg) 15 | 16 | ## System status integration 17 | 18 | Shows bar graphs representing the current utilization of the system. 19 | * CPU% - Total CPU utilization for all cores and hyperthreads. 20 | * Mem% - Memory utilization. 21 | * Swap - Swap (page file) utilization. 22 | * Disk - Disk I/O utilization. 23 | 24 | On Linux, the flag `-sysstat-disk` can be specified to select for which harddisk to show utilization. 25 | 26 | ## GMail integration 27 | 28 | Shows the number of unread messages for a certain label. This can be set up in multiple ways, but for a personal GMail 29 | account, go to https://console.developers.google.com, create a new project, enable the GMail API, and then under 30 | credentials create a new OAuth 2.0 Client ID. Download the JSON credentials file for that Client ID, and specify the 31 | path to it using the `-gmail-credentials` flag to the OLED controller program. The OLED controller program will output 32 | an URL that you need to visit, and after completing authentication, the website will show a token that needs to be 33 | input to the OLED controller. Once this has been done once, the credentials will be cached, and the operation doesn't 34 | need to be performed again (though you still need to specify the path to the downloaded credentials file). 35 | 36 | ## OpenWeatherMap integration 37 | 38 | Shows the current temperature and weather condition in a specified location. An account needs to be created at 39 | https://openweathermap.org, where you will get a personal API key that needs to be passed to the program using the 40 | `-weather-api-key` flag, together with the desired location for which to get the current weather with the 41 | `-weather-location` flag. The location should be specified in the format `,`, e.g. "Los Angeles,US". 42 | 43 | The temperature is in Celsius by default. This can be changed with the `-temperature-unit` flag. 44 | 45 | ## NVIDIA integration 46 | 47 | Shows bar graphs representing the current utilization of the graphic card, as well as the current temperature. 48 | * GPU% - GPU utilization. 49 | * Mem% - Memory utilization. 50 | * PCIe - PCIe bus utilization. 51 | * Fan - Intended fan speed. 52 | 53 | The temperature is in Celsius by default. This can be changed with the `-temperature-unit` flag. 54 | 55 | NVML, NVIDIA Management Library, is used to gather status from the graphic card. A shared library needs to be installed 56 | locally for this to work. On Linux, the shared library is called "libnvidia-ml.so", which probably comes together with 57 | the NVIDIA drivers. On Windows the library is called "nvml.dll", and can be found in the CUDA toolkit. 58 | 59 | ## License 60 | 61 | Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 62 | Licensed under the MIT License. 63 | -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Drauthius/go-oled-controller/3fefcfa640aef44efbec459b15579b11bc684c20/example.jpg -------------------------------------------------------------------------------- /gmail.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Get status for GMail. This requires some work on your part. Register a project on console.developers.google.com, 5 | // then give access to that project to use GMail, and lastly create an OAuth 2.0 Client ID. The resulting JSON-file can be 6 | // given as an argument to the program, and the first time you will be prompted with an URL to visit, and a token to 7 | // fill in after logging in on the site. 8 | 9 | package main 10 | 11 | import ( 12 | "context" 13 | "encoding/json" 14 | "fmt" 15 | "io/ioutil" 16 | "log" 17 | "os" 18 | "path/filepath" 19 | "time" 20 | 21 | "github.com/kirsle/configdir" 22 | "golang.org/x/oauth2" 23 | "golang.org/x/oauth2/google" 24 | "google.golang.org/api/gmail/v1" 25 | "google.golang.org/api/option" 26 | ) 27 | 28 | // Get a GMail service 29 | func getService(config *oauth2.Config) *gmail.Service { 30 | ctx := context.Background() 31 | 32 | configDir := configdir.LocalConfig("oled-controller") 33 | err := configdir.MakePath(configDir) 34 | if err != nil { 35 | log.Printf("Failed to create configuration path %s: %v\n", configDir, err) 36 | return nil 37 | } 38 | tokenFile := filepath.Join(configDir, "token.json") 39 | token, err := getTokenFromFile(tokenFile) 40 | if err != nil { 41 | token := getTokenFromWeb(config) 42 | if token == nil { 43 | return nil 44 | } 45 | saveTokenToFile(tokenFile, token) 46 | } 47 | 48 | gmailService, err := gmail.NewService(ctx, option.WithTokenSource(config.TokenSource(ctx, token))) 49 | if err != nil { 50 | log.Println("Failed to create GMail service:", err) 51 | return nil 52 | } 53 | 54 | return gmailService 55 | } 56 | 57 | // Get an API token from the web (2-peg OAuth or something) 58 | func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { 59 | authUrl := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) 60 | fmt.Printf("Please visit this URL to authenticate: \n%v\n", authUrl) 61 | 62 | fmt.Print("Code: ") 63 | var authCode string 64 | if _, err := fmt.Scan(&authCode); err != nil { 65 | log.Println("Failed to read code:", err) 66 | return nil 67 | } 68 | 69 | token, err := config.Exchange(context.TODO(), authCode) 70 | if err != nil { 71 | log.Println("Failed to exchange OAuth token:", err) 72 | return nil 73 | } 74 | return token 75 | } 76 | 77 | // Read a previously stored API token from a file. 78 | func getTokenFromFile(file string) (*oauth2.Token, error) { 79 | f, err := os.Open(file) 80 | if err != nil { 81 | return nil, err 82 | } 83 | defer f.Close() 84 | token := &oauth2.Token{} 85 | err = json.NewDecoder(f).Decode(token) 86 | return token, err 87 | } 88 | 89 | // Save a retrieved API token to a file. 90 | func saveTokenToFile(file string, token *oauth2.Token) { 91 | fmt.Printf("Saving credentials to file '%s'\n", file) 92 | f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 93 | if err != nil { 94 | log.Println("Failed to cache OAuth token:", err) 95 | return 96 | } 97 | defer f.Close() 98 | json.NewEncoder(f).Encode(token) 99 | } 100 | 101 | // Start a loop that gets the count of unread messages for a specific label. 102 | func GmailStats(credentials string, label string, result chan int64, quit chan bool) { 103 | defer close(result) 104 | 105 | configContent, err := ioutil.ReadFile(credentials) 106 | if err != nil { 107 | log.Printf("Failed to read credentials file %s: %v\n", credentials, err) 108 | return 109 | } 110 | config, err := google.ConfigFromJSON(configContent, gmail.GmailLabelsScope) 111 | if err != nil { 112 | log.Println("Failed to create credentials from JSON:", err) 113 | return 114 | } 115 | 116 | gmailService := getService(config) 117 | if gmailService == nil { 118 | return 119 | } 120 | 121 | user := "me" 122 | for { 123 | label, err := gmailService.Users.Labels.Get(user, label).Do() 124 | if err != nil { 125 | log.Println("Failed to get unread message count:", err) 126 | } else { 127 | result <- label.MessagesUnread 128 | } 129 | 130 | select { 131 | case <-time.After(1 * time.Minute): 132 | case <-quit: 133 | return 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /nvidia.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Get status of the first installed NVIDIA graphics card. Uses NVML to communicate with it. 5 | 6 | package main 7 | 8 | import ( 9 | "log" 10 | "time" 11 | 12 | "gitlab.com/Drauthius/gpu-monitoring-tools/bindings/go/nvml" 13 | ) 14 | 15 | // The type of a graphic card result 16 | type GraphicCardResult struct { 17 | Temperature float64 // The temperature in the desired unit. 18 | FanSpeed float64 // The intended fan speed in percent (0-1). 19 | GPU float64 // The GPU utilization in percent (0-1). 20 | Memory float64 // The memory utilization in percent (0-1). 21 | Encoder float64 // The encoder utilization in percent (0-1). 22 | Decoder float64 // The decoder utilization in percent (0-1). 23 | PCIBandwidth float64 // The PCIe bandwidth utilization in percent (0-1). 24 | } 25 | 26 | // Run a loop that will continuously get status from the NVIDIA graphics card, at the specified interval. 27 | func GraphicCardStats(interval time.Duration, unit string, results chan GraphicCardResult, quit chan bool) { 28 | defer close(results) 29 | 30 | if err := nvml.Init(); err != nil { 31 | log.Println("Failed to initiate NVML:", err) 32 | return 33 | } 34 | defer nvml.Shutdown() 35 | 36 | count, err := nvml.GetDeviceCount() 37 | if err != nil { 38 | log.Println("Failed to get device count:", err) 39 | return 40 | } else if count < 1 { 41 | log.Println("Found no NVIDIA device.") 42 | return 43 | } 44 | 45 | device, err := nvml.NewDevice(0) 46 | if err != nil { 47 | log.Println("Failed to create device:", err) 48 | return 49 | } 50 | 51 | for { 52 | status, err := device.Status() 53 | if err != nil { 54 | log.Println("Failed to get device status:", err) 55 | return 56 | } 57 | 58 | var temperature float64 59 | switch unit { 60 | case "F": 61 | temperature = float64(*status.Temperature)*9/5 + 32 62 | case "K": 63 | temperature = float64(*status.Temperature) + 273.15 64 | default: 65 | fallthrough 66 | case "C": 67 | temperature = float64(*status.Temperature) 68 | } 69 | results <- GraphicCardResult{ 70 | Temperature: temperature, 71 | FanSpeed: float64(*status.FanSpeed) / 100, 72 | GPU: float64(*status.Utilization.GPU) / 100, 73 | Memory: float64(*status.Utilization.Memory) / 100, 74 | Encoder: float64(*status.Utilization.Encoder) / 100, 75 | Decoder: float64(*status.Utilization.Decoder) / 100, 76 | PCIBandwidth: float64(*status.PCI.Throughput.RX+*status.PCI.Throughput.TX) / float64(*device.PCI.Bandwidth), 77 | } 78 | 79 | select { 80 | case <-time.After(interval): 81 | case <-quit: 82 | return 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /oled_controller.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Show programmable information on the OLED screens. This file is responsible for finding the screens, talking with 5 | // the QMK firmware, and keeping track of the tags that are to be shown on them. 6 | // Note that a special version of the QMK firmware is required, and a special version of glcdfont.c to proper show all 7 | // the icons and text. 8 | 9 | package main 10 | 11 | import ( 12 | "flag" 13 | "log" 14 | "os" 15 | "os/signal" 16 | "runtime" 17 | "sync" 18 | "syscall" 19 | "time" 20 | 21 | "github.com/bearsh/hid" 22 | ) 23 | 24 | // Device detection constants. 25 | const ( 26 | VENDOR_ID = 0x04D8 // The USB vendor ID to look for. 27 | PRODUCT_ID = 0xEB2D // The USB product ID to look for. 28 | USAGE = 0x0061 // The USB usage to look for (Windows/Mac only). 29 | USAGE_PAGE = 0xFF60 // The USB usage page to look for (Windows/Mac only) 30 | INTERFACE = 1 // The USB interface number to look for (Linux only) 31 | ) 32 | 33 | // Struct containing the program arguments 34 | type Args struct { 35 | debug *bool // Whether debugging is enabled 36 | temperatureUnit *string // The unit in which to display temperature (C, F, or K). 37 | sysStatDisk *string // The name of the disk for which to show I/O usage (Linux only) 38 | gmailCredentials *string // The path to the JSON credential file for fetching GMail information. 39 | gmailLabel *string // The label for which to fetch the number of unread messages. 40 | weatherKey *string // The openweathermap.org API key 41 | weatherLocation *string // The location for which to get the current temperature. 42 | } 43 | 44 | // Global argument object 45 | var gArgs Args 46 | 47 | // Icon constants. Assumes a custom glcdfont.c to show some of the nicer icons. 48 | const ( 49 | BAR_CHAR = "\x7F" // The character to use for drawing a horizontal bar. 50 | MAIL_ICON = "\x01\x02" // The character(s) to use for drawing a mail icon. 51 | DEGREES_ICON = "\x11" // The character to use to draw the degree (°) symbol. 52 | FAN_ICON_1 = "\x12\x13" // Characters showing a fan icon, variant 1 53 | FAN_ICON_2 = "\x14\x15" // Characters showing a fan icon, variant 2 54 | ) 55 | 56 | type MessageID byte // The type of a message to/from the OLED controller. 57 | // Messages understood by the OLED controller. 58 | const ( 59 | CommandMsg = 0xC0 60 | EventMsg = 0xC1 61 | ) 62 | 63 | type ResultID byte // The type of a result code 64 | // The result codes 65 | const ( 66 | Success = 0x00 67 | Failure = 0x01 68 | ) 69 | 70 | type CommandID byte // The type of a command to the OLED controller. 71 | // Commands understood by the OLED controller. 72 | const ( 73 | SetUp = 0x00 // Set up the OLED controller, and get the screen size. 74 | Clear = 0x01 // Clear an OLED screen. 75 | SetLine = 0x02 // Set the content of a line on an OLED screen. 76 | SetChars = 0x03 // Set the content of a portion of the OLED screen. 77 | Present = 0x04 // Show changed lines to a screen. 78 | ) 79 | 80 | type EventID byte // The type of an event from the OLED controller. 81 | // Events understood by the OLED controller. 82 | const ( 83 | ChangeTag = 0x00 // Change the content of a screen. 84 | IncrementTag = 0x01 // Increment the tag shown on the screen by one. 85 | DecrementTag = 0x02 // Decrement the tag shown on the screen by one. 86 | ) 87 | 88 | type ScreenID byte // The type of a screen identifier. 89 | // Screen identifiers 90 | const ( 91 | Master = 0x00 // OLED screen on the master side 92 | Slave = 0x01 // OLED screen on the slave side 93 | ) 94 | 95 | // Structure holding a response from the OLED controller. 96 | type Response struct { 97 | Success bool // Whether the command was successful. 98 | Command CommandID // The command that was issued. 99 | Screen ScreenID // Which screen the command was for. 100 | Params []byte // Additional parameters sent by the firmware. 101 | } 102 | 103 | // Structure holding an event from the OLED controller. 104 | type Event struct { 105 | Event EventID // The event that was issued. 106 | Screen ScreenID // Which screen the event is for. 107 | Params []byte // Addition parameters set by the firmware. 108 | } 109 | 110 | // Class for OLED control 111 | type OLEDController struct { 112 | Device *hid.Device // The associated HID device 113 | Columns, Rows uint8 // The number of columns and rows available on the display(s) 114 | } 115 | 116 | // Screen size, in characters. 117 | type Area struct { 118 | Width, Height uint8 // The width and height 119 | } 120 | 121 | // Class for screen control 122 | type Screen struct { 123 | ID ScreenID // The screen's unique ID 124 | Controller *OLEDController // Reference to the OLED controller 125 | Tag uint8 // Which tag to show 126 | Events chan Event // Channel to handle events 127 | Quit chan bool // Channel to handle termination 128 | } 129 | 130 | // Start the screen handler. 131 | // It will run until the screen.Quit channel has been closed. 132 | func (screen *Screen) Run(wg *sync.WaitGroup) { 133 | wg.Add(1) 134 | defer wg.Done() 135 | defer screen.Controller.SendCommand(Clear, screen.ID, nil) 136 | 137 | stopped := false 138 | hasTag := false 139 | stop := make(chan bool) 140 | results := make(chan []string, 5) 141 | 142 | showTag := func(tagID uint8) { 143 | tag, found := tags[tagID] 144 | if !found { 145 | log.Printf("Tag %d out of range.", tagID) 146 | } else { 147 | hasTag = true 148 | screen.Tag = tagID 149 | results = make(chan []string, 5) 150 | go tag.Draw(Area{screen.Controller.Columns, screen.Controller.Rows}, results, stop) 151 | } 152 | } 153 | 154 | showTag(screen.Tag) 155 | 156 | for { 157 | select { 158 | case event := <-screen.Events: 159 | if event.Screen != screen.ID { 160 | log.Panicln("Received event intended for another screen:", event) 161 | } else if stopped { 162 | log.Println("Got event while shutting down:", event) 163 | continue 164 | } 165 | 166 | var tag uint8 167 | switch event.Event { 168 | case ChangeTag: 169 | tag = event.Params[0] 170 | case IncrementTag: 171 | if len(tags) < 1 { 172 | log.Printf("Cannot increment tag: No tags found.") 173 | continue 174 | } 175 | // TODO: This check doesn't account for a non-consecutive tags map, that doesn't start at 1. 176 | tag = screen.Tag + 1 177 | if _, found := tags[tag]; !found { 178 | tag = 1 179 | if _, found := tags[tag]; !found { 180 | log.Printf("Failed to increment tag of screen 0x%02X currently on tag %d.\n", screen.ID, screen.Tag) 181 | continue 182 | } 183 | } 184 | case DecrementTag: 185 | if len(tags) < 1 { 186 | log.Printf("Cannot increment tag: No tags found.") 187 | continue 188 | } 189 | // TODO: This check doesn't account for a non-consecutive tags map, that doesn't end on len(tags) 190 | tag = screen.Tag - 1 191 | if _, found := tags[tag]; !found { 192 | tag = uint8(len(tags)) 193 | if _, found := tags[tag]; !found { 194 | log.Printf("Failed to decrement tag of screen 0x%02X currently on tag %d.\n", screen.ID, screen.Tag) 195 | continue 196 | } 197 | } 198 | } 199 | 200 | // Change the currently shown tag. 201 | if hasTag { 202 | stop <- true 203 | } 204 | showTag(tag) 205 | case lines, more := <-results: 206 | if !more { 207 | if stopped { 208 | return 209 | } 210 | hasTag = false 211 | screen.Controller.SendCommand(Clear, screen.ID, nil) 212 | } else { 213 | screen.Controller.DrawScreen(screen.ID, lines) 214 | } 215 | case <-screen.Quit: 216 | screen.Controller.SendCommand(Clear, screen.ID, nil) 217 | if hasTag { 218 | if !stopped { 219 | stop <- true 220 | } 221 | } else { 222 | return 223 | } 224 | stopped = true 225 | } 226 | } 227 | } 228 | 229 | // Draw the specified content to the specified screen. 230 | func (oled *OLEDController) DrawScreen(screen ScreenID, lines []string) { 231 | for i, line := range lines { 232 | if i > int(oled.Rows) { 233 | log.Printf("Attempting to draw more rows than the OLED supports: %d/%d\n", i, oled.Rows) 234 | break 235 | } 236 | if len(line) > int(oled.Columns) && *gArgs.debug { 237 | log.Printf("Attempting to draw more columns than the OLED supports: %d/%d\n", len(line), oled.Columns) 238 | } 239 | oled.SendCommand(SetLine, screen, append([]byte{byte(i)}, line...)) 240 | time.Sleep(10 * time.Millisecond) // Ensure that the command gets handled properly. 241 | } 242 | oled.SendCommand(Present, screen, nil) 243 | } 244 | 245 | // Draw over a part of the screen 246 | // Note: Start offset is zero indexed 247 | func (oled *OLEDController) DrawChars(screen ScreenID, start uint8, chars string) { 248 | oled.SendCommand(SetChars, screen, append([]byte{byte(start), byte(len(chars))}, chars...)) 249 | } 250 | 251 | // Send a command to the OLED controller. 252 | func (oled *OLEDController) SendCommand(cmd CommandID, screen ScreenID, data []byte) bool { 253 | buf := make([]byte, 32) 254 | 255 | buf[0] = byte(CommandMsg) 256 | buf[1] = byte(cmd) 257 | buf[2] = byte(screen) 258 | 259 | // Remaining bytes are command-specific. 260 | if data != nil { 261 | copy(buf[3:32], data) 262 | } 263 | 264 | _, err := oled.Device.Write(buf) 265 | if err != nil { 266 | log.Println("Failed to write to device:", err) 267 | return false 268 | } 269 | if *gArgs.debug { 270 | log.Println(">", buf[:]) 271 | } 272 | 273 | return true 274 | } 275 | 276 | // Read a response or event from the OLED controller. 277 | func (oled *OLEDController) ReadResponse() (interface{}, error) { 278 | buf := make([]byte, 32) 279 | size, err := oled.Device.ReadTimeout(buf, 500) 280 | if err != nil { 281 | log.Println("Failed to read from device:", err) 282 | return nil, err 283 | } else if size < 1 { 284 | // Timed out 285 | return nil, nil 286 | } 287 | 288 | if *gArgs.debug { 289 | log.Println("<", buf[:size]) 290 | } 291 | switch buf[0] { 292 | case Success, Failure: 293 | resp := Response{ 294 | Success: buf[0] == Success, 295 | Command: CommandID(buf[1]), 296 | Screen: ScreenID(buf[2]), 297 | Params: buf[3:], 298 | } 299 | 300 | if !resp.Success { 301 | log.Printf("Command 0x%02X failed with error 0x%02X.\n", resp.Command, buf[0]) 302 | return nil, nil 303 | } 304 | 305 | return resp, nil 306 | case EventMsg: 307 | event := Event{ 308 | Event: EventID(buf[1]), 309 | Screen: ScreenID(buf[2]), 310 | Params: buf[3:], 311 | } 312 | return event, nil 313 | default: 314 | log.Printf("Received unknown message 0x%02X\n", buf[0]) 315 | return nil, nil 316 | } 317 | } 318 | 319 | // Loop setting up and filling the OLED screens. 320 | func (oled *OLEDController) Run() { 321 | defer oled.Device.Close() 322 | 323 | if err := oled.Device.SetNonblocking(false); err != nil { 324 | log.Println("Failed to set the device blocking.") 325 | return 326 | } 327 | 328 | // Start by setting up 329 | oled.SendCommand(SetUp, Master, nil) 330 | resp, _ := oled.ReadResponse() 331 | if resp == nil { 332 | log.Println("Set up failed.") 333 | return 334 | } 335 | switch resp.(type) { 336 | case Response: 337 | oled.Columns = resp.(Response).Params[0] 338 | oled.Rows = resp.(Response).Params[1] 339 | default: 340 | log.Println("Wrong response for set up command.") 341 | return 342 | } 343 | 344 | if *gArgs.debug { 345 | log.Printf("OLED size %dx%d\n", oled.Columns, oled.Rows) 346 | } 347 | if oled.Columns < 1 || oled.Rows < 1 { 348 | log.Println("Failed to get screen size from set up.") 349 | return 350 | } 351 | 352 | sigs := make(chan os.Signal, 1) 353 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 354 | 355 | var wg sync.WaitGroup 356 | quit := make(chan bool, 5) 357 | masterCtrl := make(chan Event, 1) 358 | slaveCtrl := make(chan Event, 1) 359 | 360 | // Read loop. Makes sure that responses and events are processed. 361 | go func() { 362 | wg.Add(1) 363 | defer wg.Done() 364 | for { 365 | select { 366 | case <-quit: 367 | return 368 | default: 369 | resp, err := oled.ReadResponse() 370 | if err != nil { 371 | // Read error. Device is probably unreachable. 372 | sigs <- syscall.SIGHUP 373 | return 374 | } else if resp != nil { 375 | switch resp.(type) { 376 | case Event: 377 | switch resp.(Event).Screen { 378 | case Master: 379 | masterCtrl <- resp.(Event) 380 | case Slave: 381 | slaveCtrl <- resp.(Event) 382 | default: 383 | log.Printf("Got event 0x%02X for unknown screen 0x%02X.\n", 384 | resp.(Event).Event, 385 | resp.(Event).Screen) 386 | } 387 | } 388 | } 389 | } 390 | } 391 | }() 392 | 393 | // Start the handlers for the different screens, and specify which tag to show on them initially. 394 | go (&Screen{ID: Master, Controller: oled, Tag: 1, Events: masterCtrl, Quit: quit}).Run(&wg) 395 | go (&Screen{ID: Slave, Controller: oled, Tag: 2, Events: slaveCtrl, Quit: quit}).Run(&wg) 396 | 397 | // Wait for signal 398 | sig := <-sigs 399 | 400 | log.Println("Stopping due to", sig) 401 | signal.Reset() // Reset signal handling to terminate in case another one is issued 402 | 403 | close(quit) 404 | wg.Wait() 405 | 406 | if err := oled.Device.SetNonblocking(true); err == nil { 407 | // Consume any lingering messages. 408 | // This prevents junk from lying around in the HID pipe, causing failures next run. 409 | for { 410 | if resp, _ := oled.ReadResponse(); resp == nil { 411 | break 412 | } 413 | } 414 | } 415 | 416 | if sig == syscall.SIGHUP { 417 | return 418 | } else { 419 | os.Exit(0) 420 | } 421 | } 422 | 423 | // Main function, which handles flags and looks for the correct USB HID device. 424 | func main() { 425 | log.SetPrefix("oled_controller ") 426 | log.Println("Started.") 427 | 428 | gArgs.debug = flag.Bool("debug", false, "Whether debug output should be produced") 429 | 430 | if runtime.GOOS == "linux" { 431 | gArgs.sysStatDisk = flag.String("sysstat-disk", "sda", "Which disk to monitor for I/O usage") 432 | } 433 | 434 | gArgs.temperatureUnit = flag.String("temperature-unit", "C", "Temperature unit to use (C/F/K)") 435 | 436 | gArgs.gmailCredentials = flag.String("gmail-credentials", "", "Path to JSON credential file for GMail access") 437 | gArgs.gmailLabel = flag.String("gmail-label", "INBOX", "For which label to count unread messages") 438 | 439 | gArgs.weatherKey = flag.String("weather-api-key", "", "API key to openweathermap.org") 440 | gArgs.weatherLocation = flag.String("weather-location", "", "The location to get the current weather as ','") 441 | 442 | flag.Parse() 443 | 444 | for { 445 | for _, devInfo := range hid.Enumerate(VENDOR_ID, PRODUCT_ID) { 446 | found := false 447 | if runtime.GOOS != "linux" { 448 | found = devInfo.Usage == USAGE && devInfo.UsagePage == USAGE_PAGE 449 | } else { 450 | // FIXME: This check is weak, and will match a keyboard without raw HID enabled... 451 | // Usage and UsagePage are only supported on Windows/Mac. 452 | found = devInfo.Interface == INTERFACE 453 | } 454 | 455 | if found { 456 | log.Println("Found device at:", devInfo.Path, devInfo.Usage, devInfo.UsagePage) 457 | device, err := devInfo.Open() 458 | if err != nil { 459 | log.Println("Failed to open device:", err) 460 | } else { 461 | oled := OLEDController{Device: device} 462 | oled.Run() 463 | } 464 | } 465 | } 466 | time.Sleep(2 * time.Second) 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /sysstats_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // +build linux 5 | 6 | // Get system status from /proc/ and /sys/ (Linux edition) 7 | 8 | package main 9 | 10 | import ( 11 | "log" 12 | "math" 13 | "time" 14 | 15 | linuxproc "github.com/c9s/goprocinfo/linux" 16 | ) 17 | 18 | // Get system statistics at the specified interval. 19 | // This will get the current CPU, memory, swap, and disk usage in fractions (0.0-1.0) 20 | func SystemStats(interval time.Duration, results chan []float64, quit chan bool) { 21 | var prevIdle, prevTotal, prevIOTicks uint64 22 | var prevUptime float64 23 | 24 | defer close(results) 25 | 26 | for { 27 | var cpu, mem, swap, disk float64 28 | 29 | stats, err := linuxproc.ReadStat("/proc/stat") 30 | if err != nil { 31 | log.Println("Failed to retrieve stat information:", err) 32 | } else { 33 | idle := stats.CPUStatAll.Idle + stats.CPUStatAll.IOWait 34 | nonIdle := stats.CPUStatAll.User + stats.CPUStatAll.Nice + stats.CPUStatAll.System + stats.CPUStatAll.IRQ + stats.CPUStatAll.SoftIRQ + stats.CPUStatAll.Steal 35 | total := idle + nonIdle 36 | 37 | if prevIdle != 0 && prevTotal != 0 { 38 | totalDelta := total - prevTotal 39 | idleDelta := idle - prevIdle 40 | cpu = math.Max(float64(totalDelta-idleDelta)/float64(totalDelta), 0) 41 | if *gArgs.debug { 42 | log.Println("CPU%: ", cpu*100) 43 | } 44 | } 45 | 46 | prevIdle = idle 47 | prevTotal = total 48 | } 49 | 50 | meminfo, err := linuxproc.ReadMemInfo("/proc/meminfo") 51 | if err != nil { 52 | log.Println("Failed to retrieve meminfo information:", err) 53 | } else { 54 | mem = math.Max(float64(meminfo.MemTotal-meminfo.MemAvailable)/float64(meminfo.MemTotal), 0) 55 | swap = math.Max(float64(meminfo.SwapTotal-meminfo.SwapFree)/float64(meminfo.SwapTotal), 0) 56 | if math.IsInf(swap, 0) { 57 | // In case there is no swap. 58 | swap = 0 59 | } 60 | if *gArgs.debug { 61 | log.Println("Mem%: ", mem*100) 62 | log.Println("Swap%:", swap*100) 63 | } 64 | } 65 | 66 | uptime, err := linuxproc.ReadUptime("/proc/uptime") 67 | if err != nil { 68 | log.Println("Failed to retrieve uptime information:", err) 69 | } else { 70 | diskStats, err := linuxproc.ReadDiskStats("/proc/diskstats") 71 | if err != nil { 72 | log.Println("Failed to retrieve disk status information:", err) 73 | } else { 74 | for _, diskStat := range diskStats { 75 | if diskStat.Name == *gArgs.sysStatDisk { 76 | if prevIOTicks != 0 { 77 | disk = math.Max(float64(diskStat.IOTicks-prevIOTicks)/(uptime.Total-prevUptime)/1000, 0) 78 | if *gArgs.debug { 79 | log.Println("Disk%:", disk*100) 80 | } 81 | } 82 | prevIOTicks = diskStat.IOTicks 83 | break 84 | } 85 | } 86 | } 87 | 88 | prevUptime = uptime.Total 89 | } 90 | 91 | results <- []float64{cpu, mem, swap, disk} 92 | 93 | select { 94 | case <-quit: 95 | return 96 | case <-time.After(interval): 97 | } 98 | 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /sysstats_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // +build windows 5 | 6 | // Get system status from TypePerf (Windows edition) 7 | 8 | package main 9 | 10 | import ( 11 | "bufio" 12 | "log" 13 | "os" 14 | "os/exec" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | // Start TypePerf for the specified fields (counters), and feed the results to the specified channel. 21 | func typeperf(interval uint, result chan []string, quit chan bool, fields []string) { 22 | defer close(result) 23 | 24 | fields = append(append(fields, "-si"), strconv.Itoa(int(interval))) 25 | cmd := exec.Command("TypePerf", fields...) 26 | cmd.Stderr = os.Stderr 27 | 28 | stdout, err := cmd.StdoutPipe() 29 | if err != nil { 30 | log.Println("Failed to connect stdout for TypePerf: ", err) 31 | return 32 | } else if err := cmd.Start(); err != nil { 33 | log.Println("Failed to start TypePerf:", err) 34 | return 35 | } 36 | 37 | defer cmd.Process.Kill() 38 | 39 | reader := bufio.NewReader(stdout) 40 | 41 | // Read the first empty line and header. 42 | reader.ReadString('\n') 43 | reader.ReadString('\n') 44 | 45 | for { 46 | select { 47 | case <-quit: 48 | return 49 | default: 50 | line, err := reader.ReadString('\n') 51 | if err != nil { 52 | log.Println("Read error from TypePerf:", err) 53 | return 54 | } 55 | 56 | result <- strings.Split(strings.TrimSuffix(line, "\r\n"), ",") 57 | } 58 | } 59 | } 60 | 61 | // Get system statistics at the specified interval, rounded to whole seconds. 62 | // This will get the current CPU, memory, swap (page file), and disk usage in fractions (0.0-1.0) 63 | func SystemStats(interval time.Duration, results chan []float64, quit chan bool) { 64 | defer close(results) 65 | 66 | tp := make(chan []string, 5) 67 | go typeperf(uint(interval.Seconds()), tp, quit, []string{ 68 | `\Processor(_Total)\% Processor Time`, 69 | `\Memory\% Committed Bytes In Use`, 70 | `\Paging file(_Total)\% Usage`, 71 | `\PhysicalDisk(_Total)\% Disk Time`, 72 | }) 73 | 74 | // System status will take a second to fill up. To avoid it feeling like lag, send an empty result directly. 75 | results <- make([]float64, 4) 76 | 77 | for { 78 | select { 79 | case fields, more := <-tp: 80 | if !more { 81 | return 82 | } 83 | values := make([]float64, 4) 84 | for i, field := range fields { 85 | if i != 0 { // First field is a timestamp 86 | value, err := strconv.ParseFloat(strings.TrimRight(strings.TrimLeft(field, `"`), `"`), 64) 87 | if err != nil { 88 | log.Printf("Failed to parse field %d in TypePerf data: '%s'\n", i, field) 89 | value = 0 90 | } 91 | values[i-1] = value / 100 92 | if *gArgs.debug { 93 | switch i { 94 | case 1: 95 | log.Println("CPU%: ", value) 96 | case 2: 97 | log.Println("Mem%: ", value) 98 | case 3: 99 | log.Println("Swap: ", value) 100 | case 4: 101 | log.Println("Disk: ", value) 102 | } 103 | } 104 | } 105 | } 106 | results <- values 107 | case <-time.After(10 * time.Second): 108 | log.Println("TypePerf read timed out") 109 | quit <- true // FIXME: This is not enough if the process has hung 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tag.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // A tag represents different content that can be shown on the screens (i.e. a "view"). Each tag is identified by a 5 | // number, and each screen can show the content of at most one tag. Define different tags in this file to make the 6 | // screens show different content. 7 | 8 | package main 9 | 10 | import ( 11 | "fmt" 12 | "math" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "golang.org/x/text/transform" 18 | "golang.org/x/text/unicode/norm" 19 | ) 20 | 21 | // The tag interface 22 | type Tag interface { 23 | // Function to draw content to a tag. The function will be run in a goroutine, and must close the results channel 24 | // upon exit. Put content to draw on the results channel, which expects lines up to area.Height. 25 | Draw(area Area, results chan []string, quit chan bool) 26 | } 27 | 28 | type GeneralInfo struct{} // Tag interface for showing general information. 29 | type SysStats struct{} // Tag interface for showing system status. 30 | type GPUStats struct{} // Tag interface for showing status of the graphics card. 31 | 32 | // Map containing the available tags and their unique index. 33 | // It should be kept consecutive, and starting from 1, if you wish to use the increment/decrement feature. The set_tag 34 | // event will send the number that was pressed (e.g. KC_1 => 1). 35 | var tags = map[uint8]Tag{ 36 | 1: &GeneralInfo{}, 37 | 2: &SysStats{}, 38 | 3: &GPUStats{}, 39 | } 40 | 41 | // Draws some general information. 42 | // The first line is the time, the second is the current layer, the third a motivational message or number of 43 | // unread messages, and the fourth is the current temperature. 44 | func (*GeneralInfo) Draw(area Area, results chan []string, quit chan bool) { 45 | defer close(results) 46 | 47 | info := []string{"", "%l", "You look great today!", ""} 48 | unreadMails := make(chan int64, 5) 49 | weatherReport := make(chan WeatherResult, 5) 50 | stop := make(chan bool) 51 | stopped := false 52 | wait := 0 53 | 54 | if *gArgs.gmailCredentials != "" { 55 | go GmailStats(*gArgs.gmailCredentials, *gArgs.gmailLabel, unreadMails, stop) 56 | wait++ 57 | } 58 | 59 | var location string 60 | if *gArgs.weatherKey != "" && *gArgs.weatherLocation != "" { 61 | go WeatherStats(*gArgs.weatherKey, *gArgs.temperatureUnit, *gArgs.weatherLocation, weatherReport, stop) 62 | wait++ 63 | 64 | // The firmware only supports Latin characters, without diacritics. These need to be either normalized, or 65 | // removed completely before drawn to the display, otherwise it just won't look right. 66 | isNonLatin := func(r rune) bool { return r >= 0x80 } 67 | t := transform.Chain(norm.NFD, transform.RemoveFunc(isNonLatin), norm.NFC) 68 | // Assume that the location is , 69 | location, _, _ = transform.String(t, strings.Split(*gArgs.weatherLocation, ",")[0]) 70 | } 71 | 72 | for { 73 | info[0] = time.Now().Local().Format("Mon Jan _2 15:04:05") 74 | results <- info 75 | 76 | select { 77 | case numUnread, more := <-unreadMails: 78 | if !more { 79 | info[2] = "" 80 | wait-- 81 | if stopped && wait < 1 { 82 | return 83 | } 84 | continue 85 | } 86 | info[2] = fmt.Sprintf("%s%d unread emails", MAIL_ICON, numUnread) 87 | case weather, more := <-weatherReport: 88 | if !more { 89 | info[3] = "" 90 | wait-- 91 | if stopped && wait < 1 { 92 | return 93 | } 94 | continue 95 | } 96 | info[3] = fmt.Sprintf("%s%d%s%s in %s", 97 | WEATHER_ICONS[weather.Weather], 98 | int(math.Round(weather.Temperature)), 99 | DEGREES_ICON, 100 | *gArgs.temperatureUnit, 101 | location) 102 | case <-time.After(1 * time.Second): 103 | case <-quit: 104 | if wait < 1 { 105 | return 106 | } else if !stopped { 107 | close(stop) 108 | } 109 | stopped = true 110 | } 111 | } 112 | } 113 | 114 | // Draw system status as bar graphs. 115 | // The bars are CPU, memory, swap (page file), and disk usage as percentages. 116 | func (*SysStats) Draw(area Area, results chan []string, quit chan bool) { 117 | defer close(results) 118 | 119 | sysStat := make(chan []float64, 5) 120 | columns := []string{"CPU%", "Mem%", "Swap", "Disk"} 121 | 122 | go SystemStats(1*time.Second, sysStat, quit) 123 | for { 124 | select { 125 | case values, more := <-sysStat: 126 | if !more { 127 | return 128 | } 129 | 130 | output := make([]string, len(values)) 131 | for i, value := range values { 132 | value := math.Min(math.Max(0.0, value), 1.0) 133 | if math.IsInf(value, 0) || math.IsNaN(value) { 134 | value = 0.0 135 | } 136 | barLen := int(area.Width) - len(columns[i]) - 2 137 | // Draw the label and a nice bar. 138 | output[i] = fmt.Sprintf("%s[%-*s]", 139 | columns[i], 140 | barLen, 141 | strings.Repeat(BAR_CHAR, int(math.Round(float64(barLen)*value)))) 142 | } 143 | results <- output 144 | } 145 | } 146 | } 147 | 148 | // Draw status of the graphics card as bar graphs. 149 | // The bars are GPU, memory, and PCIe bus utilization in percentages. It will also show the current temperature. 150 | func (*GPUStats) Draw(area Area, results chan []string, quit chan bool) { 151 | defer close(results) 152 | 153 | gpuStats := make(chan GraphicCardResult, 5) 154 | columns := []string{"GPU%", "Mem%", "PCIe", FAN_ICON_2} 155 | 156 | go GraphicCardStats(1*time.Second, *gArgs.temperatureUnit, gpuStats, quit) 157 | for { 158 | select { 159 | case result, more := <-gpuStats: 160 | if !more { 161 | return 162 | } 163 | 164 | values := []float64{ 165 | result.GPU, 166 | result.Memory, 167 | result.PCIBandwidth, 168 | result.FanSpeed, 169 | } 170 | 171 | output := make([]string, len(values)) 172 | for i, value := range values { 173 | label := columns[i] 174 | 175 | // Clamp 176 | value := math.Min(math.Max(0.0, value), 1.0) 177 | if math.IsInf(value, 0) || math.IsNaN(value) { 178 | value = 0.0 179 | } 180 | 181 | prefix := "" 182 | if i == len(values)-1 { // Temperature + Fan speed 183 | temp := strconv.Itoa(int(math.Round(result.Temperature))) 184 | prefix = fmt.Sprintf("Temp:%s%s%s%s", 185 | temp, 186 | DEGREES_ICON, 187 | *gArgs.temperatureUnit, 188 | strings.Repeat(" ", 4-len(temp))) 189 | 190 | // Swap icon each iteration 191 | if columns[i] == FAN_ICON_1 { 192 | columns[i] = FAN_ICON_2 193 | } else { 194 | columns[i] = FAN_ICON_1 195 | } 196 | } 197 | 198 | barLen := int(area.Width) - len(label) - len(prefix) - 2 199 | // Draw the label and a nice bar. 200 | output[i] = fmt.Sprintf("%s%s[%-*s]", 201 | prefix, 202 | columns[i], 203 | barLen, 204 | strings.Repeat(BAR_CHAR, int(math.Round(float64(barLen)*value)))) 205 | } 206 | results <- output 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /weather.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Albert "Drauthius" Diserholt. All rights reserved. 2 | // Licensed under the MIT License. 3 | 4 | // Get weather information for a location from OpenWeatherMap.org 5 | 6 | package main 7 | 8 | import ( 9 | "log" 10 | "net/http" 11 | "time" 12 | 13 | owm "github.com/briandowns/openweathermap" 14 | ) 15 | 16 | type WeatherCondition byte // The type of a weather condition 17 | // The different weather conditions 18 | const ( 19 | ClearSky = 0x00 // Clear sky 20 | FewClouds = 0x01 // Few clouds, partially sunny 21 | Cloudy = 0x02 // Cloudy 22 | Rain = 0x03 // Rainy 23 | Thunderstorm = 0x04 // Thunderstorm-y 24 | Snow = 0x05 // Snowy 25 | Mist = 0x06 // Misty 26 | ) 27 | 28 | // The type of a weather result 29 | type WeatherResult struct { 30 | Temperature float64 // The current temperature at the location 31 | Weather WeatherCondition // The current weather condition 32 | } 33 | 34 | // Map from weather condition to characters showing icons found in glcdfont.c 35 | var WEATHER_ICONS = map[WeatherCondition]string{ 36 | ClearSky: "\x03\x04", // Sun icon 37 | FewClouds: "\x05\x06", // Cloud+sun icon 38 | Cloudy: "\x07\x08", // Cloud icon 39 | Rain: "\x09\x0A", // Rain icon 40 | Thunderstorm: "\x0B\x0C", // Thunder icon 41 | Snow: "\x0D\x0E", // Snow icon 42 | Mist: "\x0F\x10", // Mist icon 43 | } 44 | 45 | // Start a loop that gets the current temperature (in the specified unit as "C", "F", or "K") and weather status at the 46 | // specified location, with the specified API key. 47 | func WeatherStats(apiKey string, unit string, location string, result chan WeatherResult, quit chan bool) { 48 | defer close(result) 49 | 50 | weather, err := owm.NewCurrent(unit, "EN", apiKey) 51 | if err != nil { 52 | log.Println("Failed to create weather service:", err) 53 | return 54 | } 55 | 56 | for { 57 | weather.CurrentByName(location) 58 | if weather.Cod != 200 { 59 | log.Printf("Failed to get weather report: %s (%d)\n", http.StatusText(weather.Cod), weather.Cod) 60 | } else if len(weather.Weather) < 1 { 61 | log.Println("Failed to get weather report. Unknown location?") 62 | } else { 63 | // Translate the icon code to a weather condition. 64 | var icon WeatherCondition 65 | switch weather.Weather[0].Icon[:2] { 66 | case "01": 67 | icon = ClearSky 68 | case "02": 69 | icon = FewClouds 70 | case "03", "04": 71 | icon = Cloudy 72 | case "09", "10": 73 | icon = Rain 74 | case "11": 75 | icon = Thunderstorm 76 | case "13": 77 | icon = Snow 78 | case "50": 79 | icon = Mist 80 | } 81 | result <- WeatherResult{Temperature: weather.Main.Temp, Weather: icon} 82 | } 83 | 84 | select { 85 | case <-time.After(5 * time.Minute): 86 | case <-quit: 87 | return 88 | } 89 | } 90 | } 91 | --------------------------------------------------------------------------------