Linux PyQt tray application to control OctoPrint instances
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

octotray 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. #!/usr/bin/env python3
  2. # OctoTray Linux Qt System Tray OctoPrint client
  3. #
  4. # depends on:
  5. # - python-pyqt5
  6. # - curl
  7. # - xdg-open
  8. #
  9. # see also:
  10. # https://doc.qt.io/qt-5/qtwidgets-widgets-imageviewer-example.html
  11. # https://stackoverflow.com/a/22618496
  12. import json
  13. import subprocess
  14. import sys
  15. import os
  16. import threading
  17. import time
  18. from PyQt5 import QtWidgets, QtGui, QtCore, QtNetwork
  19. from PyQt5.QtWidgets import QSystemTrayIcon, QAction, QMenu, QMessageBox, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QDesktopWidget, QSizePolicy, QSlider, QLayout
  20. from PyQt5.QtGui import QIcon, QPixmap, QImageReader
  21. from PyQt5.QtCore import QCoreApplication, QSettings, QUrl, QTimer, QSize, Qt
  22. class AspectRatioPixmapLabel(QLabel):
  23. def __init__(self, *args, **kwargs):
  24. super(AspectRatioPixmapLabel, self).__init__(*args, **kwargs)
  25. self.setMinimumSize(1, 1)
  26. self.setScaledContents(False)
  27. self.pix = QPixmap(0, 0)
  28. def setPixmap(self, p):
  29. self.pix = p
  30. super(AspectRatioPixmapLabel, self).setPixmap(self.scaledPixmap())
  31. def heightForWidth(self, width):
  32. if self.pix.isNull():
  33. return self.height()
  34. else:
  35. return (self.pix.height() * width) / self.pix.width()
  36. def sizeHint(self):
  37. w = self.width()
  38. return QSize(int(w), int(self.heightForWidth(w)))
  39. def scaledPixmap(self):
  40. return self.pix.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
  41. def resizeEvent(self, e):
  42. if not self.pix.isNull():
  43. super(AspectRatioPixmapLabel, self).setPixmap(self.scaledPixmap())
  44. class CamWindow(QWidget):
  45. reloadDelayDefault = 1000 # in ms
  46. addSize = 100
  47. reloadOn = True
  48. def __init__(self, parent, name, icon, app, manager, host, *args, **kwargs):
  49. super(CamWindow, self).__init__(*args, **kwargs)
  50. self.url = "http://" + host + ":8080/?action=snapshot"
  51. self.host = host
  52. self.app = app
  53. self.parent = parent
  54. self.manager = manager
  55. self.manager.finished.connect(self.handleResponse)
  56. self.setWindowTitle(name + " Webcam Stream")
  57. self.setWindowIcon(icon)
  58. box = QVBoxLayout()
  59. self.setLayout(box)
  60. label = QLabel(self.url)
  61. box.addWidget(label, 0)
  62. box.setAlignment(label, Qt.AlignHCenter)
  63. self.img = AspectRatioPixmapLabel()
  64. self.img.setPixmap(QPixmap(640, 480))
  65. box.addWidget(self.img, 1)
  66. slide = QHBoxLayout()
  67. box.addLayout(slide, 0)
  68. self.slider = QSlider(Qt.Horizontal)
  69. self.slider.setMinimum(0)
  70. self.slider.setMaximum(2000)
  71. self.slider.setTickInterval(100)
  72. self.slider.setPageStep(100)
  73. self.slider.setSingleStep(100)
  74. self.slider.setTickPosition(QSlider.TicksBelow)
  75. self.slider.setValue(self.reloadDelayDefault)
  76. self.slider.valueChanged.connect(self.sliderChanged)
  77. slide.addWidget(self.slider, 1)
  78. self.slideLabel = QLabel(str(self.reloadDelayDefault) + "ms")
  79. slide.addWidget(self.slideLabel, 0)
  80. size = self.size()
  81. size.setHeight(size.height() + self.addSize)
  82. self.resize(size)
  83. self.loadImage()
  84. def getHost(self):
  85. return self.host
  86. def sliderChanged(self):
  87. self.slideLabel.setText(str(self.slider.value()) + "ms")
  88. def closeEvent(self, event):
  89. self.reloadOn = False
  90. self.url = ""
  91. self.parent.removeWebcamWindow(self)
  92. def scheduleLoad(self):
  93. if self.reloadOn:
  94. QTimer.singleShot(self.slider.value(), self.loadImage)
  95. def loadImage(self):
  96. url = QUrl(self.url)
  97. request = QtNetwork.QNetworkRequest(url)
  98. self.manager.get(request)
  99. def handleResponse(self, reply):
  100. if reply.url().url() == self.url:
  101. if reply.error() == QtNetwork.QNetworkReply.NoError:
  102. reader = QImageReader(reply)
  103. reader.setAutoTransform(True)
  104. image = reader.read()
  105. if image != None:
  106. if image.colorSpace().isValid():
  107. image.convertToColorSpace(QColorSpace.SRgb)
  108. self.img.setPixmap(QPixmap.fromImage(image))
  109. self.scheduleLoad()
  110. else:
  111. print("Error decoding image: " + reader.errorString())
  112. else:
  113. print("Error loading image: " + reply.errorString())
  114. class OctoTray():
  115. name = "OctoTray"
  116. vendor = "xythobuz"
  117. version = "0.2"
  118. iconPath = "/usr/share/pixmaps/"
  119. iconName = "octotray_icon.png"
  120. # 0=host, 1=key
  121. # (2=method, 3=menu, 4...=actions)
  122. printers = [
  123. [ "PRINTER_HOST_HERE", "PRINTER_API_KEY_HERE" ]
  124. ]
  125. statesWithWarning = [
  126. "Printing", "Pausing", "Paused"
  127. ]
  128. camWindows = []
  129. def __init__(self):
  130. self.app = QtWidgets.QApplication(sys.argv)
  131. QCoreApplication.setApplicationName(self.name)
  132. if not QSystemTrayIcon.isSystemTrayAvailable():
  133. print("System Tray is not available on this platform!")
  134. sys.exit(0)
  135. self.manager = QtNetwork.QNetworkAccessManager()
  136. self.menu = QMenu()
  137. for p in self.printers:
  138. method = self.getMethod(p[0], p[1])
  139. print("Printer " + p[0] + " has method " + method)
  140. p.append(method)
  141. if method == "unknown":
  142. continue
  143. menu = QMenu(self.getName(p[0], p[1]))
  144. p.append(menu)
  145. self.menu.addMenu(menu)
  146. action = QAction("Turn on")
  147. action.triggered.connect(lambda chk, x=p: self.printerOnAction(x))
  148. p.append(action)
  149. menu.addAction(action)
  150. action = QAction("Turn off")
  151. action.triggered.connect(lambda chk, x=p: self.printerOffAction(x))
  152. p.append(action)
  153. menu.addAction(action)
  154. action = QAction("Get Status")
  155. action.triggered.connect(lambda chk, x=p: self.printerStatusAction(x))
  156. p.append(action)
  157. menu.addAction(action)
  158. action = QAction("Show Webcam")
  159. action.triggered.connect(lambda chk, x=p: self.printerWebcamAction(x))
  160. p.append(action)
  161. menu.addAction(action)
  162. action = QAction("Open Web UI")
  163. action.triggered.connect(lambda chk, x=p: self.printerWebAction(x))
  164. p.append(action)
  165. menu.addAction(action)
  166. self.quitAction = QAction("&Quit")
  167. self.quitAction.triggered.connect(self.exit)
  168. self.menu.addAction(self.quitAction)
  169. iconPathName = ""
  170. if os.path.isfile(self.iconName):
  171. iconPathName = self.iconName
  172. elif os.path.isfile(self.iconPath + self.iconName):
  173. iconPathName = self.iconPath + self.iconName
  174. else:
  175. print("no icon found")
  176. self.icon = QIcon()
  177. if iconPathName != "":
  178. pic = QPixmap(32, 32)
  179. pic.load(iconPathName)
  180. self.icon = QIcon(pic)
  181. trayIcon = QSystemTrayIcon(self.icon)
  182. trayIcon.setToolTip(self.name + " " + self.version)
  183. trayIcon.setContextMenu(self.menu)
  184. trayIcon.setVisible(True)
  185. sys.exit(self.app.exec_())
  186. def openBrowser(self, url):
  187. os.system("xdg-open http://" + url)
  188. def showDialog(self, title, text1, text2, question, warning):
  189. msg = QMessageBox()
  190. if warning:
  191. msg.setIcon(QMessageBox.Warning)
  192. else:
  193. msg.setIcon(QMessageBox.Information)
  194. msg.setWindowTitle(title)
  195. msg.setText(text1)
  196. if text2 is not None:
  197. msg.setInformativeText(text2)
  198. if question:
  199. msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
  200. else:
  201. msg.setStandardButtons(QMessageBox.Ok)
  202. retval = msg.exec_()
  203. if retval == QMessageBox.Yes:
  204. return True
  205. return False
  206. def sendRequest(self, host, headers, path, content = None):
  207. cmdline = 'curl -s'
  208. for h in headers:
  209. cmdline += " -H \"" + h + "\""
  210. if content == None:
  211. cmdline += " -X GET"
  212. else:
  213. cmdline += " -X POST"
  214. cmdline += " -d '" + content + "'"
  215. cmdline += " http://" + host + "/api/" + path
  216. r = subprocess.run(cmdline, shell=True, capture_output=True, timeout=10, text=True)
  217. return r.stdout
  218. def sendPostRequest(self, host, key, path, content):
  219. headers = [ "Content-Type: application/json",
  220. "X-Api-Key: " + key ]
  221. return self.sendRequest(host, headers, path, content)
  222. def sendGetRequest(self, host, key, path):
  223. headers = [ "X-Api-Key: " + key ]
  224. return self.sendRequest(host, headers, path)
  225. def getState(self, host, key):
  226. r = self.sendGetRequest(host, key, "job")
  227. try:
  228. rd = json.loads(r)
  229. if "state" in rd:
  230. return rd["state"]
  231. except json.JSONDecodeError:
  232. pass
  233. return "Unknown"
  234. def getProgress(self, host, key):
  235. r = self.sendGetRequest(host, key, "job")
  236. try:
  237. rd = json.loads(r)
  238. if "progress" in rd:
  239. return rd["progress"]
  240. except json.JSONDecodeError:
  241. pass
  242. return "Unknown"
  243. def getName(self, host, key):
  244. r = self.sendGetRequest(host, key, "printerprofiles")
  245. try:
  246. rd = json.loads(r)
  247. if "profiles" in rd:
  248. p = next(iter(rd["profiles"]))
  249. if "name" in rd["profiles"][p]:
  250. return rd["profiles"][p]["name"]
  251. except json.JSONDecodeError:
  252. pass
  253. return host
  254. def getMethod(self, host, key):
  255. r = self.sendGetRequest(host, key, "plugin/psucontrol")
  256. try:
  257. rd = json.loads(r)
  258. if "isPSUOn" in rd:
  259. return "psucontrol"
  260. except json.JSONDecodeError:
  261. pass
  262. r = self.sendGetRequest(host, key, "system/commands/custom")
  263. try:
  264. rd = json.loads(r)
  265. for c in rd:
  266. if "action" in c:
  267. if (c["action"] == "all off") or (c["action"] == "all on"):
  268. return "system"
  269. except json.JSONDecodeError:
  270. pass
  271. return "unknown"
  272. def setPSUControl(self, host, key, state):
  273. cmd = "turnPSUOff"
  274. if state:
  275. cmd = "turnPSUOn"
  276. return self.sendPostRequest(host, key, "plugin/psucontrol", '{ "command":"' + cmd + '" }')
  277. def setSystemCommand(self, host, key, state):
  278. cmd = "all%20off"
  279. if state:
  280. cmd = "all%20on"
  281. return self.sendPostRequest(host, key, "system/commands/custom/" + cmd, '')
  282. def setPrinter(self, host, key, method, state):
  283. if method == "psucontrol":
  284. return self.setPSUControl(host, key, state)
  285. elif method == "system":
  286. return self.setSystemCommand(host, key, state)
  287. else:
  288. return "error"
  289. def exit(self):
  290. QCoreApplication.quit()
  291. def printerOnAction(self, item):
  292. self.setPrinter(item[0], item[1], item[2], True)
  293. def printerOffAction(self, item):
  294. state = self.getState(item[0], item[1])
  295. if state in self.statesWithWarning:
  296. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to turn it off?", True, True) == True:
  297. self.setPrinter(item[0], item[1], item[2], False)
  298. else:
  299. self.setPrinter(item[0], item[1], item[2], False)
  300. def printerWebAction(self, item):
  301. self.openBrowser(item[0])
  302. def printerStatusAction(self, item):
  303. progress = self.getProgress(item[0], item[1])
  304. s = ""
  305. warning = False
  306. if ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress) and (progress["completion"] != None) and (progress["printTime"] != None) and (progress["printTimeLeft"] != None):
  307. s = "%.1f%% Completion\n" % progress["completion"]
  308. s += "Printing since " + time.strftime("%H:%M:%S", time.gmtime(progress["printTime"])) + "\n"
  309. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTimeLeft"])) + " left\n"
  310. elif ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress):
  311. s = "No job is currently running"
  312. else:
  313. s = "Could not read printer status!"
  314. warning = True
  315. self.showDialog("OctoTray Status", s, None, False, warning)
  316. def printerWebcamAction(self, item):
  317. for cw in self.camWindows:
  318. if cw.getHost() == item[0]:
  319. cw.show()
  320. cw.activateWindow()
  321. return
  322. window = CamWindow(self, self.name, self.icon, self.app, self.manager, item[0])
  323. self.camWindows.append(window)
  324. screenGeometry = QDesktopWidget().screenGeometry()
  325. width = screenGeometry.width()
  326. height = screenGeometry.height()
  327. x = (width - window.width()) / 2
  328. y = (height - window.height()) / 2
  329. window.setGeometry(int(x), int(y), int(window.width()), int(window.height()))
  330. window.show()
  331. window.activateWindow()
  332. def removeWebcamWindow(self, window):
  333. self.camWindows.remove(window)
  334. if __name__ == "__main__":
  335. tray = OctoTray()