From b8d32c04a73ffe55fce4dfb458bbb00cb5caa22f Mon Sep 17 00:00:00 2001 From: Martin Ziegler Date: Thu, 4 Dec 2025 15:30:19 +0100 Subject: [PATCH] Started wiring up AMS module --- .../public/icons/extruder-change-filament.png | Bin 0 -> 74158 bytes frontend/src/api/client.ts | 74 +- .../components/control/AMSHumidityModal.tsx | 100 +++ .../components/control/AMSMaterialsModal.tsx | 560 ++++++++++++ .../src/components/control/AMSSectionDual.tsx | 845 +++++++++++++++--- frontend/src/pages/ControlPage.tsx | 1 + 6 files changed, 1478 insertions(+), 102 deletions(-) create mode 100644 frontend/public/icons/extruder-change-filament.png create mode 100644 frontend/src/components/control/AMSHumidityModal.tsx create mode 100644 frontend/src/components/control/AMSMaterialsModal.tsx diff --git a/frontend/public/icons/extruder-change-filament.png b/frontend/public/icons/extruder-change-filament.png new file mode 100644 index 0000000000000000000000000000000000000000..c709c2b51de49c9da4271280ddeb59c6eb62be54 GIT binary patch literal 74158 zcmV*CKyAN?P)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vGqB>(^xB>_oNB=7(L00(qQO+^Rk33QF`Pn}akPo&5uWr`9dQlutJff+_XlmIp)LqTE$0rD9vB+gxu z4-iPX)I~0Irzp0i01l7Vv<&qwm5aOo9wB&tA`p-RsO%<|9RKm z)!oOEs=LUd(CeJN*IMuUywChR@34lw+r7KJtATel@U8~l)xf(Ncvl1OYT#WBysLqC zHSn$m-qpan8hBR&?`q&(4ZN#?cQtT-HE?u)(cgdUUwYz8pZx9L{_S&5KK0aRAA9W4 z-SP49?&|ATcduW4efP>MukLQ%ysHO~DM;_T-IDdY(J36;}?2$)zKk(rX z?LPXkAKm@kU-*UZc|9Cz`ZxdaZ{GR#v(N5MPL7rL;>An5(_TTZe8Df=x=;@_e;O@_y1bSfB(I1PvPV{N$l;?`uf+t_H(yRPrvl>kAM8~m%sew zN3LJH_QB78@$>)X6JL7bzj*4Yr*`jo&toBRE&=q~?zPwQdHwa>o%~(AaB=t8qmS+0 z_xSsFmo8q66P@m^y!_JcrI%mcy`EtE>$ZLQ-+XEJ?QcIDvF_~7ojW$E9A}R%?%cVp z{I_qP?oLnd>|VL@>R&hUzoYVePh_6|?@#~qqfdY9nfL#_|LpI5?l*qp*MH^CoufGZ zYrlN?^5xyjFJ0Na`syp`X>RR~j?bA{UwY~C?$rd)jq5jdN0IR2rH2fl#~**+?$V`) zcenYA69N0^V~?d5`snU`-}il0{GB*i+8pHzhvWa~T#hNb9gri+S_f_`07`ZT{wRO^ z-@O&a;(*=`j-v$nF3v14odYO14t>t=qE9hs`;`+Kq%92C?oJZM>$_*4dv5pItFP@& z6CBrXT;IK(va3lV*KXX{-9kSBKRG$sU3%!!?%{_X&gY@sh4fA*7fyDMrg#3pcI0^I&}+K~Q%-ah*$ zfAZLqPd@n{eCwHK{-@_&ckZJZ{57cX73cY5FZ9^XCk@WXMWqutf(*LIg*d{G~GJdShe(xq`yJ#w}K^&B1& zWc8WsQ(;ggC^9s>GXVy;{w-X-D7d{xQokdR>S%ybc5d*}@J{d9(nZ9-3Pz_0~s#=!0v~B z@P|MAfyX~^>!Tn0=yOH*z5o3{4&na%&%g4EAOHA|{y+cQKlvx0`oll^e?NQk`mNoy zIQ)%_-A~hVFf`*sufulx^mHtHv^$RR_%L+Ih<_aVPGIu6lc7s8=LvPO{At=-3hxoVDpYI0#;ZQ|q$X#*gYqb27WFGgxigJ=uizZN3nQJiH|sR@6|H55X-|BSZe} z)v~n*T5K#_Rs}rrB^XEqo-c2w9iItw1DD!*zOF$i$XkX z@Jzs-o1kHseCgqbOjg$uxR+nNyu13^wcS(ScO4ugWWIX(&B4`)b4 z+v}0(#?70B8RrzG&d))21WN-X=R+U)A7V(B9_CJXWR!=$BRjYmUXc`Fgn=XRBOZog z+26>J?`np8I6OVb(gPB7ILT3^V((D!;zOYy9VQ8#p5Bh$qpOSr9=e!?iwqGTdH9jt zLj+QeDL>8-n;tPTK7IZAjnBo_|J6_Y#7}-sfxkz$2P%O6_%omR2cQ4^7k>Sl-~49g zHZT41tvJ)I80MvyUfjKq;TA))SFSJ|sgs>Qe_{7nhGrLaoC`5v(<2VjtglNO95_Bh zIs)h<9~~>s!#s&O6g`ny7SGDI4l(?D-}|VJ$q)^=7hifQ`)fjVl!G_o-L$nOH3v>O zjy_W!NKjAP#~ypn?!E7Mk2Ie{z6`_YCE)`YMtug!v;l5UrPL^SIL=D2BP%*Prq{@^ zZZ9r)Tr*sU`~KBPXkBxb&jBC)_um?Sn=N7jB{mhs3YGbr~}LLxP6H6QfF&45hdVB+2-j2 zO%SvgI%In3-@HzP^r*$#UZdGw62y~{+MuFr;=JTT=f+9oNqbfxucU`%KEfh`L(~j) zS$TuTx$uuJ=q(KR1kkJLfv+VY+(d7~i-<=6=~sdj=PwgAdjDIYN6pO}x9pXx40O)s zGKoV&htC9T@VuJQ0}1fr#S2;V`1Yrsd+xbk|382GpZ&o5-}eC%z@ z!E!Ub#Puj0M?Z}-+>XPNFCD8-P0X;a}yNiCrNxROQU?93o;&S07aM!OfAHAJ6(^JuS=1b>73%(d3Tu9Jh zGjwwW{8;tfL9eu^?_0d7fxPPNRc%eh)XNV=(#uPPcqcDhE<^`%K0bca$ouyB3 z+GR~PZj%Y`X`r)|fq-XCk63QfX>ZY`}@ED`=3eRxZL{U%xSn|0OMlB zb#c#t%7=+53g^1Xnqx%OUhhHi7^r^4>KjJsGO88X$hvjEI-CZ_um)#wYfMZYhrobG zxoZL3)H4Uu_+-t=`ebN&3Fc4BrvRD$q2wlZR*3CibERCI(FJ(rPaCm|jw1jg0d&5C5nIqJamnGmS!U<+(|_}) zcfa_b{NfMg;`ujK!2`B^N9I7^eEOTmU;M(q{>&G@_=VjQPkhO_hZjDhX67S!LW2fn zvb@aD4P_Hl8JJ>_*KT|MPE)_9ZrFf{b`qSS!n z1V^+-zzgvhC0U0vBd z^USwg?8q%wPbC5W-H(0jW4|j8572hs11KwMN1y%7pZpJ>|NQ5@y@YIc%ov)_Tf*^HttaA@wGYjPTfX~dP?&|4RXq0bOaQKz?IDgNaA1ka+8X17kd^0~Wn z!$4qRgCuzVq;sM6`033E`skE7kO?6CFwes;o_+S)yWju4KiIu^`SP6v(Z{lVRfRqv z+kFq9T(kL!FMavTzx>3PzT`Ck@{~@PU?96QOglZg89U*EDJNUSNXN;Fw{dU-3*+Db zIBgE{!A?Y#SxraiAnGW49Yq_DF*wEGTD`%w?hor2JZ15QqZpT=puJFt5S;dmt5<-o zw1J1AhGX5h^x=b^QwDXwa@y|{**-kueIrHZK9-ipqP97LthStq76)6p$T>Pqb_hGk z7N_Zf6HI%D#~EokJ&K3p>*&GQVlUya zB0-aVk7F?WBOlr))C!Ix5G1x8!=|^P_n}83JF;5D#;V`-Yd3b+ZeDYxPIp^b-w4t( z-L{LJb8hCJYGT`qkws9tIdS*|-1G!CFp;p|P=mbzivvomUv=ne3xCD&cIO#hc8<4X zZh)l?IO$Pw+|Z9I4V+_E8Rdbc<^!2Z*@2k>gx-*U0#F&{C7UdFIE_uc&NQo=8N|gV z>ERebM8^aSw7_czm*DSWge3-~yeG16WV!sxYgcxcb6xP0pZxb1wITZ5-`)2BLd2IZ zzvQ@i9&*O}vDWNAg#fc2Im@7u-kAQJVhvM^P za19=UE}dEZG4`o{(Cbka!_J)NIOBh}w4-G9Zg3hNAo z<9l|sUb`8*K%3o%9WRQpZ@fxe?CjwS3mmEkNz=iJP6x;4WO?-`Hf%t zk&pcFKgw16&)v$!(yLrl)1a5Od5pK;U|bw3V|NUE`zX$e(xV~gC7CmEO~=K8oO1^P z2HBQQu>`<4%orV{onNtMnLL!q%w$iT0`Po%W+rzT;6!(r8!(*Abxp2cFq}IHJ$ec* z%Gx$9Cj^W}Ml57ke47(s%)Aq94K4*#PQTnkea_h-r{&Zv{s^FEWrscCl@HgA*x$+p z-kZ!Dq%Fb3(AnND7k4AewS0OZcHqU? z9u~aa8ejygmmJ0om?r06q2fZ~Vhw`?X*Dms!4^jLx$tJkZ5OzTCjcg-r+G*cBy?;c0hiJ=LBiJ2Inbz!3|%jaW1qudz~B3*8OL$ zOyR8Xr!!_Kl!cTFSx8a1o8gzF;Yt^7oif{iy!eO|Kwac2RohZaFV)42%5MOn<0KH5 zyK~Nch^(afhZ@pSdBG`nd{I}NM2U+w&IOb3#MPiw*ZH$oqAc>P#Tyrsk`Q_e7mFbs zwgbz}#7}+W$vgn~{gv$j`M$3Klv{POZ2iP%$s!kX)mlG^wy$Sgo9o^N!kTlC#cvZ! z+9e8~9*69YnOLdeW&o6radD_)mdU8qm}Edcbp5#Ca>51}ixy7q8E*%kERTbE&sLm~ ztMdkePQz*x#?9w8y*@YbKZ*$cwUxnPfLA1Hv(LP(`D z9_y5_(}_RwSDyBk;Otxj9^s{n95`Ve5GOy4Y+Qi$MpAImCb-9?b!5ug6{$9eON*Ys z;h~8DM`uNjL={>|9Nv2NO-N05*D6^wNPQG*%#ShsV{> z;f@II5Fr6!=gQk2@5Re6?w))0`MYU;m(T8N0DbG*-`+j5!U| z4QFgm0{-(3;d1*63mdoyD?+K~_MOF(j|jvG)^EN==VWREpE|2fS>C}>p3*WR*n3a=<1GK*yvfa^bTjn4Bkv({^trme zYQo;eUX~#i!EA3#S(ckg8nGQKrN=i)OP=8;ydXadG))KPciCy9zv)|6{Q|!7w`cUe zx~#q5$bt=fhNQ=_0|9hCGQu`Gem!^Mzxw)v^#tvG4Ipp4OgBtd&AbJZp1;GejWI6w zwvloAp%-}Qp^Guv#b&1T7^)B*r`_Fo87)V1hc& zriaZz9jJ8^AH&mvgm>^Jnw-1vA5BbvLyF-~AG4?|CnqG0&Ou!IPn*5`?Tzf=se(*4 zdKrQec{}8*&UN0%&yqNLC!v&yCak64h2ovapacd2%1eHn^5!*Y~**cX#KJ z-5r8YVmK>_rS6Z+JRXidsdj@_!lX4SnxE411h0OQ zw!odl_PoP_i?bwN#X={3mwypGAlrQnpk`_ZMzt1(WVx6i!ua$|2^tj0=XT}}w~ped zc_8cd>CN5CSFY@?@#HD>UfdYt$7T+PlC79WF`@SEh8)wYy~cAbJgQ)(Ya4wdA80n) zGV#9coy+gZ$_^`A%!j~%r?9+DuY`eL24CiJ-nC6YV{Gx|dxnq=j0Od+Vvh(#y^^gC z*(MyXa>?Db6|$wKaLLn46@fO?)J$Kwm|nlk5np~FG}4>EH1_4nl- zH!=r_iNM4j+_CKfNqU{*1Q_}!Yz&%_NWH7q2WPv_0Yp~AD%s4IC=-1$Op0=6{7vv+ zTwe0w+CR_5WFpDq4ZJMl=B*oEUofjhfsTpAiD4LOo$s6_M!+Cf;_y5Yp~FL7;|4rm z_J)%Y8Jb*7uwoK|wD;OA0|hR@bTdKC6RSLUS9~LnLJ=V40~#XY6tmCJrYW=mW04MD zP%oq&_=70a7jC0?gEnn&Qc96Of6KWFUBCuyd60+>N1|f*l#CzUFvsw2cN;Qix~22F z%y00O1`7%2Z=EE8oW>4Ue4M^p5RVL?0Gz#d(xYd4Im5>QQX4b@U-V7@_kwzc$4Ou$ zH0Gf8I0O(2H-W8Uz=7A0JQYZM57KsD14uWYaW8Vyi+k6>I0=Sb6L42!@@Wt9s^j9k zdB+F0dUIuU$nXZ|F))(-GK$;*|u31 z(46GUBa?>GR*aD|4%SA3Nq1;0`t22}a~25Vq8&b~?>KMfrY;;HkR)?VAcPP@To!vq z?4X2x_0L^E%1;}I)(pdOcw`b&KBT#kG5E0r@9evrQr--7>B1%73gq1>BsBx4bFk`X z@aQw>U$&=D_s-C*h81qK z%0e<-xrSm$5C@%qg%l>)RRjV!^rEo~A3 z&*G)Pt!+E5NQLX@m3sz3z@@A;%yYFf|2}+(^XHf(=CXF=yUi+N=ouu^FF(DY41I(& zbUHsZtCdb|P0P_u+EdMau2-+J0Cen~Dcm;YOJ11o%$%pn3@uP~2h-m6vv^R}2VlFe z0fYv|-9{003rlrm6hc?P&7(Lgj|-5Aa{YmNvY;>oKny{He04a}AhMicLxyYD?p%u@ zdAc$aQ?p?;>;yL2=M-ZxiDk~jMjgXP0(AwN7fQ+2EK(e^BGeT)g8B}tidZ$92_Ws) zo#oEC)=&n35bThxlEQ%jf?RZg@<+)E>?Vn7sR#@^oWF_?gKEg1`G>$(J`w?VqpQ)A zq@vf(FCqg@93B}WyQ1l^xR1UgFJjRX&?{UBPHuMsC(f%bT#fa3l~DKKZ1**QG&UJi<&%j}(kOS-gDS9p zeS|DT009mh&^GqpLzOyZ+4Y!7;LeV}b+kFinltRak zeNg}p%emQ$sL3%Tx*vS#Cj9} zytk_K7wIndW+*oXq=#gzPQcK^5ujXPC6HL5x|6KlhXM^D(SmN1Uw)R%m4CU1yzbqv zELjrg#fe5=Ww$!4=J-bb1T%@}JUwM5{^?Pn&3gt=KE0w(jm|2ZqKl^PS>2+m2GiB7 zINso%KoqAFC$X^)E(40-VSW_8)vG!#N1l!zsX!ZjXGDY0z)d+Ie2aIKMIE)3u-vV% ztfU*_Ks^}SeGMQqp%ddEId<5h4yven3fiY|6r02GvqGuOn1Sqs!okCrRvL9@x8gx# z8>pH`+<<%6+}s37Ga<~Lz!+jVr!6hn<1D?Na(cuBDXT=eW<=dkO)&v@u}iM_$@JINN;8u< zP_-ZVA9Fkw}hvPle zIFt0@Mc!b(j*_*$-4SyLGi5V2_wubp0-6Ed1VQG-9guo>T>QoBltI4NMlcLbbm8H& zHUWuP+|Ut}#;_7P-iZw=K*ii-bvt)z^E#??C)}RP-L7XI4*&ol07*naR2qvS-WmFl znPGKXcVPfmM^6ypj(O)(f#92ATbIhwOsKF*2OiEwK6z|O%FqM+G|^OUAWG4C@G3Jn zQo}J!2$h3LN-jF7&dG4Q38uqC-7fQ#wO$DDK6nSHY+*=N{{5l z51e=rWnbs%QgVB{2AvGmDR7vPU5*qg`_NzVm*={l0ttp=RSpj9Z6=vhy7Z}90=FCy zZ}06xkpW$?xN?qPXQ)Gi1lMJ#OXBb%W@PmVVG&n-3<(J)%1C4m-8LJ(nLc(IMgk|} zEkZ}MgU5!V^7+!3)r14!55Cq=ed!zY z%4R8Bk70l%6WW};hd_gCD+q~}S}#3Qb@s_=Irny+na`}p%U7T<;l34mKzrd73WM7f zC!A2k4UPs$MHU2@0jDzoJzpQLVzI(?xjTzT3O|96*tJQ|R5wV8Xt+ z`3#vD#?te2gbIU%I zSfS#U6^3vcGp}f(LjE@T<*7r8E-*Nk$aSGSZEL$OFbPy>B?x@`5ISW`yKN2#MS7>aeB@4EK9Y-C zK8$zE;K>_AvuX!d+~CWbP`N+QJ2U2SgE~4mu~QjaE2Qc_v^J}5?YIXqtMQWPi>G`D=kZ=6O)1}XUSVeuolnQ-q7 z8~euL1apBQ_wo4*OJ7CooM4Pk?~~h{6GYteMbJSq^*Awb$}#e*369J0qEBQgz5%)> z{@lS_2NpbXpx2YoiWHgc6n@hev`kXYD$=#)Kab5zKWONQ=pk-qDU1VVB$adFU9~e|IZKS2NFkgLnmJ0c!*e{Y@<@c0^57>79 z14uu`F%vvoI3rUs6pBNb8My$uv_1N%TG3&ZyaGdG;|C}aWghC&@)RsPt7yyw?bP|Y zEbjKEgGtuqsaS?YaSmS?GHt<6{OA+#DNLQ0MFfL_N2IAo1_L*SpNx;3ZaR|vX6Oe_ z-lbv{A~%V^7nOkP(v2O@5vDDi4*}*W!$NQ|ByBytkaFCD9sxp9>hRKGTUO$HWS$|V zxcBs7hIqWj&t0aHRmXGD)$QEO3l)22kUFuNcFF~!;)4E4bRGu~I)I=N>`a4|@YD%B z2mo~Ta#wE!^|XPGiIHpMi>8FX``djFpiN%2X((t1#~o&`Di1h8@RA7}$IxnyIvj0a zH!Tv7Ohjo(<07dWf`+3O>TVc<$#RA;;PoYh2?F~W84Yh;&mCWZHlKR?K0^XO4!tK@ z91PW@I$S6VRizIV?nhhhwPXUQjTuI!t zP?}*p9EBchPsgLNR-L4=@Bwl56y&(^ zp?ww>_Q+4GP4{q!o!J*ayx?1bv=tYiq#uy)`x-!Xno8O${Be#b7S&YK?A9>`$hl6$ zN1y4Y1`pwIf?`qi7|#ZPVGs^U0xHr_*SyehxWSGtk`X`*&X46Os{156O*+ow6X z*Nq_=_~-sLoxaY_Vv5_8!G_ul0%0b=l>wwM0535d80*y4zXjqNoE&qgXnx73T0jo; ziTu)L7)K#Afgw3fyBQawcgrlM&CqYr1Mz)-1E``$cac0gLDyI_ml!M^L@58TQkEp3Oa?6bML)%xf3{iWryQLCn`3WP z;yEhbwFb46ubW$4Mblw{C4+?qb07E&@9V{f@=Wi}uv9tv>{EL>ctrO&av4$YmUh1# zV58t@&knBIU8K&Z%Dd`Dn!yLA$`Ah0f9mOQSCSkp)9``V?t1`rSb~~$`!Twf97m}h zGy=}U=_nWgb&Np<1HtIu4CAa{!-bTC!@h?KY4F7%hN9(^Q7A+H=aa0O6p7Qs-{M;x zYH^RA1ETPPWA;68&}d`v6=@j>{rW=@#QQ;_{4G_oFh2;&OL^l^pa`7LvF%fFdwP@3<{8V1kHI4Nnz| z2}?070HhM9!ENms^3f^lWU50Ctq~O2_A{(V`3gi^Lu`55G+U=$P|bxQf~QZgc2Dp) zEXrc;xjbrqK2N0{We+}X$mCtxwB;ooK7}e11mFY@&IfH0Kq|{>_*X9Up%!fC8$9J7 zXT_nVl8VqzW91%b^#%^AduW}+0%va--q)`p?B7u^4oNF%(_rxv%FcmUaU7)C_1W*h zJHH{=s()d|9`vfUB|N-A|G>bvx~LN1*`An9*%kuYFP?PrRODNBD?cZ+m!G%To_YG4 zkACiR|MHb*zV(c4|M5TkNB{fp{_gLps4a-6x8=vF|M?&M!R~MWoxcO2x3!%aK)>@l zzjNoMmoEEu8zn_xS%W7WCB{w``oQCl?>_P4KlW!o_K8pY%{Oxb_mnb5#{ejb^KmeZ zStWrh0|o^i%?c~cY36ahmLn}y2m5q#38J#?+kzDdJ^RuBEbHGB;8UAxz=Ui)YHCB)rVzVfv+4Sp`#gFmwE zx4zl+;fMK!ki+t~!uXdfJ2Qa({LlaG?&X(W@_lGksPaZz&A2*avfV4MeE%+Q%>St; zzwzXM`jH?0$p3XnRu+Mau}4)FbaFbw34u@;FnoYWokR}|$C(BOyoRhJ7dLboe{*g@ zK#B7htv7S#ya-@kheD6%^8U9wS;_OYvh)P@K-ns!!#RHOfVcc`v#GD$@`dXJ8O}g& zg$xi>V4JVgDlli@GlP;S%Kd(b1D^71WU1 zG+Y8tCK&y;JvD$ZSK22CSgjK1yodPp z{J_+;2p*Bbcpr1-xLQRPelYlj7hd3HdB2_0O-E%eEmN|5ma;B^CP2oxG_jixGE`js zfQGgVYdp#q7CcZbctu#l*iIbma=;uQ@Gc<4&fGMqnZOfbIK$f0hD_{l1({D%lJ@Xk zN1SF;%e&NgY}!SS{PAWUjW3ThsyxFINaURweo?~Sn9Aw7_VO!&@M3AR!Xw+E{Ls)5 z;SE4Y@MZJx#N4L(vsV>>x|HKfY}~(>6Xl?7wsN+o-=|p#7-~BwgR#Elr*!4j)*PU> zmVgDyBri7oVDwY{uhYuX-u*zH;DMjEPWiG)Dc$v*r1moat~<)nNiRjfMgDRQ{q@2c z{oozg&J3Unxij~XD44r@QQT7UGKBp{1marAl?2h{4ENr+^)~h@!FHoqwLJz=)j1x$ zN{da!Suq9~<&7zdfNG&rI;%T2Pa(q@pKtMn7%^hx;Ev<1=awB*JI=!~L6oi^hr68~ z$)KKc^BK6s-#~8wl@?CPPo)@0!xu8}<6f1ad{H^RI`mh$>U3KH*50Qs)cfu#uZ|f$ zbV!C@{9z{bP<*^dZfQ=3&y5OagFio(b zAKos9z3hlqXNKHplX9&GuTzG=qxgM~3;)AAe21>`kNuPe+w%eAD2?j3+FrZ*n)ZG8 zk%z0-(ThJ1W8{QZ==Ej#c^lCA+$j4FY-a`#FVHwm5P4M_hfo+OlGV{{&a)%RUw!Qr zgXrmRKK;mtfAGVvExsJB=ulO_8lo618Vkq>hEsXekH&&R3Rjk`VQSpzBP3IO#|hS? z2iDlY&+=^`c=hjkAGX3wE5(^jGwD1yC)q2@+1ab`c|2fUt!I3FoW%$4XGRHO!s{C$ zN}I8@vTwuJAy((Ym~{*sN4ViicJL%{a=a;`&PXqM8pZZ~Ln=_3_0RIp3B4&F-uij* z!ehz-6^1#yyplJqdP`3gYaOf@enc+bN- zv7H$}ef<+k&&4=XLk-9=4($DE7(T-Bw#nBr+{@7BZwHW}+-xfs<#ZftQ+xzkZ);J- z7;793ScG4Wwa2C|Rfc{$b^&LDF=h5dLpQLjmhr1*#|fgVS6}zxF!5_d=31QQL|-a$ zlzx&R>ATB(1!FUJju?XR&TQXSI$g#V-{@n9U%g~yYJPj)}>10RYG9&>f>)mL8KJ^TFg29ZA# z>9wptBforwF7%^dpFoA@^gINpzdb9B4*PmyiHAL(PzZmtaik)Nfq@A4#ktB-1Q@4d z1KLg;+nn+y;zek`|JJv@wRaf9L=6DmL+@f ze&;Q4#?z}aIN^_uq_WB$ZP~?B>-?}$_1mH2S0g`?9`e%m6M*(E`Q(XOhAbpH9RiPQ z^w?uk4t<8BlrQ~zD-8tI0gjb+qo~>{)W-I`cGLiM*eCykEA+->WiQ{bF#dq6)m{VPg)jFhYuQ}wD6_uG?CJ^3Fp2Y=y(=S|}LI2Q{%zH6O#f%_9W zT{Pe|(Km1970%hS*zitlXZAomU`9mWyqULgVqloB3!o*3arg&&GFzVNyb`Y)%o2|!H}wo&HM2WNswV&$i1Z6EoekNolf`dh!bd+DW@lfWZa)aAW^L2hqkn?5Kl|J`$)AsqDGX?M~CUH@_n`Fn>bNvKxQ0=m*zImb&v>s-!zEljluiuI7%m5;IUdhjdT+fA6l?}BVpcrp+agRuSC)sPSzV z(?h1J&N^o?ARWStvx-tJ*$koAOVCa5lr4m6qHwfEA>?}}>F{S@7M1NZN?WrM=c*dG z3?2e0^|IDRwjsEv&R<`$$c>peWehRHB(qvR{>&r@dNRw}5?&zV%bxdjC>8az_ z1DTN5;ZRcXc0q*W=tSEiQ`)D#BSnRj^EEUy<)m1dsbDiWMapQ=x>g}u;Tlpsmlk^F z^A_7Hd2;shi!Z4ki1a5w>vwPiCCpBH-avHeMgoYZZ{LCK%m8BicrC&6x`fJDuP30s z$xIl8@pArd+_?58b~KtYbA%{M0X$&7m(40Mrx&r$D^EEwEED}7MWWrM-nEM1zf5Gq#i0rz%zWBvY{mf7Q^yd~I&Ad3j{I{nbhhNHD0k9_4>eTL} zieAGenyq09{50cLN0WFC%mD{DP$v3)P)D3zR3(s@JBWkD5#VrjN-$Xs4Q7%9%h?z6 z-ne7lotAd+RY-n_ICn6FF?4iV?- zR=A8eFdXgMMRP5O#hrX`c7Mdm-#oqGJmCCEE)gEG!beixf$huyf^6^NZEpkP4gtbH!Xt-E*(!~0 zKOf$Od)HQ_fosz^ItM`{laPFNNusJKY$FfPt|0g~iN-c|zX>^j8j3K@=k z_i7xxjuXm!H_8l}C8UzUDdp_R&IsBIw}c?-xlH6}&PFO2fCsAy3PC zvyb7M{Nv~p9Xwp-vUC_%k@>%sb1oV98(&IWoV4;{V^$(>-k4klErTuaxyHm2ISzN; zpSGt3vcV6QRJxQ^9*)&Rl*qLfmC7j)$WOg20kqoI5gK51gmgRV-``X^$}dqsPrT^% zjz3PuQ?gw&=0LaL+qi7P$Py!072H@h!wtlS=Af`tzkG$|i8i^be3^K}m zaRWtAEhLT8)b$|P1Rg)gag93-x+9r@8q+QM`(Nl7P;BTTN&ZDy{oIA<9!~Zi{f5Rm zLZ~sGtdc8eWsT`z0zUYJxtsYBS}tb&tIz+dUpYkO@B_YeJ`4-K6qx--b8~DRRP&|t z&UvY5+ifd-HQ`4=biNib5$3|#wS2^bA@gf&jFsE-)Ja`on~y_*p$UDincVQsU7kK| z_O7wvzpGWg(UcyQII;Dx#6HZ>n>B-@KRqaaOOWh0p*XHQh`!f9$K49rvn79FK|9(D z8tWXDldj7b0Bh>sIz>fQgOrFwOlvyitzI<{_@5Myk=u(c1Zi`e=1NA&4a5s zWYkBdJkY8`IO&4~x}J7xq4CHDhiqPPXDL=AXsW=Fg$ihi;|UZzw|uPD0Rc=7Sj)cZ-j|>gAh$Kd(h|>5pJPjqedO0~*@B?Sp{HxO zg7a%FGCvNRYFKnWBuB_oo;fy)D}yfA#Sdu$N)NTyzqB{{WDXERe^t<^xS%}LlpkK# zCKO=msO5F`g}(Tfz6_I1@C}WLuEXc@;Por;jjf@9sM_CV65S(s8%Jjj;TrB0VD$!d zhSlA}3dSkZH#6>j{`u$pY#Vjqo=n6jW|B{ZKo?+8qBBeyT3uU(Zti5=-SPty*YWcW;aknlMs6h_QpW4e1cGkU&|5Q4jCL_U{Ow3biKnTfi>5=yz5! zIOOKU7xMt#;bRSeq6Y>xTM)^0Ro&H-#g3a`5`cXNGN-`;i9@?8lqj1TAd3t!lM{i|Ou-vR6mz{t@N12h;%7|yB$y!nh= z#dPoAy3<>3C~*-K0QObA+v>ViW*Fk`SKgm+c#Fh>c3)(oDep_UL1vZ%eQ6*mf6_wg zAu2lp?$s!66ykn%>=(Z5`+fkMZHjHejpN~YY1_am%%ZCk=3ll*0Fw4g{x-2vw`m48 z1X)a@#~jau-NtMad`dYUQq0p5{R@}RLdE&$@ov}y`ZQ4=0^8pCrS0-jw>`8s<9PJ% zUEZ@SZ?Cd&Y|aL+7FPc3i+1QQrreK4a$NaG9$E%BojZ+Hv5tSxCk(5QsxBot+aj{t zJ^SplyJw&Kb{2|qyV=O<&H+PlZo@Yvg%|>&<)Xs{e#9$0DQJr6FA1FwSM zg&vyoFmMo*t4D)e>8nL@{3g?=rShN$P_hd$-gI=F32JXU0*N(46X0o(-KcrF86^u~ z_ri-W>@L4>**iftJp>I9+h;}+oL5}vj1o3X_kM780M%Q>Y&T9iLQc0j#c~Xk<>L)L zGc-dV;VO+QFJT%Xs;amFLJU5g5SjQI`Ie&$irTMWDH;XXW?n`T6L@Z6CxXU zDHZ<&0d?6be-45hC_iCRV=fqES|_&JZ(BgAi);xJ?|&O-qpXfG^<{s zcdZ+gZ{DqH zl1jY5+jh1l=n4YCq79L`y7CzEi`kLqkiIQ909Z|%IE_vWJ{%gSU%ZZtoBYGA8l*08 z`rI3Czo~vl|15k2hJ}}2otii1X3+flTFK z-;>g2B{l2bAisQsxH{%TPb!_!_VT}diHy|$N=@*$v7H$}RMZvX1mU!Q?PWIoSz4z= zEBfhO%npn>0SR>2SK|~6{r%vjCJsFBo92@V_RLU)e=GWAw(Xa=%97BP*PBpnx91 zqe&{i2TOiv4q>6*%Kqi6dM?#P8~Di*9j(vDgzEM`j=|;-=B5lxS#5Dn0h7rf9YQxmz9yC zl9cS8#|9h%@7>_}G~0rxc)OHZ<3iBR0p}TheJT|h z%FK{Cpbs7Yr|p6jeYvSqfHQCfpE~dWWv&!Yh32G(G{P}>GtlC>@^6R=e2?GAPYiQq zpC4ZCnnHDhineQ8$j?R@a-n~110UIQs7s(LzZFOBwg0pYOv{&UXjA#|Kc@g9qaAG^ zsCK%E8(sQc{L&w{2AtNk1&=sN{{YK|?p_YgTttu|3D@Bl;|k=&KAVorWRSD2Yur3o z#^$L%c@|BSz}&+NmsN|7o&UCMxn+S+UYa!L^y4qa*t+Go$> z%^c5))4s_dfIdsNR1i4qoHJ33Awi7%#pnKjzNOHU)4g$*{LY5Kx8VmLurlhg6XBt- z_2Qf34E^u{-HpEV-riOW+~1d@{CAlf8Q>PVV6%L-tmXC4p^t63=4Dc?-WF?W6`ui= zFPoz&jt4Kt)FY-cR_81_P=2_|+gq^!=Ax2jHkjHz)FzN1Q{Ho=FARNKy}j+s0IEH^ zG-!9MQ%uT$em?6eHD@xJzr(kk_+D*H>{vAQjIT#PbtKvmxKdR(@TZ(!f@)V_(9X~q z_s$tu$_urej;(8S0)Y2f58u9`D?=0b*1R%->yA=E0S?a5HS}{=a*@(K&;aTPlS7f{ z!k$W>o|OQ!ytqgZ%1=Sitb6ku41%_;#j^!Pj(O4)w?cm6z0OhGkXzeE|FG~LkEJ7D z1Tyo!c|F$ReaWbE7k1HC3+g3D(F08^h~N}=?xB=uq_(z6qzv#%a_CB!;OVVEs2U8J zr}19D*|>ObAoEXBVx`wOgfZ}=tGO-7=7p_2i*G}Aj|XQ55cnak1&217WjUkTsVZc+ z+T!gzyI=j)U$x+gC%zPx<31_`0RvyzMjf0lK?s&{CE*I#;3@LfjssB*2N;LrwAE2Z zNpOZCy(_zi8Tv46LzuP&RMX0WEH{`u25AFHRR9LB1H13Q+KvizWA+s*<>AH^3o*1T z-@Sl1^B)icn-8zym6OnBHO!|$Nj~y|oM0_%pDm)?s&`;3k{P6ycixc|nRpBu1$1~# z0tiuURr<1|jL1Ds%&Cc_954A1MR{{jY4-9LrlM&7;L=(HOFhq>t<5K()gQi7zE6bBK91eE1GnT83ye9TKVR7dK~=tZwWeQy90WB-Qcp$-)e z?lEO$DkSA~rH-%dCW^yv0t?3mPU86#4+Tw%#kn40BQx^8HfS-IQnzI3v{yuM-~c#k zcIWc4lB4{@3M-S`uh*gSkdl5KP({!t5c=FbZwT0AR}{fX0vf(y&qbeHkhaIb9@NXS zid6p{Hs>hi{MH2t3*OlD@$4OqVH91566DL@unX`v4t`aE=|X)J=FofS zB8EN(qUyIsLaD{9p5C6ahDV-rK4Y6Cqv+L-CK_YYC^ePNV;vv{xRD3zUb_6ERoot8 zF#@><8Q|-n%6GgBIwT~n#G8VHwo_6cVm(TBcN~kRNv<=d}(swWgSgSVYeb(CB)oqbG)_eV1FA zszaCWrRh2Z`d?flL+C^&Y|H;V&PR3TbMxSRZa1#?24RycZGq9Is&vv*2RP#LhZy$#w!7N3vO`ccN{A0I(hTJgp8HhUDb z61}CGH>9cubrM%6Q7l=+HfduP9zd^b9#l6#;Ws?Exwfm)7_$wHoU*rN>2 zfswVnj2p6hJUBCeq-3V4(2|_&K-pevRn>BOhD_J*^yMObc?dFrs~q0-`#{C6^VA5y zHRE-n&CrdeBRS5CA^E@;dY?I^^kuBD*5|8EKjnhd1vJlS)L~ZHo9~=e@M+Jj{Vk)v z8$z{1TI#n8!LTZQut_EoV8k%PbmEj{;ynJrW7a+ex~CdohCMM$ig`^Hoh)+jIhTBd zJRAHFKpWLRH5Be~9)Q33aLGRbIRX`j&WQcF@iVkt!iSNC9!CwOFyvmyJ?RNM7eJIB ztb92%rcL$~&=&ghSI%o+5L35;joyh-%`g4Z-*++eF@ucaR;Pav;W zd&qk`Iy-=HQXLx>q7IJPbcC7Jj5V!6V^hvAShH1WcAEaxK;WE8MeFGzL5arVU2VdR zX=tYA`JXIkS(Pjzz}J4^wSd5zIx}X0YP7afw+aHAbw|@j3SA33kiSe+j-rC8Hs)OH z;LEP=QbXblymx?WWvsA~6?sk{IGs=w$P<_Z%ee#?!@Bho9ecP5NDE455$&(RfrqMb z+#GjO-tz4mXIdw3B|i)Sz$5SCs*JQ+4*(v@kE?F_PdkU4AVB9^6d-F#`RFvm!J&$W z(q?~bjM)OsTT@@T@@nwqZhNj(rOct3i6*$@w?dgbr4Io7L$`Gs&^o=x{h0wYd7|Ah zP7LAEPNN#`>bwqykYc1+#fZ{^Z^WeH4tynyqv4e=s#(5<_=#RC=bZ=%SjJ!BcFyd)zN z5{T2lp$wLnqw<(TU;0O|5o9{r()dWl0>%NcJ^cedbW*UgUv`2{pu4No05x*&lAmIR z*sd5#3qec2bG8aR9LYP!ih}HwNP49Y5@34H@u1tBy14l6f{a>$k%0v_BOHcq#v^oE zEnPpB+a$+g=qD;a;{9ehZJPlq=ska*9YFNkG6zNWGm&PvlXEfx6$*%8!*vx;9mYgm zn>50yT4e%H3e;jsCreO=(-*_03$X}3#%8zS4tztUK&1#4@Dz$N+rUFf4t+`0!Amv( zq(gcEegkMpMv^G+{m08n4k2i>f_0j=2)V$blR{U7Z<4>ZL6(GjJ}wY0 zy2!KXWtd5SXlmtb9U~0z$T^`9 zhH;^YS_OMXJMa@|kb=&9a_$o8loUIV`~?@tQQEu>6x!tyoQ&B)SZ6C&rN1yNUU>=J z;#K`)b?p^HLx1@2jWs!O6>f}_M>mT)eApXlasIQ1nQViuhVu+L0}&(E-YiuY)|efE zook{)WTnlNoB0(qH8>m2pq79ad&>FUpgzt{0Pz!@1Q9R3q_?|y?uJ81mXbUFs+@Pp zuOOxGFsdX|3jN4yQYc@tTGG|mIEcxvEmJSEEih6Bzpx5K%xrQ_!=Xj3ULt+Da?FMw zjPf9qLw0M$SNpfv?_$8-d-}5XHn%ea2)s2<1uv^%WO#N-*x`t5D?l&! zHc=vDVza{ZVmbo&a6kzbTQym{8ZC-a&6`FV9Gq5jtC#2!!KT;)6#`phw=HzdvLjDH zp_T#y2F`(D_|Nq^14u3$T5*ku_nh&&LH!+BpHW6yWnY_z%I`sN(l2F*CVf5=1jx98 zMy~}E0MNy0R{Pd%cY(+fW$no>KoS1rgeQjc+c^1sAb$6W zd)`iM3l2!uUYxWvd&)>O_6|)r*u$QMCPTlDTRBM()*k4rL*M$rP#pOyk31~-vCC+z zmm}s(Kd2_{$xsjNLZ``^1&5XN#kedYKH<$&t$QcF&kP_{0U;Qbk`|m}*v`4TaT+Rd z29!qAF-%*deT3sTc5*nq4Nkd@Q_=S#AWak@QkAgiYm_O~F-3@G@;{2%lulq03|blX zG6Yx`?kHP<99kRSL|LInzAAq8rjV;_buJzazk`#OK!q6j8%*$99vwzNld&7J%M%DE z{lXK5WF6lH-}7XHX)C7S7te;T z`bV-gS|}zFss{GOPebT~hofrGmD@R6*8;2k!oQvGGXn_DG=~O)I)>yNoncI>MiD7& zkHmhu$go^TV>PV`77s(7rCf}NXzQGqpjWm&aXymiVi&+YQ->iH!-=x{BGcf!eN zpy}E2%iZH$Wi0Rf=9U-}gn~$=b)G^KCKs1I*Fner^@V8*9_kQ4JQEOLg&I6!?1Sw;r__7gq_?n%V7!=4WPmQBmz+xeE=x8AA% zjHF6YE=~wARVrhY<*79(lk0pqM=^ZRBVRxi&MVp|!JWud5RR<#NnnOl5U1HVQt=Iq zqAM@r+uSit`Uh3t88@)9wP%GR4X1DB)0Ht!Iy7+Zb71t1K8V06&+s_%P&BesV+jJv z)Sq7Jd+QVt+HCMmoBT24@?83$Pn*%d^nkPN(5HPCJ$QW=!N4t4*Yl!FE+?#^=vwhZ zzg&OfHDu0#VBk>yLqJ`~EeA58Z9E+MD73+X`pVd5f19Vxz; z?0zRnjGX9zZKFSl+@jL&of3svuB}kzDRW{S z4RX8%crz$`Qq7n16N)Lop_Btd90=sG(r)Dr3(|%v0}N%_gu`;RLN|}K_MG_3IKi1S%?S>t8o054~~;w zV~M?fABT)d&J-_B4j%YWXV4g_D3=hZafs$*Pz}@EmOl6C-6D=pwAmXkKzQ_ zdu2m#O3Q#Q3WtWYn{zU}^Me4lSE*j)2j?1IS#6^S;>OiWmoB=HKo!4}B>j9C?%|03 zU>FEjP)E2qP6bA~`@8M60z^hTpd%@dU+rcOH;4kNu z4gZ5S_QaRKUaxUo0GuUP`ckKd>im{7aSy&xb(we@-)9C;6$ja>KvpM%!-v0kR>`LW z7!AQpI8|`|vNw4maXthjLlTFEqhwdN_{TBPFN#}r3b%I<(W`}KAXc)?Hp20}SH8#M z4K19YWT2csxDuRy9i=@+hB)*naWa2ESE8em-OigAYNLVmfYES|;l}q^yVJHSp$H*3Zg&czn{SH;8 ztp*rmu+nDky9#V&P{FT+KN}~Z& zt`myC{7V6Nd+F?g_)Z>G0N&^Ic|;)K2CFy-G-#$g6}^7ewnItWRTf9IX_4i@!yJbn zJmVtcCCrsoIo}JOdVhNIqyfl~?)YXF)_n?f>3o1lGk``a zd6w`9s?x$lSJF@=!l54=Pv|O!=+7ZR#4o=&gv+z@Ob(lQ9EgZ_MIWkK+p612R0{t& z$UpYT&zrW;Z?)6rmbZaH9F4ll1bsmoNFvPE?`C*9R$ns6Foyt#rb=Ii&N+@f6zU(d z(o;mDUzj21JGgVe%^hI%0`BxK%A88%^?2dPfrl7ApKZJ!kV+=&-Q;H)!f*cg$q!yS z$n~ld?nYomZr#eluX;m2h)xaUwpqSa-B-ejUK>kd(Vu-`hizFvPdPx}*<)|hQph2H zy}Qm_z0w4U=;7{`tbtm&j5pB&Q+n<35twjOaF1KgAq0b&cC&XSGUXd>A?|%Eg|}!A zFB(+F8~Vfh#;9hWpxNq{mQ4qBx}_GoVF9-ibw_sAu_#_NHyFrS0X2Z7U&BJb_*U$; za%fBtOAYkUA589s7Fm;E;UAgW6HKtBK^KfmR7b^XCeXqT^!GQg3Qu#?{hx(Uuz*5fewv`raq~*zqa_u~# zDy8)vqll+s4xJJxTj>ie?s5Ux+uhFWfvOzPFJj%BmGNGj=1@{KRpAwc!|Zf$dL5jB z!&Xsopr`=`V;b3Q12`L$`Do^}D-8HkGI+sfy+ntIY}yCeZew?kx_oe+>dbgLlwl6E zQ7>)2o7u_<=tynL$$Jf`Iw>jm059~*XNQ(ut_M9RjwK|rCWYwg7@aw&&$c-?@^OwF z6=1C2m=V0D^2e>SoFcO(TBjIKfHi*9Fc$p74x2T?WEE)nB{roM+@>ZRxTA) z5Ok?aWsMJYn$?i1fG>NYaU<*v?F1}8u4vE4+)Dz)XCO5?^sBkzl%1|*=?n+-kvnzv zTF}nl_4GbB@*o;F$I=5a-(g5=uT67~ho+WUUZF4m91!OW*x=X1@Rz-Zayok7{2=AP z0|Pp>H^eGg&T*ysM3W~aV}IrE*8T9p$>{IU|D2-=0!QVwL&H6NFt&COzm46QJamGMe; zf{g%C7LpG{pp`Z(4A2(2XwN(=zt?)p>l)>K^=8M>6#>8#pi3v4lLp5+7CFukoC`PS z%0K-OwqZEOb2MF5%o=)AP&Ez$+2Dj)AKN;&5+ z7XigZ4;CAkeBb_dW&o`Tn4FKYm1&8hGw=^%I3oMNj$&3;7mt!P>#p`R=VF{q4%?1Q zz^Nw49MCTZwBm!p0%ag3C!G$-Nuo`4Bbif=D2A#=#(8(4(0kn zDobw|$ZpX*q~E3ICIuEKaOPXLs<#Gy<=pN{ce0z|2QzOFt(kB&h5#cE^Z}_ypoca3 z(^pgJs^YC<=R-K!ao#4+Z7=*(BNFLQ$XQKoGWtN=*m>)f25JQ?YE}Nd_`}cW9j66j z2mrG2t^T7+^^2^dSIX_B$6DKgjoV)Mo=G-sEOMnqRN00h=|?xxr)>1Oj9N7J{JkaC zH(`Kq=y~Ng3_-dK)P*=+^9m}x$Z9sPFd9UqAQ>kkGdLA+IUDs8vBw#tG0@!&O`1ao878nnIUYwT!CL+#WAe~hpec)sKJpL!)H}|$#c0XK zIg`oeAYUfJ?KW9rCWuxVJYpG}L=!%MU(4veVmHt43@Y)hEZ@Wh>i)UbrL2{oM$5@* z&+zSf-k5alY8DjI^TGUGXAMs{_;*U6h<;^@a`wycj1x$%G@1foi&ee)p^ASU|n=8C;TcQ;pS}^N;OUy@Q0i zqnl(Ze@B{unc<{(c49i_oJSqb?@k?3 z*bwHDNcgVh66%Z*^=h}btZ{eJaMW%^=1FoCDsg4H&Y z4mgH~r&(0E63hbJC)F{ysB+Iw2m;pb^=))H^cQYn#v|1^!~o@(DwW)mrW|_doQtnj^`4#p zBMR+E8UJJdG^5DJsIZrS<+6O^TNI{jhJ|Gaor1TvL4m97dVLm{-p+Pr0NrH}d4=&h zd0oF!b?l9(*(j2mgKxbW0VtcXt+FCmT31&&QY*qWA9?rb!a>?C$6M-v4q|fdZXd*5%&C>&K|LsV zP$e*L=5uru3B%7V=X_ug)#!hg+Ec9D)|H@6U2ugZDb4bXcvcl{420oPB*2%Ag;AoV zF!Y1NcXF?$(jV^_SoH`7`17dSvh{dlTD99*zN{2!gC`vFd4S@te0X*M1r_R%bs|@a zRnS@(=$!}CHzEw6!(h8fR-PI>Grt>2D}Vy?ne3rsO}0QWrJn;y0NefevP!6Gr)z2> zQ97KWRtlI~+Z#sB5ILWVO6HGU9AygSzBvFP%UonRD;$r^9{cX&xe5pST zX}$E2LlOc(c{5AezCfNnC_D8Mj3zVTil1-mkbn5+ln?ZE#3-i{BA1A!ek2bdyrWlJ z4UGa0acvQN;nh)MJ}m*iB6Q2%8*JeZyp)f8>U`kv6^C<#b_yLCQD*~+i=!uR@B7RE zf>tN0;kG?|6(iFyP=~0>aZ9|9NFt=TO`X2U(^^ponS`EWBjcx^t1Jcv@l{?q%AJeNoNDjUMp>V-#{F< z&6fP57wC_`-~eVqXgG%|g|mkS3kx_Z?DEl@kRSS$VUwP6n3ZMfY3KdmLD`4sRHdHA zKTcHL_+!FZ$e0ede1K!qe?wvg4RVxj9a5BmUG&wf#3q;|DPzJkna;`Zt2HK>c)ZcK znKoLstpDX)nS)W?Ru_n85tK8RwOZg!xAz*#RD zG>|YZ_n6K6$c#k`)~JEUnU{jBih#(Ek_Djw6qZ7IY_ULv=wsRK&6;fr*JB*JxTG(` z>_S=NTiNpv@56j)vUs(&^vPWW8BcPaJKsFm$1sL3<_;4WiWwYzQ3dtT%MQ;RXLJ|? zeH;RPIj3!fs2`LcI zW{lJ~){wcuVPYEAD)I!Ds~y>tr}TYcrXv+xD)m+F&;X`Q>exeT%w|x~ie0~gdRa{g zjDaRTXtV{mTwrlQ1o~kHMiE@TYJd3*gcx(|2w?arSNHyXb^t9|mSiYol!AB40D`Er zoQyKn#R?2Fyy~oqkc)s@20|b_z^_plN|^dhNYSq#siF4EkW~(CLybDx)Ij z1UeRxPPbBD^h*aW`};UmxR9{Rl6QCk1@iL}L=6jnkPFOF=1^w*aaHBl(`Xw$1@E;W z8@QEMmatjob|uhaEg5pamEfgw^*geytgz-ZDtR)p ziZr`fxTkO1mZ=wyOS0iu`W6WbefV!d9fb0H@L`+QsKC#_Lx2_FOpQ-72dd!%G$OJG zpBWKeXtmhk0Dq73^D74GCq}F@C7fb3o}45&97bieuUF9Pyab&cw}_FS6*#4mU-0Ne z^3XfR-$dVnKpU$QXtt+a9m_WZrC#^LImJCN=7GxL!p1sgPcVRcobS*-ECLBK4h*N- zpp7=Xm1V(mHbznA^eEUa_}Ysu-L$OZWq2vyz5WJ{p=Iwg_D3Wb(+;&aqg;O-gGBfa zY<~fkVOon+d8HXaRdn{%tIET`%bR*!{+*LleWP5pTG_yYf~$QM!B)i&eaO?|AaFYT zgB&!AZ_!`M2c?sD8|xO5DK5$=IFJzsYmtCDzg|}UG}$nylK{O8!JBYolpPf!?5gH+$;166|N{4X9xpD#A~h6wsX@d)w$i8~xF( zTn#flXo32tLMofSB^jJNrOP88@5FW{7Mur_!+>L9hD0N`Y>{b^n-Px=(Fx`Pa^j32 z6KH*{Dp?@76mEt*aWZL`4@2WnnJY*a{Xl3Cln(g1j|}=DREKW;a_$a=Mw_-|D@BgX zayfTiGdz)v`fbCow1d#_B791)39y1t z2fs_62{yT)x)r_)8s0E8?I!yxd;Zuw(&1>+_G<{Mm_zH7AKsNMu9Pen1aFM1)AUhm z@s3jC%CZZ6t`8YJp`~Pi<$ya9>?NYt{zc6ywi@U#EfkmhW5jAoux#i**PPOf0p?Pw z`lr_%y;LsUyLw_jcItw`=uh3&d7Cvyr%)P0sv1=0zWf$zYDWJqiCcx-@Rd4soIlH# z{oAIpwjACIQl1X)G|}7SBPd<&zp(rN$$PV3&9d`8@9VC)x~i*3l0B0WM~9NE79fkH zXweQFIIt5$cT$4DK#ah!9oPZlT*U~;_%g^JVEZb$2;kdHw&BDgDa8~`isB%Vlt_`? z?0Ft*p7ML1-@EoXO;QXcp6U|dea^Rswchp4@0#}7dmlS?ya_+y{*MiyW5@GeW%`Nq z)kp9o%ykpI(TDP2%v}|R2)zvJRIrB?O@KNefUN60>aB(u z`cetpqR0sW(&ir;Nzf@L?4(z|>go-a8mxJB@^Vx55NZWg2`~A9f<>JCmQ+HHca*hM z5P|zg40bfE@^zCp@m5N6Q*eAb0hL9df+h!0X$_Vsw6)NKm4IkBw6yjNg)YYFB{RAi zn$^GP)t?-pSLwc0{_<&d`5FDsWswZ$)la2vAVH~>1p2QfM#j+uaB#3i0axgW9)cyN zk`Zcn^|a?!{KC+EyLET`#IXd>4Q_!>96yn_w!{G-tC6Or+bo_m27CCt0xF_#)H*eA zyK#q1Sa5@YgNi`T9w$XbX_OdPtb-4QlnRN0O&+Q*k4u5jCz}wjQId}{=E`BcG)BRC zDENwSo8i+D+PM_jTj=9(13&ERXR~S6=;vw@`L2%+Y9PgsEFI!eh1n2aOxZ0dh?-7R zq&%&O{4O3_Q1VJwsTV#|TlMd&JV?Wa6Et)ak%zP;|3UyQWRjnKod|$uQ+E;``XhXK zK~}kFc>s~0YoZytGGBb&QyN`r7iCR;j_7T>32nI+19R9m)(Y*1VEVOR|4(l@9LsG^wx_!?zJ#QUHc%eXRZ{##i!wigtF$E}iXY`m zzF<=jv4a7FQ8sX%3dp`(HKBQSxyao|e+7>?%!7Wwb_*zm`A#~G`-#l2L3X*_no_jfV84Sz*mZP!-Yyypf zi$0=QzR1L)o|CZYe`o;;Freyg0uM1gC+Nn;oBbF5Yry^|+S=KbL(f3LWHvi%<$ zK+^P~5yXVk1_Q#j9#@VLWd(|0d%xk$8byVDzl!&iKQgw2nyhgwJ4iQ!!;4y9P=QBV z*|d*506l3rGhMasZ4HK84$E37fL;}u<*JV#q>s0V7Nap4V0URqsZ+RV zH`1eyI?Thr`GF&-DQDR!=isV*|8ew{e-VVMa(Fz!i@PFP#gWh6W|7>3=|*y5S=7JN7}I^Nwl=$P=R@0 z_>tY|+fVOKpE~_NTHq(#{@5`Pc%6?QMJix^6)v@@x}BLLr=R@C@DJ~A%Ehq z8nS2(9UCk}ql0AB*}y(_v5dtu1bh{Tc%q;}igeJm>t#)DO2Ze&ZuhMz8y?0A`^RcB zpR^v{LSJv*Prv9(7&_eB0%1s;fIrw9wRpC5S!5A|)TcV-U17+n`jLXs2MH*)zxIcx z;6eb*QvODHAAOPFJmB_X3nn?y3(#u$k=H`Cc9M@u(iy(yx-M%)u>65$rR}4iWMvxq zThNzk=_BK!4<0y8T7x53Iyof9V+0v3iL zZFm4;QSP~1L!Y1VxaVD5E&6iHE?ZmgML+MvJ9cCQXv{B|LJvi(#GKO9My zm7|_je}oYk3*${z#L;)s>zo=&#TV8?u3D3hWTtIEJsroIoPnsqi*qS-Xb#?GM2^d0 zw5k*qz2s=2h%DK~B#uqZzgoQ|V+w1DV(4k}2sC9LkhjvONZ_#a@SwvA58=IZjLUR2 zH%$FyhG@-<=`56^jQkE>#ex1vI-atsFtaH#wD(mX;!=Z?*RFA}!HD?UBJN{c`MFkg z$OV4s7pehR`Ih}x#j{{7qQ*heRB=wkl;`Yo4Yh%^lsrqDN(IvOrx-;w+sptmVNCOzH2JU#{sY4or2|ZrUmVdyKv8s%4Ysg>N5y85#POANCi0 zDOkR5=Rrn(qyJXR3wtQk4OZ7jKGj2I<-Afxc!LT^kt6 zvG6JA&@O!;IQ7!Z!EE8C<)k1jGa8l$U!_ddUBcS>#L1JNqU1qr|371(4q$zB1Qy|M zb4=d#oJ*cn!6|Y<6K|gz_%Wu7_bg9(jmWWm=uzm(3zQBoculB+jb0SlP7k=2%9e9? zY#?E8HdeWy!8yRu{)x7O#;ho2m~pEwXA8!p;cy+r!9+J{DZ60u?)av~CJ&f8*y63Q z$Y0sTvC;4NWW8Vn5LN`h3#@Ie;>HAo*Tr;)Bd4M&9))0Nu1Eh)o3Dbv!h<&CWV&3V z8#?Uk*`sBX#jPk`WNII?y+|?z0Zg z&))N-DCc>cocFfjSyN6NL!W~L`9&eBH|N@dKj{w-6x!&^7M!iZo)uU1fDSC4M9Jm6 zp^9uIEMlvi2u0C}?LGU8n<8`U<0$2;Wm;B(^IJC69{x&y>EBA1|74L?|D!l1N@;WG zFbsTyDzNDyv-*Gq0^j_`H$FLR{lteK*#gyJ>KGVN6OF>I&2nVYmR>90sEmWvNv~Ch z19cQIW>m<^usx%$c1Q$}ANeS`itE+T^dcEF1rQkA6J0Gg^v&F0n&tH77|&Kh6z~YF zR_X>%5oJMatq-}@u=^gOQ6tt`@|%LsYI?Z9s(5l;fC(k zR2;B@qv$S(yz~fT1z#ej?8r8SK?i+<*l5Tl2d=@n(GSnL_f%_(R$eT@h4cfmbi)-r zkn!9tSzXAf`ziz`q3@Lp$V0C8biQB#SR{vM%e5EcWD#Y#%M2}6xr-=A(shZ_=ZvRy z@3{Sr{O0jN*y^u>AtgN~J{`>N~|IwfR>G%HO0Q|&<9~nSL)7%}& ztVQn!sdp9apcAcloz2r+)Z1HYwliyOr{Zx+&s$&{u%@gyR#}l*B1a6_<5qJOSSO-# zUIAGY#oj2YZWeuTQdEfGwE-#_<*Px|Wj5u5^fmRaW-83^G^pWOy$L*t)hDUw3^|>cLCXQD zj?4RM3HG4|kxsAumA@zZ>8sd%^2;mKAyDyaf9S&>jEHG6XBL#J^3}7(xlC67foEc$ z1R5VV3AuAyXWC18x9m=zKD~SI``)*E|NGzfsSWS|PJUznJ#hbny9Xb*FH0Y~Zb*Bm z@#$G2-56=lk;5@H49p7-{D{Q7zo$sWNz+?n02d?ei-=`*!K%+WHlb(-_uw*TYS%sb z6bl0JFLrRJ0E(o)P7TBR!bz#pQkW&1E zgNDC#P2!T(oS|O?(r+RKE!;~`4bRf&eCU@S5Vfk>=u4~gIVtLW^auB5f9ava>-k#W ziQPNjdH<(A_@NJd63hp+{m20NwO{+STR!za{G(gWoVio|sS_DmM{4F~n5QBuBpF0L zoj7erHnT(ptnc(a?)qWc^G)Ycd zcDOTd$SzEEl;N}VDHw-;3;p2pdJ*B1KC7{Ls}I4{FM|WK$3HC-)2%$S z!zu+=1fWlF&8ENQyg%rZTl%(WIWPU>F;klIe4nW@mS41>CM=dS3XG5>m7_Z9qF)~3 z*duDGh{dg_2EVAmyY!!26;*imC2AVA$Qo}I{pu+!cnFM)5v4vN(^y#)ZnkcF@_Qbg zo;r2n(*to}haVY0i^|E9d3J)@!HCXr7}eeIf;ki(wbMc+F@|3X(csj8q`R+>)yGQT z5LXv-xmP=tzM1BMWzXpseHkKmoK7PO-SJo%1_y=(ay%Zm@ysDDsMD@c5;i%qu^^~K z79O%n$rb_m<-^h)<17aN5>zbP$yk$5FS=&VWj@X8`Z!1qeaIX1^F#i6(H?KgI66jt zcpa`-B~bF!kLYq*y?7#@J$jJ;wg~17eG|C~{Z^vuw!mBD9|S*0zqrYkpWv!5Nx&E@ z_C9fOVM+D@K4nYC}H(h8mY1ZfAk{peY^p+xkqfpY^R88C?w zCj`}Jt)Y@}DzwnAFXyoc&QT6v`HjkIhiHs@FyLOwE@0KDqQ#XnYtuTI{dgt;h+%Nl z3^_s{BH7tzic^6qr?9KC7vF#hH}t{14^Oh?x14c>!Ki^-J5oXHmq}AYEcrcDBF0=w zW2?gbi0gSOlIK&Yc867qg*wyO-0J||LP_GSzaKnLD0OTi$!`J9qcKKJs?Gr$Y@`P=V+z&5J}#`YV1+TG;_i+=gsCQsxo{J@KNxKX`m zmapYJLULnOxXK}~FvcGK>Y-kPPp3z2@-)uirpWipFPh>q*h9#2p0~h~zgPyAQk)8c zEF%o`<#X&m&aw2*rTXW%^ahLmR#y%Bsi1@#{^tn2MRuKt)BJ9m{ed0$+itsU_uwNB z?H+#U;V*4~2YB*h1<>i+Z{OX1`u09ai4;mbik!>V#ALP$vR+f98mNACh^4WjSWfPv zyjIsnp&75k=j;fIa>3lV4Dg%X9jlTRt(~U26D!kl!0W^ z<~U4Ag+N3GOH=@A(uxbNs#&Avbj=^|Qjtr!ae66l=ed0GvNuurih^XJ2|V(1F5zKh zPMZil9Cz`IEbuXv14Wkpb6zBSX`MQ68D!i*mi%T_XB@T)z_e<)J%ZAIi~g%DstE-s ze!8+u<)d)A`o+?2LC&EeQ$!iTRQm&{{*iO>iGI{K@>2uUK8;ZOz9B7(?x|Q z{~P^s05?_XlD`RsBW=l#UY14XD9`XiUTcqi;z?87O1uBqE4zN#N~7{GK(ba#1k>8a z@o%?QMCc0oAZgAvo?CD^4w*eBBtp5N5C6u_YPnXK%75(mvE4oIxceVJ_~1idAG8O0 z__4-7zxu1cD!2dbKllgNUw!RWt+sCTwRSI0T?eH~_qJRpt4hrgdvT8Oa2kNAlBqSV zMJUQ{6cpVPbHm=6Mp$lz zGo-_k^sO5ec<58JUS>H?3Ia{Hx9_^CPKG^T4;=p3)}^d^z|sll-Bu$bfnj zWpv{VofSr4Mlsa#gyE8vlVuzAF6C_MJ3Cv&;9!#gCmWHmK!a>expxX28V!ot5j-Ld ze=5zZauPV?(OyHwK!$#?ScJM_D}(+gku>xl{;mp08Kg`)`sE%Pc(fVxp%u+=wBl)r zJVbY2PM^u|z_%YOfELx`CytL|G`r`mF%hUL^AQj1f)GF!qYs z1`K12oL$H9vZvizhSSae(-{LuaJ>i;X_zN?&>u z9Q`+)>;*IUkr#TiDSu=x4}o`Sg4tTK2A`K^{rj@elj{mACKz zK9Ct2FqMquL}5YCxd7K35SOu&4=qanmb|z8a30$};SE%l@!Y8GPZU5fxILv04ah>MR@Iq%~_!Dg~|2<$Qs0@Qxp?%{0;iN%wIrYF#sO9CcWEEYC_c z-I7KaOcrL-F|t(@C6JTR&2Vh4bsYG)SbFH`l$r|;oBRVynWg`ce~*5th!?tMlq1my zpA9+YewXw~%hn^)AKQgx6pCL?`-Yy8a^!Dm_DLVTos6Hp`;qs2>fMh#@~aO&^2p13 zFb8|~Ckmi@?zwk&_uconEF9z3IhO`%OxUw8UXATj&Yo$Jv%2MEG!w|STFp}zlU*I| zId;#MUiauOVs+Hciq&K|v=sZ};28w43#Lo^gi!FoUiyZDhFdi!xJgs~0CCO>;n$iq zyK8BwxhdMymZz~PcG{EKmVn@cE{MKkvG{dGEX=)2^#syzZm4SWmhdh zo(T5j7g=PrW3J4=l)ib&*+Qj1G^NF^!6N;XZ9`egFhtokg@Aq-7qSMhOq5&BH~j}@ zk3P)6*3i$v8OB9yGzAc8&T8rLMLoq6E|8T##(E&2E8FN_^qj8Kc`5d!uN3i!+^!D}TD`m4q3u)M5} zu(>E_E#IIvFI+VZ`HE+gOZkwiG=yylOR|w-a0jJ}X^Sj5IbR|GNTs2UKn8DvG}nEU zQ|J0<%xgsSAOM_q~6F4eg4?nU->Kl!7YFPzyABToH}(zCBQ7kTQ9yt z(p5w2^mDBS+|&fcs+gYDk)jx9PS9-9t1t)v%|XqGqu(cLI?GeGemHqTN3Hwm3kn>E z8kiJ#C1$#nH-!Rrj>ZMBWxD5nzT}6{5AK>YM<3(m2j3LqI5bScsevdh=?Pbp=$txD zvAl)8GiZf(u5ZSWgV4OU$PZfSAV=l*JUZ$<9&lAZez^e$2im~1u=!gLfplm9GWwVG z#jIs3e`OpTLO_3MuXwRJ;}(C=w_+)MRmisJ2O;)fc&+o6{B=58Hv;qPU;q6__bA_( zvp-P_v{*QH{D?}Z#hHpmeKUGRIb27mI!B?DI5iGNL;ZD&V$|*RZjO6-Cb5CeYMy`U zpLu1J%48}SdkuXc{8C$;xrHd0YwbF%lsuG|b=Cxl)PTzmfD`{P6xsG#rr=e6vPTA4 z1<9gSIy%9+_mTX%^uX@Jtw7_@@CMe6zNBV`4-&|=$|lFFacW8qXN9A=WdNj(*?M$J z-U_in)H#I)h*R^}E;Ea^*2oZDAS$=-d^!Hez3kNmvSWY9g_0G5^uf{Q}5`XE?w=wd#~@jJH*Wu@3LqO%6cWX%NDYw_ z49aW>3a1-m_1=3Z+-fm@1SMxFk$V{1%#{v?@;W$I@k?1=L{SXPL-k7T;QM4Jh)FM5 zr-m7vYphUVfX`XS7iACXpoLEy1`{fR&PP$`PnTz-KV? z!X-0Nr9AWzby`*+gG1*KP1h6jRu755LZasim#tKK+)ux)E3kUx051AdIO*u0tvp9I zFQe5E=G1BVfb+6@s)VI)CP&nJaI8>0l{>xfy5bgG9&XE`(f7RPr*=Q}Q$M|X?|a|-EsEU8?N1dzx981{r?N!i z{P}ZPhLZ0|<8&xvno>+u=Rv@6Ce1{8)0Xm33h)|P4OMXs&X~{ll_i4;Qb%h*_#zVw zq@gX8piZC=G@)rHAfCW#+{3vWjYY1-uvcRAx)AxIYk&&TU-^g6I=_60Qwb=scNcxi zkPI&#b*O0x8lAbCAW6RCI)R20js%^5vvD2%m$Q<((Wf|U{J>Vc&0>#?+!l0l!GP75@mX^Q;^EWjY{aEjUr%lmR7%ac%NVxm2{_fws{^lERdb1ES7!(ZfqLeyf!1N}-N1^D7mLe(Ef|ONpn1-@B zS`!j*)gZ-NY1eXvUB=^!)X=^iDBc=WT~o@=`!`HaP#ci*);lLfK&-FJVxeAK9P z1H9h$;dZGCKrZ@o7^}(oPIgaq?|dE z$(P`MBY>BUVwVOk5t_f z=pCb#aa1b?Wg9ueC{JwOap!$$S+49}d=Wh7goG5JZ9gsAv5&otlm|ay=Gc<)loO2N+M9WKoO*wN{>#NDb}%$zw;4?>_p`kKT}F zJJR{S?N1dz3kNUhe(w3_bh?gr5TwRY4=5Oc|Jd&X0S_QM+``E5=N~n9aM$Rd!zoZ; zUC1`fYHke7%{d%0Z?>_;N*?b=K7IPs?tOV4{@w3>&+d*p&Pd^ETF&zqF7CebN8jFk z=Ci-KJ9qZ6u%VSU9%|aw{?Z-#rc=ZESMF+fGa&2Z+rS;kjL_*bx9{Hd&Uft|dH9jk z;+J=S@P~i6dpS-DAHXa(-fGtp237L}hWzC3t_067edHrvf9~_2+ns;nNihQD z277TfTYhpzMk+4UTYiHlgYJ)h^dsK%{p@EyvwQvZ*DUTETfJ|Sds}>JFNLmUt)CSg zf79Ccxv#*7*8a~YR&UV9Hll;4}Iu{JiZ{C`?jAbfQ}vKhU%(AHj@Q?)6f$w!CSr$gy7UvFi|gP5NO@S zSzDqjF%I2^LyUuP9s<=eqs|aOg*8$YX$xrWPpx=8?*Tiud*J>DcK4_CIhO9+l`GVK zySyX$n2vqn!ui}BzH9gDE3c#oHSzH{<=wlJsgc_PVBYJyix)5LUVAMqN5)aShL;Dm zPUcyY!#Vc_@!_+drY~=_ZccLe@Z4_qj(5Bx4|ILdg{8DcFTM1V>F3X%-@V9raNKd{ z9pZW6g%@^b-#n|c91EWWJv7gqJ-54X@xty>TCZz?Lyl9a9NwSGfr{j7zyGzRKy|`) zGOI`4k%!4nWpGVp!c1sKHqld};ElAvPe1*%Pi(&b{qGNNx1}{cySwM^dzGIJEyL#v z&%dyHPa#2D0HA=+$)zZImWsrPUPg?rV#J|#-4&h7TPS^0#8n_UAr8|IlcS9`#-Qdb>{T$ zGr#ef-Q$lxZf#17@Zk@C*m2a|2^gNmxp3jq?p^PC*Y218!Y}Ub&*K}d{aP#H)L;L` z*Aqmq#>r0QDaU(u?@o(yJ1v@gM=p;I=!+LG?p{w)tlK`+?g+u!>3?qC1Lzuvw1N8d7C)nnJ? z$vQa!({ICabvX9$`>VjwrG(_bX|oib)Zx3=YvKdYT6*6sE&2@_8&;w#l`FL94SnDP zAKbnFgCG1G@A;{pdIC~!$MzEi5H%(Se&dZdcGoW8K&_3`2EwZ`j?{X+U~erG*wtYI zPW7uW0CGY;fo2XFbxgD8+%oIh^HM3UB0cIOz4vAf-);7hFJHctpg9zWz2e{%$6>|p zxzwg?tAWr^zP-CUvgY?Dm6Rnt$1%9a-TSH%5D&q z1HpDIuLFDF{`-9p@!4mevCOgXax^W_x&wb}xD{Z!rn;g)e@=lI=fGG8?7}9bc!sU6V{{ z>{eP5VjwkOg(!|aKN)*c4D;o#7ozmWBIZ9h=} zJ^b*yZPFk6&UfwNtU##S>wb}=SG@<56lxtKh1RLHwkjrImU8WPXC5m?yY)IZMVryt zwz)*R1K<8|E}5_hJztGG6BQ~ljJ@++_wD}jU;g(~%Red{U6yyg^BtL)du{hbT7fH< zuIw(R#XFm+`Pbs8uVluq!HXqn&93@TA>*l+(%pORx#xFZ{_h?z!EIFTA+>((iuBf%pg(7q* z2|nHyM0f6g|3CiE-5>tpH+A6mrCWy_bS3*HA#f)&w%V^8kgEEtE|0>Eov^=LVPyEu zk6uN$$iSFt0?aO)c(yPM{P@$zCR*Xr%2A0#mUQ9%@xu6a9e<(#`qf|kPgK+2|9gM$ z`YUm&%>IwUYQU;`1dTErDrS7bj4o^C`X-k^EhTReRAuqD8alys9O|?Dp?j^BpG#^w zTGy9?pH`j4o$DS2My;ZY2cyvf;PlMA(M36P`poYB2ky`HIi2ds48-3^O~0xD2=Zt! zj(#RB)SGXQR$l%vk021RSl;)r_6aWn6Rq z!nxfaeDj-e)Ta`RXL7^ur*;oL_|Wc-yYF<2MmWH;EdWjy$Lj-aYnnG=5eW5xEd^c6+@J?`$ra&-*yX7W)Dq?LhI~QV{sf`*50Y|-e9{WweX9(fBorC z@BS!#ejJ*v$-B}jz3W}?OzV^`&U@aIpt`bqCN0bFeeJ8e=bwLGIxq&+M;>{2_wkSa zO1^A%N0JF#99)+X2Ah_P%A&LErR1E*#nEY96BMa6sX6_WJz^rybZT7_mOQZQjLE5E zp)=q@+tGBDpzG!y2oH{(n28-KhD+Bk`CQDy4?koV^WS{#w|9?!_p#{ix~=Ii{^Bn> zzPWhef&n7`>AoG!)IT*kbU62Zn*#3XgG-n~H&$>udv(Q9D#pI@+8xX`e$bt=&ZZww z7CkIGZkkLP0uJ8_Dtj-%lOtVaD&fPy=gsoNd{ut;9|!B(GW)jJKac?0Yu@YL>RCf; zY8?$@duN?fs#p}F0|11xm{3Kk)P8iT?+X}LG*HHE48FkWNJESxFyILeob^(=El;PF zIQrs|v@B2TzL(a8>DX6aJH5M*0sH047Xx=FGhKJ+DBRb2KC?|vJ@w2!{!+WA%o9(1 zZ}(f|XgS=hH=F=Iv5iA?QE#)KlrEy^?O?E6$+3`pRp4r0F_`T={<$>qSnN$>L zHnyS6rHmbEowQK$520s+3cEE3G-2RbMuq&QV=pV3{yFW9nY;$1egQb}bqOz|4?ozm zo&wEvUJ%}S%^0%p>^S)^TIlWDA4mZCWov>XVscZJ3iX;b4PxWu(SWrq%0e4e#8Oyw zSEVs!?bydAx@xTQaz%m{s6Pl%CLYp;Czsy+@o`>bENCpMz z?$8oE{q(cD?|%1jyL3*yf-d|qaOb?CO#mIv{hL2fPR2d2zWhpN9a*XK;Qr-Ll6AKLNwe@IyXMQ{N+}fOgs?uR}xe_ zP07Xt!3zn@3klYzGCrZJY=D}3i{r5`{LUAhex}vA>#n>-Hah0L40J1*S^Vv^Jk-)G zyuB|kSf>j_rT5fRPbbLE2j|Pm^4tIBb55!-_0H-I@LWmYe(rPsW_LD0On_sT!>LU8 z1`lr(qFdF)rfDf?VZ&IzbJWMN?Lp`Vu$!YnHP=bnZt^CTY^JPNZbPm`vAF(%;M1*# zzdO_Q{rN{9&5Y{Lq?hpS-!Amqw?B{o;%4665#+^}UycLi4mKJ<8w84q%V~u5F=o<9 zB8?`QE()MtS8#9M0gyZ!7$c<3_IQps(WM2c#ZuTu8$`bFQ@g&A0r4N~PGlB~8LD-I z_1d*eky}fj)A^~bp~(}s%sS;W=s`n;0J4mi(lWiC35UyRVJ=*{V1UpXF#cf*`pq|A z-#wP@5tRn%7vgx(r8dVw`v%fO;o~GOrBt*ejHSHupEA-NkSKvu1gd{{0O16|W$NRFn;D_~Nn zPc7&k1kv^uXrveK3NYP!$ z|BLGwMmY@zT*Z?fjT#itz;et(V4&Vno(fwC;_%f9sp3C-mk#;)}Dj^ z>>^WLp>#!LayRS*&k#2hIkcGgZ{4dyO!Lz#z&!8lk3% zZc!NO9Yr}XL}R11Dht@IS#w2jtl4XVh=_?2F$||D)I+caXKSvj8I#1JS?~%p8Trw| zZJz=3+Fbd%o~1Td&Rs6MmKxUgrA+uYCx!SU5U%Fsh&ms1vR%2-yY}!5zwl4(%(giU zpyMcF`T_b11A7}BT_$89nHKO|x>DEE@Q)bcFHSVv%7=uas7qJ>q-qHDN|(%{$P{2; zqyrX%K7To(3>5-Va~Pz<3wW&5%83W8QhkYfV{%Oh)ZWl2hoids#T4;?%>L2tX7V5( zWOn8)EhhK6Zq|0N0hE4tYbT|wvJ48&t0Wbaq`r!3jHjkiVGqj$bhvo@fPubmyP>dP zg>@DyH73hx7(1=E?9|wRwX=ky)ux@+6@%SGlIw`AEujxpn1o9H;z@P6;!qrAsS{75 zC(MXuNvq`HQz|7UO&qk(|3hyNmpH1D!PSe^Qa7=c1}&Xa$9TE{rBM0NlEHnQjq{$B z?$W=jLUhVFxRp-jg#O~KHi?cWC}=tA_~m(zK01NqXkh58L2#=>*c(1ghaYwp+*&L3 z0WaE2E$6R#Ac-Gku>I-VPh~07X=V_gCHZD-2O2;OknQ@!=|=--X{4^;dqO6Cyal~u zOcbLVqPTdbZ@J+ap81BTWd&|@Qiclsuv5Nfnh)A`^Pt)K&u#;336pSb?Gen$vVl{CrhdFx%!-+_y}1yV>{3QVs?h5ElkZawqWDo>q8g>b5bd* z?NzH(VJjf2)WYz3D8?oi)BSSlladr2M?<}KH84=I2B2pK3mNIB)7-OQ1r&7e*=T*r z$~efv08#-4C-fG-474QL!$6J`jkD``$r(Hx(1N}be)v8S`0CgT!w}que59~+YvUjw zsE7XI0XT?}EXmRHszgX~jFL>ceXNvj*S2HRr0kXPy!hCQmtdtr@(=n5LKcD9(<#)V zW;TB?jQq8eUFBsX^w?;5YnkAlDo>hL2>z9l$8?A9$-2Ft|M{QKxwp7yQUinmVIgq)P&Sq6jRNhxE}u2xsq= zPK-vhlJ-K&-W+Yp@bXsqZ2`ptj>;iw%k0r#_HQbCwqc9_^>mNEe8_J(1-hfbf-9B) z03ZNKL_t)zrXU~F|2(>#@11gk`@lXJJk0%x4*JqTZ+GM-oDOf*O8|_jv82{UIhuI3 zx)uS^F~v+bCl|HZU&pX&7JT3})1UPdHMEI!TE`&Tg>(xG-db?*ZX9s8dw3l0EN_&U z^dV=fz&SgR@dLM-tm8OHTPZwXqtEC1;Zx3FbKJ>*< zkCeWmDF3jNa#R(2QX$CVXXqzk!9#up2Y6eq0lO`yp*-@7r}A^AJe&SYUtZ)dKU?D^ z``F){;jV|WV0v?qlZhC5I)^eff3vq65ugj>E~i&!^pS(^5XOo&5QBe@J`nrpQxv}w$Nb>Cm<&AS-{?=1lxwEInS{BT z%8ahDEj#0+!DpF8AN{X$@X0jvCUHp11J7PfHpiv^rgcv-?ce|5mU3pabn+-m3H80` zkNj#$*%-cpj_moAYzi##$$iN)3q~P!c zmyN}>jN3w%G$4bJSzV{v#RZB*jcm>-B$6D9ANkt#OcLETTWr7vPKAZ{To&8lC0p8@ z)EO7xtkWOP>45Dij!gnk1~MoN6>_p-e( z09#;b8(elPryIOQ*h~hMSn(#5qKsaWhuS-+SEF7-BwFW_H3rDvoFs#7Y@7#NS zkTPQw0u5eD2FJ*i^f|WHEp3t;mcmnh3a+voiw~m*Reroj(R)1s?o3$??jqa3cbU+l zkYf1q$lnDF1TsBsA}l4d?}_L=v@2O8>*CAEOgUfV5}jSW z!m5IqsDMxL715gQbK(H(>ZDE$+?vXlHPVN!pq7v$%c+xnvQ?oiQ=)+-xYgYRRH-V9 z_y0t8-6c!Ryybn@yZ5~3J^y`)+`Rn_381{t&YFVtSS+4gPVAJhibSD$P$gGOW~pq| zl{D_!A3mH3gj}wdvja#l5qoTz||GJqRRd_Vn> zpHU(r+^QadM|TwKI1)XwHjZUR@ZujcqtJ0St`8M5M|n8Qd&Dw&sA;Ob%HO~yW$CxN z9{J%{Dr(as{FJGY2i^v#UAhLAl}B!hx&`?v+Xq8&-;4ggO1MihZt5?o1h=}D?{9BSFj6P>B_xM+=9r_eQx7}>FlH$}W|GG(~g+A9NPpSH%m zM9{8A;voFq8fWlPg_Pw6FZ?0Lt-WQh)TLkAU?&Kq&#uYQQQc9!!Ba0=Iv3zr(pWC} z1p2~Zi^CF}jCb0?w>!7B1}V^YIH2p6U&K|-(1$St^DXo%1MnpClRm)F(YjU~0YJb3 zU&wXnL7Uqh9C-l?aF)vk?X`Zw%1<0*?0nKUkDQ;&7w5h>;5YSf;4RR*A9?rx6p8rl z{?6b2+t**uBFidrPq`W+TEWDDn_9!y5y}h zR}w_DMy?&xDxFQE>p{^OS!uXsRg|uC1fuN`2^g*pV-EBwPqA952AXqt2Zs*Fg=I{f z3$VlXRyf8TXIV1E*nN99PBD0rx4Be`mOcewaa>JjX2EKYz?bKZw8V|YE%>+&9CTK? zSb{^?N&-f7R(-XCAgEnO$m1iDsBX&Ve)(lyxup+Z1QwTL&*qMAHT?@Kv zv^>01;cS-l^XAY#sKyK)Wl-djXwj`p*YZeKaI@;$iJ1KP*jDfuY~m_k zFbvs^Z}4O*E8L_m?3yX-2(NNWPkj6Mv%<_H?s-gujWBmP=q<-~>8Qg*(g^F6r5N(h z$`<7x`WC*)+YJpMnpQ8Zyr*NQcu3Zrui9k^JbwPwxOK&q37w^kuq^^#Six6f9TExDh5N#mGenjkLE zdqHegXCQ`K6;XH{0eXXX=)-kq_eZ{5Ec#pROh71mHSzuQ={i|~1<3J`l}X+5YUp-+ z$=I?mpz0dBr8QDeJP1TnVxBEMC`PuSGkHQ(M9Zv}PqE2MaUL3^$HBPKg}|bZl)RfE zVU)gbNF!rPzy^h}lQ>2%l)1^<4GkcawVbP}h_jg30WN)DS{s6g2fKK<4W;@%1D!sw zz~j^!vKZIBBSR!GCo&6m4ZFn>2pe9ajILzv(~dXyu^;ZV`ZsLwf~% z4y%uuIH#ZdLgW@m_-$*MbczoeeE{tYE)m}3?S=-BM#35VtEqBJA_|x>4~s>8vXdtx z^OR?ThgaWfZ2lNf8WGtzdYZHt!Ld;kiJ7+G5e(-ey)~|MDaTh$Jv(XBSwJ4ux+T31HYW=J4J`Kc$=4i<23?h}X!>n`d58@DknM&Bklm&zkDiJB zWV#aRnmK?>@LXWb6Xo%84_^MkcnJ8dn?fy0v1kzgbruV|;Osb;=QW+j_AKnOK+tZ> z1etc38h?CoQ^)G!?XM||efYl9hZeUi19#clf z-A$gD4ARP1n9`qs0|L5Y-*?;+0+)>5kE#QD(-|&!PdWd7IH(4wVYzOD zU~U~j?KS{O-QLh?O$!L6lQT{uEu7TUfe=T}%}JHrZ@soq`{B)B!MdCj3gxxbD)^nD%2gfcRixE^^Y@Kel8T7Gz)Bmv+1&22IM{L+eNsF>AED50`}A+mtg?~k`@?x-Y+x9rF)qU1c`vKw ztbEz1fFf7!AN_BWa?=ktG=RJ*O3Pt4hZ?oDjlpBLEi-NjAX^^23gGI*l?pfk*BEXB zfqaba^+dy7U=0qPq=szk016B*vN8Sy&p1bmK)*2dcc70K$P~^7j^ehpGH+e)wRWVe zetKJ>V!;!{WYOYkmvcGUM2bS-=zrywHTfj3SL^HHI6bXG)mC`P z1h|l3Lnl*K`ne(i_UXIoDL<>+(tk~~Fkqt*vl$gb9w`AF?1p}jQX%o$Wrjn3T~L_b zknoG=w&*OGvI72F0r+GQ+}!;Q4ImW41Tcd0N+oWN@{k;p1xy~$68TaNt8+|`(@y^$ z1#6)w5~smYID%`SwFsubP&sa&o7eEk0&T0ZT2h1!gc{w;l&6zGc~XafY2@!2TQ>)O z9NIi-2Ua?i9j|FG({uv{LVUZ(Q^sec7xt=1DH?nQR%YE98WT7T4AQf4PmJ=&Zzm_` z;Gtz$@=F((;_o1hG_60$L@(C^I+rluWkq%(!Bxyrd`H{=a zX)GNGzm-0{lgn3_a_6=NtxWym`1Rwvqj_l}k8p$Qa>mJdKlr~JEfC$aYkAoQiy3)C z3^zzG@=7RzCqGvkU^ZuAqFuXGoVwQ_`h@nuL6#Mo*A@Y@r#uShp3;n)2Aw$QhK&hp zTpfiLvBnm+1(qXO%nkaT{o!J+SEgkKZZh|`Jd_2X_mv8$b3vaz{o?9}(XWW22aY{_ z^bzfN^>m@Cr;$$nd+s=F=(C7)d-?mKZ$OMd(uda}y5w6)M zuE8WHo{Wtsk##$MW;4^+SLRFR%QnC<;lca7c~g*2He=J{SA7{}Rv`Euy=R|$_L;AI z07w?S|7v5gwk}E_Tz^=RyMFfpL;mMa~Q{`E>}*OS~YtE#A)NS zd~MCl&bC^>%HMh*8H@Em3@YoD(hpwkWRCysdi8vDv6XdN--QGH$RGSCXy}%UPEBHN5xx?kdH2?OOoCX{ zKt<#e>_SQzoU%_VhTkt{M!tIGz#{_zYj6}F%v)rV)Gf|iwoPxqRsO!Ehs?^UOaf63 zuJ(}050H?O-?80XUrld>t~>Vt#RqMQlTT7koY~9Z8K2;jlc<*$o8BFPt;>#4smOcOM2jpTko zhNA9et)vj*@(w46YaLOs@lGa?WSAe~xTU;tu4BIUOTjQ47uYYF| zhrZn9lE?GVwJXiVF{F`4x|P35=(WMd$`e;%2Lpitai|PLU|Sowg2{Kp%$i#11Ncol8%X(eFALN+7XiMZlw0<+*SM6`BrX^)msD0ntbE4 z^p%ALq%5JCdsXQzS;Kd%RtcCoc5-Kes#Oyo2N?ZxUx~*y@?HXF$y&3Z>uLhFt7FjWmK=;9N;cDYIcz!H~J`R?+~WV(Gk561dFTdwQ3JQ@stCT^zQ$N>6+ z%FS9U#y=Nx&*$1)qdc|_cqdS(exiRWq$(YiuR2I@4cycmal+-GB(hdvB6BaqO;`_1pqKCs+C)y#v}QloxG8M3MXsbXJL9cIAb62ta!<3yI^*fhgbL| zKt!DYq^s2y$bcw2+H}l2uyUOA&>?`BF&w?H{HHA(2+?8RpruN= zDJXU^SyDdG{oj6=5@JnQj+9Vb{~M)f!|P+9(Z;C z72P>?a^?r<0|V9a_x<$IzxQSK;ei=rI%UAQ(2ASg92F%gAr1?{#G zL}`Kij!}Z^GoSs;qXOLI?S{2L)Rwe9M|p}gAAYh1apm=;-H|BkaMn+$XopHsYVWxA zoZ4!1t3xsYz%gk$4i=eO%$v22Z^lkhC`Ic4)RG;9YeVbP(geCSdDAT=jfRSFizfY< zQnfr8+TL2z6Jg!(YGTxMa8U`sFU62EC*o_KoGxb+u6Zq8DGFa5lu25Kwg%FqygbQW zTaVCzo}6dL87*8D09WJ4o=r<24oiS^JGT5JDQNSs@wLqGwNgUl29j&t!rn$dB?tsw zP)`>O?2f+zul?mq1(n(4Lj&oZAkWKBn8rSOEDt-xrvm?_^>~AzH*vdR0fd%ND34yB zzU}nxa%SWBG~y+mIAxyyB1T{X(ErV$dCtd#99?$5x_(o{E?4qto33T*=1RWLLy^ON zp`dsuhY{=+Q7e{`M$$BTMN>sP&W5vUIOtQVna$r=u5*gjHatfK;5>)H4W6~40mkgo ziE4<9F5ODiX|`G*oO3RTpwZ-y9!g! zp7t=5kp`(<&LwHkS>bU# zFnoHF_h0jAO+NW@gwM0wvU~IspZH5}>zC*h;)iTEEP!}pzAw|r#KDPtyXNG@Q;vnG zH(k0z%R!%bwKmwmes60r^hb#J&I4<7uF>|mRsn*mE4xuR@nCr1tohUUwV!key48(X z4hmMcz-i3s0t%l38y`5L0YGiP*b$+K#YZsYvaUaJl=1H2Yj}iL!)9IM_2N!yRxEeC z)0UVudvsr|IL0x{bkZ4l`!C1-yMceBXM?cG-*X&3X>Wn2paONDJWT?ponevZj;)fx z+kP&GFMvX8O;|Yj01?YpM(+FhZo1gZHE-H+f*od&Uw-&(V<#m@I+FLT^WEB$`4SyN zg5x>fmiG?+S=nw_0P%KG-p+G40dPD=J{-jqmLI&$*A|$S>*o>2F$pA`0}sY$5D+ zB*`8p5V?R=b_Rq*%0jQku?Hg&v_64oIU|a$O20TDORU@g_f#^hBDiWx7tmQXRL3R( zX3EpVeu)=4SP4bo_z!oDo+4ZFmj$IQl$x%5mI{d_|CC4Nbf2N`_mHL~-kTY0;J^$R z7A!AYp%MV8T}pC$X%O{m8@z6dCu(_?iC3g$2*CMqK8<~B~Aoj!fL z;~s16D2WxeN7EI%a*Xa4HCmoZOq%aLpr(uyXlMcg$D!O9NSkeUt@|iQy8!7kkvhhd@iGB(Z&>8b6-2~K4~Qa}1~I(p@YG72l((f^`Q84OcxBv52OKT6(O+qF z&zMl?I}kE4l$lv)*3w%znipi!+AvM+2d4QENYOR#Y`|~UnEK7Uh~%-yzx(J{zxK8J ze)ea6=B7Vbr@9Vu``s`9?l1hyfAKHB_|0#8(^`s_=Ll1ecquhjEXH)ER?XH!gXdgo zwzK&h`)jYimM>lB1tU=wU%}R%Da+t=d%gvGZ{@kbDLVsXvri31_bp_Zyw<@-TI$=? zJ=vH@CeAT+xDK?xHaD~=KXi+#0k`nN1N0~lbXN9wzG-?gt?6xfP4CHkbeZunlLNfsiq#}Ow?qp@ zz~=n$Ep(K+42W(llL+to>Gyu=mp}gTzy66&eB!1C(Sdg1zWUX#KKiBK{oQ}^`@jEt z`LOTPyF1R@xx0`jT5m*@V4`}*;QFLORr`r$7LLkl?8!YU*Xo= zdJJ8Xq~jAf1%`~n*+t5^ef0cs=b;W~p>ZIPKws;NOQSDmTj!>>jG*JVEuuRr7f3u8 z9aMp>11;EwQt}-4!E5Pv;7zm9ZqI6ciUk-dp5SJj0d|>y<{S}Lg5|@&Gdl^L z^!@wt0muCYlc-YL^l4o0M27=wWWeCPF5X>E8t z@9h~Q^NGgWa*Km6Es*YWKQ8yeek>cpe#qQzFdtMtpI*&VPd&}V#iOThKmGJ)KK<$4 zuYBSYN`5o80}Y`6`(OR5fBMz0{obRzAmola?%Lgc=Jw3M9aWjVsfJ=uf0P-^ZBy7u zVaA1z#_~>Wnsc80;4P(>GVngnBN1`B_KEB0_>ff_*y^oV(OQfN3?>qMZR`x5mh;!s zQfyRS2NDhcc(tw5-U{r}u-3-8D3HRq-0BY1MZi(n3RfDPwY$}IT^F(vgXdhXEZLz) zka5&8=^{bDT}3jGnOgbC5mt5d!7`l+eu=lWVm21dxDpI?g7!|qNR{>7};(`1ntQaCw6DfoQZ#&+1-Bo z?P-ZlWxSf%+KgTC5#CYf2cQW+g^;vCO;_(YftDW=GXM6AU(EACeIZE!-~Ro92GDmN zd+gCCo_IWgbI0yfnr8aDF2%SsH_vDt#u(OO^sB}I*8@wJ>u40o*T?vz`|D}e&mMkr zcR9wO>$V&NwYIz#(r`FD5Oj5pCJ?iZ$Zit`w#Cw#b%HuqJ%+M6Jk@6~0B27>M{9eX zsZL({$)Etx+Bap$7eD_!*_>J2>HA;gK`~aOPg%|g%AsW|O3gpr7V+}8hj>a32GERP zz;W6V9AR;SdGe5pN~EP>bR|HO$^ZtCIpty`O8XEV^hbV*%M-=9F!)FR5M>rEWtoCL zmR<)roiL$G7j_MT*d`M^CvQt{BV9RqCsaz@VC9I8`J~ONa7W4V!~_EYdOSgR?(Cbp z=bm|PcV~j}W^M->Km^R;G{dwYn1Z*AGUZ8aLBAfYYeUqeVPZI)fVGsjX3js;mBE26 zXpv}5P&lm*3Kb7Ca%+%HQE(cVOtIQos|SUKFYHS-eaP;`)P#ZX1}?#?lL1FJCFO9O zFtn!D+qGhK102n!3gk5iQrv+zvE=xTYu+Sd7VW><|zpYY&a1lCwe~A%0ngsxSe>)a(Xk8)3^5a1-+oNz2vQJo4;< zO)JrsCsMl>G7|%#MOp68`PGV4Zs_&u)x6Rf-hJnf{%H4djxq>K zXh+9v%?)p+68NTbjP9(>h1OBHpT*da2^ODuLMpqK16Kv8%a$5-Xb;Ylikb~t3yHrW znoDXN=mR_UXn(JX79U#J4E z27@8CD{3)%oaEY}R&##Y&MBu>$0!jMroz%9w`NTXr&q5Wbxd(J&RAc=*#O9I1y7Dk zy~nZ)1p9;IZkK1b?@Ex?)awBgAYBlyEBUxpGZJ3;D7y%H(db9M)!eb zBzGr>9)9>C9h=tG#}%ki!zcau6M=ySgr7S4q`d2>b{D(VdFLFlaOUX2mcr{m0*oBU zWMFYzWz6(q;Z%>%G7_7y(Vn2#VgrzY6AQSZPhbtwat(dZ5pY8?*Nk&`7U-5+cs?g8 zCot4`&sW#hgaEWP$_2UtO>o&8iK7riym;m+enJ;K=dEayKhxpRX9fS2qr8YDB~zx| z>vr#8`DSeg8bBH|N~yBZe2qdaVdE3E5nQ(|DpZj21n|pRoJo#j2f9y`QD?&H#gCF{k z<-hsOZ|+`r>4G)ix;LStBv^RL_VLHRm*01NEiwIa0*@B=fd}p{Lj(=tQJ(G^t_WK= zJ51YcN@|yWt2>gdJ2=b#)hvE<5$<$}8&J&#YNW5PHsLLM$pEF23f>jMXyOnG(lwu22It%UEOkmU?H=F)B*4nkmz0aa3r$ROX1ym4epFGm9> zjgo28YUz~JkQk%}?qGkJz9>bTAB$ICdHEH)ggSMMN-M%@hgV;DW%u;4r!)?Maw!k} zZQTcX%VN$Lj~l(Oq)SGZjq~T8dDgPjtV}$-^wLWPI7^i-$GPb0xJ5?L*nNsjI<8o= z(Ot_QvT==`T)#)2@{>E`AIcSlg~dV(3|{^%w6OIY=?f=9zygvKQ8n!r>F5Qz)KD9fR=~3&+ z1RH5|^>n#7$%TvOchA4@0}y&*jTh#dO!NW6}Yg<=CM8*xwIOrVu6LA^&` zRtTo0zp`4)QyHR5AJ30|Slj2bJ)uME)3s}{3$4mn3EBI;ga!rItGl=OvL#Em%v%mJ z!DPdln3$WusU&=9W_Y85DrvPgH7+Q5uN438G%m=#CYh<*WU=v=f&ZVLyK6BSv(#bY=%kF?>(s|PokBA*&={+ zju1m=x5`h_-ir~J+{FUa)&eOpEtskKc@mV!B^yJpCsuftC~QA9UtSuddx2L>D>MQ# zdeN%ARELa@w10lK^2$gT4u=N|zwjvw_zO0x9dHc2Aqe5AawOkH8Yuux|DajBlrP1+ zDcb=DP?d>l)Cq#>-!%#f!6Ci5X)RMnVJJXeYcifVjFL18N{j9YCJd54`_FdS@>8c6 zAw+US{WvP)5X!kGi~hI9$}jg~po2p-fXbu6GnT+S8ZYJTcFs9BT8C^0Y;_aH8o9~HeG(kz{MqV7N^Ei$-@@t2pPCjA zIbwCcZPKsqN{hTjAJFQt^eHtMmV&e&W5!N71J6`67?|qsD(f!C=@fkUihUO!?G^M2 z?8xk68n<#EC^)e#PJ{ksn)A>MfxT&MY)-<$E8tDp4mg02HNrBB7DL&+K@<63PSe43 zo9O1XwqGqspQWzBwOMvF`y5?OOpO)=A4<26XG7@n^+{LOO-0=?EUf@jqTD-JwXB@z zSn56J<*QmJkyfro*uVx`tH{a%fyv&u~AawG`@5~%G~HIaKB-c{#jVD7B*O;O4+ z@vuJA;*_g&#*`zcwXU{pfH3vytQCN6ob-a0wR20Y-EDD`>(Uow%^mk+c`}o!c<$gU z7mc_0d)SY5#X~Ew!mL300r}N`c?45i^kRj7^p1_}4TQ_9dH)2f2k6dTy_$7s5y{o{ zS+c9+hj(5GU*`uODjV)Zh&MoqN+Boc-3p*Q)c%y=&9lu}w?;Y<)rzuG-TeIl2N2rQ z3~1b-=qwG2qUE63ukmk1EBGUdL&4N&C^NsMZ=2~Dge5}srLSGhOb=7u*b6)ZsCh63 zl#_Rb+K>usdmCvDNG104q>Xr)mFeiI+y<}LoB1UGLx_yCIHbR!)xCj`)(>+29GRO0%y(;Zcl6>NKgdIXEHylwVtcdCvd_GEBJX+V=;bV5h|S!;?VkF4qLF z%;iUegHdQ98O-Krjg0+raO?wZ>6SR>7|#WhaXbz0Lto?vmLu}>m$J&1vVNh0+GC?% zy(~vW{;oo(gE`>>k13BUiXR2Cg^328)Z%MUL)OwdkrwFgyY9Bk?|kV?%0R2d2Jc7G zlCdh97VSvhyT-}_@Np09_19mw6?YBW$nLV6@Q>^UjZ`NEm*0cP+gpyP;)e_l_`e=Tds zlzH@GAOlYeHcqEYnLj8!0gf53t1iC0^7Q51>#qi1bn!-})`xUfPGl|LZ~j(}FYf;0 z$3DJCr|4i$Ie9MUfd?Nk$1-*XIngHAUsE^x^F1Qn@d1t=vFFzwj6|>jGiAU+yda;zbB~C|O|C6@<|r@dbM;KSv%5V7$X>@_X-j zNA7c-+r9M4ODUJK6$R8gS|S$8ia$SIuUw~2W)hen;gM=uNp2Fu_o-8-?KZ(XZ1{{1 zT_zBMd=4v6z+25F0~?~J=@s~rOd8$%2}jpN_@*H{mD4165WkfxK>{F^OXXkmv0*ux z0ypV!paE1TtXRlze^tjs8F5CBon2`-L~2`-%upro8gJ>z(G~~iQ+cQmoH$1FoGQhu zM(+d#%V$oeOTxoe%v#|93~Kx4pExeybknfrR-0Apt4k*-C>cl;X#mN86J(|LQE&)k zk~0!lVV!ZcnzTZ+e1|fF$2vTCd-c^Cvb-z2byN+hgiEWpv^gSMpgpn)NQAB z55N17-7oz7qq}$A|1JdEJ^9oVyU&0A-&+1J{Doi4%JcW{?#|*_`2OXOeQft&#!KJ) z`ZsrPKJh)_&!0V?B}q^Ei1htgNQ-V+jlm-tcVs;v!TDlVUa;bwXKoy)(EZG23mbU2 z9jVm|JE|DQq}%6>v#RBmL9u%iOYldg%&Ae4NAOuHjk@kHf!641~?6r>?$7yg=Qf+fWH+Jlotb| zR|*4@06F(&9``u=_=b;XZCHMDF-wx@I?)PVe2tG)W$j*CAt=810uvE*?|57wt15hK zA+vkvPTtB_=`Wr}k0tHQrz`MtWAyqFUdxqP%&WZ(Av4)5F_R6X=nitw6)EVKw63kA z2SWn+)qvf${1c?C7FipMe|ecrKL5CT=9y<2#PA$s+FjcZo~1hZnTi1lRu0##jPJ@A z@{O!gx#P^4-+AJR@BN1lJ@oLu*oe)^fd-IzvG(vpfmajVdjV*Q*_3kMK7ZFsrNf|C z8cbMAQJ2tFs9gs08W^DQM@q^vNIstD>&b`iq0s7kw!s8!=@$JeT!*(M1odnWCHJbWAcF@cg9K@0nV}GZ}@_zJzgX=bm$85=A!R@ z=dmngeJ%3h>^oP7`|ME=efl$>Hr@7i_`Hx7@3pjiENSxb>EzLcrra}G6bpU6X$zfa zo_*fD3wf)OYtQU@!eCVhEItfd{I*RovM!L&63l`b&#RaTLg_uVC?P*i^OFzUF< z8=c`7;PaWG(DOz$6oKCfd)OK!CYcFr&m;v+C zqNp{sKNiV}cobL1q#lxeg%2)|7y{JXT zsSXXn%#glYJibq4u2y0p8(GWY){0QlH>ai>b}5q!tvtsQDZ2a#r?K^---&!(ta^Tz z^?YeLpwCMhsb!t*%D?TDui-DEo1GLD2)6_R3=g~<)XTv@NbmPrZqq=wxV z9VRh+{Mu-mJn0wSk6{_034jC)w!1R3f3##P3JV_|my8pjgRyewf@GNR{uEd>2iSu@ zl?lPRwkOghef;q!BX1wnM1H%2=u_IowU%xzeW4ruU@AT*o_2ZKIFG4}6Zh5B8>PLI z0}UXgjmfP|x}j8ruaWi7a%y&+7gZo9d)ua_gMuITA@b+ABFf$#<|32-q!(wqS9SQI zkN;_8ckXRj8rh~v!|dR(i?<~-+sg);oanGMrt;FolIhYf!oevUEo(O-SKQNLvV)&9 zj$6aEP5j7hereUAMf6y9P(F>w*O#bS2B7-E*Gbi%3xC@S?@#Xim^+{&(3s?PS)dV^N zZ;oh-?j#7W(NfYv72H`>NOhC4v!}cb_3XSOVv2O2;KRJ(_DjG@DHMS+U5w!kBF zv20OzBA=QMNQ{T`TD!zy3eh5*w<`jkz-ztykl4Y?oagAxNC$(IrLT?gM*TWT@+m*s zlFb0ydg6#=QMeiDM>oy)m1Xm>;y|c)T1dMgaNgPwDRjmSEAl}zN9qI9CDvf1<>4|r zc-_;^;)zVUWXV-=!#`L@Fyd%UEv6li14uWbagbHoiQ+wC8f@DJm zvZYqN_s?5J%>oT6lA!Kf-!5h8kodv7>qpt|aYohr+5oHM;pQ*|@?{sjcE}K0Gj)$|?M=HB% z^e&SB!?ptrAoQ@vtXe=$vaJD{>B={1(a-U!2#bPr4(iuzTpI`k3`m-&zmh!Zt?9|6 z#i=>k)k>0%T;=oqsn*+764>Mg=Z98M9E}1`o#>l&E~M*pIb92s#OsNVWvU#$&hcWw z7^dSZx^BpVT$O6rtc>C(w?^4$0!tnW8;`9k(NcqtO!!5fgey44Q8(Sfjl=+rWU#=W zcqdpg-ts*XITB3FV6EwM;9I#+fM5m_y9rc+N#5w~6t!T_t4kOR3WgZfx@tgto8M!(;67rum#b>*M0UtE)EA*1)XiSgUN#g;7TNIu)!~v*Zi~mxVDNNhulrPUK4n z58Qpq{3l<`l9tqz$8rznfqOOdiX#X zypfZdSce_+0b*H|>PO(N;84`uIkzz%X>km^18v!Q1*en}Fs^2|^~#Z(q1U?VLl%&E z-Fpgd%iAKcfKPzRl&h=MKJtP~Q-hCaGK22OUfpFf5tEuOJk#T#i3|*_2Ft9l1@Mf0 zIxW2xur^?<2&ECOLP>`np^TuWfBiRvPaDb#%57k3WnORv`mHz*J z|8rhup_UM;*OgVRcQfC+_uX^OH-Fz5@3}pbqqP(xhu!oAX)ubY6pTt76IM6e z`^JtO)X+FN2w3srTja;UgSMpnLpv`#gZ_6oR_Z{7KM^-3K{Sd%pdRCrCoIe75M+|b zhdze2nVD$BeVIW~7*m>f6(zh^c_eE{qj(W%*j+f4}5#V?r zk7e}{?p79YUdrPHcj7>A-ek`N;5HTjqmB=~#VH{wV94RvZ4DfI8aQED9_4prl<&YN za%Xh~EeM?9Agj@B$wRXi1`1>j!m(mtEaIe7y~v{2IUI~py6sf*$v=~tv4FGW8TcSc z*f|?#yir}@+6;nhB*>7*PCU(7fN5VfSgzz7r%WRFJCvM4wg}q%vau?^&|iKT z6s!Cwm!ZQ_b>H_&9Z0qje!rH8+OJ5|Ko(@74U7;K2m=ct5kt;3fvJjsFFJmFSR2>p zaOeypthQYhWupiRZy@((1z;p(6z*hpjbYmiXjbtSbDdGvirxD_eQ>?(cs#6TvVktH<{P0@vq zX|OjRwH$jjD3cMm7?GT|2gGvSE8n<>j?{nYk)l!7Yx$=3ri>JQ!?98a0{YT!CO!mb z^B`#iPCgJBgQ*cG!6-CayD+Wva~mcZLF&HAdx!k3xZOzD6F) z@S{#uFvFQc5B(Jkl?9zV2jq6ROh#_yIwDTc8j9aID00;;%ul-5 zmI~u`=_FGfh~{6*I`|MzVg#<6|DSPW4^gnf!R?oYoa31MQFtjF@WJ6R8si873$ zFuTlt+eSb3h{Ku@c$Di1*(l4U!p?^rL{N+gWmFiACw?R7q%pa9ew&v(onE~1_U__* z4ECuf&Mls}x4n4z^_$tk_h9i>nr=i?wyaV=qNt*KJtP4B94Or;Hmb^5W%$87`NJ@# zd6oW_M5&v;!GOh}tcb?IeAq7sMtzp%EPsJ(%;v0Yi(+2%_rPxnvSN}m#exu9)I)fs zt@pWhvk>}F(4IV-ac17KfZ@IM)|=5qCLM(ddl+l4m4DzBVfW$r~l0(ML}9 zS?t`vMxeQGB38&p$4VWj_JKhiA<#aDptM%{uVfHN001BWNklFna7{Gcxnh7!62bEj;uQ@X)ARV{4^CA7q0mf5cyIL z14kznj(JEdJZRR9bWZy&)J#_MHFQl*{>v-*r~JzaI0Hu>fiavXV(xG`10~Q@y6p!C zHcm@_NLqrxrAl!OjePLRwR1Yq7}#t_20dqE3*YnPlcv3W?OJr$)#B_`aVy)9-A;{4 zZPc1DD1cGM#doCVOdXILP7Ou5Wob6W+afJ5=OIwsEX2&wrRPn^0-!&3BK8X zAPXUR(*rZd4v8YLq~l1l`C>JT4S*{`wuz`yk1FX*;DNtL zP%?uD41?n6wChs{{DBQejb~M4QZx{Z$Wf$pzK1O#08%OKxk^f2ywS#&PWcQ{t+K#i^;j)^qc*o-gWrfH%`kYwSW~5*N)%`Wr~u)>HYj<`k6{gCEO~}mqT*$qxJd*HMYN*}Y zdVh-6xKPZ}DKQA?LHK7D=kj(rnyw6nFi=EN`+rrch8$X_l3F8&S2E&QA!0^u4F{q? zZOez|6QgTntBjOY%2wyh+K`s5z@ii6@5*S z(uG2qgiNzYU$sqZuoUfg@?Ki7P16rMGq7hnkZ?7K{%Ay%V|6 zZ8?`wKH%)^Wn~2p3=m-DrUuLzE|lT8CypsU?(=D2gjZou3oLzy3@BetlidRO$N0Gu zSmMfO1Uzw>Le>G3>p{z;hdbyE`?5bg3ZczW*z!n;z0G+Vmwpm&#&bT*N$U`San67* zo&rMmh`$0b;Tw+CI*^F52ds#5BMO8#lZ&8K#xP2RhT6l+(R!mYY&L-DWQs0-T%#BbPX@RH$w9h7xV7f1Ov+K-M+iHwtG9Z;>O}b-_sQrUwPx^ z;`*(zBnG$^AM%N;AxGnsqn>pNjhn>Y>wwHiZ$_A|Y~XpoaKI!Eg)Y4XAHjEzJ_swh z{S)0p(|g<`*ajxG7wPDok<&xB93z=Nc4?zEV!tWryaoqw+75Vi9m(l3sSF8`dhu&_ z<$+AlOox@DERj|^y-9fr1*Q>?LQXaj8Oy)cOW_GSKf%biq;Z*}$pg2y+h@C&Wk}mw zXLKHFWtwtlv$6um;&?5?_S(!6F1*1PP7MDf!e;pu9hnDWYAW*B-{m%WXMXgrvmvPx zI{7C>DDnr;aRYQJYvRv+(qV8QfH(fQO>m>BHhG|^>lA6-PMoZ|kmlmU{)#d=RN4^< z$~vDJe8CCqcRE(;KqU@`o{9}tWNI|SG%^Sv+4+RYuH;Rc&ta5k^D_cMv`@}u6;0$x zHGE{Sz^w>X!c^`sI{P5N94FnxITxZ`1zEcUVVh&uZtN{?1}_wvW5VE?pD92ZhE7Dn z;i{q|Wtzdv^PG!uufwYat3sp=XKGE25immze3#(Eb20**%*T~DYykjG2hA|_VlH3Q zn#^fY6Wz-2|7{|C@-aB!ya|8`zNa{WP3<8gkXFj*M=GRg$>1nH=U|q3P&rMsz%x5T z(@7K^dK(M?ore;q{z(HrMSR103e&7E9CV0N6{!I<7Q*B4j?NO;0$0n)zw%2ux!PQ- zzT{~s|LEKPF?o8{=W|#t=o}4A4gOBYN*%}=Vh~>n22v^E;KCNF4DgwwSTmJ$1mIhv zb8d7>MjQ^Y$|QvY+aNVXlvI)}#dotZ0Z8<9Xp=OQhmoWYLlX@VGeB!7^pzvgCXW*aub5J7pj)u15FyskLrdJMqfVt}(4g5G^QRf@~VUnIcDP4tcJXY#J zFvo#h97)P=2-grI6@=EYdD^MDHXnsx$jr%u`6Zz`N4yHoNDPA@7>KwhyMbTb;bJtg z3K1Y%F_;fr-p(TmEPxD_5QS0gra5!<`i}EXmy&iq{hIT6jA1hlq3~mLdkhu?)>bA` zd8~nc0!FvTTD&oOw3U&$XR>19Qu^l2D7dk2fJr*Cm%uT8U(Tmf~2DL5dy#43Y6 z_@~{}*ibk)B&8}?@@MN}81%6+I!h!uYkOqlk%BsiWuoa3q)3?W zhibM_L&6h!rcjO`^!aZ zGV&OTGCEwl5!hckR_Z`?_F?F1ph{KA@}HX!8sQ>9oCW!`s;ywjpmHJsn+i%BXG0yM zIu^*_1m}TKod^+8X23^ax$d((sbykR)-vYLi-Y4_Jf6VNG|PAxy57z;PG;?Ncw01= z<5<9EgxgW>-3U1j1brLHdm$?rE??m3Pn1!Ip_Z~wAhve&k4Tn74PO5JgH^tp>p1_^ zbjr?-FfvP?vMGG|PjZb_NY;q-alr9<`Z$M&cNR}vI`o+)oIQiTd9I1~*QU0#sRVd{ zPW(U?aZbpULv!*zq;fs`s?`EnT83{S0T<#*mS^c5H{>6X=0k_*9fQs9ao_nN*Rff+ zrM-rZ($XXffC%?$Uf??6Dy-V0Bu18f;IQ+0+=KtZvvlN%j&jwOB;0(%v04XWb{8YY zaO4AT4iVT@i1!Tzp|r_0Vih5GTp(IA=euXFP0be$YUh%B+*C;5Lx>>coU$UI93ygv zt{?!&#cW`6FVW;6BWHVY5Nhpgl?O5S?fn99fQ2N(}(UF!K>s_j1xes;u%-)gR;0e`d}IzGD{{D8=j_v%tz3d z!A1VbU$X=d8_9p*%DDhMwRrp1Z65-BHS<|G`ui`j{ny6gYghBAvdf+-V`{p>M$k~G zkfp7CN>I>WE4?J^QaHA}gbs?z2LP%cgMpBMbHu;!F=}CS$eoPV%>EaD%S{@PHMY~T z)Fg8~p-o`tllp0_Y}38y>sZjrp(%+SZ*4!cOQc3H8uE`;ZN9(Lu~G-}m4C1eV*mJw zB)FFoJo{BrYA^_+{l;- z;d8Dg=W&HPB5U~QlF_W&9CFlPAZ{W<{?eP95{(|b82?;rc@*+KPA851Wioo1UYJ-qKEU#+nBaFdw1(QFKlb7`2TGRN0f0YehNX8?Gtim8nJA3FNOq)0VaBQUD3>&pmoJ)m zRN{MaJm`8k$lE;YgdBY!N16kNn(WoLb~H-q#B@>%(MjKT#0$!Xq9B@d`I$}u3HgJl z{?|wb{iM-)nY<165e|oat|3eB$Y9S#@*L1fO?%~h=DE^T-OWmNf&~HusO! zCKMm^kp;9qxEDBX4#IA#1Cc>i&TKm3X8QuX0x1_7B^exnnl!77GY-KzR>KWY`3%w3%VrR0VVDa;y+Ifije#qg6p5>A1_ z6+`Y(y&uE^V=E~?Ub@w=Bg~peaaN3_rxe$i_HWJV8|s>Z+##JNB72j~6loT5at0?A z1~`hRwHOnED1qCo4BgiF>TwF`j4OV@i zE`V@a`gYX3)XGi{LN5T6r#&oxd(SkGa5*FeDKeL{yfvK*OO12;Gtg{~q!hA0atMR8 zspCSD3qF<__(wKx7Fy00P0l#iZ1x}I3EYD_sVQRBM{>cI3b3wRN0EpCX+yrW*FBRf z!UZ(KX9f4uPd&AG_L-+F@|Ta5I#87lmrz_$c$qJkX|z#l8mhrAj54e%F*IOENQVEg z(@S&nuY#s@xiwK0nxU4eoaOPw0Q@OE3QY49@o>&YIn9IHL*XPE1cn13IPfc2@M75T z1z{Za14bfv6ajqSkDuQ>n!y5DA?CM+U^9pn6q1>v>eOknwoh2%0(t4=mogYcwRtRJ zM;X6*^`<;flR^fMqO+ujih`!+0k{DsR6-7p*58wV1G2gLhtr(#RW1~26Bx1@<-M8z z(!j6TIuK8A!%v%bgU{pz1<^hf1uaeu@8};L0k1~wZcRpxz=$pRPTdR;aArWBCzbc} z_KUmOO4!v2lC$KG%3nMPS)awt!)%WBKD@3E676WKO*J8vvFl@Ye0 zm?c}nn-NTfs44_H4N=1%hyIXboTQr|-#gQw`I&2I9?I6-mL#?`?Q`ZUSXI8C)xSJP zQ$tB^kpd6?VX%dV$!he-LE}M)Wj#2L^Hx5TX)@61y|FX+v_P7haimFCjhK-|*8A0Q zmiQ-!$M|2e6HwJzsIzU^OzH$a)ITcB9c_I8kpfujZ(x*u%8|f7&3h0~4oBh~ndFR1 zDTcDrdkK`wO+uK{2_Iemn1C^ctvjIAvf>aI)DGE6j>xOp;4j&%t;<%#Sugl2zw#^Q zdZ%N>4kV{-OTuh8mhp7P*jmZe(IL;5K^YlxpQxaaIBT6bhLm!pO-{;b4^d-z0|ZT= zEUz4V>#esVz`J=X!IQoXK_QJu10C3=olbBK{OXgiDm6`;7>{II3Q#_AZ8jg_#yr5A zH1>wS<~?TGah{tuGB0%NMw)MZL>)$I$l%Wtm4jKEb~;i%;dj257v)cwvpEZ8V)vpM z&>ujXIvzbqA33_Tz z)K2zY^G~2qPar0J=zsF6Z9+?knfpzko2@(<{H=}^JJ3=e3J5Ww^bQgZn7YDuxlPit2$TJ&pbWtosI`Bf|mEI0< z^8_xz2RhvnD?yI)?es4hT_nOw)ew@pk;Lky4Dme}0|)SN8k%-G53-u$uSrBd_UK6& z!iV?J*6m92uBWpdsutORp}>V(c*4m)R3Bzgi>IJn#W3?DQx0ez7q!x6S@MvCQ z#P&7ts8Jf2atz}9YdC{PuC-5z9@mzuk+L|Hl@vG$HN$=;cW>-wQDyMqz&sX?BOwDG zFa*x5xi&J1TBt3{4y?ei)=ETY4?G{;7y?5d41M3b)#e*cSN>B#((^^EOul8)h-)b? zx5%ezfM57RS|p;Q%9VSqEm~^H|Fk1zBtRY9CS1<&XcG;pf@l7LeHp}Q7EYW12+V2% zfC^eQz%$s}bR-acYZv*}slfj091>^)K?03PG9SWdjwM185v@QG0ugd@g#zG<@)YGS zWhs3WuK`9kg z=+b$vW&(FF+uLfX1&!0V6e%~ZXIPtn_5LTeT_=as_Od4K+D=Bm0&_FVm`X?XF zGe+EcO|y+4f&6E{qJLCb9yQNg{$;>|$j}_d)ZtRk;Ak3?x2^?LQo!au;(CZRkWMls z8RW{OgI6$im{(zFM8`h}NT!s4(L{Aj#p;mYmVI!f90u!i zA;O`^BuGl~8G_;(olb&DG90BA1Aek|ivz({xK;X`Z}1+tqe$M*iehhPz1f+(oq+oe zuTNUlAPU7Wa#>W%P0^232E|7-*DRBrA?O$xBTh^ZcO0x$SQvB`P^exRAjZq```cm^m{k+cJVxVpGO-e zX$@Ea0Vq6CK3_5m{L*C~Nd1=$?~(Hmw6fMPW>QPI$MP60x_f5KNtvgHUHrv0M+8arEfs++W^fF+POv&e zPNtm1pI10$NSeJGHm>JJ5?D60mp&H#C>&s*^#1+q_{n?)`-g+~&>{4HfY#PUr-l1J@ zsgs)p`8x_+S>w<&#W%)u2zlP=SZNcE$cwy=qB4v(ZdTwC2XrGTgk4QY7wL@{{DPVG zVdleBXhvNaNVq^lN2ew#F2t{q={zaqj+#{a;sub|ir?$fQ+NaD$fTM?T!R@Ua|$83 z->6lXWaGtF><&|d06+3*sihHE9rk9;0Q{qNP05O76f$T~ei~<$jzQnNo7#=c@-~%8FDyRsiBAMvnx7Lv+2OU6X0sP`q)iGduF7NvEdl2~_C`g(%gHWWys-Gxr@p^%zRCAW9mw~7g2)<1 zt>;Mek5W}&zUc?Gn}3oD%$6I#3bM2!^in&FWAFf3(?ddwa}t?5C_St;2+h!=f^ceh z@UDM~YM4ZwaWuJzvMHmiBxmx9AWI9WptjuLS*pA_iB(bqh+wLkkw@4VCMeJo#y?`TSiTT3=DiZ zhW_iU+{+7*FI`;x(l7t=*1V7E~}ts zvgrlP$O<#4lyatH0t}T^d|Vszgp(_8CtsQ>bnF?=-%9OJ2PzHLhJzjpKdqwn~1KB~x!e)2yd6d69iP3FL?jhC3)jGK;B3vJ#( zuCf5yV55LjfaZSLm^7BqnGe1wIZ%r;Zay3X&M{Q*6BmnP;&((5omyr%VTE=CFhW57 zXQ-JI$dDDa&*XnPWaSF`YO&lTtCAnWW%Y)OaDlE28SMDh^E4&o-TPjt1E~ZRhH->Z zbTz7I6^14waCS&R3UeKjj$%H_AT^>+n*^jeu}8H}r`xq|^kcx2{5qyTkZOf1>`+0( zQ-Q64eB~jXLak18(mMWIcHF=3p2(WdGN25(+6s#7Nt)VJ=?*cxk-IfcdBJFw$OH!+ z5-$HVX&tSj<4?y!W2!+FNiurx82#!KIFq;Ilo$tGJ|x01;>QfU;tw% znfy>Vz_c`Q1^`|G0v>{c!Iq|{<;q=Ufzjs+h@|yrLOm8|^?o--{P;`-OHp+-)mLxG(@f7+iyeZ?_wYRfx zc{cpd!-JHqK<>xSI!Kzd)c({!yamIWCfTWNeM23!AJ^7MQn{3Wbvo|pfJbwBo$nI! zi5>5P10m%aPE}Ijz@g2m!D=D_TWY``Df*zp!JIE$ln)k#a; zaQ1;yPXVTQ^XH(!aOR}Zgxkx8`Zzy*2%9XUCarTPJ4<(J;ZmlfNC}tl%QfTU1@!hlN2xh6WkBOOOy8nxP!Jl$P%96d1a@yHi9!8bliDMi8XCyM`Fvxu3t` z{kFfXV_z$dy{_87Q*i!bW*R+UC8>fMpEX6!(fZ#IXFk8p3dqavYvE}JfLR4U<_ygyf4RKirEovp?*KY!X;23i z@_X3i!YDs$lJg7w69df~wX?yIvTm!@qA%G}Zep9_xKc7+V(0;ds0P*=`>IE?h-z)oW+1ghoq7Z7sTiW{qNu+rw zd2jn1HKFVD6hZ@XyETl)l!LX7`ahkY!U#IYrq!~c)krjY{9>E=8bO^E*uC1;iTSuh z$>ZPBuhX!|X%&Z*@EKE7ZY(+WU?>*Xa>Dkq1=6JZD(f2_HC}e zB>0YX;kJd-wvn)oav#8JtCa}!A~X*3<*NekmRF7XNFuXXm^xgIC0eY><|N;_wwW2g zC5oZO#;mc%ip>`0c_u``(dR`WB3a_XUl#IN=|dNp!;QYZC`OXhsRvQ;es>948;m&; zgDk5tiScK8TDWMG8FA^=OK68no(=lvhI?LLyxWb$S1IF!TyEsZmMxQmNY?ax&N`=Ks zg6ocZF(srL#F2%K>IoS8uzM=`t16g5=~`e|^M*h)%97^>&1?Xb8ex znp@w3AtJ0eh?m69fLbi*R*@`>EG@{BFcZ*QrtqJznTfK}P+B~me|k_P6H00K3I8)P zt4E=e-H(aem|$-x8TUGx9K(0GxuvJ7qLmj&#v*k$oNN0E*w!UPIi`-oMIdHwbI2Mi z)0CNVc>S?EiDzB&YY*5G@j~2HigLgjX+L2B+%%bls7&M+B~sX=Aou~^P1|EZ87|wA ziAZ?=g27#fTV3jnKX~3{-ls_<9NVk@k-s5B%4D&9`$y97@8evgBMUH8Z|9J+I+muj zHn6=5jOSfg%i6hZ#om2Q%B2@Uq_1tatC5;*-SgvPZ&w1ALEiGg$mCilA9mWICf9}< z`UMJ)1qbZJ%9Oq3JpX#I=W>z{B$IOZ7ArLu!7);a>tQ&YSR(iy? zD7s8I)O3-uzr$IRX=w-m@`YZf2}N{7z7te=WTg|x%rrOx4-<9ERG`TtTG7x!n+RpZ4*Qsg z0FWd6g5U$LJKLiwgtE~@ihPavm$%A+PbsdtMOY%8dk7_Zx}&7H0J+&1$Hvo7MM$hrFF{d<;081MOZY_S)a9sJs^~+U)DAWyt5>s7Q_e8 z_7(IDF7s?UL&eO!zB5kr47yyLwL`oo%;JCYKIFQZWW^Xw-CpwL3xp|lZ56Mt*c2Fh z8)1MhK0m(GB4-_N9GTqh8vJ+ndync0A7m(i3zxLtiY|6^$o;HWaMS9<^RpxG6!qU! zc?!KzgV&-z>F6Tk}l!zCg#I=TO5 zr^^XplTyznAj2ew=Q}MBm-PWL4DlT@0;ZPo02Q~?c1C+>?A9~PtW|BqGbl4=>ozsr z0p4AdeeC|APzP?!*D1Np-`OsVv$EY8TOA04Q8~udCl}US?N*Hbt@EYOO1vFN$Lt*GD97RbaJL;z&U-d z6uXZ3I;^Lq^9OKTM!u5^46EDU{kw-o>cyLLiL|HlpA9o=+f=DGJj39tHU0K3OsV%w1e7gaNM- zF7VsXdX@`hw#CT%UP#pQ%4$Wy*=)^z`gJ<9)EI-;$+fy(>%eJVd%?vyT@7gRrgaoY z$z&TMuvHxT##ADO3x&y0XuDm@YReA6gown4I=R$M0hMDD12lsjT;pl3{sznxh7DI{ zMv=A(izbqTbk5T{egUJF#$6S_f4&DgDeG4#^KGrt%4sE8@bB{BTzX-~DXz+SORZv2 zBfCMQp$*HsAKD3s!{Q38eouP-pi;W399DnxyWfi97N4#rOl0Zskbk_6!YoP^aIVgj zJAL_p#8bXX1YfCLsh<>=6;Psw5V?CRg?l&~P`3IM?L!N(M&xyh_G;(iPS!)SOurlJ zHPbP>%_vSZk7Y<30x8Xl?^GjB+~sXCs$KIEKaBz~sikAS!lW4eSbu>y9jmvOHV; zKC(Ar0eIkR%|tDLw-s{ENXMUeI3AlNBShYh{**Iix6eIu5^A_R_SQ4s*TUdRI6eMH zVO{9fDZJOn<{$Lm&B?@F)#l?k5aG@~VMSX3`1fQOSF?cVEoitPFdHbhG5T-7^-R9^z1JgfL4fCcDYelMLj%*+zdnO2CHQJLSrH zaJ5Vc9H=U&Q$&J)3uzSMCQ@uw8~FJ-vn%+mcy32AgvWqSh_rdjKL|bPP%8ZtnB(Bk z4<>-eQ_+rWIlF+zBq=d;E?a)f{dYJjP3L5&pjJ7~1-~${F^uffr$PrM-xhM(3N@9e z+7TyNmh*wWT~8gEsGx*EzpUQ84(<9M^7o!=cY_diqm$~_rQozok3m| zPvsr5+=4AUm3{W|ty0xuLjF)+i_@(J_-{Ngz~6(H{q?l&T1Q;(=E*4F!vOffIc}@i z-H8UqrrhNg$E`Fmxh94mIAY2$c*}^ae&Ijm=2E43nq78DgTMqi_IffpWg3KY^kw8G zTQWF-9d@uO|EM05H9ml|w2=oYr)U)B3HUu`BfcwxERJaL(}U1( z3MO{+OFe3y4J=nVVdaQGRHC--_({HxurzRPh{{%fQu&OB`WL*)x2TsAgZNTfs`%S- zBr)Bk!33%C4eJGa>lu%}BrHVbPll*YjR24nhf!4Lg}7K9g(PMkzrzZUQ!_CJ@uMj~ z(U$0J1|M|I*|7lFtk?NCIb%0^9 zB%n^d_h3l+h%0%rJmM-`s8&?ehkH4_9t#L-X>AV7F6?X3!NfZDX`bOw5{ycmX#64v zm=vXVy!VNr^@z9uIig|i1%f-)vD#c>$qtA?Y8|=?iA2VK=vtb-%8EHL^yZ}RK#jAk z^DckrjV;uoKFjgo2&`k>yKkwL6&IIetA$@A+Ux^ZwiA}c85#od+VI;d;0%VkG(h?f zqd;1>mNrH{S-zS~ctr#Fd-sHzD1EZ*8{Dmf(?PO@ubs%S@<3cRKz162K4H%t2@1s5 z5Wuq@wZK|FES6Qr0{bW(alVp!;7sip(YCsDog_z_|JuN%i(Shco%K>gPI3nHvXu{r zq)qD%Oir-4EVPc5k?B*1^r}X?MULhQ`&ggYb8_mpAC-|@t&f+qI>9eWLA{8LKU#)( z@v~7>1eOML0WE545GwU1=Y$ zLvW8Cd+4MxV>231fRI((1k7 zJBl|$3vf_MmaG@}_~ON(l&xuAn9EQEL+(h)Ticdu5$6x(%R@#l0R$Yy#zmyD9&nsQ z!r}r86H`|xC7DLiYx9C)XlY{C8>o?gwEx?(+wGt8WfIZHWc`~GFxLp0p`^iFp?DOV zpp5YlAd6k*h$Zp&rH>-p(h_Z!#NC#I`5a5=aYBVnr%pG7biu#`^ZOTxk9!n*_8oZ8 zAb-J3YCU%YtN1ErylK|1gm-t;wfS{R5i&+bAklHIZUT#e!v}traC+ z+)Ou-PI)zb9(oE+%+OV-CKsAQZmfArSS9_O?>gg6Ec8?Dc#4G<+9ET)7rkAP-`XN@ zT7Y!`$J7jF>GhtdPC!p4fU&6JQ&{F@c|IbqpU9ayue-3OU)~BD@5H!LDgGQMNMHhR z6R0A`^n>6$8i9@(6fN_@G#J9;$aVMWxjdZ|W4QNG_Vx@;>I^d_BscBes<_U|phR4O z=-UMcm_YE<(|tuJ0UL)Cw#>wu3T(?cvl2k|8v{W}t&ChB^}DRQrC@*6QIyEiDIyM5 zQbMb7dYX$`OL*YJcA>&v=nX!OVX%`K(>Nw5S8#+D!C@&+khSKp(zBtPhD>z)Fu{aV~jw8Wu@CYQ-%8_5q=*b+iQbAH5wmTe&KuRKCWfxZvCmVm}4TgG6`0JDIifS|Qm&~Q7 z0#OV$)DL-jf>fnW&%Qm?8h>1JBtk1Qm%4vg_8FjhSyY-FET$iDsW_{-(+R$$H#MuR zZ?132^Xqhmfwk>kyfJ)>`D|2SihE1^#>_=6oScuz)pjS*DgYHK4+Yz>eG#A%0`oWd zrfgi=D$lmOctz}4HQz5@DYgNQR&6E%_vcEA~)YErq^J83M2O#P!7lcN>b+#EB3P0 zJxndkf>bSoPCLuH{2iVd(YVF3-h9~p7Qb3v*~Z`VhB3hxA2KH;wp=iU;cnt@#Z4Dl zhbV~eyFk6Wqf%;sA9Y0lf(36rg-g7Jgh@!0axxC($zJ^W?1Shq*8EI=Qc+_si?>`Q ziYty8spSQ$TTGl8wP`KZP||CDX+_Q{;G?sMLY!_jHwH=>@$9n*-euy!rlM%WA!m9z2R`7E+&J`+C@yu@waIp@k7^S{jEBinYc}M z7KPQ)^kM1N58#c$=ahkW9Ked3Xl}(571K(hmt5-!zgq>0_hJSM8LMq&XJa@%EU29? zBft%%l;40}B4p;%4cY9qmgO{88ERVGSER*9lUDg?o6IUPtccG!w7<3GlFi29($J2g zKH^sdzv^#2?$d!mN6V(v?2UBP(&%*`d$as80q_BF;R}oiI@VmXH3+l+89yxv>KNnB z=bX(~CqAAFhstQ%JXU;Xg%q|Rt1_}YNKpBhqBrFg6V7&U;gxFfIXhWWO$tfuma>65 zK1Sn?(_hmvFd@x>bt&#uAf8C;T~{)VcDu`&e9^T5iCmuyTLem>>5;S^Y{HSAxb9Lp zq@*A#%HD3&FUNW@5n6}dH3~LQCLQz1_b-J#U*9RMhON6B`053t6D!4l@ghokhGkrv zBj+&|mFrai!HmUhsMPn0@8cLACk-_=W&gTUJp=u^yN>FGmS&>*wBkmx8|n18n?oe} z&)ZOgjW3l0l3@Io+6%CsN^h;u@y=fG4mwXxESBEJZ5djBfoH^TjPGxET-MSz zmiF`1XO)QT$lLHFX9?N1_yrNFf;eo?-X(Q3-|o7mm}l4_Z-z$CbhR?`#T#3tl7s&R zKRy;bCsk%)$|`GujjpPikw`*vg$rYzC=cIHX`fdHUn+^%zq5&ynjEE16-% z%zH8^J5nXQn?EeF_RB9a?n3)z#2ynJ?@y+C{BJw84#c@co*(_&Z|44W-lamGhc{xs zIda_K)~7H<{RyFb-&^{dhf}v$J!tl54z2tW?O=N(S|PpWPMX2>9S7x8bmyJC$D04= zjH)SA?GJv6stRZXeDZ1^hEic9omBe8m&Y%Vij~9?TEMC4$1|Ko0sTfYWGEHRH2m(B z+x~QEe`-AMRGWK<-qLod786toK&nK$6J3WTGS+)YR=B00N)$A@k(|nb8~G zG+9;C6fFX4(6GRX9Wh&AC*rzhw=vD}?TLdlC{ypK);;(2Bh@zH!!%FYRp>f8AIH0S za@z-HkeHI0(9@ZGR`eGvzTSn2u-Cy-do#vPtktawN?Rn-wDWO2n#Ox5{fdvJM3p#v zFf9icR7F`uGQ{4cy?@&N84X2eBev-Mk^umbPhTO z-Le?oZEf%)dL7$|P*%mK_3 zY(7tG`|Q<=!(AC;#)Ugg7N`Vlh;V}7IKB^RSh}3_&<JoewETZt781L(KK5Z#>IklVjWCZQw=k(`B{pq~9 zb1Hg0I#Y~8#mYByCF@RTZbjHD=o(n}MJCi~w&jiMvCWcc-PQHHLGym(4kc0rfkT58 z^b(uhyX!R5GM}1l6~2E7Iq&cPR@1(5$ZB0_(BWo&-n}c<;)Q^B3RS{x>LE=nh#U8G za=&9+F0K{dOC0MJ6{V0L>$D(YqKVOUw_|Oq)WWk;HQu7oH9{fKo-BKy=u)*_YkL9Y zVfNEROE#{nA-belQYUW9o-$j=V<3MH%3rX|e(^SSJ z_8&^P(rK~;@lkLVLXVoZ-R(TdFQ3%V*ZREYlFN5rI2~alU%eEV=a29)%(l_RiM*BY z)+!!Td751ne7s11Zl;=@>F`Hq_!_9#yZQ3>Y=STK)g3(lIee7|0zqu%3tyLV`N7Y8 z4DVL@{qMi8>OR&$hS40E1*lScy{qJ!7GJfv^1heNE(Jl*eZfi+P~rvG4eEB2Eiz9F z?8@?dn9cb|n`bWH^XTZ#=X%Hk)y!SNg*WAJ%~BvWMp-9WEaZ^^LRc(3FNUG!O*yMS z6Xjbt!#lw#hB%M+^O2F&hVa%&KD1BN5b&lezJYPZ0Rme!R!6Tdo_h=j-Q(Z_nXB60gq#2({eCXh#Gbj+VVBW&O9T^@JxGYKkyeYHPwrrM!=-H|Z&;0owdx)OvMxJDp-H zmxM;viu2XyaZGb~ZpVv0dFSC>Ym0%@K?y44q0z$LT|5t!KU~5!Ryd~hXz(}#8v&v% zKpH>Ha}v_fm6361;f+Xq8{}Rc@|w7sYVU{YOfbPv(LZ7D?Ecs%j+9u2+*OhMZFw1m zRBGV8r{{hnz>jn<|2&Adyv-jCca}w_6C31@$$=b7hY2rH%NZjo-Q^$W5_7td zg1%0;+w0A&u^kNRaL_1J1T`Sj{OF z^|8vBvgCuO%1ys|d_xz4yzjehL1$Pk^?Vh_it%$`uFp@g5*XsFot^N1Z`1$ZGI(Qt zUYN=9JFh-5(AQh`SyqCK+uI$BU4%f;(U4Q(QJoG>YVqDVs~Id^(eqdT{BfhteuTROxX3Q zccJTc&hED-xf2ld637K3avq6VQ^7sULmr#w+6)6v=7TZW@i#VKB+ha-zj*N?Lrz*k z-6Yw&;3$0zF`w;Sx_z%5i#CVbKrSeDp_(Uf+u{E_{fP^)`AH|~tncOlDPY0krL?At z8lQz`(0V@Hws2FyX09}$i)xEi#Z;Tt_5bl8iTX^F-}T@uz0_rfwDE=92W9DMNu!|u E1ODZ9SpWb4 literal 0 HcmV?d00001 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 73247e1f8..13a4cbee9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -46,8 +46,15 @@ export interface AMSTray { id: number; tray_color: string | null; tray_type: string | null; + tray_sub_brands: string | null; // Full name like "PLA Basic", "PETG HF" + tray_id_name: string | null; // Bambu filament ID like "A00-Y2" (can decode to color) + tray_info_idx: string | null; // Filament preset ID like "GFA00" - maps to cloud setting_id remain: number; k: number | null; // Pressure advance value + tag_uid: string | null; // RFID tag UID (any tag) + tray_uuid: string | null; // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools) + nozzle_temp_min: number | null; // Min nozzle temperature + nozzle_temp_max: number | null; // Max nozzle temperature } export interface AMSUnit { @@ -124,6 +131,15 @@ export interface PrinterStatus { chamber_light: boolean; // Active extruder for dual nozzle (0=right, 1=left) active_extruder: number; + // AMS mapping - which AMS is connected to which nozzle + // Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS + ams_mapping: number[]; + // Per-AMS extruder mapping - extracted from each AMS unit's info field + // Format: {ams_id: extruder_id} where extruder 0=right, 1=left + // Note: JSON keys are always strings + ams_extruder_map: Record; + // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool) + tray_now: number; } export interface PrinterCreate { @@ -421,6 +437,34 @@ export interface KProfilesResponse { nozzle_diameter: string; } +// Slot Preset Mapping +export interface SlotPresetMapping { + ams_id: number; + tray_id: number; + preset_id: string; + preset_name: string; +} + +// Filament types +export interface Filament { + id: number; + name: string; + type: string; // PLA, PETG, ABS, etc. + brand: string | null; + color: string | null; + color_hex: string | null; + cost_per_kg: number; + spool_weight_g: number; + currency: string; + density: number | null; + print_temp_min: number | null; + print_temp_max: number | null; + bed_temp_min: number | null; + bed_temp_max: number | null; + created_at: string; + updated_at: string; +} + // Notification Provider types export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email'; @@ -1036,6 +1080,25 @@ export const api = { body: JSON.stringify(profile), }), + // Slot Preset Mappings + getSlotPresets: (printerId: number) => + request>(`/printers/${printerId}/slot-presets`), + getSlotPreset: (printerId: number, amsId: number, trayId: number) => + request(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`), + saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) => + request(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, { + method: 'PUT', + }), + deleteSlotPreset: (printerId: number, amsId: number, trayId: number) => + request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, { + method: 'DELETE', + }), + + // Filaments + listFilaments: () => request('/filaments/'), + getFilament: (id: number) => request(`/filaments/${id}`), + getFilamentsByType: (type: string) => request(`/filaments/by-type/${type}`), + // Notification Providers getNotificationProviders: () => request('/notifications/'), getNotificationProvider: (id: number) => request(`/notifications/${id}`), @@ -1204,10 +1267,10 @@ export const api = { }), enableMotors: (printerId: number) => request(`/printers/${printerId}/control/motors/enable`, { method: 'POST' }), - amsLoadFilament: (printerId: number, trayId: number) => + amsLoadFilament: (printerId: number, trayId: number, extruderId?: number) => request(`/printers/${printerId}/control/ams/load`, { method: 'POST', - body: JSON.stringify({ tray_id: trayId }), + body: JSON.stringify({ tray_id: trayId, extruder_id: extruderId }), }), amsUnloadFilament: (printerId: number) => request(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }), @@ -1239,6 +1302,13 @@ export const api = { method: 'POST', }), + // Refresh a specific AMS tray (trigger RFID re-read) + refreshAmsTray: (printerId: number, amsId: number, trayId: number) => + request(`/printers/${printerId}/control/ams/refresh-tray`, { + method: 'POST', + body: JSON.stringify({ ams_id: amsId, tray_id: trayId }), + }), + // Print Options (AI Detection) setPrintOption: ( printerId: number, diff --git a/frontend/src/components/control/AMSHumidityModal.tsx b/frontend/src/components/control/AMSHumidityModal.tsx new file mode 100644 index 000000000..5640abfec --- /dev/null +++ b/frontend/src/components/control/AMSHumidityModal.tsx @@ -0,0 +1,100 @@ +import { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface AMSHumidityModalProps { + humidity: number; + temperature: number; + dryingStatus: 'idle' | 'drying'; + remainingTime?: number; // in minutes + onClose: () => void; +} + +// Single humidity icon that fills based on level (larger version for modal) +function HumidityIconLarge({ humidity }: { humidity: number }) { + const getIconSrc = (): string => { + if (humidity < 25) return '/icons/humidity-empty.svg'; + if (humidity < 40) return '/icons/humidity-half.svg'; + return '/icons/humidity-full.svg'; + }; + + return ( + + ); +} + +export function AMSHumidityModal({ + humidity, + temperature, + dryingStatus, + remainingTime, + onClose, +}: AMSHumidityModalProps) { + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const formatRemainingTime = () => { + if (dryingStatus === 'idle') return 'Idle'; + if (!remainingTime) return 'Drying...'; + const hours = Math.floor(remainingTime / 60); + const mins = remainingTime % 60; + if (hours > 0) { + return `${hours}h ${mins}m`; + } + return `${mins}m`; + }; + + return ( +
+
+ {/* Header */} +
+

Current AMS humidity

+ +
+ + {/* Content */} +
+ {/* Large humidity icon */} +
+ +
+ ❊ + {dryingStatus === 'idle' ? 'Idle' : 'Drying'} +
+
+ + {/* Stats row */} +
+
+
Humidity
+
{humidity}%
+
+
+
Temperature
+
{temperature} °C
+
+
+
Remaining Time
+
{formatRemainingTime()}
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/control/AMSMaterialsModal.tsx b/frontend/src/components/control/AMSMaterialsModal.tsx new file mode 100644 index 000000000..6cf1596df --- /dev/null +++ b/frontend/src/components/control/AMSMaterialsModal.tsx @@ -0,0 +1,560 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, ExternalLink } from 'lucide-react'; +import { api } from '../../api/client'; +import type { AMSTray, KProfile } from '../../api/client'; + +interface AMSMaterialsModalProps { + tray: AMSTray; + amsId: number; // AMS unit ID (0, 1, 2, 3) + slotLabel: string; + printerId: number; + printerModel: string; // e.g., "H2D", "X1C", "P1S" + nozzleDiameter?: string; + onClose: () => void; + onConfirm?: (data: MaterialSettings) => void; +} + +// Extract base filament name (without printer/nozzle suffix) +// e.g., "# Bambu PLA Basic @BBL H2D" -> "# Bambu PLA Basic" +// e.g., "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" -> "Devil Design PLA Basic" +function getBaseFilamentName(name: string): string { + const atIndex = name.indexOf(' @'); + return atIndex > 0 ? name.substring(0, atIndex) : name; +} + +// Get normalized name for deduplication (without # prefix and @ suffix) +// e.g., "# Bambu PLA Basic @BBL H2D" -> "Bambu PLA Basic" +// e.g., "Bambu PLA Basic @BBL X1C" -> "Bambu PLA Basic" +function getNormalizedName(name: string): string { + let normalized = name; + if (normalized.startsWith('# ')) { + normalized = normalized.substring(2); + } + const atIndex = normalized.indexOf(' @'); + return atIndex > 0 ? normalized.substring(0, atIndex) : normalized; +} + +// Get clean name for K-profile matching (without # prefix and @ suffix) +function getCleanFilamentName(name: string): string { + let cleanName = name; + if (cleanName.startsWith('# ')) { + cleanName = cleanName.substring(2); + } + const atIndex = cleanName.indexOf(' @'); + return atIndex > 0 ? cleanName.substring(0, atIndex) : cleanName; +} + +export interface MaterialSettings { + filamentType: string; + color: string; + nozzleTempMax: number; + nozzleTempMin: number; + kProfile: string | null; + kValue: number; +} + +function hexToRgb(hex: string | null): string { + if (!hex) return 'rgb(128, 128, 128)'; + const cleanHex = hex.replace('#', '').substring(0, 6); + const r = parseInt(cleanHex.substring(0, 2), 16) || 128; + const g = parseInt(cleanHex.substring(2, 4), 16) || 128; + const b = parseInt(cleanHex.substring(4, 6), 16) || 128; + return `rgb(${r}, ${g}, ${b})`; +} + +function trayColorToHex(trayColor: string | null): string { + if (!trayColor) return '#808080'; + // Tray color comes as RRGGBBAA, we need #RRGGBB + const cleanHex = trayColor.replace('#', '').substring(0, 6); + return `#${cleanHex}`; +} + +// Check if tray has valid Bambu Lab UUID (32-char hex) +function isBambuLabSpool(trayUuid: string | null): boolean { + if (!trayUuid) return false; + const uuid = trayUuid.trim(); + if (uuid.length !== 32) return false; + if (uuid === '00000000000000000000000000000000') return false; + return /^[0-9a-fA-F]{32}$/.test(uuid); +} + +// Bambu Lab color codes from tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow") +const BAMBU_COLOR_CODES: Record = { + 'Y2': 'Sunflower Yellow', 'Y0': 'Yellow', 'Y1': 'Lemon Yellow', + 'K0': 'Black', 'W0': 'White', 'W1': 'Ivory White', + 'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Magenta', + 'B0': 'Blue', 'B1': 'Navy Blue', 'B2': 'Sky Blue', 'B3': 'Cyan', + 'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Jade Green', + 'O0': 'Orange', 'O1': 'Mandarin Orange', + 'P0': 'Purple', 'P1': 'Pink', 'P2': 'Sakura Pink', + 'N0': 'Gray', 'N1': 'Silver Gray', 'N2': 'Charcoal', + 'D0': 'Brown', 'D1': 'Chocolate', + 'T0': 'Titan Gray', 'T1': 'Jade White', +}; + +function getColorNameFromTrayId(trayIdName: string | null): string | null { + if (!trayIdName) return null; + // tray_id_name format: "A00-Y2" or "G02-K0" - color code is after the dash + const parts = trayIdName.split('-'); + if (parts.length < 2) return null; + const colorCode = parts[1]; + return BAMBU_COLOR_CODES[colorCode] || null; +} + +// Find best matching K-profile for a filament +// Profiles have names like "HF Bambu PLA Basic Sunflower Yellow", "High Flow_Bambu PLA Basic" +// tray_sub_brands is like "PLA Basic", "PETG HF" +// tray_type is like "PLA", "PETG" +// colorName is like "Sunflower Yellow" +function findBestKProfile( + profiles: KProfile[], + traySubBrands: string | null, + trayType: string | null, + colorName: string | null +): KProfile | null { + if (!profiles.length) return null; + + const subBrands = traySubBrands?.toLowerCase() || ''; + const type = trayType?.toUpperCase() || ''; + const color = colorName?.toLowerCase() || ''; + + // Priority 1: Match tray_sub_brands AND color (e.g., "PLA Basic" + "Sunflower Yellow") + if (subBrands && color) { + const exactColorMatch = profiles.find(p => { + const name = p.name.toLowerCase(); + return name.includes(subBrands) && name.includes(color); + }); + if (exactColorMatch) return exactColorMatch; + } + + // Priority 2: Match tray_sub_brands without color (e.g., "PLA Basic" in "High Flow_Bambu PLA Basic") + if (subBrands) { + const subBrandsMatch = profiles.find(p => + p.name.toLowerCase().includes(subBrands) + ); + if (subBrandsMatch) return subBrandsMatch; + } + + // Priority 3: Match filament type in profile name (e.g., "PLA" in "High Flow_Bambu PLA Basic") + if (type) { + const typeMatches = profiles.filter(p => + p.name.toUpperCase().includes(type) + ); + + if (typeMatches.length > 0) { + // Prefer "Basic" profiles for generic type matching + const basicMatch = typeMatches.find(p => + p.name.toLowerCase().includes('basic') + ); + if (basicMatch) return basicMatch; + + return typeMatches[0]; + } + } + + // Priority 4: Default profile + const defaultProfile = profiles.find(p => + p.name.toLowerCase() === 'default' || p.slot_id === 0 + ); + return defaultProfile || null; +} + +export function AMSMaterialsModal({ + tray, + amsId, + slotLabel, + printerId, + printerModel, + nozzleDiameter = '0.4', + onClose, + onConfirm, +}: AMSMaterialsModalProps) { + const queryClient = useQueryClient(); + + // Determine slot type + const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE'; + const isBambuSpool = isBambuLabSpool(tray.tray_uuid); + const isEditable = isEmpty || !isBambuSpool; + + // Fetch K-profiles + const { data: kProfilesData } = useQuery({ + queryKey: ['kprofiles', printerId, nozzleDiameter], + queryFn: () => api.getKProfiles(printerId, nozzleDiameter), + }); + + // Fetch cloud filament presets + const { data: cloudSettings } = useQuery({ + queryKey: ['cloudSettings'], + queryFn: () => api.getCloudSettings(), + }); + + // Fetch saved slot preset mapping + const { data: savedPreset } = useQuery({ + queryKey: ['slotPreset', printerId, amsId, tray.id], + queryFn: () => api.getSlotPreset(printerId, amsId, tray.id), + enabled: isEditable, // Only fetch for editable (non-Bambu) slots + }); + + // Mutation for saving preset mapping + const savePresetMutation = useMutation({ + mutationFn: ({ presetId, presetName }: { presetId: string; presetName: string }) => + api.saveSlotPreset(printerId, amsId, tray.id, presetId, presetName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['slotPreset', printerId, amsId, tray.id] }); + }, + }); + + // Get filament presets from cloud settings: + // 1. Show ALL filament presets (like Bambu Studio does in cloud profiles) + // 2. Prioritize current printer model, then deduplicate by base name + // 3. Sort alphabetically + const filamentPresets = (() => { + const allPresets = cloudSettings?.filament || []; + + // Sort presets: current printer model first, then others + const modelPattern = new RegExp(`@.*\\b${printerModel}\\b`, 'i'); + const sortedByModel = [...allPresets].sort((a, b) => { + const aMatches = modelPattern.test(a.name); + const bMatches = modelPattern.test(b.name); + if (aMatches && !bMatches) return -1; + if (!aMatches && bMatches) return 1; + return 0; + }); + + // Remove duplicates - keep first occurrence of each normalized name + // (presets for current printer model will be kept due to sorting above) + // Uses normalized name (without # prefix) so "# Bambu ABS" and "Bambu ABS" are treated as same + const seenNormalizedNames = new Set(); + const unique = sortedByModel.filter(p => { + const normalizedName = getNormalizedName(p.name); + if (seenNormalizedNames.has(normalizedName)) return false; + seenNormalizedNames.add(normalizedName); + return true; + }); + + // Sort by base name alphabetically + const sorted = unique.sort((a, b) => + getBaseFilamentName(a.name).localeCompare(getBaseFilamentName(b.name)) + ); + + return sorted; + })(); + + // State + const [selectedPresetId, setSelectedPresetId] = useState(null); + const [selectedPresetName, setSelectedPresetName] = useState(null); + const [selectedFilamentType, setSelectedFilamentType] = useState(tray.tray_type || ''); + const [selectedColor, setSelectedColor] = useState(trayColorToHex(tray.tray_color)); + const [nozzleTempMax, setNozzleTempMax] = useState(tray.nozzle_temp_max || 220); + const [nozzleTempMin, setNozzleTempMin] = useState(tray.nozzle_temp_min || 190); + const [selectedKProfile, setSelectedKProfile] = useState(''); + const [kValue, setKValue] = useState(tray.k || 0); + + // Load saved preset when data is available + useEffect(() => { + if (savedPreset && !selectedPresetId) { + setSelectedPresetId(savedPreset.preset_id); + setSelectedPresetName(savedPreset.preset_name); + } + }, [savedPreset, selectedPresetId]); + + // Handle filament preset selection from dropdown + const handlePresetSelect = (settingId: string) => { + if (!settingId) { + setSelectedPresetId(null); + setSelectedPresetName(null); + return; + } + setSelectedPresetId(settingId); + const preset = filamentPresets.find(f => f.setting_id === settingId); + if (preset) { + setSelectedPresetName(preset.name); + // Extract filament type from preset name (e.g., "Devil Design PLA Basic @BBL X1C" -> look for PLA, PETG, etc.) + const typeMatch = preset.name.match(/\b(PLA|PETG|ABS|TPU|ASA|PA|PC|PVA|HIPS|PLA-S|PETG-CF|PA-CF|PET-CF)\b/i); + if (typeMatch) { + setSelectedFilamentType(typeMatch[1].toUpperCase()); + } + } + }; + + // Find best K-profile when data loads or filament type changes + const profiles = kProfilesData?.profiles || []; + + // Get color name from Bambu tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow") + const bambuColorName = getColorNameFromTrayId(tray.tray_id_name); + + useEffect(() => { + // For Bambu spools, use tray_sub_brands (e.g., "PLA Basic") and color name + // For non-Bambu, use clean name from preset (e.g., "Bambu PLA Basic" without #) for K-profile matching + const subBrands = isBambuSpool + ? tray.tray_sub_brands + : (selectedPresetName ? getCleanFilamentName(selectedPresetName) : null); + const type = isBambuSpool ? tray.tray_type : (selectedFilamentType || tray.tray_type); + const colorName = isBambuSpool ? bambuColorName : null; + + const bestProfile = findBestKProfile(profiles, subBrands, type, colorName); + + if (bestProfile) { + setSelectedKProfile(bestProfile.name); + setKValue(parseFloat(bestProfile.k_value) || 0); + } else { + setSelectedKProfile('Default'); + setKValue(0); + } + }, [profiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, isBambuSpool, bambuColorName]); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleKProfileChange = (profileName: string) => { + setSelectedKProfile(profileName); + const profile = profiles.find(p => p.name === profileName); + if (profile) { + setKValue(parseFloat(profile.k_value) || 0); + } + }; + + const handleConfirm = () => { + // Save preset mapping for non-Bambu slots + if (isEditable && selectedPresetId && selectedPresetName) { + savePresetMutation.mutate({ + presetId: selectedPresetId, + presetName: selectedPresetName, + }); + } + + onConfirm?.({ + filamentType: selectedFilamentType, + color: selectedColor, + nozzleTempMax, + nozzleTempMin, + kProfile: selectedKProfile, + kValue, + }); + onClose(); + }; + + const handleReset = () => { + setSelectedFilamentType(tray.tray_type || ''); + setSelectedColor(trayColorToHex(tray.tray_color)); + setNozzleTempMax(tray.nozzle_temp_max || 220); + setNozzleTempMin(tray.nozzle_temp_min || 190); + setKValue(tray.k || 0); + }; + + const filamentDisplayName = isBambuSpool + ? (tray.tray_sub_brands || tray.tray_type || 'Unknown') + : (selectedFilamentType || 'Select filament'); + + const serialNumber = isBambuSpool && tray.tray_uuid + ? tray.tray_uuid + : 'N/A'; + + return ( +
+
+ {/* Header */} +
+

+ AMS Materials Setting - {slotLabel} +

+ +
+ + {/* Content */} +
+ {/* Status indicator */} + {isBambuSpool && ( +
+ + Bambu Lab RFID detected - fields auto-filled +
+ )} + {!isBambuSpool && !isEmpty && ( +
+ + Non-Bambu Lab filament - please verify settings +
+ )} + {isEmpty && ( +
+ + Empty slot - configure filament settings +
+ )} + + {/* Filament */} +
+ + {isEditable ? ( + + ) : ( +
+ {filamentDisplayName} +
+ )} +
+ + {/* Color */} +
+ +
+ {isEditable ? ( + <> + setSelectedColor(e.target.value)} + className="w-10 h-10 rounded cursor-pointer border-2 border-gray-300 dark:border-bambu-dark-tertiary" + /> + {selectedColor.toUpperCase()} + + ) : ( + <> +
+ + {bambuColorName || trayColorToHex(tray.tray_color).toUpperCase()} + + + )} +
+
+ + {/* Nozzle Temperature */} +
+ +
+
+
min
+
+ setNozzleTempMin(Number(e.target.value))} + disabled={!isEditable} + className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60" + /> + °C +
+
+
+
max
+
+ setNozzleTempMax(Number(e.target.value))} + disabled={!isEditable} + className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60" + /> + °C +
+
+
+
+ + {/* SN */} +
+ +
+ {serialNumber} +
+
+ + {/* Flow Dynamics Calibration */} +
+

