Linux PyQt tray application to control OctoPrint instances
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

octotray 13KB

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