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

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