+ Flow Dynamics Calibration{' '} + + Wiki + + +

+ + {/* K Profile */} +
+ + +
+ + {/* Factor K */} +
+ +
+ {kValue.toFixed(3)} +
+
+
+
+ + {/* Footer */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/control/AMSSectionDual.tsx b/frontend/src/components/control/AMSSectionDual.tsx index dc549fe2f..7ce97b79b 100644 --- a/frontend/src/components/control/AMSSectionDual.tsx +++ b/frontend/src/components/control/AMSSectionDual.tsx @@ -1,11 +1,20 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; import { api } from '../../api/client'; -import type { PrinterStatus, AMSUnit } from '../../api/client'; -import { Loader2 } from 'lucide-react'; +import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client'; +import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react'; +import { AMSHumidityModal } from './AMSHumidityModal'; +import { AMSMaterialsModal } from './AMSMaterialsModal'; + +// Filament change stages from MQTT stg_cur +const STAGE_HEATING_NOZZLE = 7; +const STAGE_FILAMENT_UNLOADING = 22; +const STAGE_FILAMENT_LOADING = 24; +const STAGE_CHANGING_FILAMENT = 4; interface AMSSectionDualProps { printerId: number; + printerModel: string; status: PrinterStatus | null | undefined; nozzleCount: number; } @@ -49,6 +58,144 @@ function HumidityIcon({ humidity }: { humidity: number }) { ); } +// Filament change progress card - appears during load/unload operations +interface FilamentChangeCardProps { + isLoading: boolean; // true = loading, false = unloading + currentStage: number; + onRetry?: () => void; +} + +interface StepInfo { + label: string; + status: 'completed' | 'in_progress' | 'pending'; + stepNumber: number; +} + +function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + // Determine step status based on current stage + // When stage is -1 (initial/waiting), show first step as in_progress + const getLoadingSteps = (): StepInfo[] => { + // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete) + let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + + if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { + // Initial state or heating - step 1 is active + step1Status = 'in_progress'; + } else if (currentStage === STAGE_FILAMENT_LOADING) { + // Loading filament - step 1 done, step 2 active + step1Status = 'completed'; + step2Status = 'in_progress'; + } + + return [ + { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, + { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status }, + { label: 'Purge old filament', stepNumber: 3, status: step3Status }, + ]; + }; + + const getUnloadingSteps = (): StepInfo[] => { + let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + + if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { + // Initial state or heating - step 1 is active + step1Status = 'in_progress'; + } else if (currentStage === STAGE_FILAMENT_UNLOADING) { + // Unloading filament - step 1 done, step 2 active + step1Status = 'completed'; + step2Status = 'in_progress'; + } + + return [ + { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, + { label: 'Retract filament from extruder', stepNumber: 2, status: step2Status }, + ]; + }; + + const steps = isLoading ? getLoadingSteps() : getUnloadingSteps(); + const title = isLoading ? 'Loading' : 'Unloading'; + const headerText = isLoading ? 'Filament loading...' : 'Filament unloading...'; + + return ( +
+ {/* Collapsible header */} + + + {/* Content */} + {!isCollapsed && ( +
+
+ {/* Steps list */} +
+

{title}

+
+ {steps.map((step) => ( +
+ {/* Step indicator */} + {step.status === 'completed' ? ( +
+ + + +
+ ) : step.status === 'in_progress' ? ( +
+ {step.stepNumber} +
+ ) : ( +
+ {step.stepNumber} +
+ )} + {/* Step label */} + + {step.label} + +
+ ))} +
+
+ + {/* Extruder image */} +
+ Extruder +
+
+ + {/* Retry button */} + {onRetry && ( + + )} +
+ )} +
+ ); +} + interface AMSPanelContentProps { units: AMSUnit[]; side: 'left' | 'right'; @@ -57,9 +204,35 @@ interface AMSPanelContentProps { onSelectAms: (index: number) => void; selectedTray: number | null; onSelectTray: (trayId: number | null) => void; + onHumidityClick: (humidity: number, temp: number) => void; + onSlotRefresh: (amsId: number, slotId: number) => void; + onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void; } // Panel content - NO wiring, just slots and info +// Get slot label based on AMS unit ID and tray index +// Regular AMS (ID 0-3): A1, A2, A3, A4 / B1, B2, B3, B4 / etc. +// AMS-HT (ID >= 128): HT-A, HT-B (for first HT unit), HT2-A, HT2-B (for second), etc. +function getSlotLabel(amsId: number, trayIndex: number): string { + if (amsId >= 128) { + // AMS-HT unit - uses HT-A, HT-B naming + const htUnitNumber = amsId - 128; // 0 for first HT, 1 for second, etc. + const slotLetter = String.fromCharCode(65 + trayIndex); // A, B + if (htUnitNumber === 0) { + return `HT-${slotLetter}`; + } + return `HT${htUnitNumber + 1}-${slotLetter}`; + } + // Regular AMS - uses A1, B2, etc. naming + const prefix = String.fromCharCode(65 + amsId); // 65 is ASCII for 'A' + return `${prefix}${trayIndex + 1}`; +} + +// Check if AMS unit is an AMS-HT (ID >= 128) +function isAmsHT(amsId: number): boolean { + return amsId >= 128; +} + function AMSPanelContent({ units, side, @@ -68,22 +241,25 @@ function AMSPanelContent({ onSelectAms, selectedTray, onSelectTray, + onHumidityClick, + onSlotRefresh, + onEyeClick, }: AMSPanelContentProps) { const selectedUnit = units[selectedAmsIndex]; - const slotPrefix = side === 'left' ? 'A' : 'B'; + const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false; return (
{/* AMS Tab Selectors */} -
+
{units.map((unit, index) => (
+ {/* Slot Labels */} -
- {selectedUnit.tray.map((tray, index) => ( -
- {slotPrefix}{index + 1} - -
- ))} +
+ {selectedUnit.tray.map((tray, index) => { + const slotLabel = getSlotLabel(selectedUnit.id, index); + return ( + + ); + })}
{/* AMS Slots - NO wiring here */} -
- {selectedUnit.tray.map((tray) => { +
+ {selectedUnit.tray.map((tray, index) => { const globalTrayId = selectedUnit.id * 4 + tray.id; const isSelected = selectedTray === globalTrayId; const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE'; const isLight = isLightColor(tray.tray_color); + const slotLabel = getSlotLabel(selectedUnit.id, index); return ( - )}
- +
); })}
@@ -194,117 +414,487 @@ function AMSPanelContent({ // Unified wiring layer - draws ALL wiring in one place interface WiringLayerProps { isDualNozzle: boolean; + leftSlotCount: number; // Number of slots on left panel (4 for regular AMS, 1-2 for AMS-HT) + rightSlotCount: number; // Number of slots on right panel + leftIsHT: boolean; // Is left panel an AMS-HT + rightIsHT: boolean; // Is right panel an AMS-HT + leftActiveSlot?: number | null; // Currently active slot index on left panel (0-3) + rightActiveSlot?: number | null; // Currently active slot index on right panel (0-3) + leftFilamentColor?: string | null; // Filament color for left active path + rightFilamentColor?: string | null; // Filament color for right active path } -function WiringLayer({ isDualNozzle }: WiringLayerProps) { +function WiringLayer({ + isDualNozzle, + leftSlotCount, + rightSlotCount, + leftIsHT, + rightIsHT, + leftActiveSlot, + rightActiveSlot, + leftFilamentColor, + rightFilamentColor, +}: WiringLayerProps) { if (!isDualNozzle) return null; // All measurements relative to this container // Container spans full width between panels - // Left panel wiring: slots → hub → down → right → down to extruder - // Right panel wiring: slots → hub → down → left → down to extruder + // Regular AMS: slots → hub → down → toward center → down to extruder + // AMS-HT: single slot on left → direct line down to extruder + + // Regular AMS: Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel + // Left panel center ~150, slots start at 150 - 124 = 26 + // Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 + + // AMS-HT: Left aligned with pl-2 (8px), slot starts at 8px + 28px = 36px center + // For 2 slots: 36, 100 (36 + 64) + + // Right panel calculations for regular AMS: + // Right panel center ~450, slots start at 450 - 124 = 326 + // Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 + + // Right panel AMS-HT: Left aligned, starts at ~308 (300 panel offset + 8px padding) + // Slot center: 308 + 28 = 336 + + // Determine colors for wiring paths + const defaultColor = '#909090'; + const leftActiveColor = leftFilamentColor ? hexToRgb(leftFilamentColor) : null; + const rightActiveColor = rightFilamentColor ? hexToRgb(rightFilamentColor) : null; + + // Slot X positions for regular AMS (4 slots) + const leftSlotX = [54, 118, 182, 246]; + // Right slot positions + const rightSlotX = [354, 418, 482, 546]; return ( -
- {/* SVG for all wiring - single coordinate system */} - {/* Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel */} - {/* Left panel center ~150, slots start at 150 - 124 = 26 */} - {/* Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 */} - {/* Right panel center ~450, slots start at 450 - 124 = 326 */} - {/* Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 */} +
{/* Left panel wiring */} - {/* Vertical lines from 4 slots */} - - - - + {leftIsHT ? ( + <> + {/* AMS-HT: Simple direct line from slot to extruder */} + {/* Slot vertical lines - highlight active slot */} + + {leftSlotCount > 1 && ( + + )} + {leftSlotCount > 1 && ( + + )} + {/* Path to extruder - always colored if filament loaded */} + 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} /> + + + ) : ( + <> + {/* Regular AMS: 4 slots with hub */} + {/* Vertical lines from 4 slots - highlight active slot */} + {leftSlotX.map((x, i) => ( + + ))} - {/* Horizontal bar connecting left slots */} - + {/* Horizontal bar connecting left slots - highlight from active slot to hub */} + {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? ( + <> + {/* Background bar */} + + {/* Highlight segment from active slot to hub (center at 150) */} + + + ) : ( + + )} - {/* Left hub */} - + {/* Left hub */} + - {/* Vertical from left hub down */} - + {/* Vertical from left hub down */} + - {/* Horizontal from left hub toward center */} - + {/* Horizontal from left hub toward center */} + - {/* Vertical down to left extruder inlet */} - + {/* Vertical down to left extruder inlet */} + + + )} {/* Right panel wiring */} - {/* Vertical lines from 4 slots */} - - - - + {rightIsHT ? ( + <> + {/* AMS-HT: Simple direct line from slot to extruder */} + + {rightSlotCount > 1 && ( + + )} + {rightSlotCount > 1 && ( + + )} + 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} /> + + + ) : ( + <> + {/* Regular AMS: 4 slots with hub */} + {/* Vertical lines from 4 slots - highlight active slot */} + {rightSlotX.map((x, i) => ( + + ))} - {/* Horizontal bar connecting right slots */} - + {/* Horizontal bar connecting right slots - highlight from active slot to hub */} + {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? ( + <> + {/* Background bar */} + + {/* Highlight segment from active slot to hub (center at 450) */} + + + ) : ( + + )} - {/* Right hub */} - + {/* Right hub */} + - {/* Vertical from right hub down */} - + {/* Vertical from right hub down */} + - {/* Horizontal from right hub toward center */} - + {/* Horizontal from right hub toward center */} + - {/* Vertical down to right extruder inlet */} - + {/* Vertical down to right extruder inlet */} + + + )} - {/* Extruder image - positioned at bottom center */} - Extruder + {/* Extruder image container - positioned at bottom center */} + {/* Image is 56x71 pixels, scaled to h=50px = width ~39px */} + {/* Scale factor: 50/71 = 0.704 */} + {/* Green circles in original image: left center ~(15.2,34.2), right center ~(41.0,33.9) */} + {/* Scaled positions: left x≈10.7, right x≈28.9, y≈24 from top */} +
+ Extruder + {/* Extruder inlet indicator circles - overlay on extruder image */} + {/* Left inlet (extruder 1) - left side of extruder */} +
+ {/* Right inlet (extruder 0) - right side of extruder */} +
+
); } -export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) { +export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) { const isConnected = status?.connected ?? false; const isPrinting = status?.state === 'RUNNING'; const isDualNozzle = nozzleCount > 1; const amsUnits: AMSUnit[] = status?.ams ?? []; + // Per-AMS extruder map: {ams_id: extruder_id} where extruder 0=right, 1=left + // This is extracted from each AMS unit's info field bit 8 in the backend + // Note: JSON keys are always strings, so we use Record + const amsExtruderMap: Record = status?.ams_extruder_map ?? {}; - const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits; - const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : []; + // Distribute AMS units based on ams_extruder_map + // Each AMS unit's info field tells us which extruder it's connected to: + // extruder 0 = right nozzle, extruder 1 = left nozzle + const leftUnits = (() => { + if (!isDualNozzle) return amsUnits; + if (Object.keys(amsExtruderMap).length > 0) { + // Filter AMS units assigned to extruder 1 (left nozzle) + // JSON keys are strings, so convert unit.id to string + return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1); + } + // Fallback: odd indices go to left (extruder 1) + return amsUnits.filter((_, i) => i % 2 === 1); + })(); + + const rightUnits = (() => { + if (!isDualNozzle) return []; + if (Object.keys(amsExtruderMap).length > 0) { + // Filter AMS units assigned to extruder 0 (right nozzle) + // JSON keys are strings, so convert unit.id to string + return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0); + } + // Fallback: even indices go to right (extruder 0) + return amsUnits.filter((_, i) => i % 2 === 0); + })(); const [leftAmsIndex, setLeftAmsIndex] = useState(0); const [rightAmsIndex, setRightAmsIndex] = useState(0); const [selectedTray, setSelectedTray] = useState(null); + // Track if load has been triggered (to disable Load button until unload or slot change) + const [loadTriggered, setLoadTriggered] = useState(false); + + // Modal states + const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null); + const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null); + + // Track user-initiated filament change operations (for showing progress card immediately) + const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null); + // Track the previous stage for detecting when operation completes + const prevStageRef = useRef(-1); + + // Track if we've done initial sync from tray_now + const initialSyncDone = useRef(false); + + // Sync selectedTray and loadTriggered from status.tray_now on initial load + // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool + useEffect(() => { + if (initialSyncDone.current) return; + + const trayNow = status?.tray_now; + if (trayNow !== undefined && trayNow !== null) { + initialSyncDone.current = true; + if (trayNow !== 255 && trayNow !== 254) { + // Valid AMS tray is loaded - select it and set loadTriggered + console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`); + setSelectedTray(trayNow); + setLoadTriggered(true); + } else { + // No filament loaded or external spool + console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`); + } + } + }, [status?.tray_now]); const loadMutation = useMutation({ - mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId), + mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) => + api.amsLoadFilament(printerId, trayId, extruderId), + onSuccess: (data, { trayId, extruderId }) => { + console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data); + // Disable Load button after successful load + setLoadTriggered(true); + }, + onError: (error, { trayId, extruderId }) => { + console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error); + }, }); const unloadMutation = useMutation({ mutationFn: () => api.amsUnloadFilament(printerId), + onSuccess: (data) => { + console.log(`[AMSSectionDual] Unload filament success:`, data); + // Re-enable Load button after unload + setLoadTriggered(false); + }, + onError: (error) => { + console.error(`[AMSSectionDual] Unload filament error:`, error); + }, }); + // Handle tray selection - also re-enables Load button when changing slot + const handleTraySelect = (trayId: number | null) => { + if (trayId !== selectedTray) { + // Slot changed - re-enable Load button + setLoadTriggered(false); + } + setSelectedTray(trayId); + }; + + // Helper to get extruder ID for a given tray + const getExtruderIdForTray = (trayId: number): number | undefined => { + // For dual-nozzle printers, calculate which AMS unit the tray belongs to + // and look up which extruder it's connected to + if (!isDualNozzle) return undefined; + + // Find which AMS unit contains this tray + // Global tray ID format: amsId * 4 + slotIndex (for regular AMS) + // For AMS-HT (id >= 128): amsId * 4 + slotIndex (but only 2 slots) + for (const unit of amsUnits) { + const slotsInUnit = unit.id >= 128 ? 2 : 4; // AMS-HT has 2 slots + const baseSlotId = unit.id * 4; + if (trayId >= baseSlotId && trayId < baseSlotId + slotsInUnit) { + // Found the AMS unit - look up its extruder + const extruderId = amsExtruderMap[String(unit.id)]; + console.log(`[AMSSectionDual] Tray ${trayId} belongs to AMS ${unit.id}, extruder: ${extruderId}`); + return extruderId; + } + } + return undefined; + }; + const handleLoad = () => { + console.log(`[AMSSectionDual] handleLoad called, selectedTray: ${selectedTray}`); if (selectedTray !== null) { - loadMutation.mutate(selectedTray); + const extruderId = getExtruderIdForTray(selectedTray); + console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`); + // Show filament change card immediately + setUserFilamentChange({ isLoading: true }); + loadMutation.mutate({ trayId: selectedTray, extruderId }); } }; const handleUnload = () => { + // Show filament change card immediately + setUserFilamentChange({ isLoading: false }); unloadMutation.mutate(); }; const isLoading = loadMutation.isPending || unloadMutation.isPending; + // Handlers for modals and actions + const handleHumidityClick = (humidity: number, temp: number) => { + setHumidityModal({ humidity, temp }); + }; + + const refreshMutation = useMutation({ + mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) => + api.refreshAmsTray(printerId, amsId, trayId), + onSuccess: (data, variables) => { + console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data); + }, + onError: (error, variables) => { + console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error); + }, + }); + + const handleSlotRefresh = (amsId: number, slotId: number) => { + // Trigger RFID re-read for the specific tray + console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`); + refreshMutation.mutate({ amsId, trayId: slotId }); + }; + + const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => { + setMaterialsModal({ tray, slotLabel, amsId }); + }; + + // Determine if we're in a filament change stage (from MQTT) + const currentStage = status?.stg_cur ?? -1; + const isMqttFilamentChangeActive = [ + STAGE_HEATING_NOZZLE, + STAGE_FILAMENT_UNLOADING, + STAGE_FILAMENT_LOADING, + STAGE_CHANGING_FILAMENT, + ].includes(currentStage); + + // Auto-close card when operation completes + // Track when we transition from an active filament change stage back to -1 + useEffect(() => { + const wasInFilamentChange = [ + STAGE_HEATING_NOZZLE, + STAGE_FILAMENT_UNLOADING, + STAGE_FILAMENT_LOADING, + STAGE_CHANGING_FILAMENT, + ].includes(prevStageRef.current); + + if (isMqttFilamentChangeActive) { + // MQTT is now reporting a stage, clear user-triggered state + // Card will continue showing because isMqttFilamentChangeActive is true + setUserFilamentChange(null); + } else if (wasInFilamentChange && currentStage === -1) { + // Transition from active stage to idle - operation completed + // Close the card by clearing user state + setUserFilamentChange(null); + } + + // Update previous stage for next comparison + prevStageRef.current = currentStage; + }, [isMqttFilamentChangeActive, currentStage]); + + // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload + const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null; + + // Determine if loading or unloading for the card display + const isFilamentLoading = userFilamentChange !== null + ? userFilamentChange.isLoading + : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE); + + // Get the loaded tray info for wire coloring + // Wire coloring should show the path from the currently loaded filament to the extruder + // But ONLY if the currently displayed AMS panel is the one with the loaded filament + const trayNow = status?.tray_now ?? 255; + const getLoadedTrayInfo = (): { + leftActiveSlot: number | null; + rightActiveSlot: number | null; + leftFilamentColor: string | null; + rightFilamentColor: string | null; + } => { + // tray_now: 255 = no filament, 254 = external spool, 0-253 = valid tray ID + if (trayNow === 255 || trayNow === 254) { + return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; + } + + // Find which AMS and slot contains the loaded tray + for (const unit of amsUnits) { + const slotsInUnit = unit.id >= 128 ? 2 : 4; + const baseSlotId = unit.id * 4; + if (trayNow >= baseSlotId && trayNow < baseSlotId + slotsInUnit) { + const slotIndex = trayNow - baseSlotId; + const tray = unit.tray[slotIndex]; + const color = tray?.tray_color ?? null; + + // Determine if this AMS is on left or right side + const extruderId = amsExtruderMap[String(unit.id)]; + + // Check if this AMS unit is the one currently displayed in the panel + const currentLeftUnit = leftUnits[leftAmsIndex]; + const currentRightUnit = rightUnits[rightAmsIndex]; + + if (extruderId === 1) { + // Left side (extruder 1) + // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament + const isDisplayed = currentLeftUnit?.id === unit.id; + return { + leftActiveSlot: isDisplayed ? slotIndex : null, + rightActiveSlot: null, + leftFilamentColor: isDisplayed ? color : null, // Hide color if different AMS is selected + rightFilamentColor: null + }; + } else { + // Right side (extruder 0) + const isDisplayed = currentRightUnit?.id === unit.id; + return { + leftActiveSlot: null, + rightActiveSlot: isDisplayed ? slotIndex : null, + leftFilamentColor: null, + rightFilamentColor: isDisplayed ? color : null // Hide color if different AMS is selected + }; + } + } + } + + return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; + }; + + const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo(); + return (
{/* Dual Panel Layout - just the panels, no wiring */} @@ -316,7 +906,10 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua selectedAmsIndex={leftAmsIndex} onSelectAms={setLeftAmsIndex} selectedTray={selectedTray} - onSelectTray={setSelectedTray} + onSelectTray={handleTraySelect} + onHumidityClick={handleHumidityClick} + onSlotRefresh={handleSlotRefresh} + onEyeClick={handleEyeClick} /> {isDualNozzle && ( @@ -327,13 +920,26 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua selectedAmsIndex={rightAmsIndex} onSelectAms={setRightAmsIndex} selectedTray={selectedTray} - onSelectTray={setSelectedTray} + onSelectTray={handleTraySelect} + onHumidityClick={handleHumidityClick} + onSlotRefresh={handleSlotRefresh} + onEyeClick={handleEyeClick} /> )}
{/* Unified Wiring Layer - ALL wiring drawn here */} - + {/* Action Buttons Row - aligned with extruder */}
@@ -351,8 +957,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
); } diff --git a/frontend/src/pages/ControlPage.tsx b/frontend/src/pages/ControlPage.tsx index 3f8ee3b83..ac701c6df 100644 --- a/frontend/src/pages/ControlPage.tsx +++ b/frontend/src/pages/ControlPage.tsx @@ -255,6 +255,7 @@ export function ControlPage() { {/* AMS Section */}