├── fonts └── put_fonts_here ├── .gitignore ├── requirements.txt ├── .gitattributes ├── pages ├── imgs │ ├── line.jpg │ ├── white.jpg │ ├── clouds1.jpg │ ├── clouds10.jpg │ ├── clouds11.jpg │ ├── clouds12.jpg │ ├── clouds13.jpg │ ├── clouds14.jpg │ ├── clouds15.jpg │ ├── clouds2.jpg │ ├── clouds3.jpg │ ├── clouds4.jpg │ ├── clouds5.jpg │ ├── clouds6.jpg │ ├── clouds7.jpg │ ├── clouds8.jpg │ ├── clouds9.jpg │ ├── arrow-left.png │ └── arrow-right.png ├── empty.json ├── ticks │ └── clock.py ├── welcome.json ├── enjoy.json ├── showcase2.json ├── showcase4.json ├── showcase1.json └── showcase3.json ├── readme_photos ├── colors.jpg ├── noFont.jpg ├── invalidLayout.jpg └── dice_152068_pixabay.png ├── config.json ├── LICENSE ├── main.py ├── controller.py └── README.md /fonts/put_fonts_here: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | errorLog.txt 2 | __pycache__/* 3 | *.pyc 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | keyboard==0.13.5 2 | Pillow==9.1.0 3 | StreamDeck==0.9.0 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /pages/imgs/line.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/line.jpg -------------------------------------------------------------------------------- /pages/imgs/white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/white.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds1.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds10.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds11.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds12.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds13.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds14.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds15.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds2.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds3.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds4.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds5.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds6.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds7.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds8.jpg -------------------------------------------------------------------------------- /pages/imgs/clouds9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/clouds9.jpg -------------------------------------------------------------------------------- /pages/imgs/arrow-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/arrow-left.png -------------------------------------------------------------------------------- /pages/imgs/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/pages/imgs/arrow-right.png -------------------------------------------------------------------------------- /readme_photos/colors.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/readme_photos/colors.jpg -------------------------------------------------------------------------------- /readme_photos/noFont.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/readme_photos/noFont.jpg -------------------------------------------------------------------------------- /readme_photos/invalidLayout.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/readme_photos/invalidLayout.jpg -------------------------------------------------------------------------------- /readme_photos/dice_152068_pixabay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TriLinder/StreamDeckController/HEAD/readme_photos/dice_152068_pixabay.png -------------------------------------------------------------------------------- /pages/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank"], 3 | "ticks": [], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | } 9 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "startingPage": "welcome.json", 3 | "startingBrightness": 75, 4 | "deviceSerial": "selectOnStartup", 5 | "font": "yourFont.ttf", 6 | "fontCenterFix": false 7 | } -------------------------------------------------------------------------------- /pages/ticks/clock.py: -------------------------------------------------------------------------------- 1 | from time import localtime, strftime 2 | 3 | format = 0 4 | formats = ["%H:%M:%S", "%H:%M", "%d.%m\n%H:%M:%S"] 5 | 6 | def nextTickWait(coords, page, serial) : 7 | return 1 #Time until the next tick in seconds 8 | 9 | def getKeyState(coords, page, serial, action) : #Runs every tick 10 | #print(coords, page, serial, action) 11 | 12 | if action == "clock" : 13 | return {"caption": strftime(formats[format], localtime()), 14 | "fontSize": 14, 15 | "fontColor": "white", 16 | "actions": {}} 17 | 18 | def keyPress(coords, page, serial) : #Cycle through time formats on keypress 19 | global format 20 | format += 1 21 | 22 | if format+1 > len(formats) : 23 | format = 0 24 | 25 | #print("Hello, world!") -------------------------------------------------------------------------------- /pages/welcome.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["arrow-left.png", "arrow-right.png", "line.jpg", "blank"], 3 | "ticks": [], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | "2x1" : {"caption":"Welcome", "fontSize":14, "color":"white", "background":"blank", "actions":{"switchPage":"showcase1.json"}}, 9 | 10 | "0x1" : {"caption":"", "fontSize":14, "color":"white", "background":"line.jpg", "actions":{"none":""}}, 11 | "1x1" : {"caption":"", "fontSize":14, "color":"white", "background":"arrow-right.png", "actions":{"none":""}}, 12 | 13 | "4x1" : {"caption":"", "fontSize":14, "color":"white", "background":"line.jpg", "actions":{"none":""}}, 14 | "3x1" : {"caption":"", "fontSize":14, "color":"white", "background":"arrow-left.png", "actions":{"none":""}} 15 | } 16 | } -------------------------------------------------------------------------------- /pages/enjoy.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank"], 3 | "ticks": [], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | "2x1" : {"caption":"Enjoy!", "fontSize":14, "color":"white", "background":"blank", "actions":{"switchPage":"enjoy.json"}}, 9 | 10 | "0x0" : {"caption":"Show\ncoordinates", "fontSize":14, "color":"#292929", "background":"blank", "actions":{"showCoords":true}}, 11 | "4x0" : {"caption":"Cool\ncolors", "fontSize":14, "color":"#292929", "background":"blank", "actions":{"randomColors":""}}, 12 | 13 | "0x2" : {"caption":"Source\ncode", "fontSize":14, "color":"#292929", "background":"blank", "actions":{"openURL":"https://github.com/TriLinder/StreamDeckController"}}, 14 | "4x2" : {"caption":"Start\nover", "fontSize":14, "color":"#292929", "background":"blank", "actions":{"switchPage":"welcome.json"}} 15 | } 16 | } -------------------------------------------------------------------------------- /pages/showcase2.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank", "white.jpg"], 3 | "ticks": ["clock.py"], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | "1x1" : {"caption":"COOL", "fontSize":12, "color":"black", "fontAlignment":"bottom", "background":"white.jpg", "actions":{"none":""}}, 9 | "2x1" : {"caption":"TEXT", "fontSize":12, "color":"black", "fontAlignment":"center", "background":"white.jpg", "actions":{"none":""}}, 10 | "3x1" : {"caption":"ALIGNMENT", "fontSize":12, "color":"black", "fontAlignment":"top", "background":"white.jpg", "actions":{"none":""}}, 11 | "0x1" : {"caption":"", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 12 | "4x1" : {"caption":"", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 13 | 14 | "2x2" : {"caption":"NEXT", "fontSize":14, "color":"white", "background":"blank", "actions":{"switchPage":"showcase3.json"}} 15 | } 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TriLinder 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 | -------------------------------------------------------------------------------- /pages/showcase4.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank", "white.jpg"], 3 | "ticks": [], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | "2x0" : {"caption":"ACTIONS:", "fontSize":12, "color":"white", "background":"blank", "actions":{"none":""}}, 9 | 10 | "0x1" : {"caption":"switchPage", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"switchPage":"showcase3.json"}}, 11 | "1x1" : {"caption":"exit", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"exit":""}}, 12 | "2x1" : {"caption":"setBrightness", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"setBrightness":75}}, 13 | "3x1" : {"caption":"showCoords", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"showCoords":false}}, 14 | "4x1" : {"caption":"runCommand", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"runCommand":"calc.exe"}}, 15 | "0x2" : {"caption":"keyboardType", "fontSize":10, "color":"black", "background":"white.jpg", "actions":{"keyboardType":"Hello, world! You are writing from a Stream Deck! Isn't that amazing?"}}, 16 | "1x2" : {"caption":"keyboardShortcut", "fontSize":9, "color":"black", "background":"white.jpg", "actions":{"keyboardShortcut":"CTRL+A"}}, 17 | "4x2" : {"caption":"openURL", "fontSize":12, "color":"black", "background":"white.jpg", "actions":{"openURL":"https://github.com/TriLinder/StreamDeckController"}}, 18 | "3x2" : {"caption":"screenshot", "fontSize":10, "color":"black", "background":"white.jpg", "actions":{"screenshot":"screenshot.jpg"}}, 19 | 20 | "2x2" : {"caption":"NEXT", "fontSize":12, "color":"white", "background":"blank", "actions":{"switchPage":"enjoy.json"}} 21 | } 22 | } -------------------------------------------------------------------------------- /pages/showcase1.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank", "white.jpg"], 3 | "ticks": ["clock.py"], 4 | "dimensions": "5x3", 5 | "created": 1646871751, 6 | 7 | "buttons": { 8 | "0x0" : {"caption":"This", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 9 | "1x0" : {"caption":"is", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 10 | "2x0" : {"caption":"the", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 11 | "3x0" : {"caption":"Stream\nDeck", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 12 | "4x0" : {"caption":"Controller.", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 13 | 14 | "2x1" : {"caption":"00:00", "fontSize":14, "color":"white", "background":"blank", "actions":{"none":""}, "ticks":{"clock.py":"clock"}}, 15 | "3x1" : {"caption":"<--\nPress to\nchange format", "fontSize":10, "color":"white", "background":"blank", "actions":{"none":""}}, 16 | "1x1" : {"caption":"-->\nPress to\nchange format", "fontSize":10, "color":"white", "background":"blank", "actions":{"none":""}}, 17 | 18 | "2x2" : {"caption":"NEXT", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"switchPage":"showcase2.json"}}, 19 | "0x2" : {"caption":"", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 20 | "1x2" : {"caption":"", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 21 | "3x2" : {"caption":"", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}}, 22 | "4x2" : {"caption":"", "fontSize":14, "color":"black", "background":"white.jpg", "actions":{"none":""}} 23 | } 24 | } -------------------------------------------------------------------------------- /pages/showcase3.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": ["blank","clouds1.jpg", "clouds2.jpg", 3 | "clouds3.jpg","clouds4.jpg", 4 | "clouds5.jpg","clouds6.jpg", 5 | "clouds7.jpg","clouds8.jpg", 6 | "clouds9.jpg","clouds10.jpg", 7 | "clouds11.jpg","clouds12.jpg", 8 | "clouds13.jpg","clouds14.jpg", 9 | "clouds15.jpg"], 10 | "ticks": [], 11 | "dimensions": "5x3", 12 | "created": 1646871751, 13 | 14 | "buttons": { 15 | "0x0" : {"caption":"Image source", "fontSize":9, "color":"gray", "fontAlignment":"top", "background":"clouds1.jpg", "actions":{"openURL":"https://pixabay.com/photos/clouds-sky-atmosphere-blue-sky-7050884/"}}, 16 | 17 | "1x0" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds2.jpg", "actions":{"none":""}}, 18 | "2x0" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds3.jpg", "actions":{"none":""}}, 19 | "3x0" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds4.jpg", "actions":{"none":""}}, 20 | "4x0" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds5.jpg", "actions":{"none":""}}, 21 | "0x1" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds6.jpg", "actions":{"none":""}}, 22 | 23 | "1x1" : {"caption":"25%", "fontSize":20, "color":"black", "background":"clouds7.jpg", "actions":{"setBrightness":25}}, 24 | "2x1" : {"caption":"50%", "fontSize":20, "color":"black", "background":"clouds8.jpg", "actions":{"setBrightness":50}}, 25 | "3x1" : {"caption":"75%", "fontSize":20, "color":"black", "background":"clouds9.jpg", "actions":{"setBrightness":75}}, 26 | 27 | "4x1" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds10.jpg", "actions":{"none":""}}, 28 | "0x2" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds11.jpg", "actions":{"none":""}}, 29 | "1x2" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds12.jpg", "actions":{"none":""}}, 30 | 31 | "2x2" : {"caption":"NEXT", "fontSize":12, "color":"black", "background":"clouds13.jpg", "actions":{"switchPage":"showcase4.json"}}, 32 | 33 | "3x2" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds14.jpg", "actions":{"none":""}}, 34 | "4x2" : {"caption":"", "fontSize":12, "color":"black", "background":"clouds15.jpg", "actions":{"none":""}} 35 | } 36 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from StreamDeck.DeviceManager import DeviceManager 2 | import controller 3 | import time 4 | import json 5 | import sys 6 | import os 7 | 8 | def getConfigKey(key) : #Load a key from config.json 9 | if not os.path.isfile("config.json") : 10 | print("'config.json' not found.") 11 | input("Press [ENTER] to quit..") 12 | sys.exit() 13 | 14 | with open("config.json", "r") as f : 15 | j = json.loads(f.read()) 16 | 17 | try : 18 | return j[key] 19 | except KeyError : 20 | print(f"'{key}' not found in config.json") 21 | input("Press [ENTER] to quit..") 22 | sys.exit() 23 | 24 | def writeConfigKey(key, data) : #Write key to config.json 25 | if not os.path.isfile("config.json") : 26 | print("'config.json' not found.") 27 | input("Press [ENTER] to quit..") 28 | sys.exit() 29 | 30 | with open("config.json", "r") as f : 31 | j = json.loads(f.read()) 32 | 33 | j[key] = data 34 | 35 | with open("config.json", "w") as f : 36 | json.dump(j, f) 37 | 38 | 39 | def chooseDevice(devices) : #Select device 40 | if len(devices) > 0 : 41 | print("Please choose your device:") 42 | 43 | serials = [] 44 | 45 | i = 0 46 | for device in devices : 47 | i += 1 48 | 49 | device.open() 50 | 51 | type = device.deck_type() 52 | serial = device.get_serial_number() 53 | 54 | device.close() 55 | 56 | serials.append(serial) 57 | 58 | print(f" {i}. {type} ({serial})") 59 | 60 | selection = input("Option: ").strip() 61 | 62 | try : 63 | selection = int(selection) 64 | except : 65 | print("Not an integer.") 66 | input("Press [ENTER] to quit..") 67 | sys.exit() 68 | 69 | if selection not in range(1, len(serials)+1) : 70 | print("Not in range.") 71 | input("Press [ENTER] to quit..") 72 | sys.exit() 73 | 74 | serial = serials[selection-1] 75 | 76 | else : 77 | print("No Stream Deck found. Please make sure that you installed the streamdeck module correctly, and that your device is plugged in.") 78 | input("Press [ENTER] to quit..") 79 | sys.exit() 80 | 81 | print("\nDevice selected.") 82 | if input("Would you like to save your selection? y/N ").lower().strip() == "y" : 83 | writeConfigKey("deviceSerial", serial) 84 | print("Option saved.") 85 | 86 | return serial 87 | 88 | 89 | if __name__ == "__main__" : 90 | #print("Starting..") 91 | 92 | startingPage = getConfigKey("startingPage") 93 | startingBrighntess = getConfigKey("startingBrightness") 94 | deviceSerial = getConfigKey("deviceSerial") 95 | font = getConfigKey("font") 96 | fontCenterFix = getConfigKey("fontCenterFix") 97 | 98 | if deviceSerial == "selectOnStartup" : 99 | deviceSerial = chooseDevice(DeviceManager().enumerate()) 100 | 101 | streamdecks = DeviceManager().enumerate() 102 | 103 | for deck in streamdecks : 104 | deck.open() 105 | 106 | if deck.get_serial_number() == deviceSerial : 107 | deck.close() 108 | 109 | c = controller.controller(deck, font=font) 110 | deck.set_brightness(startingBrighntess) 111 | 112 | p = controller.pages(c) 113 | 114 | p.controller.fontCenterFix = bool(fontCenterFix) 115 | 116 | p.switchToPage(startingPage) 117 | 118 | while True : 119 | time.sleep(.1) 120 | p.tick() 121 | 122 | deck.close() 123 | 124 | print(f"Stream Deck device with the serial '{deviceSerial}' was not found.\nTry reconnecting it.") -------------------------------------------------------------------------------- /controller.py: -------------------------------------------------------------------------------- 1 | from StreamDeck.DeviceManager import DeviceManager 2 | from StreamDeck.ImageHelpers import PILHelper 3 | from PIL import Image, ImageDraw, ImageFont 4 | import subprocess 5 | import webbrowser 6 | import importlib 7 | import threading 8 | import platform 9 | import keyboard 10 | import random 11 | import uuid 12 | import json 13 | import math 14 | import time 15 | import sys 16 | import os 17 | 18 | class button : 19 | def __init__(self, keyIndex, controller) : 20 | self.controller = controller 21 | self.keyIndex = keyIndex 22 | 23 | #Get the coords of the button. (Top left button is 0x0.) 24 | self.x = (keyIndex % controller.width) 25 | self.y = math.ceil((keyIndex+1) / controller.width)-1 26 | 27 | self.coords = f"{self.x}x{self.y}" 28 | 29 | self.caption = "" 30 | self.fontSize = 14 31 | self.fontColor = "white" 32 | self.activated = False 33 | self.font = controller.fontName 34 | self.fontAlignment = "center" 35 | #self.font = "C:\\Windows\\Fonts\\Arial.ttf" 36 | 37 | self.background = Image.new("RGB", (controller.buttonRes, controller.buttonRes)) 38 | 39 | def setCaption(self, caption) : 40 | self.caption = str(caption) 41 | 42 | def setFont(self, font, size=None, color=None) : 43 | self.font = font 44 | 45 | if size : 46 | self.fontSize = size 47 | 48 | if color : 49 | self.fontColor = color 50 | 51 | def sendToDevice(self) : 52 | if not self.activated : 53 | size = 0 54 | else : 55 | size = round(self.controller.buttonRes / 6) 56 | 57 | try : 58 | image = PILHelper.create_scaled_image(self.controller.deck, self.background, margins=[size, size, size, size]) 59 | except : 60 | return 61 | 62 | draw = ImageDraw.Draw(image) 63 | 64 | fontSize = self.fontSize 65 | if self.activated : 66 | fontSize = round(fontSize / 1.25) 67 | 68 | if not self.caption.strip() == "" : 69 | 70 | try : 71 | font = self.controller.getFont(self.font, fontSize) #Load font from memory 72 | except Exception as e : 73 | font = None 74 | self.caption = "NO\nFONT" #Font loading failed. Throw an error 75 | self.fontColor = "white" 76 | 77 | w, h= draw.textsize(self.caption, font=font) 78 | 79 | if self.fontAlignment == "center" : 80 | y = ((image.height - h) / 2) 81 | elif self.fontAlignment == "top" : 82 | if not self.activated : #Move text towards the center when in activated mode 83 | y = h 84 | else : 85 | y = h + (self.controller.buttonRes / 8) 86 | elif self.fontAlignment == "bottom" : 87 | if not self.activated : #Move text towards the center when in activated mode 88 | y = image.height - h 89 | else : 90 | y = image.height - h - (self.controller.buttonRes / 8) 91 | 92 | x = image.width / 2 93 | 94 | if not self.controller.fontCenterFix : 95 | x -= (w/2) 96 | 97 | draw.text((round(x), round(y)), text=self.caption, font=font, anchor="ma", fill=self.fontColor, align="center") 98 | 99 | nativeImage = PILHelper.to_native_format(self.controller.deck, image) 100 | 101 | with self.controller.deck : 102 | self.controller.deck.set_key_image(self.keyIndex, nativeImage) 103 | 104 | return image 105 | 106 | def loadImage(self, path) : 107 | self.background = Image.open(path) 108 | 109 | def coordsCaption(self) : 110 | self.caption = f"{self.keyIndex} - {self.coords}" 111 | 112 | class controller : 113 | def __init__(self, deck, font) : 114 | self.deck = deck 115 | 116 | deck.open() 117 | deck.reset() 118 | 119 | self.keyCount = deck.key_count() 120 | self.height = deck.key_layout()[0] 121 | self.width = deck.key_layout()[1] 122 | self.serial = deck.get_serial_number() 123 | self.buttonRes = deck.key_image_format()["size"][0] #The resolution of a single button 124 | self.fontName = font 125 | self.fonts = {} 126 | 127 | self.fontCenterFix = False 128 | 129 | self.disableInput = False 130 | 131 | self.resetScreen() 132 | 133 | def getFontPath(self, fontName) : 134 | path = os.path.dirname(sys.argv[0]) #Path to this .py file 135 | path = os.path.join(path, "fonts", fontName) 136 | return path 137 | 138 | def getFont(self, fontName, size) : 139 | fontKey = f"{fontName}-{size}" 140 | 141 | if fontKey in self.fonts : 142 | return self.fonts[fontKey] 143 | 144 | font = ImageFont.truetype(self.getFontPath(fontName), round(size)) 145 | self.fonts[fontKey] = font 146 | return font 147 | 148 | def resetScreen(self) : 149 | d = {} 150 | 151 | for key in range(self.keyCount) : 152 | btn = button(key, self) 153 | d[btn.coords] = btn 154 | 155 | self.screen = d 156 | 157 | def sendScreenToDevice(self) : 158 | keysImages = {} 159 | 160 | for key in self.screen : 161 | image = self.screen[key].sendToDevice() 162 | keysImages[key] = image 163 | 164 | return keysImages 165 | 166 | def setKeyCallback(self, func) : 167 | self.deck.set_key_callback(func) 168 | 169 | def screenshot(self, filename) : 170 | width = self.buttonRes * self.width 171 | height = self.buttonRes * self.height 172 | 173 | screenshot = Image.new("RGB", (width, height)) 174 | keysImages = self.sendScreenToDevice() #Re-render every key 175 | 176 | for key in keysImages : 177 | coordsX = int(key.split("x")[0]) 178 | coordsY = int(key.split("x")[1]) 179 | 180 | pixelX = coordsX * self.buttonRes 181 | pixelY = coordsY * self.buttonRes 182 | 183 | screenshot.paste(keysImages[key], (pixelX, pixelY)) 184 | 185 | screenshot.save(filename, quality=100) 186 | 187 | def coordsCaptions(self, clearScreen) : 188 | 189 | if clearScreen : 190 | self.resetScreen() 191 | 192 | for key in self.screen : 193 | if self.screen[key].caption == "" or clearScreen : 194 | self.screen[key].coordsCaption() 195 | 196 | def randomColors(self) : 197 | self.resetScreen() 198 | 199 | for key in self.screen : 200 | self.screen[key].background = Image.new("RGB", (self.buttonRes, self.buttonRes), (random.randint(0,255), random.randint(0,255), random.randint(0,255))) 201 | 202 | class pages : 203 | def __init__(self, controller) : 204 | self.pages = {} 205 | self.images = {} 206 | self.ticks = {} 207 | 208 | self.activePage = {} 209 | self.activePageName = "" 210 | 211 | self.tickingItems = {} 212 | 213 | self.controller = controller 214 | self.controller.setKeyCallback(self.clickHandler) 215 | 216 | for page in os.listdir("pages") : #Load all the .json files to memory 217 | if page.endswith(".json") : 218 | with open(os.path.join("pages", page), "r") as f : 219 | self.pages[page] = json.loads(f.read()) 220 | 221 | requiredTags = ["images", "ticks", "dimensions", "created", "buttons"] 222 | 223 | for page in self.pages : #Load all the used images to memory 224 | for tag in requiredTags : 225 | if not tag in self.pages[page] : 226 | self.error("Invalid\njson", f"Tag '{tag}' not found in {page}") 227 | 228 | return None 229 | 230 | for image in self.pages[page]["images"] : 231 | if not image in self.images : 232 | path = os.path.join("pages", "imgs", image) 233 | 234 | if os.path.isfile(path) : 235 | self.images[image] = Image.open(path) 236 | else : 237 | #print(f"{image} not found!") 238 | self.images[image] = Image.new("RGB", (self.controller.buttonRes, self.controller.buttonRes)) 239 | 240 | for tick in self.pages[page]["ticks"] : #Imports all the ticking files 241 | if tick.endswith(".py") : 242 | name = tick[:-3] 243 | id = "t" + uuid.uuid4().hex[:12] 244 | 245 | #exec(f"global {id}") #Trying to import the module using diffrent methods 246 | #exec(f"import pages.ticks.{name} as {id}") 247 | 248 | #module = __import__(f"pages.ticks.{name}") 249 | #print(module) 250 | #globals()[id] = module 251 | 252 | try : 253 | module = importlib.import_module(f"pages.ticks.{name}") 254 | globals()[id] = module 255 | 256 | self.ticks[tick] = id 257 | except Exception as e : 258 | print(e) 259 | pass 260 | 261 | 262 | def error(self, screenError, logError) : #Throws the stream deck into an error state. 263 | self.controller.resetScreen() 264 | 265 | self.tickingItems = {} 266 | 267 | self.controller.screen["0x0"].caption = screenError 268 | self.controller.screen["0x0"].fontColor = "black" 269 | self.controller.screen["0x0"].background = Image.new("RGB", (self.controller.buttonRes, self.controller.buttonRes), (255, 255, 255)) 270 | self.controller.screen["1x0"].caption = "See log\nfor details" 271 | self.controller.screen["0x1"].caption = "Exit" 272 | self.controller.sendScreenToDevice() 273 | 274 | self.activePage = {"buttons":{"1x0": {"actions": {"openTxt":"errorLog.txt"}}, "0x1": {"actions": {"exit":""}}}} 275 | self.activePageName = "**error**" 276 | 277 | print(f"[ERROR] {logError}") 278 | 279 | try : 280 | with open("errorLog.txt", "a") as f : 281 | f.write(f"{logError}\n") 282 | except : 283 | if not logError == "Could not write to log." : 284 | self.error("I/O\nError", "Could not write to log.") 285 | 286 | def switchToPage(self, page) : 287 | if self.activePageName == "**error**" : #Make sure you can't switch the page when in error state. Doing so would probably break something 288 | return False 289 | 290 | if not page in self.pages : 291 | self.error("Page\nmissing", f"Could not find '{page}'") 292 | return False 293 | 294 | j = self.pages[page] 295 | buttons = j["buttons"] 296 | 297 | self.controller.resetScreen() 298 | #self.controller.coordsCaptions() 299 | 300 | self.activePage = j 301 | self.activePageName = page 302 | 303 | self.tickingItems = {} 304 | 305 | dimensions = f"{self.controller.width}x{self.controller.height}" #Detect pages for other stream deck layouts 306 | if not dimensions == j["dimensions"] : 307 | self.error("Invalid\nlayout.", f"Page '{page}' is in an invalid layout size for this Stream Deck '{self.controller.serial}'.") 308 | return False 309 | 310 | #self.controller.disableInput = True 311 | 312 | for button in buttons : 313 | buttonJ = buttons[button] 314 | 315 | if not buttonJ["background"] in self.images : 316 | self.error("Missing\nimage.", f"Image '{buttonJ['background']}' was not found. Please add the image to the 'images' list of '{page}'") 317 | return False 318 | 319 | try : 320 | key = self.controller.screen[button] 321 | except KeyError : 322 | self.error("Invalid\ncoords", f"Invalid coords '{button}'.") 323 | return False 324 | 325 | key.setCaption(buttonJ["caption"]) 326 | key.background = self.images[buttonJ["background"]] 327 | key.fontSize = buttonJ["fontSize"] 328 | key.fontColor = buttonJ["color"] 329 | 330 | try : 331 | key.fontAlignment = buttonJ["fontAlignment"] 332 | except KeyError : 333 | key.fontAlignment = "center" 334 | 335 | if "ticks" in buttonJ : 336 | #self.tickingItems[button] = "hello" 337 | t = {} 338 | 339 | for tick in buttonJ["ticks"] : 340 | t[tick] = {"action": buttonJ["ticks"][tick], "lastTrigger": 0, "nextTrigger": 0} 341 | 342 | self.tickingItems[button] = t 343 | 344 | #print(self.tickingItems) 345 | 346 | #self.tick() 347 | self.controller.sendScreenToDevice() 348 | 349 | def triggerAction(self, coords, action, actionData) : 350 | print(coords, action, actionData) 351 | 352 | if action == "switchPage" : 353 | self.switchToPage(actionData) 354 | elif action == "exit" : 355 | self.controller.deck.reset() 356 | self.controller.deck.close() 357 | os._exit(1) 358 | elif action == "setBrightness" : 359 | self.controller.deck.set_brightness(int(actionData)) 360 | elif action == "showCoords" : 361 | self.controller.coordsCaptions(actionData) 362 | self.controller.sendScreenToDevice() 363 | elif action == "runCommand" : 364 | subprocess.call(str(actionData), shell=True, stderr=subprocess.DEVNULL) 365 | elif action == "screenshot" : 366 | self.controller.screenshot(actionData) 367 | elif action == "openTxt" : #Should only be used on the error screen. 368 | system = platform.system().lower() 369 | 370 | if system == 'windows' or system == 'darwin' : 371 | os.system(f"start {actionData}") #Windows or Mac OS 372 | else : 373 | subprocess.call(('xdg-open', actionData)) #Linux 374 | elif action == "keyboardType" : 375 | keyboard.write(actionData) 376 | elif action == "keyboardShortcut" : 377 | keyboard.send(actionData.lower()) 378 | elif action == "openURL" : 379 | webbrowser.open(actionData) 380 | elif action == "randomColors" : 381 | self.controller.randomColors() 382 | self.controller.sendScreenToDevice() 383 | 384 | 385 | def tickKeyPress(self, coords, tickModule, tick) : 386 | try : 387 | tickModule.keyPress(coords, self.activePageName, self.controller.serial) 388 | except Exception as e : 389 | self.error("keyPress\nError.", f"keypress() in module {tick}: {e}") 390 | 391 | def clickHandler(self, deck, keyIndex, state) : 392 | x = (keyIndex % self.controller.width) 393 | y = math.ceil((keyIndex+1) / self.controller.width)-1 394 | coords = f"{x}x{y}" 395 | 396 | if self.controller.disableInput : #Input disabled, do not continue 397 | return 398 | 399 | self.controller.screen[coords].activated = state #Triggers the click 'animation'. 400 | self.controller.screen[coords].sendToDevice() 401 | 402 | 403 | if not state : #Wait until the button is released 404 | try : 405 | button = self.activePage["buttons"][coords] 406 | 407 | if coords in self.tickingItems : #Triggers tick function on ticking buttons 408 | try : 409 | ticks = self.tickingItems[coords] 410 | except KeyError : 411 | return 412 | 413 | for tick in ticks : 414 | tickID = self.ticks[tick] 415 | 416 | tickModule = globals()[tickID] 417 | 418 | thread = threading.Thread(target=self.tickKeyPress, args=(coords, tickModule, tick)) 419 | thread.start() 420 | 421 | #self.tickKeyPress(coords, tickModule, tick) 422 | 423 | 424 | for action in button["actions"] : 425 | actionData = button["actions"][action] 426 | 427 | try : 428 | self.triggerAction(coords, action, actionData) 429 | except Exception as e : 430 | self.error("Could not\ntrigger.", f"Could not trigger action '{action}' with action data '{actionData}', error: {e}") 431 | return False 432 | 433 | 434 | except KeyError : 435 | pass 436 | 437 | def threadedTick(self, ticks, tickID, tick, action, button) : 438 | tickModule = globals()[tickID] 439 | 440 | #print("starting", tick, self.activePageName) 441 | startingPage = self.activePageName 442 | 443 | try : 444 | newState = tickModule.getKeyState(button, self.activePageName, self.controller.serial, action) 445 | except Exception as e : 446 | self.error("Ticks\nerror", f"Error in '{tick}'. Button: '{button}' Page: '{self.activePageName}' Serial: '{self.controller.serial}' Action: '{action}' Error: '{e}'") 447 | return False 448 | 449 | if not self.activePageName == startingPage : 450 | #print("interrupted", tick, self.activePageName, startingPage) 451 | return False 452 | 453 | #print("finished", tick, self.activePageName) 454 | 455 | key = self.controller.screen[button] 456 | 457 | if "caption" in newState : 458 | key.caption = newState["caption"] 459 | 460 | if "background" in newState : 461 | key.background = newState["background"] 462 | 463 | if "fontColor" in newState : 464 | key.fontColor = newState["fontColor"] 465 | 466 | if "fontSize" in newState : 467 | key.fontSize = newState["fontSize"] 468 | 469 | if "doNotUpdate" in newState : 470 | doNotUpdate = newState["doNotUpdate"] 471 | else : 472 | doNotUpdate = False 473 | 474 | if len(newState["actions"]) > 0 : 475 | for action in newState["actions"] : 476 | actionData = newState["actions"][action] 477 | self.triggerAction(button, action, actionData) 478 | self.controller.sendScreenToDevice() 479 | else : 480 | if not doNotUpdate : 481 | key.sendToDevice() 482 | 483 | try : 484 | wait = tickModule.nextTickWait(button, self.activePageName, self.controller.serial) 485 | except Exception as e : 486 | self.error("nextTickWait\nerror", f"nextTickWait() in {tick}: {e}") 487 | ticks[tick]["nextTrigger"] = time.time() + wait 488 | 489 | def tick(self) : 490 | startingTime = time.time() 491 | threads = {} 492 | 493 | for button in self.tickingItems : 494 | try : 495 | ticks = self.tickingItems[button] 496 | except KeyError : 497 | return False 498 | for tick in ticks : 499 | try : 500 | tickID = self.ticks[tick] 501 | except KeyError : 502 | self.error("Ticks file\nnot found.", f"The ticks file '{tick}' was not found. Please add it to the top of '{self.activePageName}'.") 503 | return False 504 | 505 | action = ticks[tick]["action"] 506 | nextTrigger = ticks[tick]["nextTrigger"] 507 | 508 | if time.time() > nextTrigger : 509 | #self.threadedTick(ticks, tickID, tick, action, button) 510 | thread = threading.Thread(target=self.threadedTick, args=(ticks, tickID, tick, action, button)) 511 | threads[button] = thread 512 | 513 | thread.start() 514 | 515 | for thread in threads.values() : 516 | thread.join() 517 | 518 | if (time.time() - startingTime) > 1 : 519 | self.error("Tick took\ntoo long", f"Tick took over a second ({round(time.time() - startingTime, 2)}s) to finish. Avoid doing time-expensive tasks in getKeyState()") 520 | 521 | #print(f"tick finished! {round(time.time() - startingTime, 2)}s") 522 | 523 | 524 | #self.controller.disableInput = False 525 | 526 | 527 | # ------------------------ # 528 | def helloWorldTest() : 529 | streamdecks = DeviceManager().enumerate() 530 | for index, deck in enumerate(streamdecks) : 531 | 532 | c = controller(deck) 533 | 534 | middleKey = c.screen["2x2"] 535 | middleKey.loadImage("test.png") 536 | middleKey.setCaption("Hello,\nworld!") 537 | middleKey.setFont("C:\\Windows\\Fonts\\Arial.ttf", size=15, color="red") 538 | 539 | c.sendScreenToDevice() 540 | 541 | while True : 542 | time.sleep(10) 543 | 544 | # ------------------------ # 545 | 546 | if __name__ == "__main__" and False : #Disabled 547 | streamdecks = DeviceManager().enumerate() 548 | 549 | for index, deck in enumerate(streamdecks) : 550 | c = controller(deck, "C:\\Windows\\Fonts\\Arial.ttf") 551 | 552 | p = pages(c) 553 | p.switchToPage("welcome.json") 554 | 555 | while True : 556 | time.sleep(.5) 557 | p.tick() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stream Deck Controller 2 | 3 | A Linux compatiable software for the Elgato Stream Deck with plugin support. 4 | 5 | _________________ 6 | 7 | colors.jpg 8 | 9 | --- 10 | 11 | ## Installation 12 | 13 |     1. Download the latest [release](https://github.com/TriLinder/StreamDeckController/releases) 14 | 15 |     2. Decompress the archive into a new directory 16 | 17 |     3. Install all the required modules from `requirements.txt` 18 | 19 |     4. Install the `streamdeck` module according to their [documentation](https://python-elgato-streamdeck.readthedocs.io/en/stable/pages/installation.html) 20 | 21 |     5. Download a font of your choice and place it into the `fonts` directory 22 | 23 |     6. Open `config.json` and modify `font` to the name of your font of choice. 24 | 25 |     7. Run `main.py` 26 | 27 | You should be asked to select your device from a list. If you can't see your device, make sure you installed the `streamdeck` module correctly and consider rebooting. 28 | 29 | --- 30 | 31 | ## Demo 32 | 33 | If you're using the 15 button Stream Deck (the one in the picture above), you will be presented with a demo. Enjoy! 34 | 35 | --- 36 | 37 | ## Troubleshooting 38 | 39 | noFont.jpg 40 | 41 | If you're presented with a screen like this, you haven't installed the font correctly. Please double-check that the font file is either a `.ttf` or a `.otf` and its in the correct directory. 42 | 43 | Then check that you entered the right name into `config.json`. 44 | 45 | If you still can't get your font to work, try another font or create an issue here on GitHub. 46 | 47 | --- 48 | 49 | invalidLayout.jpg 50 | 51 | This error screen means the page you're trying to load is made for a different Stream Deck model. If you see this on the first start, you're probably not using the 15 button model and so can't load up the demo. Don't worry however as you can still use your own profiles as is explained below. 52 | 53 | # Text isn't centered correctly / is off-screen 54 | 55 | This is a weird bug I couldn't figure out: the text on buttons renders off-center on some operating systems. As a workaround, you can set the `fontCenterFix` key in `config.json` to `true`, like this: 56 | 57 | ```json 58 | "fontCenterFix": true 59 | ``` 60 | 61 | --- 62 | 63 | ## Creating your own profiles 64 | 65 | This is the fun part. 66 | 67 | ‏‏‎ ‎ 68 | 69 | Under the `pages` directory you can find `empty.json`. Use this as a template for your first page. 70 | 71 | ```json 72 | { 73 | "images": ["blank"], 74 | "ticks": [], 75 | "dimensions": "5x3", 76 | "created": 1646871751, 77 | 78 | "buttons": { 79 | } 80 | } 81 | ``` 82 | 83 | First, let's start with editing the `dimensions` key. This is used to determine whether or not a page is suitable for the connected Stream Deck. It's in the simple format of `Width X Hight`, so the 15 button Stream Deck in the pictures above has the dimensions of `5x3`. Set this to your Stream Deck's dimensions. 84 | 85 | ‏‏‎ ‎ 86 | 87 | The `created` key is a [UNIX timestmap](https://en.wikipedia.org/wiki/Unix_time) and while it's not currently used by the program, I still recommend setting it as it may be used in a future version. 88 | 89 | ‏‏‎ ‎ 90 | 91 | We'll get to the `images` and `ticks` keys later. 92 | 93 | ‏‏ 94 | 95 | --- 96 | 97 | ### Adding buttons 98 | 99 | Now let's add some buttons. 100 | 101 | First, however, let's look at coordinates. 102 | 103 | | 0x0 | 1x0 | 2x0 | 3x0 | 4x0 | 104 | |:---:| --- | --- | --- | --- | 105 | | 0x1 | 1x1 | 2x1 | 3x1 | 4x1 | 106 | | 0x2 | 1x2 | 2x2 | 3x2 | 4x2 | 107 | 108 | These are the coordinates for the 15 button Stream Deck. As you can see, the coordinates are in the format of `x/y` with the top left button being `0x0`. 109 | 110 | ‎ 111 | 112 | You can always bring the coordinates up with the `showCoords` action, which we'll talk about later. 113 | 114 | ‎ 115 | 116 | Now that you know how to work with the coordinates, we can add our first button. 117 | 118 | This is how our `empty.json` template looks with a new button. 119 | 120 | ```json 121 | { 122 | "images": ["blank"], 123 | "ticks": [], 124 | "dimensions": "5x3", 125 | "created": 1646871751, 126 | 127 | "buttons": { 128 | "0x0" : {"caption":"Hello,\nworld.", "fontSize":12, "color":"white", "fontAlignment":"center", "background":"blank", "actions":{"none":""}} 129 | } 130 | } 131 | ``` 132 | 133 | Let's examine our button a little closer. 134 | 135 | ```json 136 | "0x0" : {"caption":"Hello,\nworld.", 137 | "fontSize":12, 138 | "color":"white", 139 | "fontAlignment":"center", 140 | "background":"blank", 141 | "actions":{ 142 | "none":"" 143 | } 144 | } 145 | ``` 146 | 147 | The button is under the key `0x0`, which you may recognize as coordinates. If we look at the table higher up, we can see that `0x0` corresponds to the top-left corner of our Stream Deck. 148 | 149 | ‏‏‎ ‎ 150 | 151 | Here is the explanation of the keys you can see in the example: 152 | 153 | | **KEY** | **EXPLANATION** | 154 | | ---------------:|:------------------------------------------------------------------------------------------------------------------------ | 155 | | `caption` | The text displayed on the button itself | 156 | | `fontSize` | The size of the font used to display the caption | 157 | | `color` | is the color of the font. You can either use names like `white` or `blue`, or you can use hex codes like `#2a2a2a`. | 158 | | `fontAlignment` | Vertical alignment of the font. Can either be `top`, `bottom` or `center`. **This tag is optional.** | 159 | | `background` | The background image of your button, we'll talk about how to add your own images below. | 160 | | `actions` | Actions to trigger when the button is pressed. You can find more info about `actions` below. | 161 | | `ticks` | Not seen in the example above, this tag is used for plugins which we'll also talk about later. **This tag is optional.** | 162 | 163 | ‏‏‎ ‎ 164 | 165 | Now let's run our example. Save the example with a new, unique file name such as `test1.json` 166 | 167 | ‏‏‎ ‎ 168 | 169 | Since there's currently no link leading to our file, we'll set the program to start on our page automatically. 170 | 171 | ‏‏‎ ‎ 172 | 173 | Let's open up `config.json`, that's located in the same directory as `main.py`, and edit it. 174 | 175 | This is how the file looks by default. 176 | 177 | ```json 178 | { 179 | "startingPage": "welcome.json", 180 | "startingBrightness": 75, 181 | "deviceSerial": "selectOnStartup", 182 | "font": "yourFont.ttf", 183 | "fontCenterFix": false 184 | } 185 | ``` 186 | 187 | We'll modify the `startingPage` to our page's file name. So if you named your file `test1.json`, the modified line will look like this: 188 | 189 | ```json 190 | "startingPage": "test1.json", 191 | ``` 192 | 193 | Now let's start `main.py` again. 194 | 195 | ‎ 196 | 197 | If you've done everything correctly, you should see a simple blank page with `Hello, world.` in the top left. 198 | 199 | Congratulations! You've created your first page! 200 | 201 | But it's looking a bit dull, isn't it? Let's add some images. 202 | 203 | --- 204 | 205 | ## Adding images 206 | 207 | Adding new images is pretty easy. First, let's find an image to add, preferably something square-ish. 208 | 209 | ‎ 210 | 211 | What about this [dice vector drawing from Pixabay](https://pixabay.com/vectors/dice-cube-die-game-gamer-chance-152068/)? 212 | 213 | It's not exactly square, but that doesn't matter, as the software will scale it to fit the button anyway. 214 | 215 | dice_152068_pixabay.png 216 | 217 | ‎ 218 | 219 | All we need is the lowest resolution version available, as the Stream Deck display isn't that high-res. 220 | 221 | ‎ 222 | 223 | Download the `.png` file and place it inside the `pages/imgs` directory under the name `dice.png` 224 | 225 | ‎ 226 | 227 | Now let's open up `test1.json` again and add the image to the button. 228 | 229 | ```json 230 | { 231 | "images": ["blank"], 232 | "ticks": [], 233 | "dimensions": "5x3", 234 | "created": 1646871751, 235 | 236 | "buttons": { 237 | "0x0" : {"caption":"Hello,\nworld.", "fontSize":12, "color":"white", "fontAlignment":"center", "background":"blank", "actions":{"none":""}} 238 | } 239 | } 240 | ``` 241 | 242 | First, we have to add the image to the `images` key at the top. You may get away with not doing this in some cases, but you should add it regardless. 243 | 244 | ```json 245 | "images": ["blank", "dice.png"], 246 | ``` 247 | 248 | ‎ 249 | 250 | Then we'll go to our button and change the `background` key to add our image. 251 | 252 | ```json 253 | "0x0" : {"caption":"Hello,\nworld.", 254 | "fontSize":12, 255 | "color":"white", 256 | "fontAlignment":"center", 257 | "background":"dice.png", 258 | "actions":{ 259 | "none":"" 260 | } 261 | } 262 | ``` 263 | 264 | Save the file and start `main.py` again. 265 | 266 | ‎ 267 | 268 | You should see that your button got a nice new background. But it's still not doing anything when pressed. Let's change that by adding actions. 269 | 270 | --- 271 | 272 | ## 3, 2, 1, Action! 273 | 274 | Actions are triggered by pressing a button or by plugins (ticks). 275 | 276 | As of this version, the program has 10 actions built-in. 277 | 278 | | **ACTION** | **INPUT** | **DESCRIPTION** | 279 | |:------------------:|:----------------------:|:-------------------------------------------------------------------------------------------------:| 280 | | `switchPage` | `str` - Page file name | Switches to a page | 281 | | `exit` | NONE | Exits the program | 282 | | `setBrightness` | `int` - Brightness | Sets the Stream Deck's brightness | 283 | | `showCoords` | `bool` - Overwrite | Set's each button's caption to it's coordinates. If `false` won't overwrite existing captions. | 284 | | `runCommand` | `str` - Command to run | Runs a shell command, such as `ping 1.1.1.1` | 285 | | `screenshot` | `str` - Path | Takes a screenshot of the Stream Deck's current screen, saves it to set path. | 286 | | `openTxt` | `str` - Path | Opens a `.txt` file in an editor. Used by error screens and **should not be used** anywhere else. | 287 | | `keyboardType` | `str` - Text | Types a text by acting as a keyboard. | 288 | | `keyboardShortcut` | `str` - Shortcut | Executes a keyboard shortcut, such as `CTRL + A` | 289 | | `openURL` | `str` - URL | Opens a URL | 290 | | `randomColors` | NONE | Fills the screen with random colors, as seen on the first picture. | 291 | 292 | You can trigger any of these actions with a button by adding it to it's `actions` key. 293 | 294 | Let's go back to our button we created in `test1.json` 295 | 296 | ```json 297 | "0x0" : {"caption":"Hello,\nworld.", 298 | "fontSize":12, 299 | "color":"white", 300 | "fontAlignment":"center", 301 | "background":"dice.png", 302 | "actions":{ 303 | "none":"" 304 | } 305 | } 306 | ``` 307 | 308 | We'll set the button to fill the screen with random colors using the `randomColors` action. 309 | 310 | All we need to do is add it to the button's `actions` key. 311 | 312 | ```json 313 | "0x0" : {"caption":"Hello,\nworld.", 314 | "fontSize":12, 315 | "color":"white", 316 | "fontAlignment":"center", 317 | "background":"dice.png", 318 | "actions":{ 319 | "randomColors":"" 320 | } 321 | } 322 | ``` 323 | 324 | As `randomColors` doesn't require any inputs, you can set it to whatever you want. 325 | 326 | Save the file as always and run `main.py` to see the changes. 327 | 328 | ‎ 329 | 330 | When the button is pressed, the screen should flash with random colors. 331 | 332 | Congrats! Your button is now working! 333 | 334 | But we can't see it very clearly, how about we set the brightness a little bit higher? 335 | 336 | ‎ 337 | 338 | All we need to do is to modify the button again and add the `setBrightness` action. 339 | 340 | ```json 341 | "actions":{ 342 | "randomColors":"", 343 | "setBrightness": 100 344 | } 345 | ``` 346 | 347 | According to the table above, `setBrightness` requires input in the form of an integer, so we need to give such input. 348 | 349 | ‎ 350 | 351 | Save the file, run `main.py`, observe the results. 352 | 353 | When pressed, your console output should look something like this. 354 | 355 | ```shell 356 | 0x0 randomColors 357 | 0x0 setBrightness 100 358 | ``` 359 | 360 | This means both actions were triggered successfully, and the brightness was increased. 361 | 362 | ‎ 363 | 364 | If you can run the demo, you can test out all the actions in it. (`showcase4.json`) 365 | 366 | **The most important action** is `switchPage`, which switches to an entirely different page. I recommend looking through the demo's `.json` files to see the actions in action. 367 | 368 | ‎ 369 | 370 | But if you still feel limited by your options, you can make your own by making plugins. 371 | 372 | --- 373 | 374 | ## Plugins 375 | 376 | This is what most of you were probably waiting for. Plugins. 377 | 378 | If you went through the demo, or saw my [Reddit post](https://www.reddit.com/r/elgato/comments/tcjzte/made_an_opensource_alternative_to_the_official/), you possibly noticed a clock on the 2nd page (`showcase1.json`). 379 | 380 | But if you paid attention above, you'd know that a clock action doesn't exist. 381 | 382 | ‎ 383 | 384 | So how is this even possible? Well, the answer seems kinda obvious now, it's done using a simple plugin. 385 | 386 | ‎ 387 | 388 | Plugins are located under the `pages/ticks` directory. 389 | 390 | As you can see, the demo in fact includes a `clock.py`, so let's take a look at it. 391 | 392 | ```python 393 | from time import localtime, strftime 394 | 395 | format = 0 396 | formats = ["%H:%M:%S", "%H:%M", "%d.%m\n%H:%M:%S"] 397 | 398 | def nextTickWait(coords, page, serial) : 399 | return 1 #Time until the next tick in seconds 400 | 401 | def getKeyState(coords, page, serial, action) : #Runs every tick 402 | #print(coords, page, serial, action) 403 | 404 | if action == "clock" : 405 | return {"caption": strftime(formats[format], localtime()), 406 | "fontSize": 14, 407 | "fontColor": "white", 408 | "actions": {}} 409 | 410 | def keyPress(coords, page, serial) : #Cycle through time formats on keypress 411 | global format 412 | format += 1 413 | 414 | if format+1 > len(formats) : 415 | format = 0 416 | ``` 417 | 418 | And this is how the clock button under `showcase1.json` looks. 419 | 420 | ```json 421 | "2x1" : {"caption":"00:00", 422 | "fontSize":14, 423 | "color":"white", 424 | "background":"blank", 425 | "actions":{"none":""}, 426 | "ticks":{"clock.py":"clock"} 427 | } 428 | ``` 429 | 430 | But first, don't forget to add the plugin's file name to the `ticks` key of every page where it's used. 431 | 432 | ```json 433 | "images": ["blank", "white.jpg"], 434 | "ticks": ["clock.py"], 435 | ``` 436 | 437 | ‎ 438 | 439 | But how does it work? **Let's create our own plugin** for dice rolls. 440 | 441 | ‎ 442 | 443 | First, let's create a `dice.py` under the `pages/ticks` directory. 444 | 445 | Now let's type some simple code to generate a random dice roll. 446 | 447 | ```python 448 | import random #Imports the random module, used for generating random numbers 449 | 450 | def generateDiceRoll(): #A function to generate a dice roll 451 | return random.randint(1, 6) #Return a random number from 1 to 6. 452 | ``` 453 | 454 | This is nice, but it still can't communicate with the Stream Deck. So let's connect it. 455 | 456 | First let's show some text, any text, from the plugin on the Stream Deck. 457 | 458 | ‎ 459 | 460 | Back in `test1.json`, we'll first have to add our new plugin to the `ticks` key. 461 | 462 | ```json 463 | { 464 | "images": ["blank", "dice.png"], 465 | "ticks": ["dice.py"], 466 | "dimensions": "5x3", 467 | "created": 1646871751, 468 | 469 | "buttons": { 470 | "0x0" : {"caption":"Hello,\nworld.", "fontSize":12, "color":"white", "fontAlignment":"center", "background":"dice.png", "actions":{"randomColors":"", "setBrightness":100}} 471 | } 472 | } 473 | ``` 474 | 475 | Now we'll modify our button again to work with the plugin. 476 | 477 | We'll start by removing all the actions, so it doesn't do anything when pressed. 478 | 479 | ```json 480 | "0x0" : {"caption":"Hello,\nworld.", 481 | "fontSize":12, 482 | "color":"white", 483 | "fontAlignment":"center", 484 | "background":"dice.png", 485 | "actions":{}, 486 | } 487 | ``` 488 | 489 | Now we'll add the `ticks` key. 490 | 491 | ```json 492 | "0x0" : {"caption":"Hello,\nworld.", 493 | "fontSize":12, 494 | "color":"white", 495 | "fontAlignment":"center", 496 | "background":"dice.png", 497 | "actions":{}, 498 | "ticks": {"dice.py":"diceRoll"} 499 | } 500 | ``` 501 | 502 | The syntax above says: every tick ask `dice.py` for updates with the `diceRoll` custom action. 503 | 504 | ‎ 505 | 506 | Now we can return to our `dice.py` file and continue programming. 507 | 508 | Before outputting anything to the Stream Deck, we need to tell Stream Deck Controller how often to ask our program for output. 509 | 510 | ```python 511 | def nextTickWait(coords, page, serial): #A function called by the Stream Deck Controller 512 | return 1 #Time until next tick in seconds 513 | ``` 514 | 515 | The function above is called after every tick to determine how long to wait for the next one. In our case, we only need 1 TPS (tick per second), but you can dynamically change the value if you want to. 516 | 517 | The TPS cannot be higher than the value of `maxTPS` in `config.json`. 518 | 519 | Now that a TPS is set, we can finally respond to ticks by sending output. 520 | 521 | ```python 522 | import random #Imports the random module, used for generating random numbers 523 | 524 | caption = "Press to\nroll the dice." #Caption (text) to display on the key 525 | 526 | def generateDiceRoll(): #A function to generate a dice roll 527 | return random.randint(1, 6) #Return a random number from 1 to 6. 528 | 529 | def nextTickWait(coords, page, serial): #A function called by the Stream Deck Controller 530 | return 1 #Time until next tick in seconds 531 | 532 | def getKeyState(coords, page, serial, action): #Runs every tick 533 | if action == "diceRoll": #Check the correct action was called 534 | return {"caption": str(caption), 535 | "fontSize": 12, 536 | "fontColor": "white", 537 | "actions": {} 538 | } 539 | ``` 540 | 541 | We have added a new function: `getKeyState`, which is called once every tick. 542 | 543 | First, we check that the correct custom action is called with the `if` statement. 544 | 545 | Then we return a dictionary with new values for our button. 546 | 547 | All the keys in the dictionary are optional. 548 | 549 | | **KEY** | **FORMAT** | 550 | | ------------- |:----------------:| 551 | | `caption` | `str` | 552 | | `background` | `PIL Image` | 553 | | `fontColor` | `str` | 554 | | `fontSize` | `int` or `float` | 555 | | `doNotUpdate` | `bool` | 556 | | `actions` | `dict` | 557 | 558 | If we start main.py, we can see that the text is in fact displayed. 559 | 560 | But the program crashes when the button is pressed. That's because our plugin is missing a `keyPress` function, so the program doesn't know what to do when the button is pressed. 561 | 562 | ```python 563 | def keyPress(coords, page, serial): 564 | global caption 565 | caption = generateDiceRoll() 566 | ``` 567 | 568 | And there we go! 569 | 570 | The `keyPress` function gets called when the button is pressed. 571 | 572 | When called, the function sets the global `caption` variable to a random number, and the caption gets sent to the device next tick from the `getKeyState` function. 573 | 574 | Let's restart the program and see if it works! 575 | 576 | If you've done everything correctly, your plugin should now work. 577 | 578 | You are now ready to use Stream Deck Controller. Good luck! 579 | 580 | --- 581 | 582 | ## Final notes 583 | 584 | Sorry for the huge delay for releasing the program. I just couldn't get around to finishing the last few lines of this README. I also just didn't expect to get this amount of attention on Reddit. 585 | 586 | If you find a bug, please create an issue for it. Thanks for reading through this and have a great day. 587 | --------------------------------------------------------------------------------