├── 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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------