Linux PyQt tray application to control OctoPrint instances
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

octotray 18KB

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