moistcontrol-gui 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. #!/usr/bin/env python3
  2. #
  3. # Moisture control - Graphical user interface
  4. #
  5. # Copyright (c) 2013 Michael Buesch <m@bues.ch>
  6. #
  7. # This program is free software; you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation; either version 2 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License along
  18. # with this program; if not, write to the Free Software Foundation, Inc.,
  19. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  20. #
  21. import sys
  22. if sys.version_info[0] < 3:
  23. print("The Python interpreter is too old.")
  24. print("PLEASE INSTALL Python 3.x")
  25. raw_input("Press enter to exit.")
  26. sys.exit(1)
  27. import math
  28. from pymoistcontrol import *
  29. # Serial communication parameters
  30. SERIAL_BAUDRATE = 19200
  31. SERIAL_PAYLOAD_LEN = 12
  32. class MainWidget(QWidget):
  33. """The central widget inside of the main window."""
  34. serialConnected = Signal()
  35. serialDisconnected = Signal()
  36. fetchCycle = [
  37. "global_state",
  38. "log",
  39. "rtc",
  40. "pot_state",
  41. "pot_rem_state",
  42. ]
  43. def __init__(self, parent):
  44. """Class constructor."""
  45. QWidget.__init__(self, parent)
  46. self.setLayout(QGridLayout(self))
  47. self.globConfWidget = GlobalConfigWidget(self)
  48. self.layout().addWidget(self.globConfWidget, 0, 0)
  49. self.tabWidget = QTabWidget(self)
  50. self.layout().addWidget(self.tabWidget, 0, 1)
  51. self.potWidgets = []
  52. for i in range(MAX_NR_FLOWERPOTS):
  53. potWidget = PotWidget(i, self)
  54. self.potWidgets.append(potWidget)
  55. self.tabWidget.addTab(potWidget,
  56. "Pot %d" % (i + 1))
  57. self.setUiEnabled(False)
  58. self.logWidget = LogWidget(self)
  59. self.layout().addWidget(self.logWidget, 1, 0, 1, 2)
  60. self.connected = False
  61. self.pollTimer = QTimer(self)
  62. self.pollTimer.setSingleShot(True)
  63. self.globConfWidget.configChanged.connect(self.__handleGlobConfigChange)
  64. self.globConfWidget.rtcEdited.connect(self.__handleRtcEdit)
  65. for pot in self.potWidgets:
  66. pot.configChanged.connect(self.__handlePotConfigChange)
  67. pot.manModeChanged.connect(self.__handleManModeChange)
  68. pot.watchdogRestartReq.connect(self.__handleWatchdogRestartReq)
  69. self.pollTimer.timeout.connect(self.__pollTimerEvent)
  70. def __handleCommError(self, exception):
  71. QMessageBox.critical(self,
  72. "Serial communication failed",
  73. "Serial communication failed:\n"
  74. "%s" % str(exception))
  75. self.disconnectDev()
  76. def __makeMsg_GlobalConfig(self):
  77. msg = MsgContrConf(flags = 0,
  78. sensor_lowest_value = self.globConfWidget.lowestRawSensorVal(),
  79. sensor_highest_value = self.globConfWidget.highestRawSensorVal())
  80. if self.globConfWidget.globalEnableActive():
  81. msg.flags |= msg.CONTR_FLG_ENABLE
  82. return msg
  83. def __makeMsg_RTC(self):
  84. dateTime = self.globConfWidget.getRtcDateTime()
  85. msg = MsgRtc(second = dateTime.time().second(),
  86. minute = dateTime.time().minute(),
  87. hour = dateTime.time().hour(),
  88. day = dateTime.date().day() - 1,
  89. month = dateTime.date().month() - 1,
  90. year = clamp(dateTime.date().year(), 2000, 2063) - 2000,
  91. day_of_week = dateTime.date().dayOfWeek() - 1)
  92. return msg
  93. def __makeMsg_PotConfig(self, potNumber):
  94. pot = self.potWidgets[potNumber]
  95. self.globConfWidget.handlePotEnableChange(potNumber,
  96. pot.isEnabled())
  97. msg = MsgContrPotConf(pot_number = potNumber,
  98. flags = 0,
  99. min_threshold = pot.getMinThreshold(),
  100. max_threshold = pot.getMaxThreshold(),
  101. start_time = pot.getStartTime(),
  102. end_time = pot.getEndTime(),
  103. dow_on_mask = pot.getDowEnableMask())
  104. if pot.isEnabled():
  105. msg.flags |= msg.POT_FLG_ENABLED
  106. if pot.loggingEnabled():
  107. msg.flags |= msg.POT_FLG_LOG
  108. if pot.verboseLoggingEnabled():
  109. msg.flags |= msg.POT_FLG_LOGVERBOSE
  110. return msg
  111. def __handleGlobConfigChange(self):
  112. try:
  113. self.serial.send(self.__makeMsg_GlobalConfig())
  114. except SerialError as e:
  115. self.__handleCommError(e)
  116. return
  117. def __handleRtcEdit(self):
  118. try:
  119. self.serial.send(self.__makeMsg_RTC())
  120. except SerialError as e:
  121. self.__handleCommError(e)
  122. return
  123. def __handlePotConfigChange(self, potNumber):
  124. try:
  125. self.serial.send(self.__makeMsg_PotConfig(potNumber))
  126. except SerialError as e:
  127. self.__handleCommError(e)
  128. return
  129. def __handleManModeChange(self):
  130. try:
  131. msg = MsgManMode()
  132. for i, pot in enumerate(self.potWidgets):
  133. if pot.forceStopWateringActive():
  134. msg.force_stop_watering_mask |= 1 << i
  135. if pot.forceOpenValveActive():
  136. msg.valve_manual_mask |= 1 << i
  137. msg.valve_manual_state |= 1 << i
  138. if pot.forceStartMeasActive():
  139. msg.force_start_measurement_mask |= 1 << i
  140. self.serial.send(msg)
  141. except SerialError as e:
  142. self.__handleCommError(e)
  143. return
  144. def __sendFreeze(self, freeze=True):
  145. msg = MsgManMode()
  146. msg.flags |= MsgManMode.MANFLG_FREEZE_CHANGE
  147. if freeze:
  148. msg.flags |= MsgManMode.MANFLG_FREEZE_ENABLE
  149. self.serial.send(msg)
  150. def __sendClearNotify(self):
  151. msg = MsgManMode()
  152. msg.flags |= MsgManMode.MANFLG_NOTIFY_CHANGE
  153. self.serial.send(msg)
  154. def __handleWatchdogRestartReq(self, potNumber):
  155. try:
  156. self.__stopPolling()
  157. self.__sendFreeze(True)
  158. # Remove WD-trigger flag from remanent state
  159. msg = self.__convertRxMsg(self.serial.sendSync(MsgContrPotRemStateFetch(potNumber)),
  160. fatalOnNoMsg = True)
  161. if not self.__checkRxMsg(msg, Message.MSG_CONTR_POT_REM_STATE):
  162. return
  163. msg.fc = 0
  164. msg.flags &= ~msg.POT_REMFLG_WDTRIGGER
  165. self.serial.send(msg)
  166. # Disable the notification LED
  167. self.__sendClearNotify()
  168. self.__sendFreeze(False)
  169. self.__startPolling()
  170. except SerialError as e:
  171. self.__handleCommError(e)
  172. return
  173. def setUiEnabled(self, enabled = True):
  174. self.globConfWidget.setEnabled(enabled)
  175. for i in range(self.tabWidget.count()):
  176. self.tabWidget.widget(i).setEnabled(enabled)
  177. def __fetchCycleNext(self):
  178. action = self.fetchCycle[self.fetchCycleNumber]
  179. try:
  180. if action == "global_state":
  181. msg = MsgContrStateFetch()
  182. elif action == "log":
  183. msg = MsgLogFetch()
  184. elif action == "rtc":
  185. msg = MsgRtcFetch()
  186. elif action == "pot_state":
  187. msg = MsgContrPotStateFetch(self.potCycleNumber)
  188. elif action == "pot_rem_state":
  189. msg = MsgContrPotRemStateFetch(self.potCycleNumber)
  190. else:
  191. assert(0)
  192. self.serial.send(msg)
  193. except SerialError as e:
  194. self.__handleCommError(e)
  195. return
  196. self.pollTimer.start(math.ceil(msg.calcFrameDuration() * 1000) + 10)
  197. def __startPolling(self):
  198. self.fetchCycleNumber = 0
  199. self.potCycleNumber = 0
  200. self.logCount = 0
  201. self.__pollRetries = 0
  202. self.__fetchCycleNext()
  203. def __stopPolling(self):
  204. self.pollTimer.stop()
  205. # Drain pending RX-messages
  206. time.sleep(0.1)
  207. while self.serial.poll():
  208. pass
  209. def __checkRxMsg(self, msg, expectedType, ignoreErrorCode=False):
  210. ok = True
  211. if not ignoreErrorCode:
  212. if msg.getErrorCode() != Message.COMM_ERR_OK:
  213. QMessageBox.critical(self,
  214. "Received message: Error",
  215. "Received an error: %d" % \
  216. msg.getErrorCode())
  217. ok = False
  218. if ok and\
  219. msg.getType() is not None and\
  220. msg.getType() != expectedType:
  221. QMessageBox.critical(self,
  222. "Received message: Unexpected type",
  223. "Received a message with an unexpected "
  224. "type. (got %d, expected %d)" % \
  225. (msg.getType(), expectedType))
  226. ok = False
  227. if not ok:
  228. self.disconnectDev()
  229. return ok
  230. def __convertRxMsg(self, msg, fatalOnNoMsg=False):
  231. msg = Message.fromRawMessage(msg)
  232. if not msg:
  233. if fatalOnNoMsg:
  234. QMessageBox.critical(self, "Communication failed",
  235. "Serial communication timeout")
  236. self.disconnectDev()
  237. return None
  238. error = msg.getErrorCode()
  239. if error not in (Message.COMM_ERR_OK, Message.COMM_ERR_FAIL):
  240. QMessageBox.critical(self, "Communication failed",
  241. "Serial communication error: %d" % error)
  242. self.disconnectDev()
  243. return None
  244. return msg
  245. def __pollTimerEvent(self):
  246. try:
  247. msg = self.__convertRxMsg(self.serial.poll())
  248. if not msg:
  249. self.__pollRetries += 1
  250. if self.__pollRetries >= 200:
  251. QMessageBox.critical(self,
  252. "Communication failed",
  253. "Communication failed. "
  254. "Retry timeout.")
  255. self.disconnectDev()
  256. return
  257. self.pollTimer.start(5) # Retry
  258. return
  259. self.__pollRetries = 0
  260. except SerialError as e:
  261. self.__handleCommError(e)
  262. return
  263. error = msg.getErrorCode()
  264. advanceFetchCycle = True
  265. action = self.fetchCycle[self.fetchCycleNumber]
  266. if action == "global_state":
  267. if self.__checkRxMsg(msg, Message.MSG_CONTR_STATE):
  268. self.globConfWidget.handleGlobalStateMessage(msg)
  269. elif action == "log":
  270. if self.__checkRxMsg(msg, Message.MSG_LOG,
  271. ignoreErrorCode = True):
  272. if error == Message.COMM_ERR_OK:
  273. self.logWidget.handleLogMessage(msg)
  274. self.logCount += 1
  275. if self.logCount < 8:
  276. advanceFetchCycle = False
  277. if advanceFetchCycle:
  278. self.logCount = 0
  279. elif action == "rtc":
  280. if self.__checkRxMsg(msg, Message.MSG_RTC):
  281. self.globConfWidget.handleRtcMessage(msg)
  282. elif action in ("pot_state", "pot_rem_state"):
  283. expected = { "pot_state" : Message.MSG_CONTR_POT_STATE,
  284. "pot_rem_state" : Message.MSG_CONTR_POT_REM_STATE,
  285. }[action]
  286. if self.__checkRxMsg(msg, expected):
  287. if msg.pot_number == self.potCycleNumber:
  288. if action == "pot_state":
  289. self.globConfWidget.handlePotStateMessage(msg)
  290. self.potWidgets[self.potCycleNumber].handlePotStateMessage(msg)
  291. elif action == "pot_rem_state":
  292. self.globConfWidget.handlePotRemStateMessage(msg)
  293. self.potWidgets[self.potCycleNumber].handlePotRemStateMessage(msg)
  294. else:
  295. assert(0)
  296. else:
  297. QMessageBox.critical(self,
  298. "%s message mismatch" % action,
  299. "Received pot state message for the"
  300. "wrong pot (was %d, expected %d)." % \
  301. (msg.pot_number, self.potCycleNumber))
  302. self.potCycleNumber += 1
  303. if self.potCycleNumber < MAX_NR_FLOWERPOTS:
  304. advanceFetchCycle = False
  305. else:
  306. self.potCycleNumber = 0
  307. else:
  308. assert(0)
  309. if advanceFetchCycle:
  310. self.fetchCycleNumber += 1
  311. if self.fetchCycleNumber >= len(self.fetchCycle):
  312. self.fetchCycleNumber = 0
  313. self.__fetchCycleNext()
  314. else:
  315. self.__fetchCycleNext()
  316. def __initializeDev(self):
  317. try:
  318. # Get the global configuration from the device
  319. msg = self.__convertRxMsg(self.serial.sendSync(MsgContrConfFetch()),
  320. fatalOnNoMsg = True)
  321. if not self.__checkRxMsg(msg, Message.MSG_CONTR_CONF):
  322. return
  323. self.globConfWidget.handleGlobalConfMessage(msg)
  324. # Get the pot configurations from the device
  325. for i in range(MAX_NR_FLOWERPOTS):
  326. msg = self.__convertRxMsg(self.serial.sendSync(MsgContrPotConfFetch(i)),
  327. fatalOnNoMsg = True)
  328. if not self.__checkRxMsg(msg, Message.MSG_CONTR_POT_CONF):
  329. return
  330. self.potWidgets[i].handlePotConfMessage(msg)
  331. self.globConfWidget.handlePotConfMessage(msg)
  332. # Reset manual mode
  333. msg = MsgManMode(force_stop_watering_mask = 0,
  334. valve_manual_mask = 0,
  335. valve_manual_state = 0)
  336. self.serial.send(msg)
  337. # Start cyclic data fetching
  338. self.__startPolling()
  339. except SerialError as e:
  340. self.__handleCommError(e)
  341. return False
  342. return True
  343. def isConnected(self):
  344. return self.connected
  345. def connectDev(self, port=None):
  346. if self.connected:
  347. return
  348. if not port:
  349. dlg = SerialOpenDialog(self)
  350. if dlg.exec_() != QDialog.Accepted:
  351. return
  352. port = dlg.getSelectedPort()
  353. try:
  354. self.serial = SerialComm(port, baudrate = SERIAL_BAUDRATE,
  355. payloadLen = SERIAL_PAYLOAD_LEN,
  356. debug = False)
  357. except SerialError as e:
  358. QMessageBox.critical(self, "Cannot connect serial port",
  359. "Cannot connect serial port:\n" + str(e))
  360. return
  361. self.logWidget.clear()
  362. self.setUiEnabled(True)
  363. if not self.__initializeDev():
  364. return
  365. self.connected = True
  366. self.serialConnected.emit()
  367. def disconnectDev(self):
  368. self.setUiEnabled(False)
  369. self.pollTimer.stop()
  370. if self.serial:
  371. self.serial.close()
  372. self.serial = None
  373. if self.connected:
  374. self.connected = False
  375. self.serialDisconnected.emit()
  376. def getSettingsText(self):
  377. settings = [
  378. "[MOISTCONTROL_SETTINGS]\n" \
  379. "file_version=0\n" \
  380. "date=%s\n" % \
  381. (QDateTime.currentDateTime().toUTC().toString("yyyy.MM.dd_hh:mm:ss.zzz_UTC"))
  382. ]
  383. # Write global config
  384. msg = self.__makeMsg_GlobalConfig()
  385. settings.append(msg.toText())
  386. # Write pot configs
  387. for i in range(MAX_NR_FLOWERPOTS):
  388. msg = self.__makeMsg_PotConfig(i)
  389. settings.append(msg.toText())
  390. return "\n".join(settings)
  391. def setSettingsText(self, settings):
  392. self.__stopPolling()
  393. # Parse and upload the new config
  394. try:
  395. p = configparser.ConfigParser()
  396. p.read_string(settings)
  397. ver = p.getint("MOISTCONTROL_SETTINGS", "file_version")
  398. if ver != 0:
  399. raise Error("Unsupported file version. "
  400. "Expected v0, but got v%d." % ver)
  401. # Read global config
  402. msg = MsgContrConf()
  403. msg.fromText(settings)
  404. self.serial.send(msg) # send to device
  405. # Read pot configs
  406. for i in range(MAX_NR_FLOWERPOTS):
  407. msg = MsgContrPotConf(i)
  408. msg.fromText(settings)
  409. self.serial.send(msg) # send to device
  410. except configparser.Error as e:
  411. raise Error(str(e))
  412. except SerialError as e:
  413. raise Error("Failed to send config to device:\n" % str(e))
  414. finally:
  415. # Restart the communication
  416. self.__initializeDev()
  417. def doLoadSettings(self, filename):
  418. try:
  419. fd = open(filename, "rb")
  420. settings = fd.read().decode("UTF-8")
  421. fd.close()
  422. self.setSettingsText(settings)
  423. except (IOError, UnicodeError, Error) as e:
  424. QMessageBox.critical(self,
  425. "Failed to read file",
  426. "Failed to read the settings file:\n"
  427. "%s" % str(e))
  428. def loadSettings(self):
  429. fn, filt = QFileDialog.getOpenFileName(self,
  430. "Load settings from file",
  431. "",
  432. "Settings file (*.moi);;"
  433. "All files (*)")
  434. if not fn:
  435. return
  436. self.doLoadSettings(fn)
  437. def doSaveSettingsAs(self, filename):
  438. try:
  439. fd = open(filename, "wb")
  440. fd.write(self.getSettingsText().encode("UTF-8"))
  441. fd.close()
  442. except (IOError, UnicodeError, Error) as e:
  443. QMessageBox.critical(self,
  444. "Failed to write file",
  445. "Failed to write the settings file:\n"
  446. "%s" % str(e))
  447. def saveSettingsAs(self):
  448. fn, filt = QFileDialog.getSaveFileName(self,
  449. "Save settings to file",
  450. "",
  451. "Settings file (*.moi);;"
  452. "All files (*)")
  453. if not fn:
  454. return
  455. if "(*.moi)" in filt:
  456. if not fn.endswith(".moi"):
  457. fn += ".moi"
  458. self.doSaveSettingsAs(fn)
  459. class MainWindow(QMainWindow):
  460. """The main program window."""
  461. def __init__(self):
  462. """Class constructor."""
  463. QMainWindow.__init__(self)
  464. self.setWindowTitle("Flowerpot moisture control")
  465. mainWidget = MainWidget(self)
  466. self.setCentralWidget(mainWidget)
  467. self.setMenuBar(QMenuBar(self))
  468. menu = QMenu("&File", self)
  469. self.loadButton = menu.addAction("&Load settings...", self.loadSettings)
  470. self.saveButton = menu.addAction("&Save settings as...", self.saveSettingsAs)
  471. menu.addSeparator()
  472. menu.addAction("&Exit", self.close)
  473. self.menuBar().addMenu(menu)
  474. menu = QMenu("&Device", self)
  475. self.connMenuButton = menu.addAction("&Connect", self.connectDev)
  476. self.disconnMenuButton = menu.addAction("&Disconnect", self.disconnectDev)
  477. self.menuBar().addMenu(menu)
  478. toolBar = QToolBar(self)
  479. self.connToolButton = toolBar.addAction("Connect", self.connectDev)
  480. self.disconnToolButton = toolBar.addAction("Disconnect", self.disconnectDev)
  481. self.addToolBar(toolBar)
  482. self.updateConnButtons()
  483. mainWidget.serialConnected.connect(self.updateConnButtons)
  484. mainWidget.serialDisconnected.connect(self.updateConnButtons)
  485. def updateConnButtons(self):
  486. connected = self.centralWidget().isConnected()
  487. self.connMenuButton.setEnabled(not connected)
  488. self.connToolButton.setEnabled(not connected)
  489. self.disconnMenuButton.setEnabled(connected)
  490. self.disconnToolButton.setEnabled(connected)
  491. self.loadButton.setEnabled(connected)
  492. self.saveButton.setEnabled(connected)
  493. def loadSettings(self):
  494. self.centralWidget().loadSettings()
  495. def saveSettingsAs(self):
  496. self.centralWidget().saveSettingsAs()
  497. def connectDev(self, port=None):
  498. self.centralWidget().connectDev(port)
  499. def disconnectDev(self):
  500. self.centralWidget().disconnectDev()
  501. # Program entry point
  502. def main():
  503. # Create the main QT application object
  504. qApp = QApplication(sys.argv)
  505. # Create and show the main window
  506. mainWnd = MainWindow()
  507. mainWnd.show()
  508. if len(sys.argv) >= 2:
  509. mainWnd.connectDev(sys.argv[1])
  510. # Enter the QT event loop
  511. return qApp.exec_()
  512. if __name__ == "__main__":
  513. sys.exit(main())