diff --git a/.gitignore b/.gitignore
index 0e20043..f86492e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,13 @@
-
+*.js
*.json
+*.old
+!drivers.json
*.pyc
**/__pycache__/
*.vscode
-
*.log
-*.old
+**/log/
+**/config/
+**/exports/
+**/flowcharts/
+getDict.py
\ No newline at end of file
diff --git a/DuetWebAPI.py b/DuetWebAPI.py
deleted file mode 100644
index a33af6e..0000000
--- a/DuetWebAPI.py
+++ /dev/null
@@ -1,616 +0,0 @@
-# Python Script containing a class to send commands to, and query specific information from,
-# Duet based printers running either Duet RepRap V2 or V3 firmware.
-#
-# Does NOT hold open the connection. Use for low-volume requests.
-# Does NOT, at this time, support Duet passwords.
-#
-# Not intended to be a gerneral purpose interface; instead, it contains methods
-# to issue commands or return specific information. Feel free to extend with new
-# methods for other information; please keep the abstraction for V2 V3
-#
-# Copyright (C) 2020 Danal Estes all rights reserved.
-# Copyright (C) 2021 Haytham Bennani
-# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT
-#
-# Requires Python3
-
-class DuetWebAPI:
- import requests
- import json
- import sys
- import time
- import datetime
- pt = 0
- _base_url = ''
- _rrf2 = False
-
-
- def __init__(self,base_url):
- self._base_url = base_url
- try:
- print('Connecting to', base_url, '..')
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=(2,60))
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- j = self.json.loads(r.text)
- _=j['coords']
- firmwareName = j['firmwareName']
-
- try:
- firmwareName = j['firmwareName']
- firmwareVersion = j['firmwareVersion']
- print('Duet Firmware:', firmwareName, '- V'+firmwareVersion)
- if firmwareVersion[0] == "2":
- self._rrf2 = True
- except Exception as e:
- self._rrf2 = True
- self.pt = 2
- return
- except:
- try:
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=(2,60))
- j = self.json.loads(r.text)
- _=j
- self.pt = 3
- return
- except:
- print(self._base_url," does not appear to be a RRF2 or RRF3 printer", file=self.sys.stderr)
- return
-####
-# The following methods are a more atomic, reading/writing basic data structures in the printer.
-####
-
- def printerType(self):
- return(self.pt)
-
- def baseURL(self):
- return(self._base_url)
-
- def getCoords(self):
- import time
- try:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- if not r.ok:
- print('Error in getStatus session: ', r)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- while self.getStatus() not in "idle":
- time.sleep(0.5)
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- jc=j['coords']['xyz']
- an=j['axisNames']
- ret=self.json.loads('{}')
- for i in range(0,len(jc)):
- ret[ an[i] ] = jc[i]
- return(ret)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- ja=j['move']['axes']
- ret=self.json.loads('{}')
- for i in range(0,len(ja)):
- ret[ ja[i]['letter'] ] = ja[i]['userPosition']
- return(ret)
- except Exception as e1:
- print('Error in getStatus: ',e1 )
-
- def getCoordsAbs(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- jc=j['coords']['machine']
- an=j['axisNames']
- ret=self.json.loads('{}')
- for i in range(0,len(jc)):
- ret[ an[i] ] = jc[i]
- return(ret)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- ja=j['move']['axes']
- ret=self.json.loads('{}')
- for i in range(0,len(ja)):
- ret[ ja[i]['letter'] ] = ja[i]['machinePosition']
- return(ret)
-
- def getLayer(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=3')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- s = j['currentLayer']
- return (s)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- s = j['job']['layer']
- if (s == None): s=0
- return(s)
-
- def getG10ToolOffset(self,tool):
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- ja=j['move']['axes']
- jt=j['tools']
- ret=self.json.loads('{}')
- to = jt[tool]['offsets']
- for i in range(0,len(to)):
- ret[ ja[i]['letter'] ] = to[i]
- return(ret)
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- ja=j['axisNames']
- jt=j['tools']
- ret=self.json.loads('{}')
- to = jt[tool]['offsets']
- for i in range(0,len(to)):
- ret[ ja[i] ] = to[i]
- return(ret)
-
- return({'X':0,'Y':0,'Z':0}) # Dummy for now
-
- def getNumExtruders(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- jc=j['coords']['extr']
- return(len(jc))
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- return(len(j['move']['extruders']))
-
- def getNumTools(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- jc=j['tools']
- return(len(jc))
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- return(len(j['tools']))
-
- def getStatus(self):
- import time
- try:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- if not r.ok:
- print('Error in getStatus session: ', r)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- s=j['status']
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if not self._rrf2:
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
- if not r2.ok:
- print('Error in getStatus end session: ', r2)
- if ('I' in s): return('idle')
- if ('P' in s): return('processing')
- if ('S' in s): return('paused')
- if ('B' in s): return('canceling')
- return(s)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j:
- j = j['result']
- _status = str(j['state']['status'])
- return( _status.lower() )
- except Exception as e1:
- print('Error in getStatus: ',e1 )
- return 'Error'
-
- def gCode(self,command):
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- import time
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command)
- r = self.requests.get(URL,timeout=8)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/code/')
- r = self.requests.post(URL, data=command)
- if (r.ok):
- return(0)
- else:
- print("gCode command return code = ",r.status_code)
- print(r.reason)
- return(r.status_code)
-
- def gCodeBatch(self,commands):
- for command in commands:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- import time
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- time.sleep(0.5)
- URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command)
- r = self.requests.get(URL,timeout=8)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- json_response = r.json()
- buffer_size = int(json_response['buff'])
- #print( "Buffer: ", buffer_size )
- #print( command, ' -> ', reply )
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/code/')
- r = self.requests.post(URL, data=command)
- if not (r.ok):
- print("gCode command return code = ",r.status_code)
- print(r.reason)
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
- return(r.status_code)
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
-
- def getFilenamed(self,filename):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_download?name='+filename)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/file/'+filename)
- r = self.requests.get(URL,timeout=8)
- return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' '))
-
- def getTemperatures(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- return('Error: getTemperatures not implemented (yet) for RRF V2 printers.')
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- jsa=j['sensors']['analog']
- return(jsa)
-
- def checkDuet2RRF3(self):
- if (self.pt == 2):
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- s=j['firmwareVersion']
- if s == "3.2":
- return True
- else:
- return False
-
- def getCurrentTool(self):
- import time
- try:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- if not r.ok:
- print('Error in getStatus session: ', r)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- while self.getStatus() not in "idle":
- time.sleep(0.5)
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- ret=j['currentTool']
- return(ret)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- ret=j['state']['currentTool']
- return(ret)
- except Exception as e1:
- print('Error in getStatus: ',e1 )
-
- def getHeaters(self):
- import time
- try:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- if not r.ok:
- print('Error in getStatus session: ', r)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- while self.getStatus() not in "idle":
- time.sleep(0.5)
- URL=(f'{self._base_url}'+'/rr_status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- ret=j['heaters']
- return(ret)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j: j = j['result']
- ret=j['heat']['heaters']
- return(ret)
- except Exception as e1:
- print('Error in getStatus: ',e1 )
-
- def isIdle(self):
- try:
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- if not r.ok:
- print('Error in getStatus session: ', r)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- URL=(f'{self._base_url}'+'/rr_status?type=2')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- s=j['status']
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
- if not r2.ok:
- print('Error in getStatus end session: ', r2)
- return False
- if ('I' in s):
- return True
- else:
- return False
-
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/status')
- r = self.requests.get(URL,timeout=8)
- j = self.json.loads(r.text)
- if 'result' in j:
- j = j['result']
- status = str(j['state']['status'])
- if status.upper() == 'IDLE':
- return True
- else:
- return False
- except Exception as e1:
- print('Error in isIdle(): ',e1 )
- return False
-####
-# The following methods provide services built on the atomics above.
-####
-
-
- # Given a line from config g that defines an endstop (N574) or Z probe (M558),
- # Return a line that will define the same thing to a "nil" pin, i.e. undefine it
- def _nilEndstop(self,configLine):
- ret = ''
- for each in [word for word in configLine.split()]: ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' '
- return(ret)
-
- def clearEndstops(self):
- c = self.getFilenamed('/sys/config.g')
- commandBuffer = []
- for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
- commandBuffer.append(self._nilEndstop(each))
- self.gCodeBatch(commandBuffer)
-
-
- def resetEndstops(self):
- import time
- c = self.getFilenamed('/sys/config.g')
- commandBuffer = []
- for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
- commandBuffer.append(self._nilEndstop(each))
- for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]:
- commandBuffer.append(each)
- self.gCodeBatch(commandBuffer)
-
- def resetAxisLimits(self):
- c = self.getFilenamed('/sys/config.g')
- commandBuffer = []
- for each in [line for line in c if 'M208 ' in line]:
- commandBuffer.append(each)
- self.gCodeBatch(commandBuffer)
-
- def resetG10(self):
- c = self.getFilenamed('/sys/config.g')
- commandBuffer = []
- for each in [line for line in c if 'G10 ' in line]:
- commandBuffer.append(each)
- self.gCodeBatch(commandBuffer)
-
- def resetAdvancedMovement(self):
- c = self.getFilenamed('/sys/config.g')
- commandBuffer = []
- for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]:
- commandBuffer.append(each)
- self.gCodeBatch(commandBuffer)
-
- def getTriggerHeight(self):
- _errCode = 0
- _errMsg = ''
- triggerHeight = 0
- if (self.pt == 2):
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
- r = self.requests.get(sessionURL,timeout=8)
- buffer_size = 0
- while buffer_size < 150:
- bufferURL = (f'{self._base_url}'+'/rr_gcode')
- buffer_request = self.requests.get(bufferURL,timeout=8)
- try:
- buffer_response = buffer_request.json()
- buffer_size = int(buffer_response['buff'])
- except:
- buffer_size = 149
- if buffer_size < 150:
- print('Buffer low: ', buffer_size)
- time.sleep(0.6)
- URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31')
- r = self.requests.get(URL,timeout=8)
- replyURL = (f'{self._base_url}'+'/rr_reply')
- reply = self.requests.get(replyURL,timeout=8)
- # Reply is of the format:
- # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0"
- start = reply.find('trigger height')
- triggerHeight = reply[start+15:]
- triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
- if not self._rrf2:
- #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
- endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
- r2 = self.requests.get(endsessionURL,timeout=8)
- if (self.pt == 3):
- URL=(f'{self._base_url}'+'/machine/code/')
- r = self.requests.post(URL, data='G31')
- # Reply is of the format:
- # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0"
- reply = r.text
- start = reply.find('trigger height')
- triggerHeight = reply[start+15:]
- triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
- if (r.ok):
- return (_errCode, _errMsg, triggerHeight )
- else:
- _errCode = float(r.status_code)
- _errMsg = r.reason
- print("getTriggerHeight command return code = ",r.status_code)
- print(r.reason)
- return (_errCode, _errMsg, None )
-
\ No newline at end of file
diff --git a/Flowcharts/00 - Main Program Launch.dia b/Flowcharts/00 - Main Program Launch.dia
new file mode 100644
index 0000000..153fad0
Binary files /dev/null and b/Flowcharts/00 - Main Program Launch.dia differ
diff --git a/Flowcharts/01 - TAMV GUI Init.dia b/Flowcharts/01 - TAMV GUI Init.dia
new file mode 100644
index 0000000..b7fbe63
Binary files /dev/null and b/Flowcharts/01 - TAMV GUI Init.dia differ
diff --git a/Flowcharts/02 - Signals.dia b/Flowcharts/02 - Signals.dia
new file mode 100644
index 0000000..fa4e083
Binary files /dev/null and b/Flowcharts/02 - Signals.dia differ
diff --git a/Flowcharts/02 - Signals.dia~ b/Flowcharts/02 - Signals.dia~
new file mode 100644
index 0000000..db69e6e
Binary files /dev/null and b/Flowcharts/02 - Signals.dia~ differ
diff --git a/Flowcharts/Endstop Calibration b/Flowcharts/Endstop Calibration
new file mode 100644
index 0000000..a9f3546
Binary files /dev/null and b/Flowcharts/Endstop Calibration differ
diff --git a/Flowcharts/Endstop Calibration~ b/Flowcharts/Endstop Calibration~
new file mode 100644
index 0000000..4560fdf
Binary files /dev/null and b/Flowcharts/Endstop Calibration~ differ
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index d603f37..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,22 +0,0 @@
-MIT License
-
-Copyright (c) 2020 Danal Estes
-Copyright (c) 2021 Haytham Bennani
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/README.md b/README.md
deleted file mode 100644
index d3dcfa4..0000000
--- a/README.md
+++ /dev/null
@@ -1,242 +0,0 @@
-**TAMV _is under active development!_**
-# Table of Contents
-- [TAMV Walkthrough Video](#tamv-beta-in-action)
-- [TAMV = Tool Alignment (using) Machine Vision](#tamv--tool-alignment-using-machine-vision)
-- [Why should I use this version/fork of TAMV?](#why-should-i-use-this-versionfork-of-tamv)
-- [What's included in this package?](#whats-included-in-this-package)
-- [What do I need to run TAMV?](#what-do-i-need-to-run-tamv)
-- [What do I need to run ZTATP?](#what-do-i-need-to-run-ztatp)
-- [How do I install OpenCV on my Raspberry Pi?](#how-do-i-install-opencv-on-my-raspberry-pi)
-- [How do I run these packages?](#how-do-i-run-these-packages)
- * [TAMV_GUI](#tamv_gui)
- * [ZTATP](#ztatp)
- * [TAMV (legacy command-line interface)](#tamv-legacy-command-line-interface)
-- [TAMV Community Videos](#tamv-community-videos)
-
-**You can find me (H2B) on the [Jubilee Discord Server](https://discord.gg/XkphRqb) and I'll be more than glad to help you get things up and running.**
-
-# TAMV Beta in Action
-We've got a short walkthrough of TAMV in operation up on YouTube, and there you can see a real-time setup and alignment for a 3 tool printer. Click on the image to head over there!
-
-
-
-
-_[back to top](#table-of-contents)_
-# TAMV = Tool Alignment (using) Machine Vision
-Ever needed to simplify nozzle and tool alignment on a toolchanging 3D printer/machine?
-
-Well, welcome to the wonderful experience of **automated nozzle alignment** and Z offset setting using machine vision (for X&Y offset alignment) and electrical touch plates (for Z offset alignment).
-
-This program significantly improves how tool-changing machines using Duet RRF v2/3 on Duet2 / Duet3 controlers by leveraging open-source computer vision algorithms from the OpenCV project to eliminate the guesswork from tool alignment and (nearly) completely automate the process. Let the machines do the work!
-
-TAMV in its current release is a graphical program that allows you to connect to your printer and automatically calculate tool offsets in XY space using a generic [USB microscope](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/) and a Raspberry Pi (preferably a Pi4 or better).
-
-You may opt to use a webcam for tool alignment, but this approach (while reliable) will need significantly more work to overcome issues related to insufficient/inappropriate lighting and limited depth-of-field of webcam optics (not to mention more sensor noise artifacts), all of which can throw off the computer vision algorithms and cause headaches.
-
-_[back to top](#table-of-contents)_
-# Why should I use this version/fork of TAMV?
-1. Its now a graphical interface with ZERO command line steps at run-time.
-2. Its got a bundled installation script for getting OpenCV 4.5.1 running on a Raspberry Pi (it will take 1.5 hours to install the first time)
-3. The program guides you through the alignment process by only enabling the right procedures at the right times.
-4. The computer vision functions automatically calibrate your camera output to your machine's movement so there's no need to worry about lens distortion or slightly off-plane images.
-5. You may use any USB webcam (no picam yet!) that is compatible with Raspbian (when running TAMV on a Raspberry Pi)
-6. You can also use RTSP/network cameras and run the main program on a desktop/laptop if you so prefer (just need Python -- not much benefit over a Raspberry Pi 4 since the computer vision is running some basic blob detection.)
-7. TAMV can run repeatability tests for your machine (based on the number of cycles you define) and export/display useful visualizations of how your machine behaves over time to aid in identifying tool drift.
-8. TAMV allows you to save your machine address and some advanced parameters to save you some time when running it multiple times
-9. **Its completely open-source and community driven.**
-10. *Did we mention its a graphical interface now?*
-
-_[back to top](#table-of-contents)_
-# What's included in this package?
-1. **TAMV_GUI.py**: the main interface for automated X/Y offset tool alignment using computer vision
-2. **ZTATP.py**: a second program that uses electrical touch plates to physically measure tool Z offsets using your Duet controller's endstop inputs
-3. **TAMV.py**: the "original" command line version of TAMV, which also includes data export and repeatability testing
-
-_[back to top](#table-of-contents)_
-# What do I need to run TAMV?
-1. **A fully functional tool changer platform running RepRapFirmware 2.0.5 or 3.2+**
-
- It has only been tested on machines using either Duet2 (+Duex2 or Duex5) or Duet 3 boards.
-
- All your toolchanging macros (*tpre, tpost, tfree*) have to be up and running - you can run tool changes cleanly.
-
- All of your tools are assumed to have reached a **"safe working area"** once the tool is active (after tpost has completed), and your selected "Controlled Point" (the XY position where your microscope is located) can be reached using a large X move followed by a large Y move (be mindful of collisions here!)
-
- You will need to modify your tool change macros to exclude any extrusion moves temporarily to make sure the nozzles are clear of any filament and can be detected reliably. This is still a manual modification and we're working on automating this in the near future.
-
- All of your tools must have clean nozzles with no excessive filament deposits surrounding the nozzle.
-
-2. **A Raspberry Pi (3 or better with at least 2GB of RAM)**
-
- We prefer a **Raspberry Pi 4 with 4GB of RAM** for a smoother experience, but it will also work on a Model 3 Raspberry Pi.
-
- OpenCV requires at least 2GB of RAM to be installed, keep this in mind before jumping into installing openCV. It also takes over an hour to compile OpenCV on a Raspberry Pi 4
-
-3. **[A generic USB microscope](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/) with the light controls built-in to the USB cable**
-
- This is a generic mass-manufactured part commonly found at your favorite ecommerce outlet of choice. **Make sure you are using the variant that has a lighting control wheel built-in to the USB cable**, as there are alternative versions that look very similar which lack lighting control and have been found to be incompatible with Raspbian and will not work.
-
- You may choose to use a webcam you already have at home, but be mindful that computer vision requires a specific type of lighting (mainly soft diffuse light directed perpendicular from the camera to the object/nozzle) to work consistently. Webcams also tend to have wide-angle lens optics and offer a very coarse depth of focus, further complicating things when you need to focus on a 0.4mm nozzle from a 25mm focal distance. Add to that webcam sensors typically exhibit a lot of noise artifacts when forced to zoom in so closely to the nozzles. Overall, it will work, but you'll need to fiddle with it until its just right. We all started using TAMV with Logitech C270 webcams, and then moved to microscopes for the vastly superior experience.
-
- [Amazon US Link](https://www.amazon.com/gp/product/B07F7T7SC1/)
-
- [Amazon UK Link](https://www.amazon.co.uk/gp/product/B07KQDQ4VR/)
-
-
-4. **A little dose of patience (since you're reading this and already own a toolchanger, then we're sure you've got this bit covered..)**
-
- You'll find a great community and ton of help and answers to your questions on the [Jubilee Discord Server](https://discord.gg/XkphRqb)
-
-_[back to top](#table-of-contents)_
-# What do I need to run ZTATP?
-1. Any **tool changer machine**, just like #1 above for TAMV, running RRF
-2. A **Raspberry Pi**, just like #2 above for TAMV
-
- You may also choose to use any computer that can run a Python3 script and communicate with your Duet over the local network.
-
-3. Some sort of electrically **conductive touch plate** which you're going to connect to an endstop input (ground) on your Duet board.
-
- This can be as simple as a block of aluminum with an electrical wire screwed into it. As long as its electrically conductive and relatively small (under 3 inches on its longest side), you should be OK (size plays a large part in signal noise, so avoid using your printer's build plate..)
-
-4. A second electrical wire that you can hook up to your tools (one at a time, ZTATP pauses and prompts you to connect/disconnect everything for each tool).
-
- We can recommend some electrical wire and an alligator clip that you can connect to your nozzle for each alignment sequence.
-
-5. A **multimeter** (REQUIRED!) to test for electrical continuity between your tool nozzle and touch plate before each alignement sequence.
-
- We are talking about moving your tool down into a block of metal on your printer bed with ZTATP, so any collisions will definitely lead to machine/tool damage that will cause frustration and expensive repair.
-
-6. **Patience, more so than TAMV**, since you want to make sure each tool is ready to be slowly lowered into a block of solid metal, and you definitely don't want to mess this up.
-
- Don't worry. If you do your checks before each probe move, you won't have any crashes (hopefully).
-
- **But you are taking full responsibilty and accountability for using ZTATP and we will not be liable for any damages that you may incur.**
-
-_[back to top](#table-of-contents)_
-# How do I install OpenCV on my Raspberry Pi?
-1. Clone this repository into your home folder
-
- Open a terminal window on your Raspberry pi
-
- type `cd`, and hit enter
-
- type `git clone https://github.com/HaythamB/TAMV` and hit enter
-
-2. type `cd TAMV` and press enter
-3. Run `~/TAMV/install_opencv.sh`
-4. Wait about 20 minutes for everything to be installed
-
- Note for Raspberry Pi 3 users: you will probably have to increase the default SWAP size to at least 2GB for the install to run successfully. Here's the commands to run before installing openCV to set up a 2GB swap file. Run a Google search on "how to increase raspberry pi 3 swap size" for more info.
-
-```
-sudo sed -i 's/CONF_SWAPSIZE=100/CONF_SWAPSIZE=2048/g' /etc/dphys-swapfile
-sudo /etc/init.d/dphys-swapfile stop
-sudo /etc/init.d/dphys-swapfile start
-```
-
-_[back to top](#table-of-contents)_
-# How do I run these packages?
-## TAMV_GUI
-1. Connect your microscope/webcam to a USB port (preferably only connect 1 when starting it for the first time)
-2. Open a terminal window *from the Raspbian desktop and NOT AN SSH SESSION* and run `~/TAMV/TAMV_GUI.py`
-3. Follow the buttons and prompts on-screen. There are tooltips everywhere to guide you!
-
- You can always also watch [this video here](https://youtu.be/1nGc_hFzK0s?t=5) to see how a calibration is run.
-
-TAMV.py = Tool Align Machine Vision - for Duet based tool changing 3D printers.
-
-* Runs on the Pi that has the USB or Pi camera
-* Requires network connection to DUET RepRap V2 or V3 based printer.
-* This MAY be, but is not required to be, the Pi in a Duet3+Pi configuration
-* Requires OpenCV installed on the Pi.
-* MUST run on the graphic console, not SSH. This can be physical, VNC, or any combination of the two.
-* Always use soft diffused lighting when running TAMV (a diffused LED ring works great). This is the most important factor to get it to detect nozzles consistently and reliably without any fuss.
-
-P.S. Reminder: Never NEVER run a graphic app with 'sudo'. It can break your XWindows (graphic) setup. Badly.
-
-_[back to top](#table-of-contents)_
-## ZTATP
-ZTATP.py = Z Tool Align Touch Plate - for Duet based tool changing 3D printers.
-
-* Requires network connection to DUET RepRap V2 or V3 based printer.
-* This MAY be, but is not required to be, the Pi in a Duet3+Pi configuration
-* Warning: RRF 2&3 have a **hardcoded offset of 0.7mm applied to Z-probes** which you must over-ride in your config.g file. If you don't fix this issue, your offsets will be calculated 0.7mm too close to your print surface **and this will result in damage to your printer!** A simple G31 Z0 at the end of your config.g file (or whatever Z offset applies to your probe) is sufficient to fix this issue. A later release of ZTATP will automatically handle this issue, but for now, it is a manual fix.
-### Installation
-
- See instructions above for TAMV. It will be in the same directory.
-
-### Parameters
-#### -h, --help
-show help message and exit
-
-#### -duet DUET
-Name or IP address of Duet printer. You can use -duet=localhost if you are on the embedded Pi on a Duet3.
-
-#### -touchplate TOUCHPLATE TOUCHPLATE
-x y of center of a 15x15mm touch plate (these can be decimal values)
-
-#### -pin PIN PIN
-input pin to which wires from nozzles are attached (only in RRF3)
-
-#### -tool TOOL
-set a run for an individual tool number
-
-### Run
-
- cd TAMV
- ./ZTATP.py -touchplate X Y
-
-NOTE: Requires Wiring! Each nozzle must be wired to the GPIO specified (default is io5.in, can be overriden on command line). The touchplate must be grounded. Recommend about running with finger on power switch, in case a given touch does not stop.
-
-_[back to top](#table-of-contents)_
-## TAMV (legacy command-line interface)
-### Preparation steps
-TAMV, ZTATP, and their associated plot functions utilize Python3+, and some additional libraries for GUI elements and processing. If you have some errors while running the code, consider running the following commands to install missing modules.
-
- sudo apt-get update
- sudo apt-get upgrade
- sudo apt-get install python3-matplotlib
- sudo apt-get install python3-pyqt5
-
-### Installation
-
- cd
- git clone https://github.com/HaythamB/TAMV/
-
-### Run
- usage: TAMV.py [-h] [-duet DUET] [-vidonly] [-camera CAMERA] [-cp CP CP]
- [-repeat REPEAT] [-xray] [-loose] [-export] [-alternate]
-
- Program to allign multiple tools on Duet based printers, using machine vision.
-
- optional arguments:
- -h, --help show this help message and exit
- -duet DUET Name or IP address of Duet printer. You can use
- -duet=localhost if you are on the embedded Pi on a Duet3.
- -vidonly Open video window and do nothing else.
- -camera CAMERA Index of /dev/videoN device to be used. Default 0.
- -cp CP CP x y that will put 'controlled point' on carriage over
- camera.
- -repeat REPEAT Repeat entire alignment N times and report statistics
- -xray Display edge detection output for troubleshooting.
- -loose Run circle detection algorithm with less stringent
- parameters to help detect worn nozzles.
- -export Export repeat raw data to output.csv when done.
- -alternate Try alternative nozzle detection method
-
-
-_[back to top](#table-of-contents)_
-
-# TAMV Community Videos
-## Danal's Original TAMV Release
-You can find the original release of [TAMV in Danal Estes' repository](https://github.com/DanalEstes/TAMV).
-
-[![Danal's Original TAMV Release](http://img.youtube.com/vi/e_d_XHwGfRM/0.jpg)](https://www.youtube.com/watch?v=e_d_XHwGfRM)
-## Walkthrough: Luke's Laboratory
-Be sure to check out [Luke's latest releases on his website](https://www.lukeslab.online/).
-
-[![Luke's Laboratory Walkthrough](http://img.youtube.com/vi/ZjgjIAw_s7E/0.jpg)](https://www.youtube.com/watch?v=ZjgjIAw_s7E)
-## Walkthrough: Reefing Ninja
-[![Reefing Ninja's Walkthrough](http://img.youtube.com/vi/uPa1ecRxpr4/0.jpg)](https://www.youtube.com/watch?v=uPa1ecRxpr4)
-
-_[back to top](#table-of-contents)_
diff --git a/TAMV.py b/TAMV.py
new file mode 100644
index 0000000..6fae1b6
--- /dev/null
+++ b/TAMV.py
@@ -0,0 +1,2626 @@
+#!/usr/bin/env python3
+
+import argparse, logging, os, sys, traceback, time
+from logging.handlers import RotatingFileHandler
+from urllib.parse import urlparse
+# openCV imports
+from cv2 import cvtColor, COLOR_BGR2RGB
+# Qt imports
+from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QSize, QThread, QMutex
+from PyQt5.QtGui import QIcon, QPixmap
+from PyQt5.QtWidgets import QMainWindow, QDesktopWidget, QStyle, QWidget, QMenu, QAction, QStatusBar, QLabel, QHBoxLayout, QVBoxLayout, QTextEdit, QPushButton, QApplication, QTabWidget, QButtonGroup, QGridLayout, QFrame, QCheckBox
+# Other imports
+import json
+import numpy as np
+import copy
+import threading
+# Custom modules import
+from modules.SettingsDialog import SettingsDialog
+from modules.ConnectionDialog import ConnectionDialog
+from modules.DetectionManager import DetectionManager
+from modules.PrinterManager import PrinterManager
+from modules.StatusTipFilter import StatusTipFilter
+
+########################################################################### Core application class
+class App(QMainWindow):
+ # Global mutex
+ __mutex = QMutex()
+ # Signals
+ ######## Detection Manager
+ startVideoSignal = pyqtSignal()
+ setImagePropertiesSignal = pyqtSignal(object)
+ getVideoFrameSignal = pyqtSignal()
+ # Endstop detection signals
+ toggleEndstopDetectionSignal = pyqtSignal(bool)
+ toggleEndstopAutoDetectionSignal = pyqtSignal(bool)
+ # Nozzle detection signals
+ toggleNozzleDetectionSignal = pyqtSignal(bool)
+ toggleNozzleAutoDetectionSignal = pyqtSignal(bool)
+ # UV coordinates update signal
+ getUVCoordinatesSignal = pyqtSignal()
+ # Master detection enable/disable signal
+ toggleDetectionSignal = pyqtSignal(bool)
+
+ ######## Printer Manager
+ connectSignal = pyqtSignal(object)
+ disconnectSignal = pyqtSignal(object)
+ moveRelativeSignal = pyqtSignal(object)
+ moveAbsoluteSignal = pyqtSignal(object)
+ callToolSignal = pyqtSignal(int)
+ unloadToolSignal = pyqtSignal()
+ pollCoordinatesSignal = pyqtSignal()
+ pollCurrentToolSignal = pyqtSignal()
+ setOffsetsSignal = pyqtSignal(object)
+ limitAxesSignal = pyqtSignal()
+ flushBufferSignal = pyqtSignal()
+ saveToFirmwareSignal = pyqtSignal()
+ announceSignal = pyqtSignal(bool)
+ # Settings Dialog
+ resetImageSignal = pyqtSignal()
+ pushbuttonSize = 38
+ # default move speed in feedrate/min
+ __moveSpeed = 6000
+ # Maximum number of retries for detection
+ __maxRetries = 3
+ # Maximum runtime (in seconds) for calibration cycles
+ __maxRuntime = 120
+ # Timeout for Qthread termination (DetectionManager and PrinterManager)
+ __detectionManagerThreadWaitTime = 20
+ __printerManagerThreadWaitTime = 60
+
+ ########################################################################### Initialize class
+ def __init__(self, parent=None):
+ # send calling to log
+ _logger.debug('*** calling App.__init__')
+
+ # output greeting to log
+ _logger.info('Initializing application.. ')
+ # call QMainWindow init function
+ super().__init__()
+ #### class attributes definition
+ if(True):
+ # main window size
+ self.windowWidthOriginal, self.windowHeightOriginal = 800, 600
+ # main camera capture size
+ # self.imageWidthOriginal, self.imageHeightOriginal = 640, 480
+ # stylesheets used for interface status updates
+ self.styleGreen = '* { background-color: green; color: white;} QToolTip { color: black; background-color: #DDDDDD } *:hover{background-color: #003874; color: #ffa000;} *:pressed{background-color: #ffa000; color: #003874;}'
+ self.styleRed = '* { background-color: red; color: white;} QToolTip { color: black; background-color: #DDDDDD }'
+ self.styleDisabled = '* { background-color: #cccccc; color: #999999; border-style: solid;} QToolTip { color: black; background-color: #DDDDDD }'
+ self.styleOrange = '* { background-color: dark-grey; color: #ffa000;} QToolTip { color: black; background-color: #DDDDDD }'
+ self.styleBlue = '* { background-color: #003874; color: #ffa000;} QToolTip { color: black; background-color: #DDDDDD }'
+ self.styleDefault = '* { background-color: rgba(0,0,0,0); color: black;} QToolTip { color: black; background-color: #DDDDDD }'
+ # standby image placeholder
+ self.standbyImage = QPixmap('./resources/background.png')
+ self.errorImage = QPixmap('./resources/error.png')
+ # user-defined cameras array
+ self.__cameras = []
+ # active camera
+ self.__activeCamera ={}
+ # user-defined printers array
+ self.__printers = []
+ # active printer
+ self.__activePrinter = {}
+ # Control Point
+ self.__cpCoordinates = {'X': None, 'Y': None, 'Z': None}
+ self.__currentPosition = {'X': None, 'Y': None, 'Z': None}
+ self.__stateManualCPCapture = False
+ self.__stateAutoCPCapture = False
+ self.__stateEndstopAutoCalibrate = False
+ self.__restorePosition = None
+ self.__firstConnection = False
+ self.state = 0
+ # Camera transform matrix
+ self.transformMatrix = None
+ self.transform_input = None
+ self.mpp = None
+ # Nozzle detection
+ self.__stateAutoNozzleAlignment = False
+ self.__stateOverrideManualNozzleAlignment = False
+ self.singleToolOffsetsCapture = False
+ self.__displayCrosshair = False
+
+ #### setup window properties
+ if(True):
+ _logger.debug(' .. setting up window properties..')
+ self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
+ self.setWindowTitle('TAMV')
+ self.setWindowIcon(QIcon('./resources/jubilee.png'))
+ screen = QDesktopWidget().availableGeometry()
+ self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(self.windowWidthOriginal,self.windowHeightOriginal),screen))
+ self.setMinimumWidth(self.windowWidthOriginal)
+ self.setMinimumHeight(self.windowHeightOriginal)
+ appScreen = self.frameGeometry()
+ appScreen.moveCenter(screen.center())
+ self.move(appScreen.topLeft())
+ self.centralWidget = QWidget()
+ self.setCentralWidget(self.centralWidget)
+ # create stylehseets
+ self.globalStylesheet = '\
+ QLabel#instructions_text {\
+ background-color: rgba(255,153,0,.4);\
+ }\
+ QPushButton {\
+ border: 1px solid #adadad;\
+ border-style: outset;\
+ border-radius: 4px;\
+ font: 16px;\
+ padding: 2px;\
+ }\
+ QPushButton>QToolTip {\
+ color: black;\
+ }\
+ QPushButton#calibrating:enabled {\
+ background-color: orange;\
+ color: white;\
+ }\
+ QPushButton#completed:enabled {\
+ background-color: blue;\
+ color: white;\
+ }\
+ QPushButton:hover,QPushButton:enabled:hover,QPushButton:enabled:!checked:hover,QPushButton#completed:enabled:hover {\
+ background-color: #003874;\
+ color: #ffa000;\
+ border: 1px solid #aaaaaa;\
+ }\
+ QPushButton:pressed,QPushButton:enabled:pressed,QPushButton:enabled:checked,QPushButton#completed:enabled:pressed {\
+ background-color: #ffa000;\
+ border: 1px solid #aaaaaa;\
+ }\
+ QPushButton:enabled {\
+ background-color: green;\
+ color: white;\
+ }\
+ QLabel#labelPlus {\
+ font: 20px;\
+ padding: 0px;\
+ }\
+ QPushButton#plus:enabled {\
+ font: 20px;\
+ padding: 0px;\
+ background-color: #eeeeee;\
+ color: #000000;\
+ }\
+ QPushButton#plus:enabled:hover {\
+ font: 20px;\
+ padding: 0px;\
+ background-color: green;\
+ color: #000000;\
+ }\
+ QPushButton#plus:enabled:pressed {\
+ font: 20px;\
+ padding: 0px;\
+ background-color: #FF0000;\
+ color: #222222;\
+ }\
+ QPushButton#debug,QMessageBox > #debug {\
+ background-color: blue;\
+ color: white;\
+ }\
+ QPushButton#debug:hover, QMessageBox > QAbstractButton#debug:hover {\
+ background-color: green;\
+ color: white;\
+ }\
+ QPushButton#debug:pressed, QMessageBox > QAbstractButton#debug:pressed {\
+ background-color: #ffa000;\
+ border-style: inset;\
+ color: white;\
+ }\
+ QPushButton#active, QMessageBox > QAbstractButton#active {\
+ background-color: green;\
+ color: white;\
+ }\
+ QPushButton#active:pressed,QMessageBox > QAbstractButton#active:pressed {\
+ background-color: #ffa000;\
+ }\
+ QPushButton#terminate {\
+ background-color: red;\
+ color: white;\
+ }\
+ QPushButton#terminate:pressed {\
+ background-color: #c0392b;\
+ }\
+ QPushButton:disabled, QPushButton#terminate:disabled {\
+ background-color: #cccccc;\
+ color: #999999;\
+ }\
+ QInputDialog QDialogButtonBox > QPushButton:enabled, QDialog QPushButton:enabled,QPushButton[checkable="true"]:enabled {\
+ background-color: none;\
+ color: black;\
+ border: 1px solid #adadad;\
+ border-style: outset;\
+ border-radius: 4px;\
+ font: 14px;\
+ padding: 6px;\
+ }\
+ QPushButton:enabled:checked {\
+ background-color: #ffa000;\
+ border: 1px solid #aaaaaa;\
+ }\
+ QInputDialog QDialogButtonBox > QPushButton:pressed, QDialog QPushButton:pressed {\
+ background-color: #ffa000;\
+ }\
+ QInputDialog QDialogButtonBox > QPushButton:hover:!pressed, QDialog QPushButton:hover:!pressed {\
+ background-color: #003874;\
+ color: #ffa000;\
+ }\
+ QToolTip, QLabel > QToolTip, QPushButton > QLabel> QToolTip {\
+ color: black;\
+ }\
+ '
+ self.setStyleSheet(self.globalStylesheet)
+ #### Driver API imports
+ if(True):
+ try:
+ self.__firmwareList = []
+ self.__driverList = []
+ with open('drivers.json','r') as inputfile:
+ driverJSON = json.load(inputfile)
+ _logger.info(' .. loading drivers..')
+ for driverEntry in driverJSON:
+ self.__firmwareList.append(driverEntry['firmware'])
+ self.__driverList.append(driverEntry['filename'])
+ except:
+ _logger.critical('Cannot load driver definitions: ' + traceback.format_exc())
+ raise SystemExit('Cannot load driver definitions.')
+ #### Setup components
+ # load user parameters
+ if(True):
+ try:
+ with open('./config/settings.json','r') as inputfile:
+ self.__userSettings = json.load(inputfile)
+ except FileNotFoundError:
+ try:
+ # try and see if moving from older build
+ with open('./settings.json','r') as inputfile:
+ self.__userSettings = json.load(inputfile)
+ try:
+ _logger.info(' .. moving settings file to /config/settings.json.. ')
+ # create config folder if it doesn't exist
+ os.makedirs('./config',exist_ok=True)
+ # move settings.json to new config folder
+ os.replace('./settings.json','./config/settings.json')
+ except:
+ _logger.warning('Cannot rename old settings.json, leaving in place and using new file.')
+ except FileNotFoundError:
+ # create config folder if it doesn't exist
+ os.makedirs('./config',exist_ok=True)
+ # No settings file defined, create a default file
+ _logger.info(' .. creating new settings.json..')
+ # create a camera array
+ self.__userSettings['camera'] = [
+ {
+ 'video_src': 0,
+ 'display_width': '640',
+ 'display_height': '480',
+ 'default': 1
+ } ]
+ # Create a printer array
+ self.__userSettings['printer'] = [
+ {
+ 'address': 'http://localhost',
+ 'password': 'reprap',
+ 'name': 'My Duet',
+ 'nickname': 'Default',
+ 'controller' : 'RRF',
+ 'version': '',
+ 'default': 1,
+ 'rotated': 0,
+ 'tools': [
+ {
+ 'number': 0,
+ 'name': 'Tool 0',
+ 'nozzleSize': 0.4,
+ 'offsets': [0,0,0]
+ } ]
+ } ]
+ try:
+ # set class attributes
+ self._cameraWidth = int(self.__userSettings['camera'][0]['display_width'])
+ self._cameraHeight = int(self.__userSettings['camera'][0]['display_height'])
+ self._videoSrc = self.__userSettings['camera'][0]['video_src']
+ # save default settings file
+ with open('./config/settings.json','w') as outputfile:
+ json.dump(self.__userSettings, outputfile)
+ except Exception as e1:
+ errorMsg = 'Error reading user settings file.' + traceback.format_exc()
+ _logger.critical(errorMsg)
+ raise SystemExit(errorMsg)
+ _logger.info(' .. reading configuration settings..')
+ # Fetch defined cameras
+ if(True):
+ defaultCameraDefined = False
+ for source in self.__userSettings['camera']:
+ try:
+ self.__cameras.append(source)
+ if(source['default'] == 1 and defaultCameraDefined is False):
+ self.__activeCamera = source
+ defaultCameraDefined = True
+ elif(defaultCameraDefined):
+ source['default'] = 0
+ continue
+ except KeyError as ke:
+ source['default'] = 0
+ continue
+ if(defaultCameraDefined is False):
+ self.__userSettings['camera'][0]['default'] = 1
+ self.__activeCamera = self.__userSettings['camera'][0]
+ self._cameraHeight = int(self.__activeCamera['display_height'])
+ self._cameraWidth = int(self.__activeCamera['display_width'])
+ self._videoSrc = self.__activeCamera['video_src']
+ if(len(str(self._videoSrc)) == 1 or str(self._videoSrc) == "-1"):
+ self._videoSrc = int(self._videoSrc)
+ # Fetch defined machines
+ if(True):
+ defaultPrinterDefined = False
+ for machine in self.__userSettings['printer']:
+ # Find default printer first
+ try:
+ self.__printers.append(machine)
+ if(machine['default'] == 1):
+ self.__activePrinter = machine
+ defaultPrinterDefined = True
+ except KeyError as ke:
+ # no default field detected - create a default if not already done
+ if(defaultPrinterDefined is False):
+ machine['default'] = 1
+ defaultPrinterDefined = True
+ else:
+ machine['default'] = 0
+ # Check if password doesn't exist
+ try:
+ temp = machine['password']
+ except KeyError:
+ machine['password'] = 'reprap'
+ # Check if nickname doesn't exist
+ try:
+ temp = machine['nickname']
+ except KeyError:
+ machine['nickname'] = machine['name']
+ # Check if controller doesn't exist
+ try:
+ temp = machine['controller']
+ except KeyError:
+ machine['controller'] = 'RRF'
+ # Check if version doesn't exist
+ try:
+ temp = machine['version']
+ except KeyError:
+ machine['version'] = ''
+ # Check if rotated kinematics doesn't exist
+ try:
+ temp = machine['rotated']
+ except KeyError:
+ machine['rotated'] = 0
+ # Check if tools doesn't exist
+ try:
+ temp = machine['tools']
+ except KeyError:
+ machine['tools'] = [ { 'number': 0, 'name': 'Tool 0', 'nozzleSize': 0.4, 'offsets': [0,0,0] } ]
+ if(machine['default'] == 1):
+ self.__activePrinter = machine
+ # Check if we have no default machine
+ if(defaultPrinterDefined is False):
+ self.__activePrinter = self.__userSettings['printer'][0]
+ if(self.__activePrinter['controller'] == 'RRF'):
+ (_errCode, _errMsg, self.printerURL) = self.sanitizeURL(self.__activePrinter['address'])
+ self.__activePrinter['address'] = self.printerURL
+ if _errCode > 0:
+ # invalid input
+ _logger.error('Invalid printer URL detected in settings.json')
+ _logger.error(_errMsg)
+ _logger.info('Defaulting to \"http://localhost\"...')
+ self.printerURL = 'http://localhost'
+
+ ##### Settings Dialog
+ self.__settingsGeometry = None
+ # Note: settings dialog is created when user clicks the button
+
+ #### Setup interface
+ ##### Menu bar
+ self.setupMenu()
+ ##### Status bar
+ self.setupStatusbar()
+ ##### GUI elements
+ self.setupMainWindow()
+
+ # send exiting to log
+ _logger.debug('*** exiting App.__init__')
+
+ ########################################################################### Menu setup
+ def setupMenu(self):
+ # send calling to log
+ _logger.debug('*** calling App.setupMenu')
+
+ self.menubar = self.menuBar()
+ self.menubar.installEventFilter(StatusTipFilter(self))
+ #### File menu
+ fileMenu = QMenu('&File', self)
+ self.menubar.addMenu(fileMenu)
+ #### Preferences
+ self.preferencesAction = QAction(self)
+ self.preferencesAction.setText('&Preferences..')
+ self.preferencesAction.triggered.connect(self.displayPreferences)
+ fileMenu.addAction(self.preferencesAction)
+ #### Save offsets to firmware
+ self.saveFirmwareAction = QAction(self)
+ self.saveFirmwareAction.setText('&Save offsets..')
+ self.saveFirmwareAction.triggered.connect(self.saveOffsets)
+ fileMenu.addAction(self.saveFirmwareAction)
+ # Quit
+ self.quitAction = QAction(self)
+ self.quitAction.setText('&Quit')
+ self.quitAction.triggered.connect(self.close)
+ fileMenu.addSeparator()
+ fileMenu.addAction(self.quitAction)
+
+ # send exiting to log
+ _logger.debug('*** exiting App.setupMenu')
+
+ ########################################################################### Status bar setup
+ def setupStatusbar(self):
+ # send calling to log
+ _logger.debug('*** calling App.setupStatusbar')
+
+ self.statusBar = QStatusBar()
+ self.statusBar.showMessage('Welcome.')
+ self.setStatusBar(self.statusBar)
+ #### CP coodinate status
+ self.cpLabel = QLabel('CP: undef')
+ self.statusBar.addPermanentWidget(self.cpLabel)
+ self.cpLabel.setStyleSheet(self.styleOrange)
+
+ #### Connection status
+ self.connectionStatusLabel = QLabel('Disconnected')
+ self.connectionStatusLabel.setStyleSheet(self.styleOrange)
+ self.statusBar.addPermanentWidget(self.connectionStatusLabel)
+
+ # send exiting to log
+ _logger.debug('*** exiting App.setupStatusbar')
+
+ ########################################################################### Main window setup
+ def setupMainWindow(self):
+ # send calling to log
+ _logger.debug('*** calling App.setupMenu')
+
+ self.containerLayout = QVBoxLayout()
+ self.containerLayout.setSpacing(8)
+ self.centralWidget.setLayout(self.containerLayout)
+ #### Main grid layout
+ if(True):
+ self.mainLayout = QHBoxLayout()
+ self.mainLayout.setSpacing(8)
+ self.containerLayout.addLayout(self.mainLayout)
+ ##### Left toolbar
+ if(True):
+ self.leftToolbarLayout = QVBoxLayout()
+ self.leftToolbarLayout.setAlignment(Qt.AlignTop)
+ self.mainLayout.addLayout(self.leftToolbarLayout)
+ # Connect button
+ self.connectButton = QPushButton('+')
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ self.connectButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.connectButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.connectButton.setToolTip('Connect..')
+ self.connectButton.setDisabled(True)
+ self.connectButton.clicked.connect(self.connectPrinter)
+ self.leftToolbarLayout.addWidget(self.connectButton)#, 1, 0, 1, 1, Qt.AlignLeft|Qt.AlignTop)
+ # Disconnect button
+ self.disconnectButton = QPushButton('D')
+ self.disconnectButton.setStyleSheet(self.styleDisabled)
+ self.disconnectButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.disconnectButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.disconnectButton.setToolTip('Disconnect..')
+ self.disconnectButton.setDisabled(True)
+ self.disconnectButton.clicked.connect(self.haltPrinterOperation)
+ self.leftToolbarLayout.addWidget(self.disconnectButton)#, 1, 0, 1, 1, Qt.AlignLeft|Qt.AlignTop)
+ # Crosshair button
+ self.crosshairDisplayButton = QPushButton('-+-')
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.crosshairDisplayButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.crosshairDisplayButton.setToolTip('toggle crosshair on display')
+ self.crosshairDisplayButton.setDisabled(False)
+ self.crosshairDisplayButton.setChecked(False)
+ self.crosshairDisplayButton.clicked.connect(self.toggleCrosshair)
+ self.leftToolbarLayout.addWidget(self.crosshairDisplayButton)
+ # Setup Control Point button
+ self.cpSetupButton = QPushButton('CP')
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ self.cpSetupButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.cpSetupButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.cpSetupButton.setToolTip('Setup Control Point..')
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.clicked.connect(self.setupCPCapture)
+ self.leftToolbarLayout.addWidget(self.cpSetupButton)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton = QPushButton('Auto')
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ self.cpAutoCaptureButton.setMinimumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.cpAutoCaptureButton.setMaximumSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.cpAutoCaptureButton.setToolTip('Automated CP Capture..')
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.clicked.connect(self.setupCPAutoCapture)
+ self.leftToolbarLayout.addWidget(self.cpAutoCaptureButton)
+
+ ##### Main image preview
+ if(True):
+ self.image = QLabel(self)
+ self.image.resize(self._cameraWidth, self._cameraHeight)
+ self.image.setMinimumWidth(self._cameraWidth)
+ self.image.setMinimumHeight(self._cameraHeight)
+ self.image.setAlignment(Qt.AlignLeft)
+ # self.image.setStyleSheet('text-align:center; border: 1px solid black')
+ self.image.setPixmap(self.standbyImage)
+ self.mainLayout.addWidget(self.image)#, 1, 1, 1, -1, Qt.AlignLeft|Qt.AlignTop)
+
+ ##### Right toolbar
+ if(True):
+ # Jog Panel
+ self.tabPanel = QTabWidget()
+ self.firstTab = QWidget()
+ self.jogPanel = QGridLayout()
+ self.jogPanel.setAlignment(Qt.AlignRight|Qt.AlignTop)
+ self.jogPanel.setSpacing(5)
+
+ # create jogPanel buttons
+ ## increment size
+ self.button_1 = QPushButton('1')
+ self.button_1.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_1.setMaximumHeight(self.pushbuttonSize)
+ self.button_1.setToolTip('set jog distance to 1 unit')
+ self.button_01 = QPushButton('.1')
+ self.button_01.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_01.setMaximumHeight(self.pushbuttonSize)
+ self.button_01.setToolTip('set jog distance to 0.1 unit')
+ self.button_001 = QPushButton('.01')
+ self.button_001.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_001.setMaximumHeight(self.pushbuttonSize)
+ self.button_001.setToolTip('set jog distance to 0.01 unit')
+ self.incrementButtonGroup = QButtonGroup()
+ self.incrementButtonGroup.addButton(self.button_1)
+ self.incrementButtonGroup.addButton(self.button_01)
+ self.incrementButtonGroup.addButton(self.button_001)
+ self.incrementButtonGroup.setExclusive(True)
+ self.button_1.setCheckable(True)
+ self.button_01.setCheckable(True)
+ self.button_001.setCheckable(True)
+ self.button_1.setChecked(True)
+
+ # horizontal separators
+ self.incrementLine = QFrame()
+ self.incrementLine.setFrameShape(QFrame.HLine)
+ self.incrementLine.setLineWidth(1)
+ self.incrementLine.setFrameShadow(QFrame.Sunken)
+ self.keypadLine = QFrame()
+ self.keypadLine.setFrameShape(QFrame.HLine)
+ self.keypadLine.setFrameShadow(QFrame.Sunken)
+ self.keypadLine.setLineWidth(1)
+
+ ## X movement
+ self.button_x_left = QPushButton('-X', objectName='plus')
+ self.button_x_left.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_x_left.setMaximumHeight(self.pushbuttonSize)
+ self.button_x_left.setToolTip('jog X-')
+ self.button_x_left.clicked.connect(self.xleftClicked)
+ self.button_x_right = QPushButton('X+', objectName='plus')
+ self.button_x_right.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_x_right.setMaximumHeight(self.pushbuttonSize)
+ self.button_x_right.setToolTip('jog X+')
+ self.button_x_right.clicked.connect(self.xRightClicked)
+
+ ## Y movement
+ self.button_y_left = QPushButton('-Y', objectName='plus')
+ self.button_y_left.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_y_left.setMaximumHeight(self.pushbuttonSize)
+ self.button_y_left.setToolTip('jog Y-')
+ self.button_y_left.clicked.connect(self.yleftClicked)
+ self.button_y_right = QPushButton('Y+', objectName='plus')
+ self.button_y_right.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_y_right.setMaximumHeight(self.pushbuttonSize)
+ self.button_y_right.setToolTip('jog Y+')
+ self.button_y_right.clicked.connect(self.yRightClicked)
+
+ ## Z movement
+ self.button_z_down = QPushButton('-Z', objectName='plus')
+ self.button_z_down.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_z_down.setMaximumHeight(self.pushbuttonSize)
+ self.button_z_down.setToolTip('jog Z-')
+ self.button_z_down.clicked.connect(self.zleftClicked)
+ self.button_z_up = QPushButton('Z+', objectName='plus')
+ self.button_z_up.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ self.button_z_up.setMaximumHeight(self.pushbuttonSize)
+ self.button_z_up.setToolTip('jog Z+')
+ self.button_z_up.clicked.connect(self.zRightClicked)
+
+ ## layout jogPanel buttons
+ # add increment buttons
+ self.jogPanel.addWidget(self.button_001,0,0)
+ self.jogPanel.addWidget(self.button_01,0,1)
+ self.jogPanel.addWidget(self.button_1,1,1)
+ # add separator
+ self.jogPanel.addWidget(self.incrementLine, 2,0,1,2)
+ # add X movement buttons
+ self.jogPanel.addWidget(self.button_x_left,3,0)
+ self.jogPanel.addWidget(self.button_x_right,3,1)
+ # add Y movement buttons
+ self.jogPanel.addWidget(self.button_y_left,4,0)
+ self.jogPanel.addWidget(self.button_y_right,4,1)
+ # add Z movement buttons
+ self.jogPanel.addWidget(self.button_z_down,5,0)
+ self.jogPanel.addWidget(self.button_z_up,5,1)
+ # add separator
+ self.jogPanel.addWidget(self.keypadLine, 6,0,1,2)
+
+ self.tabPanel.setDisabled(True)
+ self.mainLayout.addWidget(self.tabPanel)
+ self.firstTab.setLayout(self.jogPanel)
+ self.tabPanel.addTab(self.firstTab,'Jog')
+ self.tabPanel.setFixedWidth(95)
+ self.tabPanel.setTabBarAutoHide(True)
+ self.tabPanel.setStyleSheet('QTabWidget::pane {\
+ margin: -13px -9px -13px -9px;\
+ border: 1px solid white;\
+ padding: 0px;\
+ }')
+
+ #### Footer layout
+ if(True):
+ self.footerLayout = QGridLayout()
+ self.footerLayout.setSpacing(0)
+ self.containerLayout.addLayout(self.footerLayout)
+ # Intructions box
+ self.instructionsBox = QTextEdit()
+ self.instructionsBox.setReadOnly(True)
+ self.instructionsBox.setFixedSize(640, 45)
+ self.instructionsBox.setVisible(False)
+ self.footerLayout.addWidget(self.instructionsBox, 0,0,1,-1,Qt.AlignLeft|Qt.AlignVCenter)
+
+ # Manual CP Capture button
+ self.manualCPCaptureButton = QPushButton('Save CP')
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ self.manualCPCaptureButton.setMinimumSize(self.pushbuttonSize*2,self.pushbuttonSize)
+ self.manualCPCaptureButton.setMaximumSize(self.pushbuttonSize*2,self.pushbuttonSize)
+ self.manualCPCaptureButton.setToolTip('Capture current machine coordinates as the Control Point.')
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.clicked.connect(self.manualCPCapture)
+ self.footerLayout.addWidget(self.manualCPCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter)
+
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton = QPushButton('Capture offset')
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ self.overrideManualOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.overrideManualOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.overrideManualOffsetCaptureButton.setToolTip('Capture current position and calculate tool offset.')
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.clicked.connect(self.overrideManualToolOffsetCapture)
+ self.footerLayout.addWidget(self.overrideManualOffsetCaptureButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter)
+
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton = QPushButton('Capture offset')
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ self.manualToolOffsetCaptureButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.manualToolOffsetCaptureButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.manualToolOffsetCaptureButton.setToolTip('Capture current position and calculate tool offset.')
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.clicked.connect(self.manualToolOffsetCapture)
+ self.footerLayout.addWidget(self.manualToolOffsetCaptureButton, 0,0,1,1,Qt.AlignRight|Qt.AlignVCenter)
+
+ # Start Alignment button
+ self.startAlignToolsButton = QPushButton('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleDisabled)
+ self.startAlignToolsButton.setMinimumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.startAlignToolsButton.setMaximumSize(self.pushbuttonSize*3,self.pushbuttonSize)
+ self.startAlignToolsButton.setToolTip('Start automated tool offset calibration')
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.clicked.connect(self.startAlignTools)
+ self.footerLayout.addWidget(self.startAlignToolsButton, 0,1,1,1,Qt.AlignRight|Qt.AlignVCenter)
+
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton = QPushButton('Resume Auto Align')
+ self.resumeAutoToolAlignmentButton.setMinimumSize(self.pushbuttonSize*4,self.pushbuttonSize)
+ self.resumeAutoToolAlignmentButton.setMaximumSize(self.pushbuttonSize*4,self.pushbuttonSize)
+ self.resumeAutoToolAlignmentButton.setToolTip('Resume automated calibration')
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.clicked.connect(self.resumeAutoAlignment)
+ self.footerLayout.addWidget(self.resumeAutoToolAlignmentButton, 0,0,1,1,Qt.AlignRight|Qt.AlignVCenter)
+
+
+ #### Set current interface state to disconnected
+ self.stateDisconnected()
+
+ # send exiting to log
+ _logger.debug('*** exiting App.setupMenu')
+
+ ########################################################################### Jog Panel functions
+ def xleftClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'X': str(-1*incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ def xRightClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'X': str(incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ def yleftClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'Y': str(-1*incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ def yRightClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'Y': str(incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ def zleftClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'Z': str(-1*incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ def zRightClicked(self):
+ # disable buttons
+ self.tabPanel.setDisabled(True)
+ # fetch current increment value
+ if self.button_1.isChecked():
+ incrementDistance = 1
+ elif self.button_01.isChecked():
+ incrementDistance = 0.1
+ elif self.button_001.isChecked():
+ incrementDistance = 0.01
+ params = {'moveSpeed': self.__moveSpeed, 'position':{'Z': str(incrementDistance)}}
+ self.moveRelativeSignal.emit(params)
+
+ ########################################################################### GUI State functions
+ def stateDisconnected(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(True)
+ self.connectButton.setDisabled(False)
+ self.connectButton.setStyleSheet(self.styleGreen)
+ # Disconnect button
+ self.disconnectButton.setVisible(False)
+ self.disconnectButton.setDisabled(True)
+ self.disconnectButton.setStyleSheet(self.styleDisabled)
+ # Setup CP button
+ self.cpSetupButton.setVisible(False)
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(False)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleDisabled)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Delete tool buttons
+ count = self.jogPanel.count()
+ for i in range(11,count):
+ item = self.jogPanel.itemAt(i)
+ widget = item.widget()
+ widget.setVisible(False)
+ # widget.deleteLater()
+ self.resetCalibration()
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(False)
+ if(self.__displayCrosshair):
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.crosshairDisplayButton.setChecked(True)
+ else:
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(True)
+
+ def stateConnected(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('D')
+ self.disconnectButton.setToolTip('Disconnect..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(False)
+ self.cpSetupButton.setStyleSheet(self.styleGreen)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(False)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleDisabled)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Jog panel tab
+ self.tabPanel.setDisabled(False)
+
+ # Add tool checkboxes to right panel
+ self.toolButtons = []
+ self.toolCheckboxes = []
+ # highest tool number storage
+ numTools = max(self.__activePrinter['tools'], key= lambda x:int(x['number']))['number']
+ _logger.debug('Highest tool number is: ' + str(numTools))
+ # Delete old toolbuttons, if they exist
+ # Delete tool buttons
+ count = self.jogPanel.count()
+ for i in range(11,count):
+ item = self.jogPanel.itemAt(i)
+ widget = item.widget()
+ widget.deleteLater()
+ currentTool = 0
+ for tool in range(numTools+1):
+ # add tool buttons
+ toolButton = QPushButton('T' + str(tool))
+ toolButton.setObjectName('toolButton_'+str(tool))
+ toolButton.setFixedSize(self.pushbuttonSize,self.pushbuttonSize)
+ toolButton.clicked.connect(self.identifyToolButton)
+ # check if current index exists in tool numbers from machine
+ if(any(d.get('number', -1) == tool for d in self.__activePrinter['tools'])):
+ toolTip = 'Fetch T' + str(tool) + ' to current machine position.'
+ toolTip += ''
+ toolTip += 'X: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][0])
+ toolTip += '
'
+ toolTip += 'Y: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][1])
+ toolTip += '
'
+ toolTip += 'Z: ' + "{:>9.3f}".format(self.__activePrinter['tools'][currentTool]['offsets'][2])
+ toolTip += '
'
+ currentTool += 1
+ toolButton.setToolTip(toolTip)
+ self.toolButtons.append(toolButton)
+ # add tool checkboxes
+ toolCheckbox = QCheckBox()
+ toolCheckbox.setObjectName('toolCheckbox_' + str(tool))
+ toolCheckbox.setToolTip('Add T' + str(tool) + ' to calibration.')
+ toolCheckbox.setChecked(True)
+ toolCheckbox.setCheckable(True)
+ toolCheckbox.setObjectName('toolCheckbox_' + str(tool))
+ self.toolCheckboxes.append(toolCheckbox)
+
+ # Display tool buttons
+ for i,button in enumerate(self.toolButtons):
+ button.setCheckable(True)
+ index = button.objectName()
+ index = int(index[-1:])
+ if int(self.__activePrinter['currentTool']) == index:
+ button.setChecked(True)
+ else:
+ button.setChecked(False)
+ # button.clicked.connect(self.callTool)
+ self.jogPanel.addWidget(button, (7+i), 0, Qt.AlignCenter|Qt.AlignHCenter)
+
+ # Display tool checkboxes
+ for i,checkbox in enumerate(self.toolCheckboxes):
+ checkbox.setCheckable(True)
+ checkbox.setChecked(True)
+ # button.clicked.connect(self.callTool)
+ self.jogPanel.addWidget(checkbox, (7+i), 1, Qt.AlignCenter|Qt.AlignHCenter)
+ # Alignment/Detection state reset
+ self.state = 0
+ # Endstop calibration state flags
+ self.__stateManualCPCapture = False
+ self.__stateAutoCPCapture = False
+ self.__stateEndstopAutoCalibrate = False
+ self.toggleEndstopAutoDetectionSignal.emit(False)
+ # Nozzle calibration state flags
+ self.__stateOverrideManualNozzleAlignment = False
+ self.__stateAutoNozzleAlignment = False
+ self.toggleNozzleAutoDetectionSignal.emit(False)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(False)
+ if(self.__displayCrosshair):
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.crosshairDisplayButton.setChecked(True)
+ else:
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setChecked(False)
+
+ _logger.debug('Tool data and interface created successfully.')
+
+ def stateCPSetup(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('C')
+ self.disconnectButton.setToolTip('Cancel..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(True)
+ self.cpAutoCaptureButton.setDisabled(False)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleOrange)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(True)
+ self.manualCPCaptureButton.setDisabled(False)
+ self.manualCPCaptureButton.setStyleSheet(self.styleGreen)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleDisabled)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(False)
+ if(self.__displayCrosshair):
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.crosshairDisplayButton.setChecked(True)
+ else:
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(False)
+
+ def stateCPAuto(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('C')
+ self.disconnectButton.setToolTip('Cancel..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(True)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleDisabled)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(True)
+ self.crosshairDisplayButton.setStyleSheet(self.styleDisabled)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(True)
+
+ def stateCalibrateReady(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('C')
+ self.disconnectButton.setToolTip('Cancel..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(False)
+ self.cpSetupButton.setStyleSheet(self.styleBlue)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(False)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(True)
+ self.startAlignToolsButton.setDisabled(False)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleGreen)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(False)
+ if(self.__displayCrosshair):
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.crosshairDisplayButton.setChecked(True)
+ else:
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(False)
+ # Tool buttons
+ for button in self.toolButtons:
+ button.setStyleSheet('')
+ button.setDisabled(False)
+
+ def stateCalibtrateRunning(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(True)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('C')
+ self.disconnectButton.setToolTip('Cancel..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(False)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Detecting..')
+ self.startAlignToolsButton.setStyleSheet(self.styleOrange+' * {font: italic}')
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(True)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(True)
+ self.crosshairDisplayButton.setStyleSheet(self.styleDisabled)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(True)
+ # Tool selection buttons disable
+ for button in self.toolButtons:
+ button.setDisabled(True)
+
+ def stateCalibrateComplete(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(False)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(False)
+ self.disconnectButton.setStyleSheet(self.styleRed)
+ self.disconnectButton.setText('D')
+ self.disconnectButton.setToolTip('Disconnect..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(False)
+ self.cpSetupButton.setStyleSheet(self.styleBlue)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(False)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(True)
+ self.startAlignToolsButton.setDisabled(False)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleGreen)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(False)
+ if(self.__displayCrosshair):
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.crosshairDisplayButton.setChecked(True)
+ else:
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(False)
+ # Tool buttons
+ for button in self.toolButtons:
+ button.setStyleSheet('')
+ button.setDisabled(False)
+
+ def stateExiting(self):
+ # Settings option in menu
+ self.preferencesAction.setDisabled(True)
+ # Connect button
+ self.connectButton.setVisible(True)
+ self.connectButton.setDisabled(True)
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ # Disconnect button
+ self.disconnectButton.setVisible(True)
+ self.disconnectButton.setDisabled(True)
+ self.disconnectButton.setStyleSheet(self.styleDisabled)
+ self.disconnectButton.setText('-')
+ self.disconnectButton.setToolTip('Disconnecting..')
+ # Setup CP button
+ self.cpSetupButton.setVisible(True)
+ self.cpSetupButton.setDisabled(True)
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setVisible(True)
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual capture button
+ self.manualCPCaptureButton.setVisible(False)
+ self.manualCPCaptureButton.setDisabled(True)
+ self.manualCPCaptureButton.setStyleSheet(self.styleDisabled)
+ # Start Alignment button
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.startAlignToolsButton.setText('Align Tools')
+ self.startAlignToolsButton.setStyleSheet(self.styleGreen)
+ # Override Manual Tool offset Capture button
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Manual Tool offset Capture button
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # Resume auto alignment button
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ # Crosshair display button
+ self.crosshairDisplayButton.setVisible(True)
+ self.crosshairDisplayButton.setDisabled(True)
+ self.crosshairDisplayButton.setStyleSheet(self.styleDisabled)
+ self.crosshairDisplayButton.setChecked(False)
+ # Jog panel tab
+ self.tabPanel.setDisabled(True)
+
+ ########################################################################### User interactions
+ def setupCPCapture(self):
+ self.__stateSetupCPCapture = True
+ self.toggleEndstopDetectionSignal.emit(True)
+ # enable detection
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ # update machine coordinates
+ self.pollCoordinatesSignal.emit()
+ # Get original physical coordinates
+ self.originalPrinterPosition = self.__currentPosition
+ self.__restorePosition = copy.deepcopy(self.__currentPosition)
+ self.resetCalibrationVariables()
+ self.stateCPSetup()
+ self.repaint()
+
+ def manualCPCapture(self):
+ self.__stateSetupCPCapture = False
+ self.__stateManualCPCapture = True
+ # update machine coordinates
+ self.pollCoordinatesSignal.emit()
+ # stop endstop detection
+ self.toggleEndstopDetectionSignal.emit(False)
+ # update GUI
+ self.stateCalibrateReady()
+
+ def setupCPAutoCapture(self):
+ # send calling to log
+ _logger.debug('*** calling App.setupCPAutoCapture')
+ self.startTime = time.time()
+ #################################### Camera Calibration
+ # Update GUI state
+ self.stateCPAuto()
+ # set state flag to handle camera calibration
+ self.__stateEndstopAutoCalibrate = True
+ self.__stateAutoCPCapture = True
+ # Start detector in DetectionManager
+ self.toggleEndstopAutoDetectionSignal.emit(True)
+ self.uv = [None, None]
+ self.unloadToolSignal.emit()
+ self.pollCoordinatesSignal.emit()
+ # send exiting to log
+ _logger.debug('*** exiting App.setupCPAutoCapture')
+
+ def haltCPAutoCapture(self):
+ self.resetCalibration()
+ self.resetCalibrationVariables()
+ self.statusBar.showMessage('CP calibration cancelled.')
+ # Reset GUI
+ self.stateConnected()
+
+ def haltNozzleCapture(self):
+ self.resetNozzleAlignment()
+ self.statusBar.showMessage('Nozzle calibration halted.')
+ # Reset GUI
+ self.stateCalibrateReady()
+
+ def overrideManualToolOffsetCapture(self):
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ # set tool alignment state to trigger offset calculation
+ self.state = 100
+ self.pollCoordinatesSignal.emit()
+
+ def manualToolOffsetCapture(self):
+ self.manualToolOffsetCaptureButton.setDisabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': self.__currentPosition, 'cpCoordinates': self.__cpCoordinates, 'continue': False}
+ self.setOffsetsSignal.emit(params)
+
+ def resetCalibration(self):
+ # Reset program state, and frame capture control to defaults
+ self.__stateEndstopAutoCalibrate = False
+ self.__stateAutoCPCapture = False
+ self.__stateSetupCPCapture = False
+ self.__stateOverrideManualNozzleAlignment = False
+ self.__stateAutoNozzleAlignment = False
+ self.toggleEndstopAutoDetectionSignal.emit(False)
+ self.toggleNozzleAutoDetectionSignal.emit(False)
+ self.toggleEndstopDetectionSignal.emit(False)
+ self.toggleDetectionSignal.emit(False)
+ self.__displayCrosshair = False
+ self.resetCalibrationVariables()
+ if(self.__restorePosition is not None):
+ self.unloadToolSignal.emit()
+ self.getVideoFrameSignal.emit()
+
+ def resetNozzleAlignment(self):
+ # Reset program state, and frame capture control to defaults
+ self.__stateEndstopAutoCalibrate = False
+ self.__stateAutoCPCapture = False
+ self.__stateSetupCPCapture = False
+ self.__stateOverrideManualNozzleAlignment = False
+ self.__stateAutoNozzleAlignment = False
+ self.toggleEndstopAutoDetectionSignal.emit(False)
+ self.toggleNozzleAutoDetectionSignal.emit(False)
+ self.toggleEndstopDetectionSignal.emit(False)
+ self.toggleDetectionSignal.emit(False)
+ self.__displayCrosshair = False
+ self.resetCalibrationVariables()
+ self.tabPanel.setDisabled(True)
+ self.unloadToolSignal.emit()
+ self.getVideoFrameSignal.emit()
+
+ # Function to reset calibrateTools variables
+ def resetCalibrationVariables(self):
+ # Setup camera calibration move coordinates
+ self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ]
+ # reset all variables
+ self.guessPosition = [1,1]
+ self.uv = [None, None]
+ self.olduv = self.uv
+ if(self.transformMatrix is None or self.mpp is None):
+ _logger.debug('Camera calibration matrix reset.')
+ self.state = 0
+ elif(len(self.transformMatrix) < 6):
+ self.transformMatrix = None
+ self.mpp = None
+ self.state = 0
+ else:
+ self.state = 200
+ self.detect_count = 0
+ self.space_coordinates = []
+ self.camera_coordinates = []
+ self.retries = 0
+ self.calibrationMoves = 0
+ self.repeatCounter = 0
+
+ def startAlignTools(self):
+ # send calling to log
+ _logger.debug('*** calling App.startAlignTools')
+ self.startTime = time.time()
+ # start as automated alignment state
+ self.__stateOverrideManualNozzleAlignment = False
+ self.__stateAutoNozzleAlignment = True
+
+ # Update GUI state
+ self.stateCalibtrateRunning()
+ self.updateStatusbarMessage('Starting tool alignment..')
+
+ # Create array for tools included in alignment process
+ self.alignmentToolSet = []
+ self.workingToolset = None
+ for checkbox in self.toolCheckboxes:
+ if(checkbox.isChecked()):
+ self.alignmentToolSet.append(int(checkbox.objectName()[13:]))
+ if(len(self.alignmentToolSet) > 0):
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ self.calibrateTools(self.alignmentToolSet)
+ else:
+ # tell user no tools selected
+ errorMsg = 'No tools selected for alignment'
+ self.updateStatusbarMessage(errorMsg)
+ _logger.info(errorMsg)
+ self.haltNozzleCapture()
+ # send exiting to log
+ _logger.debug('*** exiting App.startAlignTools')
+
+ def identifyToolButton(self):
+ self.tabPanel.setDisabled(True)
+ sender = self.sender()
+ for button in self.toolButtons:
+ if button.objectName() != sender.objectName():
+ button.setChecked(False)
+ toolIndex = int(sender.objectName()[11:])
+ if(toolIndex != int(self.__activePrinter['currentTool'])):
+ self.updateStatusbarMessage('>>> Loading T' + str(toolIndex) + '.. >>>')
+ self.callTool(toolIndex)
+ else:
+ self.updateStatusbarMessage('<<< Unloading tool.. <<<')
+ self.callTool(-1)
+
+ def calibrateTools(self, toolset=None):
+ # toolset is passed the first time we call the function
+ # this creates the workingToolset, from which we pop each tool on each cycle
+ if(toolset is not None):
+ self.workingToolset = toolset
+ # check if we still have tools to calibrate in the workingToolset list
+ if(len(self.workingToolset)>0):
+ # grab the first item in the list (FIFO)
+ toolIndex = self.workingToolset[0]
+ _logger.info('Calibrating T' + str(toolIndex) +'..')
+ self.startAlignToolsButton.setText('Detecting T'+ str(toolIndex) +'..')
+ # delete the tool from the list before processing it
+ self.workingToolset.pop(0)
+ # update toolButtons GUI to indiciate which tool we're working on
+ for button in self.toolButtons:
+ buttonName = 'toolButton_' + str(toolIndex)
+ if(button.objectName() == buttonName):
+ button.setStyleSheet(self.styleOrange)
+ self.resetCalibrationVariables()
+ # main program state flags
+ self.__stateOverrideManualNozzleAlignment = False
+ self.__stateAutoNozzleAlignment = True
+ # DetectionManager state flag
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ # Capture start time of tool calibration run
+ self.toolTime = time.time()
+ # Call tool and start calibration
+ self.callTool(toolIndex)
+ else:
+ # entire list has been processed, output results
+ calibration_time = np.around(time.time() - self.startTime,1)
+ _logger.info('Calibration completed (' + str(calibration_time) + 's) with a resolution of ' + str(self.mpp) + '/pixel')
+ # save to firmware
+ self.saveOffsets()
+ # reset GUI
+ self.stateCalibrateComplete()
+ self.repaint()
+ # reset state flags for main program and Detection Manager
+ self.resetNozzleAlignment()
+
+ def autoCalibrate(self):
+ self.tabPanel.setDisabled(True)
+ # create variable for tracking the calibration run time, if not already defined
+ try:
+ if(runtime):
+ pass
+ except:
+ runtime = time.time()
+ if(self.uv is not None):
+ if(self.uv[0] is not None and self.uv[1] is not None):
+ # First calibration step
+ if(self.state == 0):
+ _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv))
+ self.updateStatusbarMessage('Calibrating camera step 0..')
+ if(self.olduv is not None):
+ if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]):
+ self.repeatCounter += 1
+ if(self.repeatCounter > 5):
+ self.nozzleDetectionFailed()
+ return
+ # loop through again
+ self.retries += 1
+ self.pollCoordinatesSignal.emit()
+ return
+ else:
+ self.repeatCounter = 0
+ self.olduv = self.uv
+ self.space_coordinates = []
+ self.camera_coordinates = []
+
+ self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y']))
+ self.camera_coordinates.append((self.uv[0], self.uv[1]))
+
+ # move carriage for calibration
+ self.offsetX = self.calibrationCoordinates[0][0]
+ self.offsetY = self.calibrationCoordinates[0][1]
+ params = {'position':{'X': self.offsetX, 'Y': self.offsetY}}
+ self.lastState = 0
+ self.state = 1
+ self.moveRelativeSignal.emit(params)
+ return
+ elif(self.state >= 1 and self.state < len(self.calibrationCoordinates)):
+ # capture the UV data when a calibration move has been executed, before returning to original position
+ if(self.state != self.lastState and self.state < len(self.calibrationCoordinates)):
+ _logger.debug('*** State: ' + str(self.state) + ' Coords:' + str(self.__currentPosition) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv))
+ if(self.olduv is not None):
+ if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]):
+ self.repeatCounter += 1
+ if(self.repeatCounter > 10):
+ self.nozzleDetectionFailed()
+ return
+ # loop through again
+ self.retries += 1
+ self.pollCoordinatesSignal.emit()
+ return
+ else:
+ self.repeatCounter = 0
+ # Calculate mpp at first move
+ if(self.state == 1):
+ self.mpp = np.around(0.5/self.getDistance(self.olduv[0],self.olduv[1],self.uv[0],self.uv[1]),3)
+ # save position as previous position
+ self.olduv = self.uv
+ # save machine coordinates for detected nozzle
+ self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y']))
+ # save camera coordinates
+ self.camera_coordinates.append((self.uv[0],self.uv[1]))
+
+ # return carriage to relative center of movement
+ self.offsetX = (-1*self.offsetX)
+ self.offsetY = (-1*self.offsetY)
+ self.lastState = self.state
+ params = {'position':{'X': self.offsetX, 'Y': self.offsetY}}
+ self.moveRelativeSignal.emit(params)
+ return
+ # otherwise, move the carriage from the original position to the next step and increment state
+ else:
+ self.updateStatusbarMessage('Calibrating camera step ' + str(self.state) + '..')
+ _logger.debug('Step ' + str(self.state) + ' detection UV: ' + str(self.uv))
+ # move carriage to next calibration point
+ self.offsetX = self.calibrationCoordinates[self.state][0]
+ self.offsetY = self.calibrationCoordinates[self.state][1]
+ self.lastState = int(self.state)
+ self.state += 1
+ params = {'position':{'X': self.offsetX, 'Y': self.offsetY}}
+ self.moveRelativeSignal.emit(params)
+ return
+ elif(self.state == len(self.calibrationCoordinates)):
+ # Camera calibration moves completed.
+ if(self.olduv is not None):
+ if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]):
+ self.repeatCounter += 1
+ if(self.repeatCounter > 10):
+ self.nozzleDetectionFailed()
+ return
+ # loop through again
+ self.retries += 1
+ self.pollCoordinatesSignal.emit()
+ return
+ else:
+ self.repeatCounter = 0
+ # Update GUI thread with current status and percentage complete
+ updateMessage = 'Millimeters per pixel is ' + str(self.mpp)
+ self.updateStatusbarMessage(updateMessage)
+ _logger.info(updateMessage)
+
+ # save position as previous position
+ self.olduv = self.uv
+ # save machine coordinates for detected nozzle
+ self.space_coordinates.append((self.__currentPosition['X'], self.__currentPosition['Y']))
+ # save camera coordinates
+ self.camera_coordinates.append((self.uv[0],self.uv[1]))
+
+ # calculate camera transformation matrix
+ cameraCalibrationTime = np.around(time.time() - self.startTime,1)
+ _logger.info('Camera calibrated (' + str(cameraCalibrationTime) + 's); aligning..')
+
+ # Calculate transformation matrix
+ self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)]
+ self.transformMatrix, self.transform_residual = self.least_square_mapping(self.transform_input)
+
+ # define camera center in machine coordinate space
+ self.newCenter = self.transformMatrix.T @ np.array([0, 0, 0, 0, 0, 1])
+ self.guessPosition[0]= np.around(self.newCenter[0],3)
+ self.guessPosition[1]= np.around(self.newCenter[1],3)
+ _logger.info('Calibration positional guess: ' + str(self.guessPosition))
+
+ # Set next calibration variables state
+ self.state = 200
+ self.retries = 0
+ self.calibrationMoves = 0
+
+ params = {'position':{'X': self.guessPosition[0], 'Y': self.guessPosition[1]}}
+ self.moveAbsoluteSignal.emit(params)
+ return
+ elif(self.state == 200):
+ # Update GUI with current status
+ if(self.__stateEndstopAutoCalibrate):
+ updateMessage = 'Endstop calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')'
+ else:
+ updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' calibration step ' + str(self.calibrationMoves) + '.. (MPP=' + str(self.mpp) +')'
+ self.updateStatusbarMessage(updateMessage)
+ # if(self.olduv is not None):
+ # if(self.olduv[0] == self.uv[0] and self.olduv[1] == self.uv[1]):
+ # # print('Repeating detection: ' + str(self.repeatCounter))
+ # self.repeatCounter += 1
+ # if(self.repeatCounter > 10):
+ # print('Failed to detect.')
+ # self.nozzleDetectionFailed()
+ # return
+ # # loop through again
+ # print('Retrying', self.retries)
+ # self.retries += 1
+ # self.pollCoordinatesSignal.emit()
+ # return
+ # else:
+ # # print('Took ' + str(self.repeatCounter) + ' attempts.')
+ # self.repeatCounter = 0
+ # increment moves counter
+ self.calibrationMoves += 1
+ # nozzle detected, frame rotation is set, start
+ self.cx,self.cy = self.normalize_coords(self.uv)
+ self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0]
+ self.offsets = -1*(0.55*self.transformMatrix.T @ self.v)
+ self.offsets[0] = np.around(self.offsets[0],3)
+ self.offsets[1] = np.around(self.offsets[1],3)
+ _logger.debug('*** State: ' + str(self.state) + ' retries: ' + str(self.retries) + ' X' + str(self.__currentPosition['X']) + ' Y' + str(self.__currentPosition['Y']) + ' UV: ' + str(self.uv) + ' old UV: ' + str(self.olduv) + ' Offsets: ' + str(self.offsets))
+ # Add rounding handling for endstop alignment
+ if(self.__stateEndstopAutoCalibrate):
+ if(abs(self.offsets[0])+abs(self.offsets[1]) <= 0.02):
+ self.offsets[0] = 0.0
+ self.offsets[1] = 0.0
+
+ # Start timer if we're calibrating the CP using the automated endstop detection
+ try:
+ if(self.toolTime):
+ pass
+ except:
+ self.toolTime = time.time()
+
+ # calculate current calibration cycle runtime
+ runtime = np.around(time.time() - self.toolTime,1)
+ # check if too much time has passed
+ if(runtime > 120 or self.calibrationMoves > 30):
+ print('Runtime:', runtime, ' moves: ', self.calibrationMoves)
+ self.retries = 10
+ # Otherwise, check if we're not aligned to the center
+ elif(self.offsets[0] != 0.0 or self.offsets[1] != 0.0):
+ self.olduv = self.uv
+ params = {'position': {'X': self.offsets[0], 'Y': self.offsets[1]}, 'moveSpeed':1000}
+ self.moveRelativeSignal.emit(params)
+ _logger.debug('Calibration move X{0:-1.3f} Y{1:-1.3f} F1000 '.format(self.offsets[0],self.offsets[1]))
+ return
+ # finally, we're aligned to the center, and we should update the tool offsets
+ elif(self.offsets[0] == 0.0 and self.offsets[1] == 0.0):
+ # endstop calibration wrapping up
+ if(self.__stateEndstopAutoCalibrate):
+ updateMessage = 'Endstop auto-calibrated in ' + str(self.calibrationMoves) + ' steps. (MPP=' + str(self.mpp) +')'
+ # update state flags for endstop alignment
+ self.__stateAutoCPCapture = False
+ self.__stateEndstopAutoCalibrate = False
+ self.__stateManualCPCapture = False
+ # update detection manager state
+ self.toggleEndstopAutoDetectionSignal.emit(False)
+ # disable detection manager frame analysis
+ self.toggleDetectionSignal.emit(False)
+ self.__displayCrosshair = False
+ # Set CP location
+ self.__cpCoordinates['X'] = np.around(self.__currentPosition['X'],2)
+ self.__cpCoordinates['Y'] = np.around(self.__currentPosition['Y'],2)
+ self.__cpCoordinates['Z'] = np.around(self.__currentPosition['Z'],2)
+ # Update GUI statusbar with CP coordinates and green status
+ self.cpLabel.setText('CP: ('+ str(self.__cpCoordinates['X']) + ', ' + str(self.__cpCoordinates['Y']) + ')')
+ self.cpLabel.setStyleSheet(self.styleGreen)
+ # Reset entire GUI for next state
+ self.stateCalibrateReady()
+ self.repaint()
+ # tool calibration wrapping up
+ elif(self.__stateAutoNozzleAlignment):
+ updateMessage = 'Tool ' + str(self.__activePrinter['currentTool']) + ' has been calibrated.'
+ self.state = 100
+ self.retries = 0
+ self.updateStatusbarMessage(updateMessage)
+ self.pollCoordinatesSignal.emit()
+ return
+ elif(self.retries < 100 and runtime <= self.__maxRuntime):
+ self.retries += 1
+ self.pollCoordinatesSignal.emit()
+ return
+ if(self.retries < self.__maxRetries):
+ self.retries += 1
+ # enable detection
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ self.pollCoordinatesSignal.emit()
+ return
+ # If we've reached this part of the code, we've run over our limit of retries
+
+ def nozzleDetectionFailed(self):
+ self.state = -99
+ # End auto calibration
+ self.__stateAutoNozzleAlignment = False
+ self.__stateOverrideManualNozzleAlignment = True
+ # calibrating nozzle manual
+ self.tabPanel.setDisabled(False)
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setVisible(True)
+ self.overrideManualOffsetCaptureButton.setDisabled(False)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleBlue)
+ self.resumeAutoToolAlignmentButton.setVisible(True)
+ self.resumeAutoToolAlignmentButton.setDisabled(False)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleGreen)
+ self.toggleNozzleAutoDetectionSignal.emit(False)
+ self.toggleNozzleDetectionSignal.emit(True)
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+
+ def resumeAutoAlignment(self):
+ if(self.transformMatrix is None or self.mpp is None):
+ self.state = 0
+ elif(len(self.transformMatrix) < 6):
+ self.transformMatrix = None
+ self.mpp = None
+ self.state = 0
+ else:
+ self.state = 200
+ self.retries = 0
+ self.repeatCounter = 0
+ self.uv = [None, None]
+ self.olduv = [None, None]
+ self.__stateAutoNozzleAlignment = True
+ self.toolTime = time.time()
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ self.overrideManualOffsetCaptureButton.setVisible(False)
+ self.overrideManualOffsetCaptureButton.setDisabled(True)
+ self.overrideManualOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ self.startAlignToolsButton.setVisible(True)
+ self.startAlignToolsButton.setDisabled(True)
+ self.updateStatusbarMessage('Resuming auto detection of current tool..')
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ self.pollCoordinatesSignal.emit()
+
+ ########################################################################### Interface with Detection Manager
+ def createDetectionManagerThread(self, announce=True):
+ if(announce):
+ _logger.info(' .. starting Detection Manager.. ')
+ # Thread setup
+ self.detectionThread = QThread()
+ self.detectionManager = DetectionManager(videoSrc=self._videoSrc, width=self._cameraWidth, height=self._cameraHeight, parent=None)
+ self.detectionManager.moveToThread(self.detectionThread)
+
+ # Thread management signals and slots
+ self.detectionManager.errorSignal.connect(self.detectionManagerError)
+ self.detectionThread.started.connect(self.detectionManager.processFrame)
+ self.detectionThread.finished.connect(self.detectionManager.quit)
+ self.detectionThread.finished.connect(self.detectionManager.deleteLater)
+ self.detectionThread.finished.connect(self.detectionThread.deleteLater)
+
+ # Video frame signals and slots
+ self.detectionManager.detectionManagerNewFrameSignal.connect(self.refreshImage)
+ self.detectionManager.detectionManagerReadySignal.connect(self.startVideo)
+ self.getVideoFrameSignal.connect(self.detectionManager.processFrame)
+ # Camera image properties signals and slots
+ self.setImagePropertiesSignal.connect(self.detectionManager.relayImageProperties)
+ self.resetImageSignal.connect(self.detectionManager.relayResetImage)
+ # Endstop alignment signals and slots
+ self.toggleEndstopDetectionSignal.connect(self.detectionManager.toggleEndstopDetection)
+ self.toggleEndstopAutoDetectionSignal.connect(self.detectionManager.toggleEndstopAutoDetection)
+ # Nozzle alignment signals and slots
+ self.toggleNozzleDetectionSignal.connect(self.detectionManager.toggleNozzleDetection)
+ self.toggleNozzleAutoDetectionSignal.connect(self.detectionManager.toggleNozzleAutoDetection)
+ # UV coordinates update signals and slots
+ self.getUVCoordinatesSignal.connect(self.detectionManager.sendUVCoorindates)
+ self.detectionManager.detectionManagerUVCoordinatesSignal.connect(self.saveUVCoordinates)
+ # Master detection swtich enable/disable
+ self.toggleDetectionSignal.connect(self.detectionManager.enableDetection)
+
+ @pyqtSlot(object)
+ def startVideo(self, cameraProperties):
+ # send calling to log
+ _logger.debug('*** calling App.startVideo')
+ # capture camera properties
+ self.__activeCamera = cameraProperties
+ self.startVideoSignal.emit()
+ # send exiting to log
+ _logger.debug('*** exiting App.startVideo')
+
+ @pyqtSlot(object)
+ def refreshImage(self, data):
+ self.__mutex.lock()
+ frame = data[0]
+ self.image.setPixmap(frame)
+ self.__mutex.unlock()
+ self.getVideoFrameSignal.emit()
+
+ @pyqtSlot(object)
+ def updateStatusbarMessage(self, message):
+ self.__mutex.lock()
+ # send calling to log
+ _logger.debug('*** calling App.updateStatusbarMessage')
+ try:
+ self.statusBar.showMessage(message)
+ self.statusBar.setStyleSheet('')
+ except:
+ errorMsg = 'Error sending message to statusbar.'
+ _logger.error(errorMsg)
+ self.__mutex.unlock()
+ app.processEvents()
+ # send exiting to log
+ _logger.debug('*** exiting App.updateStatusbarMessage')
+
+ @pyqtSlot(object)
+ def detectionManagerError(self, message):
+ self.haltPrinterOperation(silent=True)
+ # self.__mutex.lock()
+ try:
+ self.resetCalibration()
+ self.stateExiting()
+ self.statusBar.showMessage(message)
+ self.statusBar.setStyleSheet(self.styleRed)
+ self.cpLabel.setStyleSheet(self.styleOrange)
+ self.cpLabel.setVisible(False)
+ self.connectionStatusLabel.setStyleSheet(self.styleOrange)
+ self.connectionStatusLabel.setText('Critical Error')
+ self.__mutex.unlock()
+ if(self.__restorePosition is not None):
+ self.moveAbsoluteSignal.emit(self.__restorePosition)
+ except Exception as e:
+ print(e)
+ # self.__mutex.unlock()
+ errorMsg = 'Error sending message to statusbar.'
+ _logger.error(errorMsg)
+ # Kill thread
+ self.detectionThread.quit()
+ self.detectionThread.wait(self.__detectionManagerThreadWaitTime)
+ if(self.__restorePosition is not None):
+ self.printerThread.quit()
+ self.printerThread.wait(self.__printerManagerThreadWaitTime)
+ # display error image
+ self.image.setPixmap(self.errorImage)
+
+ ########################################################################### Interface with Printer Manager
+ def createPrinterManagerThread(self,announce=True):
+ if(announce):
+ _logger.info(' .. starting Printer Manager.. ')
+ try:
+ self.printerManager = PrinterManager(parent=None, firmwareList=self.__firmwareList, driverList=self.__driverList, announcemode=announce)
+ self.printerThread = QThread()
+ self.printerManager.moveToThread(self.printerThread)
+
+ self.printerManager.errorSignal.connect(self.printerError)
+ # self.printerManager.printerDisconnectedSignal.connect(self.printerThread.quit)
+ # self.printerManager.printerDisconnectedSignal.connect(self.printerManager.deleteLater)
+ self.printerThread.finished.connect(self.printerManager.quit)
+ self.printerThread.finished.connect(self.printerManager.deleteLater)
+ self.printerThread.finished.connect(self.printerThread.deleteLater)
+
+ # PrinterManager object signals
+ self.printerManager.updateStatusbarSignal.connect(self.updateStatusbarMessage)
+ self.printerManager.activePrinterSignal.connect(self.printerConnected)
+ self.printerManager.printerDisconnectedSignal.connect(self.printerDisconnected)
+ self.connectSignal.connect(self.printerManager.connectPrinter)
+ self.disconnectSignal.connect(self.printerManager.disconnectPrinter)
+ self.announceSignal.connect(self.printerManager.setAnnounceMode)
+
+ self.moveRelativeSignal.connect(self.printerManager.moveRelative)
+ self.moveAbsoluteSignal.connect(self.printerManager.moveAbsolute)
+ self.printerManager.moveCompleteSignal.connect(self.printerMoveComplete)
+
+ self.pollCoordinatesSignal.connect(self.printerManager.getCoordinates)
+ self.printerManager.coordinatesSignal.connect(self.saveCurrentPosition)
+
+ self.callToolSignal.connect(self.printerManager.callTool)
+ self.unloadToolSignal.connect(self.printerManager.unloadTools)
+ self.printerManager.toolLoadedSignal.connect(self.toolLoaded)
+ self.pollCurrentToolSignal.connect(self.printerManager.currentTool)
+ self.printerManager.toolIndexSignal.connect(self.registerActiveTool)
+
+ self.printerManager.offsetsSetSignal.connect(self.calibrateOffsetsApplied)
+ self.setOffsetsSignal.connect(self.printerManager.calibrationSetOffset)
+ self.saveToFirmwareSignal.connect(self.printerManager.saveOffsets)
+
+ self.printerThread.start()#priority=QThread.TimeCriticalPriority)
+ except Exception as e:
+ print(e)
+ errorMsg = 'Failed to start PrinterManager.'
+ _logger.critical(errorMsg)
+ self.printerError(errorMsg)
+
+ def connectPrinter(self):
+ self.connectButton.setStyleSheet(self.styleDisabled)
+ self.connectButton.setDisabled(True)
+ self.preferencesAction.setDisabled(True)
+ self.statusBar.setStyleSheet("")
+ self.connectionStatusLabel.setText('Connecting..')
+ self.connectionStatusLabel.setStyleSheet(self.styleBlue)
+ self.repaint()
+
+ self.connection_dialog = ConnectionDialog(parent=self, newPrinter=False, settings=self.__userSettings, stylesheet=self.globalStylesheet)
+ connectionDialogReturn = self.connection_dialog.exec()
+ # Destroy connection_dialog window
+ self.connection_dialog = None
+
+ if(connectionDialogReturn == -2):
+ # new printer definition
+ pass
+ elif(connectionDialogReturn >= 0):
+ # fetch printer from user objects
+ self.__activePrinter = self.__userSettings['printer'][connectionDialogReturn]
+ try:
+ self.announceSignal.emit(False)
+ if(self.printerThread.isRunning() is True):
+ # Printer already running, delete thread and restart
+ self.printerThread.quit()
+ self.printerThread.wait(self.__printerManagerThreadWaitTime)
+ self.createPrinterManagerThread(announce=False)
+ except: self.createPrinterManagerThread(announce=False)
+ self.announceSignal.emit(True)
+ self.connectSignal.emit(self.__activePrinter)
+ else:
+ # user closed window, update GUI back to default state
+ # Settings option in menu
+ self.preferencesAction.setDisabled(False)
+ # Connect button
+ self.connectButton.setVisible(True)
+ self.connectButton.setDisabled(False)
+ self.connectButton.setStyleSheet(self.styleGreen)
+
+ def haltPrinterOperation(self, **kwargs):
+ try:
+ silent = kwargs['silent']
+ except: silent = False
+ # disconnect button
+ self.disconnectButton.setStyleSheet(self.styleDisabled)
+ self.disconnectButton.setDisabled(True)
+ # Cancel CP Capture
+ if(self.__stateAutoCPCapture is True or self.__stateEndstopAutoCalibrate is True or self.__stateSetupCPCapture is True):
+ self.haltCPAutoCapture()
+ return
+ # Cancel Nozzle Detection
+ if(self.__stateAutoNozzleAlignment is True or self.__stateOverrideManualNozzleAlignment is True):
+ self.haltNozzleCapture()
+ return
+ # Disconnect from printer
+ # Status label
+ self.connectionStatusLabel.setText('Disconnecting..')
+ self.connectionStatusLabel.setStyleSheet(self.styleBlue)
+ # Setup CP button
+ self.cpSetupButton.setStyleSheet(self.styleDisabled)
+ self.cpSetupButton.setDisabled(True)
+ # CP Automated Capture button
+ self.cpAutoCaptureButton.setDisabled(True)
+ self.cpAutoCaptureButton.setStyleSheet(self.styleDisabled)
+ # Jog panel
+ self.tabPanel.setDisabled(True)
+ self.repaint()
+ params = {}
+ if(silent):
+ params['noUpdate'] = True
+ else:
+ params['noUpdate'] = False
+ # restore position
+ if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None):
+ _logger.debug('Restoring to CP position..')
+ params['parkPosition'] = self.__cpCoordinates
+ self.disconnectSignal.emit(params)
+ elif(self.__restorePosition is not None):
+ _logger.debug('Restoring to master restore position..')
+ params['parkPosition'] = self.__restorePosition
+ self.disconnectSignal.emit(params)
+ else:
+ self.disconnectSignal.emit(params)
+ self.resetCalibrationVariables()
+ self.updateStatusbarMessage('Printer disconnected.')
+
+ @pyqtSlot(object)
+ def printerConnected(self, printerJSON):
+ self.__mutex.lock()
+ # send calling to log
+ _logger.debug('*** calling App.printerConnected')
+ self.__activePrinter = printerJSON
+ # Status label
+ self.connectionStatusLabel.setText(self.__activePrinter['nickname'] + ' [' + self.__activePrinter['controller'] + ' v' + self.__activePrinter['version'] + ']')
+ self.connectionStatusLabel.setStyleSheet(self.styleGreen)
+ # poll for position
+ self.__firstConnection = True
+ self.__mutex.unlock()
+ self.pollCoordinatesSignal.emit()
+ # Gui state
+ self.stateConnected()
+ self.repaint()
+ # send exiting to log
+ _logger.debug('*** exiting App.printerConnected')
+
+ @pyqtSlot(object)
+ def printerDisconnected(self, **kwargs):
+ self.__mutex.lock()
+ try:
+ message = kwargs['message']
+ except: message = None
+ # send calling to log
+ _logger.debug('*** calling App.printerDisconnected')
+ # update status bar
+ if(message is not None):
+ self.statusBar.showMessage(message)
+ self.statusBar.setStyleSheet("")
+ # CP Label
+ self.cpLabel.setText('CP: undef')
+ self.cpLabel.setStyleSheet(self.styleOrange)
+ # Status label
+ self.connectionStatusLabel.setText('Disconnected')
+ self.connectionStatusLabel.setStyleSheet(self.styleOrange)
+ self.__firstConnection = True
+ self.__restorePosition = None
+ self.__mutex.unlock()
+ self.stateDisconnected()
+ self.repaint()
+ # send exiting to log
+ _logger.debug('*** exiting App.printerDisconnected')
+
+ @pyqtSlot(object)
+ def printerError(self, message):
+ _logger.debug('Printer Error: ' + message)
+ if(self.__restorePosition is not None):
+ params = {'parkPosition': self.__restorePosition}
+ self.disconnectSignal.emit(params)
+ else:
+ self.disconnectSignal.emit(None)
+ # Kill printer thread
+ try:
+ self.printerThread.quit()
+ self.printerThread.wait(self.__printerManagerThreadWaitTime)
+ except: _logger.warning('Printer thread not created yet.')
+ self.printerDisconnected(message=message)
+ self.statusBar.setStyleSheet(self.styleRed)
+
+ def callTool(self, toolNumber=-1):
+ # disable detection
+ self.toggleDetectionSignal.emit(False)
+ self.__displayCrosshair = False
+ toolNumber = int(toolNumber)
+ if(toolNumber == -1):
+ self.startAlignToolsButton.setText('Unloading..')
+ self.unloadToolSignal.emit()
+ return
+ try:
+ self.startAlignToolsButton.setText('Loading T' +str(toolNumber) + '..')
+ self.callToolSignal.emit(toolNumber)
+ except:
+ errorMsg = 'Unable to call tool from printer: ' + str(toolNumber)
+ _logger.error(errorMsg)
+ self.printerError(errorMsg)
+
+ @pyqtSlot()
+ def toolLoaded(self):
+ self.pollCurrentToolSignal.emit()
+ if(self.__cpCoordinates['X'] is not None and self.__cpCoordinates['Y'] is not None):
+ params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__cpCoordinates['X'], 'Y': self.__cpCoordinates['Y'], 'Z': self.__cpCoordinates['Z']}}
+ else:
+ params = {'protected':True,'moveSpeed': 5000, 'position':{'X': self.__currentPosition['X'], 'Y': self.__currentPosition['Y'], 'Z': self.__currentPosition['Z']}}
+ self.moveAbsoluteSignal.emit(params)
+
+ @pyqtSlot(int)
+ def registerActiveTool(self, toolIndex):
+ self.__mutex.lock()
+ self.__activePrinter['currentTool'] = toolIndex
+ for button in self.toolButtons:
+ if(button.objectName() != ('toolButton_'+str(toolIndex))):
+ button.setChecked(False)
+ else:
+ button.setChecked(True)
+ self.__mutex.unlock()
+ if(self.__stateAutoNozzleAlignment is False and self.__stateOverrideManualNozzleAlignment is False and int(toolIndex) != -1):
+ self.startAlignToolsButton.setVisible(False)
+ if(self.__cpCoordinates is not None and self.__cpCoordinates['X'] is not None):
+ self.manualToolOffsetCaptureButton.setEnabled(True)
+ self.manualToolOffsetCaptureButton.setVisible(True)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleGreen)
+ else:
+ self.manualToolOffsetCaptureButton.setEnabled(False)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ elif(int(toolIndex) != -1):
+ self.manualToolOffsetCaptureButton.setEnabled(False)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ self.startAlignToolsButton.setVisible(True)
+ self.startAlignToolsButton.setText('Detecting T' + str(toolIndex) +'..')
+ else:
+ self.manualToolOffsetCaptureButton.setEnabled(False)
+ self.manualToolOffsetCaptureButton.setVisible(False)
+ self.manualToolOffsetCaptureButton.setStyleSheet(self.styleDisabled)
+ self.startAlignToolsButton.setVisible(True)
+
+ @pyqtSlot()
+ def printerMoveComplete(self):
+ self.tabPanel.setDisabled(False)
+ if(self.__stateAutoCPCapture and self.__stateEndstopAutoCalibrate):
+ # enable detection
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ statusMsg = '(Endstop auto detection active.)'
+ self.updateStatusbarMessage(statusMsg)
+ _logger.debug(statusMsg)
+ # Calibrating camera, go based on state
+ self.tabPanel.setDisabled(True)
+ elif(self.__stateAutoNozzleAlignment is True):
+ # enable detection
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ statusMsg = '(Tool/nozzle auto detection active.)'
+ self.updateStatusbarMessage(statusMsg)
+ _logger.debug(statusMsg)
+ # calibrating nozzle auto
+ self.tabPanel.setDisabled(True)
+ elif(self.__stateOverrideManualNozzleAlignment is True):
+ # enable detection
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ statusMsg = '(Tool/nozzle manual override active.)'
+ self.updateStatusbarMessage(statusMsg)
+ _logger.debug(statusMsg)
+ # calibrating nozzle manual
+ self.startAlignToolsButton.setVisible(False)
+ self.startAlignToolsButton.setDisabled(True)
+ self.manualCPCaptureButton.setVisible(True)
+ return
+ elif(self.__stateManualCPCapture is True):
+ statusMsg = '(Endstop/CP manual override active.)'
+ self.updateStatusbarMessage(statusMsg)
+ _logger.debug(statusMsg)
+ else:
+ statusMsg = 'Ready.'
+ self.updateStatusbarMessage(statusMsg)
+ _logger.debug(statusMsg)
+ self.pollCoordinatesSignal.emit()
+
+ @pyqtSlot(object)
+ def saveUVCoordinates(self, uvCoordinates):
+ self.uv = uvCoordinates
+ if(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True):
+ if(uvCoordinates is None):
+ # failed to detect, poll coordinates again
+ self.retries += 1
+ self.startAlignToolsButton.setText('Retrying (' + str(self.retries) + ')..')
+ # check if we've exceeded our maxRetries or maxRuntime
+ if(self.retries > self.__maxRetries):
+ if(self.__stateEndstopAutoCalibrate is True):
+ self.updateStatusbarMessage('Failed to detect endstop.')
+ _logger.warning('Failed to detect endstop. Cancelled operation.')
+ self.__stateAutoCPCapture = False
+ self.__stateEndstopAutoCalibrate = False
+ self.toggleEndstopAutoDetectionSignal.emit(False)
+ self.haltCPAutoCapture()
+ elif(self.__stateAutoNozzleAlignment is True):
+ updateMessage = 'Failed to detect nozzle. Try manual override.'
+ self.updateStatusbarMessage(updateMessage)
+ _logger.warning(updateMessage)
+ self.nozzleDetectionFailed()
+ return
+ self.pollCoordinatesSignal.emit()
+ return
+ self.autoCalibrate()
+
+ @pyqtSlot(object)
+ def saveCurrentPosition(self, coordinates):
+ self.__mutex.lock()
+ self.__currentPosition = coordinates
+ self.__mutex.unlock()
+ _logger.debug('Coordinates received:' + str(coordinates))
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ if(self.__stateManualCPCapture is True):
+ _logger.debug('saveCurrentPosition: manual CP capture')
+ self.__cpCoordinates = coordinates
+ self.__stateManualCPCapture = False
+ self.cpLabel.setText('CP: ('+ str(self.__cpCoordinates['X']) + ', ' + str(self.__cpCoordinates['Y']) + ')')
+ self.cpLabel.setStyleSheet(self.styleGreen)
+ self.updateStatusbarMessage('CP captured.')
+ self.repaint()
+ elif(self.__stateEndstopAutoCalibrate is True or self.__stateAutoNozzleAlignment is True):
+ if(self.state != 100):
+ if(int(self.__activePrinter['currentTool']) > -1):
+ _logger.debug('saveCurrentPosition: autoCalibrate nozzle for T' + str(int(self.__activePrinter['currentTool'])))
+ else:
+ _logger.debug('saveCurrentPosition: autoCalibrate endstop.')
+ elif(self.state == 100):
+ _logger.debug('saveCurrentPosition: autoCalibrate nozzle set offsets for T' + str(int(self.__activePrinter['currentTool'])))
+ # set state to detect next tool
+ self.state = 200
+ self.__stateAutoNozzleAlignment = True
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates, 'continue': True}
+ self.setOffsetsSignal.emit(params)
+ return
+ self.getUVCoordinatesSignal.emit()
+ elif(self.__stateOverrideManualNozzleAlignment is True):
+ _logger.debug('saveCurrentPosition: manual nozzle set offsets for T' + str(int(self.__activePrinter['currentTool'])))
+ # set state to detect next tool
+ self.state = 200
+ self.__stateAutoNozzleAlignment = True
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ params={'toolIndex': int(self.__activePrinter['currentTool']), 'position': coordinates, 'cpCoordinates': self.__cpCoordinates, 'continue': True}
+ self.setOffsetsSignal.emit(params)
+ return
+ elif(self.__firstConnection):
+ _logger.debug('saveCurrentPosition: firstConnection')
+ self.__restorePosition = copy.deepcopy(self.__currentPosition)
+ self.__firstConnection = False
+
+ @pyqtSlot(object)
+ def calibrateOffsetsApplied(self, params=None):
+ try:
+ offsets = params['offsets']
+ except: offsets = None
+ try:
+ __continue = params['continue']
+ except: __continue = True
+ self.resumeAutoToolAlignmentButton.setDisabled(True)
+ self.resumeAutoToolAlignmentButton.setVisible(False)
+ self.resumeAutoToolAlignmentButton.setStyleSheet(self.styleDisabled)
+ for button in self.toolButtons:
+ buttonName = 'toolButton_' + str(self.__activePrinter['currentTool'])
+ if(button.objectName() == buttonName):
+ toolTip = 'Fetch T' + str(self.__activePrinter['currentTool']) + ' to current machine position.'
+ toolTip += ''
+ toolTip += 'X: ' + "{:>9.3f}".format(offsets['X'])
+ toolTip += '
'
+ toolTip += 'Y: ' + "{:>9.3f}".format(offsets['Y'])
+ toolTip += '
'
+ toolTip += 'Z: ' + "{:>9.3f}".format(offsets['Z'])
+ toolTip += '
'
+ button.setToolTip(toolTip)
+ if(__continue is True):
+ toolCalibrationTime = np.around(time.time() - self.toolTime,1)
+ successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ') -- [' + str(toolCalibrationTime) + 's].'
+ self.updateStatusbarMessage(successMsg)
+ _logger.info(successMsg)
+ self.state = 200
+ self.retries = 0
+ self.__stateAutoNozzleAlignment = True
+ self.__stateOverrideManualNozzleAlignment = False
+ self.toggleNozzleAutoDetectionSignal.emit(True)
+ self.calibrateTools(self.workingToolset)
+ else:
+ successMsg = 'Tool ' + str(self.__activePrinter['currentTool']) + ': (X' + str(offsets['X']) + ', Y' + str(offsets['Y']) + ', Z' + str(offsets['Z']) + ').'
+ self.updateStatusbarMessage(successMsg)
+ _logger.info(successMsg)
+
+ def saveOffsets(self):
+ self.saveToFirmwareSignal.emit()
+ _logger.info('Offsets saved to firmware.')
+
+ ########################################################################### Interface with Settings Dialog
+ def displayPreferences(self, event=None, newPrinterFlag=False):
+ _logger.debug('*** calling App.displayPreferences')
+ # check if we already have a printer manager thread, and start it
+ try:
+ if(self.printerThread.isRunning() is False):
+ self.createPrinterManagerThread(announce=False)
+ except: self.createPrinterManagerThread(announce=False)
+ # Set up settings window
+ try:
+ self.settingsDialog = SettingsDialog(parent=self, newPrinter=newPrinterFlag, geometry=self.__settingsGeometry, settings=self.__userSettings, firmwareList=self.__firmwareList, cameraProperties=self.__activeCamera)
+ # # Signals
+ # self.settingsDialog.settingsAlignmentPollSignal.connect()
+ # self.settingsDialog.settingsChangeVideoSrcSignal.connect()
+ # self.settingsDialog.settingsRequestCameraProperties.connect()
+ # self.settingsDialog.settingsRequestImageProperties.connect()
+ self.settingsDialog.settingsResetImageSignal.connect(self.relayResetCameraDefaults)
+ self.settingsDialog.settingsSetImagePropertiesSignal.connect(self.relayImageParameters)
+ self.settingsDialog.settingsUpdateSignal.connect(self.updateSettings)
+ self.settingsDialog.settingsStatusbarSignal.connect(self.updateStatusbarMessage)
+ self.settingsDialog.settingsGeometrySignal.connect(self.saveSettingsGeometry)
+ self.settingsDialog.rejected.connect(self.settingsDialog.deleteLater)
+ self.settingsDialog.accepted.connect(self.settingsDialog.deleteLater)
+ self.settingsDialog.settingsNewPrinter.connect(self.saveNewPrinter)
+
+ except:
+ errorMsg = 'Cannot start settings dialog.'
+ _logger.exception(errorMsg)
+ return
+ # self.settingsDialog.update_settings.connect(self.updateSettings)
+ self.settingsDialog.exec()
+
+ _logger.debug('*** exiting App.displayPreferences')
+
+ @pyqtSlot(object)
+ def relayImageParameters(self, imageProperties):
+ self.setImagePropertiesSignal.emit(imageProperties)
+
+ @pyqtSlot()
+ def relayResetCameraDefaults(self):
+ self.resetImageSignal.emit()
+
+ @pyqtSlot(object)
+ def updateSettings(self, settingOptions):
+ _logger.debug('*** calling App.updateSettings')
+ self.__userSettings = settingOptions
+ self.saveUserSettings()
+ _logger.debug('*** exiting App.updateSettings')
+
+ @pyqtSlot(object)
+ def saveNewPrinter(self, newSettingsOptions):
+ self.__userSettings = newSettingsOptions
+ self.saveUserSettings()
+ newPrinterIndex = len(newSettingsOptions['printer'])-1
+ self.__activePrinter = newSettingsOptions['printer'][newPrinterIndex]
+ self.connectSignal.emit(self.__activePrinter)
+
+ @pyqtSlot(object)
+ def saveSettingsGeometry(self, geometry):
+ self.__settingsGeometry = geometry
+
+ ########################################################################### Utilities
+ def sanitizeURL(self, inputString='http://localhost'):
+ _logger.debug('*** calling App.sanitizeURL')
+ _errCode = 0
+ _errMsg = ''
+ _printerURL = 'http://localhost'
+ u = urlparse(inputString)
+ if(u[0] ==''):
+ u = u._replace(scheme="http")
+ scheme = u[0]
+ if(scheme.lower() not in ['http','https']):
+ _errCode = 1
+ _errMsg = 'Invalid scheme. Please only use http/https connections.'
+ else:
+ if(u.netloc == ''):
+ _printerURL = u.scheme + '://' + u.path
+ else:
+ _printerURL = u.geturl()
+ _logger.debug('Sanitized ' + inputString + ' to address: ' + _printerURL)
+ _logger.debug('*** exiting App.sanitizeURL')
+ return(_errCode, _errMsg, _printerURL)
+
+ def saveUserSettings(self):
+ # save user settings.json
+ _logger.debug('*** calling App.saveUserSettings')
+ try:
+ # get default camera from user settings
+ cameraSet = False
+ # new_video_src = 0
+ # for camera in self.__userSettings['camera']:
+ # try:
+ # if(camera['default'] == 1 and cameraSet is False):
+ # # if new default, switch feed
+ # if(self._videoSrc != new_video_src):
+ # new_video_src = camera['video_src']
+ # self.video_thread.changeVideoSrc(newSrc=new_video_src)
+ # self._videoSrc = new_video_src
+ # cameraSet = True
+ # continue
+ # elif(cameraSet):
+ # # already have a default, unset other entries
+ # camera['default'] = 0
+ # except KeyError:
+ # # No default camera defined, add key
+ # camera['default'] = 0
+ # continue
+ # check if there are no cameras set as default
+ if(cameraSet is False):
+ # Set first camera entry to be the default source
+ self.__userSettings['camera'][0]['default'] = 1
+ # try:
+ # # activate default camera feed
+ # self.video_thread.changeVideoSrc(newSrc=new_video_src)
+ # self._videoSrc = new_video_src
+ # except:
+ # _logger.critical('Cannot load default camera source.')
+ # Save settings to file
+ with open('./config/settings.json','w') as outputfile:
+ json.dump(self.__userSettings, outputfile)
+
+ _logger.info('User preferences saved to settings.json')
+ self.updateStatusbarMessage('User preferences saved to settings.json')
+ self.statusBar.setStyleSheet('')
+ except Exception as e1:
+ _logger.error('Error saving user settings file.' + str(e1))
+ self.statusBar.showMessage('Error saving user settings file.')
+ self.statusBar.setStyleSheet(self.styleRed)
+ _logger.debug('*** exiting App.saveUserSettings')
+
+ def getDistance(self, x1, y1, x0, y0):
+ _logger.debug('*** calling CalibrateNozzles.getDistance')
+ x1_float = float(x1)
+ x0_float = float(x0)
+ y1_float = float(y1)
+ y0_float = float(y0)
+ x_dist = (x1_float - x0_float) ** 2
+ y_dist = (y1_float - y0_float) ** 2
+ retVal = np.sqrt((x_dist + y_dist))
+ returnVal = np.around(retVal,3)
+ _logger.debug('*** exiting CalibrateNozzles.getDistance')
+ return(returnVal)
+
+ def normalize_coords(self,coords):
+ xdim, ydim = self._cameraWidth, self._cameraHeight
+ returnValue = (coords[0] / xdim - 0.5, coords[1] / ydim - 0.5)
+ return(returnValue)
+
+ def least_square_mapping(self,calibration_points):
+ # Compute a 2x2 map from displacement vectors in screen space to real space.
+ n = len(calibration_points)
+ real_coords, pixel_coords = np.empty((n,2)),np.empty((n,2))
+ for i, (r,p) in enumerate(calibration_points):
+ real_coords[i] = r
+ pixel_coords[i] = p
+ x,y = pixel_coords[:,0],pixel_coords[:,1]
+ A = np.vstack([x**2,y**2,x * y, x,y,np.ones(n)]).T
+ transform = np.linalg.lstsq(A, real_coords, rcond = None)
+ return transform[0], transform[1].mean()
+
+ def toggleCrosshair(self):
+ if(self.__stateAutoCPCapture is False and self.__stateAutoNozzleAlignment is False and self.__stateEndstopAutoCalibrate is False):
+ if(self.__displayCrosshair is False):
+ self.crosshairDisplayButton.setChecked(True)
+ self.crosshairDisplayButton.setStyleSheet(self.styleOrange)
+ self.__displayCrosshair = True
+ self.toggleNozzleDetectionSignal.emit(True)
+ self.toggleDetectionSignal.emit(True)
+ self.__displayCrosshair = True
+ else:
+ self.crosshairDisplayButton.setChecked(False)
+ self.crosshairDisplayButton.setStyleSheet(self.styleBlue)
+ self.__displayCrosshair = False
+ self.toggleNozzleDetectionSignal.emit(False)
+ self.toggleDetectionSignal.emit(False)
+ self.__displayCrosshair = False
+
+ ########################################################################### Close application handler
+ def closeEvent(self, event):
+ _logger.debug('*** calling App.closeEvent')
+ self.stateExiting()
+ try:
+ _logger.info('Closing TAMV..')
+ self.statusBar.showMessage('Shutting down TAMV..')
+ self.repaint()
+ try:
+ self.detectionThread.quit()
+ self.detectionThread.wait(self.__detectionManagerThreadWaitTime)
+ except: pass
+ try:
+ self.printerThread.quit()
+ self.printerThread.wait(self.__printerManagerThreadWaitTime)
+ except: pass
+ except Exception:
+ _logger.critical('Close event error: \n' + traceback.format_exc())
+ # Output farewell message
+ print()
+ print('Thank you for using TAMV!')
+ print('Check out www.jubilee3d.com')
+ _logger.debug('*** exiting App.closeEvent')
+ super(App, self).closeEvent(event)
+
+ ########################################################################### First run setups
+ def show(self):
+ self.createDetectionManagerThread()
+ self.createPrinterManagerThread()
+ _logger.info('Initialization complete.')
+ # Output welcome message
+ print()
+ print(' Welcome to TAMV!')
+ print()
+ super().show()
+
+ def startModules(self):
+ self.detectionThread.start(priority=QThread.TimeCriticalPriority)
+ self.announceSignal.emit(False)
+
+if __name__=='__main__':
+ ### Setup OS options
+ # os.putenv("QT_LOGGING_RULES","qt5ct.debug=true")
+ os.putenv("OPENCV_VIDEOIO_DEBUG", "0")
+ # os.putenv("OPENCV_VIDEOIO_PRIORITY_LIST", "DSHOW,FFMPEG,GSTREAMER")
+ ### Setup argmument parser
+ parser = argparse.ArgumentParser(description='Program to allign multiple tools on Duet/klipper based printers, using machine vision.', allow_abbrev=False)
+ parser.add_argument('-d','--debug',action='store_true',help='Enable debug output to terminal')
+ # Execute argument parser
+ args=vars(parser.parse_args())
+
+ ### Setup logging
+ _logger = logging.getLogger("TAMV")
+ _logger.setLevel(logging.DEBUG)
+ ### # file handler logging
+ fileFormatter = logging.Formatter('%(asctime)s --- %(levelname)s --- M:%(name)s --- T:%(threadName)s --- F:%(funcName)s --- L:%(lineno)d --- %(message)s')
+ # migrate logs to /log path
+ try:
+ os.makedirs('./log', exist_ok=True)
+ except:
+ print()
+ print('Cannot create \"./log folder.')
+ print('Please create this folder manually and restart TAMV')
+ raise SystemExit('Cannot retrieve log path.')
+ if(os.path.exists('./TAMV.log')):
+ if(os.path.exists('./log/TAMV.log')):
+ print()
+ print('Deleting old log file ./TAMV.log..')
+ try:
+ os.remove('./TAMV.log')
+ except:
+ print('Cannot delete old log file \"./TAMV.log\", ignoring it..')
+ print('Log file now located in \"./log/TAMV.log\"')
+ else:
+ try:
+ print()
+ print('Moving log file to \"./log/TAMV.log\"..')
+ os.replace('./TAMV.log','./log/TAMV.log')
+ except:
+ print('Unknown OS error while moving log file to new folder.')
+ print('Please move \"./TAMV.log\" to \"./log/TAMV.log\" and restart TAMV.')
+ raise SystemExit('Cannot move log file.')
+ fh = RotatingFileHandler('./log/TAMV.log',backupCount=1,maxBytes=1000000)
+ if(args['debug']):
+ fh.setLevel(logging.DEBUG)
+ else:
+ fh.setLevel(logging.INFO)
+ fh.setFormatter(fileFormatter)
+ _logger.addHandler(fh)
+ ### # console handler logging
+ consoleFormatter = logging.Formatter(fmt='%(levelname)-9s: %(message)s')
+ ch = logging.StreamHandler()
+ if(args['debug']):
+ ch.setLevel(logging.DEBUG)
+ else:
+ ch.setLevel(logging.INFO)
+ ch.setFormatter(consoleFormatter)
+ _logger.addHandler(ch)
+
+ ### # log startup messages
+ print()
+ _logger.warning('This is an alpha release. Always use only when standing next to your machine and ready to hit EMERGENCY STOP.')
+ print()
+
+ ### start GUI application
+ app = QApplication(sys.argv)
+ a = App()
+ a.show()
+ t = threading.Thread(target=a.startModules)
+ t.start()
+ sys.exit(app.exec())
diff --git a/TAMV_GUI.py b/TAMV_GUI.py
deleted file mode 100755
index eaa044b..0000000
--- a/TAMV_GUI.py
+++ /dev/null
@@ -1,2382 +0,0 @@
-#!/usr/bin/env python3
-
-# TAMV version 2.0RC1
-# Python Script to align multiple tools on Jubilee printer with Duet3d Controller
-# Using images from USB camera and finding circles in those images
-#
-# TAMV originally Copyright (C) 2020 Danal Estes all rights reserved.
-# TAMV 2.0 Copyright (C) 2021 Haytham Bennani all rights reserved.
-# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT
-#
-# Requires OpenCV to be installed on Pi
-# Requires running via the OpenCV installed python (that is why no shebang)
-# Requires network connection to Duet based printer running Duet/RepRap V2 or V3
-#
-
-# GUI imports
-from PyQt5.QtWidgets import (
- QAction,
- QApplication,
- QCheckBox,
- QCheckBox,
- QComboBox,
- QDesktopWidget,
- QDialog,
- QDialogButtonBox,
- QGridLayout,
- QGroupBox,
- QHBoxLayout,
- QHeaderView,
- QInputDialog,
- QLabel,
- QLineEdit,
- QMainWindow,
- QMenu,
- QMenuBar,
- QMessageBox,
- QPushButton,
- QSlider,
- QSpinBox,
- QStatusBar,
- QStyle,
- QTableWidget,
- QTableWidgetItem,
- QTextEdit,
- QVBoxLayout,
- QWidget
-)
-from PyQt5.QtGui import QPixmap, QImage, QPainter, QColor, QIcon
-from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QThread, QMutex, QPoint, QSize
-
-# Core imports
-import os
-import sys
-import cv2
-import numpy as np
-import math
-import DuetWebAPI as DWA
-from time import sleep, time
-import datetime
-import json
-import time
-
-# graphing imports
-import matplotlib
-import matplotlib.pyplot as plt
-import matplotlib.cm as cm
-import matplotlib.patches as patches
-from matplotlib.ticker import FormatStrFormatter
-
-# styles
-global style_green, style_red, style_disabled, style_orange
-style_green = 'background-color: green; color: white;'
-style_red = 'background-color: red; color: white;'
-style_disabled = 'background-color: #cccccc; color: #999999; border-style: solid;'
-style_orange = 'background-color: dark-grey; color: orange;'
-
-class CPDialog(QDialog):
- def __init__(self,
- parent=None,
- title='Set Controlled Point',
- summary='Instructions:
Jog until controlled point is centered in the window.
Click OK to save and return to main window.',
- disabled = False):
- super(CPDialog,self).__init__(parent=parent)
- self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
- self.setWindowTitle(title)
- QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel
- self.buttonBox = QDialogButtonBox(QBtn)
- self.buttonBox.accepted.connect(self.accept)
- self.buttonBox.rejected.connect(self.reject)
-
- self.layout = QGridLayout()
- self.layout.setSpacing(3)
- # add information panel
- self.cp_info = QLabel(summary)
- # add jogging grid
- self.buttons={}
- buttons_layout = QGridLayout()
-
- # X
- self.button_x1 = QPushButton('-1')
- self.button_x2 = QPushButton('-0.1')
- self.button_x3 = QPushButton('-0.01')
- self.button_x4 = QPushButton('+0.01')
- self.button_x5 = QPushButton('+0.1')
- self.button_x6 = QPushButton('+1')
- # set X sizes
- self.button_x1.setFixedSize(60,60)
- self.button_x2.setFixedSize(60,60)
- self.button_x3.setFixedSize(60,60)
- self.button_x4.setFixedSize(60,60)
- self.button_x5.setFixedSize(60,60)
- self.button_x6.setFixedSize(60,60)
- # attach actions
- self.button_x1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-1 G90'))
- self.button_x2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-0.1 G90'))
- self.button_x3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X-0.01 G90'))
- self.button_x4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X0.01 G90'))
- self.button_x5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X0.1 G90'))
- self.button_x6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 X1 G90'))
- # add buttons to window
- x_label = QLabel('X')
- buttons_layout.addWidget(x_label,0,0)
- buttons_layout.addWidget(self.button_x1,0,1)
- buttons_layout.addWidget(self.button_x2,0,2)
- buttons_layout.addWidget(self.button_x3,0,3)
- buttons_layout.addWidget(self.button_x4,0,4)
- buttons_layout.addWidget(self.button_x5,0,5)
- buttons_layout.addWidget(self.button_x6,0,6)
-
- # Y
- self.button_y1 = QPushButton('-1')
- self.button_y2 = QPushButton('-0.1')
- self.button_y3 = QPushButton('-0.01')
- self.button_y4 = QPushButton('+0.01')
- self.button_y5 = QPushButton('+0.1')
- self.button_y6 = QPushButton('+1')
- # set X sizes
- self.button_y1.setFixedSize(60,60)
- self.button_y2.setFixedSize(60,60)
- self.button_y3.setFixedSize(60,60)
- self.button_y4.setFixedSize(60,60)
- self.button_y5.setFixedSize(60,60)
- self.button_y6.setFixedSize(60,60)
- # attach actions
- self.button_y1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-1 G90'))
- self.button_y2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-0.1 G90'))
- self.button_y3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y-0.01 G90'))
- self.button_y4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y0.01 G90'))
- self.button_y5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y0.1 G90'))
- self.button_y6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Y1 G90'))
- # add buttons to window
- y_label = QLabel('Y')
- buttons_layout.addWidget(y_label,1,0)
- buttons_layout.addWidget(self.button_y1,1,1)
- buttons_layout.addWidget(self.button_y2,1,2)
- buttons_layout.addWidget(self.button_y3,1,3)
- buttons_layout.addWidget(self.button_y4,1,4)
- buttons_layout.addWidget(self.button_y5,1,5)
- buttons_layout.addWidget(self.button_y6,1,6)
-
- # Z
- self.button_z1 = QPushButton('-1')
- self.button_z2 = QPushButton('-0.1')
- self.button_z3 = QPushButton('-0.01')
- self.button_z4 = QPushButton('+0.01')
- self.button_z5 = QPushButton('+0.1')
- self.button_z6 = QPushButton('+1')
- # set X sizes
- self.button_z1.setFixedSize(60,60)
- self.button_z2.setFixedSize(60,60)
- self.button_z3.setFixedSize(60,60)
- self.button_z4.setFixedSize(60,60)
- self.button_z5.setFixedSize(60,60)
- self.button_z6.setFixedSize(60,60)
- # attach actions
- self.button_z1.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-1 G90'))
- self.button_z2.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-0.1 G90'))
- self.button_z3.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z-0.01 G90'))
- self.button_z4.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z0.01 G90'))
- self.button_z5.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z0.1 G90'))
- self.button_z6.clicked.connect(lambda: self.parent().printer.gCode('G91 G1 Z1 G90'))
- # add buttons to window
- z_label = QLabel('Z')
- buttons_layout.addWidget(z_label,2,0)
- buttons_layout.addWidget(self.button_z1,2,1)
- buttons_layout.addWidget(self.button_z2,2,2)
- buttons_layout.addWidget(self.button_z3,2,3)
- buttons_layout.addWidget(self.button_z4,2,4)
- buttons_layout.addWidget(self.button_z5,2,5)
- buttons_layout.addWidget(self.button_z6,2,6)
-
- #self.macro_field = QLineEdit()
- #self.button_macro = QPushButton('Run macro')
- #buttons_layout.addWidget(self.button_macro,3,1,2,1)
- #buttons_layout.addWidget(self.macro_field,3,2,1,-1)
-
-
- # Set up items on dialog grid
- self.layout.addWidget(self.cp_info,0,0,1,-1)
- self.layout.addLayout(buttons_layout,1,0,3,7)
- # OK/Cancel buttons
- self.layout.addWidget(self.buttonBox)
-
- # apply layout
- self.setLayout(self.layout)
-
- def setSummaryText(self, message):
- self.cp_info.setText(message)
-
-class DebugDialog(QDialog):
- def __init__(self,parent=None, message=''):
- super(DebugDialog,self).__init__(parent=parent)
- self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
- self.setWindowTitle('Debug Information')
- # Set layout details
- self.layout = QGridLayout()
- self.layout.setSpacing(3)
-
- # text area
- self.textarea = QTextEdit()
- self.textarea.setAcceptRichText(False)
- self.textarea.setReadOnly(True)
- self.layout.addWidget(self.textarea,0,0)
- # apply layout
- self.setLayout(self.layout)
- temp_text = ''
- try:
- if self.parent().video_thread.isRunning():
- temp_text += 'Video thread running\n'
- except Exception as e1:
- None
- if len(message) > 0:
- temp_text += '\nCalibration Debug Messages:\n' + message
- self.textarea.setText(temp_text)
-
-class CameraSettingsDialog(QDialog):
- def __init__(self,parent=None, message=''):
- super(CameraSettingsDialog,self).__init__(parent=parent)
- self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
- self.setWindowTitle('Camera Settings')
-
- #QBtn = QDialogButtonBox.Close
- #self.buttonBox = QDialogButtonBox(QBtn)
- #self.buttonBox.accepted.connect(self.accept)
- #self.buttonBox.rejected.connect(self.reject)
-
- # Get camera settings from video thread
- try:
- (brightness_input, contrast_input, saturation_input, hue_input) = self.parent().video_thread.getProperties()
- except Exception as set1:
- self.updateStatusbar('Error fetching camera parameters.')
- print('ERROR: Camera Settings: ' + str(set1))
-
- # Set layout details
- self.layout = QVBoxLayout()
- self.layout.setSpacing(3)
- # apply layout
- self.setLayout(self.layout)
-
- # Camera Combobox
- self.camera_combo = QComboBox()
- camera_description = str(video_src) + ': ' \
- + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) \
- + 'x' + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \
- + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FPS)) + 'fps'
- self.camera_combo.addItem(camera_description)
- #self.camera_combo.currentIndexChanged.connect(self.parent().video_thread.changeVideoSrc)
- # Get cameras button
- self.camera_button = QPushButton('Get cameras')
- self.camera_button.clicked.connect(self.getCameras)
- if self.parent().video_thread.alignment:
- self.camera_button.setDisabled(True)
- else: self.camera_button.setDisabled(False)
- #self.getCameras()
- # Brightness slider
- self.brightness_slider = QSlider(Qt.Horizontal)
- self.brightness_slider.setMinimum(0)
- self.brightness_slider.setMaximum(255)
- self.brightness_slider.setValue(int(brightness_input))
- self.brightness_slider.valueChanged.connect(self.changeBrightness)
- self.brightness_slider.setTickPosition(QSlider.TicksBelow)
- self.brightness_slider.setTickInterval(1)
- self.brightness_label = QLabel(str(int(brightness_input)))
- # Contrast slider
- self.contrast_slider = QSlider(Qt.Horizontal)
- self.contrast_slider.setMinimum(0)
- self.contrast_slider.setMaximum(255)
- self.contrast_slider.setValue(int(contrast_input))
- self.contrast_slider.valueChanged.connect(self.changeContrast)
- self.contrast_slider.setTickPosition(QSlider.TicksBelow)
- self.contrast_slider.setTickInterval(1)
- self.contrast_label = QLabel(str(int(contrast_input)))
- # Saturation slider
- self.saturation_slider = QSlider(Qt.Horizontal)
- self.saturation_slider.setMinimum(0)
- self.saturation_slider.setMaximum(255)
- self.saturation_slider.setValue(int(saturation_input))
- self.saturation_slider.valueChanged.connect(self.changeSaturation)
- self.saturation_slider.setTickPosition(QSlider.TicksBelow)
- self.saturation_slider.setTickInterval(1)
- self.saturation_label = QLabel(str(int(saturation_input)))
- # Hue slider
- self.hue_slider = QSlider(Qt.Horizontal)
- self.hue_slider.setMinimum(0)
- self.hue_slider.setMaximum(8)
- self.hue_slider.setValue(int(hue_input))
- self.hue_slider.valueChanged.connect(self.changeHue)
- self.hue_slider.setTickPosition(QSlider.TicksBelow)
- self.hue_slider.setTickInterval(1)
- self.hue_label = QLabel(str(int(hue_input)))
- # Reset button
- self.reset_button = QPushButton("Reset to defaults")
- self.reset_button.setToolTip('Reset camera settings to defaults.')
- self.reset_button.clicked.connect(self.resetDefaults)
-
- # Save button
- self.save_button = QPushButton('Save and Close')
- self.save_button.setToolTip('Save current parameters to settings.json file')
- self.save_button.clicked.connect(self.sendUserParameters)
- self.save_button.setObjectName('active')
-
- # Close button
- self.close_button = QPushButton('Cancel and close')
- self.close_button.setToolTip('Cancel changes and return to main program.')
- self.close_button.clicked.connect(self.closeCPWindow)
- self.close_button.setObjectName('terminate')
-
- # Layout objects
- # Camera drop-down
- self.camera_box = QGroupBox('Camera')
- self.layout.addWidget(self.camera_box)
- cmbox = QHBoxLayout()
- self.camera_box.setLayout(cmbox)
- cmbox.addWidget(self.camera_combo)
- cmbox.addWidget(self.camera_button)
-
- # Brightness
- self.brightness_box =QGroupBox('Brightness')
- self.layout.addWidget(self.brightness_box)
- bvbox = QHBoxLayout()
- self.brightness_box.setLayout(bvbox)
- bvbox.addWidget(self.brightness_slider)
- bvbox.addWidget(self.brightness_label)
- # Contrast
- self.contrast_box =QGroupBox('Contrast')
- self.layout.addWidget(self.contrast_box)
- cvbox = QHBoxLayout()
- self.contrast_box.setLayout(cvbox)
- cvbox.addWidget(self.contrast_slider)
- cvbox.addWidget(self.contrast_label)
- # Saturation
- self.saturation_box =QGroupBox('Saturation')
- self.layout.addWidget(self.saturation_box)
- svbox = QHBoxLayout()
- self.saturation_box.setLayout(svbox)
- svbox.addWidget(self.saturation_slider)
- svbox.addWidget(self.saturation_label)
- # Hue
- self.hue_box =QGroupBox('Hue')
- self.layout.addWidget(self.hue_box)
- hvbox = QHBoxLayout()
- self.hue_box.setLayout(hvbox)
- hvbox.addWidget(self.hue_slider)
- hvbox.addWidget(self.hue_label)
- # Reset button
- self.layout.addWidget(self.reset_button)
- self.layout.addWidget(self.save_button)
- self.layout.addWidget(self.close_button)
-
- # OK Cancel buttons
- #self.layout.addWidget(self.buttonBox)
-
- def resetDefaults(self):
- self.parent().video_thread.resetProperties()
- (brightness_input, contrast_input, saturation_input, hue_input) = self.parent().video_thread.getProperties()
-
- brightness_input = int(brightness_input)
- contrast_input = int(contrast_input)
- saturation_input = int(saturation_input)
- hue_input = int(hue_input)
- self.brightness_slider.setValue(brightness_input)
- self.brightness_label.setText(str(brightness_input))
- self.contrast_slider.setValue(contrast_input)
- self.contrast_label.setText(str(contrast_input))
- self.saturation_slider.setValue(saturation_input)
- self.saturation_label.setText(str(saturation_input))
- self.hue_slider.setValue(hue_input)
- self.hue_label.setText(str(hue_input))
-
- def changeBrightness(self):
- parameter = int(self.brightness_slider.value())
- try:
- self.parent().video_thread.setProperty(brightness=parameter)
- except:
- None
- self.brightness_label.setText(str(parameter))
-
- def changeContrast(self):
- parameter = int(self.contrast_slider.value())
- try:
- self.parent().video_thread.setProperty(contrast=parameter)
- except:
- None
- self.contrast_label.setText(str(parameter))
-
- def changeSaturation(self):
- parameter = int(self.saturation_slider.value())
- try:
- self.parent().video_thread.setProperty(saturation=parameter)
- except:
- None
- self.saturation_label.setText(str(parameter))
-
- def changeHue(self):
- parameter = int(self.hue_slider.value())
- try:
- self.parent().video_thread.setProperty(hue=parameter)
- except:
- None
- self.hue_label.setText(str(parameter))
-
- def getCameras(self):
- # checks the first 6 indexes.
- i = 6
- index = 0
- self.camera_combo.clear()
- _cameras = []
- original_camera_description = str(video_src) + ': ' \
- + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) \
- + 'x' + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \
- + str(self.parent().video_thread.cap.get(cv2.CAP_PROP_FPS)) + 'fps'
- _cameras.append(original_camera_description)
- while i > 0:
- if index != video_src:
- tempCap = cv2.VideoCapture(index)
- if tempCap.read()[0]:
- api = tempCap.getBackendName()
- camera_description = str(index) + ': ' \
- + str(tempCap.get(cv2.CAP_PROP_FRAME_WIDTH)) \
- + 'x' + str(tempCap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + ' @ ' \
- + str(tempCap.get(cv2.CAP_PROP_FPS)) + 'fps'
- _cameras.append(camera_description)
- tempCap.release()
- index += 1
- i -= 1
- #cameras = [line for line in allOutputs if float(line['propmode']) > -1 ]
- _cameras.sort()
- for camera in _cameras:
- self.camera_combo.addItem(camera)
- self.camera_combo.setCurrentText(original_camera_description)
-
- def sendUserParameters(self):
- _tempSrc = self.camera_combo.currentText()
- _tempSrc = _tempSrc[:_tempSrc.find(':')]
- self.parent().saveUserParameters(cameraSrc=_tempSrc)
- self.close()
-
- def closeCPWindow(self):
- self.parent().updateStatusbar('Camera changes discarded.')
- self.close()
-
-class OverlayLabel(QLabel):
- def __init__(self):
- super(OverlayLabel, self).__init__()
- self.display_text = 'Welcome to TAMV. Enter your printer address and click \"Connect..\" to start.'
-
- def paintEvent(self, event):
- super(OverlayLabel, self).paintEvent(event)
- pos = QPoint(10, 470)
- painter = QPainter(self)
- painter.setBrush(QColor(204,204,204,230))
- painter.setPen(QColor(255, 255, 255,0))
- painter.drawRect(0,450,640,50)
- painter.setPen(QColor(0, 0, 0))
- painter.drawText(pos, self.display_text)
-
- def setText(self, textToDisplay):
- self.display_text = textToDisplay
-
-class CalibrateNozzles(QThread):
- # Signals
- status_update = pyqtSignal(str)
- message_update = pyqtSignal(str)
- change_pixmap_signal = pyqtSignal(np.ndarray)
- calibration_complete = pyqtSignal()
- detection_error = pyqtSignal(str)
- result_update = pyqtSignal(object)
-
- alignment = False
- _running = False
- display_crosshair = False
- detection_on = False
-
- def __init__(self, parent=None, th1=1, th2=50, thstep=1, minArea=600, minCircularity=0.8,numTools=0,cycles=1, align=False):
- super(QThread,self).__init__(parent=parent)
- # transformation matrix
- self.transform_matrix = []
- self.xray = False
- self.loose = False
- self.detector_changed = False
- self.detect_th1 = th1
- self.detect_th2 = th2
- self.detect_thstep = thstep
- self.detect_minArea = minArea
- self.detect_minCircularity = minCircularity
- self.numTools = numTools
- self.cycles = cycles
- self.alignment = align
- self.message_update.emit('Detector created, waiting for tool..')
-
- # start with detection off
- self.display_crosshair = False
- self.detection_on = False
-
- # Video Parameters
- self.brightness_default = 0
- self.contrast_default = 0
- self.saturation_default = 0
- self.hue_default = 0
- self.brightness = -1
- self.contrast = -1
- self.saturation = -1
- self.hue = -1
-
- # Start Video feed
- self.cap = cv2.VideoCapture(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.brightness_default = self.cap.get(cv2.CAP_PROP_BRIGHTNESS)
- self.contrast_default = self.cap.get(cv2.CAP_PROP_CONTRAST)
- self.saturation_default = self.cap.get(cv2.CAP_PROP_SATURATION)
- self.hue_default = self.cap.get(cv2.CAP_PROP_HUE)
-
- self.ret, self.cv_img = self.cap.read()
- if self.ret:
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- else:
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.ret, self.cv_img = self.cap.read()
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
-
- def toggleXray(self):
- if self.xray:
- self.xray = False
- else: self.xray = True
-
- def toggleLoose(self):
- self.detector_changed = True
- if self.loose:
- self.loose = False
- else: self.loose = True
-
- def setProperty(self,brightness=-1, contrast=-1, saturation=-1, hue=-1):
- try:
- if int(brightness) >= 0:
- self.brightness = brightness
- self.cap.set(cv2.CAP_PROP_BRIGHTNESS,self.brightness)
- except Exception as b1:
- print('Brightness exception: ', b1 )
- try:
- if int(contrast) >= 0:
- self.contrast = contrast
- self.cap.set(cv2.CAP_PROP_CONTRAST,self.contrast)
- except Exception as c1:
- print('Contrast exception: ', c1 )
- try:
- if int(saturation) >= 0:
- self.saturation = saturation
- self.cap.set(cv2.CAP_PROP_SATURATION,self.saturation)
- except Exception as s1:
- print('Saturation exception: ', s1 )
- try:
- if int(hue) >= 0:
- self.hue = hue
- self.cap.set(cv2.CAP_PROP_HUE,self.hue)
- except Exception as h1:
- print('Hue exception: ', h1 )
-
- def getProperties(self):
- return (self.brightness_default, self.contrast_default, self.saturation_default,self.hue_default)
-
- def resetProperties(self):
- self.setProperty(brightness=self.brightness_default, contrast = self.contrast_default, saturation=self.saturation_default, hue=self.hue_default)
-
- def run(self):
- self.createDetector()
- while True:
- if self.detection_on:
- if self.alignment:
- try:
- if self.loose:
- self.detect_minCircularity = 0.3
- else: self.detect_minCircularity = 0.8
- if self.detector_changed:
- self.createDetector()
- self.detector_changed = False
- self._running = True
- while self._running:
- self.cycles = self.parent().cycles
- for rep in range(self.cycles):
- for tool in range(self.parent().num_tools):
- # process GUI events
- app.processEvents()
- # Update status bar
- self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles))
- # Load next tool for calibration
- self.parent().printer.gCode('T'+str(tool))
- # Move tool to CP coordinates
- self.parent().printer.gCode('G1 X' + str(self.parent().cp_coords['X']))
- self.parent().printer.gCode('G1 Y' + str(self.parent().cp_coords['Y']))
- self.parent().printer.gCode('G1 Z' + str(self.parent().cp_coords['Z']))
- # Wait for moves to complete
- while self.parent().printer.getStatus() not in 'idle':
- # process GUI events
- app.processEvents()
- self.ret, self.cv_img = self.cap.read()
- if self.ret:
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- else:
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.ret, self.cv_img = self.cap.read()
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- continue
- # Update message bar
- self.message_update.emit('Searching for nozzle..')
- # Process runtime algorithm changes
- if self.loose:
- self.detect_minCircularity = 0.3
- else: self.detect_minCircularity = 0.8
- if self.detector_changed:
- self.createDetector()
- self.detector_changed = False
- # Analyze frame for blobs
- (c, transform, mpp) = self.calibrateTool(tool, rep)
- # process GUI events
- app.processEvents()
- # apply offsets to machine
- self.parent().printer.gCode( 'G10 P' + str(tool) + ' X' + str(c['X']) + ' Y' + str(c['Y']) )
- # signal end of execution
- self._running = False
- # Update status bar
- self.status_update.emit('Calibration complete: Resetting machine.')
- # HBHBHB
- # Update debug window with results
- # self.parent().debugString += '\nCalibration output:\n'
- self.parent().printer.gCode('T-1')
- self.parent().printer.gCode('G1 X' + str(self.parent().cp_coords['X']))
- self.parent().printer.gCode('G1 Y' + str(self.parent().cp_coords['Y']))
- self.parent().printer.gCode('G1 Z' + str(self.parent().cp_coords['Z']))
- self.status_update.emit('Calibration complete: Done.')
- self.alignment = False
- self.detection_on = False
- self.display_crosshair = False
- self._running = False
- self.calibration_complete.emit()
- except Exception as mn1:
- self.alignment = False
- self.detection_on = False
- self.display_crosshair = False
- self._running = False
- self.detection_error.emit(str(mn1))
- self.cap.release()
- else:
- # don't run alignment - fetch frames and detect only
- try:
- if self.loose:
- self.detect_minCircularity = 0.3
- else: self.detect_minCircularity = 0.8
- self._running = True
- # transformation matrix
- #self.transform_matrix = []
- while self._running and self.detection_on:
- # Update status bar
- #self.status_update.emit('Detection mode: ON')
- # Process runtime algorithm changes
- if self.loose:
- self.detect_minCircularity = 0.3
- else: self.detect_minCircularity = 0.8
- if self.detector_changed:
- self.createDetector()
- self.detector_changed = False
- # Run detection and update output
- self.analyzeFrame()
- # process GUI events
- app.processEvents()
- except Exception as mn1:
- self._running = False
- self.detection_error.emit(str(mn1))
- self.cap.release()
- else:
- while not self.detection_on:
- try:
- self.ret, self.cv_img = self.cap.read()
- if self.ret:
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- else:
- # reset capture
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.ret, self.cv_img = self.cap.read()
- if self.ret:
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- continue
- app.processEvents()
- except Exception as mn2:
- self.status_update( 'Error: ' + str(mn2) )
- print('Error: ' + str(mn2))
- self.cap.release()
- self.detection_on = False
- self._running = False
- exit()
- app.processEvents()
- app.processEvents()
- continue
- self.cap.release()
-
- def analyzeFrame(self):
- # Placeholder coordinates
- xy = [0,0]
- # Counter of frames with no circle.
- nocircle = 0
- # Random time offset
- rd = int(round(time.time()*1000))
- # reset capture
- #self.cap.open(video_src)
- #self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- #self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- #self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
-
- while True and self.detection_on:
- app.processEvents()
- self.ret, self.frame = self.cap.read()
- if not self.ret:
- # reset capture
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- continue
- if self.alignment:
- try:
- # capture tool location in machine space before processing
- toolCoordinates = self.parent().printer.getCoords()
- except Exception as c1:
- toolCoordinates = None
- # capture first clean frame for display
- cleanFrame = self.frame
- # apply nozzle detection algorithm
- # Detection algorithm 1:
- # gamma correction -> use Y channel from YUV -> GaussianBlur (7,7),6 -> adaptive threshold
- gammaInput = 1.2
- self.frame = self.adjust_gamma(image=self.frame, gamma=gammaInput)
- yuv = cv2.cvtColor(self.frame, cv2.COLOR_BGR2YUV)
- yuvPlanes = cv2.split(yuv)
- yuvPlanes[0] = cv2.GaussianBlur(yuvPlanes[0],(7,7),6)
- yuvPlanes[0] = cv2.adaptiveThreshold(yuvPlanes[0],255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,35,1)
- self.frame = cv2.cvtColor(yuvPlanes[0],cv2.COLOR_GRAY2BGR)
- target = [int(np.around(self.frame.shape[1]/2)),int(np.around(self.frame.shape[0]/2))]
- # Process runtime algorithm changes
- if self.loose:
- self.detect_minCircularity = 0.3
- else: self.detect_minCircularity = 0.8
- if self.detector_changed:
- self.createDetector()
- self.detector_changed = False
- # run nozzle detection for keypoints
- keypoints = self.detector.detect(self.frame)
- # draw the timestamp on the frame AFTER the circle detector! Otherwise it finds the circles in the numbers.
- if self.xray:
- cleanFrame = self.frame
- # check if we are displaying a crosshair
- if self.display_crosshair:
- self.frame = cv2.line(cleanFrame, (target[0], target[1]-25), (target[0], target[1]+25), (0, 255, 0), 1)
- self.frame = cv2.line(self.frame, (target[0]-25, target[1] ), (target[0]+25, target[1] ), (0, 255, 0), 1)
- else: self.frame = cleanFrame
- # update image
- local_img = self.frame
- self.change_pixmap_signal.emit(local_img)
- if(nocircle> 25):
- self.message_update.emit( 'Error in detecting nozzle.' )
- nocircle = 0
- continue
- num_keypoints=len(keypoints)
- if (num_keypoints == 0):
- if (25 < (int(round(time.time() * 1000)) - rd)):
- nocircle += 1
- self.frame = self.putText(self.frame,'No circles found',offsety=3)
- self.message_update.emit( 'No circles found.' )
- local_img = self.frame
- self.change_pixmap_signal.emit(local_img)
- continue
- if (num_keypoints > 1):
- if (25 < (int(round(time.time() * 1000)) - rd)):
- self.message_update.emit( 'Too many circles found. Please stop and clean the nozzle.' )
- self.frame = self.putText(self.frame,'Too many circles found '+str(num_keypoints),offsety=3, color=(255,255,255))
- self.frame = cv2.drawKeypoints(self.frame, keypoints, np.array([]), (255,255,255), cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
- local_img = self.frame
- self.change_pixmap_signal.emit(local_img)
- continue
- # Found one and only one circle. Put it on the frame.
- nocircle = 0
- xy = np.around(keypoints[0].pt)
- r = np.around(keypoints[0].size/2)
- # draw the blobs that look circular
- self.frame = cv2.drawKeypoints(self.frame, keypoints, np.array([]), (0,0,255), cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
- # Note its radius and position
- ts = 'U{0:3.0f} V{1:3.0f} R{2:2.0f}'.format(xy[0],xy[1],r)
- xy = np.uint16(xy)
- #self.frame = self.putText(self.frame, ts, offsety=2, color=(0, 255, 0), stroke=2)
- self.message_update.emit(ts)
- # show the frame
- local_img = self.frame
- self.change_pixmap_signal.emit(local_img)
- rd = int(round(time.time() * 1000))
- #end the loop
- break
- # and tell our parent.
- if self.detection_on:
- return (xy, target, toolCoordinates, r)
- else:
- return
-
- def calibrateTool(self, tool, rep):
- # timestamp for caluclating tool calibration runtime
- self.startTime = time.time()
- # average location of keypoints in frame
- self.average_location=[0,0]
- # current location
- self.current_location = {'X':0,'Y':0}
- # guess position used for camera calibration
- self.guess_position = [1,1]
- # current keypoint location
- self.xy = [0,0]
- # previous keypoint location
- self.oldxy = self.xy
- # Tracker flag to set which state algorithm is running in
- self.state = 0
- # detected blob counter
- self.detect_count = 0
- # Save CP coordinates to local class
- self.cp_coordinates = self.parent().cp_coords
- # number of average position loops
- self.position_iterations = 5
- # calibration move set (0.5mm radius circle over 10 moves)
- self.calibrationCoordinates = [ [0,-0.5], [0.294,-0.405], [0.476,-0.155], [0.476,0.155], [0.294,0.405], [0,0.5], [-0.294,0.405], [-0.476,0.155], [-0.476,-0.155], [-0.294,-0.405] ]
-
- # Check if camera calibration matrix is already defined
- if len(self.transform_matrix) > 1:
- # set state flag to Step 2: nozzle alignment stage
- self.state = 200
- self.parent().debugString += '\nCalibrating T'+str(tool)+':C'+str(rep)+': '
-
- # Space coordinates
- self.space_coordinates = []
- self.camera_coordinates = []
- self.calibration_moves = 0
-
- while True:
- (self.xy, self.target, self.tool_coordinates, self.radius) = self.analyzeFrame()
- # analyzeFrame has returned our target coordinates, average its location and process according to state
- self.average_location[0] += self.xy[0]
- self.average_location[1] += self.xy[1]
-
- self.detect_count += 1
-
- # check if we've reached our number of detections for average positioning
- if self.detect_count >= self.position_iterations:
- # calculate average X Y position from detection
- self.average_location[0] /= self.detect_count
- self.average_location[1] /= self.detect_count
- # round to 3 decimal places
- self.average_location = np.around(self.average_location,3)
- # get another detection validated
- (self.xy, self.target, self.tool_coordinates, self.radius) = self.analyzeFrame()
-
- #### Step 1: camera calibration and transformation matrix calculation
- if self.state == 0:
- self.parent().debugString += 'Calibrating camera...\n'
- # Update GUI thread with current status and percentage complete
- self.status_update.emit('Calibrating camera..')
- self.message_update.emit('Calibrating rotation.. (10%)')
- # Save position as previous location
- self.oldxy = self.xy
- # Reset space and camera coordinates
- self.space_coordinates = []
- self.camera_coordinates = []
- # save machine coordinates for detected nozzle
- self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) )
- # save camera coordinates
- self.camera_coordinates.append( (self.xy[0],self.xy[1]) )
- # move carriage for calibration
- self.offsetX = self.calibrationCoordinates[0][0]
- self.offsetY = self.calibrationCoordinates[0][1]
- self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ')
- # Update state tracker to second nozzle calibration move
- self.state = 1
- continue
- # Check if camera is still being calibrated
- elif self.state >= 1 and self.state < len(self.calibrationCoordinates):
- # Update GUI thread with current status and percentage complete
- self.status_update.emit('Calibrating camera..')
- self.message_update.emit('Calibrating rotation.. (' + str(self.state*10) + '%)')
- # check if we've already moved, and calculate mpp value
- if self.state == 1:
- self.mpp = np.around(0.5/self.getDistance(self.oldxy[0],self.oldxy[1],self.xy[0],self.xy[1]),4)
- # save position as previous position
- self.oldxy = self.xy
- # save machine coordinates for detected nozzle
- self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) )
- # save camera coordinates
- self.camera_coordinates.append( (self.xy[0],self.xy[1]) )
- # return carriage to relative center of movement
- self.offsetX = -1*self.offsetX
- self.offsetY = -1*self.offsetY
- self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ')
- # move carriage a random amount in X&Y to collect datapoints for transform matrix
- self.offsetX = self.calibrationCoordinates[self.state][0]
- self.offsetY = self.calibrationCoordinates[self.state][1]
- self.parent().printer.gCode('G91 G1 X' + str(self.offsetX) + ' Y' + str(self.offsetY) +' F3000 G90 ')
- # increment state tracker to next calibration move
- self.state += 1
- continue
- # check if final calibration move has been completed
- elif self.state == len(self.calibrationCoordinates):
- calibration_time = np.around(time.time() - self.startTime,1)
- self.parent().debugString += 'Camera calibration completed in ' + str(calibration_time) + ' seconds.\n'
- self.parent().debugString += 'Millimeters per pixel: ' + str(self.mpp) + '\n\n'
- print('Millimeters per pixel: ' + str(self.mpp))
- print('Camera calibration completed in ' + str(calibration_time) + ' seconds.')
- # Update GUI thread with current status and percentage complete
- self.message_update.emit('Calibrating rotation.. (100%) - MPP = ' + str(self.mpp))
- self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles))
- # save position as previous position
- self.oldxy = self.xy
- # save machine coordinates for detected nozzle
- self.space_coordinates.append( (self.tool_coordinates['X'], self.tool_coordinates['Y']) )
- # save camera coordinates
- self.camera_coordinates.append( (self.xy[0],self.xy[1]) )
- # calculate camera transformation matrix
- self.transform_input = [(self.space_coordinates[i], self.normalize_coords(camera)) for i, camera in enumerate(self.camera_coordinates)]
- self.transform_matrix, self.transform_residual = self.least_square_mapping(self.transform_input)
- # define camera center in machine coordinate space
- self.newCenter = self.transform_matrix.T @ np.array([0, 0, 0, 0, 0, 1])
- self.guess_position[0]= np.around(self.newCenter[0],3)
- self.guess_position[1]= np.around(self.newCenter[1],3)
- self.parent().printer.gCode('G90 G1 X{0:-1.3f} Y{1:-1.3f} F1000 G90 '.format(self.guess_position[0],self.guess_position[1]))
- # update state tracker to next phase
- self.state = 200
- # start tool calibration timer
- self.startTime = time.time()
- self.parent().debugString += '\nCalibrating T'+str(tool)+':C'+str(rep)+': '
- continue
- #### Step 2: nozzle alignment stage
- elif self.state == 200:
- # Update GUI thread with current status and percentage complete
- self.message_update.emit('Tool calibration move #' + str(self.calibration_moves))
- self.status_update.emit('Calibrating T' + str(tool) + ', cycle: ' + str(rep+1) + '/' + str(self.cycles))
- # increment moves counter
- self.calibration_moves += 1
- # nozzle detected, frame rotation is set, start
- self.cx,self.cy = self.normalize_coords(self.xy)
- self.v = [self.cx**2, self.cy**2, self.cx*self.cy, self.cx, self.cy, 0]
- self.offsets = -1*(0.55*self.transform_matrix.T @ self.v)
- self.offsets[0] = np.around(self.offsets[0],3)
- self.offsets[1] = np.around(self.offsets[1],3)
- # Move it a bit
- self.parent().printer.gCode( 'M564 S1' )
- self.parent().printer.gCode( 'G91 G1 X{0:-1.3f} Y{1:-1.3f} F1000 G90 '.format(self.offsets[0],self.offsets[1]) )
- # save position as previous position
- self.oldxy = self.xy
- if ( self.offsets[0] == 0.0 and self.offsets[1] == 0.0 ):
- self.parent().debugString += str(self.calibration_moves) + ' moves.\n'
- self.parent().printer.gCode( 'G1 F13200' )
- # Update GUI with progress
- # calculate final offsets and return results
- self.tool_offsets = self.parent().printer.getG10ToolOffset(tool)
- final_x = np.around( (self.cp_coordinates['X'] + self.tool_offsets['X']) - self.tool_coordinates['X'], 3 )
- final_y = np.around( (self.cp_coordinates['Y'] + self.tool_offsets['Y']) - self.tool_coordinates['Y'], 3 )
- string_final_x = "{:.3f}".format(final_x)
- string_final_y = "{:.3f}".format(final_y)
- # Save offset to output variable
- # HBHBHBHB
- _return = {}
- _return['X'] = final_x
- _return['Y'] = final_y
- _return['MPP'] = self.mpp
- _return['time'] = np.around(time.time() - self.startTime,1)
- self.message_update.emit('Nozzle calibrated: offset coordinates X' + str(_return['X']) + ' Y' + str(_return['Y']) )
- self.parent().debugString += 'T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.\n'
- print('T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.')
- self.message_update.emit('T' + str(tool) + ', cycle ' + str(rep+1) + ' completed in ' + str(_return['time']) + ' seconds.')
- self.parent().printer.gCode( 'G1 F13200' )
-
- self.parent().debugString += 'G10 P' + str(tool) + ' X' + string_final_x + ' Y' + string_final_y + '\n'
- x_tableitem = QTableWidgetItem(string_final_x)
- x_tableitem.setBackground(QColor(100,255,100,255))
- y_tableitem = QTableWidgetItem(string_final_y)
- y_tableitem.setBackground(QColor(100,255,100,255))
- self.parent().offsets_table.setItem(tool,0,x_tableitem)
- self.parent().offsets_table.setItem(tool,1,y_tableitem)
- self.result_update.emit({
- 'tool': str(tool),
- 'cycle': str(rep),
- 'mpp': str(self.mpp),
- 'X': string_final_x,
- 'Y': string_final_y
- })
- return(_return, self.transform_matrix, self.mpp)
- else:
- self.state = 200
- continue
- self.avg = [0,0]
- self.location = {'X':0,'Y':0}
- self.count = 0
-
- def normalize_coords(self,coords):
- xdim, ydim = camera_width, camera_height
- return (coords[0] / xdim - 0.5, coords[1] / ydim - 0.5)
-
- def least_square_mapping(self,calibration_points):
- # Compute a 2x2 map from displacement vectors in screen space to real space.
- n = len(calibration_points)
- real_coords, pixel_coords = np.empty((n,2)),np.empty((n,2))
- for i, (r,p) in enumerate(calibration_points):
- real_coords[i] = r
- pixel_coords[i] = p
- x,y = pixel_coords[:,0],pixel_coords[:,1]
- A = np.vstack([x**2,y**2,x * y, x,y,np.ones(n)]).T
- transform = np.linalg.lstsq(A, real_coords, rcond = None)
- return transform[0], transform[1].mean()
-
- def getDistance(self, x1, y1, x0, y0 ):
- x1_float = float(x1)
- x0_float = float(x0)
- y1_float = float(y1)
- y0_float = float(y0)
- x_dist = (x1_float - x0_float) ** 2
- y_dist = (y1_float - y0_float) ** 2
- retVal = np.sqrt((x_dist + y_dist))
- return np.around(retVal,3)
-
- def stop(self):
- self._running = False
- self.detection_on = False
- try:
- tempCoords = self.printer.getCoords()
- if self.printer.isIdle():
- self.parent().printer.gCode('T-1')
- self.parent().printer.gCode('G1 X' + str(tempCoords['X']) + ' Y' + str(tempCoords['Y']))
- while self.parent().printer.getStatus() not in 'idle':
- time.sleep(1)
- except: None
- self.cap.release()
- self.exit()
-
- def createDetector(self):
- # Setup SimpleBlobDetector parameters.
- params = cv2.SimpleBlobDetector_Params()
- # Thresholds
- params.minThreshold = self.detect_th1
- params.maxThreshold = self.detect_th2
- params.thresholdStep = self.detect_thstep
-
- # Area
- params.filterByArea = True # Filter by Area.
- params.minArea = self.detect_minArea
-
- # Circularity
- params.filterByCircularity = True # Filter by Circularity
- params.minCircularity = self.detect_minCircularity
- params.maxCircularity= 1
-
- # Convexity
- params.filterByConvexity = True # Filter by Convexity
- params.minConvexity = 0.3
- params.maxConvexity = 1
-
- # Inertia
- params.filterByInertia = True # Filter by Inertia
- params.minInertiaRatio = 0.3
-
- # create detector
- self.detector = cv2.SimpleBlobDetector_create(params)
-
- def adjust_gamma(self, image, gamma=1.2):
- # build a lookup table mapping the pixel values [0, 255] to
- # their adjusted gamma values
- invGamma = 1.0 / gamma
- table = np.array([((i / 255.0) ** invGamma) * 255
- for i in np.arange(0, 256)]).astype('uint8')
- # apply gamma correction using the lookup table
- return cv2.LUT(image, table)
-
- def putText(self, frame,text,color=(0, 0, 255),offsetx=0,offsety=0,stroke=1): # Offsets are in character box size in pixels.
- if (text == 'timestamp'): text = datetime.datetime.now().strftime('%m-%d-%Y %H:%M:%S')
- fontScale = 1
- if (frame.shape[1] > 640): fontScale = stroke = 2
- if (frame.shape[1] < 640):
- fontScale = 0.8
- stroke = 1
- offpix = cv2.getTextSize('A', cv2.FONT_HERSHEY_SIMPLEX ,fontScale, stroke)
- textpix = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX ,fontScale, stroke)
- offsety=max(offsety, (-frame.shape[0]/2 + offpix[0][1])/offpix[0][1]) # Let offsety -99 be top row
- offsetx=max(offsetx, (-frame.shape[1]/2 + offpix[0][0])/offpix[0][0]) # Let offsetx -99 be left edge
- offsety=min(offsety, (frame.shape[0]/2 - offpix[0][1])/offpix[0][1]) # Let offsety 99 be bottom row.
- offsetx=min(offsetx, (frame.shape[1]/2 - offpix[0][0])/offpix[0][0]) # Let offsetx 99 be right edge.
- cv2.putText(frame, text,
- (int(offsetx * offpix[0][0]) + int(frame.shape[1]/2) - int(textpix[0][0]/2)
- ,int(offsety * offpix[0][1]) + int(frame.shape[0]/2) + int(textpix[0][1]/2)),
- cv2.FONT_HERSHEY_SIMPLEX, fontScale, color, stroke)
- return(frame)
-
- def changeVideoSrc(self, newSrc=-1):
- self.cap.release()
- video_src = newSrc
- # Start Video feed
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.brightness_default = self.cap.get(cv2.CAP_PROP_BRIGHTNESS)
- self.contrast_default = self.cap.get(cv2.CAP_PROP_CONTRAST)
- self.saturation_default = self.cap.get(cv2.CAP_PROP_SATURATION)
- self.hue_default = self.cap.get(cv2.CAP_PROP_HUE)
-
- self.ret, self.cv_img = self.cap.read()
- if self.ret:
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
- else:
- self.cap.open(video_src)
- self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, camera_width)
- self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, camera_height)
- self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
- #self.cap.set(cv2.CAP_PROP_FPS,25)
- self.ret, self.cv_img = self.cap.read()
- local_img = self.cv_img
- self.change_pixmap_signal.emit(local_img)
-
-class App(QMainWindow):
- cp_coords = {}
- numTools = 0
- current_frame = np.ndarray
- mutex = QMutex()
- debugString = ''
- calibrationResults = []
-
- def __init__(self, parent=None):
- super().__init__()
- self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
- self.setWindowTitle('TAMV')
- self.setWindowIcon(QIcon('jubilee.png'))
- global display_width, display_height
- screen = QDesktopWidget().availableGeometry()
- self.small_display = False
- # HANDLE DIFFERENT DISPLAY SIZES
- # 800x600 display - fullscreen app
- if int(screen.width()) >= 800 and int(screen.height()) >= 550 and int(screen.height() < 600):
- self.small_display = True
- print('800x600 desktop detected')
- display_width = 512
- display_height = 384
- self.setWindowFlag(Qt.FramelessWindowHint)
- self.showFullScreen()
- self.setGeometry(0,0,700,500)
- app_screen = self.frameGeometry()
- # 848x480 display - fullscreen app
- elif int(screen.width()) >= 800 and int(screen.height()) < 550:
- self.small_display = True
- print('848x480 desktop detected')
- display_width = 448
- display_height = 336
- self.setWindowFlag(Qt.FramelessWindowHint)
- self.showFullScreen()
- self.setGeometry(0,0,700,400)
- app_screen = self.frameGeometry()
- # larger displays - normal window
- else:
- self.small_display = False
- display_width = 640
- display_height = 480
- self.setGeometry(QStyle.alignedRect(Qt.LeftToRight,Qt.AlignHCenter,QSize(800,600),screen))
- app_screen = self.frameGeometry()
- app_screen.moveCenter(screen.center())
- self.move(app_screen.topLeft())
- # SET UP STYLESHEETS FOR GUI ELEMENTS
- self.setStyleSheet(
- '\
- QPushButton {\
- border: 1px solid #adadad;\
- border-style: outset;\
- border-radius: 4px;\
- font: 14px;\
- padding: 6px;\
- }\
- QPushButton:hover,QPushButton:enabled:hover,QPushButton:enabled:!checked:hover {\
- background-color: #27ae60;\
- border: 1px solid #aaaaaa;\
- }\
- QPushButton:pressed,QPushButton:enabled:pressed,QPushButton:enabled:checked {\
- background-color: #ae2776;\
- border: 1px solid #aaaaaa;\
- }\
- QPushButton:enabled {\
- background-color: green;\
- color: white;\
- }\
- QPushButton#debug,QMessageBox > #debug {\
- background-color: blue;\
- color: white;\
- }\
- QPushButton#debug:hover, QMessageBox > QAbstractButton#debug:hover {\
- background-color: green;\
- color: white;\
- }\
- QPushButton#debug:pressed, QMessageBox > QAbstractButton#debug:pressed {\
- background-color: #ae2776;\
- border-style: inset;\
- color: white;\
- }\
- QPushButton#active, QMessageBox > QAbstractButton#active {\
- background-color: green;\
- color: white;\
- }\
- QPushButton#active:pressed,QMessageBox > QAbstractButton#active:pressed {\
- background-color: #ae2776;\
- }\
- QPushButton#terminate {\
- background-color: red;\
- color: white;\
- }\
- QPushButton#terminate:pressed {\
- background-color: #c0392b;\
- }\
- QPushButton:disabled, QPushButton#terminate:disabled {\
- background-color: #cccccc;\
- color: #999999;\
- }\
- QInputDialog QDialogButtonBox > QPushButton:enabled, QDialog QPushButton:enabled,QPushButton[checkable="true"]:enabled {\
- background-color: none;\
- color: black;\
- border: 1px solid #adadad;\
- border-style: outset;\
- border-radius: 4px;\
- font: 14px;\
- padding: 6px;\
- }\
- QPushButton:enabled:checked {\
- background-color: #ae2776;\
- border: 1px solid #aaaaaa;\
- }\
- QInputDialog QDialogButtonBox > QPushButton:pressed, QDialog QPushButton:pressed {\
- background-color: #ae2776;\
- }\
- QInputDialog QDialogButtonBox > QPushButton:hover:!pressed, QDialog QPushButton:hover:!pressed {\
- background-color: #27ae60;\
- }\
- '
- )
- # LOAD USER SAVED PARAMETERS OR CREATE DEFAULTS
- self.loadUserParameters()
- # GUI ELEMENTS DEFINITION
- # Menubar
- if not self.small_display:
- self._createActions()
- self._createMenuBar()
- self._connectActions()
- self.centralWidget = QWidget()
- self.setCentralWidget(self.centralWidget)
- # create the label that holds the image
- self.image_label = OverlayLabel()
- self.image_label.setFixedSize( display_width, display_height )
- pixmap = QPixmap( display_width, display_height )
- self.image_label.setPixmap(pixmap)
- # create a status bar
- self.statusBar = QStatusBar()
- self.statusBar.showMessage('Loading up video feed and libraries..',5000)
- self.setStatusBar( self.statusBar )
- # CP location on statusbar
- self.cp_label = QLabel('CP: undef')
- self.statusBar.addPermanentWidget(self.cp_label)
- self.cp_label.setStyleSheet(style_red)
- # Connection status on statusbar
- self.connection_status = QLabel('Disconnected')
- self.connection_status.setStyleSheet(style_red)
- self.statusBar.addPermanentWidget(self.connection_status)
- # BUTTONS
- # Connect
- self.connection_button = QPushButton('Connect..')
- self.connection_button.setToolTip('Connect to a Duet machine..')
- self.connection_button.clicked.connect(self.connectToPrinter)
- self.connection_button.setFixedWidth(170)
- # Disconnect
- self.disconnection_button = QPushButton('STOP / DISCONNECT')
- self.disconnection_button.setToolTip('End current operation,\nunload tools, and return carriage to CP\nthen disconnect.')
- self.disconnection_button.clicked.connect(self.disconnectFromPrinter)
- self.disconnection_button.setFixedWidth(170)
- self.disconnection_button.setObjectName('terminate')
- self.disconnection_button.setDisabled(True)
- # Controlled point
- self.cp_button = QPushButton('Set Controlled Point..')
- self.cp_button.setToolTip('Define your origin point\nto calculate all tool offsets from.')
- self.cp_button.clicked.connect(self.controlledPoint)
- self.cp_button.setFixedWidth(170)
- #self.cp_button.setStyleSheet(style_disabled)
- self.cp_button.setDisabled(True)
- # Calibration
- self.calibration_button = QPushButton('Start Tool Alignment')
- self.calibration_button.setToolTip('Start alignment process.\nMAKE SURE YOUR CARRIAGE IS CLEAR TO MOVE ABOUT WITHOUT COLLISIONS!')
- self.calibration_button.clicked.connect(self.runCalibration)
- #self.calibration_button.setStyleSheet(style_disabled)
- self.calibration_button.setDisabled(True)
- self.calibration_button.setFixedWidth(170)
- # Jog Panel
- self.jogpanel_button = QPushButton('Jog Panel')
- self.jogpanel_button.setToolTip('Open a control panel to move carriage.')
- self.jogpanel_button.clicked.connect(self.displayJogPanel)
- self.jogpanel_button.setDisabled(True)
- self.jogpanel_button.setFixedWidth(170)
- # Debug Info
- self.debug_button = QPushButton('Debug Information')
- self.debug_button.setToolTip('Display current debug info for troubleshooting\nand to display final G10 commands')
- self.debug_button.clicked.connect(self.displayDebug)
- self.debug_button.setFixedWidth(170)
- self.debug_button.setObjectName('debug')
- # Exit
- self.exit_button = QPushButton('Quit')
- self.exit_button.setToolTip('Unload tools, disconnect, and quit TAMV.')
- self.exit_button.clicked.connect(self.close)
- self.exit_button.setFixedWidth(170)
-
- # OTHER ELEMENTS
- # Repeat spinbox
- self.repeat_label = QLabel('Cycles: ')
- self.repeat_label.setAlignment(Qt.AlignRight|Qt.AlignVCenter)
- self.repeatSpinBox = QSpinBox()
- self.repeatSpinBox.setValue(1)
- self.repeatSpinBox.setMinimum(1)
- self.repeatSpinBox.setSingleStep(1)
- self.repeatSpinBox.setDisabled(True)
- # Offsets table
- self.offsets_box = QGroupBox("Tool Offsets")
- self.offsets_box.setMaximumWidth(170)
- self.offsets_table = QTableWidget()
- self.offsets_table.setColumnCount(2)
- self.offsets_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self.offsets_table.setColumnWidth(0,50)
- self.offsets_table.setColumnWidth(1,50)
- self.offsets_table.setHorizontalHeaderItem(0, QTableWidgetItem("X"))
- self.offsets_table.setHorizontalHeaderItem(1, QTableWidgetItem("Y"))
- self.offsets_table.resizeRowsToContents()
- vbox = QVBoxLayout()
- vbox.setSpacing(1)
- self.offsets_box.setLayout(vbox)
- vbox.addWidget(self.offsets_table)
- self.offsets_box.setVisible(False)
- # Tool buttons table
- self.toolBoxLayout = QHBoxLayout()
- self.toolBoxLayout.setSpacing(1)
- self.toolBox = QGroupBox()
- self.toolBoxLayout.setContentsMargins(0,0,0,0)
- self.toolBox.setLayout(self.toolBoxLayout)
- self.toolBox.setVisible(False)
- self.toolButtons = []
- # Xray checkbox
- self.xray_box = QCheckBox('X-ray')
- self.xray_box.setChecked(False)
- self.xray_box.stateChanged.connect(self.toggle_xray)
- self.xray_box.setDisabled(True)
- self.xray_box.setVisible(False)
- # Loose checkbox
- self.loose_box = QCheckBox('Loose detection')
- self.loose_box.setChecked(False)
- self.loose_box.stateChanged.connect(self.toggle_loose)
- self.loose_box.setDisabled(True)
- self.loose_box.setVisible(False)
- # Detection checkbox
- self.detect_box = QCheckBox('Detect ON')
- self.detect_box.setChecked(False)
- self.detect_box.stateChanged.connect(self.toggle_detect)
- # create a grid box layout
- grid = QGridLayout()
- grid.setSpacing(3)
- # add elements to grid
- # FIRST ROW
- grid.addWidget(self.connection_button,1,1,Qt.AlignLeft)
- grid.addWidget(self.detect_box,1,2,1,1)
- grid.addWidget(self.xray_box,1,3,1,1)
- grid.addWidget(self.loose_box,1,4,1,1)
- grid.addWidget(self.toolBox,1,5,1,1)
- grid.addWidget(self.disconnection_button,1,7,1,-1,Qt.AlignLeft)
- # SECOND ROW
-
- # THIRD ROW
- # main image viewer
- grid.addWidget(self.image_label,3,1,4,6)
- grid.addWidget(self.jogpanel_button,3,7,1,1)
- grid.addWidget(self.offsets_box,4,7,1,1)
- if self.small_display:
- grid.addWidget(self.exit_button,5,7,1,1)
- grid.addWidget(self.debug_button,6,7,1,1)
- # FOURTH ROW
- grid.addWidget(self.cp_button,7,1,1,1)
- grid.addWidget(self.calibration_button,7,2,1,1)
- grid.addWidget(self.repeat_label,7,3,1,1)
- grid.addWidget(self.repeatSpinBox,7,4,1,1)
- # set the grid layout as the widgets layout
- self.centralWidget.setLayout(grid)
- # start video feed
- self.startVideo()
- # flag to draw circle
- self.crosshair = False
-
- def toggle_detect(self):
- self.video_thread.display_crosshair = not self.video_thread.display_crosshair
- self.video_thread.detection_on = not self.video_thread.detection_on
- if self.video_thread.detection_on:
- self.xray_box.setDisabled(False)
- self.xray_box.setVisible(True)
- self.loose_box.setDisabled(False)
- self.loose_box.setVisible(True)
- else:
- self.xray_box.setDisabled(True)
- self.xray_box.setVisible(False)
- self.loose_box.setDisabled(True)
- self.loose_box.setVisible(False)
- self.updateStatusbar('Detection: OFF')
-
- def cleanPrinterURL(self, inputString='http://localhost'):
- _errCode = 0
- _errMsg = ''
- _printerURL = 'http://localhost'
- from urllib.parse import urlparse
- u = urlparse(inputString)
- scheme = u[0]
- netlocation = u[1]
- if len(scheme) < 4 or scheme.lower() not in ['http']:
- _errCode = 1
- _errMsg = 'Invalid scheme. Please only use http connections.'
- elif len(netlocation) < 1:
- _errCode = 2
- _errMsg = 'Invalid IP/network address.'
- elif scheme.lower() in ['https']:
- _errCode = 3
- _errMsg = 'Cannot use https connections for Duet controllers'
- else:
- _printerURL = scheme + '://' + netlocation
- return( _errCode, _errMsg, _printerURL )
-
- def loadUserParameters(self):
- global camera_width, camera_height, video_src
- try:
- with open('settings.json','r') as inputfile:
- options = json.load(inputfile)
- camera_settings = options['camera'][0]
- camera_height = int( camera_settings['display_height'] )
- camera_width = int( camera_settings['display_width'] )
- video_src = camera_settings['video_src']
- if len(str(video_src)) == 1: video_src = int(video_src)
- printer_settings = options['printer'][0]
- tempURL = printer_settings['address']
- ( _errCode, _errMsg, self.printerURL ) = self.cleanPrinterURL(tempURL)
- if _errCode > 0:
- # invalid input
- print('Invalid printer URL detected in settings.json!')
- print('Defaulting to \"http://localhost\"...')
- self.printerURL = 'http://localhost'
- except FileNotFoundError:
- # create parameter file with standard parameters
- options = {}
- options['camera'] = []
- options['camera'].append( {
- 'video_src': 1,
- 'display_width': '640',
- 'display_height': '480'
- } )
- options['printer'] = []
- options['printer'].append( {
- 'address': 'http://localhost',
- 'name': 'Localhost'
- } )
- try:
- camera_width = 640
- camera_height = 480
- video_src = 1
- with open('settings.json','w') as outputfile:
- json.dump(options, outputfile)
- except Exception as e1:
- print('Error writing user settings file.')
- print(e1)
-
- def saveUserParameters(self, cameraSrc=-2):
- global camera_width, camera_height, video_src
- cameraSrc = int(cameraSrc)
- try:
- if cameraSrc > -2:
- video_src = cameraSrc
- options = {}
- options['camera'] = []
- options['camera'].append( {
- 'video_src': video_src,
- 'display_width': camera_width,
- 'display_height': camera_height
- } )
- options['printer'] = []
- options['printer'].append( {
- 'address': self.printerURL,
- 'name': 'Default printer'
- } )
- with open('settings.json','w') as outputfile:
- json.dump(options, outputfile)
- except Exception as e1:
- print('Error saving user settings file.')
- print(e1)
- if int(video_src) != cameraSrc:
- self.video_thread.changeVideoSrc(newSrc=cameraSrc)
- self.updateStatusbar('Current profile saved to settings.json')
-
- def _createMenuBar(self):
- menuBar = self.menuBar()
- # Creating menus using a QMenu object
- fileMenu = QMenu('&File', self)
- menuBar.addMenu(fileMenu)
- fileMenu.addAction(self.debugAction)
- fileMenu.addAction(self.cameraAction)
- fileMenu.addSeparator()
- fileMenu.addAction(self.saveAction)
- fileMenu.addSeparator()
- fileMenu.addAction(self.quitAction)
-
- self.analysisMenu = QMenu('&Analyze',self)
- menuBar.addMenu(self.analysisMenu)
- self.analysisMenu.addAction(self.graphAction)
- self.analysisMenu.addAction(self.exportAction)
- self.analysisMenu.setDisabled(True)
-
- def _createActions(self):
- # Creating action using the first constructor
- self.debugAction = QAction(self)
- self.debugAction.setText('&Debug info')
- self.cameraAction = QAction(self)
- self.cameraAction.setText('&Camera settings')
- self.quitAction = QAction(self)
- self.quitAction.setText('&Quit')
- self.saveAction = QAction(self)
- self.saveAction.setText('&Save current settings')
- self.graphAction = QAction(self)
- self.graphAction.setText('&Graph calibration data..')
- self.exportAction = QAction(self)
- self.exportAction.setText('&Export to output.json')
-
- def _connectActions(self):
- # Connect File actions
- self.debugAction.triggered.connect(self.displayDebug)
- self.cameraAction.triggered.connect(self.displayCameraSettings)
- self.quitAction.triggered.connect(self.close)
- self.saveAction.triggered.connect(self.saveUserParameters)
-
- self.graphAction.triggered.connect(lambda: self.analyzeResults(graph=True))
- self.exportAction.triggered.connect(lambda: self.analyzeResults(export=True))
-
- def displayCameraSettings(self):
- self.camera_dialog = CameraSettingsDialog(parent=self)
- self.camera_dialog.exec_()
-
- def displayDebug(self):
- dbg = DebugDialog(parent=self,message=self.debugString)
- if dbg.exec_():
- None
-
- def displayJogPanel(self):
- try:
- local_status = self.printer.getStatus()
- if local_status == 'idle':
- jogPanel = CPDialog(parent=self,summary='Control printer movement using this panel.',title='Jog Control')
- if jogPanel.exec_():
- None
- except Exception as e1: self.statusBar.showMessage('Printer is not available or is busy. ')
-
- def startVideo(self):
- # create the video capture thread
- self.video_thread = CalibrateNozzles(parent=self,numTools=0, cycles=1,minArea=600, align=False)
- # connect its signal to the update_image slot
- # connect its signal to the update_image slot
- self.video_thread.detection_error.connect(self.updateStatusbar)
- self.video_thread.status_update.connect(self.updateStatusbar)
- self.video_thread.message_update.connect(self.updateMessagebar)
- self.video_thread.change_pixmap_signal.connect(self.update_image)
- self.video_thread.calibration_complete.connect(self.applyCalibration)
- self.video_thread.result_update.connect(self.addCalibrationResult)
-
- # start the thread
- self.video_thread.start()
-
- def stopVideo(self):
- try:
- if self.video_thread.isRunning():
- self.video_thread.stop()
- except Exception as vs2:
- self.updateStatusbar('ERROR: cannot stop video')
- print('ERROR: cannot stop video')
- print(vs2)
-
- def closeEvent(self, event):
- try:
- if self.printer.isIdle():
- tempCoords = self.printer.getCoords()
- self.printer.gCode('T-1')
- self.printer.gCode('G1 X' + str(tempCoords['X']) + ' Y' + str(tempCoords['Y']))
- except Exception as ce1: None # no printer connected usually.
- print()
- print('Thank you for using TAMV!')
- print('Check out www.jubilee3d.com')
- event.accept()
-
- def connectToPrinter(self):
- # temporarily suspend GUI and display status message
- self.image_label.setText('Waiting to connect..')
- self.updateStatusbar('Please enter machine IP address or name prefixed with http(s)://')
- self.connection_button.setDisabled(True)
- self.disconnection_button.setDisabled(True)
- self.calibration_button.setDisabled(True)
- self.cp_button.setDisabled(True)
- self.jogpanel_button.setDisabled(True)
- self.offsets_box.setVisible(False)
- self.connection_status.setText('Connecting..')
- self.connection_status.setStyleSheet(style_orange)
- self.cp_label.setText('CP: undef')
- self.cp_label.setStyleSheet(style_orange)
- self.repeatSpinBox.setDisabled(True)
- self.xray_box.setDisabled(True)
- self.xray_box.setChecked(False)
- self.xray_box.setVisible(False)
- self.loose_box.setDisabled(True)
- self.loose_box.setChecked(False)
- self.loose_box.setVisible(False)
- self.repaint()
- try:
- # check if printerURL has already been defined (user reconnecting)
- if len(self.printerURL) > 0:
- None
- except Exception:
- # printerURL initalization to defaults
- self.printerURL = 'http://localhost'
- # Prompt user for machine connection address
- text, ok = QInputDialog.getText(self, 'Machine URL','Machine IP address or hostname: ', QLineEdit.Normal, self.printerURL)
- # Handle clicking OK/Connect
- if ok and text != '' and len(text) > 5:
- ( _errCode, _errMsg, tempURL ) = self.cleanPrinterURL(text)
- while _errCode != 0:
- # Invalid URL detected, pop-up window to correct this
- text, ok = QInputDialog.getText(self, 'Machine URL', _errMsg + '\nMachine IP address or hostname: ', QLineEdit.Normal, text)
- if ok:
- ( _errCode, _errMsg, tempURL ) = self.cleanPrinterURL(text)
- else:
- self.updateStatusbar('Connection request cancelled.')
- self.resetConnectInterface()
- return
- # input has been parsed and is clean, proceed
- self.printerURL = tempURL
- # Handle clicking cancel
- elif not ok:
- self.updateStatusbar('Connection request cancelled.')
- self.resetConnectInterface()
- return
- # Handle invalid input
- elif len(text) < 6 or text[:4] not in ['http']:
- self.updateStatusbar('Invalid IP address or hostname: \"' + text +'\". Add http(s):// to try again.')
- self.resetConnectInterface()
- return
- # Update user with new state
- self.statusBar.showMessage('Attempting to connect to: ' + self.printerURL )
- # Attempt connecting to the Duet controller
- try:
- self.printer = DWA.DuetWebAPI(self.printerURL)
- if not self.printer.printerType():
- # connection failed for some reason
- self.updateStatusbar('Device at '+self.printerURL+' either did not respond or is not a Duet V2 or V3 printer.')
- self.resetConnectInterface()
- return
- else:
- # connection succeeded, update objects accordingly
- self._connected_flag = True
- self.num_tools = self.printer.getNumTools()
- self.video_thread.numTools = self.num_tools
- # UPDATE OFFSET INFORMATION
- self.offsets_box.setVisible(True)
- self.offsets_table.setRowCount(self.num_tools)
- for i in range(self.num_tools):
- current_tool = self.printer.getG10ToolOffset(i)
- offset_x = "{:.3f}".format(current_tool['X'])
- offset_y = "{:.3f}".format(current_tool['Y'])
- x_tableitem = QTableWidgetItem(offset_x)
- y_tableitem = QTableWidgetItem(offset_y)
- x_tableitem.setBackground(QColor(255,255,255,255))
- y_tableitem.setBackground(QColor(255,255,255,255))
- self.offsets_table.setVerticalHeaderItem(i,QTableWidgetItem('T'+str(i)))
- self.offsets_table.setItem(i,0,x_tableitem)
- self.offsets_table.setItem(i,1,y_tableitem)
- # add tool buttons
- toolButton = QPushButton('T'+str(i))
- toolButton.setToolTip('Fetch T' + str(i) + ' to current machine position.')
- self.toolButtons.append(toolButton)
- except Exception as conn1:
- self.updateStatusbar('Cannot connect to: ' + self.printerURL )
- print('Duet Connection exception: ', conn1)
- self.resetConnectInterface()
- return
- # Get active tool
- _active = self.printer.getCurrentTool()
- # Display toolbox
- for i,button in enumerate(self.toolButtons):
- button.setCheckable(True)
- if int(_active) == i:
- button.setChecked(True)
- else:
- button.setChecked(False)
- button.clicked.connect(self.callTool)
- self.toolBoxLayout.addWidget(button)
- self.toolBox.setVisible(True)
- # Connection succeeded, update GUI first
- self.updateStatusbar('Connected to a Duet V'+str(self.printer.printerType()))
- self.connection_button.setText('Online: ' + self.printerURL[self.printerURL.rfind('/')+1:])
- self.statusBar.showMessage('Connected to printer at ' + self.printerURL, 5000)
- self.connection_status.setText('Connected.')
- self.image_label.setText('Set your Controlled Point to continue.')
- # enable/disable buttons
- self.connection_button.setDisabled(True)
- self.calibration_button.setDisabled(True)
- self.disconnection_button.setDisabled(False)
- self.cp_button.setDisabled(False)
- self.jogpanel_button.setDisabled(False)
- self.analysisMenu.setDisabled(True)
- # update connection status indicator to green
- self.connection_status.setStyleSheet(style_green)
- self.cp_label.setStyleSheet(style_red)
-
- def callTool(self):
- # handle scenario where machine is busy and user tries to select a tool.
- if not self.printer.isIdle():
- self.updateStatusbar('Machine is not idle, cannot select tool.')
- return
-
- # get current active tool
- _active = self.printer.getCurrentTool()
-
- # get requested tool number
- sender = self.sender()
-
- # update buttons to new status
- for button in self.toolButtons:
- button.setChecked(False)
- self.toolButtons[int(self.sender().text()[1:])].setChecked(True)
-
- # handle tool already active on printer
- if int(_active) == int(sender.text()[1:]):
- msg = QMessageBox()
- status = msg.question( self, 'Unload ' + sender.text(), 'Unload ' + sender.text() + ' and return carriage to the current position?',QMessageBox.Yes | QMessageBox.No )
- if status == QMessageBox.Yes:
- self.toolButtons[int(self.sender().text()[1:])].setChecked(False)
- if len(self.cp_coords) > 0:
- self.printer.gCode('T-1')
- self.printer.gCode('G1 X' + str(self.cp_coords['X']))
- self.printer.gCode('G1 Y' + str(self.cp_coords['Y']))
- self.printer.gCode('G1 Z' + str(self.cp_coords['Z']))
- else:
- tempCoords = self.printer.getCoords()
- self.printer.gCode('T-1')
- self.printer.gCode('G1 X' + str(tempCoords['X']))
- self.printer.gCode('G1 Y' + str(tempCoords['Y']))
- self.printer.gCode('G1 Z' + str(tempCoords['Z']))
- # End video threads and restart default thread
- self.video_thread.alignment = False
-
- # Update GUI for unloading carriage
- self.calibration_button.setDisabled(False)
- self.cp_button.setDisabled(False)
- self.updateMessagebar('Ready.')
- self.updateStatusbar('Ready.')
- else:
- # User cancelled, do nothing
- return
- else:
- # Requested tool is different from active tool
- msg = QMessageBox()
- status = msg.question( self, 'Confirm loading ' + sender.text(), 'Load ' + sender.text() + ' and move to current position?',QMessageBox.Yes | QMessageBox.No )
-
- if status == QMessageBox.Yes:
- # return carriage to controlled point position
- if len(self.cp_coords) > 0:
- self.printer.gCode('T-1')
- self.printer.gCode(sender.text())
- self.printer.gCode('G1 X' + str(self.cp_coords['X']))
- self.printer.gCode('G1 Y' + str(self.cp_coords['Y']))
- self.printer.gCode('G1 Z' + str(self.cp_coords['Z']))
- else:
- tempCoords = self.printer.getCoords()
- self.printer.gCode('T-1')
- self.printer.gCode(self.sender().text())
- self.printer.gCode('G1 X' + str(tempCoords['X']))
- self.printer.gCode('G1 Y' + str(tempCoords['Y']))
- self.printer.gCode('G1 Z' + str(tempCoords['Z']))
- # START DETECTION THREAD HANDLING
- # close camera settings dialog so it doesn't crash
- try:
- if self.camera_dialog.isVisible():
- self.camera_dialog.reject()
- except: None
- # update GUI
- self.cp_button.setDisabled(True)
- self.jogpanel_button.setDisabled(False)
- self.calibration_button.setDisabled(True)
- self.repeatSpinBox.setDisabled(True)
-
- else:
- self.toolButtons[int(self.sender().text()[1:])].setChecked(False)
-
- def resetConnectInterface(self):
- self.connection_button.setDisabled(False)
- self.disconnection_button.setDisabled(True)
- self.calibration_button.setDisabled(True)
- self.cp_button.setDisabled(True)
- self.jogpanel_button.setDisabled(True)
- self.offsets_box.setVisible(False)
- self.connection_status.setText('Disconnected')
- self.connection_status.setStyleSheet(style_red)
- self.cp_label.setText('CP: undef')
- self.cp_label.setStyleSheet(style_red)
- self.repeatSpinBox.setDisabled(True)
- self.analysisMenu.setDisabled(True)
- self.detect_box.setChecked(False)
- self.detect_box.setDisabled(False)
- self.xray_box.setDisabled(True)
- self.xray_box.setChecked(False)
- self.xray_box.setVisible(False)
- self.loose_box.setDisabled(True)
- self.loose_box.setChecked(False)
- self.loose_box.setVisible(False)
- self.video_thread.detection_on = False
- self.video_thread.loose = False
- self.video_thread.xray = False
- self.video_thread.alignment = False
-
- index = self.toolBoxLayout.count()-1
- while index >= 0:
- curWidget = self.toolBoxLayout.itemAt(index).widget()
- curWidget.setParent(None)
- index -= 1
- self.toolBox.setVisible(False)
- self.toolButtons = []
- self.repaint()
-
- def controlledPoint(self):
- # handle scenario where machine is busy and user tries to select a tool.
- if not self.printer.isIdle():
- self.updateStatusbar('Machine is not idle, cannot select tool.')
- return
- # display crosshair on video feed at center of image
- self.crosshair = True
- self.calibration_button.setDisabled(True)
-
- if len(self.cp_coords) > 0:
- self.printer.gCode('T-1')
- self.printer.gCode('G90 G1 X'+ str(self.cp_coords['X']) + ' Y' + str(self.cp_coords['Y']) + ' Z' + str(self.cp_coords['Z']) )
- dlg = CPDialog(parent=self)
- if dlg.exec_():
- self.cp_coords = self.printer.getCoords()
- self.cp_string = '(' + str(self.cp_coords['X']) + ', ' + str(self.cp_coords['Y']) + ')'
- self.readyToCalibrate()
- else:
- self.statusBar.showMessage('CP Setup cancelled.')
- self.crosshair = False
-
- def readyToCalibrate(self):
- self.statusBar.showMessage('Controlled Point coordinates saved.',3000)
- self.image_label.setText('Controlled Point set. Click \"Start Tool Alignment\" to calibrate..')
- self.cp_button.setText('Reset CP ')
- self.cp_label.setText('CP: ' + self.cp_string)
- self.cp_label.setStyleSheet(style_green)
- self.detect_box.setChecked(False)
- self.detect_box.setDisabled(False)
- self.detect_box.setVisible(True)
- self.xray_box.setDisabled(True)
- self.xray_box.setChecked(False)
- self.xray_box.setVisible(False)
- self.loose_box.setDisabled(True)
- self.loose_box.setChecked(False)
- self.loose_box.setVisible(False)
- self.video_thread.detection_on = False
- self.video_thread.loose = False
- self.video_thread.xray = False
- self.video_thread.alignment = False
- self.calibration_button.setDisabled(False)
- self.cp_button.setDisabled(False)
-
- self.toolBox.setVisible(True)
- self.repeatSpinBox.setDisabled(False)
-
- if len(self.calibrationResults) > 1:
- self.analysisMenu.setDisabled(False)
- else:
- self.analysisMenu.setDisabled(True)
-
- def applyCalibration(self):
- # update GUI
- self.readyToCalibrate()
- # close camera settings dialog so it doesn't crash
- try:
- if self.camera_dialog.isVisible():
- self.camera_dialog.reject()
- except: None
- # prompt for user to apply results
- msgBox = QMessageBox(parent=self)
- msgBox.setIcon(QMessageBox.Information)
- msgBox.setText('Do you want to save the new offsets to your machine?')
- msgBox.setWindowTitle('Calibration Results')
- yes_button = msgBox.addButton('Apply offsets and save (M500)',QMessageBox.ApplyRole)
- yes_button.setStyleSheet(style_green)
- cancel_button = msgBox.addButton('Apply offsets',QMessageBox.NoRole)
-
- # Update debug string
- self.debugString += '\nCalibration results:\n'
- for result in self.calibrationResults:
- calibrationCode = 'G10 P' + str(result['tool']) + ' X' + str(result['X']) + ' Y' + str(result['Y'])
- self.debugString += calibrationCode + '\n'
-
- # Prompt user
- returnValue = msgBox.exec()
- if msgBox.clickedButton() == yes_button:
- for result in self.calibrationResults:
- calibrationCode = 'G10 P' + str(result['tool']) + ' X' + str(result['X']) + ' Y' + str(result['Y'])
- self.printer.gCode(calibrationCode)
- self.printer.gCode('M500 P10') # because of Rene.
- self.statusBar.showMessage('Offsets applied and stored using M500.')
- print('Offsets applied and stored using M500.')
- else:
- self.statusBar.showMessage('Temporary offsets applied. You must manually save these offsets.')
- # Clean up threads and detection
- self.video_thread.alignment = False
- self.video_thread.detect_on = False
- self.video_thread.display_crosshair = False
- # run stats
- self.analyzeResults()
-
- def analyzeResults(self, graph=False, export=False):
- if len(self.calibrationResults) < 1:
- self.updateStatusbar('No calibration data found.')
- return
- if graph or export:
- # get data as 3 dimensional array [tool][axis][datapoints] normalized around mean of each axis
- (numTools, totalRuns, toolData) = self.parseData(self.calibrationResults)
- else:
- # display stats to terminal
- self.stats()
- if graph:
- matplotlib.use('Qt5Agg',force=True)
- # set up color and colormap arrays
- colorMap = ["Greens","Oranges","Blues", "Reds"] #["Blues", "Reds","Greens","Oranges"]
- colors = ['blue','red','green','orange']
- # initiate graph data - 1 tool per column
- # Row 0: scatter plot with standard deviation box
- # Row 1: histogram of X axis data
- # Row 2: histogram of Y axis data
-
- # Set backend (if needed)
- #plt.switch_backend('Qt4Agg')
-
- fig, axes = plt.subplots(ncols=3,nrows=numTools,constrained_layout=False)
-
- for i, data in enumerate(toolData):
- # create a color array the length of the number of tools in the data
- color = np.arange(len(data[0]))
-
- # Axis formatting
- # Major ticks
- axes[i][0].xaxis.set_major_formatter(FormatStrFormatter('%.3f'))
- axes[i][0].yaxis.set_major_formatter(FormatStrFormatter('%.3f'))
- # Minor ticks
- axes[i][0].xaxis.set_minor_formatter(FormatStrFormatter('%.3f'))
- axes[i][0].yaxis.set_minor_formatter(FormatStrFormatter('%.3f'))
- # Draw 0,0 lines
- axes[i][0].axhline()
- axes[i][0].axvline()
- # x&y std deviation box
- x_sigma = np.around(np.std(data[0]),3)
- y_sigma = np.around(np.std(data[1]),3)
- axes[i][0].add_patch(patches.Rectangle((-1*x_sigma,-1*y_sigma), 2*x_sigma, 2*y_sigma, color="green",fill=False, linestyle='dotted'))
- axes[i][0].add_patch(patches.Rectangle((-2*x_sigma,-2*y_sigma), 4*x_sigma, 4*y_sigma, color="red",fill=False, linestyle='-.'))
-
- # scatter plot for tool data
- axes[i][0].scatter(data[0], data[1], c=color, cmap=colorMap[i])
- axes[i][0].autoscale = True
-
- # Histogram data setup
- # Calculate number of bins per axis
- x_intervals = int(np.around(math.sqrt(len(data[0])),0)+1)
- y_intervals = int(np.around(math.sqrt(len(data[1])),0)+1)
-
- # plot histograms
- x_kwargs = dict(alpha=0.5, bins=x_intervals,rwidth=.92, density=True)
- n, bins, hist_patches = axes[i][1].hist([data[0],data[1]],**x_kwargs, color=[colors[0],colors[1]], label=['X','Y'])
- axes[i][2].hist2d(data[0], data[1], bins=x_intervals, cmap='Blues')
- axes[i][1].legend()
-
-
- # add a 'best fit' line
- # calculate mean and std deviation per axis
- x_mean = np.mean(data[0])
- y_mean = np.mean(data[1])
- x_sigma = np.around(np.std(data[0]),3)
- y_sigma = np.around(np.std(data[1]),3)
- # calculate function lines for best fit
- x_best = ((1 / (np.sqrt(2 * np.pi) * x_sigma)) *
- np.exp(-0.5 * (1 / x_sigma * (bins - x_mean))**2))
- y_best = ((1 / (np.sqrt(2 * np.pi) * y_sigma)) *
- np.exp(-0.5 * (1 / y_sigma * (bins - y_mean))**2))
- # add best fit line to plots
- axes[i][1].plot(bins, x_best, '-.',color=colors[0])
- axes[i][1].plot(bins, y_best, '--',color=colors[1])
-
- x_count = int(sum( p == True for p in ((data[0] >= (x_mean - x_sigma)) & (data[0] <= (x_mean + x_sigma))) )/len(data[0])*100)
- y_count = int(sum( p == True for p in ((data[1] >= (y_mean - y_sigma)) & (data[1] <= (y_mean + y_sigma))) )/len(data[1])*100)
- # annotate std dev values
- annotation_text = "Xσ: " + str(x_sigma) + " ("+str(x_count) + "%)"
- if x_count < 68:
- x_count = int(sum( p == True for p in ((data[0] >= (x_mean - 2*x_sigma)) & (data[0] <= (x_mean + 2*x_sigma))) )/len(data[0])*100)
- annotation_text += " --> 2σ: " + str(x_count) + "%"
- if x_count < 95 and x_sigma*2 > 0.1:
- annotation_text += " -- check axis!"
- else: annotation_text += " -- OK"
- annotation_text += "\nYσ: " + str(y_sigma) + " ("+str(y_count) + "%)"
- if y_count < 68:
- y_count = int(sum( p == True for p in ((data[1] >= (y_mean - 2*y_sigma)) & (data[1] <= (y_mean + 2*y_sigma))) )/len(data[1])*100)
- annotation_text += " --> 2σ: " + str(y_count) + "%"
- if y_count < 95 and y_sigma*2 > 0.1:
- annotation_text += " -- check axis!"
- else: annotation_text += " -- OK"
- axes[i][0].annotate(annotation_text, (10,10),xycoords='axes pixels')
- axes[i][0].annotate('σ',(1.1*x_sigma,-1.1*y_sigma),xycoords='data',color='green')
- axes[i][0].annotate('2σ',(1.1*2*x_sigma,-1.1*2*y_sigma),xycoords='data',color='red')
- # # place title for graph
- axes[i][0].set_ylabel("Tool " + str(i) + "\nY")
- axes[i][0].set_xlabel("X")
- axes[i][2].set_ylabel("Y")
- axes[i][2].set_xlabel("X")
-
- if i == 0:
- axes[i][0].set_title('Scatter Plot')
- axes[i][1].set_title('Histogram')
- axes[i][2].set_title('2D Histogram')
- plt.tight_layout()
- figManager = plt.get_current_fig_manager()
- figManager.window.showMaximized()
- plt.ion()
- plt.show()
-
- if export:
- # export JSON data to file
- try:
- with open('output.json','w') as outputfile:
- json.dump(self.calibrationResults, outputfile)
- except Exception as e1:
- print('Error exporting data:')
- print(e1)
- self.updateStatusbar('Error exporting data, please check terminal for details.')
-
- def stats(self):
- ###################################################################################
- # Report on repeated executions
- ###################################################################################
- print('')
- print('Repeatability statistics for '+str(self.cycles)+' repeats:')
- print('+-------------------------------------------------------------------------------------------------------+')
- print('| | X | Y |')
- print('| T | Avg | Max | Min | StdDev | Range | Avg | Max | Min | StdDev | Range |')
- for index in range(self.num_tools):
- # create array of results for current tool
- _rawCalibrationData = [line for line in self.calibrationResults if line['tool'] == str(index)]
- x_array = [float(line['X']) for line in _rawCalibrationData]
- y_array = [float(line['Y']) for line in _rawCalibrationData]
- mpp_value = np.average([float(line['mpp']) for line in _rawCalibrationData])
- cycles = np.max(
- [float(line['cycle']) for line in _rawCalibrationData]
- )
- x_avg = np.average(x_array)
- y_avg = np.average(y_array)
- x_min = np.min(x_array)
- y_min = np.min(y_array)
- x_max = np.max(x_array)
- y_max = np.max(y_array)
- x_std = np.std(x_array)
- y_std = np.std(y_array)
- x_ran = x_max - x_min
- y_ran = y_max - y_min
- print('| {0:1.0f} '.format(index)
- + '| {0:7.3f} '.format(x_avg)
- + '| {0:7.3f} '.format(x_max)
- + '| {0:7.3f} '.format(x_min)
- + '| {0:7.3f} '.format(x_std)
- + '| {0:7.3f} '.format(x_ran)
- + '| {0:7.3f} '.format(y_avg)
- + '| {0:7.3f} '.format(y_max)
- + '| {0:7.3f} '.format(y_min)
- + '| {0:7.3f} '.format(y_std)
- + '| {0:7.3f} '.format(y_ran)
- + '|'
- )
- print('+-------------------------------------------------------------------------------------------------------+')
- print('Note: Repeatability cannot be better than one pixel (MPP=' + str(mpp_value) + ').')
-
- def parseData( self, rawData ):
- # create empty output array
- toolDataResult = []
- # get number of tools
- _numTools = np.max([ int(line['tool']) for line in rawData ]) + 1
- _cycles = np.max([ int(line['cycle']) for line in rawData ])
-
- for i in range(_numTools):
- x = [float(line['X']) for line in rawData if int(line['tool']) == i]
- y = [float(line['Y']) for line in rawData if int(line['tool']) == i]
- # variable to hold return data coordinates per tool formatted as a 2D array [x_value, y_value]
- tempPairs = []
-
- # calculate stats
- # mean values
- x_mean = np.around(np.mean(x),3)
- y_mean = np.around(np.mean(y),3)
- # median values
- x_median = np.around(np.median(x),3)
- y_median = np.around(np.median(y),3)
- # ranges (max - min per axis)
- x_range = np.around(np.max(x) - np.min(x),3)
- y_range = np.around(np.max(y) - np.min(y),3)
- # standard deviations
- x_sig = np.around(np.std(x),3)
- y_sig = np.around(np.std(y),3)
-
- # normalize data around mean
- x -= x_mean
- y -= y_mean
-
- # temporary object to append coordinate pairs into return value
- tempPairs.append(x)
- tempPairs.append(y)
-
- # add data to return object
- toolDataResult.append(tempPairs)
- # return dataset
- return ( _numTools, _cycles, toolDataResult )
-
- def disconnectFromPrinter(self):
- # temporarily suspend GUI and display status message
- self.image_label.setText('Restoring machine to initial state..')
- self.updateStatusbar('Restoring machine and disconnecting...')
- self.connection_button.setText('Pending..')
- self.connection_button.setDisabled(True)
- self.disconnection_button.setDisabled(True)
- self.calibration_button.setDisabled(True)
- self.cp_button.setDisabled(True)
- self.cp_button.setText('Pending..')
- self.jogpanel_button.setDisabled(True)
- self.offsets_box.setVisible(False)
- self.connection_status.setText('Disconnecting..')
- self.connection_status.setStyleSheet(style_orange)
- self.cp_label.setText('CP: undef')
- self.cp_label.setStyleSheet(style_orange)
- self.repeatSpinBox.setDisabled(True)
- self.xray_box.setDisabled(True)
- self.xray_box.setChecked(False)
- self.loose_box.setDisabled(True)
- self.toolBox.setVisible(False)
- self.repaint()
- # End video threads and restart default thread
- # Clean up threads and detection
- self.video_thread.alignment = False
- self.video_thread.detect_on = False
- self.video_thread.display_crosshair = False
- self.detect_box.setChecked(False)
- self.detect_box.setVisible(True)
-
- # update status
- self.updateStatusbar('Unloading tools and disconnecting from machine..')
- # Wait for printer to stop moving and unload tools
- _ret_error = self.printer.gCode('M400')
- if self.printer.isIdle():
- tempCoords = self.printer.getCoords()
- _ret_error += self.printer.gCode('T-1')
- # return carriage to controlled point position
- if len(self.cp_coords) > 0:
- _ret_error += self.printer.gCode('G1 X' + str(self.cp_coords['X']))
- _ret_error += self.printer.gCode('G1 Y' + str(self.cp_coords['Y']))
- _ret_error += self.printer.gCode('G1 Z' + str(self.cp_coords['Z']))
- else:
- _ret_error += self.printer.gCode('G1 X' + str(tempCoords['X']))
- _ret_error += self.printer.gCode('G1 Y' + str(tempCoords['Y']))
- _ret_error += self.printer.gCode('G1 Z' + str(tempCoords['Z']))
- # update status with disconnection state
- if _ret_error == 0:
- self.updateStatusbar('Disconnected.')
- self.image_label.setText('Disconnected.')
- else:
- # handle unforeseen disconnection error (power loss?)
- self.statusBar.showMessage('Disconnect: error communicating with machine.')
- self.statusBar.setStyleSheet(style_red)
- # Reinitialize printer object
- self.printer = None
-
- # Tools unloaded, reset GUI
- self.image_label.setText('Welcome to TAMV. Enter your printer address and click \"Connect..\" to start.')
- self.connection_button.setText('Connect..')
- self.connection_button.setDisabled(False)
- self.disconnection_button.setDisabled(True)
- self.calibration_button.setDisabled(True)
- self.cp_button.setDisabled(True)
- self.cp_button.setText('Set Controlled Point..')
- self.jogpanel_button.setDisabled(True)
- self.offsets_box.setVisible(False)
- self.connection_status.setText('Disconnected.')
- self.connection_status.setStyleSheet(style_red)
- self.cp_label.setText('CP: undef')
- self.cp_label.setStyleSheet(style_red)
- self.repeatSpinBox.setDisabled(True)
- self.xray_box.setDisabled(True)
- self.loose_box.setDisabled(True)
- self.resetConnectInterface()
-
- def runCalibration(self):
- # reset debugString
- self.debugString = ''
- # prompt for user to apply results
- msgBox = QMessageBox(parent=self)
- msgBox.setIcon(QMessageBox.Information)
- msgBox.setText('Do you want to start automated tool alignment?')
- msgBox.setWindowTitle('Start Calibration')
- yes_button = msgBox.addButton('Start calibration..',QMessageBox.YesRole)
- yes_button.setObjectName('active')
- yes_button.setStyleSheet(style_green)
- no_button = msgBox.addButton('Cancel',QMessageBox.NoRole)
-
- returnValue = msgBox.exec_()
- if msgBox.clickedButton() == no_button:
- return
- # close camera settings dialog so it doesn't crash
- try:
- if self.camera_dialog.isVisible():
- self.camera_dialog.reject()
- except: None
- # update GUI
- self.cp_button.setDisabled(True)
- self.jogpanel_button.setDisabled(False)
- self.calibration_button.setDisabled(True)
- self.xray_box.setDisabled(False)
- self.xray_box.setChecked(False)
- self.xray_box.setVisible(True)
- self.loose_box.setDisabled(False)
- self.loose_box.setChecked(False)
- self.loose_box.setVisible(True)
- self.toolBox.setVisible(False)
- self.detect_box.setVisible(False)
- for i in range(self.num_tools):
- current_tool = self.printer.getG10ToolOffset(i)
- x_tableitem = QTableWidgetItem("{:.3f}".format(current_tool['X']))
- y_tableitem = QTableWidgetItem("{:.3f}".format(current_tool['Y']))
- x_tableitem.setBackground(QColor(255,255,255,255))
- y_tableitem.setBackground(QColor(255,255,255,255))
- self.offsets_table.setVerticalHeaderItem(i,QTableWidgetItem('T'+str(i)))
- self.offsets_table.setItem(i,0,x_tableitem)
- self.offsets_table.setItem(i,1,y_tableitem)
- # get number of repeat cycles
- self.repeatSpinBox.setDisabled(True)
- self.cycles = self.repeatSpinBox.value()
-
- # create the Nozzle detection capture thread
- self.video_thread.display_crosshair = True
- self.video_thread.detection_on = True
- self.video_thread.xray = False
- self.video_thread.loose = False
- self.video_thread.alignment = True
-
- def toggle_xray(self):
- try:
- self.video_thread.toggleXray()
- except Exception as e1:
- self.updateStatusbar('Detection thread not running.')
- print( 'Detection thread error in XRAY: ')
- print(e1)
-
- def toggle_loose(self):
- try:
- self.video_thread.toggleLoose()
- except Exception as e1:
- self.updateStatusbar('Detection thread not running.')
- print( 'Detection thread error in LOOSE: ')
- print(e1)
-
- @pyqtSlot(str)
- def updateStatusbar(self, statusCode ):
- self.statusBar.showMessage(statusCode)
-
- @pyqtSlot(str)
- def updateMessagebar(self, statusCode ):
- self.image_label.setText(statusCode)
-
- @pyqtSlot(np.ndarray)
- def update_image(self, cv_img):
- #self.mutex.lock()
- self.current_frame = cv_img
- if self.crosshair:
- # Draw alignment circle on image
- alpha = 0.5
- beta = 1-alpha
- center = ( int(camera_width/2), int(camera_height/2) )
- overlay = cv2.circle(
- cv_img.copy(),
- center,
- 6,
- (0,255,0),
- int( camera_width/1.75 )
- )
- overlay = cv2.circle(
- overlay.copy(),
- center,
- 5,
- (0,0,255),
- 2
- )
- for i in range(0,8):
- overlay = cv2.circle(
- overlay.copy(),
- center,
- 25*i,
- (0,0,0),
- 1
- )
- overlay = cv2.line(overlay, (center[0],center[1]-int( camera_width/3 )), (center[0],center[1]+int( camera_width/3 )), (128, 128, 128), 1)
- overlay = cv2.line(overlay, (center[0]-int( camera_width/3 ),center[1]), (center[0]+int( camera_width/3 ),center[1]), (128, 128, 128), 1)
- cv_img = cv2.addWeighted(overlay, beta, cv_img, alpha, 0)
- # Updates the image_label with a new opencv image
- qt_img = self.convert_cv_qt(cv_img)
- self.image_label.setPixmap(qt_img)
- #self.mutex.unlock()
-
- def convert_cv_qt(self, cv_img):
- # Convert from an opencv image to QPixmap
- rgb_image = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)
- h, w, ch = rgb_image.shape
- bytes_per_line = ch * w
- convert_to_Qt_format = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888)
- p = convert_to_Qt_format.scaled(display_width, display_height, Qt.KeepAspectRatio)
- return QPixmap.fromImage(p)
-
- def addCalibrationResult(self, result={}):
- self.calibrationResults.append(result)
-
-if __name__=='__main__':
- os.putenv("QT_LOGGING_RULES","qt5ct.debug=false")
- app = QApplication(sys.argv)
- a = App()
- a.show()
- sys.exit(app.exec_())
diff --git a/ZTATP.py b/ZTATP.py
deleted file mode 100755
index bd78b10..0000000
--- a/ZTATP.py
+++ /dev/null
@@ -1,257 +0,0 @@
-#!/usr/bin/env python3
-# ZTATP = Z (only) Tool Align Touch Plate
-#
-# Python Script to align Z for multiple tools on Jubilee printer with Duet3d Controller
-# Using a touchplate, and and endstop endput wire to every nozzle and that touchplate.
-# May also work on other Duet/RepRap based mutli-tool printers.
-#
-# Copyright (C) 2020 Danal Estes all rights reserved.
-# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT
-#
-# Requires network connection to Duet based printer running Duet/RepRap V2 or V3
-#
-# Note: You MUST define Z with a PROBE in config.g. Even if it is really an endstop switch, it is OK to define that as a probe.
-# WARNING: RRF defaults to a probe offset of 0.7mm hardcoded in the source files that must be fixed using a G31 Z0 command in your config.g
-# Failure to apply this offset to Z=0 will result in tool offsets being incorrectly calculated as 0.7mm too close to the print surface.
-# THIS WILL DEFINITELY DAMAGE YOUR BUILD PLATE WHEN YOU ATTEMPT TO PRINT!
-
-import datetime
-import time
-
-try:
- import DuetWebAPI as DWA
-except ImportError:
- print("Python Library Module 'DuetWebAPI.py' is required. ")
- print("Obtain from https://github.com/DanalEstes/DuetWebAPI ")
- print("Place in same directory as script, or in Python libpath.")
- exit(2)
-import numpy as np
-import argparse
-import time
-
-def init():
- # parse command line arguments
- parser = argparse.ArgumentParser(description='Program to allign multiple tools in Z on Duet based printers, using a touch plate.', allow_abbrev=False)
- parser.add_argument('-duet',type=str,nargs=1,default=['localhost'],help='Name or IP address of Duet printer. You can use -duet=localhost if you are on the embedded Pi on a Duet3.')
- #parser.add_argument('-camera',type=str,nargs=1,choices=['usb','pi'],default=['usb'])
- parser.add_argument('-touchplate',type=float,nargs=2,default=[0.0,0.0],help="x y of center of a 15x15mm touch plate.",required=True)
- parser.add_argument('-pin',type=str,nargs=2,default='!io5.in',help='input pin to which wires from nozzles are attached (only in RRF3).')
- parser.add_argument('-tool',type=int,nargs=1,default=-1,help='(optional) set a run for an individual tool number referenced by index')
- args=vars(parser.parse_args())
-
- global duet, camera, tp, pin, tool
- duet = args['duet'][0]
- tp = args['touchplate']
- pin = args['pin']
- tool = args['tool']
-
-
- # Get connected to the printer.
- print('Attempting to connect to printer at '+duet)
- global prt
- prt = DWA.DuetWebAPI('http://'+duet)
- if (not prt.printerType()):
- print('Device at '+duet+' either did not respond or is not a Duet V2 or V3 printer.')
- exit(2)
- print("Connected to a Duet V"+str(prt.printerType())+" printer at "+prt.baseURL())
-
- if( tool is not - 1 ):
- tool = tool[0]
- if( tool > prt.getNumTools()-1 ):
- print()
- print('### ERROR IN PARAMETERS ###')
- print('You have specified tool ' + str(tool) + ' for probing, but it does not exist.')
- print('Your printer has '+str(prt.getNumTools())+' tools defined, so you must enter an index for tools between [0..' + str(prt.getNumTools()-1)+']')
- print('Please check your input parameters and try again.')
- exit(3)
-
- print('#########################################################################')
- print('# Important: #')
- print('# Your printer MUST be capable of mounting and parking every tool with #')
- print('# no collisions between tools. #')
- print('# #')
- print('# Your Z probe must be a probe, not an endstop. It is OK to define a #')
- print('# simple switch as a probe. #')
- print('# #')
- print('# WARNING!! WARNING!! #')
- print('# The probing sequence will assume a Z offset of 45mm for every tool. #')
- print('# During probing, any existing offsets will be temporarily cleared. #')
- print('# Before running this script, make sure all tools will not collide #')
- print('# with the build plate/probe plate with this 45 offset applied. #')
- print('# #')
- print('# Each nozzle MUST be wired to the specified input. Test the wiring by #')
- print('# hand before running ZTATP. If the wiring is wrong, probing will not #')
- print('# stop, and machine damage could occur. #')
- print('#########################################################################')
- print('Press enter to proceed')
- input()
- print()
- print("##################################")
- print("# Options in force for this run: #")
- print("# printer = {0:18s}#".format(duet))
- print("# touchplate = {0:6.2f} {1:6.2f} #".format(tp[0],tp[1]))
- if (prt.printerType() == 3):
- print("# firmware = V3.x.x #")
- print("# pin = {0:18s}#".format(str(pin)))
- if (prt.printerType() == 2):
- print("# firmware = V2.x.x #")
- print("# pin = Z_PROBE_IN #")
- print("##################################")
- print()
-
-def probePlate():
- prt.resetEndstops()
- # HBHBHB: TODO - insert code to handle 0.7mm offset in RRF here!
- commandBuffer = []
- commandBuffer.append('T-1') # Unmount any/all tools
- commandBuffer.append('G90 G1 X'+str(tp[0])+' Y'+str(tp[1])+' Z50 ') # The real purpose of this is to move the probe into position with its correct offsets.
- commandBuffer.append('M400') # wait for buffer to clear
- prt.gCodeBatch(commandBuffer)
- # wait for probing setup moves to complete before prompting for probe plate
- while prt.getStatus() not in 'idle':
- time.sleep(1)
- print( 'The toolhead is parked in your designated XY position for probing.' )
- input( 'Please place the probe plate in the correct position on the bed and press ENTER to start.' )
- print( 'Probing touch plate...' )
- commandBuffer = []
- commandBuffer.append('M558 F300') # set probing speed fast
- commandBuffer.append('G30') # perform first probe
- commandBuffer.append('M558 F50') # set probing speed slow
- commandBuffer.append('G30') # perform second probe
- commandBuffer.append('G30 S-1') # Now we can probe in such a way that Z is readable.
- commandBuffer.append('M400') # wait for buffer to clear
- prt.gCodeBatch(commandBuffer)
- poffs = 0
-
- # wait for probing to complete before fetching offsets
- while prt.getStatus() not in 'idle':
- time.sleep(1)
-
- poffs = prt.getCoords()['Z'] # Capture the Z position at initial point of contact
- print("Touch plate offset = "+str(poffs)) # Display captured offset to terminal
- prt.gCode('G91 G0 Z10 F1000 G90') # Lower bed to avoid collision
- return(poffs)
-
-def probeTool(tn):
- commandBuffer=[]
- print()
- print( 'Probing tool ' + str(tn) + '..')
- print( 'ZTATP will prompt you to connect your lead once the tool is positioned over the touch plate.' )
- prt.resetEndstops() # return all endstops to natural state from config.g definitions
- prt.gCode('M400') # Wait for planner to empty
- while prt.getStatus() not in 'idle':
- #print("sleeping.")
- time.sleep(1)
- commandBuffer.append('G10 P'+str(tn)+' Z0') # Remove z offsets from Tool
- commandBuffer.append('G91 G0 Z45 F1000 G90') # Lower bed to avoid collision (move to +45 relative in Z)
- commandBuffer.append('T'+str(tn)) # Pick up Tool number 'tn'
- prt.gCodeBatch(commandBuffer)
- duet2rrf3board = prt.checkDuet2RRF3()
- # Z Axis Probe setup
- # add code to switch pin based on RRF2 vs RRF3 definitions - RRF3 defaults to the original TAMV code
- if (prt.printerType() == 3):
- # START -- Code for RRF3
- print('*** RRF3 printer connected.')
- prt.gCode('M558 K0 P9 C"nil"') # Undef existing probe
- prt.gCode('M558 K0 P5 C"'+pin+'" F200 H50') # Define ( nozzle ) <--> ( probe plate ) as probe
- # END -- Code for RRF3
- if (prt.printerType() == 2):
- # START -- Code for RRF2
- # check if using a Duet 2 board with 3.2+ firmware
- if duet2rrf3board:
- print('** Duet 2 RRF 3.2+ board found, changing input pin to Z_PROBE_IN.')
- prt.gCode('M558 K0 P9 C"nil"') # Undef existing probe
- prt.gCode('M558 K0 P5 C"!^zprobe.in" F200 H50')
- else:
- # Define a normally-open (closes to ground when ACTIVE/TRIGGERED) probe connected between Z-Probe In and Ground (inverted logic to enable this behavior of NO switch)
- prt.gCode('M558 P5 I1 F200 H50') # Define ( nozzle ) <--> ( probe plate ) as probe (Z-Probe In pin connected to nozzle, probe plate is connected to ground)
- # END -- Code for RRF2
-
- prt.gCode('G0 X'+str(tp[0])+' Y'+str(tp[1])+' F10000') # Move nozzle to spot above flat part of plate
- input('Connect probe lead to tool ' + str(tn) + ' and press ENTER to continue.')
- prt.gCode('M558 F300') # set probing speed fast
- prt.gCode('G30 S-1') # Initiate a probing sequence
- # wait for probing to complete before fetching offsets
- while prt.getStatus() not in 'idle':
- time.sleep(1)
- print('First pass offset for tool ' + str(tn) + ': ' + str(prt.getCoords()['Z']) )
- prt.gCode('G91 G1 Z5 G90') # move bed away from probe for second pass
- prt.gCode('M558 F50') # set probing speed fast
- prt.gCode('G30 S-1')
-
- # wait for probing to complete before fetching offsets
- while prt.getStatus() not in 'idle':
- time.sleep(1)
-
- toffs = prt.getCoords()['Z'] # Fetch current Z coordinate from Duet controller
- print("Final offset for tool "+str(tn)+": "+str(toffs)) # Output offset to terminal for user to read
- prt.gCode('G91 G0 Z45 F1000 G90') # Lower bed to avoid collision
- if (prt.printerType() == 3):
- # START -- Code for RRF3
- prt.gCode('M574 Z1 S1 P"nil"') # Undef endstop for Z axis
- # END -- Code for RRF3
- if duet2rrf3board:
- # START -- Code for RRF3.2 on Duet 2 board
- prt.gCode('M574 Z1 S1 P"nil"')
- # END -- Code for RRF3
- prt.resetEndstops() # return all endstops to natural state from config.g definitions
- input('Please disconnect probe lead to tool ' + str(tn) + ' and press ENTER to continue. Active tool is going to be docked.')
- prt.gCode('T-1') # unload tool
- prt.gCode('M400') # wait until all movement has finished
- return(toffs) # return offsets and end function
-# End of probeTool function
-
-#
-# Main
-#
-init()
-
-# Get probe plate Z offset by probing using default-defined probe for G30
-poffs = probePlate()
-
-# initialize output variables
-toolCoords = []
-if (tool == -1):
- print( 'Running a probing sequence for all tools.')
- # start probing sequence for each tool defined on the connected printer
- for t in range(prt.getNumTools()):
- toolCoords.append(probeTool(t))
-else:
- print('Running a probing sequence for only tool '+ str(tool) )
- toolCoords.append(probeTool(tool))
-# restore all endstop definitions to the config.g defaults
-prt.resetEndstops()
-
-# Get probe offsets from machine
-(_errCode, _errMsg, probeOffset) = prt.getTriggerHeight()
-if _errCode != 0:
- probeOffset = 0
- print('Error in retrieving probe offsets. Setting offset to 0 to avoid collisions.')
-
-# Display Results and actually set G10 offsets
-print()
-print('########### Probing results')
-print("Plate Offset = "+str(poffs))
-if (tool == -1):
- for tn in range(len(toolCoords)):
- print("Final offset for tool "+str(tn)+": "+str(toolCoords[tn]))
- print()
- for tn in range(len(toolCoords)):
- finalOffset = (poffs-toolCoords[tn])-0.1-probeOffset
- print('G10 P'+str(tn)+' Z{:0.3f}'.format(finalOffset))
- # wait for probing to complete before setting offsets
- #while prt.getStatus() is 'processing':
- # time.sleep(1)
- prt.gCode('G10 P'+str(tn)+' Z{:0.3f}'.format(finalOffset))
-else:
- print("Final offset for tool "+str(tool)+": "+str(toolCoords[0]))
- print()
- finalOffset = (poffs-toolCoords[0])-0.1-probeOffset
- print('G10 P'+str(tool)+' Z{:0.3f}'.format(finalOffset))
- # wait for probing to complete before setting offsets
- #while prt.getStatus() is 'processing':
- # time.sleep(1)
- prt.gCode('G10 P'+str(tool)+' Z{:0.3f}'.format(finalOffset))
-print()
-print("Tool offsets have been applied to the current printer.")
-print("Please modify your tool definitions in config.g to reflect these newly measured values for persistent storage.")
diff --git a/drivers.json b/drivers.json
new file mode 100644
index 0000000..edba82f
--- /dev/null
+++ b/drivers.json
@@ -0,0 +1 @@
+[{"firmware":"RRF","filename":"DuetWebAPI.py"},{"firmware":"klipper","filename":"klipperAPI.py"}]
\ No newline at end of file
diff --git a/drivers/API_template.py b/drivers/API_template.py
new file mode 100644
index 0000000..8ed85e6
--- /dev/null
+++ b/drivers/API_template.py
@@ -0,0 +1,771 @@
+# API template for TAMV interfaces
+# MAIN CLASS NAME MUST BE "printerAPI" - do not change
+# Search for "##############*** YOUR CUSTOM CODE #################" to find the sections that need to be modified.
+#
+# Not intended to be a gerneral purpose interface; instead, it contains methods
+# to issue commands or return specific information. Feel free to extend with new
+# methods for other information; please keep the abstraction for V2 V3
+#
+# Copyright (C) 2020 Danal Estes all rights reserved.
+# Copyright (C) 2022 Haytham Bennani
+# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT
+#
+# Requires Python3
+import logging
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+# shared import dependencies
+import json
+import time
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.DuetWebAPI')
+
+##############*** YOUR CUSTOM CODE #################
+# Feel free to add whatever imports you may need to get this running in this section
+##############*** YOUR CUSTOM CODE #################
+
+
+#################################################################################################################################
+#################################################################################################################################
+# Main class for interface
+# Rename this to whatever you want, and this needs to be the filename and the file called from TAMV
+# For your custom TAMV tests, modify the following 2 statements in the TAMV_GUI.py file:
+# - "import DuetWebAPI as DWA"
+# - "self.printer = DWA.DuetWebAPI(self.printerURL)"
+
+class printerAPI:
+ # Max time to wait for toolchange before raising a timeout exception, in seconds
+ _toolTimeout = 300
+ # Max time to wait for HTTP requests to complete
+ _requestTimeout = 2
+ _responseTimeout = 10
+
+ ##############*** YOUR CUSTOM CODE #################
+ # Here's the section to add your class attributes
+ ##############*** YOUR CUSTOM CODE #################
+
+ #################################################################################################################################
+ # Instantiate class and connect to controller
+ # This is your main setup call, which will be returned
+ # Parameters:
+ # - baseURL (string): full IP address (not FQDN or alias) in the format 'http://xxx.xxx.xxx.xxx' without trailing '/'
+ # - optional: nickname (string): short nickname for identifying machine (strictly for TAMV GUI)
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - UnknownController: if fails to connect
+ def __init__( self, baseURL, nickname='Default' ):
+ _logger.debug('Starting API..')
+
+ # Here are the required class attributes. These get saved to settings.json
+ self._base_url = "whatever you want - this gets called from the dialog box when connecting - required"
+ self._password = "password to connect to machine (if applicable) - can be empty string"
+ self._name = "Printer name according to firmware (if applicable) - can be empty string"
+ self._nickname = "Printer nickname - future use - can be empty string"
+ self._firmwareName = "Firmware name - can be empty string"
+ self._firmwareVersion = "Firmware version - can be empty string"
+ # tools is an array of the Tool class located at the end of this file - read that first.
+ self.tools = []
+
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ # add your code to connect to your controller in this section
+ ##############*** YOUR CUSTOM CODE #################
+ if( you_board_failed_to_connect ):
+ # The board has failed to connect, return an error state
+ raise UnknownController('Unknown controller detected.')
+ except UnknownController as uc:
+ _logger.critical( "Unknown controller at " + self._base_url )
+ raise SystemExit(uc)
+ except Exception as e:
+ # Catastrophic error. Bail.
+ _logger.critical( str(e) )
+ raise SystemExit(e)
+ _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..')
+ return
+
+ #################################################################################################################################
+ # Get firmware version
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - returns either 2 or 3 depending on which RRF version is running on the controller
+ #
+ # Raises: NONE
+ def getPrinterType( self ):
+ _logger.debug('Called getPrinterType')
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ return( some_integer )
+
+ #################################################################################################################################
+ # Get number of defined tools from machine
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - Positive integer for number of defined tools on machine
+ #
+ # Raises:
+ # - FailedToolDetection: when cannot determine number of tools on machine
+ def getNumTools( self ):
+ _logger.debug('Called getNumTools')
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ return( some_integer )
+
+ #################################################################################################################################
+ # Get index of currently loaded tool
+ # Tool numbering always starts as 0, 1, 2, ..
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - Positive integer for index of current loaded tool
+ # - '-1' if no tool is loaded on the machine
+ #
+ # Raises:
+ # - FailedToolDetection: when cannot determine number of tools on machine
+ def getCurrentTool( self ):
+ _logger.debug('Called getCurrentTool')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ # Unknown condition, raise error
+ raise FailedToolDetection('Something failed. Baililng.')
+ except ConnectionError as ce:
+ _logger.critical('Connection error while polling for current tool')
+ raise SystemExit(ce)
+ except FailedToolDetection as fd:
+ _logger.critical('Failed tool detection.')
+ raise SystemExit(e1)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getCurrentTool: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Get currently defined offsets for tool referenced by index
+ # Tool numbering always starts as 0, 1, 2, ..
+ # Parameters:
+ # - toolIndex (integer): index of tool to get offsets for
+ #
+ # Returns:
+ # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 }
+ #
+ # Raises:
+ # - FailedOffsetCapture: when cannot determine number of tools on machine
+ def getToolOffset( self, toolIndex=0 ):
+ _logger.debug('Called getToolOffset')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ raise FailedOffsetCapture('getG10ToolOffset entered unhandled exception state.')
+ except FailedOffsetCapture as fd:
+ _logger.critical(str(fd))
+ raise SystemExit(fd)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getToolOffset.')
+ raise SystemExit(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getToolOffset: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Get machine status, mapping any controller status output into 1 of 3 possible states
+ # Parameters:
+ # - NONE
+ #
+ # Returns: string of following values ONLY
+ # - idle
+ # - processing
+ # - paused
+ #
+ # Raises:
+ # - StatusException: when cannot determine machine status
+ # - StatusTimeoutException: when machine takes longer than _toolTimeout seconds to respond
+ def getStatus( self ):
+ _logger.debug('Called getStatus')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ # OUTPUT MAPPING LOGIC
+ # Handle return mapping of status variable "_status"
+ # Change the conditions as appropriate, but maintain the return values and logging please.
+ if ( _status == "idle" or _status == "I"):
+ _logger.debug("Machine is idle.")
+ return ("idle")
+ elif ( _status == "paused" or _status == "S" or _status == "pausing" or _status == "D"):
+ _logger.debug("Machine is paused.")
+ return ("paused")
+ else:
+ _logger.debug("Machine is busy processing something.")
+ return ("processing")
+
+ # unknown error raise exception
+ raise StatusException('Unknown error getting machine status')
+ except StatusException as se:
+ _logger.critical(str(se))
+ raise SystemExit(se)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getStatus')
+ raise SystemExit(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getStatus: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Get current tool coordinates from machine in XYZ space
+ # Parameters:
+ # - NONE
+ #
+ # Returns:
+ # - tuple of 3 decimal places precise floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 }
+ #
+ # Raises:
+ # - CoordinatesException: when cannot determine machine status
+ def getCoordinates( self ):
+ _logger.debug('Called getCoordinates')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ if failed:
+ raise CoordinatesException("Unknown duet controller.")
+ else:
+ # NOTE: round results to a maximum of 3 decimals places
+ return( {'X': 0.000, 'Y': 0.000, 'Z': 0.000 } )
+ except CoordinatesException as ce1:
+ _logger.critical(str(ce1))
+ raise SystemExit(ce1)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getCoordinates')
+ raise SystemExit(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getCoordinates: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Set tool offsets for indexed tool in X, Y, and Z
+ # Parameters:
+ # - toolIndex (integer):
+ # - offsetX (float):
+ # - offsetY (float):
+ # - offsetZ (float):
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - SetOffsetException: when failed to set offsets in controller
+ def setToolOffsets( self, tool=None, X=None, Y=None, Z=None ):
+ _logger.debug('Called setToolOffsets')
+ try:
+ # Check for invalid tool index, raise exception if needed.
+ if( tool is None ):
+ raise SetOffsetException( "No tool index provided.")
+ # Check that any valid offset has been passed as an argument
+ elif( X is None and Y is None and Z is None ):
+ raise SetOffsetException( "Invalid offsets provided." )
+ else:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ _logger.debug( "Tool offsets applied.")
+ except SetOffsetException as se:
+ _logger.error(se)
+ return
+ except Exception as e:
+ _logger.critical( "setToolOffsets unhandled exception: " + str(e) )
+ raise SystemExit( "setToolOffsets unhandled exception: " + str(e) )
+
+ #################################################################################################################################
+ # Helper function to check if machine is idle or not
+ # Parameters: NONE
+ #
+ # Returns: boolean
+ def isIdle( self ):
+ _logger.debug("Called isIdle")
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ if( state == "idle" ):
+ return True
+ else:
+ return False
+
+ #################################################################################################################################
+ # Helper function to check if machine is homed on all axes for motion
+ # Parameters: NONE
+ #
+ # Returns: boolean
+ def isHomed( self ):
+ _logger.debug("Called isHomed")
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ if( homed ):
+ return True
+ else:
+ return False
+ except Exception as e:
+ _logger.critical( "Failed to check if machine is homed. " + str(e) )
+ raise SystemExit("Failed to check if machine is homed. " + str(e))
+
+ #################################################################################################################################
+ # Load specified tool on machine, and wait until machine is idle
+ # Tool numbering always starts as 0, 1, 2, ..
+ # If the toolchange takes longer than the class attribute _toolTimeout, then raise a warning in the log and return.
+ #
+ # ATTENTION:
+ # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs.
+ # You may change this behavior by modifying the exception handling for ToolTimeoutException.
+ #
+ # Parameters:
+ # - toolIndex (integer): index of tool to load
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - ToolTimeoutException: machine took too long to load the tool
+ def loadTool( self, toolIndex = 0 ):
+ _logger.debug('Called loadTool')
+ # variable to hold current tool loading "virtual" timer
+ toolchangeTimer = 0
+
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ # Wait until machine is done loading tool and is idle
+ while not self.isIdle() and toolchangeTimer <= self._toolTimeout:
+ self._toolTimeout += 2
+ time.sleep(2)
+ if( toolchangeTimer > self._toolTimeout ):
+ # Request for toolchange timeout, raise exception
+ raise ToolTimeoutException( "Request to change to tool T" + str(toolIndex) + " timed out.")
+ return
+ except ToolTimeoutException as tte:
+ _logger.warning(str(tte))
+ return
+ except ConnectionError as ce:
+ _logger.critical('Connection error in loadTool.')
+ raise SystemExit(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in loadTool: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Unload all tools from machine and wait until machine is idle
+ # Tool numbering always starts as 0, 1, 2, ..
+ # If the unload operation takes longer than the class attribute _toolTimeout, then raise a warning in the log and return.
+ #
+ # ATTENTION:
+ # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs.
+ # You may change this behavior by modifying the exception handling for ToolTimeoutException.
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - ToolTimeoutException: machine took too long to load the tool
+ def unloadTools( self ):
+ _logger.debug('Called unloadTools')
+ # variable to hold current tool loading "virtual" timer
+ toolchangeTimer = 0
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ # Wait until machine is done loading tool and is idle
+ while not self.isIdle() and toolchangeTimer <= self._toolTimeout:
+ self._toolTimeout += 2
+ time.sleep(2)
+ if( toolchangeTimer > self._toolTimeout ):
+ # Request for toolchange timeout, raise exception
+ raise ToolTimeoutException( "Request to unload tools timed out!")
+ return
+ except ToolTimeoutException as tte:
+ _logger.warning(str(tte))
+ return
+ except ConnectionError as ce:
+ _logger.critical('Connection error in unloadTools')
+ raise SystemExit(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in unloadTools: ' + str(e1))
+ raise SystemExit(e1)
+
+ #################################################################################################################################
+ # Execute a relative positioning move (G91 in Duet Gcode), and return to absolute positioning.
+ # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min.
+ #
+ # Parameters:
+ # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode)
+ # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min)
+ # - X (float): requested X axis final position
+ # - Y (float): requested Y axis final position
+ # - Z (float): requested Z axis final position
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - HomingException: machine is not homed
+ def moveRelative( self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None ):
+ _logger.debug('Called moveRelative')
+ try:
+ # check if machine has been homed fully
+ if( self.isHomed() is False ):
+ raise HomingException("Machine axes have not been homed properly.")
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ except HomingException as he:
+ _logger.error( he )
+ except Exception as e:
+ errorString = "Move failed to relative coordinates: ("
+ if( X is not None ):
+ errorString += " X" + str(X)
+ if( Y is not None ):
+ errorString += " Y" + str(Y)
+ if( Z is not None ):
+ errorString += " Z" + str(Z)
+ errorString += ") at speed: " + str(moveSpeed)
+ _logger.critical(errorString)
+ raise SystemExit(errorString + "\n" + str(e))
+ return
+
+ #################################################################################################################################
+ # Execute an absolute positioning move (G90 in Duet Gcode), and return to absolute positioning.
+ # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min.
+ #
+ # Parameters:
+ # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode)
+ # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min)
+ # - X (float): requested X axis final position
+ # - Y (float): requested Y axis final position
+ # - Z (float): requested Z axis final position
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def moveAbsolute( self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None ):
+ _logger.debug('Called moveAbsolute')
+ try:
+ # check if machine has been homed fully
+ if( self.isHomed() is False ):
+ raise HomingException("Machine axes have not been homed properly.")
+
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ except HomingException as he:
+ _logger.error( he )
+ except Exception as e:
+ errorString = " move failed to absolute coordinates: ("
+ if( X is not None ):
+ errorString += " X" + str(X)
+ if( Y is not None ):
+ errorString += " Y" + str(Y)
+ if( Z is not None ):
+ errorString += " Z" + str(Z)
+ errorString += ") at speed: " + str(moveSpeed)
+ _logger.critical(errorString + str(e) )
+ raise SystemExit(errorString + "\n" + str(e))
+ return
+
+ #################################################################################################################################
+ # Limit machine movement to within predefined boundaries as per machine-specific configuration.
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def limitAxes( self ):
+ _logger.debug('Called limitAxes')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ _logger.debug("Axes limits enforced successfully.")
+ except Exception as e:
+ _logger.error("Failed to limit axes movement: " + str(e))
+ raise SystemExit("Failed to limit axes movement: " + str(e))
+ return
+
+ #################################################################################################################################
+ # Flush controller movement buffer
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def flushMovementBuffer( self ):
+ _logger.debug('Called flushMovementBuffer')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+
+ _logger.debug("flushMovementBuffer ran successfully.")
+ except Exception as e:
+ _logger.error("Failed to flush movement buffer: " + str(e))
+ raise SystemExit("Failed to flush movement buffer: " + str(e))
+ return
+
+ #################################################################################################################################
+ # Save tool offsets to "firmware"
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def saveOffsetsToFirmware( self ):
+ _logger.debug('Called saveOffsetsToFirmware')
+ try:
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ _logger.debug("Tool offsets saved to firmware.")
+ except Exception as e:
+ _logger.error("Failed to save offsets: " + str(e))
+ raise SystemExit("Failed to save offsets: " + str(e))
+ return
+
+ #################################################################################################################################
+ #################################################################################################################################
+ # Core class functions
+ #
+ # These functions handle sending gcode commands to your controller:
+ # - gCode: send a single line of gcode
+ # - gCodeBatch: send an array of gcode strings to your controller and execute them sequentially
+
+ def gCode(self,command):
+ _logger.debug('gCode called')
+ ##############*** YOUR CUSTOM CODE #################
+ ##############*** YOUR CUSTOM CODE #################
+ if (ok):
+ return 0
+ else:
+ _logger.error("Error running gCode command")
+ raise SystemExit("Error running gCode command")
+ return -1
+
+ def gCodeBatch(self,commands):
+ _logger.debug('gCode called')
+ ##############*** YOUR CUSTOM CODE #################
+ for command in commands:
+ self.gCode(command)
+ ##############*** YOUR CUSTOM CODE #################
+ if (ok):
+ return 0
+ else:
+ _logger.error("Error running gCode command")
+ raise SystemExit("Error running gCode command")
+ return -1
+
+ ### DO NOT EDIT BEYOND THIS LINE ###
+ #################################################################################################################################
+ # Output JSON representation of printer
+ #
+ # Parameters: NONE
+ #
+ # Returns: JSON object for printer class
+ #
+ # Raises: NONE
+ def getJSON( self ):
+ printerJSON = {
+ 'address': self._base_url,
+ 'password': self._password,
+ 'name': self._name,
+ 'nickname': self._nickname,
+ 'controller': self._firmwareName,
+ 'firmware': self._firmwareVersion,
+ 'tools': []
+ }
+ for i, currentTool in enumerate(self._tools):
+ printerJSON['tools'].append(currentTool.getJSON())
+ return( printerJSON )
+
+ #################################################################################################################################
+ #################################################################################################################################
+ # ZTATP Core atomic class functions
+ #
+ # These are critical functions used by ZTATP to set up probes, check for odd RRF versions that have unique syntax requirements,
+ # and are use to set/rest config file changes for the endstops to ensure correct operation of the ZTATP alignment scripts.
+
+ def getFilenamed(self,filename):
+ if (self.pt == 2):
+ URL=(f'{self._base_url}'+'/rr_download?name='+filename)
+ if (self.pt == 3):
+ URL=(f'{self._base_url}'+'/machine/file/'+filename)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) )
+ return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' '))
+
+ def checkDuet2RRF3(self):
+ if (self.pt == 2):
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) )
+ j = json.loads(r.text)
+ s=j['firmwareVersion']
+
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout) )
+
+ if s == "3.2":
+ return True
+ else:
+ return False
+
+ #################################################################################################################################
+ # The following methods provide services built on the ZTATP Core atomimc class functions.
+
+ # _nilEndStop
+ # Given a line from config g that defines an endstop (N574) or Z probe (M558),
+ # Return a line that will define the same thing to a "nil" pin, i.e. undefine it
+ def _nilEndstop(self,configLine):
+ ret = ''
+ for each in [word for word in configLine.split()]:
+ ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' '
+ return(ret)
+
+ def clearEndstops(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
+ commandBuffer.append(self._nilEndstop(each))
+ self.gCodeBatch(commandBuffer)
+
+ def resetEndstops(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
+ commandBuffer.append(self._nilEndstop(each))
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetAxisLimits(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if 'M208 ' in line]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetG10(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if 'G10 ' in line]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetAdvancedMovement(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def getTriggerHeight(self):
+ _errCode = 0
+ _errMsg = ''
+ triggerHeight = 0
+ if (self.pt == 2):
+ if not self._rrf2:
+ #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
+ sessionURL = (f'{self._base_url}'+'/rr_connect?password=reprap')
+ r = self.session.get(sessionURL, timeout=(self._requestTimeout,self._responseTimeout) )
+ rawdata = r.json()
+ rawdata = json.dumps(rawdata)
+ _logger.debug( 'Response from connect: ' + rawdata )
+ buffer_size = 0
+ # while buffer_size < 150:
+ # bufferURL = (f'{self._base_url}'+'/rr_gcode')
+ # buffer_request = self.session.get(bufferURL, timeout=(self._requestTimeout,self._responseTimeout) )
+ # try:
+ # buffer_response = buffer_request.json()
+ # buffer_size = int(buffer_response['buff'])
+ # except:
+ # buffer_size = 149
+ # if buffer_size < 150:
+ # _logger.debug('Buffer low - adding 0.6s delay before next call: ' + str(buffer_size))
+ # time.sleep(0.6)
+ URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout) )
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ reply = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout) )
+ # Reply is of the format:
+ # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0"
+ start = reply.find('trigger height')
+ triggerHeight = reply[start+15:]
+ triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
+ if not self._rrf2:
+ #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
+ endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
+ r2 = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout) )
+ if (self.pt == 3):
+ URL=(f'{self._base_url}'+'/machine/code/')
+ r = self.requests.post(URL, data='G31')
+ # Reply is of the format:
+ # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0"
+ reply = r.text
+ start = reply.find('trigger height')
+ triggerHeight = reply[start+15:]
+ triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
+ if (r.ok):
+ return (_errCode, _errMsg, triggerHeight )
+ else:
+ _errCode = float(r.status_code)
+ _errMsg = r.reason
+ _logger.error("Bad resposne in getTriggerHeight: " + str(r.status_code) + ' - ' + str(r.reason))
+ return (_errCode, _errMsg, None )
+
+ #################################################################################################################################
+#################################################################################################################################
+# Exception Classes
+# Do not change this
+class Error(Exception):
+ """Base class for other exceptions"""
+ pass
+class UnknownController(Error):
+ pass
+class FailedToolDetection(Error):
+ pass
+class FailedOffsetCapture(Error):
+ pass
+class StatusException(Error):
+ pass
+class CoordinatesException(Error):
+ pass
+class SetOffsetException(Error):
+ pass
+class ToolTimeoutException(Error):
+ pass
+class HomingException(Error):
+ pass
+
+#################################################################################################################################
+#################################################################################################################################
+# helper class for tool definition
+# Do not change this
+class Tool:
+ # class attributes
+ _number = 0
+ _name = "Tool"
+ _nozzleSize = 0.4
+ _offsets = {"X":0, "Y": 0, "Z": 0}
+
+ def __init__( self, number=0, name="Tool", nozzleSize=0.4, offsets={"X":0, "Y": 0, "Z": 0} ):
+ self._number = number
+ self._name = name
+ self._nozzleSize = nozzleSize
+ self._offsets = offsets
+
+ def getJSON( self ):
+ return( {
+ "number": self._number,
+ "name": self._name,
+ "nozzleSize": self._nozzleSize,
+ "offsets": [ self._offsets["X"], self._offsets["Y"], self._offsets["Z"] ]
+ } )
\ No newline at end of file
diff --git a/drivers/DuetWebAPI.py b/drivers/DuetWebAPI.py
new file mode 100644
index 0000000..8d72431
--- /dev/null
+++ b/drivers/DuetWebAPI.py
@@ -0,0 +1,1276 @@
+# Python Script containing a class to send commands to, and query specific information from,
+# Duet based printers running either Duet RepRap V2 or V3 firmware.
+#
+# Does NOT hold open the connection. Use for low-volume requests.
+# Does NOT, at this time, support Duet passwords.
+#
+# Not intended to be a gerneral purpose interface; instead, it contains methods
+# to issue commands or return specific information. Feel free to extend with new
+# methods for other information; please keep the abstraction for V2 V3
+#
+# Copyright (C) 2020 Danal Estes all rights reserved.
+# Copyright (C) 2022 Haytham Bennani
+# Released under The MIT License. Full text available via https://opensource.org/licenses/MIT
+#
+# Requires Python3
+# from csv import excel_tab
+# from string import lower
+from http.client import *
+
+
+import logging
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.connection import ConnectTimeoutError
+from urllib3.util.retry import Retry
+from urllib3.exceptions import *
+from urllib.parse import urlparse
+import socket
+# import dependencies
+import json
+import time
+
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.DuetWebAPI')
+# # enable HTTP requests logging
+# import http.client
+# http.client.HTTPConnection.debuglevel = 1
+
+#################################################################################################################################
+#################################################################################################################################
+# Exception Classes
+class Error(Exception):
+ """Base class for other exceptions"""
+ pass
+class UnknownController(Error):
+ pass
+class FailedToolDetection(Error):
+ pass
+class FailedOffsetCapture(Error):
+ pass
+class StatusException(Error):
+ pass
+class CoordinatesException(Error):
+ pass
+class SetOffsetException(Error):
+ pass
+class ToolTimeoutException(Error):
+ pass
+class HomingException(Error):
+ pass
+class DuetSBCHandler(Error):
+ pass
+
+#################################################################################################################################
+#################################################################################################################################
+# helper class for tool definition
+class Tool:
+ # class attributes
+ _number = 0
+ _name = "Tool"
+ _nozzleSize = 0.4
+ _offsets = {"X":0, "Y": 0, "Z": 0}
+
+ def __init__(self, number=0, name="Tool", nozzleSize=0.4, offsets={"X":0, "Y": 0, "Z": 0}):
+ self._number = number
+ self._name = name
+ self._nozzleSize = nozzleSize
+ self._offsets = offsets
+
+ def getJSON(self):
+ return({
+ "number": self._number,
+ "name": self._name,
+ "nozzleSize": self._nozzleSize,
+ "offsets": [ self._offsets["X"], self._offsets["Y"], self._offsets["Z"] ]
+ })
+
+#################################################################################################################################
+#################################################################################################################################
+# Main class for interface
+class printerAPI:
+ # Any unhandled/general exceptions raised will cause a system exit to prevent machine damage.
+
+ #################################################################################################################################
+ #################################################################################################################################
+ # Class attributes
+ # Duet hardware version (Duet 2 or Duet 3 controller)
+ pt = 0
+ # base URL to connect to machine
+ _base_url = ''
+ # Machine-specific attributes
+ _nickname = 'Default'
+ _firmwareName = 'RRF'
+ _firmwareVersion = ''
+ _password = 'reprap'
+ # special internal flag to handle Duet 2 boards running RRF v3 firmware - only needed for Duet controllers
+ # you may delete this variable if extending API to other boards
+ _rrf2 = False
+ # Max time to wait for toolchange before raising a timeout exception, in seconds
+ _toolTimeout = 300
+ # Max time to wait for HTTP requests to complete
+ _requestTimeout = 2
+ _responseTimeout = 5
+ # flag to indicate if machine has been homed or not
+ _homed = None
+ # Tools
+ _tools = []
+
+ #################################################################################################################################
+ # Instantiate class and connect to controller
+ # Parameters:
+ # - baseURL (string): full IP address (not FQDN or alias) in the format 'http://xxx.xxx.xxx.xxx' without trailing '/'
+ # - nickname (string): short nickname for identifying machine (strictly for TAMV GUI)
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - UnknownController: if fails to connect
+ def __init__(self, baseURL, nickname='Default', password='reprap'):
+ _logger.debug('Starting DuetWebAPI..')
+ # parse input parameters
+
+ # convert hostname into IP
+ # fetch IP address
+ u = urlparse(baseURL)
+ hostIP = socket.gethostbyname(u.hostname)
+ baseURL = u.scheme + '://' + hostIP
+ if(u.port is not None):
+ baseURL += ':' + str(u.port)
+
+ # set base parameters
+ self._base_url = baseURL
+ self.password = password
+ self._nickname = nickname
+ self._tools = []
+ # Name as defined in RRF config.g file
+ self._name = 'My Duet'
+ # set up session parameters
+ self.session = requests.Session()
+ self.retry = Retry(connect=3, backoff_factor=0.4)
+ self.adapter = HTTPAdapter(max_retries=self.retry)
+ self.session.mount('http://', self.adapter)
+ try:
+ # check if its a Duet 2 board
+ # Set up session using password
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ if(r.ok):
+ j = json.loads(r.text)
+ else:
+ raise DuetSBCHandler
+
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # Get machine name
+ self._name = j['name']
+ # Setup tool definitions
+ toolData = j['tools']
+ for inputTool in toolData:
+ tempTool = Tool(
+ number = inputTool['number'],
+ name = inputTool['name'],
+ offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]})
+ self._tools.append(tempTool)
+ _logger.debug('Added tool: ' + str(tempTool.getJSON()))
+
+ # Check for firmware version
+ firmwareName = j['firmwareName']
+ # fetch hardware board type from firmware name, character 24
+ boardVersion = firmwareName[24]
+ self._firmwareVersion = j['firmwareVersion']
+ # set RRF version based on results
+ if self._firmwareVersion[0] == "2":
+ # Duet running RRF v2
+ self._rrf2 = True
+ self.pt = 2
+ else:
+ # Duet 2 hardware running RRF v3
+ self._rrf2 = False
+ self.pt = 2
+ _logger.info(' .. connected to '+ firmwareName + '- V'+ self._firmwareVersion + '..')
+ return
+ except DuetSBCHandler as sbc:
+ # We're probably dealing with a Duet 3 controller, get required firmware info
+ try:
+ _logger.debug('Trying to connect to Duet 3 board..')
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ _logger.debug('Got reply, parsing again..')
+ j = json.loads(r.text)
+ _=j
+ firmwareName = j['boards'][0]['firmwareName']
+ firmwareVersion = j['boards'][0]['firmwareVersion']
+ self.pt = 3
+
+ # Setup tool definitions
+ toolData = j['tools']
+ for inputTool in toolData:
+ tempTool = Tool(
+ number = inputTool['number'],
+ name = inputTool['name'],
+ offsets={'X': inputTool['offsets'][0], 'Y': inputTool['offsets'][1], 'Z':inputTool['offsets'][2]})
+ self._tools.append(tempTool)
+
+ _logger.debug('Duet 3 board detected')
+ _logger.info(' .. connected to: '+ firmwareName + '- V'+firmwareVersion + '..')
+ return
+ except:
+ # The board is neither a Duet 2 controller using RRF v2/3 nor a Duet 3 controller board, return an error state
+ raise UnknownController('Unknown controller detected.')
+ except UnknownController as uc:
+ errorMsg = 'Unknown controller at " + self._base_url + " - does not appear to be an RRF2 or RRF3 printer'
+ _logger.error(errorMsg)
+ raise SystemExit(errorMsg)
+ except requests.exceptions.ConnectTimeout:
+ errorMsg = 'Connect operation: Connection timed out.'
+ _logger.critical(errorMsg)
+ raise Exception(errorMsg)
+ # except HTTPException as ht:
+ # _logger.error('DuetWebAPIT init: Connection error.')
+ except Exception as e:
+ # Catastrophic error. Bail.
+ _logger.critical('DuetWebAPI2 Init: ' + str(e))
+ raise Exception('DuetWebAPI Init: ' + str(e))
+
+ #################################################################################################################################
+ # Get firmware version
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - returns either 2 or 3 depending on which RRF version is running on the controller
+ #
+ # Raises: NONE
+ def getPrinterType(self):
+ _logger.debug('Called getPrinterType')
+ return(self.pt)
+
+ #################################################################################################################################
+ # Get number of defined tools from machine
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - Positive integer for number of defined tools on machine
+ #
+ # Raises:
+ # - FailedToolDetection: when cannot determine number of tools on machine
+ def getNumTools(self):
+ _logger.debug('Called getNumTools')
+
+ # try:
+ # if (self.pt == 2):
+ # # Duet RRF v2
+ # URL=(f'{self._base_url}'+'/rr_status?type=2')
+ # r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # j = json.loads(r.text)
+ # jc=j['tools']
+ # _logger.debug('Number of tools: ' + str(len(jc)))
+
+ # # Send reply to clear buffer
+ # replyURL = (f'{self._base_url}'+'/rr_reply')
+ # r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # return(len(jc))
+ # elif (self.pt == 3):
+ # # Duet RRF v3
+ # URL=(f'{self._base_url}'+'/machine/status')
+ # r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # j = json.loads(r.text)
+ # if 'result' in j: j = j['result']
+ # _logger.debug('Number of tools: ' + str(len(j['tools'])))
+ # return(len(j['tools']))
+ # # failed to get tool data, raise exception
+ # raise FailedToolDetection('Cannot determine number of tools on machine')
+ # except FailedToolDetection as ft:
+ # _logger.critical('Failed to detect number of tools on machine')
+ # raise Exception(ft)
+ # except Exception as e:
+ # _logger.critical("Connection error: " + str(e))
+ # raise Exception(e)
+ return(len(self._tools))
+
+ #################################################################################################################################
+ # Get index of currently loaded tool
+ # Tool numbering always starts as 0, 1, 2, ..
+ # Parameters:
+ # - NONE
+ #
+ # Returns: integer
+ # - Positive integer for index of current loaded tool
+ # - '-1' if no tool is loaded on the machine
+ #
+ # Raises:
+ # - FailedToolDetection: when cannot determine number of tools on machine
+ def getCurrentTool(self):
+ _logger.debug('Called getCurrentTool')
+ try:
+ if (self.pt == 2):
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # Wait for machine to be in idle state
+ while self.getStatus() not in "idle":
+ _logger.debug('Machine not idle, sleeping 0.5 seconds.')
+ time.sleep(0.5)
+ # Fetch machine data
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ ret=j['currentTool']
+
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ _logger.debug('Found current tool: ' + str(ret))
+ return(ret)
+ elif (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ if 'result' in j: j = j['result']
+ ret=j['state']['currentTool']
+ _logger.debug('Found current tool: ' + str(ret))
+ return(ret)
+ else:
+ # Unknown condition, raise error
+ raise FailedToolDetection('Something failed. Baililng.')
+ except ConnectTimeoutError:
+ errorMsg = 'getCurrentTool: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except ConnectionError as ce:
+ _logger.critical('Connection error while polling for current tool')
+ raise Exception(ce)
+ except FailedToolDetection as fd:
+ _logger.critical('Failed tool detection.')
+ raise Exception(e1)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getCurrentTool: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Get currently defined offsets for tool referenced by index
+ # Tool numbering always starts as 0, 1, 2, ..
+ # Parameters:
+ # - toolIndex (integer): index of tool to get offsets for
+ #
+ # Returns:
+ # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 }
+ #
+ # Raises:
+ # - FailedOffsetCapture: when cannot determine number of tools on machine
+ def getToolOffset(self, toolIndex=0):
+ _logger.debug('Called getToolOffset: ' + str(toolIndex))
+ try:
+ if (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ if 'result' in j: j = j['result']
+ ja=j['move']['axes']
+ jt=j['tools']
+ ret=json.loads('{}')
+ to = jt[toolIndex]['offsets']
+ for i in range(0,len(to)):
+ ret[ ja[i]['letter'] ] = to[i]
+ _logger.debug('Tool offset for T' + str(toolIndex) +': ' + str(ret))
+ return(ret)
+ elif (self.pt == 2):
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ ja=j['axisNames']
+ jt=j['tools']
+ ret=json.loads('{}')
+ for currentTool in jt:
+ if(currentTool['number'] == int(toolIndex)):
+ ret['X'] = currentTool['offsets'][0]
+ ret['Y'] = currentTool['offsets'][1]
+ ret['Z'] = currentTool['offsets'][2]
+ ret['U'] = currentTool['offsets'][3]
+ else:
+ continue
+ # to = jt[toolIndex]['offsets']
+ # for i in range(0,len(to)):
+ # ret[ ja[i] ] = to[i]
+ _logger.debug('Tool offset for T' + str(toolIndex) +': ' + str(ret))
+
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ return(ret)
+ else:
+ raise FailedOffsetCapture('getG10ToolOffset entered unhandled exception state.')
+ except ConnectTimeoutError:
+ errorMsg = 'getToolOffset: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except FailedOffsetCapture as fd:
+ _logger.critical('DuetWebAPI getToolOffset: ' + str(fd))
+ raise Exception(fd)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getToolOffset.')
+ raise Exception(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getToolOffset: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Get machine status, mapping any controller status output into 1 of 3 possible states
+ # Parameters:
+ # - NONE
+ #
+ # Returns: string of following values ONLY
+ # - idle
+ # - processing
+ # - paused
+ #
+ # Raises:
+ # - StatusException: when cannot determine machine status
+ # - StatusTimeoutException: when machine takes longer than _toolTimeout seconds to respond
+ def getStatus(self):
+ _logger.debug('Called getStatus')
+ try:
+ if (self.pt == 2):
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ _status=j['status']
+ elif (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ if 'result' in j:
+ j = j['result']
+ _status = str(j['state']['status'])
+ _status = _status.lower()
+ else:
+ # unknown error raise exception
+ raise StatusException('Unknown error getting machine status')
+
+ # Send reply to clear buffer
+ # replyURL = (f'{self._base_url}'+'/rr_reply')
+ # r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # OUTPUT MAPPING LOGIC
+ # Handle return mapping of status variable "_status"
+ if (_status == "idle" or _status == "I"):
+ _logger.debug("Machine is idle.")
+ return ("idle")
+ elif (_status == "paused" or _status == "S" or _status == "pausing" or _status == "D"):
+ _logger.debug("Machine is paused.")
+ return ("paused")
+ else:
+ _logger.debug("Machine is busy processing something.")
+ return ("processing")
+ except ConnectTimeoutError:
+ errorMsg = 'getStatus: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except StatusException as se:
+ _logger.critical('DuetWebAPI getStatus: ' + str(se))
+ raise Exception(se)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getStatus')
+ raise Exception(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getStatus: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Get current tool coordinates from machine in XYZ space
+ # Parameters:
+ # - NONE
+ #
+ # Returns:
+ # - tuple of floats: { 'X': 0.000 , 'Y': 0.000 , 'Z': 0.000 }
+ #
+ # Raises:
+ # - CoordinatesException: when cannot determine machine status
+ def getCoordinates(self):
+ _logger.debug('*** Called getCoordinates')
+ try:
+ if (self.pt == 2):
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # poll machine for coordinates
+ while self.getStatus() not in "idle":
+ _logger.debug('moveAbsolute: sleeping 0.3 seconds..')
+ time.sleep(0.3)
+
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ if not r.ok:
+ raise ConnectionError('Error in getCoordinates session 2: ' + str(r))
+ j = json.loads(r.text)
+ jc=j['coords']['xyz']
+ an=j['axisNames']
+ ret=json.loads('{}')
+ for i in range(0,len(jc)):
+ ret[ an[i] ] = jc[i]
+
+ # Send reply to clear buffer
+ #replyURL = (f'{self._base_url}'+'/rr_reply')
+ #r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+ _logger.debug('*** exiting getCoordinates')
+ return(ret)
+ elif (self.pt == 3):
+ while self.getStatus() not in "idle":
+ _logger.debug('XX - printer not idle _SLEEPING_')
+ time.sleep(0.5)
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ if not r.ok:
+ raise ConnectionError('Error in getCoordinates session 3: ' + str(r))
+ j = json.loads(r.text)
+ if 'result' in j: j = j['result']
+ ja=j['move']['axes']
+ ret=json.loads('{}')
+ for i in range(0,len(ja)):
+ ret[ ja[i]['letter'] ] = ja[i]['userPosition']
+ _logger.debug('Returning coordinates: ' + str(ret))
+ _logger.debug('*** exiting getCoordinates')
+ return(ret)
+ else:
+ #unknown error, raise exception
+ raise CoordinatesException("Unknown duet controller.")
+ except ConnectTimeoutError:
+ errorMsg = 'getCoordinates: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except CoordinatesException as ce1:
+ _logger.critical('DuetWebAPI getCoordinates: ' + str(ce1))
+ raise Exception(ce1)
+ except ConnectionError as ce:
+ _logger.critical('Connection error in getCoordinates')
+ raise Exception(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in getCoordinates: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Set tool offsets for indexed tool in X, Y, and Z
+ # Parameters:
+ # - toolIndex (integer):
+ # - offsetX (float):
+ # - offsetY (float):
+ # - offsetZ (float):
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - SetOffsetException: when failed to set offsets in controller
+ def setToolOffsets(self, tool=None, X=None, Y=None, Z=None):
+ _logger.debug('Called setToolOffsets: ' + str(tool))
+ try:
+ # Check for invalid tool index, raise exception if needed.
+ if(tool is None):
+ raise SetOffsetException("No tool index provided.")
+ # Check that any valid offset has been passed as an argument
+ elif(X is None and Y is None and Z is None):
+ raise SetOffsetException("Invalid offsets provided.")
+ else:
+ offsetCommand = "G10 P" + str(int(tool))
+ if(X is not None):
+ offsetCommand += " X" + str(X)
+ if(Y is not None):
+ offsetCommand += " Y" + str(Y)
+ if(Z is not None):
+ offsetCommand += " Z" + str(Z)
+ _logger.debug(offsetCommand)
+ self.gCode(offsetCommand)
+ _logger.debug("Tool offsets applied.")
+ except ConnectTimeoutError:
+ errorMsg = 'setToolOffsets: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except SetOffsetException as se:
+ _logger.error('DuetWebAPI setToolOffsets: ' + str(se))
+ return
+ except Exception as e:
+ _logger.critical("setToolOffsets unhandled exception: " + str(e))
+ raise Exception("setToolOffsets unhandled exception: " + str(e))
+
+ #################################################################################################################################
+ # Helper function to check if machine is idle or not
+ # Parameters: NONE
+ #
+ # Returns: boolean
+ def isIdle(self):
+ _logger.debug("Called isIdle")
+ state = self.getStatus()
+ if(state == "idle"):
+ return True
+ else:
+ return False
+
+ #################################################################################################################################
+ # Helper function to check if machine is homed on all axes for motion
+ # Parameters: NONE
+ #
+ # Returns: boolean
+ def isHomed(self):
+ _logger.debug("Called isHomed")
+ if(self._homed is not None):
+ return self._homed
+ machineHomed = True
+ try:
+ if (self.pt == 2):
+ # Duet RRF v2
+
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_status?type=2')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ axesList=j['coords']['axesHomed']
+ for axis in axesList:
+ if(axis == 0):
+ machineHomed = False
+ else:
+ pass
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+ elif (self.pt == 3):
+ # Duet RRF v3
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/status')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ j = json.loads(r.text)
+ axesList=j['move']['axes']
+ for axis in axesList[0:3]:
+ if(axis['homed'] is False):
+ machineHomed = False
+ else:
+ pass
+
+ self._homed = machineHomed
+ return(self._homed)
+ except ConnectTimeoutError:
+ errorMsg = 'isHomed: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except Exception as e:
+ _logger.critical("Failed to check if machine is homed. " + str(e))
+ raise Exception("Failed to check if machine is homed. " + str(e))
+
+ #################################################################################################################################
+ # Load specified tool on machine, and wait until machine is idle
+ # Tool numbering always starts as 0, 1, 2, ..
+ # If the toolchange takes longer than the class attribute _toolTimeout, then raise a warning in the log and return.
+ #
+ # ATTENTION:
+ # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs.
+ # You may change this behavior by modifying the exception handling for ToolTimeoutException.
+ #
+ # Parameters:
+ # - toolIndex (integer): index of tool to load
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - ToolTimeoutException: machine took too long to load the tool
+ def loadTool(self, toolIndex = 0):
+ _logger.debug('Called loadTool: ' + str(toolIndex))
+ # variable to hold current tool loading "virtual" timer
+ toolchangeTimer = 0
+ try:
+ # Send command to controller to load tool specified by parameter
+ _requestedTool = int(toolIndex)
+ self.gCode("T" + str(_requestedTool))
+ # Wait until machine is done loading tool and is idle
+ while not self.isIdle() and toolchangeTimer <= self._toolTimeout:
+ self._toolTimeout += 2
+ time.sleep(2)
+ if(toolchangeTimer > self._toolTimeout):
+ # Request for toolchange timeout, raise exception
+ raise ToolTimeoutException("Request to change to tool T" + str(toolIndex) + " timed out.")
+ return
+ except ConnectTimeoutError:
+ errorMsg = 'loadTool: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except ToolTimeoutException as tte:
+ _logger.warning(str(tte))
+ return
+ except ConnectionError as ce:
+ _logger.critical('Connection error in loadTool.')
+ raise Exception(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in loadTool: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Unload all tools from machine and wait until machine is idle
+ # Tool numbering always starts as 0, 1, 2, ..
+ # If the unload operation takes longer than the class attribute _toolTimeout, then raise a warning in the log and return.
+ #
+ # ATTENTION:
+ # This assumes that your machine will not end up in an un-usable / unsteady state if the timeout occurs.
+ # You may change this behavior by modifying the exception handling for ToolTimeoutException.
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - ToolTimeoutException: machine took too long to load the tool
+ def unloadTools(self):
+ _logger.debug('Called unloadTools')
+ # variable to hold current tool loading "virtual" timer
+ toolchangeTimer = 0
+ try:
+ # Send command to controller to unload all tools
+ self.gCode("T-1")
+ # Wait until machine is done loading tool and is idle
+ while not self.isIdle() and toolchangeTimer <= self._toolTimeout:
+ self._toolTimeout += 2
+ time.sleep(2)
+ if(toolchangeTimer > self._toolTimeout):
+ # Request for toolchange timeout, raise exception
+ raise ToolTimeoutException("Request to unload tools timed out!")
+ return
+ except ConnectTimeoutError:
+ errorMsg = 'unloadTools: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except ToolTimeoutException as tte:
+ _logger.warning(str(tte))
+ return
+ except ConnectionError as ce:
+ _logger.critical('Connection error in unloadTools')
+ raise Exception(ce)
+ except Exception as e1:
+ _logger.critical('Unhandled exception in unloadTools: ' + str(e1))
+ raise Exception(e1)
+
+ #################################################################################################################################
+ # Execute a relative positioning move (G91 in Duet Gcode), and return to absolute positioning.
+ # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min.
+ #
+ # Parameters:
+ # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode)
+ # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min)
+ # - X (float): requested X axis final position
+ # - Y (float): requested Y axis final position
+ # - Z (float): requested Z axis final position
+ #
+ # Returns: NONE
+ #
+ # Raises:
+ # - HomingException: machine is not homed
+ def moveRelative(self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None):
+ _logger.debug('Called moveRelative')
+ try:
+ # check if machine has been homed fully
+ if(self.isHomed() is False):
+ raise HomingException("Machine axes have not been homed properly.")
+ # Create gcode command, starting with rapid flag (G0 / G1)
+ if(rapidMove is True):
+ moveCommand = "G91 G0 "
+ else:
+ moveCommand = "G91 G1 "
+ # Add each axis position according to passed arguments
+ if(X is not None):
+ moveCommand += " X" + str(X)
+ if(Y is not None):
+ moveCommand += " Y" + str(Y)
+ if(Z is not None):
+ moveCommand += " Z" + str(Z)
+
+ # Add move speed to command
+ moveCommand += " F" + str(moveSpeed)
+ # Add a return to absolute positioning to finish the command string creation
+ moveCommand += " G90"
+ _logger.debug(moveCommand)
+ # Send command to machine
+ self.gCode(moveCommand)
+ while self.getStatus() not in "idle":
+ _logger.debug('moveRelative: sleeping 0.3 seconds..')
+ time.sleep(0.3)
+ except ConnectTimeoutError:
+ errorMsg = 'moveRelative: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except HomingException as he:
+ _logger.error(he)
+ except Exception as e:
+ if(rapidMove is True):
+ errorString = "G0 rapid "
+ else:
+ errorString = "G1 linear "
+ errorString += " move failed to relative coordinates: ("
+ if(X is not None):
+ errorString += " X" + str(X)
+ if(Y is not None):
+ errorString += " Y" + str(Y)
+ if(Z is not None):
+ errorString += " Z" + str(Z)
+ errorString += ") at speed: " + str(moveSpeed)
+ _logger.critical('DuetWebAPI moveRelative: ' + errorString)
+ raise Exception(errorString + "\n" + str(e))
+ return
+
+ #################################################################################################################################
+ # Execute an absolute positioning move (G90 in Duet Gcode), and return to absolute positioning.
+ # You may specify if you want to execute a rapid move (G0 command), and set the move speed in feedrate/min.
+ #
+ # Parameters:
+ # - rapidMove (boolean): enable a G0 command at specified or max feedrate (in Duet CNC/Laser mode)
+ # - moveSpeed (float): speed at which to execute the move speed in feedrate/min (typically in mm/min)
+ # - X (float): requested X axis final position
+ # - Y (float): requested Y axis final position
+ # - Z (float): requested Z axis final position
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def moveAbsolute(self, rapidMove=False, moveSpeed=1000, X=None, Y=None, Z=None):
+ _logger.debug('Called moveAbsolute')
+ try:
+ # check if machine has been homed fully
+ if(self.isHomed() is False):
+ raise HomingException("Machine axes have not been homed properly.")
+ # Create gcode command, starting with rapid flag (G0 / G1)
+ if(rapidMove is True):
+ moveCommand = "G90 G0"
+ else:
+ moveCommand = "G90 G1"
+ # Add each axis position according to passed arguments
+ if(X is not None):
+ moveCommand += " X" + str(X)
+ if(Y is not None):
+ moveCommand += " Y" + str(Y)
+ if(Z is not None):
+ moveCommand += " Z" + str(Z)
+
+ # Add move speed to command
+ moveCommand += " F" + str(moveSpeed)
+ # Add a return to absolute positioning to finish the command string creation
+ moveCommand += " G90"
+ _logger.debug(moveCommand)
+ # Send command to machine
+ self.gCode(moveCommand)
+ while self.getStatus() not in "idle":
+ _logger.debug('moveAbsolute: sleeping 0.3 seconds..')
+ time.sleep(0.3)
+ except ConnectTimeoutError:
+ errorMsg = 'moveAbsolute: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except HomingException as he:
+ _logger.error(he)
+ except Exception as e:
+ if(rapidMove is True):
+ errorString = "G0 rapid "
+ else:
+ errorString = "G1 linear "
+ errorString += " move failed to absolute coordinates: ("
+ if(X is not None):
+ errorString += " X" + str(X)
+ if(Y is not None):
+ errorString += " Y" + str(Y)
+ if(Z is not None):
+ errorString += " Z" + str(Z)
+ errorString += ") at speed: " + str(moveSpeed)
+ _logger.critical('DuetWebAPI moveAbsolute: ' + errorString + str(e))
+ raise Exception(errorString + "\n" + str(e))
+ return
+
+ #################################################################################################################################
+ # Limit machine movement to within predefined boundaries as per machine-specific configuration.
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def limitAxes(self):
+ _logger.debug('Called limitAxes')
+ try:
+ self.gCode("M561 S1")
+ _logger.debug("Axes limits enforced successfully.")
+ except ConnectTimeoutError:
+ errorMsg = 'limitAxes: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except Exception as e:
+ _logger.error("Failed to limit axes movement")
+ raise Exception("Failed to limit axes movement")
+ return
+
+ #################################################################################################################################
+ # Flush controller movement buffer
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def flushMovementBuffer(self):
+ _logger.debug('Called flushMovementBuffer')
+ try:
+ self.gCode("M400")
+ _logger.debug("flushMovementBuffer ran successfully.")
+ except ConnectTimeoutError:
+ errorMsg = 'flushBuffer: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except Exception as e:
+ _logger.error("Failed to flush movement buffer.")
+ raise Exception("Failed to flush movement buffer")
+ return
+
+ #################################################################################################################################
+ # Save tool offsets to "firmware"
+ #
+ # Parameters: NONE
+ #
+ # Returns: NONE
+ #
+ # Raises: NONE
+ def saveOffsetsToFirmware(self):
+ _logger.debug('Called saveOffsetsToFirmware')
+ try:
+ self.gCode("M500 P10")
+ _logger.debug("Tool offsets saved to firmware.")
+ except ConnectTimeoutError:
+ errorMsg = 'saveOffsetsToFirmware: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except Exception as e:
+ _logger.error("Failed to save offsets: " + str(e))
+ raise Exception("Failed to save offsets: " + str(e))
+ return
+
+ #################################################################################################################################
+ # Output JSON representation of printer
+ #
+ # Parameters: NONE
+ #
+ # Returns: JSON object for printer class
+ #
+ # Raises: NONE
+ def getJSON(self):
+ printerJSON = {
+ 'address': self._base_url,
+ 'password': self._password,
+ 'name': self._name,
+ 'nickname': self._nickname,
+ 'controller': self._firmwareName,
+ 'version': self._firmwareVersion,
+ 'tools': []
+ }
+ for i, currentTool in enumerate(self._tools):
+ printerJSON['tools'].append(currentTool.getJSON())
+
+ return(printerJSON)
+
+ #################################################################################################################################
+ #################################################################################################################################
+ # Core class functions
+ #
+ # These functions handle sending gcode commands to your controller:
+ # - gCode: send a single line of gcode
+ # - gCodeBatch: send an array of gcode strings to your controller and execute them sequentially
+
+ def gCode(self,command):
+ _logger.debug('gCode called: ' + command)
+ try:
+ if (self.pt == 2):
+ # Start a connection
+ if(self._password != "reprap"):
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r2 = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+ elif (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/code/')
+ r = self.session.post(URL, data=command)
+ if (r.ok):
+ return 0
+ else:
+ _logger.error("Error running gCode command: return code " + str(r.status_code) + ' - ' + str(r.reason))
+ raise Exception("Error running gCode command: return code " + str(r.status_code) + ' - ' + str(r.reason))
+ except ConnectTimeoutError:
+ errorMsg = 'gCode: Connection timed out.'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ except SystemExit:
+ errorMsg = 'Failure to run gcode'
+ _logger.error(errorMsg)
+ raise Exception(errorMsg)
+ finally:
+ return -1
+
+ def gCodeBatch(self,commands):
+ if(self.pt == 2):
+ # Start a connection
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ else:
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ for command in commands:
+ if (self.pt == 2):
+ URL=(f'{self._base_url}'+'/rr_gcode?gcode='+command)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Send reply to clear buffer
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ r = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+ if (self.pt == 3):
+ URL=(f'{self._base_url}'+'/machine/code/')
+ r = self.requests.post(URL, data=command)
+ if not (r.ok):
+ _logger.Error("Error in gCodeBatch command: " + str(r.status_code) + str(r.reason))
+
+ if(self.pt == 2):
+ #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
+ endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
+ r = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ #################################################################################################################################
+ #################################################################################################################################
+ # ZTATP Core atomimc class functions
+ #
+ # These are critical functions used by ZTATP to set up probes, check for odd RRF versions that have unique syntax requirements,
+ # and are use to set/rest config file changes for the endstops to ensure correct operation of the ZTATP alignment scripts.
+
+ def getFilenamed(self,filename):
+ if (self.pt == 2):
+ # Start a connection
+ _logger.debug('Starting DuetWebAPI session..')
+ URL=(f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+
+ URL=(f'{self._base_url}'+'/rr_download?name='+filename)
+
+ if (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/file/'+filename)
+
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ return(r.text.splitlines()) # replace('\n',str(chr(0x0a))).replace('\t',' '))
+
+ #################################################################################################################################
+ # The following methods provide services built on the ZTATP Core atomimc class functions.
+
+ # _nilEndStop
+ # Given a line from config g that defines an endstop (N574) or Z probe (M558),
+ # Return a line that will define the same thing to a "nil" pin, i.e. undefine it
+ def _nilEndstop(self,configLine):
+ ret = ''
+ for each in [word for word in configLine.split()]:
+ ret = ret + (each if (not (('P' in each[0]) or ('p' in each[0]))) else 'P"nil"') + ' '
+ return(ret)
+
+ def clearEndstops(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
+ commandBuffer.append(self._nilEndstop(each))
+ self.gCodeBatch(commandBuffer)
+
+ def resetEndstops(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) )]:
+ commandBuffer.append(self._nilEndstop(each))
+ for each in [line for line in c if (('M574 ' in line) or ('M558 ' in line) or ('G31 ' in line))]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetAxisLimits(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if 'M208 ' in line]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetG10(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if 'G10 ' in line]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def resetAdvancedMovement(self):
+ c = self.getFilenamed('/sys/config.g')
+ commandBuffer = []
+ for each in [line for line in c if (('M566 ' in line) or ('M201 ' in line) or ('M204 ' in line) or ('M203 ' in line))]:
+ commandBuffer.append(each)
+ self.gCodeBatch(commandBuffer)
+
+ def getTriggerHeight(self):
+ _errCode = 0
+ _errMsg = ''
+ triggerHeight = 0
+ if (self.pt == 2):
+ if not self._rrf2:
+ #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
+ _logger.debug('Starting DuetWebAPI session..')
+ sessionURL = (f'{self._base_url}'+'/rr_connect?password=' + self._password)
+ r = self.session.get(sessionURL, timeout=(self._requestTimeout,self._responseTimeout))
+ rawdata = r.json()
+ rawdata = json.dumps(rawdata)
+ _logger.debug('Response from connect: ' + rawdata)
+ buffer_size = 0
+ # while buffer_size < 150:
+ # bufferURL = (f'{self._base_url}'+'/rr_gcode')
+ # buffer_request = self.session.get(bufferURL, timeout=(self._requestTimeout,self._responseTimeout))
+ # try:
+ # buffer_response = buffer_request.json()
+ # buffer_size = int(buffer_response['buff'])
+ # except:
+ # buffer_size = 149
+ # if buffer_size < 150:
+ # _logger.debug('Buffer low - adding 0.6s delay before next call: ' + str(buffer_size))
+ # time.sleep(0.6)
+ URL=(f'{self._base_url}'+'/rr_gcode?gcode=G31')
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ replyURL = (f'{self._base_url}'+'/rr_reply')
+ reply = self.session.get(replyURL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Reply is of the format:
+ # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0 U0.0"
+ start = reply.find('trigger height')
+ triggerHeight = reply[start+15:]
+ triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
+ if not self._rrf2:
+ #RRF 3 on a Duet Ethernet/Wifi board, apply buffer checking
+ endsessionURL = (f'{self._base_url}'+'/rr_disconnect')
+ r2 = self.session.get(endsessionURL, timeout=(self._requestTimeout,self._responseTimeout))
+ if (self.pt == 3):
+ # Set up session using password
+ URL=(f'{self._base_url}'+'/machine/connect?password=' + self._password)
+ r = self.session.get(URL, timeout=(self._requestTimeout,self._responseTimeout))
+ # Get session key
+ r_obj = json.loads(r.text)
+ self._sessionKey = r_obj['sessionKey']
+ self.session.headers = {'X-Session-Key': self._sessionKey }
+
+ URL=(f'{self._base_url}'+'/machine/code/')
+ r = self.requests.post(URL, data='G31')
+ # Reply is of the format:
+ # "Z probe 0: current reading 0, threshold 500, trigger height 0.000, offsets X0.0 Y0.0"
+ reply = r.text
+ start = reply.find('trigger height')
+ triggerHeight = reply[start+15:]
+ triggerHeight = float(triggerHeight[:triggerHeight.find(',')])
+ if (r.ok):
+ return (_errCode, _errMsg, triggerHeight)
+ else:
+ _errCode = float(r.status_code)
+ _errMsg = r.reason
+ _logger.error("Bad resposne in getTriggerHeight: " + str(r.status_code) + ' - ' + str(r.reason))
+ return (_errCode, _errMsg, None)
+
\ No newline at end of file
diff --git a/install_opencv.sh b/install_opencv.sh
old mode 100755
new mode 100644
diff --git a/modules/Camera.py b/modules/Camera.py
new file mode 100644
index 0000000..a60dae9
--- /dev/null
+++ b/modules/Camera.py
@@ -0,0 +1,224 @@
+from distutils.log import debug, error
+import logging
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.CaptureManager.Camera')
+
+import cv2
+import sys
+from PyQt5 import QtCore
+import threading
+
+class Camera(QtCore.QObject):
+ # class attributes
+ __videoSrc = 0
+ __width = 640
+ __height = 480
+ __frame = None
+ __success = False
+ cap = None
+
+ # init function
+ def __init__(self, *args, **kwargs):
+ super(Camera,self).__init__(parent=kwargs['parent'])
+ # send calling to log
+ _logger.debug('*** calling Camera.__init__')
+ # process arguments
+ try:
+ self.__videoSrc = kwargs['videoSrc']
+ except KeyError: pass
+ # frame width
+ try:
+ self.__width= int(kwargs['width'])
+ except KeyError: pass
+ # frame height
+ try:
+ self.__height = int(kwargs['height'])
+ except KeyError: pass
+
+ if sys.platform.startswith('linux'): # all Linux
+ self.backend = cv2.CAP_V4L
+ # elif sys.platform.startswith('win'): # MS Windows
+ # self.backend = cv2.CAP_DSHOW
+ elif sys.platform.startswith('darwin'): # macOS
+ self.backend = cv2.CAP_AVFOUNDATION
+ else:
+ self.backend = cv2.CAP_ANY # auto-detect via OpenCV
+
+ try:
+ try:
+ self.cap = cv2.VideoCapture(self.__videoSrc, self.backend)
+ self.__success = self.cap.grab()
+ if(not self.__success):
+ raise Exception
+ except:
+ _logger.debug('Camera API primary exploded, using failover cv2.CAP_ANY')
+ try:
+ self.cap.release()
+ except: pass
+ self.cap = cv2.VideoCapture(self.__videoSrc, cv2.CAP_ANY)
+ self.__success = self.cap.grab()
+ if(not self.__success):
+ _logger.debug('Camera API failover exploded')
+ raise Exception
+ self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.__width)
+ self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.__height)
+ self.cap.set(cv2.CAP_PROP_FPS, 30)
+ self.cap.set(cv2.CAP_PROP_BUFFERSIZE,1)
+ # self.cap.setExceptionMode(enable=True)
+ _logger.debug('Active CV backend: ' + self.cap.getBackendName())
+ _logger.info(' .. camera connected using ' + self.cap.getBackendName() + '..')
+ try:
+ backends = cv2.videoio_registry.getCameraBackends()
+ debugMessage = 'Backend options:'
+ for backend in backends:
+ debugMessage += ' ' + cv2.videoio_registry.getBackendName(backend) +','
+ debugMessage = debugMessage[:-1]
+ _logger.debug(debugMessage)
+ except: _logger.debug('Camera: cannot retrieve list of backends')
+ except:
+ errorMsg = 'Failed to start video source ' + str(self.__videoSrc) + ' @ ' + str(self.__width) + 'x' + str(self.__height)
+ _logger.exception(errorMsg)
+ raise SystemExit(errorMsg)
+
+ try:
+ # get startup camera properties
+ try:
+ self.brightnessDefault = self.cap.get(cv2.CAP_PROP_BRIGHTNESS)
+ except: self.brightnessDefault = None
+ try:
+ self.contrastDefault = self.cap.get(cv2.CAP_PROP_CONTRAST)
+ except: self.contrastDefault = None
+ try:
+ self.saturationDefault = self.cap.get(cv2.CAP_PROP_SATURATION)
+ except: self.saturationDefault = None
+ try:
+ self.hueDefault = self.cap.get(cv2.CAP_PROP_HUE)
+ except: self.hueDefault = None
+ except: pass
+
+ try:
+ self.__success, self.__frame = self.cap.read()
+ self.__imageSettings = {'default':1, 'brightness':self.brightnessDefault,'contrast':self.contrastDefault,'saturation':self.saturationDefault,'hue':self.hueDefault}
+ except Exception as e:
+ self.cap.release()
+ raise SystemExit
+ self.stopEvent = threading.Event()
+ self.t = threading.Thread(target=self._reader)
+ self.t.daemon = True
+ self.t.start()
+ # send exiting to log
+ _logger.debug('*** exiting Camera.__init__')
+
+ def _reader(self):
+ while True:
+ ret = self.cap.grab()
+ if not ret:
+ break
+ if self.stopEvent.is_set():
+ break
+
+ def stop(self):
+ self.stopEvent.set()
+
+ def getCurrentImageSettings(self):
+ return self.__imageSettings
+
+ def quit(self):
+ # send calling to log
+ _logger.debug('*** calling Camera.quit')
+ _logger.info(' .. closing camera..')
+ self.stop()
+ self.t.join()
+ self.cap.release()
+ # send exiting to log
+ _logger.debug('*** exiting Camera.quit')
+
+ def getFrame(self):
+ # self.__success, self.__frame = self.cap.read()
+ # if(self.__success):
+ # return(self.__frame)
+ # else:
+ # self.cap.release()
+ # raise Exception
+ ret, frame = self.cap.retrieve()
+ return frame
+
+ def getImagePropertiesJSON(self):
+ try:
+ brightness = self.cap.get(cv2.CAP_PROP_BRIGHTNESS)
+ except: brightness = None
+ try:
+ contrast = self.cap.get(cv2.CAP_PROP_CONTRAST)
+ except: contrast = None
+ try:
+ saturation = self.cap.get(cv2.CAP_PROP_SATURATION)
+ except: saturation = None
+ try:
+ hue = self.cap.get(cv2.CAP_PROP_HUE)
+ except: hue = None
+ try:
+ returnJSON = {
+ 'default': 0,
+ 'brightness': str(brightness),
+ 'contrast': str(contrast),
+ 'saturation': str(saturation),
+ 'hue': str(hue)
+ }
+ except:
+ errorMsg = 'Cannot create camera image properties object'
+ _logger.warning(errorMsg)
+ returnJSON = None
+ return(returnJSON)
+
+ def getDefaultImagePropertiesJSON(self):
+ try:
+ returnJSON = {
+ 'default': 1,
+ 'brightness': str(self.brightnessDefault),
+ 'contrast': str(self.contrastDefault),
+ 'saturation': str(self.saturationDefault),
+ 'hue': str(self.hueDefault)
+ }
+ except:
+ errorMsg = 'Cannot create camera image default properties object'
+ _logger.warning(errorMsg)
+ returnJSON = None
+ return(returnJSON)
+
+ def setImageProperties(self, imageProperties):
+ try:
+ brightness = self.cap.set(cv2.CAP_PROP_BRIGHTNESS, imageProperties['brightness'])
+ except KeyError: pass
+ except:
+ errorMsg = 'Failed to set image brightness.'
+ _logger.warning(errorMsg)
+ try:
+ contrast = self.cap.set(cv2.CAP_PROP_CONTRAST, imageProperties['contrast'])
+ except KeyError: pass
+ except:
+ errorMsg = 'Failed to set image contrast.'
+ _logger.warning(errorMsg)
+ try:
+ saturation = self.cap.set(cv2.CAP_PROP_SATURATION, imageProperties['saturation'])
+ except KeyError: pass
+ except:
+ errorMsg = 'Failed to set image saturation.'
+ _logger.warning(errorMsg)
+ try:
+ hue = self.cap.set(cv2.CAP_PROP_HUE, imageProperties['hue'])
+ except KeyError: pass
+ except:
+ errorMsg = 'Failed to set image hue.'
+ _logger.warning(errorMsg)
+
+ def resetImageDefaults(self):
+ try:
+ self.cap.set(cv2.CAP_PROP_BRIGHTNESS, self.brightnessDefault)
+ self.cap.set(cv2.CAP_PROP_CONTRAST, self.contrastDefault)
+ self.cap.set(cv2.CAP_PROP_SATURATION, self.saturationDefault)
+ self.cap.set(cv2.CAP_PROP_HUE, self.hueDefault)
+ return(True)
+ except:
+ errorMsg = 'Cannot set image properties.'
+ _logger.warning(errorMsg)
+ return(False)
diff --git a/modules/ConnectionDialog.py b/modules/ConnectionDialog.py
new file mode 100644
index 0000000..c85c0a3
--- /dev/null
+++ b/modules/ConnectionDialog.py
@@ -0,0 +1,109 @@
+import logging
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.ConnectionDialog')
+
+from PyQt5.QtWidgets import QComboBox, QDialog, QVBoxLayout, QPushButton, QFrame
+from PyQt5.QtCore import Qt
+from urllib.parse import urlparse
+
+########################################################################### Connection dialog box
+class ConnectionDialog(QDialog):
+ def __init__(self, *args, **kwargs):
+ _logger.debug('*** calling ConnectionDialog.__init__')
+
+ # Parse arguments
+ try:
+ self.__parent = kwargs['parent']
+ except: self.__parent = None
+ try:
+ self.__newPrinter = kwargs['newPrinter']
+ except: self.__newPrinter = False
+ try:
+ self.__settings = kwargs['settings']
+ except: self.__settings = None
+ try:
+ self.__stylesheet = kwargs['stylesheet']
+ except: self.__stylesheet = None
+
+ # Set up settings window
+ super(ConnectionDialog, self).__init__(parent=self.__parent)
+ self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
+ self.setWindowTitle( 'Connect to a machine' )
+ self.setWindowModality( Qt.ApplicationModal )
+ self.setMinimumWidth(300)
+ # self.setMinimumHeight(150)
+
+ # Set layout details
+ self.layout = QVBoxLayout()
+ self.layout.setSpacing(3)
+ self.setLayout( self.layout )
+ if(self.__stylesheet is not None):
+ self.setStyleSheet(self.__stylesheet)
+ # Get machines as defined in the config
+ # Printer combo box
+ self.printerCombobox = QComboBox()
+ self.printerCombobox.setFixedWidth(280)
+ self.printerCombobox.setStyleSheet('font: 16px')
+ self.defaultPrinter = {}
+ for i, device in enumerate(self.__settings['printer']):
+ machineAddress = urlparse(device['address'])
+ printerDescription = device['nickname'] + ' / ' + machineAddress.hostname
+ if( device['default'] == 1 ):
+ self.defaultPrinter = device
+ self.defaultPrinter['index'] = i
+ self.printerCombobox.addItem(printerDescription)
+ # handle selecting a new machine
+ # set default printer as the selected index
+ self.printerCombobox.setCurrentIndex(self.defaultPrinter['index'])
+ # add final option to add a new printer
+ self.printerCombobox.addItem('+++ Add a new machine..')
+ self.printerCombobox.currentIndexChanged.connect(self.addPrinter)
+ # add printer combo box to layout
+ self.connectButton = QPushButton( 'Connect..')
+ self.connectButton.clicked.connect(self.startConnection)
+ self.connectButton.setObjectName( 'active' )
+ self.connectButton.setFixedWidth(200)
+ # Close button
+ self.cancelButton = QPushButton( 'Cancel' )
+ self.cancelButton.setToolTip( 'Cancel changes and return to main program.' )
+ self.cancelButton.clicked.connect(self.reject)
+ self.cancelButton.setObjectName( 'terminate' )
+ self.cancelButton.setFixedWidth(200)
+ # Separator
+ self.spacerLine = QFrame()
+ self.spacerLine.setFrameShape(QFrame.HLine)
+ self.spacerLine.setLineWidth(1)
+ self.spacerLine.setFrameShadow(QFrame.Sunken)
+ self.spacerLine.setFixedHeight(25)
+ self.spacerLine.setFixedWidth(200)
+ # WINDOW BUTTONS
+ self.layout.setAlignment(Qt.AlignCenter)
+ self.layout.addWidget( self.printerCombobox )
+ self.layout.addWidget(self.spacerLine)
+ self.layout.addWidget(self.connectButton)
+ self.layout.addWidget(self.cancelButton)
+ self.layout.setAlignment(self.printerCombobox, Qt.AlignHCenter)
+ self.layout.setAlignment(self.spacerLine, Qt.AlignHCenter)
+ self.layout.setAlignment(self.connectButton, Qt.AlignHCenter)
+ self.layout.setAlignment(self.cancelButton, Qt.AlignHCenter)
+ _logger.debug('*** exiting ConnectionDialog.__init__')
+
+ def startConnection( self ):
+ _logger.debug('*** calling ConnectionDialog.startConnection')
+ index = self.printerCombobox.currentIndex()
+ if( index < len(self.__settings['printer'])):
+ self.done(index)
+ else:
+ self.done(-2)
+ _logger.debug('*** exiting ConnectionDialog.startConnection')
+
+ def addPrinter( self, index ):
+ _logger.debug('*** calling ConnectionDialog.addPrinter')
+ if( index == len(self.__settings['printer'])):
+ self.connectButton.setText('Create new profile..')
+ else:
+ self.connectButton.setText('Connect..')
+ _logger.debug('*** exiting ConnectionDialog.addPrinter')
+
+ def reject(self):
+ self.done(-1)
\ No newline at end of file
diff --git a/modules/DetectionManager.py b/modules/DetectionManager.py
new file mode 100644
index 0000000..1ceed11
--- /dev/null
+++ b/modules/DetectionManager.py
@@ -0,0 +1,715 @@
+import logging
+from sys import stdout
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.DetectionManager')
+
+import cv2
+import numpy as np
+from PyQt5.QtGui import QPixmap, QImage
+from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal
+from time import sleep
+import copy, sys, multiprocessing
+
+class DetectionManager(QObject):
+ # class attributes
+ __videoSource = None
+ __frameSize = {}
+ __enableDetection = False
+ __nozzleDetectionActive = False
+ __nozzleAutoDetectionActive = False
+ __endstopDetectionActive = False
+ __endstopAutomatedDetectionActive = False
+ __running = True
+ __uv = None
+ __counter = 0
+ __algorithm = None
+
+ # Signals
+ detectionManagerNewFrameSignal = pyqtSignal(object)
+ detectionManagerReadySignal = pyqtSignal(object)
+ detectionManagerImagePropertiesSignal = pyqtSignal(object)
+ detectionManagerDefaultImagePropertiesSignal = pyqtSignal()
+ detectionManagerSetImagePropertiesSignal = pyqtSignal(object)
+ detectionManagerResetImageSignal = pyqtSignal()
+ errorSignal = pyqtSignal(object)
+ finishedSignal = pyqtSignal()
+
+ detectionManagerEndstopPosition = pyqtSignal(object)
+ detectionManagerAutoEndStopSignal = pyqtSignal(object)
+ detectionManagerArrayFrameSignal = pyqtSignal(object)
+ detectionManagerUVCoordinatesSignal = pyqtSignal(object)
+
+ ##### Setup functions
+ # init function
+ def __init__(self, *args, **kwargs):
+ # send calling to log
+ _logger.debug('*** calling DetectionManager.__init__')
+ super(DetectionManager, self).__init__(parent=kwargs['parent'])
+ try:
+ self.__videoSource = kwargs['videoSrc']
+ except KeyError:
+ self.__videoSource = 0
+ try:
+ self.__frameSize['width'] = kwargs['width']
+ except KeyError:
+ self.__frameSize['width'] = 640
+ try:
+ self.__frameSize['height'] = kwargs['height']
+ except KeyError:
+ self.__frameSize['height'] = 480
+ self.startCamera()
+ self.createDetectors()
+ self.processFrame()
+
+ self.uv = [None, None]
+ # send exiting to log
+ _logger.debug('*** exiting DetectionManager.__init__')
+
+ def startCamera(self):
+ # send calling to log
+ _logger.debug('*** calling DetectionManager.startCamera')
+
+ if sys.platform.startswith('linux'): # all Linux
+ self.backend = cv2.CAP_V4L
+ # elif sys.platform.startswith('win'): # MS Windows
+ # self.backend = cv2.CAP_DSHOW
+ elif sys.platform.startswith('darwin'): # macOS
+ self.backend = cv2.CAP_AVFOUNDATION
+ else:
+ self.backend = cv2.CAP_ANY # auto-detect via OpenCV
+
+ # create shared event object
+ self.frameEvent = multiprocessing.Event()
+ self.stopEvent = multiprocessing.Event()
+ self.pipeDM, self.pipeQ = multiprocessing.Pipe()
+
+ self.proc = multiprocessing.Process(target=_reader, args=(self.pipeQ, self.frameEvent, self.stopEvent, self.__videoSource, self.__frameSize['height'], self.__frameSize['width'], self.backend))
+ self.proc.daemon = True
+ # send exiting to log
+ _logger.debug('*** exiting DetectionManager.startCamera')
+
+ def cameraReady(self, imageSettings):
+ # send calling to log
+ _logger.debug('*** calling DetectionManager.cameraReady')
+ # consume JSON
+ try:
+ brightness = imageSettings['brightness']
+ except KeyError: brightness = None
+ try:
+ contrast = imageSettings['contrast']
+ except KeyError: contrast = None
+ try:
+ staturation = imageSettings['saturation']
+ except KeyError: staturation = None
+ try:
+ hue = imageSettings['hue']
+ except KeyError: hue = None
+ self.__brightnessDefault = brightness
+ self.__contrastDefault = contrast
+ self.__saturationDefault = staturation
+ self.__hueDefault = hue
+ cameraProperties = {
+ 'videoSrc': self.__videoSource,
+ 'width': self.__frameSize['width'],
+ 'height': self.__frameSize['height'],
+ 'image': {
+ 'default': 1,
+ 'brightness': self.__brightnessDefault,
+ 'contrast': self.__contrastDefault,
+ 'saturation': self.__saturationDefault,
+ 'hue': self.__hueDefault
+ }
+ }
+ self.detectionManagerReadySignal.emit(cameraProperties)
+ # send exiting to log
+ _logger.debug('*** exiting DetectionManager.cameraReady')
+
+ def createDetectors(self):
+ # Standard Parameters
+ if(True):
+ self.standardParams = cv2.SimpleBlobDetector_Params()
+ # Thresholds
+ self.standardParams.minThreshold = 1
+ self.standardParams.maxThreshold = 50
+ self.standardParams.thresholdStep = 1
+ # Area
+ self.standardParams.filterByArea = True
+ self.standardParams.minArea = 400
+ self.standardParams.maxArea = 900
+ # Circularity
+ self.standardParams.filterByCircularity = True
+ self.standardParams.minCircularity = 0.8
+ self.standardParams.maxCircularity= 1
+ # Convexity
+ self.standardParams.filterByConvexity = True
+ self.standardParams.minConvexity = 0.3
+ self.standardParams.maxConvexity = 1
+ # Inertia
+ self.standardParams.filterByInertia = True
+ self.standardParams.minInertiaRatio = 0.3
+
+ # Relaxed Parameters
+ if(True):
+ self.relaxedParams = cv2.SimpleBlobDetector_Params()
+ # Thresholds
+ self.relaxedParams.minThreshold = 1
+ self.relaxedParams.maxThreshold = 50
+ self.relaxedParams.thresholdStep = 1
+ # Area
+ self.relaxedParams.filterByArea = True
+ self.relaxedParams.minArea = 600
+ self.relaxedParams.maxArea = 15000
+ # Circularity
+ self.relaxedParams.filterByCircularity = True
+ self.relaxedParams.minCircularity = 0.6
+ self.relaxedParams.maxCircularity= 1
+ # Convexity
+ self.relaxedParams.filterByConvexity = True
+ self.relaxedParams.minConvexity = 0.1
+ self.relaxedParams.maxConvexity = 1
+ # Inertia
+ self.relaxedParams.filterByInertia = True
+ self.relaxedParams.minInertiaRatio = 0.3
+
+ # Create 2 detectors
+ self.detector = cv2.SimpleBlobDetector_create(self.standardParams)
+ self.relaxedDetector = cv2.SimpleBlobDetector_create(self.relaxedParams)
+
+ def quit(self):
+ # send calling to log
+ _logger.debug('*** calling DetectionManager.quit')
+ _logger.info('Shutting down Detection Manager..')
+ _logger.info(' .. disconnecting video feed..')
+ self.__running = False
+ self.stopEvent.set()
+ self.proc.join()
+ _logger.info('Detection Manager shut down successfully.')
+ # send exiting to log
+ _logger.debug('*** exiting DetectionManager.quit')
+
+ # Main processing function
+ @pyqtSlot()
+ def processFrame(self):
+ if(not self.proc.is_alive() and not self.stopEvent.is_set()):
+ # Start camera process
+ self.proc.start()
+ # Retrieve camera settings
+ try:
+ cameraSettings = self.pipeDM.recv()
+ except:
+ errorMsg = 'Camera process failed to start.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ self.stopEvent.set()
+ try:
+ self.cameraReady(imageSettings=cameraSettings)
+ except:
+ errorMsg = 'Failed to read data from camera source.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ self.stopEvent.set()
+ elif(self.stopEvent.is_set()):
+ errorMsg = 'Critical error: check camera. Restart TAMV.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ else:
+ try:
+ self.frameEvent.set()
+ self.frame = self.pipeDM.recv()
+ self.frameEvent.clear()
+ except Exception as e:
+ _logger.critical('Error in camera process')
+ _logger.critical(e)
+ try:
+ if(len(self.frame)==1):
+ if(self.frame==-1):
+ self.errorSignal.emit('Failed to get signal')
+ elif(self.__enableDetection is True):
+ if(self.__endstopDetectionActive is True):
+ if(self.__endstopAutomatedDetectionActive is False):
+ self.analyzeEndstopFrame()
+ else:
+ self.burstEndstopDetection()
+ elif(self.__nozzleDetectionActive is True):
+ if(self.__nozzleAutoDetectionActive is False):
+ self.analyzeNozzleFrame()
+ else:
+ self.burstNozzleDetection()
+ else:
+ pass
+ self.receivedFrame(self.frame)
+ except Exception as e:
+ _logger.critical('Camera failed to retrieve data.')
+ _logger.critical(e)
+ _logger.critical('Critical camera error. Please restart TAMV.')
+ self.errorSignal.emit('Critical camera error. Please restart TAMV.')
+
+ # convert from cv2.mat to QPixmap and return results (frame+keypoint)
+ def receivedFrame(self, frame):
+ self.__counter += 1
+ if(self.__running):
+ rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ h, w, ch = rgb_image.shape
+ bytes_per_line = ch * w
+ convert_to_Qt_format = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888)
+ qpixmap = QPixmap.fromImage(convert_to_Qt_format)
+ try:
+ retObject = []
+ retObject.append(qpixmap)
+ self.detectionManagerNewFrameSignal.emit(retObject)
+ except:
+ raise SystemExit('Fatal error in Detection Manager.')
+
+ @pyqtSlot()
+ def sendUVCoorindates(self):
+ self.detectionManagerUVCoordinatesSignal.emit(self.__uv)
+
+ @pyqtSlot(bool)
+ def enableDetection(self, state=False):
+ self.__enableDetection = state
+
+
+ ##### Endstop detection
+ def analyzeEndstopFrame(self):
+ detectionCount = 0
+ self.uv = [None, None]
+ average_location=[0,0]
+ retries = 0
+ while(detectionCount < 5):
+ (self.__uv, self.frame) = self.endstopContourDetection(self.frame)
+ if(self.__uv is not None):
+ if(self.__uv[0] is not None and self.__uv[1] is not None):
+ average_location[0] += np.around(self.__uv[0],0)
+ average_location[1] += np.around(self.__uv[1],0)
+ detectionCount += 1
+ else:
+ retries += 1
+ else:
+ retries += 1
+ if(retries > 3):
+ average_location[0] = None
+ average_location[1] = None
+ break
+ if(average_location[0] is not None):
+ # calculate average X Y position from detection
+ average_location[0] /= detectionCount
+ average_location[1] /= detectionCount
+ # round to 0 decimal places
+ average_location = np.around(average_location,3)
+ self.__uv = average_location
+ else:
+ self.__uv = [None,None]
+
+ @pyqtSlot(int)
+ def burstEndstopDetection(self):
+ detectionCount = 0
+ self.uv = [0, 0]
+ average_location=[0,0]
+ retries = 0
+ while(detectionCount < 5):
+ # for j in range(10):
+ # self.frameEvent.set()
+ # self.frame = self.pipeDM.recv()
+ # self.frameEvent.clear()
+ (self.__uv, self.frame) = self.endstopContourDetection(self.frame)
+ if(self.__uv is not None):
+ if(self.__uv[0] is not None and self.__uv[1] is not None):
+ average_location[0] += self.__uv[0]
+ average_location[1] += self.__uv[1]
+ detectionCount += 1
+ else:
+ retries += 1
+ else:
+ retries += 1
+ if(retries > 5):
+ average_location[0] = None
+ average_location[1] = None
+ break
+ if(average_location[0] is not None):
+ # calculate average X Y position from detection
+ average_location[0] /= detectionCount
+ average_location[1] /= detectionCount
+ # round to 0 decimal places
+ average_location = np.around(average_location,0)
+ self.__uv = average_location
+ else:
+ self.__uv = None
+
+ def endstopContourDetection(self, detectFrame):
+ center = (None, None)
+ if(self.__endstopAutomatedDetectionActive is True):
+ # apply endstop detection algorithm
+ usedFrame = copy.deepcopy(detectFrame)
+ yuv = cv2.cvtColor(usedFrame, cv2.COLOR_BGR2YUV)
+ yuvPlanes = cv2.split(yuv)
+ still = yuvPlanes[0]
+ black = np.zeros((still.shape[0],still.shape[1]), np.uint8)
+ kernel = np.ones((5,5),np.uint8)
+ img_blur = cv2.GaussianBlur(still, (7, 7), 3)
+ img_canny = cv2.Canny(img_blur, 50, 190)
+ img_dilate = cv2.morphologyEx(img_canny, cv2.MORPH_DILATE, kernel, iterations=2)
+ cnt, hierarchy = cv2.findContours(img_dilate, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
+ black = cv2.drawContours(black, cnt, -1, (255, 0, 255), -1)
+ black = cv2.morphologyEx(black, cv2.MORPH_DILATE, kernel, iterations=2)
+ cnt2, hierarchy2 = cv2.findContours(black, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
+ black = cv2.cvtColor(black, cv2.COLOR_GRAY2BGR)
+ # detectFrame = black
+ if len(cnt2) > 0:
+ myContours = []
+ for k in range(len(cnt2)):
+ if hierarchy2[0][k][3] > -1:
+ myContours.append(cnt2[k])
+ if len(myContours) > 0:
+ # return only the biggest detected contour
+ blobContours = max(myContours, key=lambda el: cv2.contourArea(el))
+ contourArea = cv2.contourArea(blobContours)
+ if( len(blobContours) > 0 and contourArea >= 43000 and contourArea < 50000):
+ M = cv2.moments(blobContours)
+ center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"]))
+ detectFrame = cv2.circle(detectFrame, center, 150, (255,0,0), 5,lineType=cv2.LINE_AA)
+ detectFrame = cv2.circle(detectFrame, center, 5, (255,0,255), 2,lineType=cv2.LINE_AA)
+
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320,480), color=(0,0,0), horizontal=False, segmentWidth=4, lineWidth=2)
+ detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(640,240), color=(0,0,0), horizontal=True, segmentWidth=4, lineWidth=2)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320,480), horizontal=False, segmentWidth=4, lineWidth=1)
+ detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(640,240), horizontal=True, segmentWidth=4, lineWidth=1)
+
+ else:
+ # draw crosshair
+ keypointRadius = 57
+ width = 4
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320, 240-keypointRadius), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,240+keypointRadius), end=(320,480), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,0), end=(320, 240-keypointRadius), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320,240+keypointRadius), end=(320,480), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width)
+
+ detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(320-keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320+keypointRadius,240), end=(640,240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(0,240), end=(320-keypointRadius, 240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width)
+ detectFrame = self.dashedLine(image=detectFrame, start=(320+keypointRadius,240), end=(640,240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width)
+
+ detectFrame = cv2.circle(img=detectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA)
+ detectFrame = cv2.circle(img=detectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA)
+ return(center,detectFrame)
+
+ @pyqtSlot(bool)
+ def toggleEndstopDetection(self, endstopDetectFlag):
+ if(endstopDetectFlag is True):
+ self.__endstopDetectionActive = True
+ else:
+ self.__endstopDetectionActive = False
+
+ @pyqtSlot(bool)
+ def toggleEndstopAutoDetection(self, endstopDetectFlag):
+ if(endstopDetectFlag is True):
+ self.__endstopDetectionActive = True
+ self.__endstopAutomatedDetectionActive = True
+ else:
+ self.__endstopDetectionActive = False
+ self.__endstopAutomatedDetectionActive = False
+ self.__algorithm = None
+
+
+ def dashedLine(self, image, start, end, color=(255,255,255), segmentWidth=10, horizontal=True, lineWidth=1):
+ if(horizontal):
+ segments = int((end[0] - start[0])/segmentWidth)
+ else:
+ segments = int((end[1] - start[1])/segmentWidth)
+ for i in range(segments):
+ if(horizontal):
+ segmentStart = (start[0] + segmentWidth*i, start[1])
+ segmentEnd = (segmentStart[0]+segmentWidth, segmentStart[1])
+ else:
+ segmentStart = (start[0], start[1] + segmentWidth*i)
+ segmentEnd = (segmentStart[0], segmentStart[1]+segmentWidth)
+ if(i%2 == 0):
+ image = cv2.line( image, segmentStart, segmentEnd, color, lineWidth)
+ return(image)
+
+ ##### Nozzle detection
+ def analyzeNozzleFrame(self):
+ detectionCount = 0
+ self.uv = [None, None]
+ average_location=[0,0]
+ retries = 0
+ self.__uv = [None,None]
+ (self.__uv, self.frame) = self.nozzleDetection()
+ # draw crosshair
+ keypointRadius = 17
+ width = 4
+ self.frame = self.dashedLine(image=self.frame, start=(320,0), end=(320, 240-keypointRadius), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(320,240+keypointRadius), end=(320,480), color=(0,0,0), horizontal=False, lineWidth=2, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(320,0), end=(320, 240-keypointRadius), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(320,240+keypointRadius), end=(320,480), color=(255,255,255), horizontal=False, lineWidth=1, segmentWidth=width)
+
+ self.frame = self.dashedLine(image=self.frame, start=(0,240), end=(320-keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(320+keypointRadius,240), end=(640,240), color=(0,0,0), horizontal=True, lineWidth=2, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(0,240), end=(320-keypointRadius, 240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width)
+ self.frame = self.dashedLine(image=self.frame, start=(320+keypointRadius,240), end=(640,240), color=(255,255,255), horizontal=True, lineWidth=1, segmentWidth=width)
+
+ self.frame = self.dashedLine(image=self.frame, start=(320-keypointRadius,240), end=(320+keypointRadius, 240), color=(0,0,0), horizontal=True, lineWidth=1, segmentWidth=1)
+ self.frame = self.dashedLine(image=self.frame, start=(320,240-keypointRadius), end=(320, 240+keypointRadius), color=(0,0,0), horizontal=False, lineWidth=1, segmentWidth=1)
+
+ self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA)
+ self.frame = cv2.circle(img=self.frame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA)
+
+ def burstNozzleDetection(self):
+ detectionCount = 0
+ self.uv = [None, None]
+ average_location=[0,0]
+ retries = 0
+ while(detectionCount < 3):
+ # skip a few frames
+ for i in range(1):
+ self.frameEvent.set()
+ self.frame = self.pipeDM.recv()
+ self.frameEvent.clear()
+ (self.__uv, self.frame) = self.nozzleDetection()
+ if(self.__uv is not None):
+ if(self.__uv[0] is not None and self.__uv[1] is not None):
+ average_location[0] += self.__uv[0]
+ average_location[1] += self.__uv[1]
+ detectionCount += 1
+ else:
+ retries += 1
+ else:
+ retries += 1
+ if(retries > 5):
+ average_location[0] = None
+ average_location[1] = None
+ break
+ if(average_location[0] is not None):
+ # calculate average X Y position from detection
+ average_location[0] /= detectionCount
+ average_location[1] /= detectionCount
+ # round to 0 decimal places
+ average_location = np.around(average_location,0)
+ self.__uv = average_location
+ else:
+ self.__uv = None
+
+ def nozzleDetection(self):
+ # working frame object
+ nozzleDetectFrame = copy.deepcopy(self.frame)
+ # return value for keypoints
+ keypoints = None
+ center = (None, None)
+ # check which algorithm worked previously
+ if(self.__algorithm is None):
+ preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0)
+ preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1)
+
+ # apply combo 1 (standard detector, preprocessor 0)
+ keypoints = self.detector.detect(preprocessorImage0)
+ keypointColor = (0,0,255)
+ if(len(keypoints) != 1):
+ # apply combo 2 (standard detector, preprocessor 1)
+ keypoints = self.detector.detect(preprocessorImage1)
+ keypointColor = (0,255,0)
+ if(len(keypoints) != 1):
+ # apply combo 3 (standard detector, preprocessor 0)
+ keypoints = self.relaxedDetector.detect(preprocessorImage0)
+ keypointColor = (255,0,0)
+ if(len(keypoints) != 1):
+ # apply combo 4 (standard detector, preprocessor 1)
+ keypoints = self.relaxedDetector.detect(preprocessorImage1)
+ keypointColor = (39,127,255)
+ if(len(keypoints) != 1):
+ # failed to detect a nozzle, correct return value object
+ keypoints = None
+ else:
+ self.__algorithm = 4
+ else:
+ self.__algorithm = 3
+ else:
+ self.__algorithm = 2
+ else:
+ self.__algorithm = 1
+ elif(self.__algorithm == 1):
+ preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0)
+ keypoints = self.detector.detect(preprocessorImage0)
+ keypointColor = (0,0,255)
+ elif(self.__algorithm == 2):
+ preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1)
+ keypoints = self.detector.detect(preprocessorImage1)
+ keypointColor = (0,255,0)
+ elif(self.__algorithm == 3):
+ preprocessorImage0 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=0)
+ keypoints = self.relaxedDetector.detect(preprocessorImage0)
+ keypointColor = (255,0,0)
+ else:
+ preprocessorImage1 = self.preprocessImage(frameInput=nozzleDetectFrame, algorithm=1)
+ keypoints = self.relaxedDetector.detect(preprocessorImage1)
+ keypointColor = (39,127,255)
+ # process keypoint
+ if(keypoints is not None and len(keypoints) >= 1):
+ # create center object
+ (x,y) = np.around(keypoints[0].pt)
+ x,y = int(x), int(y)
+ center = (x,y)
+ # create radius object
+ keypointRadius = np.around(keypoints[0].size/2)
+ keypointRadius = int(keypointRadius)
+ circleFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius,color=keypointColor,thickness=-1,lineType=cv2.LINE_AA)
+ nozzleDetectFrame = cv2.addWeighted(circleFrame, 0.4, nozzleDetectFrame, 0.6, 0)
+ nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=center, radius=keypointRadius, color=(0,0,0), thickness=1,lineType=cv2.LINE_AA)
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x-5,y), (x+5, y), (255,255,255), 2)
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (x,y-5), (x, y+5), (255,255,255), 2)
+ elif(self.__nozzleAutoDetectionActive is True):
+ # no keypoints, draw a 3 outline circle in the middle of the frame
+ keypointRadius = 17
+ nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius, color=(0,0,0), thickness=3,lineType=cv2.LINE_AA)
+ nozzleDetectFrame = cv2.circle(img=nozzleDetectFrame, center=(320,240), radius=keypointRadius+1, color=(0,0,255), thickness=1,lineType=cv2.LINE_AA)
+ center = (None, None)
+ if(self.__nozzleAutoDetectionActive is True):
+ # draw crosshair
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (0,0,0), 2)
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (0,0,0), 2)
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (320,0), (320,480), (255,255,255), 1)
+ nozzleDetectFrame = cv2.line(nozzleDetectFrame, (0,240), (640,240), (255,255,255), 1)
+ return(center, nozzleDetectFrame)
+
+ @pyqtSlot(bool)
+ def toggleNozzleDetection(self, nozzleDetectFlag):
+ if(nozzleDetectFlag is True):
+ self.__nozzleDetectionActive = True
+ else:
+ self.__nozzleDetectionActive = False
+
+ @pyqtSlot(bool)
+ def toggleNozzleAutoDetection(self, nozzleDetectFlag):
+ if(nozzleDetectFlag is True):
+ self.__nozzleDetectionActive = True
+ self.__nozzleAutoDetectionActive = True
+ self.__algorithm = None
+ else:
+ self.__nozzleDetectionActive = False
+ self.__nozzleAutoDetectionActive = False
+
+ ##### Utilities
+ # adjust image gamma
+ def adjust_gamma(self, image, gamma=1.2):
+ # build a lookup table mapping the pixel values [0, 255] to
+ # their adjusted gamma values
+ invGamma = 1.0 / gamma
+ table = np.array([((i / 255.0) ** invGamma) * 255
+ for i in np.arange(0, 256)]).astype( 'uint8' )
+ # apply gamma correction using the lookup table
+ return cv2.LUT(image, table)
+
+ # Image detection preprocessors
+ def preprocessImage(self, frameInput, algorithm=0):
+ try:
+ outputFrame = self.adjust_gamma(image=frameInput, gamma=1.2)
+ except: outputFrame = copy.deepcopy(frameInput)
+ if(algorithm == 0):
+ yuv = cv2.cvtColor(outputFrame, cv2.COLOR_BGR2YUV)
+ yuvPlanes = cv2.split(yuv)
+ yuvPlanes[0] = cv2.GaussianBlur(yuvPlanes[0],(7,7),6)
+ yuvPlanes[0] = cv2.adaptiveThreshold(yuvPlanes[0],255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,35,1)
+ outputFrame = cv2.cvtColor(yuvPlanes[0],cv2.COLOR_GRAY2BGR)
+ elif(algorithm == 1):
+ outputFrame = cv2.cvtColor(outputFrame, cv2.COLOR_BGR2GRAY )
+ thr_val, outputFrame = cv2.threshold(outputFrame, 127, 255, cv2.THRESH_BINARY|cv2.THRESH_TRIANGLE )
+ outputFrame = cv2.GaussianBlur( outputFrame, (7,7), 6 )
+ outputFrame = cv2.cvtColor( outputFrame, cv2.COLOR_GRAY2BGR )
+ return(outputFrame)
+
+ ##### Image adjustment properties
+ @pyqtSlot(object)
+ def getImageProperties(self, imageSettings):
+ try:
+ brightness = imageSettings['brightness']
+ except KeyError: brightness = None
+ try:
+ contrast = imageSettings['contrast']
+ except KeyError: contrast = None
+ try:
+ staturation = imageSettings['saturation']
+ except KeyError: staturation = None
+ try:
+ hue = imageSettings['hue']
+ except KeyError: hue = None
+ if(imageSettings['default']==1):
+ self.__brightnessDefault = brightness
+ self.__contrastDefault = contrast
+ self.__saturationDefault = staturation
+ self.__hueDefault = hue
+ else:
+ self.__brightness = brightness
+ self.__contrast = contrast
+ self.__saturation = staturation
+ self.__hue = hue
+
+ @pyqtSlot(object)
+ def relayImageProperties(self, imageProperties):
+ self.pipeDM.send(imageProperties)
+
+ @pyqtSlot()
+ def relayResetImage(self):
+ settings = {'brightness': self.__brightnessDefault, 'contrast': self.__contrastDefault, 'saturation': self.__saturationDefault, 'hue': self.__hueDefault}
+ self.pipeDM.send(settings)
+
+# Independent process to run camera grab functions
+def _reader(q, frameEvent, stopEvent, videoSrc, height, width, backend):
+ cap = cv2.VideoCapture(videoSrc, backend)
+ cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
+ cap.setExceptionMode(enable=True)
+ try:
+ ret = cap.grab()
+ ret, frame = cap.retrieve()
+ if(not ret):
+ cap.release()
+ raise SystemError('Camera failure.')
+ # Get camera default settings
+ brightness = cap.get(cv2.CAP_PROP_BRIGHTNESS)
+ contrast = cap.get(cv2.CAP_PROP_CONTRAST)
+ saturation = cap.get(cv2.CAP_PROP_SATURATION)
+ hue = cap.get(cv2.CAP_PROP_HUE)
+ cameraSettings = {'default': 1, 'brightness': brightness, 'contrast': contrast, 'saturation': saturation, 'hue': hue}
+ # send default settings to queue
+ q.send(cameraSettings)
+ except Exception as e:
+ cap.release()
+ _logger.critical('Camera failed:' + str(e))
+ stopEvent.set()
+ FPS = 1/30
+ while True:
+ try:
+ ret = cap.grab()
+ except: break
+ if not ret:
+ break
+ if stopEvent.is_set():
+ break
+ if frameEvent.is_set():
+ ret, frame = cap.retrieve()
+ q.send(frame)
+ # check for inputs
+ if(q.poll(FPS/2)):
+ settings = q.recv()
+ try:
+ brightness = float(settings['brightness'])
+ except KeyError: pass
+ try:
+ contrast = float(settings['contrast'])
+ except KeyError: pass
+ try:
+ saturation = float(settings['saturation'])
+ except KeyError: pass
+ try:
+ hue = float(settings['hue'])
+ except KeyError: pass
+ try:
+ cap.set(cv2.CAP_PROP_BRIGHTNESS, brightness)
+ cap.set(cv2.CAP_PROP_CONTRAST, contrast)
+ cap.set(cv2.CAP_PROP_SATURATION, saturation)
+ cap.set(cv2.CAP_PROP_HUE, hue)
+ except:
+ _logger.warning('Failed to set image properties')
+ sleep(FPS)
+ cap.release()
+ q.send(-1)
+ q.close()
\ No newline at end of file
diff --git a/modules/PrinterManager.py b/modules/PrinterManager.py
new file mode 100644
index 0000000..3a4bcd6
--- /dev/null
+++ b/modules/PrinterManager.py
@@ -0,0 +1,477 @@
+import logging
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.PrinterManager')
+
+from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer
+import sys, time
+import importlib, importlib.util
+import numpy as np
+
+class PrinterManager(QObject):
+ # class attributes
+ __printerJSON = None
+ __activePrinter = None
+ __status = None
+ __homed = False
+ __defaultSpeed = 3000
+ __firmwareList = []
+
+ # signals
+ updateStatusbarSignal = pyqtSignal(object)
+ printerDisconnectedSignal = pyqtSignal(object)
+ printerIdleSignal = pyqtSignal()
+ printerHomedSignal = pyqtSignal()
+ moveCompleteSignal = pyqtSignal()
+ toolLoadedSignal = pyqtSignal()
+ toolIndexSignal = pyqtSignal(int)
+ activePrinterSignal = pyqtSignal(object)
+ coordinatesSignal = pyqtSignal(object)
+ offsetsSetSignal = pyqtSignal(object)
+ firmwareSavedSignal = pyqtSignal()
+ errorSignal = pyqtSignal(object)
+
+
+ # init function
+ def __init__(self, *args, **kwargs):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.__init__')
+
+ try:
+ self.__parent = kwargs['parent']
+ super(PrinterManager, self).__init__(parent=kwargs['parent'])
+ except KeyError:
+ self.__print = None
+ super().__init__()
+ try:
+ self.__firmwareList = kwargs['firmwareList']
+ except KeyError:
+ errorMsg = 'Cannot load master firmware list.'
+ self.updateStatusbarSignal.emit(errorMsg)
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+ try:
+ self.__driverList = kwargs['driverList']
+ except KeyError:
+ errorMsg = 'Cannot load master driver list.'
+ self.updateStatusbarSignal.emit(errorMsg)
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+ try:
+ self.__announce = kwargs['announcemode']
+ except KeyError: self.__announce = True
+
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.__init__')
+ pass
+
+ def quit(self):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.quit')
+ if(self.__announce):
+ _logger.info('Shutting down Printer Manager..')
+ if( self.__printerJSON is not None):
+ _logger.info(' .. disconnecting printer..')
+ self.disconnectPrinter({'noUpdate':True})
+ if( self.__printerJSON is not None):
+ if(self.__announce):
+ _logger.info(' Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')')
+ self.__printerJSON = None
+ if(self.__announce):
+ _logger.info('Printer Manager shut down successfully.')
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.quit')
+
+ # Connections
+ @pyqtSlot(object)
+ def connectPrinter(self, printer):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.connectPrinter')
+ if(self.__activePrinter is not None):
+ self.disconnectPrinter()
+ # parse input JSON
+ self.__printerJSON = printer
+
+ # start connect process
+ self.updateStatusbarSignal.emit('Attempting to connect to: ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')')
+ if(self.__announce):
+ _logger.info('Connecting to printer at ' + self.__printerJSON['address'] + '..')
+
+ # Attempt connecting to the controller
+ try:
+ # get active profile controller
+ activeDriver = self.__printerJSON['controller']
+ _logger.debug("Active controller: " + activeDriver)
+ _logger.debug(self.__firmwareList)
+ _logger.debug(self.__driverList)
+ firmwareSelect = None
+ driverSelect = None
+ for i, item in enumerate(self.__firmwareList):
+ if(item == activeDriver):
+ firmwareSelect = item
+ driverSelect = self.__driverList[i]
+ _logger.debug(' .. active driver: ' + driverSelect)
+ break
+ if(firmwareSelect is None or driverSelect is None):
+ # Firmware not found in master firmware list
+ errorMsg = 'Cannot load driver for this printer'
+ raise Exception(errorMsg)
+ # load active driver
+ spec = importlib.util.spec_from_file_location("printerAPI","./drivers/"+driverSelect)
+ driverModule = importlib.util.module_from_spec(spec)
+ sys.modules[driverSelect[:-3]] = driverModule
+ spec.loader.exec_module(driverModule)
+ try:
+ self.__activePrinter = driverModule.printerAPI(self.__printerJSON['address'])
+ if(not self.__activePrinter.isHomed()):
+ # Machine has not been homed yet
+ errorMsg = 'Printer axis not homed. Rectify and reconnect.'
+ self.errorSignal.emit(errorMsg)
+ return
+ if(not self.__activePrinter.isIdle()):
+ # connection failed for some reason
+ errorMsg = 'Device either did not respond or is not a supported controller type.'
+ self.errorSignal.emit(errorMsg)
+ return
+ else:
+ # connection succeeded, update objects accordingly
+ if(self.__announce):
+ _logger.info(' .. fetching tool information..')
+ # Send printer JSON to parent thread
+ activePrinter = self.__activePrinter.getJSON()
+ activePrinter['nickname'] = self.__printerJSON['nickname']
+ activePrinter['default'] = self.__printerJSON['default']
+ activePrinter['currentTool'] = self.__activePrinter.getCurrentTool()
+ successMsg = 'Connected to ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')'
+ if(self.__announce):
+ _logger.info(' ' + successMsg)
+ self.updateStatusbarSignal.emit(successMsg)
+ self.activePrinterSignal.emit(activePrinter)
+ except Exception as e:
+ # errorMsg = self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')' + ' is offline.'
+ errorMsg = str(e)
+ self.errorSignal.emit(errorMsg)
+ return
+ except Exception as e:
+ # Cannot connect to printer
+ _logger.exception(str(e))
+ self.errorSignal.emit(str(e))
+ return
+
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.connectPrinter')
+
+ @pyqtSlot(object)
+ def disconnectPrinter(self, kwargs={}):
+ try:
+ parkPosition = kwargs['parkPosition']
+ except KeyError:
+ parkPosition = None
+ try:
+ noUpdate = kwargs['noUpdate']
+ except KeyError: noUpdate = False
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.disconnectPrinter')
+ if(self.__activePrinter is not None):
+ try:
+ try:
+ self.__activePrinter.flushMovementBuffer()
+ if(self.__announce):
+ _logger.info(' .. unloading tools..')
+ self.__activePrinter.unloadTools()
+ except:
+ errorMsg = 'Could not unload tools.'
+ raise Exception(errorMsg)
+ # move to final park position
+ if(parkPosition is not None):
+ if(self.__announce):
+ _logger.info(' .. restoring position..')
+ self.complexMoveAbsolute(position=parkPosition)
+
+ if(noUpdate is False):
+ # notify parent thread
+ successMsg = 'Disconnected from ' + self.__printerJSON['nickname'] + ' (' + self.__printerJSON['controller']+ ')'
+ if(self.__announce):
+ _logger.info(successMsg)
+ self.printerDisconnectedSignal.emit(successMsg)
+ else:
+ self.printerDisconnectedSignal.emit(None)
+ except Exception as e:
+ _logger.exception(e)
+ self.errorSignal.emit(str(e))
+ return
+
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.disconnectPrinter')
+
+ @pyqtSlot(bool)
+ def setAnnounceMode(self, announceFlag=True):
+ self.__announce = announceFlag
+
+ # Movements and Coordinates
+ def complexMoveAbsolute(self, position=None, moveSpeed=None):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.complexMoveAbsolute')
+ if(position is None):
+ return
+ if(moveSpeed is None):
+ moveSpeed = self.__defaultSpeed
+ # form coorindates from parameter
+ try:
+ xPos = position['X']
+ except KeyError: xPos = None
+ try:
+ yPos = position['Y']
+ except KeyError: yPos = None
+ try:
+ zPos = position['Z']
+ except KeyError: zPos = None
+ try:
+ rotated = self.__printerJSON['rotated']
+ except KeyError: rotated=0
+ try:
+ if( rotated == 1):
+ # move Y -> X -> Z
+ if(yPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Y=yPos)
+ if(xPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos)
+ if(zPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Z=zPos)
+ else:
+ # move X -> Y -> Z
+ if(xPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos)
+ if(yPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Y=yPos)
+ if(zPos is not None): self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, Z=zPos)
+ except:
+ errorMsg = 'Error performing printer moves.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ raise Exception(errorMsg)
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.complexMoveAbsolute')
+
+ def complexMoveRelative(self, position=None, moveSpeed=None):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.complexMoveRelative')
+ if(position is None):
+ return
+ if(moveSpeed is None):
+ moveSpeed = self.__defaultSpeed
+ # form coorindates from parameter
+ try:
+ xPos = position['X']
+ except KeyError: xPos = None
+ try:
+ yPos = position['Y']
+ except KeyError: yPos = None
+ try:
+ zPos = position['Z']
+ except KeyError: zPos = None
+ try:
+ rotated = self.__printerJSON['rotated']
+ except KeyError: rotated=0
+ try:
+ if( rotated == 1):
+ # move Y -> X -> Z
+ if(yPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Y=yPos)
+ if(xPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos)
+ if(zPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Z=zPos)
+ else:
+ # move X -> Y -> Z
+ if(xPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos)
+ if(yPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Y=yPos)
+ if(zPos is not None): self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, Z=zPos)
+ except:
+ errorMsg = 'Error performing printer moves.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ raise Exception(errorMsg)
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.complexMoveRelative')
+
+ @pyqtSlot(object)
+ def moveRelative(self, params=None):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.moveRelative')
+ _logger.debug('Requesting a move to position: ' + str(params))
+ try:
+ position = params['position']
+ except KeyError: position = None
+ try:
+ moveSpeed = params['moveSpeed']
+ except KeyError: moveSpeed = self.__defaultSpeed
+ try:
+ protected = params['protected']
+ except KeyError: protected = False
+
+ if(position is None):
+ errorMsg = 'Move rel: Invalid position.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+ # form coorindates from parameter
+ try:
+ xPos = position['X']
+ except KeyError: xPos = 0
+ try:
+ yPos = position['Y']
+ except KeyError: yPos = 0
+ try:
+ zPos = position['Z']
+ except KeyError: zPos = 0
+ try:
+ if(protected):
+ self.complexMoveRelative(moveSpeed=moveSpeed, position={'X':xPos, 'Y': yPos, 'Z': zPos})
+ else:
+ self.__activePrinter.moveRelative(rapidMove=False, moveSpeed=moveSpeed, X=xPos, Y=yPos, Z=zPos)
+ self.moveCompleteSignal.emit()
+ except:
+ errorMsg = 'Error: moveRelative cannot run.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.moveRelative')
+
+ @pyqtSlot(object)
+ def moveAbsolute(self, params=None):
+ # send calling to log
+ _logger.debug('*** calling PrinterManager.moveAbsolute')
+ _logger.debug('Requesting a move to position: ' + str(params))
+ try:
+ position = params['position']
+ except KeyError: position = None
+ try:
+ moveSpeed = params['moveSpeed']
+ except KeyError: moveSpeed = self.__defaultSpeed
+ try:
+ protected = params['protected']
+ except KeyError: protected = False
+
+ if(position is None):
+ errorMsg = 'Move rel: Invalid position.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+
+ # form coorindates from parameter
+ try:
+ xPos = position['X']
+ except KeyError: xPos = None
+ try:
+ yPos = position['Y']
+ except KeyError: yPos = None
+ try:
+ zPos = position['Z']
+ except KeyError: zPos = None
+
+ try:
+ if(protected):
+ self.complexMoveAbsolute(moveSpeed=moveSpeed, position={'X':xPos, 'Y': yPos, 'Z': zPos})
+ else:
+ self.__activePrinter.moveAbsolute(rapidMove=False, moveSpeed=moveSpeed, X=xPos, Y=yPos, Z=zPos)
+ self.moveCompleteSignal.emit()
+ except:
+ errorMsg = 'Error: moveAbsolute cannot run.'
+ _logger.exception(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ return
+ # send exiting to log
+ _logger.debug('*** exiting PrinterManager.moveAbsolute')
+
+ @pyqtSlot()
+ def getCoordinates(self):
+ printerCoordinates = self.__activePrinter.getCoordinates()
+ self.coordinatesSignal.emit(printerCoordinates)
+
+ @pyqtSlot()
+ def saveOffsets(self):
+ self.__activePrinter.saveOffsetsToFirmware()
+
+ # Tools
+ @pyqtSlot()
+ def currentTool(self):
+ loadedToolIndex = self.__activePrinter.getCurrentTool()
+ self.toolIndexSignal.emit(int(loadedToolIndex))
+
+ @pyqtSlot(int)
+ def callTool(self, toolNumber=-1):
+ toolNumber = int(toolNumber)
+ try:
+ if(toolNumber > -1):
+ self.__activePrinter.loadTool(toolNumber)
+ else:
+ self.__activePrinter.unloadTools()
+ except:
+ raise Exception('PrinterManager: Unable to load tool: ' + str(toolNumber))
+ self.toolLoadedSignal.emit()
+
+ @pyqtSlot()
+ def unloadTools(self):
+ self.__activePrinter.unloadTools()
+ self.toolLoadedSignal.emit()
+
+ @pyqtSlot(object)
+ def calibrationSetOffset(self, params=None):
+ try:
+ toolIndex = int(params['toolIndex'])
+ except KeyError:
+ toolIndex = None
+ errorMsg = 'setOffset: Error in tool index'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ try:
+ position = params['position']
+ except KeyError:
+ position = None
+ errorMsg = 'setOffset: error in tool position'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ try:
+ cpCoordinates = params['cpCoordinates']
+ except KeyError:
+ errorMsg = 'setOffset: error in cpCoordinates'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ try:
+ __continue = params['continue']
+ except: __continue = True
+
+ if(toolIndex < 0 or toolIndex is None):
+ errorMsg = 'Invalid tool selected for offsets.'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ params = {'offsets': None}
+ self.offsetsSetSignal.emit(params)
+ return
+ elif(position is None or position['X'] is None or position['Y'] is None):
+ errorMsg = 'Invalid offsets passed to printer.'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ params = {'offsets': None}
+ self.offsetsSetSignal.emit(params)
+ return
+ elif(cpCoordinates is None):
+ errorMsg = 'Invalid CP coordinates passed to printer.'
+ _logger.error(errorMsg)
+ self.errorSignal.emit(errorMsg)
+ params = {'offsets': None}
+ self.offsetsSetSignal.emit(params)
+ return
+ try:
+ toolOffsets = self.__activePrinter.getToolOffset(toolIndex)
+ _logger.debug('calibrationSetOffset: Setting offsets for tool: ' + str(toolIndex))
+ finalOffsets = {}
+ finalOffsets['X'] = np.around(cpCoordinates['X'] + toolOffsets['X'] - position['X'],3)
+ finalOffsets['Y'] = np.around(cpCoordinates['Y'] + toolOffsets['Y'] - position['Y'],3)
+ finalOffsets['Z'] = np.around(toolOffsets['Z'],3)
+ self.__activePrinter.setToolOffsets(tool=toolIndex, X=finalOffsets['X'], Y=finalOffsets['Y'])
+ _logger.debug('X: ' + str(finalOffsets['X']) + ' Y:' + str(finalOffsets['Y']) + ' Z:' + str(finalOffsets['X']))
+ except Exception as e:
+ self.errorSignal.emit(str(e))
+ _logger.error('Other exception occurred in calibrationSetOffset')
+ _logger.exception(e)
+ params = {'offsets': None}
+ self.offsetsSetSignal.emit(params)
+ return
+ params = {'offsets': finalOffsets, 'continue': __continue}
+ self.offsetsSetSignal.emit(params)
\ No newline at end of file
diff --git a/modules/SettingsDialog.py b/modules/SettingsDialog.py
new file mode 100644
index 0000000..3d47493
--- /dev/null
+++ b/modules/SettingsDialog.py
@@ -0,0 +1,760 @@
+import logging
+from sys import stdout
+# invoke parent (TAMV) _logger
+_logger = logging.getLogger('TAMV.SettingsDialog')
+
+from cv2 import cvtColor, COLOR_BGR2RGB
+from PyQt5.QtWidgets import * #QDialog, QWidget, QVBoxLayout, QTabWidgetQ, QPushButton, QComboBox, QSlider, QLabel
+from PyQt5.QtCore import *
+import traceback, copy
+
+# Configuration settings dialog box
+class SettingsDialog(QDialog):
+ # Signals
+ settingsAlignmentPollSignal = pyqtSignal()
+ settingsChangeVideoSrcSignal = pyqtSignal(object)
+ settingsGeometrySignal = pyqtSignal(object)
+ settingsRequestCameraPropertiesSignal = pyqtSignal()
+ settingsRequestImagePropertiesSignal = pyqtSignal()
+ settingsResetImageSignal = pyqtSignal()
+ settingsSetImagePropertiesSignal = pyqtSignal(object)
+ settingsRequestDefaultImagePropertiesSignal = pyqtSignal()
+ settingsStatusbarSignal = pyqtSignal(object)
+ settingsUpdateSignal = pyqtSignal(object)
+ settingsNewPrinter = pyqtSignal(object)
+
+ def __init__(self, *args, **kwargs):
+ # send calling to log
+ _logger.debug('*** calling SettingsDialog.__init__')
+ try:
+ self.__parent = kwargs['parent']
+ except KeyError:
+ self.__parent = None
+ try:
+ self.__newPrinter = kwargs['newPrinter']
+ except KeyError:
+ self.__newPrinter = False
+ try:
+ self.__geometry = kwargs['geometry']
+ except KeyError:
+ self.__geometry = None
+ try:
+ self.__settings = copy.deepcopy(kwargs['settings'])
+ self.__originalSettingsObject = kwargs['settings']
+ except KeyError:
+ self.__settings = None
+ self.__originalSettingsObject = None
+ try:
+ self.__cameraProperties = kwargs['cameraProperties']
+ except KeyError:
+ self.__cameraProperties = None
+ try:
+ self.__firmwareList = kwargs['firmwareList']
+ except KeyError:
+ self.__firmwareList = None
+
+
+ # Set up settings window
+ super(SettingsDialog,self).__init__(parent=self.__parent)
+ self.setWindowFlag(Qt.WindowContextHelpButtonHint,False)
+ self.setWindowTitle('TAMV Configuration Settings')
+ # Restore geometry if available
+ if(self.__geometry is not None):
+ self.restoreGeometry(self.__geometry)
+ # Set layout details
+ self.layout = QVBoxLayout()
+ self.layout.setSpacing(3)
+ ############# TAB SETUP #############
+ # Create tabs layout
+ self.tabs = QTabWidget()
+ # Tab 1: Settings
+ self.settingsTab = QWidget()
+ self.settingsTab.layout = QVBoxLayout()
+ self.settingsTab.setLayout(self.settingsTab.layout)
+ # Tab 2: Cameras
+ self.camerasTab = QWidget()
+ self.camerasTab.layout = QVBoxLayout()
+ self.camerasTab.setLayout(self.camerasTab.layout)
+ # add tabs to tabs layout
+ self.tabs.addTab(self.settingsTab, 'Machines')
+ if(self.__newPrinter is False):
+ self.tabs.addTab(self.camerasTab, 'Cameras')
+ # Add tabs layout to window
+ self.layout.addWidget(self.tabs)
+ # apply layout
+ self.setLayout(self.layout)
+ ############# POPULATE TABS
+ # Create camera items
+ if(self.__newPrinter is False):
+ self.createCameraItems()
+ # Create machine items
+ self.createMachineItems()
+ ############# MAIN BUTTONS
+ # Save button
+ if(self.__newPrinter is False):
+ self.save_button = QPushButton('Save')
+ self.save_button.setToolTip('Save current parameters to settings.json file')
+ self.save_button.clicked.connect(self.updatePrinterObjects)
+ else:
+ self.save_button = QPushButton('Save and connect..')
+ self.save_button.clicked.connect(self.saveNewPrinter)
+ self.save_button.setObjectName('active')
+ # Close button
+ self.close_button = QPushButton('Cancel')
+ self.close_button.setToolTip('Cancel changes and return to main program.')
+ self.close_button.clicked.connect(self.cancelChanges)
+ self.close_button.setObjectName('terminate')
+ # WINDOW BUTTONS
+ self.layout.addWidget(self.save_button)
+ self.layout.addWidget(self.close_button)
+ # OK Cancel buttons
+ #self.layout.addWidget(self.buttonBox)
+ _logger.debug('*** exiting SettingsDialog.__init__')
+
+ def createCameraItems(self):
+ _logger.debug('*** calling SettingsDialog.createCameraItems')
+ ############# CAMERAS TAB #############
+ # Get current camera settings from video thread
+ try:
+ if(self.__cameraProperties['image'] is not None):
+ try:
+ brightness_input = self.__cameraProperties['image']['brightness']
+ except KeyError:
+ brightness_input = None
+ try:
+ contrast_input = self.__cameraProperties['image']['contrast']
+ except KeyError:
+ contrast_input = None
+ try:
+ saturation_input = self.__cameraProperties['image']['saturation']
+ except KeyError:
+ saturation_input = None
+ try:
+ hue_input = self.__cameraProperties['image']['hue']
+ except KeyError:
+ hue_input = None
+ except KeyError:
+ (brightness_input, contrast_input, saturation_input, hue_input) = (None, None, None, None)
+ except Exception:
+ errorMsg = 'Error fetching camera parameters.'
+ self.settingsStatusbarSignal.emit(errorMsg)
+ _logger.error('Error fetching camera parameters.\n' + traceback.format_exc())
+ ############# CAMERA TAB: ITEMS
+ # Camera Combobox
+ self.camera_combo = QComboBox()
+ for camera in self.__settings['camera']:
+ if(camera['default'] == 1):
+ camera_description = '* ' + str(camera['video_src']) + ': ' + str(camera['display_width']) + 'x' + str(camera['display_height'])
+ else:
+ camera_description = str(camera['video_src']) + ': ' + str(camera['display_width']) + 'x' + str(camera['display_height'])
+ self.camera_combo.addItem(camera_description)
+ #HBHBHBHB: TODO need to pass actual video source string object from parameter helper function!!!
+ #self.camera_combo.currentIndexChanged.connect(self.parent().video_thread.changeVideoSrc)
+
+ #HBHBHBHB TODO: write code to fetch cameras
+ # # Get cameras button
+ # self.camera_button = QPushButton('Get cameras')
+ # self.camera_button.clicked.connect(self.getCameras)
+ # if self.parent().video_thread.alignment:
+ # self.camera_button.setDisabled(True)
+ # else: self.camera_button.setDisabled(False)
+ #self.getCameras()
+ # cmbox.addWidget(self.camera_button)
+ #HBHBHBHB TODO: write code to fetch cameras
+
+ # Brightness slider
+ self.brightness_slider = QSlider(Qt.Horizontal)
+ self.brightness_slider.setMinimum(0)
+ self.brightness_slider.setMaximum(255)
+ self.brightness_slider.valueChanged.connect(self.changeBrightness)
+ self.brightness_slider.setTickPosition(QSlider.TicksBelow)
+ self.brightness_slider.setTickInterval(1)
+ if(brightness_input is not None):
+ self.brightness_label = QLabel(str(int(brightness_input)))
+ self.brightness_slider.setValue(int(brightness_input))
+ else:
+ self.brightness_label = QLabel()
+ self.brightness_slider.setEnabled(False)
+ # Contrast slider
+ self.contrast_slider = QSlider(Qt.Horizontal)
+ self.contrast_slider.setMinimum(0)
+ self.contrast_slider.setMaximum(255)
+ self.contrast_slider.valueChanged.connect(self.changeContrast)
+ self.contrast_slider.setTickPosition(QSlider.TicksBelow)
+ self.contrast_slider.setTickInterval(1)
+ if(contrast_input is not None):
+ self.contrast_label = QLabel(str(int(contrast_input)))
+ self.contrast_slider.setValue(int(contrast_input))
+ else:
+ self.contrast_label = QLabel()
+ self.contrast_slider.setEnabled(False)
+ # Saturation slider
+ self.saturation_slider = QSlider(Qt.Horizontal)
+ self.saturation_slider.setMinimum(0)
+ self.saturation_slider.setMaximum(255)
+ self.saturation_slider.valueChanged.connect(self.changeSaturation)
+ self.saturation_slider.setTickPosition(QSlider.TicksBelow)
+ self.saturation_slider.setTickInterval(1)
+ if(saturation_input is not None):
+ self.saturation_label = QLabel(str(int(saturation_input)))
+ self.saturation_slider.setValue(int(saturation_input))
+ else:
+ self.saturation_label = QLabel()
+ self.saturation_slider.setEnabled(False)
+ # Hue slider
+ self.hue_slider = QSlider(Qt.Horizontal)
+ self.hue_slider.setMinimum(0)
+ self.hue_slider.setMaximum(8)
+ self.hue_slider.valueChanged.connect(self.changeHue)
+ self.hue_slider.setTickPosition(QSlider.TicksBelow)
+ self.hue_slider.setTickInterval(1)
+ if(hue_input is not None):
+ self.hue_label = QLabel(str(int(hue_input)))
+ self.hue_slider.setValue(int(hue_input))
+ else:
+ self.hue_label = QLabel()
+ self.hue_slider.setEnabled(False)
+ # Reset button
+ self.reset_button = QPushButton("Reset to defaults")
+ self.reset_button.setToolTip('Reset camera settings to defaults.')
+ self.reset_button.clicked.connect(self.resetCameraToDefaults)
+ if(brightness_input is None and contrast_input is None and saturation_input is None and hue_input is None):
+ self.reset_button.setDisabled(True)
+ # Camera drop-down
+ self.camera_box = QGroupBox('Active camera source')
+ self.camerasTab.layout.addWidget(self.camera_box)
+ cmbox = QHBoxLayout()
+ self.camera_box.setLayout(cmbox)
+ cmbox.addWidget(self.camera_combo)
+ # Brightness
+ self.brightness_box =QGroupBox('Brightness')
+ self.camerasTab.layout.addWidget(self.brightness_box)
+ bvbox = QHBoxLayout()
+ self.brightness_box.setLayout(bvbox)
+ bvbox.addWidget(self.brightness_slider)
+ bvbox.addWidget(self.brightness_label)
+ # Contrast
+ self.contrast_box =QGroupBox('Contrast')
+ self.camerasTab.layout.addWidget(self.contrast_box)
+ cvbox = QHBoxLayout()
+ self.contrast_box.setLayout(cvbox)
+ cvbox.addWidget(self.contrast_slider)
+ cvbox.addWidget(self.contrast_label)
+ # Saturation
+ self.saturation_box =QGroupBox('Saturation')
+ self.camerasTab.layout.addWidget(self.saturation_box)
+ svbox = QHBoxLayout()
+ self.saturation_box.setLayout(svbox)
+ svbox.addWidget(self.saturation_slider)
+ svbox.addWidget(self.saturation_label)
+ # Hue
+ self.hue_box =QGroupBox('Hue')
+ self.camerasTab.layout.addWidget(self.hue_box)
+ hvbox = QHBoxLayout()
+ self.hue_box.setLayout(hvbox)
+ hvbox.addWidget(self.hue_slider)
+ hvbox.addWidget(self.hue_label)
+ _logger.debug('*** exiting SettingsDialog.createCameraItems')
+
+ def createMachineItems(self):
+ _logger.debug('*** calling SettingsDialog.createMachineItems')
+ ############# MACHINES TAB #############
+ if(self.__newPrinter is False):
+ # Get machines as defined in the config
+ # Printer combo box
+ self.printer_combo = QComboBox()
+ self.default_printer = None
+ self.defaultIndex = 0
+ for i, device in enumerate(self.__settings['printer']):
+ if(device['default'] == 1):
+ printer_description = '(default) ' + device['nickname']
+ self.default_printer = device
+ self.defaultIndex = i
+ else:
+ printer_description = device['nickname']
+ self.printer_combo.addItem(printer_description)
+ # set default printer as the selected index
+ self.printer_combo.setCurrentIndex(self.defaultIndex)
+ if(self.default_printer is None):
+ self.default_printer = self.__settings['printer'][0]
+ # Create a layout for the printer combo box, and the add and delete buttons
+ topbox = QGroupBox()
+ toplayout = QHBoxLayout()
+ topbox.setLayout(toplayout)
+ toplayout.addWidget(self.printer_combo)
+ # Add button
+ self.add_printer_button = QPushButton('+')
+ self.add_printer_button.setStyleSheet('background-color: green')
+ self.add_printer_button.clicked.connect(self.addProfile)
+ self.add_printer_button.setToolTip('Add a new profile..')
+ self.add_printer_button.setFixedWidth(30)
+ toplayout.addWidget(self.add_printer_button)
+ # Delete button
+ self.delete_printer_button = QPushButton('X')
+ self.delete_printer_button.setStyleSheet('background-color: red')
+ self.delete_printer_button.clicked.connect(self.deleteProfile)
+ self.delete_printer_button.setToolTip('Delete current profile..')
+ self.delete_printer_button.setFixedWidth(30)
+ toplayout.addWidget(self.delete_printer_button)
+ # add printer combo box to layout
+ self.settingsTab.layout.addWidget(topbox)
+ # Printer default checkbox
+ self.printerDefault = QCheckBox("&Default", self)
+ if(self.__newPrinter is False):
+ self.printerDefault.setChecked(True)
+ self.printerDefault.stateChanged.connect(self.checkDefaults)
+ # Printer nickname
+ if(self.__newPrinter is False):
+ self.printerNickname = QLineEdit(self.default_printer['nickname'])
+ else:
+ self.printerNickname = QLineEdit()
+ self.printerNickname.setPlaceholderText('Enter an alias for your printer')
+ self.printerNickname_label = QLabel('Nickname: ')
+ self.printerNickname_box =QGroupBox()
+ self.settingsTab.layout.addWidget(self.printerNickname_box)
+ nnbox = QHBoxLayout()
+ self.printerNickname_box.setLayout(nnbox)
+ nnbox.addWidget(self.printerNickname_label)
+ nnbox.addWidget(self.printerNickname)
+ nnbox.addWidget(self.printerDefault)
+ # Printer address
+ if(self.__newPrinter is False):
+ self.printerAddress = QLineEdit(self.default_printer['address'])
+ else:
+ self.printerAddress = QLineEdit()
+ self.printerAddress.setPlaceholderText('Enter printer interface or IP')
+ self.printerAddress_label = QLabel('Address: ')
+ self.printerAddress_box =QGroupBox()
+ self.settingsTab.layout.addWidget(self.printerAddress_box)
+ adbox = QHBoxLayout()
+ self.printerAddress_box.setLayout(adbox)
+ adbox.addWidget(self.printerAddress_label)
+ adbox.addWidget(self.printerAddress)
+ # Printer password
+ if(self.__newPrinter is False):
+ self.printerPassword = QLineEdit(self.default_printer['password'])
+ else:
+ self.printerPassword = QLineEdit()
+ self.printerPassword.setPlaceholderText('Password')
+ self.printerPassword.setToolTip('(optional): password used to connect to printer')
+ self.printerPassword_label = QLabel('Password: ')
+ adbox.addWidget(self.printerPassword_label)
+ adbox.addWidget(self.printerPassword)
+ # Printer controller
+ self.controllerName = QComboBox()
+ self.controllerName.setToolTip('Machine firmware family/category')
+ self.controllerName.setMinimumWidth(180)
+ # get controller index from master list
+ for item in self.__firmwareList:
+ self.controllerName.addItem(item)
+ if(self.__newPrinter is False):
+ # get controller index from master list
+ listIndex = -1
+ for i, item in enumerate(self.__firmwareList):
+ if(item == self.default_printer['controller']):
+ listIndex = i
+ break
+ if(listIndex > -1):
+ self.controllerName.setCurrentIndex(listIndex)
+ else:
+ _logger.error('Controller name not found for combobox.')
+ else:
+ # new printer, default to RRF/Duet
+ self.controllerName.setCurrentIndex(0)
+ self.controllerName_label = QLabel('Controller Type: ')
+ self.controllerName_box =QGroupBox()
+ self.settingsTab.layout.addWidget(self.controllerName_box)
+ cnbox = QGridLayout()
+ cnbox.setSpacing(5)
+ self.controllerName_box.setLayout(cnbox)
+ cnbox.addWidget(self.controllerName_label, 0, 0, 1, 1, Qt.AlignRight)
+ cnbox.addWidget(self.controllerName, 0, 1, 1, 2, Qt.AlignLeft)
+ # Printer with rotated XY kinematics
+ self.printerRotated = QCheckBox('Rotate XY')
+ if(self.__newPrinter is True):
+ self.printerRotated.setChecked(True)
+ else:
+ try:
+ if(self.default_printer['rotated'] == 1):
+ self.printerRotated.setChecked(True)
+ else:
+ self.printerRotated.setChecked(False)
+ except KeyError:
+ # rotated key doesn't exist, add to printer model
+ self.default_printer['rotated'] = 0
+ self.printerRotated.setChecked(False)
+ cnbox.addWidget(self.printerRotated, 0, 3, 1, 1, Qt.AlignRight)
+ # Printer name
+ if(self.__newPrinter is False):
+ self.printerName = QLineEdit(self.default_printer['name'])
+ else:
+ self.printerName = QLineEdit()
+ self.printerName.setPlaceholderText('(pulled from machine..)')
+ self.printerName.setStyleSheet('font: italic')
+ self.printerName.setEnabled(False)
+ self.printerName_label = QLabel('Name: ')
+ self.printerName_box =QGroupBox()
+ self.settingsTab.layout.addWidget(self.printerName_box)
+ if(self.__newPrinter is True):
+ self.printerName_box.setVisible(False)
+ pnbox = QHBoxLayout()
+ self.printerName_box.setLayout(pnbox)
+ pnbox.addWidget(self.printerName_label)
+ pnbox.addWidget(self.printerName)
+ # Printer firmware version identifier
+ if(self.__newPrinter is False):
+ self.versionName = QLineEdit(self.default_printer['version'])
+ else:
+ self.versionName = QLineEdit()
+ self.versionName.setPlaceholderText("(pulled from machine..)")
+ self.versionName.setStyleSheet('font: italic')
+ self.versionName.setEnabled(False)
+ self.versionName_label = QLabel('Firmware version: ')
+ self.versionName_box =QGroupBox()
+ self.settingsTab.layout.addWidget(self.versionName_box)
+ if(self.__newPrinter is True):
+ self.versionName_box.setVisible(False)
+ fnbox = QHBoxLayout()
+ self.versionName_box.setLayout(fnbox)
+ fnbox.addWidget(self.versionName_label)
+ fnbox.addWidget(self.versionName)
+ if(self.__newPrinter is False):
+ # handle selecting a new machine from the dropdown
+ self.printer_combo.activated.connect(self.refreshPrinters)
+ self.printerAddress.editingFinished.connect(self.updateAttributes)
+ self.printerPassword.editingFinished.connect(self.updateAttributes)
+ self.printerName.editingFinished.connect(self.updateAttributes)
+ self.printerNickname.editingFinished.connect(self.updateAttributes)
+ self.controllerName.activated.connect(self.updateAttributes)
+ self.versionName.editingFinished.connect(self.updateAttributes)
+ self.printerDefault.stateChanged.connect(self.updateAttributes)
+ self.printerRotated.stateChanged.connect(self.updateAttributes)
+ _logger.debug('*** exiting SettingsDialog.createMachineItems')
+
+ def checkDefaults(self):
+ _logger.debug('*** calling SettingsDialog.checkDefaults')
+ if(self.printerDefault.isChecked()):
+ index = self.printer_combo.currentIndex()
+ for i,machine in enumerate(self.__settings['printer']):
+ machine['default'] = 0
+ self.printer_combo.setItemText(i, self.__settings['printer'][i]['nickname'])
+ self.__settings['printer'][index]['default']=1
+ self.printer_combo.setItemText(index,'(default) ' + self.__settings['printer'][index]['nickname'])
+ else:
+ # User de-selected default machine
+ index = self.printer_combo.currentIndex()
+ if(index > -1):
+ self.printer_combo.setItemText(self.printer_combo.currentIndex(),self.__settings['printer'][self.printer_combo.currentIndex()]['nickname'])
+ _logger.debug('*** exiting SettingsDialog.checkDefaults')
+
+ def addProfile(self):
+ _logger.debug('*** calling SettingsDialog.addProfile')
+ # Create a new printer profile object
+ newPrinter = {
+ 'address': '',
+ 'password': 'repap',
+ 'name': '',
+ 'nickname': 'New printer..',
+ 'controller' : 'RRF/Duet',
+ 'version': '',
+ 'default': 0,
+ 'rotated': 0,
+ 'tools': [
+ {
+ 'number': 0,
+ 'name': 'Tool 0',
+ 'nozzleSize': 0.4,
+ 'offsets': [0,0,0]
+ } ]
+ }
+ # Add new profile to settingsObject list
+ self.__settings['printer'].append(newPrinter)
+ # enable all text fields
+ self.printerDefault.setDisabled(False)
+ self.printerAddress.setDisabled(False)
+ self.printerPassword.setDisabled(False)
+ self.printerNickname.setDisabled(False)
+ self.controllerName.setDisabled(False)
+ self.printerRotated.setDisabled(False)
+ self.delete_printer_button.setDisabled(False)
+ self.delete_printer_button.setStyleSheet('background-color: red')
+ # update combobox
+ self.printer_combo.addItem('New printer..')
+ self.printer_combo.setCurrentIndex(len(self.__settings['printer'])-1)
+ self.refreshPrinters(self.printer_combo.currentIndex())
+ _logger.debug('*** exiting SettingsDialog.addProfile')
+
+ def deleteProfile(self):
+ _logger.debug('*** calling SettingsDialog.deleteProfile')
+ # check if this is the "Add printer.." option which is always at the bottom of the list.
+ finalIndex = self.printer_combo.count() - 1
+ index = self.printer_combo.currentIndex()
+ if(self.__settings['printer'][index]['default'] == 1):
+ wasDefault = True
+ else:
+ wasDefault = False
+ del self.__settings['printer'][index]
+ self.printer_combo.removeItem(index)
+ index = self.printer_combo.currentIndex()
+ if(index > -1 and len(self.__settings['printer']) > 0):
+ if(wasDefault):
+ self.__settings['printer'][0]['default'] = 1
+ self.refreshPrinters(self.printer_combo.currentIndex())
+ else:
+ # no more profiles found, display empty fields
+ self.printerDefault.setChecked(False)
+ self.printerAddress.setText('')
+ self.printerPassword.setText('')
+ self.printerName.setText('')
+ self.printerNickname.setText('')
+ self.controllerName.setCurrentIndex(0)
+ self.versionName.setText('')
+ self.printerRotated.setChecked(False)
+ # disable all fields
+ self.printerDefault.setDisabled(True)
+ self.printerAddress.setDisabled(True)
+ self.printerPassword.setDisabled(True)
+ self.printerName.setDisabled(True)
+ self.printerNickname.setDisabled(True)
+ self.controllerName.setDisabled(True)
+ self.versionName.setDisabled(True)
+ self.printerRotated.setDisabled(True)
+ self.printer_combo.addItem('+++ Add a new profile --->')
+ self.printer_combo.setCurrentIndex(0)
+ self.delete_printer_button.setDisabled(True)
+ self.delete_printer_button.setStyleSheet('background-color: none')
+ _logger.debug('*** exiting SettingsDialog.deleteProfile')
+
+ def refreshPrinters(self, index):
+ _logger.debug('*** calling SettingsDialog.refreshPrinters')
+ if(index >= 0):
+ if(len(self.__settings['printer'][index]['address']) > 0):
+ self.printerAddress.setText(self.__settings['printer'][index]['address'])
+ else:
+ self.printerAddress.clear()
+ if(len(self.__settings['printer'][index]['password']) > 0):
+ self.printerPassword.setText(self.__settings['printer'][index]['password'])
+ else:
+ self.printerPassword.clear()
+ if(len(self.__settings['printer'][index]['name']) > 0):
+ self.printerName.setText(self.__settings['printer'][index]['name'])
+ else:
+ self.printerName.clear()
+ if(len(self.__settings['printer'][index]['nickname']) > 0):
+ self.printerNickname.setText(self.__settings['printer'][index]['nickname'])
+ else:
+ self.printerNickname.clear()
+ # get controller index from master list
+ listIndex = -1
+ for i, item in enumerate(self.__firmwareList):
+ if(item == self.default_printer['controller']):
+ listIndex = i
+ break
+ if(listIndex > -1):
+ self.controllerName.setCurrentIndex(listIndex)
+ else:
+ _logger.error('Controller name not found for combobox.')
+ if(len(self.__settings['printer'][index]['version']) > 0):
+ self.versionName.setText(self.__settings['printer'][index]['version'])
+ else:
+ self.versionName.clear()
+ if(self.__settings['printer'][index]['default'] == 1):
+ self.printerDefault.setChecked(True)
+ else:
+ self.printerDefault.setChecked(False)
+ if(self.__settings['printer'][index]['rotated'] == 1):
+ self.printerRotated.setChecked(True)
+ else:
+ self.printerRotated.setChecked(False)
+ _logger.debug('*** exiting SettingsDialog.refreshPrinters')
+
+ def updateAttributes(self):
+ _logger.debug('*** calling SettingsDialog.updateAttributes')
+ index = self.printer_combo.currentIndex()
+ if(index > -1):
+ self.__settings['printer'][index]['address'] = self.printerAddress.text()
+ self.__settings['printer'][index]['password'] = self.printerPassword.text()
+ self.__settings['printer'][index]['name'] = self.printerName.text()
+ self.__settings['printer'][index]['nickname'] = self.printerNickname.text()
+ self.__settings['printer'][index]['controller'] = self.controllerName.itemText(self.controllerName.currentIndex())
+ self.__settings['printer'][index]['version'] = self.versionName.text()
+ if(self.printerDefault.isChecked()):
+ self.__settings['printer'][index]['default'] = 1
+ else:
+ self.__settings['printer'][index]['default'] = 0
+ if(self.printerRotated.isChecked()):
+ self.__settings['printer'][index]['rotated'] = 1
+ else:
+ self.__settings['printer'][index]['rotated'] = 0
+ _logger.debug('*** exiting SettingsDialog.updateAttributes')
+
+ def resetCameraToDefaults(self):
+ _logger.debug('*** calling SettingsDialog.resetCameraToDefaults')
+ self.settingsResetImageSignal.emit()
+
+ _logger.debug('*** exiting SettingsDialog.resetCameraToDefaults')
+
+ def changeBrightness(self):
+ _logger.debug('*** calling SettingsDialog.changeBrightness')
+ parameter = int(self.brightness_slider.value())
+ message = {'brightness': parameter}
+ self.settingsSetImagePropertiesSignal.emit(message)
+ self.brightness_label.setText(str(parameter))
+ _logger.debug('*** exiting SettingsDialog.changeBrightness')
+
+ def changeContrast(self):
+ _logger.debug('*** calling SettingsDialog.changeContrast')
+ parameter = int(self.contrast_slider.value())
+ message = {'contrast': parameter}
+ self.settingsSetImagePropertiesSignal.emit(message)
+ self.contrast_label.setText(str(parameter))
+ _logger.debug('*** exiting SettingsDialog.changeContrast')
+
+ def changeSaturation(self):
+ _logger.debug('*** calling SettingsDialog.changeSaturation')
+ parameter = int(self.saturation_slider.value())
+ message = {'saturation': parameter}
+ self.settingsSetImagePropertiesSignal.emit(message)
+ self.saturation_label.setText(str(parameter))
+ _logger.debug('*** exiting SettingsDialog.changeSaturation')
+
+ def changeHue(self):
+ _logger.debug('*** calling SettingsDialog.changeHue')
+ parameter = int(self.hue_slider.value())
+ message = {'hue': parameter}
+ self.settingsSetImagePropertiesSignal.emit(message)
+ self.hue_label.setText(str(parameter))
+ _logger.debug('*** exiting SettingsDialog.changeHue')
+
+ def getCameras(self):
+ _logger.debug('*** calling SettingsDialog.getCameras')
+ #HBHBHBHB: TODO handle multiple camera profiles
+ # # checks the first 6 indexes.
+ # i = 6
+ # index = 0
+ # self.camera_combo.clear()
+ # _cameras = []
+ # #HBHBHBHB DEBUG camera description
+ # original_camera_description = '* ' + str(camera['video_src'])
+ # _cameras.append(original_camera_description)
+ # tempCap = cv2.VideoCapture()
+ # tempCap.setExceptionMode(True)
+ # while i > 0:
+ # if index != self.parent()._videoSrc:
+ # try:
+ # tempCap.open(index)
+ # if tempCap.read()[0]:
+ # api = tempCap.getBackendName()
+ # camera_description = str(index) + ': ' \
+ # + str(int(tempCap.get(cv2.CAP_PROP_FRAME_WIDTH))) \
+ # + 'x' + str(int(tempCap.get(cv2.CAP_PROP_FRAME_HEIGHT))) + ' @ ' \
+ # + str(int(tempCap.get(cv2.CAP_PROP_FPS))) + 'fps'
+ # _cameras.append(camera_description)
+ # tempCap.release()
+ # except:
+ # index += 1
+ # i -= 1
+ # continue
+ # index += 1
+ # i -= 1
+ # #cameras = [line for line in allOutputs if float(line['propmode']) > -1 ]
+ # _cameras.sort()
+ # for camera in _cameras:
+ # self.camera_combo.addItem(camera)
+ # self.camera_combo.setCurrentText(original_camera_description)
+ _logger.debug('*** exiting SettingsDialog.getCameras')
+
+ def updatePrinterObjects(self):
+ _logger.debug('*** calling SettingsDialog.updatePrinterObjects')
+ defaultSet = False
+ multipleDefaults = False
+ defaultMessage = "More than one connection is set as the default option.\n\nPlease review the connections for:\n\n"
+ # Do some data cleaning
+ for machine in self.__settings['printer']:
+ # Check if user forgot a nickname, default to printer address
+ if(machine['nickname'] is None or machine['nickname'] == ""):
+ machine['nickname'] = machine['address']
+ # Do some checking to catch multiple default printers set at the same time
+ if(machine['default'] == 1):
+ defaultMessage += " - " + machine['nickname'] + "\n"
+ if(defaultSet):
+ multipleDefaults = True
+ else:
+ defaultSet = True
+ # More than one profile is set as the default. Alert user, don't save, and return to the settings screen
+ if(multipleDefaults):
+ msgBox = QMessageBox()
+ msgBox.setIcon(QMessageBox.Warning)
+ msgBox.setText(defaultMessage)
+ msgBox.setWindowTitle('ERROR: Too many default connections')
+ msgBox.setStandardButtons(QMessageBox.Ok)
+ msgBox.exec()
+ return
+ # No default printed was defined, so set first item to default
+ if(defaultSet is False and len(self.__settings['printer']) > 0):
+ self.__settings['printer'][0]['default'] = 1
+ elif(len(self.__settings['printer']) == 0):
+ # All profiles have been cleared. Add a dummy template
+ self.__settings['printer'] = [
+ {
+ 'address': 'http://localhost',
+ 'password': 'reprap',
+ 'name': '',
+ 'nickname': 'Default profile',
+ 'controller' : 'RRF/Duet',
+ 'version': '',
+ 'default': 1,
+ 'rotated': 0,
+ 'tools': [
+ {
+ 'number': 0,
+ 'name': 'Tool 0',
+ 'nozzleSize': 0.4,
+ 'offsets': [0,0,0]
+ } ]
+ }
+ ]
+ pass
+ self.settingsUpdateSignal.emit(self.__settings)
+ _logger.debug('*** exiting SettingsDialog.updatePrinterObjects')
+ self.close()
+
+ def saveNewPrinter(self):
+ _logger.debug('*** calling SettingsDialog.saveNewPrinter')
+ _logger.info('Saving printer information..')
+ newPrinter = {
+ 'address': self.printerAddress.text(),
+ 'password': self.printerPassword.text(),
+ 'name': '',
+ 'nickname': self.printerNickname.text(),
+ 'controller' : str(self.controllerName.currentText()),
+ 'version': '',
+ 'default': int(self.printerDefault.isChecked()),
+ 'rotated': int(self.printerRotated.isChecked()),
+ 'tools': [
+ {
+ 'number': 0,
+ 'name': 'Tool 0',
+ 'nozzleSize': 0.4,
+ 'offsets': [0,0,0]
+ } ]
+ }
+ if(self.printerDefault.isChecked()):
+ # new default printer, clear other objects
+ for machine in self.__settings['printer']:
+ machine['default'] = 0
+ self.__settings['printer'].append(newPrinter)
+ self.settingsNewPrinter.emit(self.__settings)
+ _logger.debug('*** exiting SettingsDialog.saveNewPrinter')
+ self.close()
+
+ def cancelChanges(self):
+ _logger.debug('*** calling SettingsDialog.cancelChanges')
+ self.settingsStatusbarSignal.emit('Changes to settings discarded.')
+ self.__settings = self.__originalSettingsObject
+ self.settingsGeometrySignal.emit(self.saveGeometry())
+ self.close()
+
+ def closeEvent(self, event):
+ self.settingsGeometrySignal.emit(self.saveGeometry())
+ super().closeEvent(event)
diff --git a/modules/StatusTipFilter.py b/modules/StatusTipFilter.py
new file mode 100644
index 0000000..05ced9d
--- /dev/null
+++ b/modules/StatusTipFilter.py
@@ -0,0 +1,8 @@
+from PyQt5 import QtCore, QtGui
+
+
+class StatusTipFilter(QtCore.QObject):
+ def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
+ if isinstance(event, QtGui.QStatusTipEvent):
+ return True
+ return super().eventFilter(watched, event)
\ No newline at end of file
diff --git a/modules/__init__.py b/modules/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/plot.py b/plot.py
deleted file mode 100755
index bee5be5..0000000
--- a/plot.py
+++ /dev/null
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3
-# Copyright (C) 2021 Haytham Bennani all rights reserved.
-import os
-import json
-import argparse
-import matplotlib.pyplot as plt
-import matplotlib.cm as cm
-import matplotlib.patches as patches
-from matplotlib.ticker import FormatStrFormatter
-import matplotlib
-import numpy as np
-import math
-
-def init():
- parser = argparse.ArgumentParser(description='Program to allign multiple tools on Duet based printers, using machine vision.', allow_abbrev=False)
- parser.add_argument('-filename',type=str,nargs=1,default=['./output.json'],help='Path and filename of data file to process for plotting. Can be a relative or an absolute path. Default is \"./output.json\".')
-
- args=vars(parser.parse_args())
- inputFilename = args['filename'][0]
- matplotlib.use('Qt5Agg',force=True)
- return( inputFilename )
-
-def loadDataFile( filename="./output.json" ):
- try:
- with open(filename,"r") as text_file:
- data = text_file.read().replace('\n', '')
- result = json.loads(data)
- print( str(filename) + " has been loaded.")
- return result
- except OSError:
- print( 'Error opening file: \"' + str(filename) + '\"')
- return -1
-
-def parseData( rawData ):
- # create empty output array
- toolDataResult = []
- # get number of tools
- _numTools = np.max([ int(line['tool']) for line in rawData ]) + 1
- _cycles = np.max([ int(line['cycle']) for line in rawData ])
-
- for i in range(_numTools):
- x = [float(line['X']) for line in rawData if int(line['tool']) == i]
- y = [float(line['Y']) for line in rawData if int(line['tool']) == i]
- # variable to hold return data coordinates per tool formatted as a 2D array [x_value, y_value]
- tempPairs = []
-
- # calculate stats
- # mean values
- x_mean = np.around(np.mean(x),3)
- y_mean = np.around(np.mean(y),3)
- # median values
- x_median = np.around(np.median(x),3)
- y_median = np.around(np.median(y),3)
- # ranges (max - min per axis)
- x_range = np.around(np.max(x) - np.min(x),3)
- y_range = np.around(np.max(y) - np.min(y),3)
- # standard deviations
- x_sig = np.around(np.std(x),3)
- y_sig = np.around(np.std(y),3)
-
- # normalize data around mean
- x -= x_mean
- y -= y_mean
-
- # temporary object to append coordinate pairs into return value
- tempPairs.append(x)
- tempPairs.append(y)
-
- # add data to return object
- toolDataResult.append(tempPairs)
-
- # display summary data to terminal
- print( 'Summary for Tool '+str(i) +':' )
- print( ' Xmean: {0:6.3f} Ymean: {1:7.3f}'.format(x_mean,y_mean))
- print( ' Xmedian: {0:6.3f} Ymedian: {1:7.3f}'.format(x_median,y_median))
- print( ' Xsigma: {0:7.3f} Ysigma: {1:7.3f}'.format(x_sig,y_sig))
- print( ' Xrange: {0:7.3f} Yrange: {1:7.3f}'.format(x_range,y_range))
- print('')
-
- # return dataset
- return ( _numTools, _cycles, toolDataResult )
-
-def main():
- # parse command line arguments
- dataFilename = init()
-
- # set up color and colormap arrays
- colorMap = ["Greens","Oranges","Blues", "Reds"] #["Blues", "Reds","Greens","Oranges"]
- colors = ['blue','red','green','orange']
-
- # attempt to load data file
- j=loadDataFile(dataFilename)
- # handle file not opened errors and exit
- if j == -1:
- return
-
- # get data as 3 dimensional array [tool][axis][datapoints] normalized around mean of each axis
- ( numTools, totalRuns, toolData ) = parseData(j)
- # data file has been loaded, proceed with processing the data
- print('')
- print( "Found data for {} tools".format(numTools) + " and " + str(totalRuns) + " alignment cycles.")
- print('')
-
- # initiate graph data - 1 tool per column
- # Row 0: scatter plot with standard deviation box
- # Row 1: histogram of X axis data
- # Row 2: histogram of Y axis data
- plt.switch_backend('QT4Agg')
-
- fig, axes = plt.subplots(ncols=3,nrows=numTools,constrained_layout=False)
-
-
- for i, data in enumerate(toolData):
- # create a color array the length of the number of tools in the data
- color = np.arange(len(data[0]))
-
- # Axis formatting
- # Major ticks
- axes[i][0].xaxis.set_major_formatter(FormatStrFormatter('%.3f'))
- axes[i][0].yaxis.set_major_formatter(FormatStrFormatter('%.3f'))
- # Minor ticks
- axes[i][0].xaxis.set_minor_formatter(FormatStrFormatter('%.3f'))
- axes[i][0].yaxis.set_minor_formatter(FormatStrFormatter('%.3f'))
- # Draw 0,0 lines
- axes[i][0].axhline()
- axes[i][0].axvline()
- # x&y std deviation box
- x_sigma = np.around(np.std(data[0]),3)
- y_sigma = np.around(np.std(data[1]),3)
- axes[i][0].add_patch(patches.Rectangle((-1*x_sigma,-1*y_sigma), 2*x_sigma, 2*y_sigma, color="green",fill=False, linestyle='dotted'))
- axes[i][0].add_patch(patches.Rectangle((-2*x_sigma,-2*y_sigma), 4*x_sigma, 4*y_sigma, color="red",fill=False, linestyle='-.'))
-
- # scatter plot for tool data
- axes[i][0].scatter(data[0], data[1], c=color, cmap=colorMap[i])
- axes[i][0].autoscale = True
-
- # Histogram data setup
- # Calculate number of bins per axis
- x_intervals = int(np.around(math.sqrt(len(data[0])),0)+1)
- y_intervals = int(np.around(math.sqrt(len(data[1])),0)+1)
-
- # plot histograms
- x_kwargs = dict(alpha=0.5, bins=x_intervals,rwidth=.92, density=True)
- n, bins, hist_patches = axes[i][1].hist([data[0],data[1]],**x_kwargs, color=[colors[0],colors[1]], label=['X','Y'])
- axes[i][2].hist2d(data[0], data[1], bins=x_intervals, cmap='Blues')
- axes[i][1].legend()
-
-
- # add a 'best fit' line
- # calculate mean and std deviation per axis
- x_mean = np.mean(data[0])
- y_mean = np.mean(data[1])
- x_sigma = np.around(np.std(data[0]),3)
- y_sigma = np.around(np.std(data[1]),3)
- # calculate function lines for best fit
- x_best = ((1 / (np.sqrt(2 * np.pi) * x_sigma)) *
- np.exp(-0.5 * (1 / x_sigma * (bins - x_mean))**2))
- y_best = ((1 / (np.sqrt(2 * np.pi) * y_sigma)) *
- np.exp(-0.5 * (1 / y_sigma * (bins - y_mean))**2))
- # add best fit line to plots
- axes[i][1].plot(bins, x_best, '-.',color=colors[0])
- axes[i][1].plot(bins, y_best, '--',color=colors[1])
-
- x_count = int(sum( p == True for p in ((data[0] >= (x_mean - x_sigma)) & (data[0] <= (x_mean + x_sigma))) )/len(data[0])*100)
- y_count = int(sum( p == True for p in ((data[1] >= (y_mean - y_sigma)) & (data[1] <= (y_mean + y_sigma))) )/len(data[1])*100)
- # annotate std dev values
- annotation_text = "Xσ: " + str(x_sigma) + " ("+str(x_count) + "%)"
- if x_count < 68:
- x_count = int(sum( p == True for p in ((data[0] >= (x_mean - 2*x_sigma)) & (data[0] <= (x_mean + 2*x_sigma))) )/len(data[0])*100)
- annotation_text += " --> 2σ: " + str(x_count) + "%"
- if x_count < 95 and x_sigma*2 > 0.1:
- annotation_text += " -- check axis!"
- else: annotation_text += " -- OK"
- annotation_text += "\nYσ: " + str(y_sigma) + " ("+str(y_count) + "%)"
- if y_count < 68:
- y_count = int(sum( p == True for p in ((data[1] >= (y_mean - 2*y_sigma)) & (data[1] <= (y_mean + 2*y_sigma))) )/len(data[1])*100)
- annotation_text += " --> 2σ: " + str(y_count) + "%"
- if y_count < 95 and y_sigma*2 > 0.1:
- annotation_text += " -- check axis!"
- else: annotation_text += " -- OK"
- axes[i][0].annotate(annotation_text, (10,10),xycoords='axes pixels')
- axes[i][0].annotate('σ',(1.1*x_sigma,-1.1*y_sigma),xycoords='data',color='green')
- axes[i][0].annotate('2σ',(1.1*2*x_sigma,-1.1*2*y_sigma),xycoords='data',color='red')
- # # place title for graph
- axes[i][0].set_ylabel("Tool " + str(i) + "\nY")
- axes[i][0].set_xlabel("X")
- axes[i][2].set_ylabel("Y")
- axes[i][2].set_xlabel("X")
-
- if i == 0:
- axes[i][0].set_title('Scatter Plot')
- axes[i][1].set_title('Histogram')
- axes[i][2].set_title('2D Histogram')
- plt.tight_layout()
- figManager = plt.get_current_fig_manager()
- figManager.window.showMaximized()
- plt.show()
-
-
-
-if __name__ == "__main__":
- main()
- exit()
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index fef1982..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-certifi==2020.12.5
-chardet==4.0.0
-idna==2.10
-numpy>=1.21
-opencv-python==4.5.1.48
-PyQt5==5.15.3
-PyQt5-Qt==5.15.2
-PyQt5-sip==12.8.1
-pyqtgraph==0.11.1
-requests==2.25.1
-urllib3>= 1.26.4
-wincertstore==0.2
-imutils>=0.5
-matplotlib>=3.2
diff --git a/resources/background.png b/resources/background.png
new file mode 100644
index 0000000..9f5e498
Binary files /dev/null and b/resources/background.png differ
diff --git a/resources/error.png b/resources/error.png
new file mode 100644
index 0000000..6397e83
Binary files /dev/null and b/resources/error.png differ
diff --git a/jubilee.png b/resources/jubilee.png
similarity index 100%
rename from jubilee.png
rename to resources/jubilee.png
diff --git a/resources/nozzle.jpg b/resources/nozzle.jpg
new file mode 100644
index 0000000..c4564ad
Binary files /dev/null and b/resources/nozzle.jpg differ
diff --git a/resources/standby.jpg b/resources/standby.jpg
new file mode 100644
index 0000000..c236139
Binary files /dev/null and b/resources/standby.jpg differ
diff --git a/video_cap.png b/resources/video_cap.png
similarity index 100%
rename from video_cap.png
rename to resources/video_cap.png