From 1b95a6d7133f124736017ff1406fae45a1f1f224 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 28 Dec 2021 18:54:25 -0500 Subject: [PATCH] initial stab at sending G code with offset and rotated data, need to test out with actual machine --- __pycache__/callbackloghandler.cpython-39.pyc | Bin 0 -> 1859 bytes __pycache__/gcode_machine.cpython-39.pyc | Bin 0 -> 16214 bytes __pycache__/gerbil.cpython-39.pyc | Bin 0 -> 31500 bytes __pycache__/interface.cpython-39.pyc | Bin 0 -> 4555 bytes callbackloghandler.py | 38 + gcode_machine.py | 844 ++++++++++++ gerbil.py | 1160 +++++++++++++++++ interface.py | 118 ++ realWorldGcodeSender.py | 141 +- 9 files changed, 2275 insertions(+), 26 deletions(-) create mode 100644 __pycache__/callbackloghandler.cpython-39.pyc create mode 100644 __pycache__/gcode_machine.cpython-39.pyc create mode 100644 __pycache__/gerbil.cpython-39.pyc create mode 100644 __pycache__/interface.cpython-39.pyc create mode 100644 callbackloghandler.py create mode 100644 gcode_machine.py create mode 100644 gerbil.py create mode 100644 interface.py diff --git a/__pycache__/callbackloghandler.cpython-39.pyc b/__pycache__/callbackloghandler.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa17083315e8286c4744914650435c2d2df6be20 GIT binary patch literal 1859 zcmZuy%Wm676dh8sfuy_qNxy;HuKEjI^;}9$-6n)Np2xlC+;c{{iud;ohUfQR|A>FvW$aH{ z+X)A4Cv$QR=r~32Ow3X@s>+HlPQjCF6vn=Od@~%98 zrn1Gd;`fsdfB4nc-|%skEMt-LzKV;>+%P6Wt+KMrWWlqNFNG4bXMBOfN~9*AD;T}9|qfu!;WcyBo%SavjS*>-yHFatXj%-#g!;4l_i8>A}Gn%Db;Xu zoM)?S!(dQ)qrare*CiBCohDz&G@H|Z(a1Vq&+@EXnmo-2dbX|*S<+@A3S=43@jxnG ziaaNT40ShJ|E}rjT3-%NoX~BB^MHDxb?d zmlq^0kwu!3-tv)QggA<4@)yx)*<5cSE0nq^fi9paVsoq8qjDLe_LWS0{_tFf!7aDE#Kx&z$d=<*y-6lerN?)ADaBs35VWE$PwXN zZg|GMKDXR6{)6N8Om6=;@$DcmJfAz`$;h#>=eXU`NzZWy{1|zz7xIxac0z!L9w)*r zlw$`3Hn#ol5XjcCGjhT+)95>)3ryYzsKqCiA3EKWk>&Hri9hiI8}vQEx{llT!D5eX zH#`C>_PG5R3my!u(Wud8ouGOjJU;JwlQZ8L3`0KjMm-yw$2Kanjz{)}3Zl9r%Nd)z zXN|1^Rp%QX3ig|@O?CWqXg6Env+&mq9nU2hUC#}DEKR8NLn3TaPo2Ov4a;`|GNkW& zfG0bVL7usk=h_<-vbr(o&5a<03_F47H?{O^YXmG@kNcnCBjYbL7Um{2-hQa@&{KYa zL9nTY=A);(FW4LnC~aNeMZ;;QJJ)R5e!<#ox`XC(r+&YS57aD9ej3Te5MP;GC^yj? zpv^$@*7hww#-w7EcEz6SFZEYUV^^3*t&{;wb35&Nr(ENMtSGDAiz2j|DvIj8WXsag z>o&fT^IA`4ueJJa6eT&DO%%Ome-FBko(!_;i9#bel`2mMiA+Txid3kF@7#TI1Dngm za#Q>fx+gjDDc-WNL_5$fKYLr{(M@r0simwgrIV3Y>?ugcr94)W?dm@m)>>iwXCqSe;ix-HOZI=aGd2dj6LNTf;?{iJM$ z_h@#2;d8tt1u<=R=Rm*w=p7OoDA>MBRu}O~z-l9k&?(nB&F@9g&+9nfoHVvH(DnY- z_5{5#if*y*flf6g&;@>ixAAaKJ7_moiVMCIMfgcT);)^dr-5Q`fiz+Wq~7~>Q_6Q5 LRt-}7!#({Ui*X0O literal 0 HcmV?d00001 diff --git a/__pycache__/gcode_machine.cpython-39.pyc b/__pycache__/gcode_machine.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39d647ebdecc5258b9d7fac6361d90556938cd08 GIT binary patch literal 16214 zcmch8YiwL+dfqu_t{mQ}5lPXqd{&Yz4o!_NUaxIgE1Tj?w5gFwQudmXJs!??NDeie zp}upd%V?NQBcokxH;9vN(p%b6+n}ujG`#>x)AmnL6lqZ)z2xWlwMBtuU7$c(6ewT> zL7(S6XKp05zlzG9_q^Zz{ci8=ds*o1O&Rz%_QQWy`2H^##(!m^{a*x$_wjST1E7pm zLz&7do2#aLtyN3Dk=2NNqpPubLq$~d3qwVpS*vknEgP9w;f<(aq&A9G<>a>u#m!R1 zvCrGn)tx80w6W>gFO?nF9``mK`}A_y;&f&*g+Y{4DBGn9dUNbX zk^QLTZB}tTa<3d&2u2U|vhZ5+PxwfdAl(}LQtPREwmq^Ba zv{~KeOj6C6V2Rdubp=CWQ8?WymP|(6a*7^vNcnoTT&_Oi)QZ)LDsjH}pu#ZWciMniDRVqf%I2W;LdR+3l{m9yY#&wcn{~8SmgU zvl#!zti60|Zsp^trCEDn*}lDW>!XF4*%|xv)H2eiC+&|HR&Lz7vtpyh(o}Bc6Z_Vj zJ(c^!{%|2TGilF$@AlH{@^b3dlD%;A_Ts`U@)mN_i+5%ga`X0ew9DOEu@@I^F07!_ zm0LCk47*yGU1n!DXP2gLpy#RU3yTXYpG>Ca7FKfT)1E`8Q}*qtrIm&0JBw3G_U$`M zw{I=aV)z+!n_I}uEn$?|o3pu07rySz4IC zv0~r2wKy}2%rY%mliHN7~saC6e0nYuYOPwJLZw?OccR12uHKfW<5Sr~5$ z|4*+h+{$qp)3|=7P*E-9qQnt_ zn^FU65Z`V!q>ka+qtfa)zP)N#jo{m-M%4*?`_-6w3Eu&AQoW4tpgN^q!FLF}WD#eADWzdKKT}>NRx^-(fYbGWd?D3H3U@qiRx}$M=NFf_q-@d*;bGZw8FB@Sjb? zuvd7GO4~bShaAhb3$`mMdpb95%W88-Yo!_k}XC2}unDwENQKeK^E2FjWLadOmnUV({ zt88#uy8`A%&ciH6bL@SDcZU9$|sGxi~cpPO|fZz&5fQZfLebz>`rZ(KzH z69SV0yA;auDaF!m!FyC3c(1@dsnf3#$Qe*cz(LgoI8cTz@sSw=f0_$z|jGUm6M^-fFf8JXEx z)q~z%-QfIQLycbP>6|x?JKgupnlV0|*yFs<)uShjJ17hV3`#HO5A|}s*~{b$ds&j+hh+7$f*%w7g5YVvFA9EK@JoUZ3w~Mf z5y9UOd{ppn3w}cIHw7OP{5yibB=}o`pA`IU!Cw~q9l=itens$C1bTkxxbe@pQ1 zV*aOpVgjD|iK)*1CU^H5*3H^~Prb^OQgQH}*J>y1g$jgwp>G1m^X&c!e)#v(_i{M9?_89l4D8ZdUqy1coQ|MuHJaZjZhsZ=&F}f0WaW<{~DLw&;!I?Be+fQDZyt1 zX@cVfs|0rm1__1;juE_0aEX9w{MT<0yiM>9!4-mc0Ww{!LG>6i^azWR1YJy$1})>o zfMqP-ocGO}wNtmX6N(Q=iRm(w>ejJwROWp@QLb)mK(zQ>8;-XK+|hoVdH6=lV98Hn9O!4TK5QORY=F@zc!F1<|%kH7C!WT>sdC+=4E;{;<@O z8juU#CiL{pYFYWC9r>=aL4letI~Bjr-6>U6*~zbCfU5R;wDVvW+P%==_Y`-vW>tze zzZXg{%?&q59&Z#a{J5@Ko?OT-{KMA4aapFPdBR}cU2J%qcz#jSWpbI~)3#zp1`u$BJ!;B`GjhVVT zoXkM8UYPGkIS)Tcn1B4F=8tfp^CcJZ)!p5pI!+P4t^7o>y3GajhtU=6s&YQe$U{o5 zmmd4Q`NCtz&1+}FdA#HL-9eJV-}PfK>^2-fUf9`jD$2LCBmM2b67&1R^=PzQ?QLWP zZBr=7htujN4pk?QBZU!=z(PtQ$%Jg!Km)@U3tx1?iourq$D~!b|BV{`%xvGfL1uJr zaUZkVb~4U1cJPFJgT={1$8joHnF`LacT`bh#F6$^+}_6WrdhCE@QRyU_Izb`dkr^f zl(|?}sB?MfcCflIAQG-W^k}o>IXp$uh2%v`mGx>S?nhxbuCKkcISzo%;#Z}d-?o% z^yt=Ki`F#(M06~8K_D;T2?2!70f^szlseUx z`P_o^H0k%KWkk87v|MtiD#CW={At6AlHQW0xq$PM%ldttN^>Tr$*}bl!3+V3(PZ6U znOYO)1_j_DFkB>#_9|$1#k&)Yo56pzp^h0#wL{aTw2|qutp}BB3o73-#>!l3b!VKUgMZNc7|t$%OOiaI*wirnYC30)L=!wY4B|^AK^(qe4|9AP)Dyd~Sp{z^eUP^9C$d3vjpDaujUJ4_t-N zmlT?+@OgM}lm(uk!oxxdRM9r8*q@U{W!dQS)|F}Y{66?;VO#oa=1t*(>4q)aGG%!c zd5ozB`9qMP6&I%Pc7w0F;rWpA1Iq!K3kFOYt~b{t5jPJokw%@4vlGE+RG8#wb7-1w z*f%&W03**M`_bpdo>@2dW6FAN?pbv(l@mN+@ezagRX{M5XyF`&Zq9MJy6uGPx#3hC zn8BQI)4d7d?b&omo`V>WOXr+}LoJX9%}&at`|xnye8XP3d^#KW70BVsPiPabVas+z zzbb~l04`v&t#~1}9XLLf?e3IIa74Jf#myrJw0C!c4R;-GmlyZ~*jzVez!CUbk3VR64aB8u3^ZcA+t8x!SmXxcAN|} zYl#;d3EpO!^*9FWg@FdQ3k7PTYdxz;c8s8Lfy6*4_SXOsNbeD zZZ!|Cs8J`)+GNMU^-AhV_G-BbBh0;)J=FXg^WB99%6r1{-nR1JR8|H2 z9KH8}qTloS8})^vfw0A3TZ{kDY%zouKRYl7I^Nq}Gr65Kj*ac;q3 zrxGeAF0!|A4(YD5;3A92NK~o9nWNQECpBB?x^JIgQY*i7;w{L7M7-S@g#mPLlT1K_WmIijjdCpuRRxeuP`qt6ZvVg zk+HZKL_s_6D zl?eLfH!UM5+#s)6v> zmASyhB|fcgaw4pRHBcC0!dmqPONEd9h}d5tpkUIQ1Y|tAL@-ORML_*o z7YVKd__6hJwcz<_c@HVU{tq2Ul&;!gwPk`Of?EXi9bN+pLvSq=5Vx;j>F_Wiy9{*X>-&ZLuop4Ksjm~u7jyl2Z-P& zOx**rK_wXqk}~gGItvVHQizeYMeawL8z|G2i^u`9NBKumGXf^LC>Yo!^Y-IZx+{3; z*h*|bWYy(*_C9OkBc3YD}=ZFe|oTD-j?6{mEcT6yx zA$LrKGE8fz-iTbmi_&E|Yoe;D$o+^ui@K7+H&Qo+=|iEA6BA?l0cu1cj!~X?3+@cQ zz~TJFYJb!`KOf73Ck))yW9tPtjo~U4(+$=R4pLyDwcZ$2wZhO4hC@jFN?>rc4w482 zF`FNjw6`m^+c_Er=VZ^lYPH-@}wr#oao`d@x@@AK1x&ITSgd{IO=o{>IRN zKiS{MKthE@ctWVy%|X$MifV3oF)uDDQO=>BNBF|Im8e@+VPbK2DU$P4k;oew>R+@T z#i`Z!Y3(K4fDdv2Bf~Ufyf0Z1AKV_&~)%Zh{a)45q1v`5$+D7aA5x9tvN6P<)ykM zHQTQEmsp})CKc%DzlMZfBao{}u?7>_)B3M6kB&z_ffsazdFTRC@J7;urvDmCA{fn& zU?e}vaSv{47aP*??*2J?j{@ zuM6&rSES%Q=v6KOjZMRhbIN0#Q*N(%Xu?f4^>5Ie6P8nX8JMXFW$F+%6I5e(R%xFF zXA~!qX|O1q<39iv6Nnc8;PL&0Fe9igJe^(_&Z@E1%}|f6Bo?tp7Lkj_K4KUFJzznT zD!xzqG!`x+YcM>7wb*CE>jQ?cj-cNrRdRDw5=-7y`-SEWm_8t*@ZQlZYa$ai9)UF&T4f?orJk9 z)>-R@ulLfmcL6V76E@nCcR6UXt&O{nl?qq@H9;-nZ=0AHbnR*iCENjasDBZ8A8->z zOG%_7X_!J$Fuy*l_DTmIXilj4w$QPA-RLNVp9r)cqDSmTzzjweTu~|lQ5M~cRA}YE zhlwkH5oy>v$d4eOQCD#CAb$>N=&Z=MAo^$%(Y{$x2{@XP5*5{^wbB_P@lnuXM014@ zlsuk>DBL)n$FqS8FJFj2^!5A5Av5R-^r7eAxx$jVHeeqqGMZGH^b^MFu{0en@p_!! zy+l0$8_m#v7WUD7R3BdVeosB|-0U_4lRknT$aY?0FQCnxs3*FKV3?0rx*AXKI5)J?mdW0yc%m!jJcdl1xrK`mo{0JV5d$l}TRQ*=H<1CBmV?xpH< zI1SbQZIipo9bMn8NQe!egbL6+HRHe;^+CI0UaytmZkfhiRpFc7?I(B=s@UTj^6*w{ zId}rbP?n&7#Dhp&4V6~-mF=(*0{lDw3ZQk#ml1cm>3KWul?xX(p!x2uWx)w9Y=8g( zO`@BK<`ruhEaLE4MNHb;1n;cO)h4ENkxKcJM9A1TF@-Dkf&z0XK|axkq`eDxGJ&E+ z{}28Ryk9$WRHLv51SVlFPxmw*gL1Xk!>YF=m?!K%SH-$Xa1VzTS6h|?JWc=3{C}x= z@71mx)oP&v9kQhC6$$jo+WNasudMIkj}gjVAs;5t$)A1oKmYjV+U@r;gMlY%26O~| zAu-12MDk zwGp+XR}pDi9-;0t=2p2krbuts(fF(pEAGXeKQidMdt= z(|AvaL*AmkHEfQ?Y;z#i2U$EExd?+Kh5v@}9ZuR-Ez`l$f1YEbNS9&b-zz}4|H9nY zBI3BhRp#3eE*x|zsIk0$5V(O}1r|;=iU1O@+p8*u6l=$Y(Sq%ORYwR%jJ)JMG@ zu<|tBGhdpYetYi(`tGfts1LKxy)mTvnyHr>>rx-XxUw?!mpDgRC#((x2GU^(>R^5P z>fQBTI{)f){`J)R1dr1ZSWn11z)0{ipnCUC)=z*T^R?x_)laHE%>E>(>sJHfJi5L2 zGTQLvVwCWH1oi$@4Pw`iORkh*wNKSg?e{B$5d$KC80nMsQ|znNTHav(G#t&+b)1s{ zte3*5X*orM)ZNj-*cw899r?#4AN@%_PEs8K!xH?wB$TH?xrw^_M^HWk%B>cSpxn~G z-A1{T{XELQv>yC+Js3gk7*HG0Lq}4}@)uHDN1!^^H=;k_UEuAI+t7ZChC{2Q3qot; z|CP`R1N`_HtgGeh|9|PF>7S|fWx*)(@^*js>E5SvkXY|rT9KEDkjL-5xADvW<9GM} z&mZnz3tlqFa&!*?T_vIg`_}xWK>iIN>zgilQO>@xzUP}4eKY$_0@e|YBmEChTuaKB z5O&UzkhFdh^!u)s;%Njg_h&cMkZ1GPB{wcvfBluka{HaV8F^?|7a}NY! zuJ+)MGuzet=@t1FiMU|q}p+Guzz28v?@0C&4M5C9v_m9zk`+eSCo!5AVwl;p0wa{i{Qt18w9qcBs}sga-;Z- zS;Nt6ls+8DN65$l$ihBz4EYF}M18vJl5oWiL3+|*#~wyuvG&C}OM3AB!~7J=d#too zOLt1oKx62zf+#ebZoM1u2+0eN`C>td=WlUPY{g%K?`O~Q`1dWAu-yN>7l9QNg`OP^|20NPNgb5!C1JnN`~Nfa z*ClMVTgsBpE%siI?)Rt^*msZW2K$aaxObpl{Dt*|PKX7(8f^9A+k=)pGD4p)ZqBq{ z%A=}R@(03vtPiwxIw7yXR&Oa45Ldz?`D)dl3)o1%M9sDMNWcu}z0$u&K(-LD3+{#f z=LEk`@Gl7dC4sPpuAm?ChQT9w!;e^Xh2R>2yawDMCQM_I7?m~s2L#s%4zPi)z^^RV zx)%Y>O>N8d&HMTf*+L#I`@##v82t@zbG55jg=Yv{pxi*ApdM5h|0bHBh9c%>ff!I0 zPg(<3AC7jCM;nKm?n}61pb!q>F>@$5;t?EUvv#iY7`MGLHU63)5E&iOkr)g9HN%+4 zPu{LP?h`DnXA#6k(pPEaYA@q&P4Ne+)A-|1{9)wG?A+9y#T9!Rf9rXDYWl;BiQs<3 zFe1DsRI>SOhdBNyEPz8Zd>aFO$Qk0|V}`X;om3zEvx;11jM3PY!nTvo`>8zt7GW1Y z&b;4~&p*H;QdpA6=ka&Z`MiFFUNsqtpTy%87@7A3jz_Bg@c4ky2CvJ! hefZP#517Hhj-tX(UJX|48vWQ3W*r^IVUh#&e*rxTJp}*& literal 0 HcmV?d00001 diff --git a/__pycache__/gerbil.cpython-39.pyc b/__pycache__/gerbil.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..403285595de5d4ffb7a2cfc5b924583a999a827d GIT binary patch literal 31500 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

|9y(l2RxGV%zf8cYq! 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", self.line).strip() + lines = commands.split("\n") + lines[0] = lines[0] + self.comment # preserve comment + return lines + + + def strip(self): + """ + Remove blank spaces and newlines from beginning and end, and remove blank spaces from the middle of the line. + """ + self.line = self.line.replace(" ", "") + self.line = self.line.strip() + + + def tidy(self): + """ + Strips G-Code not contained in the whitelist. + """ + + # transform [MG]\d to G\d\d for better parsing + def format_cmd_number(matchobj): + cmd = matchobj.group(1) + cmd_nr = int(matchobj.group(2)) + self.line_is_unsupported_cmd = not (cmd in self.whitelist_commands and cmd_nr in self.whitelist_commands[cmd]) + return "{}{:02d}".format(cmd, cmd_nr) + + self.line = re.sub(self._re_match_cmd_number, format_cmd_number, self.line) + + if self.line_is_unsupported_cmd: + self.line = ";" + self.line + " ;" + self.special_comment_prefix + ".unsupported" + + + def parse_state(self): + """ + This method... + + * parses motion mode + * parses distance mode + * parses plane mode + * parses feed rate + * parses spindle speed + * parses arcs (offsets and radi) + * calculates travel distances + + ... and updates the machine's state accordingly. + """ + + # parse G0 .. G3 and remember + m = re.match(self._re_motion_mode, self.line) + if m: self.current_motion_mode = int(m.group(1)) + + # parse G90 and G91 and remember + m = re.match(self._re_distance_mode, self.line) + if m: self.current_distance_mode = m.group(1) + + # parse G17, G18 and G19 and remember + m = re.match(self._re_plane_mode, self.line) + if m: self.current_plane_mode = m.group(1) + + m = re.match(self._re_cs, self.line) + if m: self.current_cs = m.group(1) + + # see if current line has F + m = re.match(self._re_feed, self.line) + self.contains_feed = True if m else False + if m: self.feed_in_current_line = float(m.group(1)) + + # look for spindle S + m = re.match(self._re_spindle, self.line) + self.contains_spindle = True if m else False + if m: self.current_spindle_speed = int(m.group(1)) + + # arc parsing and calculations + if self.current_motion_mode == 2 or self.current_motion_mode == 3: + self.offset = [None, None, None] + for i in range(0, 3): + # loop over I, J, K offsets + regexp = self._offset_regexps[i] + + m = re.match(regexp, self.line) + if m: self.offset[i] = float(m.group(1)) + + # parses arcs + m = re.match(self._re_radius, self.line) + self.contains_radius = True if m else False + if m: self.radius = float(m.group(1)) + + + # calculate distance traveled by this G-Code cmd in xyz + self.dist_xyz = [0, 0, 0] + for i in range(0, 3): + # loop over X, Y, Z axes + regexp = self._axes_regexps[i] + + m = re.match(regexp, self.line) + if m: + if self.current_distance_mode == "G90": + # absolute distances + self.target_m[i] = self.cs_offsets[self.cs][i] + float(m.group(1)) + self.target_w[i] = float(m.group(1)) + + # calculate distance + self.dist_xyz[i] = self.target_m[i] - self.pos_m[i] + else: + # G91 relative distances + self.dist_xyz[i] = float(m.group(1)) + self.target_m[i] += self.dist_xyz[i] + self.target_w[i] += self.dist_xyz[i] + else: + # no movement along this axis, stays the same + self.target_m[i] = self.pos_m[i] + self.target_w[i] = self.pos_w[i] + + # calculate travelling distance + self.dist = math.sqrt(self.dist_xyz[0] * self.dist_xyz[0] + self.dist_xyz[1] * self.dist_xyz[1] + self.dist_xyz[2] * self.dist_xyz[2]) + + + + + def fractionize(self): + """ + Breaks lines longer than a certain threshold into shorter segments. + + Also breaks circles into segments. + + Returns a list of command strings. Does not update the machine state. + """ + + result = [] + + if self.do_fractionize_lines == True and self.current_motion_mode == 1 and self.dist > self.fract_linear_threshold: + result = self._fractionize_linear_motion() + + elif self.do_fractionize_arcs == True and (self.current_motion_mode == 2 or self.current_motion_mode == 3): + result = self._fractionize_circular_motion() + + else: + # this motion cannot be fractionized + # return the line as it was passed in + result = [self.line] + + result[0] = result[0] + self.comment # preserve comment + return result + + + def done(self): + """ + When all processing/inspecting of a command has been done, call this method. + This will virtually 'move' the tool of the machine if the current command + is a motion command. + """ + if not (self.current_motion_mode == 0 or self.current_motion_mode == 1): + # only G0 and G1 can stay active without re-specifying + self.current_motion_mode = None + + # move the 'tool' + for i in range(0, 3): + # loop over X, Y, Z axes + if self.target_m[i] != None: # keep state + self.pos_m[i] = self.target_m[i] + self.pos_w[i] = self.target_w[i] + + # re-add comment + self.line += self.comment + + #print("DONE", self.line, self.pos_m, self.pos_w, self.target) + + + def find_vars(self): + """ + Parses all variables in a G-Code line (#1, #2, etc.) and populates + the internal `vars` dict with corresponding keys and values + """ + + m = re.match(self._re_set_var, self.line) + if m: + key = m.group(1) + val = str(float(m.group(2))) # get rid of extra zeros + self.vars[key] = val + self.line = ";" + self.line + + # find variable usages + keys = re.findall(self._re_use_var, self.line) + for key in keys: + if not key in self.vars: self.vars[key] = None + + + def substitute_vars(self): + """ + Substitute a variable with a value from the `vars` dict. + """ + keys = re.findall(self._re_use_var, self.line) + + for key in keys: + val = None + if key in self.vars: + val = self.vars[key] + + if val == None: + self.line = "" + self.callback("on_var_undefined", key) + return self.line + else: + self.line = self.line.replace("#" + key, str(val)) + self.logger.info("SUBSTITUED VAR #{} -> {}".format(key, val)) + + + def scale_spindle(self): + if self.contains_spindle: + # strip the original S setting + self.line = re.sub(self._re_spindle_replace, "", self.line).strip() + self.line += "S{:d}".format(int(self.current_spindle_speed * self.spindle_factor)) + + + def override_feed(self): + """ + Call this method to + + * get a callback when the current command contains an F word + * + """ + + if self.do_feed_override == False and self.contains_feed: + # Notify parent app of detected feed in current line (useful for UIs) + if self.current_feed != self.feed_in_current_line: + self.callback("on_feed_change", self.feed_in_current_line) + self.current_feed = self.feed_in_current_line + + + if self.do_feed_override == True and self.request_feed: + + if self.contains_feed: + # strip the original F setting + self.logger.info("STRIPPING FEED: " + self.line) + self.line = re.sub(self._re_feed_replace, "", self.line).strip() + + if (self.current_feed != self.request_feed): + self.line += "F{:0.1f}".format(self.request_feed) + self.current_feed = self.request_feed + self.logger.info("OVERRIDING FEED: " + str(self.current_feed)) + self.callback("on_feed_change", self.current_feed) + + + + def transform_comments(self): + """ + Comments in Gcode can be set with semicolon or parentheses. + This method transforms parentheses comments to semicolon comments. + """ + + # transform () comment at end of line into semicolon comment + self.line = re.sub(self._re_comment_paren_convert, "\g<1>;\g<2>", self.line) + + # remove all in-line () comments + self.line = re.sub(self._re_comment_paren_replace, "", self.line) + + m = re.match(self._re_comment_get_comment, self.line) + if m: + self.line = m.group(1) + self.comment = m.group(2) + else: + self.comment = "" + + + def _fractionize_circular_motion(self): + """ + This function is a direct port of Grbl's C code into Python (gcode.c) + with slight refactoring for Python by Michael Franzl. + See https://github.com/grbl/grbl + + """ + + # implies self.current_motion_mode == 2 or self.current_motion_mode == 3 + + if self.current_plane_mode == "G17": + axis_0 = 0 # X axis + axis_1 = 1 # Y axis + axis_linear = 2 # Z axis + elif self.current_plane_mode == "G18": + axis_0 = 2 # Z axis + axis_1 = 0 # X axis + axis_linear = 1 # Y axis + elif self.current_plane_mode == "G19": + axis_0 = 1 # Y axis + axis_1 = 2 # Z axis + axis_linear = 0 # X axis + + is_clockwise_arc = True if self.current_motion_mode == 2 else False + + # deltas between target and (current) position + x = self.target_w[axis_0] - self.pos_w[axis_0] + y = self.target_w[axis_1] - self.pos_w[axis_1] + + if self.contains_radius: + # RADIUS MODE + # R given, no IJK given, self.offset must be calculated + + if tuple(self.target_w) == tuple(self.pos_w): + self.logger.error("Arc in Radius Mode: Identical start/end {}".format(self.line)) + return [self.line] + + h_x2_div_d = 4.0 * self.radius * self.radius - x * x - y * y; + + if h_x2_div_d < 0: + self.logger.error("Arc in Radius Mode: Radius error {}".format(self.line)) + return [self.line] + + # Finish computing h_x2_div_d. + h_x2_div_d = -math.sqrt(h_x2_div_d) / math.sqrt(x * x + y * y); + + if not is_clockwise_arc: + h_x2_div_d = -h_x2_div_d + + if self.radius < 0: + h_x2_div_d = -h_x2_div_d; + self.radius = -self.radius; + + self.offset[axis_0] = 0.5*(x-(y*h_x2_div_d)) + self.offset[axis_1] = 0.5*(y+(x*h_x2_div_d)) + + else: + # CENTER OFFSET MODE, no R given so must be calculated + + if self.offset[axis_0] == None or self.offset[axis_1] == None: + raise Exception("Arc in Offset Mode: No offsets in plane. {}".format(self.line)) + #self.logger.error("Arc in Offset Mode: No offsets in plane") + #return [self.line] + + # Arc radius from center to target + x -= self.offset[axis_0] + y -= self.offset[axis_1] + target_r = math.sqrt(x * x + y * y) + + # Compute arc radius for mc_arc. Defined from current location to center. + self.radius = math.sqrt(self.offset[axis_0] * self.offset[axis_0] + self.offset[axis_1] * self.offset[axis_1]) + + # Compute difference between current location and target radii for final error-checks. + delta_r = math.fabs(target_r - self.radius); + if delta_r > 0.005: + if delta_r > 0.5: + raise Exception("Arc in Offset Mode: Invalid Target. r={:f} delta_r={:f} {}".format(self.radius, delta_r, self.line)) + #self.logger.warning("Arc in Offset Mode: Invalid Target. r={:f} delta_r={:f} {}".format(self.radius, delta_r, self.line)) + #return [] + if delta_r > (0.001 * self.radius): + raise Exception("Arc in Offset Mode: Invalid Target. r={:f} delta_r={:f} {}".format(self.radius, delta_r, self.line)) + #self.logger.warning("Arc in Offset Mode: Invalid Target. r={:f} delta_r={:f} {}".format(self.radius, delta_r, self.line)) + #return [] + + #print(self.pos_m, self.target, self.offset, self.radius, axis_0, axis_1, axis_linear, is_clockwise_arc) + + #print("MCARC", self.line, self.pos_w, self.target_w, self.offset, self.radius, axis_0, axis_1, axis_linear, is_clockwise_arc) + + gcode_list = self._mc_arc(self.pos_w, self.target_w, self.offset, self.radius, axis_0, axis_1, axis_linear, is_clockwise_arc) + + return gcode_list + + + def _mc_arc(self, position, target, offset, radius, axis_0, axis_1, axis_linear, is_clockwise_arc): + """ + This function is a direct port of Grbl's C code into Python (motion_control.c) + with slight refactoring for Python by Michael Franzl. + See https://github.com/grbl/grbl + """ + + gcode_list = [] + gcode_list.append(";" + self.special_comment_prefix + ".arc_begin[{}]".format(self.line)) + + do_restore_distance_mode = False + if self.current_distance_mode == "G91": + # it's bad to concatenate many small floating point segments due to accumulating errors + # each arc will use G90 + do_restore_distance_mode = True + gcode_list.append("G90") + + center_axis0 = position[axis_0] + offset[axis_0] + center_axis1 = position[axis_1] + offset[axis_1] + # radius vector from center to current location + r_axis0 = -offset[axis_0] + r_axis1 = -offset[axis_1] + # radius vector from target to center + rt_axis0 = target[axis_0] - center_axis0 + rt_axis1 = target[axis_1] - center_axis1 + + angular_travel = math.atan2(r_axis0 * rt_axis1 - r_axis1 * rt_axis0, r_axis0 * rt_axis0 + r_axis1 * rt_axis1) + + arc_tolerance = 0.004 + arc_angular_travel_epsilon = 0.0000005 + + if is_clockwise_arc: # Correct atan2 output per direction + if angular_travel >= -arc_angular_travel_epsilon: angular_travel -= 2*math.pi + else: + if angular_travel <= arc_angular_travel_epsilon: angular_travel += 2*math.pi + + + + segments = math.floor(math.fabs(0.5 * angular_travel * radius) / math.sqrt(arc_tolerance * (2 * radius - arc_tolerance))) + + #print("angular_travel:{:f}, radius:{:f}, arc_tolerance:{:f}, segments:{:d}".format(angular_travel, radius, arc_tolerance, segments)) + + words = ["X", "Y", "Z"] + if segments: + theta_per_segment = angular_travel / segments + linear_per_segment = (target[axis_linear] - position[axis_linear]) / segments + + position_last = list(position) + for i in range(1, segments): + cos_Ti = math.cos(i * theta_per_segment); + sin_Ti = math.sin(i * theta_per_segment); + r_axis0 = -offset[axis_0] * cos_Ti + offset[axis_1] * sin_Ti; + r_axis1 = -offset[axis_0] * sin_Ti - offset[axis_1] * cos_Ti; + + position[axis_0] = center_axis0 + r_axis0; + position[axis_1] = center_axis1 + r_axis1; + position[axis_linear] += linear_per_segment; + + gcodeline = "" + if i == 1: + gcodeline += "G1" + + for a in range(0,3): + if position[a] != position_last[a]: # only write changes + txt = "{}{:0.3f}".format(words[a], position[a]) + txt = txt.rstrip("0").rstrip(".") + gcodeline += txt + position_last[a] = position[a] + + if i == 1: + if self.contains_feed: gcodeline += "F{:.1f}".format(self.feed_in_current_line) + if self.contains_spindle: gcodeline += "S{:d}".format(self.current_spindle_speed) + + gcode_list.append(gcodeline) + + + + # make sure we arrive at target + gcodeline = "" + if segments <= 1: + gcodeline += "G1" + + for a in range(0,3): + if target[a] != position[a]: + txt = "{}{:0.3f}".format(words[a], target[a]) + txt = txt.rstrip("0").rstrip(".") + gcodeline += txt + + if segments <= 1: + # no segments were rendered (very small arc) so we have to put S and F here + if self.contains_feed: gcodeline += "F{:.1f}".format(self.feed_in_current_line) + if self.contains_spindle: gcodeline += "S{:d}".format(self.current_spindle_speed) + + gcode_list.append(gcodeline) + + if do_restore_distance_mode == True: + gcode_list.append(self.current_distance_mode) + + gcode_list.append(";" + self.special_comment_prefix + ".arc_end") + + return gcode_list + + + def _fractionize_linear_motion(self): + gcode_list = [] + gcode_list.append(";" + self.special_comment_prefix + ".line_begin[{}]".format(self.line)) + + num_fractions = int(self.dist / self.fract_linear_segment_len) + + for k in range(0, num_fractions): + # render segments + txt = "" + if k == 0: + txt += "G1" + + for i in range(0, 3): + # loop over X, Y, Z axes + segment_length = self.dist_xyz[i] / num_fractions + coord_rel = (k + 1) * segment_length + if self.current_distance_mode == "G90": + # absolute distances + coord_abs = self.pos_w[i] + coord_rel + if coord_rel != 0: + # only output for changes + txt += "{}{:0.3f}".format(self._axes_words[i], coord_abs) + txt = txt.rstrip("0").rstrip(".") + else: + # relative distances + txt += "{}{:0.3f}".format(self._axes_words[i], segment_length) + txt = txt.rstrip("0").rstrip(".") + + if k == 0: + if self.contains_feed: txt += "F{:.1f}".format(self.feed_in_current_line) + if self.contains_spindle: txt += "S{:d}".format(self.current_spindle_speed) + + + gcode_list.append(txt) + + gcode_list.append(";" + self.special_comment_prefix + ".line_end") + return gcode_list + + + + + def _default_callback(self, status, *args): + print("PREPROCESSOR DEFAULT CALLBACK", status, args) diff --git a/gerbil.py b/gerbil.py new file mode 100644 index 0000000..bb37f06 --- /dev/null +++ b/gerbil.py @@ -0,0 +1,1160 @@ +""" +Gerbil - Copyright (c) 2015 Michael Franzl + +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. +""" + +import logging +import time +import re +import threading +import atexit +import os +import collections + +from queue import Queue + +from interface import Interface +from callbackloghandler import CallbackLogHandler + +from gcode_machine import GcodeMachine + +class Gerbil: + """ A universal Grbl CNC firmware interface module for Python3 + providing a convenient high-level API for scripting or integration + into parent applications like GUI's. + + There are a number of streaming applications available for the Grbl + CNC controller, but none of them seem to be an universal, re-usable + standard Python module. Gerbil attempts to fill that gap. + + See README for usage examples. + + Gerbil is a name of a cute desert rodent. We chose the name due to + its similarity to the name "Grbl". + + Features: + + * Re-usable across projects + * Non-blocking + * Asynchronous (event-based) callbacks for the parent application + * Two streaming modes: Incremental or fast ("counting characters") + * Defined shutdown + * G-Code cleanup + * G-Code variable expansion + * Dynamic feed override + * Buffer stashing + * Job halt and resume + + Callbacks: + + After assigning your own callback function (callback = ...) you will receive the following signals: + + on_boot + : Emitted whenever Grbl boots (e.g. after a soft reset). + : No arguments. + + on_disconnected + : Emitted whenever the serial port has been closed. + : No arguments + + on_log + : Emitted for informal logging or debugging messages. + : 1 argument: LogRecord instance + + on_line_sent + : Emitted whenever a line is actually sent to Grbl. + : 2 arguments: job_line_number, line + + on_bufsize_change + : Emitted whenever lines have been appended to the buffer + : 1 argument: linecount + + on_line_number_change + : Emitted whenever the current buffer position has been changed + : 1 argument: line_number + + on_processed_command + : Emitted whenever Grbl confirms a command with "ok" and is now being executed physically + : 2 arguments: processed line number, processed line + + on_alarm + : Emitted whenever Grbl sends an "ALARM" line + : 1 argument: the full line Grbl sent + + on_error + : Emitted whenever Grbl sends an "ERROR" line + : 3 arguments: the full line Grbl sent, the line that caused the error, the line number in the buffer that caused the error + + on_rx_buffer_percent + : Reports Grbl's serial receive buffer fill in percent. Emitted frequently while streaming. + : 1 argument: percentage integer from 0 to 100 + + on_progress_percent + : Reports the completion of the current job/buffer in percent. Emitted frequently while streaming. + : 1 argument: percentage integer from 0 to 100 + + on_job_completed + : Emitted when the current job/buffer has been streamed and physically executed entirely + + on_stateupdate + : Emitted whenever Grbl's state has changed + : 3 arguments: Grbl's mode ('Idle', 'Run' etc.), machine position tuple, working position tupe + + on_hash_stateupdate + : Emitted after Grbl's 'hash' EEPROM settings (`$#`) have been received + : 1 argument: dict of the settings + + on_settings_downloaded + : Emitted after Grbl's EEPROM settings (`$$`) have been received + : 1 argument: dict of the settings + + on_gcode_parser_stateupdate + : Emitted after Grbl's G-Code parser state has been received + : 1 argument: list of the state variables + + on_simulation_finished + : Emitted when Gerbil's target is set to "simulator" and the job is executed. + : 1 argument: list of all G-Code commands that would have been sent to Grbl + + on_vars_change + : Emitted after G-Code is loaded into the buffer and variables have been detected + : 1 argument: a dict of the detected variables + + on_preprocessor_feed_change + : Emitted when a F keyword is parsed from the G-Code. + : 1 argument: the feed rate in mm/min + """ + + __version__ = "0.5.0" + + def __init__(self, callback, name="mygrbl"): + """Straightforward initialization tasks. + + @param callback + Set your own function that will be called when a number of + asynchronous events happen. Useful for UI's. The + default function will just log to stdout. + + This callback function will receive two arguments. The first + is a string giving a label of the event, and the second is a variable + argument list `*args` containing data pertaining to the event. + + Note that this function may be called from a Thread. + + @param name + An informal name of the instance. Useful if you are running + several instances to control several CNC machines at once. + It is only used for logging output and UI messages. + """ + + ## @var name + # Set an informal name of the instance. Useful if you are + # running several instances to control several CNC machines at + # once. It is only used for logging output and UI messages. + self.name = name + + ## @var cmode + # Get Grbl's current mode. + # Will be strings 'Idle', 'Check', 'Run' + self.cmode = None + + ## @var cmpos + # Get a 3-tuple containing the current coordinates relative + # to the machine origin. + self.cmpos = (0, 0, 0) + + ## @var cwpos + # Get a 3-tuple containing the current working coordinates. + # Working coordinates are relative to the currently selected + # coordinate system. + self.cwpos = (0, 0, 0) + + ## @var gps + # Get list of 12 elements containing the 12 Gcode Parser State + # variables of Grbl which are obtained by sending the raw + # command `$G`. Will be available after setting + # `hash_state_requested` to True. + self.gps = [ + "0", # motion mode + "54", # current coordinate system + "17", # current plane mode + "21", # units + "90", # current distance mode + "94", # feed rate mode + "0", # program mode + "0", # spindle state + "5", # coolant state + "0", # tool number + "99", # current feed + "0", # spindle speed + ] + + ## @var poll_interval + # Set an interval in seconds for polling Grbl's state via + # the `?` command. The Grbl Wiki recommends to set this no lower + # than 0.2 (5 per second). + self.poll_interval = 0.2 + + ## @var settings + # Get a dictionary of Grbl's EEPROM settings which can be read + # after sending the `$$` command, or more conveniently after + # calling the method `request_settings()` of this class. + self.settings = { + 130: { "val": "1000", "cmt": "width" }, + 131: { "val": "1000", "cmt": "height" } + } + + ## @var settings_hash + # Get a dictionary of Grbl's 'hash' settings (also stored in the + # EEPROM) which can be read after sending the `$#` command. It + # contains things like coordinate system offsets. See Grbl + # documentation for more info. Will be available shortly after + # setting `hash_state_requested` to `True`. + self.settings_hash = { + "G54": (-600, -300, 0), + "G55": (-400, -300, 0), + "G56": (-200, -300, 0), + "G57": (-600, -600, 0), + "G58": (-400, -600, 0), + "G59": (-200, -600, 0), + "G28": (0, 0, 0), + "G30": (0, 0, 0), + "G92": (0, 0, 0), + "TLO": 0, + "PRB": (0, 0, 0), + } + + ## @var gcode_parser_state_requested + # Set this variable to `True` to receive a callback with the + # event string "on_gcode_parser_stateupdate" containing + # data that Grbl sends after receiving the `$G` command. + # After the callback, this variable reverts to `False`. + self.gcode_parser_state_requested = False + + ## @var hash_state_requested + # Set this variable to `True` to receive a callback with the + # event string "on_hash_stateupdate" containing + # the requested data. After the callback, this variable reverts + # to `False`. + self.hash_state_requested = False + + + + ## @var logger + # The logger used by this class. The default is Python's own + # logger module. Use `setup_logging()` to attach custom + # log handlers. + self.logger = logging.getLogger("gerbil") + self.logger.setLevel(5) + self.logger.propagate = False + + ## @var target + # Set this to change the output target. Default is "firmware" + # which means the serial port. Another target is "simulator", + # you will receive a callback with even string + # "on_simulation_finished" and a buffer of the G-Code commands + # that would have been sent out to Grbl. + # TODO: Add "file" target. + self.target = "firmware" + + ## @var connected + # `True` when connected to Grbl (after boot), otherwise `False` + self.connected = False + + ## @var preprocessor + # All G-code commands will go through the preprocessor + # before they are sent out via the serial port. The preprocessor + # keeps track of, and can dynamically change, feed rates, as well + # as substitute variables. It has its own state and callback + # functions. + self.preprocessor = GcodeMachine() + self.preprocessor.callback = self._preprocessor_callback + + ## @var travel_dist_buffer + # The total distance of all G-Codes in the buffer. + self.travel_dist_buffer = {} + + ## @var travel_dist_current + # The currently travelled distance. Can be used to calculate ETA. + self.travel_dist_current = {} + + ## @var is_standstill + # If the machine is currently not moving + self.is_standstill = False + + self._ifacepath = None + self._last_setting_number = 132 + + self._last_cmode = None + self._last_cmpos = (0, 0, 0) + self._last_cwpos = (0, 0, 0) + + self._standstill_watchdog_increment = 0 + + self._rx_buffer_size = 128 + self._rx_buffer_fill = [] + self._rx_buffer_backlog = [] + self._rx_buffer_backlog_line_number = [] + self._rx_buffer_fill_percent = 0 + + self._current_line = "" + self._current_line_sent = True + self._streaming_mode = None + self._wait_empty_buffer = False + self.streaming_complete = True + self.job_finished = True + self._streaming_src_end_reached = True + self._streaming_enabled = True + self._error = False + self._incremental_streaming = False + self._hash_state_sent = False + + self.buffer = [] + self.buffer_size = 0 + self._current_line_nr = 0 + + self.buffer_stash = [] + self.buffer_size_stash = 0 + self._current_line_nr_stash = 0 + + self._poll_keep_alive = False + self._iface_read_do = False + + self._thread_polling = None + self._thread_read_iface = None + + self._iface = None + self._queue = Queue() + + self._loghandler = None + + self._counter = 0 # general-purpose counter for timing tasks inside of _poll_state + + self._callback = callback + + atexit.register(self.disconnect) + + # supply defaults to GUI to make it operational + self._callback("on_settings_downloaded", self.settings) + self._callback("on_hash_stateupdate", self.settings_hash) + self.preprocessor.cs_offsets = self.settings_hash + self._callback("on_gcode_parser_stateupdate", self.gps) + + + def setup_logging(self, handler=None): + """Assign a custom log handler. + + Gerbil can be used in both console applications as well as + integrated in other projects like GUI's. Therefore, logging to + stdout is not always useful. You can pass a custom log message + handler to this method. If no handler is passed in, the default + handler is an instance of class `CallbackLogHandler` + (see file `callback_loghandler.py` included in this module). + CallbackLogHandler will deliver logged strings as callbacks to + the parent application, the event string will be "on_log". + + @param handler=None + An instance of a subclass inheriting from `logging.StreamHandler` + """ + + if handler: + self._loghandler = handler + else: + # The default log handler shipped with this module will call + # self._callback() with first parameter "on_log" and second + # parameter with the logged string. + lh = CallbackLogHandler() + self._loghandler = lh + + # attach the selected log handler + self.logger.addHandler(self._loghandler) + self._loghandler.callback = self._callback + + + def cnect(self, path=None, baudrate=115200): + """ + Connect to the RS232 port of the Grbl controller. + + @param path=None Path to the device node + + This is done by instantiating a RS232 class, included in this + module, which by itself block-listens (in a thread) to + asynchronous data sent by the Grbl controller. + """ + if path == None or path.strip() == "": + return + else: + self._ifacepath = path + + if self._iface == None: + self.logger.debug("{}: Setting up interface on {}".format(self.name, self._ifacepath)) + self._iface = Interface("iface_" + self.name, self._ifacepath, baudrate) + self._iface.start(self._queue) + else: + self.logger.info("{}: Cannot start another interface. There is already an interface {}.".format(self.name, self._iface)) + + self._iface_read_do = True + self._thread_read_iface = threading.Thread(target=self._onread) + self._thread_read_iface.start() + + self.softreset() + + + def disconnect(self): + """ + This method provides a controlled shutdown and cleanup of this + module. + + It stops all threads, joins them, then closes the serial + connection. For a safe shutdown of Grbl you may also want to + call `softreset()` before you call this method. + """ + if self.is_connected() == False: return + + self.poll_stop() + + self._iface.stop() + self._iface = None + + self.logger.debug("{}: Please wait until reading thread has joined...".format(self.name)) + self._iface_read_do = False + self._queue.put("dummy_msg_for_joining_thread") + self._thread_read_iface.join() + self.logger.debug("{}: Reading thread successfully joined.".format(self.name)) + + self.connected = False + + self._callback("on_disconnected") + + def softreset(self): + """ + Immediately sends `Ctrl-X` to Grbl. + """ + self._iface.write("\x18") # Ctrl-X + self.update_preprocessor_position() + + + def abort(self): + """ + An alias for `softreset()`. + """ + if self.is_connected() == False: return + self.softreset() + + + def hold(self): + """ + Immediately sends the feed hold command (exclamation mark) + to Grbl. + """ + if self.is_connected() == False: return + self._iface_write("!") + + + def resume(self): + """ + Immediately send the resume command (tilde) to Grbl. + """ + if self.is_connected() == False: return + self._iface_write("~") + + + def killalarm(self): + """ + Immediately send the kill alarm command ($X) to Grbl. + """ + self._iface_write("$X\n") + + + def homing(self): + """ + Immediately send the homing command ($H) to Grbl. + """ + self._iface_write("$H\n") + + + def poll_start(self): + """ + Starts forever polling Grbl's status with the `?` command. The + polling interval is controlled by setting `self.poll_interval`. + You will receive callbacks with the "on_stateupdate" event + string containing 3 data parameters self.cmode, self.cmpos, + self.cwpos, but only when Grbl's state CHANGES. + """ + if self.is_connected() == False: return + self._poll_keep_alive = True + self._last_cmode = None + if self._thread_polling == None: + self._thread_polling = threading.Thread(target=self._poll_state) + self._thread_polling.start() + self.logger.debug("{}: Polling thread started".format(self.name)) + else: + self.logger.debug("{}: Polling thread already running...".format(self.name)) + + + def poll_stop(self): + """ + Stops polling that has been started with `poll_start()` + """ + if self.is_connected() == False: return + if self._thread_polling != None: + self._poll_keep_alive = False + self.logger.debug("{}: Please wait until polling thread has joined...".format(self.name)) + self._thread_polling.join() + self.logger.debug("{}: Polling thread has successfully joined...".format(self.name)) + else: + self.logger.debug("{}: Cannot start a polling thread. Another one is already running.".format(self.name)) + + self._thread_polling = None + + + def set_feed_override(self, val): + """ + Enable or disable the feed override feature. + + @param val + Pass `True` or `False` as argument to enable or disable dynamic + feed override. After passing `True`, you may set the + requested feed by calling `self.request_feed()` one or many + times. + """ + self.preprocessor.do_feed_override = val + + + def request_feed(self, requested_feed): + """ + Override the feed speed. Effecive only when you set `set_feed_override(True)`. + + @param requested_feed + The feed speed in mm/min. + """ + self.preprocessor.request_feed = float(requested_feed) + + + @property + def incremental_streaming(self): + return self._incremental_streaming + + @incremental_streaming.setter + def incremental_streaming(self, onoff): + """ + Incremental streaming means that a new command is sent to Grbl + only after Grbl has responded with 'ok' to the last sent + command. This is necessary to flash $ settings to the EEPROM. + + Non-incremental streaming means that Grbl's 100-some-byte + receive buffer will be kept as full as possible at all times, + to give its motion planner system enough data to work with. + This results in smoother and faster axis motion. This is also + called 'advanced streaming protocol based on counting + characters' -- see Grbl Wiki. + + You can dynamically change the streaming method even + during streaming, while running a job. The buffer fill + percentage will reflect the change even during streaming. + + @param onoff + Set to `True` to use incremental streaming. Set to `False` to + use non-incremental streaming. The default on module startup + is `False`. + """ + self._incremental_streaming = onoff + if self._incremental_streaming == True: + self._wait_empty_buffer = True + self.logger.debug("{}: Incremental streaming set to {}".format(self.name, self._incremental_streaming)) + + + def send_immediately(self, line): + """ + G-Code command strings passed to this function will bypass + buffer management and will be sent to Grbl immediately. + Use this function with caution: Only send when you + are sure Grbl's receive buffer can handle the data volume and + when it doesn't interfere with currently running streams. + Only send single commands at a time. + + Applications of this method: manual jogging, coordinate settings + etc. + + @param line + A string of a single G-Code command to be sent. Doesn't have to + be \n terminated. + """ + bytes_in_firmware_buffer = sum(self._rx_buffer_fill) + if bytes_in_firmware_buffer > 0: + self.logger.error("Firmware buffer has {:d} unprocessed bytes in it. Will not send {}".format(bytes_in_firmware_buffer, line)) + return + + if self.cmode == "Alarm": + self.logger.error("Grbl is in ALARM state. Will not send {}.".format(line)) + return + + if self.cmode == "Hold": + self.logger.error("Grbl is in HOLD state. Will not send {}.".format(line)) + return + + if "$#" in line: + # The PRB response is sent for $# as well as when probing. + # Regular querying of the hash state needs to be done like this, + # otherwise the PRB response would be interpreted as a probe answer. + self.hash_state_requested = True + return + + self.preprocessor.set_line(line) + self.preprocessor.strip() + self.preprocessor.tidy() + self.preprocessor.parse_state() + self.preprocessor.override_feed() + + self._iface_write(self.preprocessor.line + "\n") + + + def stream(self, lines): + """ + A more convenient alias for `write(lines)` and `job_run()` + + @param lines + A string of G-Code commands. Each command is \n separated. + """ + self._load_lines_into_buffer(lines) + self.job_run() + + + def write(self, lines): + """ + G-Code command strings passed to this function will be appended + to the current queue buffer, however a job is not started + automatically. You have to call `job_run()` to start streaming. + + You can call this method repeatedly, e.g. for submitting chunks + of G-Code, even while a job is running. + + @param lines + A string of G-Code commands. Each command is \n separated. + """ + if type(lines) is list: + lines = "\n".join(lines) + + self._load_lines_into_buffer(lines) + + + def load_file(self, filename): + """ + Pass a filename to this function to load its contents into the + buffer. This only works when Grbl is Idle and the previous job + has completed. The previous buffer will be cleared. After this + function has completed, the buffer's contents will be identical + to the file content. Job is not started automatically. + Call `job_run` to start the job. + + @param filename + A string giving the relative or absolute file path + """ + if self.job_finished == False: + self.logger.warning("{}: Job must be finished before you can load a file".format(self.name)) + return + + self.job_new() + + with open(filename) as f: + self._load_lines_into_buffer(f.read()) + + + def job_run(self, linenr=None): + """ + Run the current job, i.e. start streaming the current buffer + from a specific line number. + + @param linenr + If `linenr` is not specified, start streaming from the current + buffer position (`self.current_line_number`). If `linenr` is specified, start streaming from this line. + """ + if self.buffer_size == 0: + self.logger.warning("{}: Cannot run job. Nothing in the buffer!".format(self.name)) + return + + if linenr: + self.current_line_number = linenr + + self.travel_dist_current = {} + + #self.preprocessor.current_feed = None + + self._set_streaming_src_end_reached(False) + self._set_streaming_complete(False) + self._streaming_enabled = True + self._current_line_sent = True + self._set_job_finished(False) + self._stream() + + + def job_halt(self): + """ + Stop streaming. Grbl still will continue processing + all G-Code in its internal serial receive buffer. + """ + self._streaming_enabled = False + + + def job_new(self): + """ + Start a new job. A "job" in our terminology means the buffer's + contents. This function will empty the buffer, set the buffer + position to 0, and reset internal state. + """ + del self.buffer[:] + self.buffer_size = 0 + self._current_line_nr = 0 + self._callback("on_line_number_change", 0) + self._callback("on_bufsize_change", 0) + self._set_streaming_complete(True) + self.job_finished = True + self._set_streaming_src_end_reached(True) + self._error = False + self._current_line = "" + self._current_line_sent = True + self.travel_dist_buffer = {} + self.travel_dist_current = {} + + self._callback("on_vars_change", self.preprocessor.vars) + + @property + def current_line_number(self): + return self._current_line_nr + + @current_line_number.setter + def current_line_number(self, linenr): + if linenr < self.buffer_size: + self._current_line_nr = linenr + self._callback("on_line_number_change", self._current_line_nr) + + + def request_settings(self): + """ + This will send `$$` to Grbl and you will receive a callback with + the argument 1 "on_settings_downloaded", and argument 2 a dict + of the settings. + """ + self._iface_write("$$\n") + + + def do_buffer_stash(self): + """ + Stash the current buffer and position away and initialize a + new job. This is useful if you want to stop the current job, + stream changed $ settings to Grbl, and then resume the job + where you left off. See also `self.buffer_unstash()`. + """ + self.buffer_stash = list(self.buffer) + self.buffer_size_stash = self.buffer_size + self._current_line_nr_stash = self._current_line_nr + self.job_new() + + def do_buffer_unstash(self): + """ + Restores the previous stashed buffer and position. + """ + self.buffer = list(self.buffer_stash) + self.buffer_size = self.buffer_size_stash + self.current_line_number = self._current_line_nr_stash + self._callback("on_bufsize_change", self.buffer_size) + + + def update_preprocessor_position(self): + # keep preprocessor informed about current working pos + self.preprocessor.position_m = list(self.cmpos) + #self.preprocessor.target = list(self.cmpos) + + def _preprocessor_callback(self, event, *data): + if event == "on_preprocessor_var_undefined": + self.logger.critical("HALTED JOB BECAUSE UNDEFINED VAR {}".format(data[0])) + self._set_streaming_src_end_reached(True) + self.job_halt() + else: + self._callback(event, *data) + + def _stream(self): + if self._streaming_src_end_reached: + return + + if self._streaming_enabled == False: + return + + if self.target == "firmware": + if self._incremental_streaming: + self._set_next_line() + if self._streaming_src_end_reached == False: + self._send_current_line() + else: + self._set_job_finished(True) + else: + self._fill_rx_buffer_until_full() + + elif self.target == "simulator": + buf = [] + while self._streaming_src_end_reached == False: + self._set_next_line(True) + if self._current_line_nr < self.buffer_size: + buf.append(self._current_line) + + # one line still to go + self._set_next_line(True) + buf.append(self._current_line) + + self._set_job_finished(True) + self._callback("on_simulation_finished", buf) + + def _fill_rx_buffer_until_full(self): + while True: + if self._current_line_sent == True: + self._set_next_line() + + if self._streaming_src_end_reached == False and self._rx_buf_can_receive_current_line(): + self._send_current_line() + else: + break + + + def _set_next_line(self, send_comments=False): + progress_percent = int(100 * self._current_line_nr / self.buffer_size) + self._callback("on_progress_percent", progress_percent) + + if self._current_line_nr < self.buffer_size: + # still something in _buffer, pop it + line = self.buffer[self._current_line_nr].strip() + self.preprocessor.set_line(line) + self.preprocessor.substitute_vars() + self.preprocessor.parse_state() + self.preprocessor.override_feed() + self.preprocessor.scale_spindle() + + if send_comments == True: + self._current_line = self.preprocessor.line + self.preprocessor.comment + else: + self._current_line = self.preprocessor.line + + self._current_line_sent = False + self._current_line_nr += 1 + + self.preprocessor.done() + + else: + # the buffer is empty, nothing more to read + self._set_streaming_src_end_reached(True) + + def _send_current_line(self): + if self._error: + self.logger.error("Firmware reported error. Halting.") + self._set_streaming_src_end_reached(True) + self._set_streaming_complete(True) + return + + self._set_streaming_complete(False) + + line_length = len(self._current_line) + 1 # +1 for \n which we will append below + self._rx_buffer_fill.append(line_length) + self._rx_buffer_backlog.append(self._current_line) + self._rx_buffer_backlog_line_number.append(self._current_line_nr) + self._iface_write(self._current_line + "\n") + + self._current_line_sent = True + self._callback("on_line_sent", self._current_line_nr, self._current_line) + + def _rx_buf_can_receive_current_line(self): + rx_free_bytes = self._rx_buffer_size - sum(self._rx_buffer_fill) + required_bytes = len(self._current_line) + 1 # +1 because \n + return rx_free_bytes >= required_bytes + + def _rx_buffer_fill_pop(self): + if len(self._rx_buffer_fill) > 0: + self._rx_buffer_fill.pop(0) + processed_command = self._rx_buffer_backlog.pop(0) + ln = self._rx_buffer_backlog_line_number.pop(0) - 1 + self._callback("on_processed_command", ln, processed_command) + + if self._streaming_src_end_reached == True and len(self._rx_buffer_fill) == 0: + self._set_job_finished(True) + self._set_streaming_complete(True) + + def _iface_write(self, line): + self._callback("on_write", line) + if self._iface: + num_written = self._iface.write(line) + + def _onread(self): + while self._iface_read_do == True: + line = self._queue.get() + + if len(line) > 0: + if line[0] == "<": + self._update_state(line) + + elif line == "ok": + self._handle_ok() + + elif re.match("^\[G[0123] .*", line): + self._update_gcode_parser_state(line) + self._callback("on_read", line) + + elif re.match("^\[...:.*", line): + self._update_hash_state(line) + self._callback("on_read", line) + + if "PRB" in line: + # last line + if self.hash_state_requested == True: + self._hash_state_sent = False + self.hash_state_requested = False + self._callback("on_hash_stateupdate", self.settings_hash) + self.preprocessor.cs_offsets = self.settings_hash + else: + self._callback("on_probe", self.settings_hash["PRB"]) + + + + elif "ALARM" in line: + self.cmode = "Alarm" # grbl for some reason doesn't respond to ? polling when alarm due to soft limits + self._callback("on_stateupdate", self.cmode, self.cmpos, self.cwpos) + self._callback("on_read", line) + self._callback("on_alarm", line) + + elif "error" in line: + #self.logger.debug("ERROR") + self._error = True + #self.logger.debug("%s: _rx_buffer_backlog at time of error: %s", self.name, self._rx_buffer_backlog) + if len(self._rx_buffer_backlog) > 0: + problem_command = self._rx_buffer_backlog[0] + problem_line = self._rx_buffer_backlog_line_number[0] + else: + problem_command = "unknown" + problem_line = -1 + self._callback("on_error", line, problem_command, problem_line) + self._set_streaming_complete(True) + self._set_streaming_src_end_reached(True) + + elif "Grbl " in line: + self._callback("on_read", line) + self._on_bootup() + self.hash_state_requested = True + self.request_settings() + self.gcode_parser_state_requested = True + + else: + m = re.match("\$(.*)=(.*) \((.*)\)", line) + if m: + key = int(m.group(1)) + val = m.group(2) + comment = m.group(3) + self.settings[key] = { + "val" : val, + "cmt" : comment + } + self._callback("on_read", line) + if key == self._last_setting_number: + self._callback("on_settings_downloaded", self.settings) + else: + self._callback("on_read", line) + #self.logger.info("{}: Could not parse settings: {}".format(self.name, line)) + + def _handle_ok(self): + if self.streaming_complete == False: + self._rx_buffer_fill_pop() + if not (self._wait_empty_buffer and len(self._rx_buffer_fill) > 0): + self._wait_empty_buffer = False + self._stream() + + self._rx_buffer_fill_percent = int(100 - 100 * (self._rx_buffer_size - sum(self._rx_buffer_fill)) / self._rx_buffer_size) + self._callback("on_rx_buffer_percent", self._rx_buffer_fill_percent) + + def _on_bootup(self): + self._onboot_init() + self.connected = True + self.logger.debug("{}: Grbl has booted!".format(self.name)) + self._callback("on_boot") + + def _update_hash_state(self, line): + line = line.replace("]", "").replace("[", "") + parts = line.split(":") + key = parts[0] + tpl_str = parts[1].split(",") + tpl = tuple([float(x) for x in tpl_str]) + self.settings_hash[key] = tpl + + def _update_gcode_parser_state(self, line): + m = re.match("\[G(\d) G(\d\d) G(\d\d) G(\d\d) G(\d\d) G(\d\d) M(\d) M(\d) M(\d) T(\d) F([\d.-]*?) S([\d.-]*?)\]", line) + if m: + self.gps[0] = m.group(1) # motionmode + self.gps[1] = m.group(2) # current coordinate system + self.gps[2] = m.group(3) # plane + self.gps[3] = m.group(4) # units + self.gps[4] = m.group(5) # dist + self.gps[5] = m.group(6) # feed rate mode + self.gps[6] = m.group(7) # program mode + self.gps[7] = m.group(8) # spindle state + self.gps[8] = m.group(9) # coolant state + self.gps[9] = m.group(10) # tool number + self.gps[10] = m.group(11) # current feed + self.gps[11] = m.group(12) # current rpm + self._callback("on_gcode_parser_stateupdate", self.gps) + + self.update_preprocessor_position() + else: + self.logger.error("{}: Could not parse gcode parser report: '{}'".format(self.name, line)) + + def _update_state(self, line): + m = re.match("<(.*?),MPos:(.*?),WPos:(.*?)>", line) + self.cmode = m.group(1) + mpos_parts = m.group(2).split(",") + wpos_parts = m.group(3).split(",") + self.cmpos = (float(mpos_parts[0]), float(mpos_parts[1]), float(mpos_parts[2])) + self.cwpos = (float(wpos_parts[0]), float(wpos_parts[1]), float(wpos_parts[2])) + + if (self.cmode != self._last_cmode or + self.cmpos != self._last_cmpos or + self.cwpos != self._last_cwpos): + self._callback("on_stateupdate", self.cmode, self.cmpos, self.cwpos) + if self.streaming_complete == True and self.cmode == "Idle": + self.update_preprocessor_position() + self.gcode_parser_state_requested = True + + + if (self.cmpos != self._last_cmpos): + if self.is_standstill == True: + self._standstill_watchdog_increment = 0 + self.is_standstill = False + self._callback("on_movement") + else: + # no change in positions + self._standstill_watchdog_increment += 1 + + + if self.is_standstill == False and self._standstill_watchdog_increment > 10: + # machine is not moving + self.is_standstill = True + self._callback("on_standstill") + + self._last_cmode = self.cmode + self._last_cmpos = self.cmpos + self._last_cwpos = self.cwpos + + + def _load_line_into_buffer(self, line): + self.preprocessor.set_line(line) + split_lines = self.preprocessor.split_lines() + + for l1 in split_lines: + self.preprocessor.set_line(l1) + self.preprocessor.strip() + self.preprocessor.tidy() + self.preprocessor.parse_state() + self.preprocessor.find_vars() + fractionized_lines = self.preprocessor.fractionize() + + for l2 in fractionized_lines: + self.buffer.append(l2) + self.buffer_size += 1 + + self.preprocessor.done() + + def _load_lines_into_buffer(self, string): + lines = string.split("\n") + for line in lines: + self._load_line_into_buffer(line) + self._callback("on_bufsize_change", self.buffer_size) + self._callback("on_vars_change", self.preprocessor.vars) + + def is_connected(self): + if self.connected != True: + #self.logger.info("{}: Not yet connected".format(self.name)) + pass + return self.connected + + def _onboot_init(self): + # called after boot. Mimics Grbl's initial state after boot. + del self._rx_buffer_fill[:] + del self._rx_buffer_backlog[:] + del self._rx_buffer_backlog_line_number[:] + self._set_streaming_complete(True) + self._set_job_finished(True) + self._set_streaming_src_end_reached(True) + self._error = False + self._current_line = "" + self._current_line_sent = True + self._clear_queue() + self.is_standstill = False + self.preprocessor.reset() + self._callback("on_progress_percent", 0) + self._callback("on_rx_buffer_percent", 0) + + def _clear_queue(self): + try: + junk = self._queue.get_nowait() + self.logger.debug("Discarding junk %s", junk) + except: + #self.logger.debug("Queue was empty") + pass + + def _poll_state(self): + while self._poll_keep_alive: + self._counter += 1 + + if self.hash_state_requested: + self.get_hash_state() + + elif self.gcode_parser_state_requested: + self.get_gcode_parser_state() + self.gcode_parser_state_requested = False + + else: + self._get_state() + + time.sleep(self.poll_interval) + + self.logger.debug("{}: Polling has been stopped".format(self.name)) + + def _get_state(self): + self._iface.write("?") + + def get_gcode_parser_state(self): + self._iface_write("$G\n") + + def get_hash_state(self): + if self.cmode == "Hold": + self.hash_state_requested = False + self.logger.info("{}: $# command not supported in Hold mode.".format(self.name)) + return + + if self._hash_state_sent == False: + self._iface_write("$#\n") + self._hash_state_sent = True + + def _set_streaming_src_end_reached(self, a): + self._streaming_src_end_reached = a + + def _set_streaming_complete(self, a): + self.streaming_complete = a + + def _set_job_finished(self, a): + self.job_finished = a + if a == True: + self._callback("on_job_completed") + + def _default_callback(self, status, *args): + print("GERBIL DEFAULT CALLBACK", status, args) diff --git a/interface.py b/interface.py new file mode 100644 index 0000000..43b94f9 --- /dev/null +++ b/interface.py @@ -0,0 +1,118 @@ +""" +Gerbil - Copyright (c) 2015 Michael Franzl + +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. +""" + +import serial +import time +import threading +import logging + +class Interface: + """Implements opening, closing, writing and threaded reading from the serial port. Read data are put into a Thread Queue. + """ + + def __init__(self, name, path, baud=115200): + """Straightforward initialization tasks. + + @param name + An informal name of the instance. Useful if you are running + several instances to control several serial ports at once. + It is only used for logging output and UI messages. + + @param path + The serial port device node living under /dev. + e.g. /dev/ttyACM0 or /dev/ttyUSB0 + + @param baud + The baud rate. Default is 115200 for Grbl > v0.9i. + """ + + self.name = name + self.path = path + self.baud = baud + self.queue = None + self.logger = logging.getLogger("gerbil.interface") + + self._buf_receive = "" + self._do_receive = False + + def start(self, queue): + """ + Open the device node and start a Thread for reading. + + @param queue + An instance of Python3's `Queue()` class. + """ + self.queue = queue + + self.logger.info("%s: connecting to %s with baudrate %i", self.name, self.path, self.baud) + + self.serialport = serial.Serial(self.path, self.baud, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, bytesize=serial.EIGHTBITS, timeout=1, writeTimeout=0) + self.serialport.flushInput() + self.serialport.flushOutput() + self._do_receive = True + self.serial_thread = threading.Thread(target=self._receiving) + self.serial_thread.start() + + def stop(self): + """ + Close the device node and shut down the reading Thread. + """ + self._do_receive = False + self.logger.info("%s: stop()", self.name) + self.serial_thread.join() + self.logger.info("%s: JOINED thread", self.name) + self.logger.info("%s: Closing port", self.name) + self.serialport.flushInput() + self.serialport.flushOutput() + self.serialport.close() + + def write(self, data): + """ + Write `data` to the device node. If data is empty, no write is performed. The number of written characters is returned. + """ + if len(data) > 0: + num_written = self.serialport.write(bytes(data,"ascii")) + return num_written + else: + self.logger.debug("%s: nothing to write", self.name) + + def _receiving(self): + while self._do_receive == True: + data = self.serialport.read(1) + waiting = self.serialport.inWaiting() + data += self.serialport.read(waiting) + self._handle_data(data) + + def _handle_data(self, data): + try: + asci = data.decode("ascii") + except UnicodeDecodeError: + self.logger.info("%s: Received a non-ascii byte. Probably junk. Dropping it.", self.name) + asci = "" + + for i in range(0, len(asci)): + char = asci[i] + self._buf_receive += char + # not all received lines are complete (end with \n) + if char == "\n": + self.queue.put(self._buf_receive.strip()) + self._buf_receive = "" diff --git a/realWorldGcodeSender.py b/realWorldGcodeSender.py index d9f522d..3dca473 100644 --- a/realWorldGcodeSender.py +++ b/realWorldGcodeSender.py @@ -8,6 +8,8 @@ import time import math from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc from svgpathtools import svg2paths, wsvg, svg2paths2, polyline +#import matplotlib +#matplotlib.use('GTK3Agg') from matplotlib import pyplot as plt from matplotlib.widgets import TextBox from matplotlib.backend_bases import MouseButton @@ -18,6 +20,11 @@ from pygcode.gcodes import MODAL_GROUP_MAP import re import sys +#sys.path.insert(1, 'C:\\Git\\gerbil\\') +#sys.path.insert(1, 'C:\\Git\\gcode_machine\\') +from gerbil import Gerbil +import serial.tools.list_ports + class Point3D: def __init__(self, X, Y, Z = None): @@ -186,11 +193,24 @@ def arcToPoints(startX, startY, endX, endY, i, j, clockWise, curZ): points.append(Point3D(x, y, curZ)) return points +def rotate(origin, point, angle): + """ + Rotate a point counterclockwise by a given angle around a given origin. + + The angle should be given in radians. + """ + ox, oy = origin + px, py = point + + qx = ox + math.cos(angle) * (px - ox) - math.sin(angle) * (py - oy) + qy = oy + math.sin(angle) * (px - ox) + math.cos(angle) * (py - oy) + return qx, qy + #################################################################################### # OverlayGcode class #################################################################################### class OverlayGcode: - def __init__(self, cv2Overhead): + def __init__(self, cv2Overhead, gCodeFile): global bedViewSizePixels global bedSize @@ -199,7 +219,7 @@ class OverlayGcode: self.xOffset = 0 self.yOffset = 0 self.rotation = 0 - self.cv2Overhead = cv2Overhead + self.cv2Overhead = cv2.cvtColor(cv2Overhead, cv2.COLOR_BGR2RGB) self.move = False fig, ax = plt.subplots() @@ -207,7 +227,7 @@ class OverlayGcode: plt.subplots_adjust(bottom=0.01, right = 0.99) plt.axis([self.bedViewSizePixels,0, self.bedViewSizePixels, 0]) #Generate matplotlib plot from opencv image - self.matPlotImage = plt.imshow(cv2.cvtColor(self.cv2Overhead, cv2.COLOR_BGR2RGB)) + self.matPlotImage = plt.imshow(self.cv2Overhead) ############################################### # Generate controls for plot ############################################### @@ -237,13 +257,18 @@ class OverlayGcode: cid = fig.canvas.mpl_connect('button_press_event', self.onclick) cid = fig.canvas.mpl_connect('button_release_event', self.onrelease) - cid = fig.canvas.mpl_connect('motion_notify_event', self.onmove) + cid = fig.canvas.mpl_connect('motion_notify_event', self.onmousemove) + cid = fig.canvas.mpl_connect('key_press_event', self.onkeypress) + + #Create object to handle controlling the CNC machine and sending the g codes to it + self.sender = GCodeSender(gCodeFile) + self.points = [] self.laserPowers = [] self.machine = Machine() - with open('test.nc', 'r') as fh: + with open(gCodeFile, 'r') as fh: for line_text in fh.readlines(): line = pygcode.Line(line_text) prevPos = self.machine.pos @@ -325,20 +350,7 @@ class OverlayGcode: def rotatePoints(self, points, origin, angle): for point in points: - point.X, point.Y = self.rotate(origin, [point.X, point.Y], angle) - - def rotate(self, origin, point, angle): - """ - Rotate a point counterclockwise by a given angle around a given origin. - - The angle should be given in radians. - """ - ox, oy = origin - px, py = point - - qx = ox + math.cos(angle) * (px - ox) - math.sin(angle) * (py - oy) - qy = oy + math.sin(angle) * (px - ox) + math.cos(angle) * (py - oy) - return qx, qy + point.X, point.Y = rotate(origin, [point.X, point.Y], angle) def overlaySvg(self, image, xOff = 0, yOff = 0, rotation = 0): """ @@ -363,13 +375,13 @@ class OverlayGcode: for point, laserPower in zip(transformedPoints, self.laserPowers): newPoint = (int(point.X), int(point.Y)) if prevPoint is not None: - cv2.line(overlay, prevPoint, newPoint, (int(laserPower * 255), 0, 0), 1) + cv2.line(overlay, prevPoint, newPoint, (int(laserPower * 255), 0, 0), 2) prevPoint = newPoint return overlay def updateOverlay(self): overlay = self.overlaySvg(self.cv2Overhead, self.xOffset, self.yOffset, self.rotation) - self.matPlotImage.set_data(cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)) + self.matPlotImage.set_data(overlay) self.matPlotImage.figure.canvas.draw() def onUpdateXOffset(self, text): @@ -390,8 +402,23 @@ class OverlayGcode: self.rotation = float(text) self.updateOverlay() - def onmove(self, event): + def onmousemove(self, event): self.move = True + self.mouseX = event.xdata + self.mouseY = event.ydata + + def onkeypress(self, event): + if event.key == 's': + self.sender.send_file(self.xOffset, self.yOffset, self.rotation) + elif event.key == 'h': + self.sender.home_machine() + elif event.key == 'z': + #Find X, Y, and Z position of the aluminum reference block on the work pice + #sepcify the X and Y estimated position of the reference block + self.sender.zero_on_workpice(self.refX, self.refY) + elif event.key == 'm': + self.sender.move_to(self.mouseX / self.bedViewSizePixels * self.bedSize.X \ + , self.mouseY / self.bedViewSizePixels * self.bedSize.Y) def onclick(self, event): self.move = False @@ -570,6 +597,64 @@ def display_4_lines(pixels, frame, flip=False): cv2.line(frame, line2,line3,(0,255,255),3) cv2.line(frame, line3,line4,(0,255,255),3) cv2.line(frame, line4,line1,(0,255,255),3) + +class GCodeSender: + def __init__(self, gCodeFile): + self.grbl = Gerbil(self.gerbil_callback) + self.grbl.setup_logging() + + ports = serial.tools.list_ports.comports() + for p in ports: + print(p.device) + + self.grbl.cnect("/dev/ttyUSB0", 57600) + self.grbl.poll_start() + self.gCodeFile = gCodeFile + + + + def gerbil_callback(self, eventstring, *data): + args = [] + for d in data: + args.append(str(d)) + print("GERBIL CALLBACK: event={} data={}".format(eventstring.ljust(30), ", ".join(args))) + + def home_machine(self): + self.gerbil.send_imediately("$H\n") + pass + + def zero_on_workpice(self, guessX, guessY): + pass + + def move_to(self, x, y , z = None, feedRate = 100): + if z == None: + zStr = "" + else: + zStr = " Z" + str(z) + xStr = " X" + str(X) + yStr = " Y" + str(Y) + fStr = " F" + str(feedRate) + self.gerbil.send_immediately("G1" + xStr + yStr + zStr + fStr + "\n") + + def send_file(self, xOffset, yOffset, rotation): + #Set to inches + self.gerbil.send_immediately("G20\n") + + self.gerbil.send_immediately("G54 X" + str(xOffset) + " Y" + str(yOffset) + "\n") + + deg = -rotation * 180 / math.pi + self.gerbil.send_immediately("G68 X0 Y0 R" + str(deg) + "\n") + #Set back to mm, typically the units g code assumes + self.gerbil.send_immediately("G21\n") + + # Turn off rotated coordinate system + self.gerbil.send_immediately("G69\n") + + with open(self.gCodeFile, 'r') as fh: + for line_text in fh.readlines(): + self.gerbil.stream(line_text) + + ############################################################################# # Main ############################################################################# @@ -653,13 +738,17 @@ gray = cv2.resize(frame, (1280, 700)) cv2.imshow('image',gray) cv2.waitKey() -############################################################# -# Warp perspective to perpendicular to bed view -############################################################# +###################################################################### +# Warp perspective to perpendicular to bed view, create overlay calss +###################################################################### +gCodeFile = 'test.nc' cv2Overhead = cv2.warpPerspective(frame, bedPixelToPhysicalLoc, (frame.shape[1], frame.shape[0])) cv2Overhead = cv2.resize(cv2Overhead, (bedViewSizePixels, bedViewSizePixels)) -GCodeOverlay = OverlayGcode(cv2Overhead) +GCodeOverlay = OverlayGcode(cv2Overhead, gCodeFile) +###################################################################### +# Create a G Code sender now that overlay is created +###################################################################### plt.show()