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 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. #!/usr/bin/env python3
  2. # OctoTray Linux Qt System Tray OctoPrint client
  3. #
  4. # depends on:
  5. # - python-pyqt5
  6. # - curl
  7. #
  8. # see also:
  9. # https://doc.qt.io/qt-5/qtwidgets-widgets-imageviewer-example.html
  10. # https://stackoverflow.com/a/22618496
  11. import json
  12. import subprocess
  13. import sys
  14. import os
  15. import threading
  16. import time
  17. import urllib.parse
  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, QDesktopServices
  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. statusDelay = 10 * 1000 # in ms
  47. addSize = 100
  48. reloadOn = True
  49. def __init__(self, parent, name, icon, app, manager, printer, *args, **kwargs):
  50. super(CamWindow, self).__init__(*args, **kwargs)
  51. self.app = app
  52. self.manager = manager
  53. self.manager.finished.connect(self.handleResponse)
  54. self.parent = parent
  55. self.printer = printer
  56. self.host = self.printer[0]
  57. self.url = "http://" + self.host + ":8080/?action=snapshot"
  58. self.setWindowTitle(name + " Webcam Stream")
  59. self.setWindowIcon(icon)
  60. box = QVBoxLayout()
  61. self.setLayout(box)
  62. label = QLabel(self.url)
  63. box.addWidget(label, 0)
  64. box.setAlignment(label, Qt.AlignHCenter)
  65. self.img = AspectRatioPixmapLabel()
  66. self.img.setPixmap(QPixmap(640, 480))
  67. box.addWidget(self.img, 1)
  68. slide = QHBoxLayout()
  69. box.addLayout(slide, 0)
  70. self.slider = QSlider(Qt.Horizontal)
  71. self.slider.setMinimum(0)
  72. self.slider.setMaximum(2000)
  73. self.slider.setTickInterval(100)
  74. self.slider.setPageStep(100)
  75. self.slider.setSingleStep(100)
  76. self.slider.setTickPosition(QSlider.TicksBelow)
  77. self.slider.setValue(self.reloadDelayDefault)
  78. self.slider.valueChanged.connect(self.sliderChanged)
  79. slide.addWidget(self.slider, 1)
  80. self.slideLabel = QLabel(str(self.reloadDelayDefault) + "ms")
  81. slide.addWidget(self.slideLabel, 0)
  82. self.statusLabel = QLabel("Status: unavailable")
  83. box.addWidget(self.statusLabel, 0)
  84. box.setAlignment(label, Qt.AlignHCenter)
  85. size = self.size()
  86. size.setHeight(size.height() + self.addSize)
  87. self.resize(size)
  88. self.loadImage()
  89. self.loadStatus()
  90. def getHost(self):
  91. return self.host
  92. def sliderChanged(self):
  93. self.slideLabel.setText(str(self.slider.value()) + "ms")
  94. def closeEvent(self, event):
  95. self.reloadOn = False
  96. self.url = ""
  97. self.parent.removeWebcamWindow(self)
  98. def scheduleLoadImage(self):
  99. if self.reloadOn:
  100. QTimer.singleShot(self.slider.value(), self.loadImage)
  101. def scheduleLoadStatus(self):
  102. if self.reloadOn:
  103. QTimer.singleShot(self.statusDelay, self.loadStatus)
  104. def loadImage(self):
  105. url = QUrl(self.url)
  106. request = QtNetwork.QNetworkRequest(url)
  107. self.manager.get(request)
  108. def loadStatus(self):
  109. s = "Status: "
  110. t = self.parent.getTemperatureString(self.host, self.printer[1])
  111. if len(t) > 0:
  112. s += t
  113. else:
  114. s += "Unknown"
  115. progress = self.parent.getProgress(self.host, self.printer[1])
  116. if ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress) and (progress["completion"] != None) and (progress["printTime"] != None) and (progress["printTimeLeft"] != None):
  117. s += " - %.1f%%" % progress["completion"]
  118. s += " - runtime "
  119. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTime"]))
  120. s += " - "
  121. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTimeLeft"])) + " left"
  122. self.statusLabel.setText(s)
  123. self.scheduleLoadStatus()
  124. def handleResponse(self, reply):
  125. if reply.url().url() == self.url:
  126. if reply.error() == QtNetwork.QNetworkReply.NoError:
  127. reader = QImageReader(reply)
  128. reader.setAutoTransform(True)
  129. image = reader.read()
  130. if image != None:
  131. if image.colorSpace().isValid():
  132. image.convertToColorSpace(QColorSpace.SRgb)
  133. self.img.setPixmap(QPixmap.fromImage(image))
  134. self.scheduleLoadImage()
  135. else:
  136. print("Error decoding image: " + reader.errorString())
  137. else:
  138. print("Error loading image: " + reply.errorString())
  139. class OctoTray():
  140. name = "OctoTray"
  141. vendor = "xythobuz"
  142. version = "0.2"
  143. iconPath = "/usr/share/pixmaps/"
  144. iconName = "octotray_icon.png"
  145. # 0=host, 1=key
  146. # (2=system-commands, 3=menu, 4...=actions)
  147. printers = [
  148. [ "PRINTER_HOST_HERE", "PRINTER_API_KEY_HERE" ]
  149. ]
  150. statesWithWarning = [
  151. "Printing", "Pausing", "Paused"
  152. ]
  153. camWindows = []
  154. def __init__(self):
  155. self.app = QtWidgets.QApplication(sys.argv)
  156. QCoreApplication.setApplicationName(self.name)
  157. if not QSystemTrayIcon.isSystemTrayAvailable():
  158. self.showDialog("OctoTray Error", "System Tray is not available on this platform!", "", False, False, True)
  159. sys.exit(0)
  160. self.manager = QtNetwork.QNetworkAccessManager()
  161. self.menu = QMenu()
  162. unknownCount = 0
  163. for p in self.printers:
  164. method = self.getMethod(p[0], p[1])
  165. print("Printer " + p[0] + " has method " + method)
  166. if method == "unknown":
  167. unknownCount += 1
  168. continue
  169. commands = self.getSystemCommands(p[0], p[1])
  170. p.append(commands)
  171. menu = QMenu(self.getName(p[0], p[1]))
  172. p.append(menu)
  173. self.menu.addMenu(menu)
  174. if method == "psucontrol":
  175. action = QAction("Turn On PSU")
  176. action.triggered.connect(lambda chk, x=p: self.printerOnAction(x))
  177. p.append(action)
  178. menu.addAction(action)
  179. action = QAction("Turn Off PSU")
  180. action.triggered.connect(lambda chk, x=p: self.printerOffAction(x))
  181. p.append(action)
  182. menu.addAction(action)
  183. for i in range(0, len(commands)):
  184. action = QAction(commands[i].title())
  185. action.triggered.connect(lambda chk, x=p, y=i: self.printerSystemCommandAction(x, y))
  186. p.append(action)
  187. menu.addAction(action)
  188. action = QAction("Get Status")
  189. action.triggered.connect(lambda chk, x=p: self.printerStatusAction(x))
  190. p.append(action)
  191. menu.addAction(action)
  192. action = QAction("Show Webcam")
  193. action.triggered.connect(lambda chk, x=p: self.printerWebcamAction(x))
  194. p.append(action)
  195. menu.addAction(action)
  196. action = QAction("Open Web UI")
  197. action.triggered.connect(lambda chk, x=p: self.printerWebAction(x))
  198. p.append(action)
  199. menu.addAction(action)
  200. if (len(self.printers) <= 0) or (unknownCount >= len(self.printers)):
  201. self.showDialog("OctoTray Error", "No printers available!", "", False, False, True)
  202. sys.exit(0)
  203. self.quitAction = QAction("&Quit")
  204. self.quitAction.triggered.connect(self.exit)
  205. self.menu.addAction(self.quitAction)
  206. iconPathName = ""
  207. if os.path.isfile(self.iconName):
  208. iconPathName = self.iconName
  209. elif os.path.isfile(self.iconPath + self.iconName):
  210. iconPathName = self.iconPath + self.iconName
  211. else:
  212. self.showDialog("OctoTray Error", "Icon file has not been found! found", "", False, False, True)
  213. sys.exit(0)
  214. self.icon = QIcon()
  215. pic = QPixmap(32, 32)
  216. pic.load(iconPathName)
  217. self.icon = QIcon(pic)
  218. trayIcon = QSystemTrayIcon(self.icon)
  219. trayIcon.setToolTip(self.name + " " + self.version)
  220. trayIcon.setContextMenu(self.menu)
  221. trayIcon.setVisible(True)
  222. sys.exit(self.app.exec_())
  223. def openBrowser(self, url):
  224. QDesktopServices.openUrl(QUrl("http://" + url))
  225. def showDialog(self, title, text1, text2 = "", question = False, warning = False, error = False):
  226. msg = QMessageBox()
  227. if error:
  228. msg.setIcon(QMessageBox.Critical)
  229. elif warning:
  230. msg.setIcon(QMessageBox.Warning)
  231. elif question:
  232. msg.setIcon(QMessageBox.Question)
  233. else:
  234. msg.setIcon(QMessageBox.Information)
  235. msg.setWindowTitle(title)
  236. msg.setText(text1)
  237. if text2 is not None:
  238. msg.setInformativeText(text2)
  239. if question:
  240. msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
  241. else:
  242. msg.setStandardButtons(QMessageBox.Ok)
  243. retval = msg.exec_()
  244. if retval == QMessageBox.Yes:
  245. return True
  246. else:
  247. return False
  248. def sendRequest(self, host, headers, path, content = None):
  249. cmdline = 'curl -s -m 1'
  250. for h in headers:
  251. cmdline += " -H \"" + h + "\""
  252. if content == None:
  253. cmdline += " -X GET"
  254. else:
  255. cmdline += " -X POST"
  256. cmdline += " -d '" + content + "'"
  257. cmdline += " http://" + host + "/api/" + path
  258. r = subprocess.run(cmdline, shell=True, capture_output=True, timeout=10, text=True)
  259. return r.stdout
  260. def sendPostRequest(self, host, key, path, content):
  261. headers = [ "Content-Type: application/json",
  262. "X-Api-Key: " + key ]
  263. return self.sendRequest(host, headers, path, content)
  264. def sendGetRequest(self, host, key, path):
  265. headers = [ "X-Api-Key: " + key ]
  266. return self.sendRequest(host, headers, path)
  267. def getTemperatureString(self, host, key):
  268. r = self.sendGetRequest(host, key, "printer")
  269. s = ""
  270. try:
  271. rd = json.loads(r)
  272. if ("state" in rd) and ("text" in rd["state"]):
  273. s += rd["state"]["text"]
  274. if "temperature" in rd:
  275. s += " - "
  276. if "temperature" in rd:
  277. if "bed" in rd["temperature"]:
  278. if "actual" in rd["temperature"]["bed"]:
  279. s += "B"
  280. s += "%.1f" % rd["temperature"]["bed"]["actual"]
  281. if "target" in rd["temperature"]["bed"]:
  282. s += "/"
  283. s += "%.1f" % rd["temperature"]["bed"]["target"]
  284. s += " "
  285. if "tool0" in rd["temperature"]:
  286. if "actual" in rd["temperature"]["tool0"]:
  287. s += "T"
  288. s += "%.1f" % rd["temperature"]["tool0"]["actual"]
  289. if "target" in rd["temperature"]["tool0"]:
  290. s += "/"
  291. s += "%.1f" % rd["temperature"]["tool0"]["target"]
  292. s += " "
  293. if "tool1" in rd["temperature"]:
  294. if "actual" in rd["temperature"]["tool1"]:
  295. s += "T"
  296. s += "%.1f" % rd["temperature"]["tool1"]["actual"]
  297. if "target" in rd["temperature"]["tool1"]:
  298. s += "/"
  299. s += "%.1f" % rd["temperature"]["tool1"]["target"]
  300. s += " "
  301. except json.JSONDecodeError:
  302. pass
  303. return s.strip()
  304. def getState(self, host, key):
  305. r = self.sendGetRequest(host, key, "job")
  306. try:
  307. rd = json.loads(r)
  308. if "state" in rd:
  309. return rd["state"]
  310. except json.JSONDecodeError:
  311. pass
  312. return "Unknown"
  313. def getProgress(self, host, key):
  314. r = self.sendGetRequest(host, key, "job")
  315. try:
  316. rd = json.loads(r)
  317. if "progress" in rd:
  318. return rd["progress"]
  319. except json.JSONDecodeError:
  320. pass
  321. return "Unknown"
  322. def getName(self, host, key):
  323. r = self.sendGetRequest(host, key, "printerprofiles")
  324. try:
  325. rd = json.loads(r)
  326. if "profiles" in rd:
  327. p = next(iter(rd["profiles"]))
  328. if "name" in rd["profiles"][p]:
  329. return rd["profiles"][p]["name"]
  330. except json.JSONDecodeError:
  331. pass
  332. return host
  333. def getMethod(self, host, key):
  334. r = self.sendGetRequest(host, key, "plugin/psucontrol")
  335. try:
  336. rd = json.loads(r)
  337. if "isPSUOn" in rd:
  338. return "psucontrol"
  339. except json.JSONDecodeError:
  340. pass
  341. r = self.sendGetRequest(host, key, "system/commands/custom")
  342. try:
  343. rd = json.loads(r)
  344. for c in rd:
  345. if "action" in c:
  346. # we have some custom commands and no psucontrol
  347. # so lets try to use that instead of skipping
  348. # the printer completely with 'unknown'
  349. return "system"
  350. except json.JSONDecodeError:
  351. pass
  352. return "unknown"
  353. def getSystemCommands(self, host, key):
  354. l = []
  355. r = self.sendGetRequest(host, key, "system/commands/custom")
  356. try:
  357. rd = json.loads(r)
  358. if len(rd) > 0:
  359. print("system commands available for " + host + ":")
  360. for c in rd:
  361. if "action" in c:
  362. print(" - " + c["action"])
  363. l.append(c["action"])
  364. except json.JSONDecodeError:
  365. pass
  366. return l
  367. def setPSUControl(self, host, key, state):
  368. cmd = "turnPSUOff"
  369. if state:
  370. cmd = "turnPSUOn"
  371. return self.sendPostRequest(host, key, "plugin/psucontrol", '{ "command":"' + cmd + '" }')
  372. def setSystemCommand(self, host, key, cmd):
  373. cmd = urllib.parse.quote(cmd)
  374. return self.sendPostRequest(host, key, "system/commands/custom/" + cmd, '')
  375. def exit(self):
  376. QCoreApplication.quit()
  377. def printerSystemCommandAction(self, item, index):
  378. if "off" in item[2][index].lower():
  379. state = self.getState(item[0], item[1])
  380. if state in self.statesWithWarning:
  381. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to run '" + item[2][index] + "'?", True, True) == True:
  382. self.setSystemCommand(item[0], item[1], item[2][index])
  383. else:
  384. return
  385. self.setSystemCommand(item[0], item[1], item[2][index])
  386. def printerOnAction(self, item):
  387. self.setPSUControl(item[0], item[1], True)
  388. def printerOffAction(self, item):
  389. state = self.getState(item[0], item[1])
  390. if state in self.statesWithWarning:
  391. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to turn it off?", True, True) == True:
  392. self.setPSUControl(item[0], item[1], False)
  393. else:
  394. self.setPSUControl(item[0], item[1], False)
  395. def printerWebAction(self, item):
  396. self.openBrowser(item[0])
  397. def printerStatusAction(self, item):
  398. progress = self.getProgress(item[0], item[1])
  399. s = ""
  400. warning = False
  401. if ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress) and (progress["completion"] != None) and (progress["printTime"] != None) and (progress["printTimeLeft"] != None):
  402. s = "%.1f%% Completion\n" % progress["completion"]
  403. s += "Printing since " + time.strftime("%H:%M:%S", time.gmtime(progress["printTime"])) + "\n"
  404. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTimeLeft"])) + " left"
  405. elif ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress):
  406. s = "No job is currently running"
  407. else:
  408. s = "Could not read printer status!"
  409. warning = True
  410. t = self.getTemperatureString(item[0], item[1])
  411. if len(t) > 0:
  412. s += "\n" + t
  413. self.showDialog("OctoTray Status", s, None, False, warning)
  414. def printerWebcamAction(self, item):
  415. for cw in self.camWindows:
  416. if cw.getHost() == item[0]:
  417. cw.show()
  418. cw.activateWindow()
  419. return
  420. window = CamWindow(self, self.name, self.icon, self.app, self.manager, item)
  421. self.camWindows.append(window)
  422. screenGeometry = QDesktopWidget().screenGeometry()
  423. width = screenGeometry.width()
  424. height = screenGeometry.height()
  425. x = (width - window.width()) / 2
  426. y = (height - window.height()) / 2
  427. window.setGeometry(int(x), int(y), int(window.width()), int(window.height()))
  428. window.show()
  429. window.activateWindow()
  430. def removeWebcamWindow(self, window):
  431. self.camWindows.remove(window)
  432. if __name__ == "__main__":
  433. tray = OctoTray()