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.py 43KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203
  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 string
  15. import urllib.parse
  16. import urllib.request
  17. import signal
  18. import operator
  19. import socket
  20. from os import path
  21. from PyQt5 import QtWidgets, QtGui, QtCore, QtNetwork
  22. from PyQt5.QtWidgets import QSystemTrayIcon, QAction, QMenu, QMessageBox, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QDesktopWidget, QSizePolicy, QSlider, QLayout, QTableWidget, QTableWidgetItem, QPushButton, QApplication, QLineEdit, QGridLayout
  23. from PyQt5.QtGui import QIcon, QPixmap, QImageReader, QDesktopServices, QFontDatabase, QCursor, QIntValidator
  24. from PyQt5.QtCore import QCoreApplication, QSettings, QUrl, QTimer, QSize, Qt, QSettings
  25. class SettingsWindow(QWidget):
  26. columns = [ "Hostname", "API Key", "Tool Preheat", "Bed Preheat" ]
  27. presets = [ "octopi.local", "000000000_API_KEY_HERE_000000000", "0", "0" ]
  28. def __init__(self, parent, *args, **kwargs):
  29. super(SettingsWindow, self).__init__(*args, **kwargs)
  30. self.parent = parent
  31. self.setWindowTitle(parent.name + " Settings")
  32. self.setWindowIcon(parent.icon)
  33. box = QVBoxLayout()
  34. self.setLayout(box)
  35. staticSettings = QGridLayout()
  36. box.addLayout(staticSettings, 0)
  37. self.jogSpeedText = QLabel("Jog Speed")
  38. staticSettings.addWidget(self.jogSpeedText, 0, 0)
  39. self.jogSpeed = QLineEdit(str(self.parent.jogMoveSpeed))
  40. self.jogSpeed.setValidator(QIntValidator(1, 6000))
  41. staticSettings.addWidget(self.jogSpeed, 0, 1)
  42. self.jogSpeedUnitText = QLabel("mm/min")
  43. staticSettings.addWidget(self.jogSpeedUnitText, 0, 2)
  44. self.jogLengthText = QLabel("Jog Length")
  45. staticSettings.addWidget(self.jogLengthText, 1, 0)
  46. self.jogLength = QLineEdit(str(self.parent.jogMoveLength))
  47. self.jogLength.setValidator(QIntValidator(1, 100))
  48. staticSettings.addWidget(self.jogLength, 1, 1)
  49. self.jogLengthUnitText = QLabel("mm")
  50. staticSettings.addWidget(self.jogLengthUnitText, 1, 2)
  51. helpText = "Usage:\n"
  52. helpText += "1st Column: Printer Hostname or IP address\n"
  53. helpText += "2nd Column: OctoPrint API Key (32 char hexadecimal)\n"
  54. helpText += "3rd Column: Tool Preheat Temperature (0 to disable)\n"
  55. helpText += "4th Column: Bed Preheat Temperature (0 to disable)"
  56. self.helpText = QLabel(helpText)
  57. box.addWidget(self.helpText, 0)
  58. box.setAlignment(self.helpText, Qt.AlignHCenter)
  59. buttons = QHBoxLayout()
  60. box.addLayout(buttons, 0)
  61. self.add = QPushButton("&Add Printer")
  62. self.add.clicked.connect(self.addPrinter)
  63. buttons.addWidget(self.add)
  64. self.remove = QPushButton("&Remove Printer")
  65. self.remove.clicked.connect(self.removePrinter)
  66. buttons.addWidget(self.remove)
  67. printers = self.parent.readSettings()
  68. self.rows = len(printers)
  69. self.table = QTableWidget(self.rows, len(self.columns))
  70. box.addWidget(self.table, 1)
  71. for i in range(0, self.rows):
  72. p = printers[i]
  73. for j in range(0, len(self.columns)):
  74. text = p[j]
  75. if (j >= 2) and (j <= 3) and (text == None):
  76. text = "0"
  77. item = QTableWidgetItem(text)
  78. self.table.setItem(i, j, item)
  79. if j == 1:
  80. font = item.font()
  81. font.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family())
  82. item.setFont(font)
  83. buttons2 = QHBoxLayout()
  84. box.addLayout(buttons2, 0)
  85. self.up = QPushButton("Move &Up")
  86. self.up.clicked.connect(self.moveUp)
  87. buttons2.addWidget(self.up)
  88. self.down = QPushButton("Move &Down")
  89. self.down.clicked.connect(self.moveDown)
  90. buttons2.addWidget(self.down)
  91. self.openWeb = QPushButton("&Open Web UI of selected")
  92. self.openWeb.clicked.connect(self.openWebUI)
  93. box.addWidget(self.openWeb, 0)
  94. self.table.setHorizontalHeaderLabels(self.columns)
  95. self.table.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
  96. self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows);
  97. self.table.resizeColumnsToContents()
  98. if self.rows <= 0:
  99. self.addPrinter()
  100. def tableToList(self):
  101. printers = []
  102. for i in range(0, self.rows):
  103. p = []
  104. for j in range(0, len(self.columns)):
  105. text = self.table.item(i, j).text()
  106. if (j >= 2) and (j <= 3) and (text == "0"):
  107. text = None
  108. p.append(text)
  109. printers.append(p)
  110. return printers
  111. def settingsValid(self, printers):
  112. for p in printers:
  113. # p[0] needs to be valid hostname or IP
  114. # TODO
  115. # p[1] needs to be valid API key (hexadecimal, 32 chars)
  116. if (len(p[1]) != 32) or not all(c in string.hexdigits for c in p[1]):
  117. return (False, "API Key not 32-digit hexadecimal")
  118. # p[2] and p[3] need to be integer temperatures (0...999)
  119. for s in [ p[2], p[3] ]:
  120. if s == None:
  121. s = "0"
  122. if (len(s) < 1) or (len(s) > 3) or not all(c in string.digits for c in s):
  123. return (False, "Temperature not a number from 0...999")
  124. js = int(self.jogSpeed.text())
  125. if (js < 1) or (js > 6000):
  126. return (False, "Jog Speed not a number from 1...6000")
  127. jl = int(self.jogLength.text())
  128. if (jl < 1) or (jl > 100):
  129. return (False, "Jog Length not a number from 1...100")
  130. return (True, "")
  131. def closeEvent(self, event):
  132. oldPrinters = [item[0:len(self.columns)] for item in self.parent.printers]
  133. newPrinters = self.tableToList()
  134. valid, errorText = self.settingsValid(newPrinters)
  135. if valid == False:
  136. r = self.parent.showDialog(self.parent.name + " Settings Invalid", errorText + "!", "Do you want to edit it again?", True, True, False)
  137. if r == True:
  138. event.ignore()
  139. return
  140. else:
  141. self.parent.removeSettingsWindow()
  142. return
  143. js = int(self.jogSpeed.text())
  144. jl = int(self.jogLength.text())
  145. if (oldPrinters != newPrinters) or (js != self.parent.jogMoveSpeed) or (jl != self.parent.jogMoveLength):
  146. r = self.parent.showDialog(self.parent.name + " Settings Changed", "Do you want to save the new configuration?", "This will restart the application!", True, False, False)
  147. if r == True:
  148. self.parent.jogMoveSpeed = js
  149. self.parent.jogMoveLength = jl
  150. self.parent.writeSettings(newPrinters)
  151. self.parent.restartApp()
  152. self.parent.removeSettingsWindow()
  153. def addPrinter(self):
  154. self.rows += 1
  155. self.table.setRowCount(self.rows)
  156. for i in range(0, len(self.columns)):
  157. item = QTableWidgetItem(self.presets[i])
  158. self.table.setItem(self.rows - 1, i, item)
  159. if i == 1:
  160. font = item.font()
  161. font.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family())
  162. item.setFont(font)
  163. self.table.resizeColumnsToContents()
  164. self.table.setCurrentItem(self.table.item(self.rows - 1, 0))
  165. def removePrinter(self):
  166. r = self.table.currentRow()
  167. if (r >= 0) and (r < self.rows):
  168. self.rows -= 1
  169. self.table.removeRow(r)
  170. self.table.setCurrentItem(self.table.item(min(r, self.rows - 1), 0))
  171. def moveUp(self):
  172. i = self.table.currentRow()
  173. if i <= 0:
  174. return
  175. host = self.table.item(i, 0).text()
  176. key = self.table.item(i, 1).text()
  177. self.table.item(i, 0).setText(self.table.item(i - 1, 0).text())
  178. self.table.item(i, 1).setText(self.table.item(i - 1, 1).text())
  179. self.table.item(i - 1, 0).setText(host)
  180. self.table.item(i - 1, 1).setText(key)
  181. self.table.setCurrentItem(self.table.item(i - 1, 0))
  182. def moveDown(self):
  183. i = self.table.currentRow()
  184. if i >= (self.rows - 1):
  185. return
  186. host = self.table.item(i, 0).text()
  187. key = self.table.item(i, 1).text()
  188. self.table.item(i, 0).setText(self.table.item(i + 1, 0).text())
  189. self.table.item(i, 1).setText(self.table.item(i + 1, 1).text())
  190. self.table.item(i + 1, 0).setText(host)
  191. self.table.item(i + 1, 1).setText(key)
  192. self.table.setCurrentItem(self.table.item(i + 1, 0))
  193. def openWebUI(self):
  194. host = self.table.item(self.table.currentRow(), 0).text()
  195. self.parent.openBrowser(host)
  196. class AspectRatioPixmapLabel(QLabel):
  197. def __init__(self, *args, **kwargs):
  198. super(AspectRatioPixmapLabel, self).__init__(*args, **kwargs)
  199. self.setMinimumSize(1, 1)
  200. self.setScaledContents(False)
  201. self.pix = QPixmap(0, 0)
  202. def setPixmap(self, p):
  203. self.pix = p
  204. super(AspectRatioPixmapLabel, self).setPixmap(self.scaledPixmap())
  205. def heightForWidth(self, width):
  206. if self.pix.isNull():
  207. return self.height()
  208. else:
  209. return (self.pix.height() * width) / self.pix.width()
  210. def sizeHint(self):
  211. w = self.width()
  212. return QSize(int(w), int(self.heightForWidth(w)))
  213. def scaledPixmap(self):
  214. return self.pix.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
  215. def resizeEvent(self, e):
  216. if not self.pix.isNull():
  217. super(AspectRatioPixmapLabel, self).setPixmap(self.scaledPixmap())
  218. class CamWindow(QWidget):
  219. reloadDelayDefault = 1000 # in ms
  220. statusDelayFactor = 2
  221. reloadOn = True
  222. sliderFactor = 100
  223. def __init__(self, parent, printer, *args, **kwargs):
  224. super(CamWindow, self).__init__(*args, **kwargs)
  225. self.app = parent.app
  226. self.manager = parent.manager
  227. self.manager.finished.connect(self.handleResponse)
  228. self.parent = parent
  229. self.printer = printer
  230. self.host = self.printer[0]
  231. self.url = "http://" + self.host + ":8080/?action=snapshot"
  232. self.setWindowTitle(parent.name + " Webcam Stream")
  233. self.setWindowIcon(parent.icon)
  234. box = QVBoxLayout()
  235. self.setLayout(box)
  236. label = QLabel(self.url)
  237. box.addWidget(label, 0)
  238. box.setAlignment(label, Qt.AlignHCenter)
  239. slide = QHBoxLayout()
  240. box.addLayout(slide, 0)
  241. self.slideStaticLabel = QLabel("Refresh")
  242. slide.addWidget(self.slideStaticLabel, 0)
  243. self.slider = QSlider(Qt.Horizontal)
  244. self.slider.setMinimum(int(100 / self.sliderFactor))
  245. self.slider.setMaximum(int(2000 / self.sliderFactor))
  246. self.slider.setTickInterval(int(100 / self.sliderFactor))
  247. self.slider.setPageStep(int(100 / self.sliderFactor))
  248. self.slider.setSingleStep(int(100 / self.sliderFactor))
  249. self.slider.setTickPosition(QSlider.TicksBelow)
  250. self.slider.setValue(int(self.reloadDelayDefault / self.sliderFactor))
  251. self.slider.valueChanged.connect(self.sliderChanged)
  252. slide.addWidget(self.slider, 1)
  253. self.slideLabel = QLabel(str(self.reloadDelayDefault) + "ms")
  254. slide.addWidget(self.slideLabel, 0)
  255. self.img = AspectRatioPixmapLabel()
  256. self.img.setPixmap(QPixmap(640, 480))
  257. box.addWidget(self.img, 1)
  258. self.statusLabel = QLabel("Status: unavailable")
  259. box.addWidget(self.statusLabel, 0)
  260. box.setAlignment(self.statusLabel, Qt.AlignHCenter)
  261. self.method = self.parent.getMethod(self.printer[0], self.printer[1])
  262. if self.method != "unknown":
  263. controls_power = QHBoxLayout()
  264. box.addLayout(controls_power, 0)
  265. self.turnOnButton = QPushButton("Turn O&n")
  266. self.turnOnButton.clicked.connect(self.turnOn)
  267. controls_power.addWidget(self.turnOnButton)
  268. self.turnOffButton = QPushButton("Turn O&ff")
  269. self.turnOffButton.clicked.connect(self.turnOff)
  270. controls_power.addWidget(self.turnOffButton)
  271. controls_temp = QHBoxLayout()
  272. box.addLayout(controls_temp, 0)
  273. self.cooldownButton = QPushButton("&Cooldown")
  274. self.cooldownButton.clicked.connect(self.cooldown)
  275. controls_temp.addWidget(self.cooldownButton)
  276. self.preheatToolButton = QPushButton("Preheat &Tool")
  277. self.preheatToolButton.clicked.connect(self.preheatTool)
  278. controls_temp.addWidget(self.preheatToolButton)
  279. self.preheatBedButton = QPushButton("Preheat &Bed")
  280. self.preheatBedButton.clicked.connect(self.preheatBed)
  281. controls_temp.addWidget(self.preheatBedButton)
  282. controls_home = QHBoxLayout()
  283. box.addLayout(controls_home, 0)
  284. self.homeAllButton = QPushButton("Home &All")
  285. self.homeAllButton.clicked.connect(self.homeAll)
  286. controls_home.addWidget(self.homeAllButton, 1)
  287. self.homeXButton = QPushButton("Home &X")
  288. self.homeXButton.clicked.connect(self.homeX)
  289. controls_home.addWidget(self.homeXButton, 0)
  290. self.homeYButton = QPushButton("Home &Y")
  291. self.homeYButton.clicked.connect(self.homeY)
  292. controls_home.addWidget(self.homeYButton, 0)
  293. self.homeZButton = QPushButton("Home &Z")
  294. self.homeZButton.clicked.connect(self.homeZ)
  295. controls_home.addWidget(self.homeZButton, 0)
  296. controls_move = QHBoxLayout()
  297. box.addLayout(controls_move, 0)
  298. self.XPButton = QPushButton("X+")
  299. self.XPButton.clicked.connect(self.moveXP)
  300. controls_move.addWidget(self.XPButton)
  301. self.XMButton = QPushButton("X-")
  302. self.XMButton.clicked.connect(self.moveXM)
  303. controls_move.addWidget(self.XMButton)
  304. self.YPButton = QPushButton("Y+")
  305. self.YPButton.clicked.connect(self.moveYP)
  306. controls_move.addWidget(self.YPButton)
  307. self.YMButton = QPushButton("Y-")
  308. self.YMButton.clicked.connect(self.moveYM)
  309. controls_move.addWidget(self.YMButton)
  310. self.ZPButton = QPushButton("Z+")
  311. self.ZPButton.clicked.connect(self.moveZP)
  312. controls_move.addWidget(self.ZPButton)
  313. self.ZMButton = QPushButton("Z-")
  314. self.ZMButton.clicked.connect(self.moveZM)
  315. controls_move.addWidget(self.ZMButton)
  316. controls_job = QHBoxLayout()
  317. box.addLayout(controls_job, 0)
  318. self.PauseButton = QPushButton("Pause/Resume")
  319. self.PauseButton.clicked.connect(self.pauseResume)
  320. controls_job.addWidget(self.PauseButton)
  321. self.CancelButton = QPushButton("Cancel Job")
  322. self.CancelButton.clicked.connect(self.cancelJob)
  323. controls_job.addWidget(self.CancelButton)
  324. self.loadImage()
  325. self.loadStatus()
  326. def pauseResume(self):
  327. self.parent.printerPauseResume(self.printer)
  328. def cancelJob(self):
  329. self.parent.printerJobCancel(self.printer)
  330. def moveXP(self):
  331. self.parent.printerMoveAction(self.printer, "x", int(self.parent.jogMoveLength), True)
  332. def moveXM(self):
  333. self.parent.printerMoveAction(self.printer, "x", -1 * int(self.parent.jogMoveLength), True)
  334. def moveYP(self):
  335. self.parent.printerMoveAction(self.printer, "y", int(self.parent.jogMoveLength), True)
  336. def moveYM(self):
  337. self.parent.printerMoveAction(self.printer, "y", -1 * int(self.parent.jogMoveLength), True)
  338. def moveZP(self):
  339. self.parent.printerMoveAction(self.printer, "z", int(self.parent.jogMoveLength), True)
  340. def moveZM(self):
  341. self.parent.printerMoveAction(self.printer, "z", -1 * int(self.parent.jogMoveLength), True)
  342. def homeX(self):
  343. self.parent.printerHomingAction(self.printer, "x")
  344. def homeY(self):
  345. self.parent.printerHomingAction(self.printer, "y")
  346. def homeZ(self):
  347. self.parent.printerHomingAction(self.printer, "z")
  348. def homeAll(self):
  349. self.parent.printerHomingAction(self.printer, "xyz")
  350. def turnOn(self):
  351. if self.method == "psucontrol":
  352. self.parent.printerOnAction(self.printer)
  353. elif self.method == "system":
  354. cmds = self.parent.getSystemCommands(self.printer[0], self.printer[1])
  355. for cmd in cmds:
  356. if "on" in cmd:
  357. self.parent.setSystemCommand(self.printer[0], self.printer[1], cmd)
  358. break
  359. def turnOff(self):
  360. if self.method == "psucontrol":
  361. self.parent.printerOffAction(self.printer)
  362. elif self.method == "system":
  363. cmds = self.parent.getSystemCommands(self.printer[0], self.printer[1])
  364. for cmd in cmds:
  365. if "off" in cmd:
  366. self.parent.setSystemCommand(self.printer[0], self.printer[1], cmd)
  367. break
  368. def cooldown(self):
  369. self.parent.printerCooldown(self.printer)
  370. def preheatTool(self):
  371. self.parent.printerHeatTool(self.printer)
  372. def preheatBed(self):
  373. self.parent.printerHeatBed(self.printer)
  374. def getHost(self):
  375. return self.host
  376. def sliderChanged(self):
  377. self.slideLabel.setText(str(self.slider.value() * self.sliderFactor) + "ms")
  378. def closeEvent(self, event):
  379. self.reloadOn = False
  380. self.url = ""
  381. self.parent.removeWebcamWindow(self)
  382. def scheduleLoadImage(self):
  383. if self.reloadOn:
  384. QTimer.singleShot(self.slider.value() * self.sliderFactor, self.loadImage)
  385. def scheduleLoadStatus(self):
  386. if self.reloadOn:
  387. QTimer.singleShot(self.slider.value() * self.sliderFactor * self.statusDelayFactor, self.loadStatus)
  388. def loadImage(self):
  389. url = QUrl(self.url)
  390. request = QtNetwork.QNetworkRequest(url)
  391. self.manager.get(request)
  392. def loadStatus(self):
  393. s = "Status: "
  394. t = self.parent.getTemperatureString(self.host, self.printer[1])
  395. if len(t) > 0:
  396. s += t
  397. else:
  398. s += "Unknown"
  399. progress = self.parent.getProgress(self.host, self.printer[1])
  400. if ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress) and (progress["completion"] != None) and (progress["printTime"] != None) and (progress["printTimeLeft"] != None):
  401. s += " - %.1f%%" % progress["completion"]
  402. s += " - runtime "
  403. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTime"]))
  404. s += " - "
  405. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTimeLeft"])) + " left"
  406. self.statusLabel.setText(s)
  407. self.scheduleLoadStatus()
  408. def handleResponse(self, reply):
  409. if reply.url().url() == self.url:
  410. if reply.error() == QtNetwork.QNetworkReply.NoError:
  411. reader = QImageReader(reply)
  412. reader.setAutoTransform(True)
  413. image = reader.read()
  414. if image != None:
  415. if image.colorSpace().isValid():
  416. image.convertToColorSpace(QColorSpace.SRgb)
  417. self.img.setPixmap(QPixmap.fromImage(image))
  418. self.scheduleLoadImage()
  419. else:
  420. print("Error decoding image: " + reader.errorString())
  421. else:
  422. print("Error loading image: " + reply.errorString())
  423. class MainWindow(QWidget):
  424. def __init__(self, parent, *args, **kwargs):
  425. super(MainWindow, self).__init__(*args, **kwargs)
  426. self.parent = parent
  427. self.mainLayout = QVBoxLayout()
  428. self.setLayout(self.mainLayout)
  429. self.mainLayout.addWidget(self.parent.menu)
  430. self.parent.menu.aboutToHide.connect(self.aboutToHide)
  431. def aboutToHide(self):
  432. self.parent.menu.show()
  433. def closeEvent(self, event):
  434. self.parent.exit()
  435. event.accept()
  436. class OctoTray():
  437. name = "OctoTray"
  438. vendor = "xythobuz"
  439. version = "0.4"
  440. iconName = "octotray_icon.png"
  441. iconPaths = [
  442. path.abspath(path.dirname(__file__)),
  443. "data",
  444. "/usr/share/pixmaps",
  445. ".",
  446. "..",
  447. "../data"
  448. ]
  449. networkTimeout = 2.0 # in s
  450. # list of lists, inner lists contain printer data:
  451. # first elements as in SettingsWindow.columns
  452. # 0=host 1=key 2=tool-preheat 3=bed-preheat
  453. # rest used for system-commands, menu, actions
  454. printers = []
  455. statesWithWarning = [
  456. "Printing", "Pausing", "Paused"
  457. ]
  458. camWindows = []
  459. settingsWindow = None
  460. # default, can be overridden in config
  461. jogMoveSpeed = 10 * 60 # in mm/min
  462. jogMoveLength = 10 # in mm
  463. def __init__(self, app, inSysTray):
  464. QCoreApplication.setApplicationName(self.name)
  465. self.app = app
  466. self.inSysTray = inSysTray
  467. self.manager = QtNetwork.QNetworkAccessManager()
  468. self.menu = QMenu()
  469. self.printers = self.readSettings()
  470. unknownCount = 0
  471. for p in self.printers:
  472. method = self.getMethod(p[0], p[1])
  473. print("Printer " + p[0] + " has method " + method)
  474. if method == "unknown":
  475. unknownCount += 1
  476. action = QAction(p[0])
  477. action.setEnabled(False)
  478. p.append(action)
  479. self.menu.addAction(action)
  480. continue
  481. commands = self.getSystemCommands(p[0], p[1])
  482. p.append(commands)
  483. menu = QMenu(self.getName(p[0], p[1]))
  484. p.append(menu)
  485. self.menu.addMenu(menu)
  486. if method == "psucontrol":
  487. action = QAction("Turn On PSU")
  488. action.triggered.connect(lambda chk, x=p: self.printerOnAction(x))
  489. p.append(action)
  490. menu.addAction(action)
  491. action = QAction("Turn Off PSU")
  492. action.triggered.connect(lambda chk, x=p: self.printerOffAction(x))
  493. p.append(action)
  494. menu.addAction(action)
  495. for i in range(0, len(commands)):
  496. action = QAction(commands[i].title())
  497. action.triggered.connect(lambda chk, x=p, y=i: self.printerSystemCommandAction(x, y))
  498. p.append(action)
  499. menu.addAction(action)
  500. if (p[2] != None) or (p[3] != None):
  501. menu.addSeparator()
  502. if p[2] != None:
  503. action = QAction("Preheat Tool")
  504. action.triggered.connect(lambda chk, x=p: self.printerHeatTool(x))
  505. p.append(action)
  506. menu.addAction(action)
  507. if p[3] != None:
  508. action = QAction("Preheat Bed")
  509. action.triggered.connect(lambda chk, x=p: self.printerHeatBed(x))
  510. p.append(action)
  511. menu.addAction(action)
  512. if (p[2] != None) or (p[3] != None):
  513. action = QAction("Cooldown")
  514. action.triggered.connect(lambda chk, x=p: self.printerCooldown(x))
  515. p.append(action)
  516. menu.addAction(action)
  517. menu.addSeparator()
  518. fileMenu = QMenu("Recent Files")
  519. p.append(fileMenu)
  520. menu.addMenu(fileMenu)
  521. files = self.getRecentFiles(p[0], p[1], 10)
  522. for f in files:
  523. fileName, filePath = f
  524. action = QAction(fileName)
  525. action.triggered.connect(lambda chk, x=p, y=filePath: self.printerFilePrint(x, y))
  526. p.append(action)
  527. fileMenu.addAction(action)
  528. action = QAction("Get Status")
  529. action.triggered.connect(lambda chk, x=p: self.printerStatusAction(x))
  530. p.append(action)
  531. menu.addAction(action)
  532. action = QAction("Show Webcam")
  533. action.triggered.connect(lambda chk, x=p: self.printerWebcamAction(x))
  534. p.append(action)
  535. menu.addAction(action)
  536. action = QAction("Open Web UI")
  537. action.triggered.connect(lambda chk, x=p: self.printerWebAction(x))
  538. p.append(action)
  539. menu.addAction(action)
  540. self.menu.addSeparator()
  541. self.settingsAction = QAction("&Settings")
  542. self.settingsAction.triggered.connect(self.showSettingsAction)
  543. self.menu.addAction(self.settingsAction)
  544. self.refreshAction = QAction("&Refresh")
  545. self.refreshAction.triggered.connect(self.restartApp)
  546. self.menu.addAction(self.refreshAction)
  547. self.quitAction = QAction("&Quit")
  548. self.quitAction.triggered.connect(self.exit)
  549. self.menu.addAction(self.quitAction)
  550. self.iconPathName = None
  551. for p in self.iconPaths:
  552. if os.path.isfile(path.join(p, self.iconName)):
  553. self.iconPathName = path.join(p, self.iconName)
  554. break
  555. if self.iconPathName == None:
  556. self.showDialog("OctoTray Error", "Icon file has not been found!", "", False, False, True)
  557. sys.exit(0)
  558. self.icon = QIcon()
  559. self.pic = QPixmap(32, 32)
  560. self.pic.load(self.iconPathName)
  561. self.icon = QIcon(self.pic)
  562. if self.inSysTray:
  563. self.trayIcon = QSystemTrayIcon(self.icon)
  564. self.trayIcon.setToolTip(self.name + " " + self.version)
  565. self.trayIcon.setContextMenu(self.menu)
  566. self.trayIcon.activated.connect(self.showHide)
  567. self.trayIcon.setVisible(True)
  568. else:
  569. self.mainWindow = MainWindow(self)
  570. self.mainWindow.show()
  571. self.mainWindow.activateWindow()
  572. screenGeometry = QDesktopWidget().screenGeometry()
  573. x = (screenGeometry.width() - self.mainWindow.width()) / 2
  574. y = (screenGeometry.height() - self.mainWindow.height()) / 2
  575. x += screenGeometry.x()
  576. y += screenGeometry.y()
  577. self.mainWindow.setGeometry(int(x), int(y), int(self.mainWindow.width()), int(self.mainWindow.height()))
  578. def showHide(self, activationReason):
  579. if activationReason == QSystemTrayIcon.Trigger:
  580. self.menu.popup(QCursor.pos())
  581. elif activationReason == QSystemTrayIcon.MiddleClick:
  582. if len(self.printers) > 0:
  583. self.printerWebcamAction(self.printers[0])
  584. def readSettings(self):
  585. settings = QSettings(self.vendor, self.name)
  586. js = settings.value("jog_speed")
  587. if js != None:
  588. self.jogMoveSpeed = int(js)
  589. jl = settings.value("jog_length")
  590. if jl != None:
  591. self.jogMoveLength = int(jl)
  592. printers = []
  593. l = settings.beginReadArray("printers")
  594. for i in range(0, l):
  595. settings.setArrayIndex(i)
  596. p = []
  597. p.append(settings.value("host"))
  598. p.append(settings.value("key"))
  599. p.append(settings.value("tool_preheat"))
  600. p.append(settings.value("bed_preheat"))
  601. printers.append(p)
  602. settings.endArray()
  603. return printers
  604. def writeSettings(self, printers):
  605. settings = QSettings(self.vendor, self.name)
  606. settings.setValue("jog_speed", self.jogMoveSpeed)
  607. settings.setValue("jog_length", self.jogMoveLength)
  608. settings.remove("printers")
  609. settings.beginWriteArray("printers")
  610. for i in range(0, len(printers)):
  611. p = printers[i]
  612. settings.setArrayIndex(i)
  613. settings.setValue("host", p[0])
  614. settings.setValue("key", p[1])
  615. settings.setValue("tool_preheat", p[2])
  616. settings.setValue("bed_preheat", p[3])
  617. settings.endArray()
  618. del settings
  619. def openBrowser(self, url):
  620. QDesktopServices.openUrl(QUrl("http://" + url))
  621. def showDialog(self, title, text1, text2 = "", question = False, warning = False, error = False):
  622. msg = QMessageBox()
  623. if error:
  624. msg.setIcon(QMessageBox.Critical)
  625. elif warning:
  626. msg.setIcon(QMessageBox.Warning)
  627. elif question:
  628. msg.setIcon(QMessageBox.Question)
  629. else:
  630. msg.setIcon(QMessageBox.Information)
  631. msg.setWindowTitle(title)
  632. msg.setText(text1)
  633. if text2 is not None:
  634. msg.setInformativeText(text2)
  635. if question:
  636. msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
  637. else:
  638. msg.setStandardButtons(QMessageBox.Ok)
  639. retval = msg.exec_()
  640. if retval == QMessageBox.Yes:
  641. return True
  642. else:
  643. return False
  644. def sendRequest(self, host, headers, path, content = None):
  645. url = "http://" + host + "/api/" + path
  646. if content == None:
  647. request = urllib.request.Request(url, None, headers)
  648. else:
  649. data = content.encode('ascii')
  650. request = urllib.request.Request(url, data, headers)
  651. try:
  652. with urllib.request.urlopen(request, None, self.networkTimeout) as response:
  653. text = response.read()
  654. return text
  655. except (urllib.error.URLError, urllib.error.HTTPError) as error:
  656. print("Error requesting URL \"" + url + "\": \"" + str(error) + "\"")
  657. return "error"
  658. except socket.timeout:
  659. print("Timeout waiting for response to \"" + url + "\"")
  660. return "timeout"
  661. def sendPostRequest(self, host, key, path, content):
  662. headers = {
  663. "Content-Type": "application/json",
  664. "X-Api-Key": key
  665. }
  666. return self.sendRequest(host, headers, path, content)
  667. def sendGetRequest(self, host, key, path):
  668. headers = {
  669. "X-Api-Key": key
  670. }
  671. return self.sendRequest(host, headers, path)
  672. def getTemperatureIsSafe(self, host, key):
  673. r = self.sendGetRequest(host, key, "printer")
  674. try:
  675. rd = json.loads(r)
  676. if "temperature" in rd:
  677. if ("tool0" in rd["temperature"]) and ("actual" in rd["temperature"]["tool0"]):
  678. if rd["temperature"]["tool0"]["actual"] > 50.0:
  679. return False
  680. if ("tool1" in rd["temperature"]) and ("actual" in rd["temperature"]["tool1"]):
  681. if rd["temperature"]["tool1"]["actual"] > 50.0:
  682. return False
  683. except json.JSONDecodeError:
  684. pass
  685. return True
  686. def getTemperatureString(self, host, key):
  687. r = self.sendGetRequest(host, key, "printer")
  688. s = ""
  689. try:
  690. rd = json.loads(r)
  691. if ("state" in rd) and ("text" in rd["state"]):
  692. s += rd["state"]["text"]
  693. if "temperature" in rd:
  694. s += " - "
  695. if "temperature" in rd:
  696. if "bed" in rd["temperature"]:
  697. if "actual" in rd["temperature"]["bed"]:
  698. s += "B"
  699. s += "%.1f" % rd["temperature"]["bed"]["actual"]
  700. if "target" in rd["temperature"]["bed"]:
  701. s += "/"
  702. s += "%.1f" % rd["temperature"]["bed"]["target"]
  703. s += " "
  704. if "tool0" in rd["temperature"]:
  705. if "actual" in rd["temperature"]["tool0"]:
  706. s += "T"
  707. s += "%.1f" % rd["temperature"]["tool0"]["actual"]
  708. if "target" in rd["temperature"]["tool0"]:
  709. s += "/"
  710. s += "%.1f" % rd["temperature"]["tool0"]["target"]
  711. s += " "
  712. if "tool1" in rd["temperature"]:
  713. if "actual" in rd["temperature"]["tool1"]:
  714. s += "T"
  715. s += "%.1f" % rd["temperature"]["tool1"]["actual"]
  716. if "target" in rd["temperature"]["tool1"]:
  717. s += "/"
  718. s += "%.1f" % rd["temperature"]["tool1"]["target"]
  719. s += " "
  720. except json.JSONDecodeError:
  721. pass
  722. return s.strip()
  723. def getState(self, host, key):
  724. r = self.sendGetRequest(host, key, "job")
  725. try:
  726. rd = json.loads(r)
  727. if "state" in rd:
  728. return rd["state"]
  729. except json.JSONDecodeError:
  730. pass
  731. return "Unknown"
  732. def getProgress(self, host, key):
  733. r = self.sendGetRequest(host, key, "job")
  734. try:
  735. rd = json.loads(r)
  736. if "progress" in rd:
  737. return rd["progress"]
  738. except json.JSONDecodeError:
  739. pass
  740. return "Unknown"
  741. def getName(self, host, key):
  742. r = self.sendGetRequest(host, key, "printerprofiles")
  743. try:
  744. rd = json.loads(r)
  745. if "profiles" in rd:
  746. p = next(iter(rd["profiles"]))
  747. if "name" in rd["profiles"][p]:
  748. return rd["profiles"][p]["name"]
  749. except json.JSONDecodeError:
  750. pass
  751. return host
  752. def getRecentFiles(self, host, key, count):
  753. r = self.sendGetRequest(host, key, "files?recursive=true")
  754. files = []
  755. try:
  756. rd = json.loads(r)
  757. if "files" in rd:
  758. t = [f for f in rd["files"] if "date" in f]
  759. fs = sorted(t, key=operator.itemgetter("date"), reverse=True)
  760. for f in fs[:count]:
  761. files.append((f["name"], f["origin"] + "/" + f["path"]))
  762. except json.JSONDecodeError:
  763. pass
  764. return files
  765. def getMethod(self, host, key):
  766. r = self.sendGetRequest(host, key, "plugin/psucontrol")
  767. if r == "timeout":
  768. return "unknown"
  769. try:
  770. rd = json.loads(r)
  771. if "isPSUOn" in rd:
  772. return "psucontrol"
  773. except json.JSONDecodeError:
  774. pass
  775. r = self.sendGetRequest(host, key, "system/commands/custom")
  776. if r == "timeout":
  777. return "unknown"
  778. try:
  779. rd = json.loads(r)
  780. for c in rd:
  781. if "action" in c:
  782. # we have some custom commands and no psucontrol
  783. # so lets try to use that instead of skipping
  784. # the printer completely with 'unknown'
  785. return "system"
  786. except json.JSONDecodeError:
  787. pass
  788. return "unknown"
  789. def getSystemCommands(self, host, key):
  790. l = []
  791. r = self.sendGetRequest(host, key, "system/commands/custom")
  792. try:
  793. rd = json.loads(r)
  794. if len(rd) > 0:
  795. print("system commands available for " + host + ":")
  796. for c in rd:
  797. if "action" in c:
  798. print(" - " + c["action"])
  799. l.append(c["action"])
  800. except json.JSONDecodeError:
  801. pass
  802. return l
  803. def setPSUControl(self, host, key, state):
  804. cmd = "turnPSUOff"
  805. if state:
  806. cmd = "turnPSUOn"
  807. return self.sendPostRequest(host, key, "plugin/psucontrol", '{ "command":"' + cmd + '" }')
  808. def setSystemCommand(self, host, key, cmd):
  809. cmd = urllib.parse.quote(cmd)
  810. return self.sendPostRequest(host, key, "system/commands/custom/" + cmd, '')
  811. def exit(self):
  812. QCoreApplication.quit()
  813. def printerSystemCommandAction(self, item, index):
  814. if "off" in item[2][index].lower():
  815. state = self.getState(item[0], item[1])
  816. if state in self.statesWithWarning:
  817. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to run '" + item[2][index] + "'?", True, True) == False:
  818. return
  819. safe = self.getTemperatureIsSafe(item[0], item[1])
  820. if safe == False:
  821. if self.showDialog("OctoTray Warning", "The printer seems to still be hot!", "Do you really want to turn it off?", True, True) == False:
  822. return
  823. self.setSystemCommand(item[0], item[1], item[2][index])
  824. def printerOnAction(self, item):
  825. self.setPSUControl(item[0], item[1], True)
  826. def printerOffAction(self, item):
  827. state = self.getState(item[0], item[1])
  828. if state in self.statesWithWarning:
  829. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to turn it off?", True, True) == False:
  830. return
  831. safe = self.getTemperatureIsSafe(item[0], item[1])
  832. if safe == False:
  833. if self.showDialog("OctoTray Warning", "The printer seems to still be hot!", "Do you really want to turn it off?", True, True) == False:
  834. return
  835. self.setPSUControl(item[0], item[1], False)
  836. def printerHomingAction(self, item, axes = "xyz"):
  837. state = self.getState(item[0], item[1])
  838. if state in self.statesWithWarning:
  839. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to home it?", True, True) == False:
  840. return
  841. axes_string = ''
  842. for i in range(0, len(axes)):
  843. axes_string += '"' + str(axes[i]) + '"'
  844. if i < (len(axes) - 1):
  845. axes_string += ', '
  846. self.sendPostRequest(item[0], item[1], "printer/printhead", '{ "command": "home", "axes": [' + axes_string + '] }')
  847. def printerMoveAction(self, printer, axis, dist, relative = True):
  848. state = self.getState(printer[0], printer[1])
  849. if state in self.statesWithWarning:
  850. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to move it?", True, True) == False:
  851. return
  852. absolute = ''
  853. if relative == False:
  854. absolute = ', "absolute": true'
  855. self.sendPostRequest(printer[0], printer[1], "printer/printhead", '{ "command": "jog", "' + str(axis) + '": ' + str(dist) + ', "speed": ' + str(self.jogMoveSpeed) + absolute + ' }')
  856. def printerPauseResume(self, printer):
  857. state = self.getState(printer[0], printer[1])
  858. if state in self.statesWithWarning:
  859. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to pause/resume?", True, True) == False:
  860. return
  861. self.sendPostRequest(printer[0], printer[1], "job", '{ "command": "pause", "action": "toggle" }')
  862. def printerJobCancel(self, printer):
  863. state = self.getState(printer[0], printer[1])
  864. if state in self.statesWithWarning:
  865. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to cancel?", True, True) == False:
  866. return
  867. self.sendPostRequest(printer[0], printer[1], "job", '{ "command": "cancel" }')
  868. def printerWebAction(self, item):
  869. self.openBrowser(item[0])
  870. def printerStatusAction(self, item):
  871. progress = self.getProgress(item[0], item[1])
  872. s = item[0] + "\n"
  873. warning = False
  874. if ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress) and (progress["completion"] != None) and (progress["printTime"] != None) and (progress["printTimeLeft"] != None):
  875. s += "%.1f%% Completion\n" % progress["completion"]
  876. s += "Printing since " + time.strftime("%H:%M:%S", time.gmtime(progress["printTime"])) + "\n"
  877. s += time.strftime("%H:%M:%S", time.gmtime(progress["printTimeLeft"])) + " left"
  878. elif ("completion" in progress) and ("printTime" in progress) and ("printTimeLeft" in progress):
  879. s += "No job is currently running"
  880. else:
  881. s += "Could not read printer status!"
  882. warning = True
  883. t = self.getTemperatureString(item[0], item[1])
  884. if len(t) > 0:
  885. s += "\n" + t
  886. self.showDialog("OctoTray Status", s, None, False, warning)
  887. def printerFilePrint(self, item, path):
  888. self.sendPostRequest(item[0], item[1], "files/" + path, '{ "command": "select", "print": true }')
  889. def setTemperature(self, host, key, what, temp):
  890. path = "printer/bed"
  891. s = "{\"command\": \"target\", \"target\": " + temp + "}"
  892. if "tool" in what:
  893. path = "printer/tool"
  894. s = "{\"command\": \"target\", \"targets\": {\"" + what + "\": " + temp + "}}"
  895. if temp == None:
  896. temp = 0
  897. self.sendPostRequest(host, key, path, s)
  898. def printerHeatTool(self, p):
  899. self.setTemperature(p[0], p[1], "tool0", p[2])
  900. def printerHeatBed(self, p):
  901. self.setTemperature(p[0], p[1], "bed", p[3])
  902. def printerCooldown(self, p):
  903. state = self.getState(p[0], p[1])
  904. if state in self.statesWithWarning:
  905. if self.showDialog("OctoTray Warning", "The printer seems to be running currently!", "Do you really want to turn it off?", True, True) == False:
  906. return
  907. self.setTemperature(p[0], p[1], "tool0", 0)
  908. self.setTemperature(p[0], p[1], "bed", 0)
  909. def printerWebcamAction(self, item):
  910. for cw in self.camWindows:
  911. if cw.getHost() == item[0]:
  912. cw.show()
  913. cw.activateWindow()
  914. return
  915. window = CamWindow(self, item)
  916. self.camWindows.append(window)
  917. window.show()
  918. window.activateWindow()
  919. screenGeometry = QDesktopWidget().screenGeometry()
  920. x = (screenGeometry.width() - window.width()) / 2
  921. y = (screenGeometry.height() - window.height()) / 2
  922. x += screenGeometry.x()
  923. y += screenGeometry.y()
  924. window.setGeometry(int(x), int(y), int(window.width()), int(window.height()))
  925. def removeWebcamWindow(self, window):
  926. self.camWindows.remove(window)
  927. def showSettingsAction(self):
  928. if self.settingsWindow != None:
  929. self.settingsWindow.show()
  930. self.settingsWindow.activateWindow()
  931. return
  932. self.settingsWindow = SettingsWindow(self)
  933. self.settingsWindow.show()
  934. self.settingsWindow.activateWindow()
  935. screenGeometry = QDesktopWidget().screenGeometry()
  936. x = (screenGeometry.width() - self.settingsWindow.width()) / 2
  937. y = (screenGeometry.height() - self.settingsWindow.height()) / 2
  938. x += screenGeometry.x()
  939. y += screenGeometry.y()
  940. self.settingsWindow.setGeometry(int(x), int(y), int(self.settingsWindow.width()), int(self.settingsWindow.height()) + 50)
  941. def removeSettingsWindow(self):
  942. self.settingsWindow = None
  943. def restartApp(self):
  944. QCoreApplication.exit(42)
  945. def closeAll(self):
  946. for cw in self.camWindows:
  947. cw.close()
  948. if self.settingsWindow != None:
  949. self.settingsWindow.close()
  950. if self.inSysTray:
  951. self.trayIcon.setVisible(False)
  952. else:
  953. self.mainWindow.setVisible(False)
  954. if __name__ == "__main__":
  955. app = QApplication(sys.argv)
  956. app.setQuitOnLastWindowClosed(False)
  957. signal.signal(signal.SIGINT, signal.SIG_DFL)
  958. inSysTray = QSystemTrayIcon.isSystemTrayAvailable()
  959. if ("windowed" in sys.argv) or ("--windowed" in sys.argv) or ("-w" in sys.argv):
  960. inSysTray = False
  961. tray = OctoTray(app, inSysTray)
  962. rc = app.exec_()
  963. while rc == 42:
  964. tray.closeAll()
  965. tray = OctoTray(app, inSysTray)
  966. rc = app.exec_()
  967. sys.exit(rc)