{tM)s2%SI<^=O% zOFqY3gd+ebqjEsm00+ambPsf~M8n>LwuVL1-hzHk$5HhnY{JD7CM>}Yc4D?j0Ac0WoB?PWj< zb`nYhe`)h;z|4{J#R;c|Okf>NA=a_Z5J}{0`~2&PTnm-J%$X6eIw|Z-91X@2Z0mty zM*}X)WoYrs`Yx!Z%F!EuGf=2KkZjYF!r@pV2AZ%c3ny-u(@9rDpE m b@=a4NGqZ3LI1Xf zMIsZ&@~?me)sYzVt%n_hKh@Vvy1050V-zw0I NZV$?5b1^2CmGm$dWBiq~GT@|CIHGHj z&e{6Fy9WV4y{qYwD;fn%fn2Q-nDqoEwNyk0{G}PQ9$J%1g2(Ed-Sv`=v>JLIrj28h z$~3?-#V}zI<$BDVQugM?@7${ZWU-&^QTJL!lMyo#z6`IMN`UXQB#<(WUc=&}$#lZB zd ps2RTA3?j>CInSFfJ7x8n~s^vzK%o3{Yo;Sv>5g*T8!D+b0a !G+KpSe4c2i5v(_8z9W?oO~*Axvgw{lrCqf>yem8~YqWj#5qIOBD~TV~6~)icQK zSAujF$onfTSUb?9fts`=5Hft~ }I*H8p(u? zr!>HHV VH4iyUPaVHu74om!)%#jU& zc+1v2^(&tG+wnC|>!+U9M-}z+Z)SFNeLV2-@n4+Ilm 3GIF4x{?&Z(m*amd#(>;5om1Q{IrsiGF6S z>-0+3>4#mz4|k8ObPfNw<3|VYoc#fJ_vF6{|9k-oZVD^AM?NcSx^wyN*bgt>yLfjv zv%51>+I3Uha8 }TwPq|+KDMpAy!OM ^~Ve#Je`o@2}@SO|)a1o@LccElAZr!-! zy?bf3>fp0xM}Vg&!2fM}NXsbmL JJ+twELs+5iIL_T?0p%pC1Hr zKTy?oTzDXq1OK45yT6Gn)G&SB(n2kV{H~LS`gwk-nCWMvrIHHd_xPY QPHwjM!t$gx)XaX*R#-TxdC6Z+*CWz!7abB ze;~kp;uoO&lK>ATpLB4*Z@XupZKFHg{05L$f>H=B02n2z3n-)VK=JnR#Q>EDWG-ay zBzV>!K!pPnGcW8h4L3Vp{q#d10Y$6&0pMN@OHV;71FsDfmNp jFtrjiW5d*~H~~OT0And0;it-mSd|NLDu6 YazNMYZ%|01w2cO9Il zb7m668*M?Nu#dv(ly5?DDuc}qx0S19W4 dr8vreZa=;Zpx$@bX=TX>IKr;z# z2Ani*bc;2UmgH=4dwXN-Mn4F|Dsw8}qM8Dai%~Pno%5!8JGR8d(GMdz3S>m@M79gb z2_Un3&ZYr)DcX&wrcENK0;r^nC6mf
`GIwB@qEj=xAL|Z0mk^cw+^93o;t-@lR3wcF^&Wn2) V0$mY0yG^wDR{{vefeiUE ztbE=M!`pCyhyAP1P)Gn#O}h6)cnOVN>21Kh8Y4uZm4UYqiV?~YBLKPAzJb|~kNu?g ze)-Sd{AK$9Ew+&W3$2}moB4f20)GdkFF^vVjRaH Ug7y&<9vbV?+dx|Jby0e zl;`<{lnd|%APn%&FZcZ7SMvO9NDK+D^!!3DI3#{ya7fAzb)=+_6wHO4993@f#8NQl z`Jb-`PrQ9R3^u(0y{PU2PYhvpRmuy%Y#Svn0K=zT)DwpaJh5~YZmC}?03+oEV9K2t zOjJ5t5U+X#DbEwj6FKi4%muKvnyd1dyv-9!DY;u#;hiew3OuoAw)%NboQg-Hnqe{T z%nIgAO1t )QZsz+{J&nRo%YnOJ(%lOltC;Bl?^VJfU%tEN0RP|^)8m&O z9IpU=p~wg23$;v7tFTb#LS7M}@4{XNc|U_~S}A`(=oQ@y?M%;+qJ<6_`EC~IB7?ma zc@}ytiujgAxwcQ>7F#O&7;dpkfbu1Vhms|M10KGBp+BIpC>o?1@iUJx C)07{;q&b~=}0_Vvm+e79McUm9)s__N%)?-4+kAX5 7LSL+e#ZX%0(M8fZhv({Sg#|&puG_Q^1Ht#@PUnm zk7`fx%i1#;zOg*TFEP)?7`Vqi#oO<*Qw%t^|3&Xcy%` v6;N+GSn6u=B574mC8ss`HV9wqBMGw&;WqtbZ^!U#V2c>pazmu= z47>w^X45!mFA_ZKs2KN%EfQV|E$M`<*stj`@QCv^dVp+j48#12_ %vYow~h8_MAxrE{7 z;ZlE@T*h$Au+v{Imjll6*5L|&rCiBy+px=DC0F^YfRs!yl(?^7Q{_GDYDp|xhhGU4en?4ammy=$SpZlZjmeouY2D?BF5 z8nxWTbM3@iKVN@Bh>EeWK;2Vu>XxYxiAnC6c#=xKQ7@-h!-6b?qJkuOb&3UEL^(1m zDprJHF)9mCU8)T+6q6(JoKli|e?yGPKv3e3&x!+*6p>^hCM%YZ5RFa>p(`+c*`WAF zBqTylI(L39AwpI2KsbCN9-E3xvsyN2M3i7qtyB4lc$|hKF+q+~);tH@D$m72Q-d(u zZGF84rToO%(UZXeo*xbjoC%H&j0`E2tZHy*Y=A$1fge&UoviXaKQKHRJaP8I=y}C7 zcy ZY1~o2)vU|_?kNfy{@V`|JppCSE zgpacZ1T)-0eT2Km;m0aR `F`-FjD%sM^pZ^BVFG!Z6=VsAZKDlH;0OkVSS&8%%z_k$uY-T762Jm^MmVl{ z6Tv5X_Sk}I62g(Sg7~}e{{uXx$bJf@2WCZablM6)X_aY30x3l0MHR>V+XB9ICUkkXK2j8(C@E4Frr$#d&3cX2z@I zfc!bU;`QUmXI6LvZ|pGu?@Ww$ieUyU6fh6Xh$iAfI2?qEhz!O 059nIScy8MQdd-peK)ra~3t0(j0cY4w(0F(a^PT5J*_}+d z IBL<2w(^SDxLmNfESA3<9f0!Nxdf68JUCESR7;&W)ZpsSV*Z z(7iB8>O;^2K(UANMp(j)0ikfK8dR>@7ztROz%8J26uHRQ*#QWem< F`I9sS_0t_=jWDX()-KE{KzM8}# zUXTp|asXHazSJTcdA)235ZTP5uo#t6T+ xQpb7j^{do25=5=T^82?uHS|mw$T_7&r7luBsmDSN0?KL>Z39&f?omY zRXt0PIpA`E(#RV^lsOx<%9?XTUcF+NjK`yjJ_5`~Ap(Qa4iBXy7-Y^o90|$6ATxVy z3vAPvJy9A8`QzJekWsIt69E47OY#ocv=FE3?X#O!ur-@D;wXFjl}$V14pQHiGPG`% z;7ci~b*Bt1n`QXoB+kmWN53rxoT r9$5kdA0+b?Pi!B2L7P~;cG_*f6!nEHdY~qZfKrV3zEL*BE&PipztVfCD zbts9j$p)otWKPp5CWrH$0XLhMTaLTKq7;G|i*CWaLmVkGeiY2zAUx?1f|CeNA@C!3 z1p!j2Q$&YKM>K%yC!zIuk9=e3Q7N#oU=ez#z_N80fItM0^V~v?F!ci4qb?@Efr{x- z#h9`g5QC7`G+|~fMttW@0G+wO)iPmaZD!VHDQL5?7AtG9VT){MHFj3xU^NaFK$q~P za%q5*%L3#GCp&?qWfVU#@8QJ+6(!JY*!p @T(9K$J}Vmi+v+~V5Z0Rt7+ zZk|K!=0S^@$2O=Q`ykL$v_zw{9AH+-RG~skRiIjR+s$c#VjTxk31hH7p`WoEZJP*E z9WQR+^Ff%svOOz^cF=plmN~Y*1_Y;_kGjt30_10~-2qN>8{jCo0Eub#6P5yw#{?aB zlrePAJBow!!gw(hA6CE-qx)p#r;l<#w~s@&&6{{LZ{e-HZM;+)Wz4Bz`Dc$2zG>Co z*=fZ-UpE{Be=bBL^CBn !-M(QH>lVnVc7V{R79kbVnBPM7;q;P$E*-b zP6;72tW>d&2$C$){Rj5#->(?K42fVp2gZgK8}z1gK^e~`HpM6<6F?0%D*_V%t76Iq zJ?Iw|i%J^_D`m{~1e3AI>q#*f2~%7k#nmY0b3=S!_{0#HF0@Zz>+t{t<4wgOIAK91 z%9Ucxnrg6SsF ^m*UQT&lUTr+S+AxrD zR4kp$IO>;2GLFin3mHf4hdt@~{i*u>8+KEhZPD<1OS3ARXaqGwh*QS@Eo96O>(m|$ z$g%~G3-_||FjFkB(P*cBP_^i*Krssc5^|3zk DVQSN{4Sn_nJfegB`F1gaKo`WCccT0863|d zvFU7pfdQ9QcteOpg~_N$FTqInIJW=HA*lg23%Qkwg575E2{I)c`EQuO6W@62##@VT zJ*upEQdW86+VyLzmiqj9wN1fUvrWMR%=0#d*Wo76DIj6) zSv2>RH`pHQi&GV|mpnw9#YQY EI|>g`Yf;*q<;{2C&Cj9D+1ETRv#L28 z5B)0hZ5pQW9(;xcq}n>~^$W3ZR0Ije)JE71P;gJt_^c}D`E!68HEZLbd%@wi765>I z%54~Ywul0a Scc+OoFV{U6sWcF-hr{?}pIWe7OlINMBH7a^x5)O08zVMghfg102auHp_SH;yY6&-+;gYrfor8_t?k%<*8W%He>DEKdA;rI!{#xj zHZTkQ6>*LphCcKN07%DaX92~WJpm{tNIgx8bQIiES#U~}Lo>n4kiUzGbCRM5gTqWq zehhjPM|%muF&wf{bwgmGsK5eeI1c9GX2qo*<*w2Qjv z;Fuo*fU}{AIGkyFOUmAowzvMy-uk${DdTjdogFD>$C|VAk!$R!QQuIyIQahQ4Lpb3 zxOU^(; 4R;#St^KLi{*0?(`TXsRw=Slee5od1Cf|BI)p|VRYFd_WU%PcJ z-EuV5ax~*=Tn^v9a_dUE`9P}q0Jye;6-$PP&ixyu#9ETJw5BYrzqhpKt(hjw=+l`^ zMIZ7!o2mwncqruc3>pCH;9tKzJIfLpMm3>f1dc*R!^Q-Z9r7@`2s4a@oNypVzp09> zAquHgHCv<}^oqxz%u!B5FR2#*v(`3a=OzL0sH$y4kI#LF)aT$C_WaUXa(#B)+PG58+f`I zm=2u>0PJO<*xOj~4gy#v(efPG?3Ytj4om_wrz|N=1;W0wnX$eD#$gGN(wy7QR8}nx zgX2z@wWrG3*UCJr7Ed+-;^`_(zUIX3Bn 6r+rp0G ziUmvy!!${2gO4dj$mT$x@-pD`IDj7y3?!hEFpOiOR$=sR5&(}{J2v$A+;@OIzqFQK zmv5fC)xP-Fy0v-J2u {h@8y}2ICb~`y!R{jyxqOm%@3U);0N8^^9FD=UQins zG%I@02__^;i46JtXb?t?$HJ1=%G63RXFU|X4%GB-v5N_XE)32%I|0S6eIII7?`M{h zv$F!7lTZsF-Pq~IqUIc{gQ~WHVuzAZyg^X2XYB A%643j{K`748-D$M!q+ z)jcnNTCwIHTq_@1G-WF4KG=P8_wwbty>~m-Dthj7>lM9==Ev0?E3bWg@y^B7{llO2 zu6ah+s?RRkpMg)RzP N!X@-EI*lfmg(Odzw@j2AF@VJ25G>)k}R>m!%9A<{+zKYG|KH` zX}X>;kE5isGKTj_I||Re7U;`bn-&t#L&C+(5hpqM6(OK~mN9fMI|^i%o^Oe) 1BtV#oL=5<+Wb~Oxl{R!rJJ6CrSf0&9bQn @Aru zJBb0fTMNK_^A?Zf-h0gLkw)o1z&F(B`|lCM5F8?WGuTZ`Q72K*2QaJu+k4_+_q=V7 z8}6(G8V20r&Jrm)xI(qlf5QF=0E!-hLdCA;jT30M6$>6a5TnT3=f?fsD$3GK20Y9u zMhNd`C3d?5F=uiT6IpO=M)3qmOAuURUxRyOimC(&_%mluof_itl43GBO_z`c6@q?% z%_boM!B$vJKpU_cLkQ*(XeD5fwMF??%CtC43!wBeWX~)b`_${EypJdeM BegaEJB+4R=C-ses zWtpbdbkp8c)82GbZ>p&`-E=6`bST|)`1;7=iHxf@?dnLmI?}FPDc7#F>tM=t@P26B zb$D?oQ`7jt{LT4?Z3owD`WOAV*8}S{eT)9bjV=G8U-5rbayR*#`FryZ55KbBb9$}u z%;LynN9B!?>m$p%R%RYLUd%MKuZ*rttTh~1GH2>rZ|}afd*$-|-uoSE^)Ec&*6R;1 znIG46-8KKle$T$zhyJL0bgk~}l4)D*sn06by3efDjVzfmd-kRGoJ#FE1qY>-H@Eb< zy%i~^cgeca1^&xbf3yGg)UBzEt6g2s
s9cgEfMN!^3}54|s~yI;!e>boDf z?|<;hr^!#JKl{to?!aSD_ubl$CNl0W^r3eL*WCLvojsYhuFSsvjIZx;r}vY+56o-3 zj<0nNJgsrm)&EgnT2=kDk~H?gl~MDNjbp^wvYJgPLO3yrI|}xL(4Qf|G?}-Y4kA8; z0OR2NEePBpv7{>Ic`3`3S`rlEek#wgR2s$92Ym~{dk9cinH;FsC#T^xcSn}JB+}6k z5)A-g_hrYkS4HR&+0_Fc%q9HRcX(Cw$k6koyl&x@X9mdKZ(#y|qn?;bpXneQxupke z12ORXSO;mcY#m_F+Te(_frqhYwGabuV66PCyUp10EdjusJo}!voBH8via8peo`&-b zsK53b9ne^C%<7?#qS`5~Gw{ &W=jOPGZQ6o3RzSrg#cl zXT&J*Rq|!?*HoOwGw7U} TQV?ie{KH+lg*o zF+xZQpJe8V87kCtHPeo!jiUcn%-MM|v!e#5RtQed0PjSq%F#zi+=u}E0o{$D9|5}! z96^k2_6fw$-KY_#<*0gFd5r!$)Pa#8(j5RBdXD2B5%*W5^mEes1?m1@a{3F>|2Y}> zoQ!==PJV-x=hn!%&x!8~()+|#p0+jo&epK(U$c1@%-@>ew;TUw$piMI0vFNQpXtn8 z 5LS1xVA73IEcBVbQ0F^+3lt?XF!9A2+H^5D`R30ANr F{6DK7We)%V literal 0 HcmV?d00001 diff --git a/__pycache__/config_flow.cpython-314.pyc b/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc9d6fb6e7f62e1f719c29539ce0dba89e399d70 GIT binary patch literal 17179 zcmeHOdu&_Rc|VuDd{ZJRQX(Zv7WJ@X>P1_YY{#!gmSj`5UZ!N)ZRD`DMB7YcQMvcZ ziG>0c(zI2vc1_Y|an^Qm+ibD3KHL}T4DA*-8IS?RN|sc~+`6l?r33Px9657S4A{Q! z+
7>Z#B;CBzd^FH^S@B4k s^G=c`6+97R_6 )&?eYu+%RMhIs^yAnUHa)BIp#HG;SJl1uKQhV3kl6bPMjFNALuzh3a69P!seD z-e9dz8>|!Rf}4a*!Fr)S=o5Uw2B9I?C^QC}gr;D#&>Y+>Y!0>vEx}fyHMm9C67&oH zV4KiZPbx_R37Op_WI1UZ4=aOKpC{Ww)}5^MU+AE@%OH38No$W*=5|u94Y>A`mOQsB zq-i34$J?06&xqQ-xeKH4wDix$V{^hF$0a!UTRxqbI~_k00TvgJ@qQLEP|hGQE{GM< zpNWN2nBl^~*~FuwWo%xEC+2wir=l<^6yq0W1*p|H9i5#$6`g(<;_fJaVQxBdCMHCt zY14d+=b`)+Ml73{n?Bt)AD6o1uM(~OW1|Nned9xsBZD6m9n!y0Xeit_9O)mLEM|@N zjSPyeb-5-c`zD9RMnxy(4h)X>g(i=M2Bp%)Oj^FGD5p|`iSiGI#zw?48l4;(8H@~$ z4v04OFJul)iUx@>CfcP)sBdUA(m!@=bW-l!vGIYv$-&6b=;UAs#v)eId}GHZCx!+J z*lx<62#y_(gdlM+GB7yVH#9sUdIkm$_8l9ZjBF@kS5or5t60a8I_S*v3cBW2n8m%T zQ4&h@L_aV#0;8LjmYziA)k)S~fd5h0qbG^Lgb3`*c0pT360l AJ|FSA6#=oMl4H1oHraA3inPH61mORSqFdha%B{6JVOi746#^ zh>GR~KE_4jbMp(r=oPKV&cvq$QOgS)ht2slqW*M(I~NtivIy*?D9>YtNQB42jZ0!r zK S3h>$h;=Z#J-C&H>J!l%; FTIEb<;4RgHehF(r+7{rAJP0*WEv43iu@JlK1ibrB%ndop z)~C`Oy1TVik0op+O6bE($goo$Llfq5Y|I5*=SHrwH=qWiTEmn}$vIx7#MEa4DM J`0Y>4?>!44i=Ey|ns6(Ekd-Qr<4>i;&?W)h?;qguCKP8rb%h)b1 z%AycrkB36$ow$7 mjh%~%`WR|t-tXWnScW|d8=S+{#FgWJM}+nVx2e)4 zJ)c Yfz5a(hu9K1~;Zi>##MEHd>XJRm@8SLXijE@Kjc~sK) zZE^?CCLWET2=g06mXFPz=KMIkk6=9p85D?G7@cSVZP^!@=noE#^ocs!c^-vkON$Q! z7r-J)tvH$VwKSI$UUJ(?E-JrjK$5%-(&whbxvbAwDt~EdxKY)4dFH$EEAe#IuH{2F zD(Wv8emK11?@L!4SnkhN*JY~%sp`O0ZMwQA>+JcJvnSWk{iOcM^G}!Gbh@A2^VFV8 zEg9#QT+OCzO?RrM`|7@QO<&g4mvr^zy!Baccgov+t?u=YCB5Bg?}?=A#7*SxOL_O@ zJhfTR_LOIP+S7aO@T!*7?q4IC+NxEesjRwfB<`ALAA0(s7n)w|_(sRKK9O$R`;+Np z!+~^F|FR`#v3+sR=k{Fe%vf4;E>G4KNVx)O*S6(B+K+vyntj(E{>AQp+MTWmFCV#S zb7pN#DO*#{Rdd+_4cVKaA-fqGGMR5R5LfRlBdI*VyvG9qCy)2(2lg}9jm*Gq_PWW0 zac4Wy{mj5#_WDN-?1ewC?PY+j(~c;!aKEkjfZR(vqUQ5!xGj*48%1IT0t^tOgzEw# z>oxkaxvj{>cXtF+P?%Buqt{j6B0#R}qYthd64t3478q3Jd=(;+%JGsgGoDv9z?MTO zFUCM6XbM%ryk<%kA%uiAim;>N#O5PY*ai)%kQ_HEJoUL 4$xQetkz`7rI2 Q-?aA`?6;sD89q>1< zYk8Yl*D9c{C4ewf@RWkIu@<+6Xm@?QWV(TE2hsU?5cIJb->C~e;cT1-ttZ6JaCA+< zdZP#tEH)2SX@!QJq$oMSpxWtKo;wLG(e2sNBFW-LNy?&)mv01OlF=B!`QgQ#C0E+} z7o5PxaL2>KD<@^HOl9Y)R*_rM%;0WTcJv=5fN(&dt?|Xa|2vF8E>#3+bX~UykSh<+ zhuR^?MMM5oA@Eu%K)DK1g9XPd+T_=jkZkKNcn!7cgcVra%Bx_cw}8WzUqY2SmvSu> z<%{iVJ^j?QM^mS(bY}Wy5p^sP&^`8!w5>B~?4*ObLas2Qeurp`MCPLB&{{A@BIgn_ z3$qvpJK~Xr=&YP#i$qSxIbN8Jg8`C&WJ4q}lbFUxcrY}9271}Z(CAnwBLC%%LYo|w zNET|_oQnx(6EhrwNp1#+Xr;1mTIR=*B@dXwsRGy{R)SH4f^s?%6~L=;YC!-@5&;OM znoZNO+1W_s3L|M6oW%Pjp|}|vM1uR9KMmyfpM}YIgRQ?=Zd*E(b5$;l++o4wc?a#D zJ3XL$R#5}3+KJwg)0N%Pz @z>=3}`4aT)D1nw{}eGSy~HE?I4g2gg Pp?X;@6p>`2P83AXJWebR!JfoFhyLLv)0Z_9X5@C0HQxDYQgi zf>$rW0 B8EB *Obs6&E)$j?d`i<&Xx$Q=X1;}Sqi zMN*B0W(xL0?6~J5m&CGUA)-l&6@qs}GFPajl5@JV&bE}Z?P|rfwxqKy?Ho*62XES4 zS$jju-mt8DtEw7JhPUj_XRS|LZ@9MPY8##(d2S?I+nK8Eyy5A{bPcX)HFZ_Dbvj4I zZ8LGXZzy^FMR_|jfkQ=kSF6j)K)Bi5=4H!jBY}3QRdM;?E0(0KDQz208i(KGarHmm z@91|j+`|y%W`X#P+&PTTAxR*aNAd`g&mwsg$zw<^0O1S7GRiC}xKJYV_>N?;>_%Zc z!k{Z^gjuCj5~Wz8v;fUP$~po1M1cU+2)Yn@DK#iA1ta`S0V0^;XMwmi BL29RKzHi;sP+>vGRZO<+|En)^+Y_m&Qx8+8N9WvUx8AmIzWl!HtR rrLs1CjW#k?P6)1)?Es_CwmzuHfMd98xgD(|zAq`Mx35}ze zyYuXnP04w@Uy{A2}V;t?Qxwl;%h|Attg3a-a`#E DR1ifWq@T+BFUNgFiSQRRznb1yGCi-ql{uJdR`RJHSQxWmoxk(X z7c}*BOmXyn9;I5;xL9AHMQ38*X8_AK=9`WS7ZeAmLGdzL0KY442d)=d6gA*pEYFvU zU@2}dB>0_R5ugc39d?p(eFFI0r;#9RrLIlxevF}wA^Cl9T-?)0@bpu3s5L7}xVAtk zs;P3Ps3tB};vG ~5Pt!hnGwPvdVsj5J>YI~|`d(K`B`qy24 zhiKe8!BywpdDBsubu^|Njaf%)%F&v2w5J^Hmxt1h9jiJpk8kUVyQ|QZ>2AJsJni1G zJn;G8&Bl&Q=lv^ t($ 2KLqaybyoQ#SFHvuT}P8 z@;^4{fv$5Hq1;6?YVChk!*PEGb#k8va!(z`eF1X3gw5wQlpT-HfNa!Iz@!=qo*Ok3 zN>i$#UV&AWw_ZS}f^I5s1g(>B>$MWNA&bL3B^si+qEPBoO{oK&0^K}+Q=jP_B` zq+HYSCcp>?-Q-}UGI~6(e-u8RsVOopWGpJ7>t;b#!~RBDJ |rda9^O {SFXnb2lX1B=r3+1FD31Ll;xBm-*-@;-4xUU0I?q5)oBFb@bIHQqd_LnhL z7Z+mZcu~jC&&GvPvQ*Rz&0W!{qEuua7Mb%L3U(fs5j~TUd?(Lfg}on6Ldt!|eZl_% zh%6+nRd6Pg+Z@PjJ-D*@5Q?itdI-~-s_so!@60-PCapVH^#l%PJY6YISJnew)1HiH z$8DX~X@1kQ<0 gR#RC@~dH&Jo9=*hW tMz$^Vr zYxf%-l$<8}EdoWsAEQ__IajMJziv2O&(=O&o7vQpcJ%&`|MB_npTBnI2cJyZ(Kf0s zhthbKqC+{R136|Pu3hh~X`r_E)=-x}-qyc~`4-b(#eTa1V%N*J_Ivc#_xDx9pI1G4 zpnqy&>L>U*@?y$?VhYGcF$GL2rr^0zOyz;}-{r-WeB2L<=L3r>6xv0h6r?Q;h?R|7 z4AVepXp7*YdeFZktOWta7Qn@U(7F?YFp3}*F2WUqxqL67A0-5uA{dMNLE&O4;iW4O zH6;M^A0@o x-MC&>+L!qICL*s$_ z4w6wM6zkcPj0)1LDL_$5nj+nfV6j6;f=GstP@GpFoakXP_ZOJ-ERbRW^-bW3TR(_^ zQW{6lM>oSOP;@ dsB6L({=l^?)^#o{sKJJScInzq?`L+n@Bbdr9FpB KYq${p3d!?GE&+l2Au`xe z1PH<-?i)Y=9yu`nL(EoLAaVpaK#y?Ixic~DyO`kxB!7*hl<@f*On4DUv6#6EJaPMn z5i@cZaZztXK~0^Rt^+Gg{U~I76a{ThRc}vM_hy~FNo%i+f;v*3j;yCU<>}6NdSn#T z^Px~s&s|W^=3*4oopx;h;lz)_-w$6q^n*y!j^5;wC}@X_g1Enhf&HmTkT-F%^BAWM zF%FQ87zdaX 3;oghR$5xj2k#i#gQUl}8Y2YJeziwo+x_ zNQ#tlB`Rg$VkIec2vw>s$eDNJeW> JhT~^Sxgqg7;~995wd6=8eS0Mw zm?3S5-3iDS0~&oz)MSNGlsfFvg*7k+eOL?B5XSKs!!VwZsoSJpv@AV0Q?*hXv>zpA zNOdfRHB-KPUTpy0LO&DM7haHr7MheC>N8|fU5(@DQ7sC9Mw=DZ_!cFmJ|R874yJI& z(Ey({fLj2=6v8+f-nX9os+sCi%KTCFY*k86A$*{rpdJOT7mjO(l23iYxbUcvhAH%K z(oj$jLSGsJszakp8MS<*S-$8kK-Z{&d{hHTs*P$kk%@@j=qz02oVftky77AraM!}M zO 3C?@Roy^FTF?_WRjw2$h=KWKfOKB^bta?^c~B;Cw3sTW4g z1 a9^d?Ap5fvwgOP;GjauSGWRu7g$y>t(h`v*+0ND7wTF_n&8 zVapKCg9JAqhcdLJg005{TpJwv#yQ+KoDa!ofQZI1dQ}GI1vdbPhBkK`2%ItwV;m2h zL?if%>2(#+2yj4(Nh1Z73wK_PbkwoA8L6*E<<1h18;QC$l>4UKkFdV352Dr8nWQ@A zUqA@t-5Tijb$he!&Xl_|>)w`fZ@b!@?GC28gReKH-6L7|(UkjW&R%tql^h$OT0P~< zK~VB=Pt@I>a<^yQfs{LNmCbG)NNpW> ;9Fqn^xJL^0p_pk0-tDY46crx{lt` zk-7s6I8@+jDm}2yd3={Vm&}(pUo~D{yjF4T@U^|K@UQQBy)8L;ELjzP%T;$#$kz9! z>U(dvycZ{>n}au8t(QGFT$?XFwrbK>RjitcyJ6LWv2x;VUUd>z`x-I3%2$cGtbDbm zz ME@wKk2s}%M=P}O5K+kM W+}D5xh79SX*shYm_#>9|oX(GX?}%I6`2s*dRwE8CRZ z>J#7>8Q-q{S7OIQrPi_XMna|{o7TlJV0Qe9<{y?6Ej6;I#~T zSlY$?1X|#JiiDc6e~+ 34QI0pED(r7u2| z`;zkaANdbIxG!Oisw!3$J5~Ftb}e$g-BUetuVJOAk0sT97-D{Xfo8VJm#f!zxMF&N z<6uf6Jbo;~!EN@Kbg4=Y +DK7yK;8dv$m&fX?sJ? zUZ1P1M&;(pwA{DSusiA7bFKFkSLWdHmA(_n`#+le=mW{Ak7uIuD^rgoAK;SQLh}6L zZ9+c53~Anl(=q0-W|ffh%n^JX(G26mJ)&7P5mx|?6;QY7K)2c9rl_P7r6b$Sj*-k* zJYDxt(pa RJ{_%|c zXlCL#Xga*&WuRBQDhgC;1N~ZMPs+0=XZK2dDP{Z1*S`*yAGF^^6aF*2zS4J$0{Gn| zTxc)+N^*}Tq(RYuH;*=?(694KrqJ6TB~x^FtxYedXrN6)LEI?EL3b4O%!Da$huci= zIgLv0?Wch!0p{;ui~{B_V~qNh5HQ2#K|~D_zHP^t9=Z4Y0Okvj;|cijp9KOJ;2tNp z^u+AG=vpz=rPyZpwvA!%BOcGi=4TG4*v+?0$S@NRps8mTQ|uNvngWKEI00Wi)0SeJ z;LIHu<)o_mV$U-lOR+7tY{;+!+Dx&&TMqnFL8@v}?50~z{Np0dO_$h}islsC{!S%u zC`
bi_DL>1qcZ``7XBED*o@?iT@RJ?}q#+(oSH86@9Cauvyskf3Cs z*U9n1cmCVf(+ltodgLBA3{PP(By@s52@&~*Vb@Jx^HP7VVe`^xu6aw6t<2RoEe+=y zTb9Ohn|%1cb`wNf+n0t5FNVX({X2NJf2SD^$KJu?vpX#+51dKwpu6eL2?n6nI|#;B zgA8$RT^hUdfDWehQ<7tw_Q)R)_3#Y^Xbb7O+zIF$eD)JhoE3z5=_4RBe51E8FDxV$ zczQ-6mYq$U1Cx*kEfa-{s|d8j+I76Hg0EZPHlXxXivjX+X#3DRd16Iz2`AkF1b(ghyK!aANoRZsf#*j5k6-w$LeiJ>`16976F8$f?OCwBqZ6ER2flQ zk(z%9M5J4|R4vuvUuqmuW#q%Z4M?ap*aR`r+@JsEfnKktw@B% VHi &MkEy%|I3r(Y~r b%xAB)<_UPU2@+zq9sb$_U*0_B!Z?jn)e|TqPPH0xOES(J^BOT_e z%1|d7Ei8LBsGyq$&5yOIYQfCXLi$=A2uiE|QnGbvv0aj&Pc)(#W10>!*0q2}!K9hM zAsLx718CX4GQSXuS<=W@169AarM(8-=H)&oN;G>cOFhD!isZd;Myo+JJmon>AfluQ z_yc!BYXK?`m4V2w!3|#Mal;dZZ1S7S#($E^vZG+OIXRYxl0wUWL9s88fx=;h7XQHn ziKne4GI%F{B$$mB2rnp{cru_FPEoM a-ZgcRPjyu1`4Y(^q2?%nHIxj?UghF S W=AF8IygCoj+$LA9hr?j=xBeEZ{9YWgE9M`J*f~w3~eNoV!>6 zqDecMe)bM=_ug~Qy?gK7^FHjb+pGl2?aTiaKj FLXwgF-6h(2JD4YbXS7-b{C^dqK#SvFIcVZ;)!%2wJoj#LCVnWJsfh%I23 z?X+zkaRi*QleR4*u0W++8K{!0Xx} a-WBl5-hfZ` z1$N831NCx!ph0d3@G>80lp6z0a#I6&o*6r#9jo+gB+s|>GHPFLts#vhXmgXG{rSpX zwg7DlGR?&AxaJv($Pxa0Iu?-yelV31WAS7}PKn*;78u1M$7h9@kdPyOMqvk%SK(1H zn1~BWc~}%vBJHM<7vs~zNm;~ETOc(n3`kO3k|RkO05yZ9@qvsyb0QK?2r(FNN2IIC zXn0zX!%@1TB*3&((yvo$a8K%%%66o1DyX=EE`~3#jVGfQ2hwqMTYihe4UV2W5gr&D z37;N*PO;G5iQwp2*a{Aeo}U;W85$0coSPU9h6c_kHVU2?IXfI4J~yN|O5O2^f#8H< zQURk%HEo<98yc7>o9-DJJ~43q%*0n0c55SBrbJ60ijczhAP>I}& *JCeefZ8b=Da1%2CJHf8h?znx6k?_j zD}_{0h$YC$)(|6Cgven==72J6N)2A@SH42#DD0Ddl~fW4MaYPJIt7(@Zy}mV-&+s` zkxvK`AA{k03)q04EJiK~vc!*{8$8h+MZzo)C(%P2ui#-DR7c?OAu^@w`x8Q90QHe) zm>D?!IP(X 9KnuJraqDNXi7Gm@N|4M$V6=~NQdk$NOJau{|SPfCDPVutipg$sux$z)1~ zGl#=U)3%q>U5?WPZ<9dukT0&2P2x0IA7J(R^Beg1$zj@i2*V_*(ExTV@Pj`0&<9t9 z7jl75BAk{W6Cz3N>PSd8hS#^HjNwIWDU)UmFiE8%_ImL)pumcFYqb0xE84X=lTNKw zwj!Q_mdR?ZHO_{}A9lg}m`RTY3+g8Ef@lemF-~ijo UO1kV z1@Waw!fzLS@KVJZNyiJ~NHL|-xT>VEAfrS)hggd^?k#h}g~dg@2(boHJgJN<#bZKo zeGNx3NSU-CD&{Z@#epUjQ~1p2$& EN1eOCX9BfqEf?eDzxo!p+jr9FLX9KX`k|B%(S zTeJEVbIXR2_`Bb+E!cAH{Y&lrYh2?>^I;hF)8W=fW@545FfExqdCs}URpni6YrfX` zSg!ljQunD9-{}ntvDE#V7%Wy1-~Sl9BhBGKAJ#*)6T1g>J@9=OSaBC1DMnF{;X|HV z0LB(~14P8R1zuLKf=6g0RJ}NYUy8CEXXtuBpqd+^kJ4@cfflp_zK6Uv1vJ(~8VHJ? zvBF^aW30d{<*>;j?{(rn3nk5X$z+iOVYMRXX&A7}0!62FlNB2DLSq^C6!|e|XeK #3oQIrr97f>C)y-u-c0K0GbB455(n8<1rhKPgZHOgT>hs?2N9&uVZcvqx1~w z;a!EhXdshy+DK^!xJJ;BW{ FPejDK6;WKNJO`IU#CE6@%kY(`KqY{}O0p>8hZK8o#2}#U zBjUI7>&1NtGNIhKEL>Gg1uI5j0ZHXpp64l )F DWQnZ--2I;SQ{QoqT4m zdG+%5FaKaR%dUCr-;m}` JrJ|3)BhbG|YK#)QY44XiudIS0Q4@~2_hv47FLzod){%8;4`$e$0< zu=zRx5!7`S6)d6RLPS9+qF(xDDRCaKBvf3~L})ko9uTO0diA5;)nlWy{WYS(4jcS` zFDmZz9Rg8NO+|&}cMuhpEdoWaS@fMnUjzh-^}?aPN>~@x{u&WfW|M%x+3D@&SeB4= zhdq{KS)coxjP9_mZ!ZD=cMnhpmKcb4A6SJU3&PLpGP(wmt_W2W$EhU+HeS&_Q|DoQ z7Ch;(qGwcsr@7K@LR`?G-9_34_;CKMC1mS+8Fn&)JNX9N%((pu7pZ^_n$GgukB=3t zxYE0kpRi0F=`2$-Ax?`P!JXKe>CbP@OaS7*C2*yw1$GgvvllZ$GAi)k$)u8Km4P$` zb8jXEZkZ@_@{yN>D4zzri$e0=-=gH-Kf?<$KP^b;aPcV9<8rrn0u~leVs!>82=y3H z=1XD=^fFCQslf@b4^<})tvmid{`80VDEMS(?483EP&|uh+KbO%6~t;BD-=0m2UeX} z;kPYVc`@OI%(P;Y!R1ecRh1%vpNRIJh*CrhVMQ0dfURe-dJd~^Lj}r&Y6up^za=ed z8iR``!xtlx9G*?Z1jVWCz33ovN#W(9+bAJrmDEE?MYzL}7+!gJi!Z8l?Vu#KN2kJY zl-fW4CpF=$x?ORrV$S`(YjNzEn-$B>VAhbgyI#F~ U`b8gdfo0^UDr}w zSFY~xQr+R@x})#wR_mT#ck+)MW}AJZl2r4HT*JD wVVsQwj`dKh)Aeu#OVi8yUW|jBHfF=2$qAIV?Q2T&Dwa~I4{bX?d%0G0| zZfVPaNDE5m@1cR50E74U^^P?eISsKMkX*P?TDFT$mCWP@(nFd_H%T)+B(0lb)9jQk ztp~ZKO)q tzcl%1Q5p%=fU zlrYm3K^ ELSrB`( zm+jf4jhCJfBePAg6ZYN$l{S#wK&NT@F+05bl-erA#^{dv(Xzxlj}sazGiUJXgK9=) zo_fEJB~*CTH>TN?LRgIkz@*Fzm<@?T#zoC=WWdORfEQR!(NrcWXHLVig`gRZ2$9V1 z7(8p2VPS}=y(I9hv7XkLpGRXWgXBGsiDr`EysP;hUKH-Voq;YGgVcei0ePAs0vBG* z1M%Qf3sD+dzt%Na%ChjPF;leXz<`u_IaOxP`T0VazI0O6STnsG5tH%cv|@ w7}n@qXliU z(Ho raO;6>TjxIGF%{3ic z;_9=f3s&8o);Bv|?^xm5@}AvqT3@%`?O*kDWk>S0-rJXMUApUDt@US5=IiR;-1Yjd zyQ8ahz1dTFx9|3~Ti5P(t-AL?2!r$GxaK9Ud2T4za(t=f`1`w-TQE?X_cr9b2ba7D z-@UZ#J)J$1cSFEq&CAcZ=1#xc_~X%k@%G*{ZRj{xZNAn2cKEGuu61y!bujN~oImrv z4h+?{A?6{`wbgDC9bbDhfW{|(-`(+vXEzLbIv-UMcm3_zAI{D}uy$Y0x$nMn--F%6 zcW{FzPS1Tu!`*A|w*PD>w`bt~o`E%|`?l?tZLxlQ#W|rkJrB%eSJOj-zS;iob&WR% z)~a^jiQKKuRkb}b5Le}A9o_RUzLQzVy!-9 5*(~$Qx=RMv{i=n3Wa{|?GHdsLUpU>^Ywg1b{IoRu$ zk_43c-J!jwdYOOhY8+|Pe`se$n#~_N%-DAO0RG_t=2WNo!`^Q}`=6fhoa! JaY#m|Xl^hZOGU$Bn(Grm zH>+Yw6<9xju2KVOL6pnUFfgqce*u%6Fws?(_l^pQ1c0 o4`P%$M^)6*!d&6)&KBdtdHY8t@Qs}ZkI%t$;nBg<)3`1)qx ze@h^nul{dIx2obGib`c6P5mpCTT$TO5l}}fp2r6SEw>}lypGcH`0$L7fGn-lU3h&V zBTUmoO8fSx1(8-vXhDJq9VnV5+PW=_J&PQUVr)WTA$cTXa2?e&hQ`tBS2dOsTQ01q z+M)^!Jpr0#R@2cI$WEVu3x#~spW_G`s`R==bT!r6$B(I)kEnF_E%C3Ri(yx35~>ZB zVVF-y{V$3AQ&Rsasr^@MG<-^WKP8=?l0%=8z`v1aq5Yqvep6> Hrm>)L@rt)^xUN$ua>v>fpN?s*u!sBr1n>R+YIY$_~nY*rxPG*&f z@lP}cE>B-Nz(R 0VZTv5(6&>$_ zYTMXLNuN&7fPIwauuhh+c{h?bH1e5E@Hq+2Kl=lMBXV%#MU~j}-+fLhsks+OYD+e; z78T7}ELtY0vXi!{%_#({hG{4B2bLQyE<1vvG|Zzb3T_8YVR-HO>0cv>5ns`%8jROi z`jv*<@2)TQ$_MK`03)pt29!p};Qa<6-`*y0`%o_!A&H6}nVDuLT|nz3duG5i^#KTa z|2M`AHK{vNjsM7O%O1WdKlmQ8#|zrsKt4cGO7;O?XywsW(l_mqxj~?&(NU+GZ2SM7 z8L`PrqUKqT!X15*R3n6n>@<6(p(y{5hK=l>i8VD(v$9|2Qw^KRffh;NkR;e52_BM! zS|p)E5@DvJ`JWbyaDZu^QVzEu!UqUB@)j*^2bg^=;!M2xN4S}%X|?_Pg9CcdG<%c> z_AwA~Z^Ov#=enBu5xT+1e3A#hMqR&p`q4$!U=p#rgR+}s(xKdNtBESrQ%Z6Za@g?^ zgA{bfORIt7w~DmF*beVX6(jHX@95YDo+H%vDXzo` Xj7~K#ig$w{9lokoECd z0J?>UM#@X5AvGTLoGhhl914i{I@Y>ZyQ_TUnRser;QO-=&OS6M;z*Tme-=Bv@$vVw z4`zSPR$^DHeDvP CE!tR~jZub$uJ%AQJe zXgiwRiY6cVeu?}PsYI`pCZ7AFyFz?DzR_L&{Bf#s;)ANt@!c2qzbt1TkxKk>Rp?wl zvoTcu>hV;i>%*#WeBHkhDu4R;Sf%r7Rd{FJ-h8io>FG%JNvhI6S?Rr16;9PTH=dA6 z|5Q~txyk+|#_xxo4qW^7SVg?P$9hgh;F964e_~ztFO|9NSn~H+^3lY0Dh?PR6{$J&z_Ixc+*lRKiZ+efDC!MFqeMM*=B?Kz5lJo`$+Po5 z=6UAL@6Al#p-?RW8v5{&-sdIcPaL?!mxaAq5DFwgEHX `+q7lvxsEVaodRhZn7&G$l;Z-ZifmQfJ0@H~Azr9gl1u{)$`JpcfQDLr= zT;LYC1UCaK4kh>**ENz5`u*-aAu%3I@%)XewokQW)1tPIYF3UKlM&t)U`%llEbxJu zX~i wy2^TW^BQ6F`CWWb+W7&M#ciyWSQ2% zDK+pj-vm}5Pe~2eyo+pgU u!At?Q9$y*4;=OE{R9F!e;j9l=9&8m zE=)Q|)%~f8^Uf~hM?h<^e3@BpnsZN3Tk!(->EeIAK%mRHx?#Xam`G&C=Wdz_xY7Xc zBPs-{lXR0VwofM`LKo4w2*21qrBG9o;DU8yUa~Th8}sO7#B2K@7CB2#*@v>YO?DUU zfMUw2Oj1eNwbM!}r=6uVL+zT6^I1pS;3 l)+94~K!>AslkPVXRfS)4~CN4!md_1Mewj#%sX~yBuP06)4`d z<0js9grn#aeDGyE_;h?mkTIkk#)UV4A<_iy^e(bhVFy3!0vpi~EI)@bw2+}}k}1x( zPw!>OX~alRb5p4I0wwn21)rR-`0hFOoFqk>R4(b3u2cZvVnQ(`RgI<7u}^`dlhXM3 z@cHx6#rj?~)|-y?;#BYWaPRp=VKO!r;aP<3003$ZQwMDZ8}=i>4muB5W~*(m%5EhL zLV^5M)4Jj-)pQrmJcyoJdV6`~>PWFQQVO0ZdPZ(*z^d2dUPC2_33kG(gILW!GpNUC zfbYkUlxcsC!Z2?*VYdCBXCN7AdCEd9*dA3+S}focFZxwc?|^l)2N*^@$Q{~6wmP^j ztY2IU+!)=#;jJW_t-4n>hm_s(>^)-#Op__sad!uNt8slUhk#*-gfJlGKrKE>2Fp1q z%TM!_+;f)c@v~(A#T xKhBHY FP!ANa+4V(XGvo?aPVqb2c#Ghg4f(r`Wajrg@#l-}9oOZ6j5 z-g{#6a{g+**nXlU_HBvrq8PuK`!T 3;0%}GUF^8EG0VMHOE^G_-&c)e&WotL5D=g`WAnr4lOz4Cn{!4}{_^y` z&tO@eJ>8FGuuQInVd&|w0a`p7hyE*PX;L}cNNSEMh+j!Jq_l3pk{Pv|R1L~8G)GQB zb}}g@7`s8CRKKL8SfOU~5H417F~8Eca8JmB<|ie?!^jw_84(=J3vi|_fVzq43~ @zw7w6qu4vVd8X9 ytoWF%<&Wm(N6p*zFmWc#dq z7J3twX6?p>Oj=V+Q#UOInpEsfSdT$P*y?zSC5635X6^=I$2#5wPNy?!E~Sl9j0e6N pvlkfj4jgx%bUq?ozmb+lr2mNzTE8c4P?ew5L2`cD2?%A!{0EK%F|+^x literal 0 HcmV?d00001 diff --git a/__pycache__/sensor.cpython-314.pyc b/__pycache__/sensor.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1caa7304db7a3ba39f9623e30d3728f85cef463 GIT binary patch literal 14235 zcmb_jYj7LabzZ!md;uUpkRS>14U!@yik4`RwoHknK~W^(6$Q!>DHtL^3K9vxyGz-m zI~`4$N#IVVkvpC-)u<6Yb;jUKGh%*){*j5?PFi)^A3#AG$VM5fO;S&ObcPx_QPV%| zId`!O5CSEq&CcNN-MjbRy{~i5cOK{{EzwhOCGY%4=s#O1>X-N+Ug~USuT4f#Hz}Ip zsLK>B8 B(EMV zahGzXkeAV#(K2^AS5ETU(F(VTGr7&2*=^x0?n PX=TJvq*6NOo$H zR}W2&4~|Z3$oNY9(uHWi&js@a;!^SkSeqq3=i@?4LEg9!P$g=su z%S(J|)X%U%A9jJ2=in?-_`^#JKLz~uUV-E$<)tpm`@TU@0Vs1&ugR{;XxR+B5iVLj zBbV}und}LmC|XIYx|OhV>fhjdUU?OFLlwlu Y zL2eJ)P; E2@F z_iB5y>nrwGPmQ%xl#Z&S4(QST>>kAt=la84oaTys9d=Q9BKv*#?mr9PrB#j83yP4; zrCxQN4|2}ssPp{9nGvUW@}2WcWXb7w3WpktnU$_7IQ13Fgv5PUf^htUNG-fUpy0X4 za+m{VW)j-?*ySsvnJ*Mz8el#Xt`gqJKph}$q;WrRMxczdz%IkV6@er2`fMvgd^h}y z%Rq!mQdt4Kf;Z%K$}5&aVP3Hq4D%|+A6^LZYJW5u3 ~{*eakf_*77Q*fk}~#O zC>#`CxO5DzG=l Yp)5TWciXnh>b4#SAf7_D!fJ@XqH!m0&lZO zb}cO}PI57I*kOghRaoF7QRFMk3eyMWgw`KI<~Kl^yQNfnciPpTboJjHd#t8Zw(Tlc zydzb0JYhTWpm#^ }$23 znOkFLGDh=vCf=R6b1L4S?mL(4JNIBNRYQMfJpWihS=xZx=*quS`flmAtt g>1A_w9Ow4O71Wn77u>X&h)!1)afU>|Dr_&A&n;m8X+c+v?H=t_`rM&_M?5EGo^ zoZ_B4Tb%6j(vlx3fY-nox)$Wv|JE%9LMO?|=QVJ{vV8&znI%qiuL+U?m|`)JbzXJ} zpcSp?mc!7L44w+62NMS*6AZFAmz=4E8(-q{%^`RQgyuM(k428yaFd)dB0TuIr--R5 zU&6cu^;zuozfij9Reg^!NjAN>i}$Snd1RbWVJ^#TAxlvx@L(3&Jw1p8oW%?^k)* zOT7V7(JRZbA^IROAp%UPHANYz9_S$~2^A3F1fXJ-1B6YHUXIC+fS95cDB5W?t)aCu zk_hpX=6x=k?N-4zB_WZ=>}4Ly)3wE!K^sY2(K^^GJ#S=lV%^6ZLIGr*^PwOE3@hvh zsG~%N${Uyb;pKV%9KeVm!yCr^EEi;s9y@aMD6d+I1cHkI6FsB6USO3RAiN2ekyo+H zQK+u<`9k3kY&YRhyjJ9=e3{SBag1*{9C~v(hzzzI#?GKD<~8SsY47NnVZK7B6Ck(~ z`T>4@7;Cw(mPvRCU@>$f=p&5QrQnrpaB-d-Kwg(UZgAA_6t}cw&)OKat}b@ggprbS z_NPz@&e=n4$-A1QwrZ_rTicvDbU1!J-E}J2bt>KEPIkEyhes3jXJg83t>cl=e#g6h zDcyWL*?c_Rd@9*|D&cq~Q8Sn^nr@9`jMZ!7z+!G)$QY~cbf>G2CaaI`8q^JXY+Uzc zDb?7rem&iCJlS$Q)o>!F{7hRfoMePDSvW)CsV4M@Z22I1q~+bRMruPLP$H7c#D++L z%**ij6x}hWp!6)h!Q(@SORj2qOhFPKBDdU7paQvY7=);h{{cyfLj)G#$tOOcHdPFF z Z!Y^6vv10L zzWETta*H7Q!x4C`@%aLgImlEBlmME50Q*-+XV4X)Fha@Md uVZL!emz$%d{hb3qn9Tam}k=YoriP$Y7A;c-LG(AbG| z>Rx$8;PxoLh!A@LGVCBEzyB7j;m?$>JaRY_N_(cF`sUe8g#(h-BMD{G6D2VCJ>>Gc zRg}8)i5!mRo 4${1WNOKdB=BO{N)(dcsXyN{_GZYSlZiE7``+`p1 zHO2HpwSD3lHwfpq2SEHlb|8pnNM$Hsx)tz66=4m$mPmf!yu3aO6OaoNh5-;*!Ey{B zLWILac;_~8pU8F~xd5IJaq2#SJq_bx8z3P(#i}~`n1W %Cb67W%^(jUSMhHUPz7M>fVqJ#EL{0>gK=pOsE;H@uKo3Ul(df4(+1i|m&`~D z;pq}<=k5fwNh>-X4NLkljhG%p01JQ}EJ`e2b;XY+q#&4*5ONV?l3;qGR@H>ooV?7( zoQBSsGmxyRh~B#lK$dd;`#1m6$qeJG5lmji#El8cstH#$a~2;61X6H7`-*Eo^hAyl z4HAVJfmaO9n6YAlyUFXM@i1t=;5B}R2?6M0{8*&}6O?5j6oPL9W66^gyhD&TLOGGi zd2a>ShewJ*&`SUyGl4ltNP>!s5?mPNHOyVd1joy3vaAcWSh9S4sWecsW<&ysS}jp) z^HE^c)9g#YfP{7R1F8YmwI2hTYrYM7Yb_+@mUOu@S?)}iw zs*I^NZE8rG8rJ)EOr7zs?U?#vnvBW*LC1R?YuDmE@urlidsDV!>WOI{+FRqAdxi}| zqIV+Up;K+=Q}(G?=_7mH-Rj%bAC2zVyJE_3mu75^4_4n>{it=v)(+3cM~;T|lbf>d zznp03NmL($qOwdwYr5fBvf)_bQ19m8=C#e4MEz-kPBWcH(w(Q1ou}`gNOg{3kxKxq z7X-kXaGXfg^dVFYt_|Nkd;9Em>&YEQf1+k!YxsWiKem6`zCC_<=d>>|_(sCwe-4`6 z1vGo~{|jK53%JK#NS-Nxm@w3c!Y_wiRft_7LF@{*r?G2RZff_A!~UXDi{P>FDu!aA zX1;{VHp%A*Py>4}xbka6WgtU}VBBZQ1Nn?W?9wYQ+Pa850NyH%1;!;=9mSsV)n2G8 zNlB<{&eaifc^Diqgp$EUXWqnwfn>s^7wD6qaSn@@G0AZeTx8~LOja bz`gg~@0nF>tym~L1$=6-lAe)R`+ zyX5(kp-;`9&fTy2@r3YRpehM*K?f-30d;Bjz5zNsP?gLB)Kz#r=alDATk+OKPI*+3 zXZe>G{ifs(kdP9f4-|BG1z_?Hu-Z${ka18mC`-vT-P@@b^MDayZiSaf8J7n-B`T#T z@mu4v%M{rBpEonG6h4LL)OoFn&rI?fh+hK?sFr3T%uFLa7tU JHd_b#9hXJ#eyOCnUd)!K Z5U-Wd|lPceO?7=DM= 8ts@K%WP8 zf;;R zd8UfKhgFfrQF%Zg$^**tc|ex`M6F({IF|sRNTA +ujO=wG@>eR zrP$j7(5-_S1F_|`6_kA)e6jMV-CISQ`aqZoUR>HtN-TLLl~7{#Nd7f(T+pN4NO>zk z=dgNhklK1AY6d%3mDlznR1JWZ0Q+L|z`gLItHhbn9r>qWTsCEkR(s2eeUmQpR?>EF zHC^qk>X!H-_|$%r^{Lf((1%oTdClUNqUE$BZ*7ZHQ4wB7*FLvZ{=R1U0$rEas`%NI z)=V5BUH{xR^ 1J;^-Qq2yTMwX? zw&$Gv*4){rUH`{ETzP%8&!FSIV9ed9Q+5)J2JSmSQ_Ja*1hMf}qGmqzk~ilRC=6o| zv?v66yc(6aWtP`ZO!`C *p6j#ch`_96Qoo_H0h%6tpIS(;VmiP;$r zoFT;e#C2o-3div0nB2wWd)UB`J8s!i1?!Fed2+&7-y(!1folj>nAedbMvUTG#{ZUJ zSSb|(Vc>8H&jwN{dj!={CO98@o7Y`~xES)DMJ6da$pu&WzEsd(P}$)RJ*B@C+D8Zk z_k*ChNgZGz02jB(xvu%x@S!2mdFs9? ?49C vU7O_jT1s$CeR{eznKYSu2UGpUO9m^x#3-mSS^vwktor0hL0Fk#fL zJCe4;Y1?GNHn~}Me;|2uGG>5=wz?0lzjuATHQtl5cENYcsvkkoJMX=-eiGW+dSdR+ z%WRptmi0IP`ug3-?MQrn^Gd4j#ar06+5tURRYN#{wK{EWNm^Ud*3P80Gi^PQw4T^f z{zUtUcE>uHwvHsNBWdf|r1flK%$M-br>qMx!xsi?tUo)@MDy`2dCJ}&JDaiB#LoV_ zs^OM0<8a<}-*&IhZ1$ua$J36JNyo{p l^Q0y?r&&`r=l9s_xZWhA+&H_g?(C?!&rF zZ9Vu1Z4K+8ZCm$N*QOQo3){BCTgLc}N0z#@r8#M7j$7l}P5Y){t7j{mcxn8>Yl)W0 zZHwp88TW&U?K59nFHJjplg{4F3n^#+*6GijrxI_3Z;fUw4QY!jX>nz$>ehAH#JS$F zt5J4Xe@7`RZI2r%d*f~wRois;-0gGm>Xf55HV(rrsl9VO-g>WNqa$&A>;au>_oV7x z1KZD?G0|T2>H|}%?Oe(MuFXRq^~c}3_s+&UiT(?zZf~k_I%BMd=}+Igv~el%;GVWt1#99Y@{JWt{Fo5S1R5_HBt_;1Cd7Dw?9gRqE@8n8CbO7a#>9uo z8<-w0%x0GA_-upK}_Z`S%8GN zZAAy#5iG?7r@~%!n*S4`?+aQ#SupaQIrxNh;jHl5y9P;tQ|+h4ooWX-&&0qN5)xD3JTuVR zg1{FYa}++fI+%5paaaAE)4#C51Q!6Ouuh006$4l!VW;28hQbSrK_~i4oe+xP6gMXL z$9JaX*{0DFGXaJd)X9h?=9xwqz6s?+k+`mXj ^O z52i>d*|U9-l&E{cmTC9aAyeQ<0}srzJZTUNL*@C> 3PN7wc_A-g!Gchhyg(LO z_lJbC(0V|vhE>~CWSMm?!j2M1!#dj*Lkr=ci!b-V7H7jyL8OO+IUfi!1ao_a-$R%4 zdoTp$5XvV+kIHp{+X2j*#4y=|ukeryUIZoNAT}=Ff#5uN`vgY<**fGKuysWo38LY? zeZ4x-(VwU%F$39Pl8XsPSE8o-xd9}fY3qbdN3|)dI{hKBB45?i{&&zl55JtL-HN7m zNYK;{x7;Y87P-(kM2_A+fT|8g!LMv;_ech~Y)^25){;RxSDI&>kb!YR4nGBrA-gg# zQv3{z6zG60(i<&%rUO $cxNZoC6r+6h-?qi{J6;#Jb`g z+MrVXK1F3sENM`$RZ->G&$EUIaNQOQRQAfn9%$_WH6*hJt@A>_128aY1!p8PN>06p z_tHwj36l%};@H8Io6Uk*hA^lR!T0e^C?=XT@q$bdZE{uK?__S{i#w1^xXSXJBJEgA zcn|TaF5%<%F+nwp!L$_i@Y-baEJT1f39XiC$nzI2^t#r=+7k3Ss_4ePa2F$c-R_ zY*JD=8R8mAV4On!jLJxEv-4B1FuP+#(#E >fXhTib zaL0N)_UgmR#`RY}9{X@Cab);DlWIDfsvL`rJgjbu+wRqD)FfV*Ou0O%>epgp&sTkh z9~o=X#)hP^A%|8y2q(KQq#C{3M!>A5?~K1Yey4FQ_}%txV{@j=a_h~%ScwPzwYuYR z1y$wTwE`?&3)~Ie4#mq-b>K3sS)021`t8@(Z=`Cw0CXno?SQNglej+DmwJrryZSeW z4~PU)9}GqDpuq9@irAC+F;p)^k^wAx28cv3MG)0bA;S(pk_C}tPl3oMN&pyph%$Do z32+2%pAEo*90u}a1F*2q30EV*$GjS1K>>piR}^1=75*Ru!N0;Egt~HnF6k1YmkCy8 zP{i`4!m1E=iX5DTr18!B-(C$a0=Q$lvX~mcAbbHr)HT@C0d+CXm0R&a5Il#9KlwWh z-&VZe?4JelLIoM*@&e@}1@S5{sK6(Y%Lp+H$WKVr1PPT8A{+>mKupMMvLhBq-hy?} zq9Vjv^kJ?A69Vzc7NHZIh*m zVG3YkpyGphdAl=Ta0>~re) zFQ~y^P{Y5VMt)5ldLq}z str: + """Encode the API key for Basic Auth.""" + token_str = f"{api_key}:" + return base64.b64encode(token_str.encode()).decode() + + +class SncfApiClient: + def __init__(self, session: ClientSession, api_key: str, timeout: int = 10): + self._session = session + self._token = encode_token(api_key) + self._timeout = timeout + + async def fetch_departures( + self, stop_id: str, max_results: int = 20 #By default = 10 + ) -> Optional[List[dict]]: + if stop_id.startswith("stop_area:"): + url = f"{API_BASE}/v1/coverage/sncf/stop_areas/{stop_id}/departures" + elif stop_id.startswith("stop_point:"): + url = f"{API_BASE}/v1/coverage/sncf/stop_points/{stop_id}/departures" + else: + raise ValueError("stop_id must start with 'stop_area:' or 'stop_point:'") + + params_raw: dict[str, object] = { + "data_freshness": "realtime", + "count": max_results, + } + params: Mapping[str, str] = {k: str(v) for k, v in params_raw.items()} + + headers = {"Authorization": f"Basic {self._token}"} + + try: + async with self._session.get( + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), + ) as resp: + if resp.status == 401: + # vrai problĂšme d'auth + raise ConfigEntryAuthFailed("Unauthorized: check your API key.") + if resp.status == 429: + # rate-limit => pas une auth failure + _LOGGER.warning("API rate limit (429) on %s with %s", url, params) + raise RuntimeError( + "SNCF API rate-limited (429)" + ) # sera gĂ©rĂ© comme non-critique + resp.raise_for_status() + data = await resp.json() + return data.get("departures", []) + except (ClientError, asyncio.TimeoutError) as err: + _LOGGER.error("Network error fetching departures from SNCF API: %s", err) + _LOGGER.debug("URL: %s, Params: %s", url, params) + return None + + async def fetch_journeys( + self, from_id: str, to_id: str, datetime_str: str, count: int = 20 + ) -> Optional[List[dict]]: + url = f"{API_BASE}/v1/coverage/sncf/journeys" + params_raw: dict[str, object] = { + "from": from_id, + "to": to_id, + "datetime": datetime_str, + "count": count, + "data_freshness": "realtime", + "datetime_represents": "departure", + } + params: Mapping[str, str] = {k: str(v) for k, v in params_raw.items()} + + headers = {"Authorization": f"Basic {self._token}"} + try: + async with self._session.get( + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), + ) as resp: + if resp.status == 401: + raise ConfigEntryAuthFailed("Unauthorized: check your API key.") + if resp.status == 429: + raise RuntimeError("Quota exceeded: 429 Too Many Requests.") + resp.raise_for_status() + data = await resp.json() + return data.get("journeys", []) + except (ClientError, asyncio.TimeoutError) as err: + _LOGGER.warning("Network error fetching journeys from SNCF API: %s", err) + return None + + async def search_stations(self, query: str) -> Optional[List[dict]]: + url = f"{API_BASE}/v1/coverage/sncf/places" + params_raw: dict[str, object] = { + "q": query, + "type[]": "stop_point", + } + params: Mapping[str, str] = {k: str(v) for k, v in params_raw.items()} + headers = {"Authorization": f"Basic {self._token}"} + try: + async with self._session.get( + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data.get("places", []) + except (ClientError, asyncio.TimeoutError) as err: + _LOGGER.error("Network error searching stations from SNCF API: %s", err) + return None \ No newline at end of file diff --git a/calendar.py b/calendar.py new file mode 100644 index 0000000..7a452ac --- /dev/null +++ b/calendar.py @@ -0,0 +1,169 @@ +"""Calendar for trains hours.""" + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SncfDataConfigEntry +from .const import CONF_ARRIVAL_NAME, CONF_DEPARTURE_NAME, CONF_TRAIN_COUNT, DOMAIN +from .coordinator import SncfUpdateCoordinator +from .helpers import get_train_num, parse_datetime + +_LOGGER = logging.getLogger(__name__) + + +async def async_create_event(self, **kwargs): + raise NotImplementedError + + +async def async_delete_event(self, uid: str): + raise NotImplementedError + + +async def async_update_event(self, uid: str, event: CalendarEvent): + raise NotImplementedError + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SncfDataConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Demo Calendar config entry.""" + coordinator: SncfUpdateCoordinator = entry.runtime_data + async_add_entities([SNCFCalendar(coordinator)], update_before_add=True) + + +@dataclass +class SNCFEventMixIn: + """Mixin for calendar event.""" + + has_delay: bool + delay: int + departure_date_time: datetime + arrival_date_time: datetime + train_num: int + + +@dataclass +class MyCalendarEvent(CalendarEvent, SNCFEventMixIn): + """A class to describe a calendar event.""" + + +class SNCFCalendar(CoordinatorEntity[SncfUpdateCoordinator], CalendarEntity): + """Representation of a Calendar element.""" + + _attr_name = "Trains" + + def __init__(self, coordinator: SncfUpdateCoordinator) -> None: + """Initialize demo calendar.""" + super().__init__(coordinator) + self._event: MyCalendarEvent | None = None + self._attr_unique_id = f"calendar_sncf_train_{coordinator.entry.entry_id}" + self._attr_device_info = { + "identifiers": {(DOMAIN, coordinator.entry.entry_id)}, + "name": "SNCF", + "manufacturer": "Master13011", + "model": "API", + "entry_type": DeviceEntryType.SERVICE, + } + + @property + def event(self) -> MyCalendarEvent | None: + """Return the current or next upcoming event.""" + if not self.available: + return None + + return self._event + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._fetch_journeys(): + self._event = min( + self._fetch_journeys(), + key=lambda x: abs(x.start.replace(tzinfo=None) - datetime.now()), + ) + if self._event: + self._attr_extra_state_attributes = { + "has_delay": self._event.has_delay, + "delay": self._event.delay, + "departure": self._event.departure_date_time, + "arrival": self._event.arrival_date_time, + "number": self._event.train_num, + } + self.async_write_ha_state() + + async def async_get_events( + self, _hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range. + + This is only called when opening the calendar in the UI. + """ + if not self.available: + return [] + + return self._fetch_journeys() + + def _async_calculate_delay( + self, journey, dep_name: str, arr_name: str + ) -> tuple[bool, int, str]: + arr_dt = parse_datetime(journey.get("arrival_date_time", "")) + section = journey.get("sections", [{}])[0] + base_arr_dt = parse_datetime(section.get("base_arrival_date_time", "")) + + delay = ( + int((arr_dt - base_arr_dt).total_seconds() / 60) + if arr_dt and base_arr_dt + else 0 + ) + summary = ( + f"{dep_name} â {arr_name} - RETARD ({delay}min)" + if delay > 0 + else f"{dep_name} â {arr_name}" + ) + + return delay > 0, delay, summary + + def _fetch_journeys(self): + """Fetch journeys.""" + calendar_events = [] + for tid, journeys in self.coordinator.data.items(): + entry = self.coordinator.entry.subentries[tid] + dep_name = entry.data[CONF_DEPARTURE_NAME] + arr_name = entry.data[CONF_ARRIVAL_NAME] + display_count = min(len(journeys), entry.data[CONF_TRAIN_COUNT]) + _LOGGER.debug("%s -> %s", dep_name, arr_name) + for journey in journeys[:display_count]: + section = journey.get("sections", [{}])[0] + dep_dt = parse_datetime(journey.get("departure_date_time", "")) + arr_dt = parse_datetime(journey.get("arrival_date_time", "")) + has_delay, delay, summary = self._async_calculate_delay( + journey, dep_name, arr_name + ) + + if dep_dt and arr_dt: + calendar_events.append( + MyCalendarEvent( + summary=summary, + start=dep_dt, + end=dep_dt + timedelta(minutes=1), + description=f"ArrivĂ©e: {arr_dt}, retard: {delay} minutes", + location=str(dep_name), + uid=section.get("id"), + has_delay=has_delay, + delay=delay, + departure_date_time=dep_dt, + arrival_date_time=arr_dt, + train_num=int(get_train_num(journey)), + ) + ) + + return calendar_events diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..2f2e1db --- /dev/null +++ b/config_flow.py @@ -0,0 +1,305 @@ +from typing import Any +import asyncio +from aiohttp import ClientError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ( + ConfigEntry, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import SncfApiClient +from .const import ( + CONF_API_KEY, + CONF_ARRIVAL_CITY, + CONF_ARRIVAL_NAME, + CONF_ARRIVAL_STATION, + CONF_DEPARTURE_CITY, + CONF_DEPARTURE_NAME, + CONF_DEPARTURE_STATION, + CONF_FROM, + CONF_TIME_END, + CONF_TIME_START, + CONF_TO, + CONF_TRAIN_COUNT, + CONF_UPDATE_INTERVAL, + CONF_OUTSIDE_INTERVAL, + CONF_SHOW_ROUTE_DETAILS, # NOUVEAU + DEFAULT_OUTSIDE_INTERVAL, + DEFAULT_TIME_END, + DEFAULT_TIME_START, + DEFAULT_TRAIN_COUNT, + DEFAULT_UPDATE_INTERVAL, + DEFAULT_SHOW_ROUTE_DETAILS, # NOUVEAU + DOMAIN, +) + + +class SncfTrainsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + MINOR_VERSION = 2 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + api = SncfApiClient(session, user_input[CONF_API_KEY]) + if not await self._validate_api_key(api): + errors["base"] = "invalid_api_key" + else: + if self.source == "user": + await self.async_set_unique_id("sncf_trains") + return self.async_create_entry(title="Trains SNCF", data=user_input) + else: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=user_input + ) + + DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + if self.source == "reconfigure": + entry = self._get_reconfigure_entry() + DATA_SCHEMA = self.add_suggested_values_to_schema(DATA_SCHEMA, entry.data) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def _validate_api_key(self, api: SncfApiClient): + """Check API Key.""" + try: + results = await api.search_stations("paris") + return bool(results) + except (ClientError, asyncio.TimeoutError): + return False + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "train": TrainSubentryFlowHandler, + } + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + """Return options.""" + return SncfTrainsOptionsFlowHandler() + + async_step_reconfigure = async_step_user + + +class SncfTrainsOptionsFlowHandler(OptionsFlow): + """Options flow.""" + + async def async_step_init(self, user_input: dict[str, Any] | None = None): + """Handle the initial options step.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + entry = self.config_entry + + DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_UPDATE_INTERVAL, + default=entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ), + ): int, + vol.Required( + CONF_OUTSIDE_INTERVAL, + default=entry.options.get( + CONF_OUTSIDE_INTERVAL, DEFAULT_OUTSIDE_INTERVAL + ), + ): int, + } + ) + + return self.async_show_form(step_id="init", data_schema=DATA_SCHEMA) + + +class TrainSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing trains subentries.""" + + api: SncfApiClient | None = None + departure_city: str | None = None + departure_station: str | None = None + arrival_city: str | None = None + arrival_station: str | None = None + departure_options: dict = {} + arrival_options: dict = {} + config_entry: ConfigEntry | None = None + + async def async_step_departure_city( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the departure city step.""" + errors = {} + if user_input is not None: + self.config_entry = self._get_entry() + api_key = self.config_entry.options.get( + "api_key" + ) or self.config_entry.data.get("api_key") + session = async_get_clientsession(self.hass) + self.api = SncfApiClient(session, api_key) + + self.departure_city = user_input[CONF_DEPARTURE_CITY] + stations = await self.api.search_stations(self.departure_city) + if not stations: + errors["base"] = "no_stations" + else: + self.departure_options = {s["id"]: s for s in stations} + return await self.async_step_departure_station() + return self.async_show_form( + step_id="departure_city", + data_schema=vol.Schema({vol.Required(CONF_DEPARTURE_CITY): str}), + errors=errors, + ) + + async def async_step_departure_station( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the departure station step.""" + if user_input is not None: + self.departure_station = user_input[CONF_DEPARTURE_STATION] + return await self.async_step_arrival_city() + options = { + k: f"{v['name']} ({k.split(':')[-1]})" + for k, v in self.departure_options.items() + } + return self.async_show_form( + step_id="departure_station", + data_schema=vol.Schema( + {vol.Required(CONF_DEPARTURE_STATION): vol.In(options)} + ), + ) + + async def async_step_arrival_city( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the arrival city step.""" + errors = {} + if user_input is not None: + self.arrival_city = user_input[CONF_ARRIVAL_CITY] + stations = await self.api.search_stations(self.arrival_city) + if not stations: + errors["base"] = "no_stations" + else: + self.arrival_options = {s["id"]: s for s in stations} + return await self.async_step_arrival_station() + return self.async_show_form( + step_id="arrival_city", + data_schema=vol.Schema({vol.Required(CONF_ARRIVAL_CITY): str}), + errors=errors, + ) + + async def async_step_arrival_station( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the arrival station step.""" + if user_input is not None: + self.arrival_station = user_input[CONF_ARRIVAL_STATION] + return await self.async_step_time_range() + options = { + k: f"{v['name']} ({k.split(':')[-1]})" + for k, v in self.arrival_options.items() + } + return self.async_show_form( + step_id="arrival_station", + data_schema=vol.Schema( + {vol.Required(CONF_ARRIVAL_STATION): vol.In(options)} + ), + ) + + async def async_step_time_range( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the time range step.""" + if user_input is not None: + dep_name = self.departure_options.get(self.departure_station, {}).get( + "name", self.departure_station + ) + arr_name = self.arrival_options.get(self.arrival_station, {}).get( + "name", self.arrival_station + ) + time_start = user_input[CONF_TIME_START] + time_end = user_input[CONF_TIME_END] + unique_id = f"{self.departure_station}_{self.arrival_station}_{time_start}_{time_end}" + + for subentry in self.config_entry.subentries.values(): + if unique_id == subentry.unique_id: + return self.async_abort(reason="already_configured_as_entry") + + return self.async_create_entry( + title=f"Trajet: {dep_name} â {arr_name} ({time_start} - {time_end})", + data={ + CONF_FROM: self.departure_station, + CONF_TO: self.arrival_station, + CONF_DEPARTURE_NAME: dep_name, + CONF_ARRIVAL_NAME: arr_name, + **user_input, + }, + unique_id=unique_id, + ) + + # NOUVEAU: On ajoute l'option boolĂ©enne + return self.async_show_form( + step_id="time_range", + data_schema=vol.Schema( + { + vol.Required(CONF_TIME_START, default=DEFAULT_TIME_START): str, + vol.Required(CONF_TIME_END, default=DEFAULT_TIME_END): str, + vol.Required(CONF_TRAIN_COUNT, default=DEFAULT_TRAIN_COUNT): int, + vol.Optional(CONF_SHOW_ROUTE_DETAILS, default=DEFAULT_SHOW_ROUTE_DETAILS): bool, + } + ), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to modify an existing entry.""" + config_subentry = self._get_reconfigure_subentry() + + if user_input is not None: + data = config_subentry.data.copy() + data.update(user_input) + + return self.async_update_and_abort( + self._get_entry(), + config_subentry, + data=data, + title=f"Trajet: {data[CONF_DEPARTURE_NAME]} â {data[CONF_ARRIVAL_NAME]} ({data[CONF_TIME_START]} - {data[CONF_TIME_END]})", + ) + + # NOUVEAU: On rĂ©cupĂšre l'ancienne valeur si elle existe + current_show_route = config_subentry.data.get(CONF_SHOW_ROUTE_DETAILS, DEFAULT_SHOW_ROUTE_DETAILS) + + DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TIME_START, default=DEFAULT_TIME_START): str, + vol.Required(CONF_TIME_END, default=DEFAULT_TIME_END): str, + vol.Required(CONF_TRAIN_COUNT, default=DEFAULT_TRAIN_COUNT): int, + vol.Optional(CONF_SHOW_ROUTE_DETAILS, default=current_show_route): bool, + } + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, config_subentry.data + ), + ) + + async_step_user = async_step_departure_city \ No newline at end of file diff --git a/const.py b/const.py new file mode 100644 index 0000000..545f277 --- /dev/null +++ b/const.py @@ -0,0 +1,27 @@ +DOMAIN = "sncf_trains" + +CONF_API_KEY = "api_key" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_OUTSIDE_INTERVAL = "outside_interval" + +DEFAULT_UPDATE_INTERVAL = 2 # minutes +DEFAULT_OUTSIDE_INTERVAL = 60 # minutes +DEFAULT_TRAIN_COUNT = 5 +DEFAULT_TIME_START = "07:00" +DEFAULT_TIME_END = "10:00" +DEFAULT_SHOW_ROUTE_DETAILS = False + +ATTRIBUTION = "Data provided by api.sncf.com" + +CONF_ARRIVAL_CITY = "arrival_city" +CONF_ARRIVAL_NAME = "arrival_name" +CONF_ARRIVAL_STATION = "arrival_station" +CONF_DEPARTURE_CITY = "departure_city" +CONF_DEPARTURE_NAME = "departure_name" +CONF_DEPARTURE_STATION = "departure_station" +CONF_FROM = "from" +CONF_TIME_END = "time_end" +CONF_TIME_START = "time_start" +CONF_TO = "to" +CONF_TRAIN_COUNT = "train_count" +CONF_SHOW_ROUTE_DETAILS = "show_route_details" # NOUVEAU \ No newline at end of file diff --git a/coordinator.py b/coordinator.py new file mode 100644 index 0000000..9297469 --- /dev/null +++ b/coordinator.py @@ -0,0 +1,183 @@ +"""Data Update Coordinator.""" + +import logging +from datetime import timedelta +from typing import Any +import asyncio +from aiohttp import ClientError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .api import SncfApiClient +from .const import ( + CONF_API_KEY, + CONF_FROM, + CONF_OUTSIDE_INTERVAL, + CONF_TIME_END, + CONF_TIME_START, + CONF_TO, + CONF_UPDATE_INTERVAL, + DEFAULT_OUTSIDE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class SncfUpdateCoordinator(DataUpdateCoordinator): + """Coordonnateur pour rĂ©cupĂ©rer les donnĂ©es des trajets SNCF.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + """Initialisation.""" + self.entry = entry + self.api_client = None + self.update_interval_minutes = entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ) + self.outside_interval_minutes = entry.options.get( + CONF_OUTSIDE_INTERVAL, DEFAULT_OUTSIDE_INTERVAL + ) + + super().__init__( + hass, + _LOGGER, + name="SNCF Train Journeys", + update_interval=timedelta(minutes=self.update_interval_minutes), + ) + + async def _async_setup(self) -> None: + """ParamĂ©trage du coordinateur.""" + api_key = self.entry.data[CONF_API_KEY] + + try: + session = async_get_clientsession(self.hass) + self.api_client = SncfApiClient(session, api_key) + + except Exception as err: + if "401" in str(err) or "403" in str(err): + raise ConfigEntryAuthFailed("ClĂ© API invalide ou expirĂ©e") from err + _LOGGER.error("Erreur lors de la rĂ©cupĂ©ration des trajets SNCF: %s", err) + raise UpdateFailed(err) from err + + def _build_datetime_param(self, time_start, time_end) -> str: + """Construit le paramĂštre datetime pour l'API (ignore le passĂ©).""" + now = dt_util.now() + h_start, m_start = map(int, time_start.split(":")) + h_end, m_end = map(int, time_end.split(":")) + + dt_start = now.replace(hour=h_start, minute=m_start, second=0, microsecond=0) + dt_end = now.replace(hour=h_end, minute=m_end, second=0, microsecond=0) + + if now > dt_end: + # Si on a dĂ©passĂ© la fin de la plage aujourd'hui, on cherche pour demain + dt_start += timedelta(days=1) + elif now > dt_start: + # CORRECTION : Si on est PENDANT la plage horaire, on cherche Ă partir de MAINTENANT + # (On n'interroge plus les trains dĂ©jĂ passĂ©s !) + dt_start = now + + return dt_start.strftime("%Y%m%dT%H%M%S") + + def _adjust_update_interval(self, time_start, time_end) -> timedelta | None: + """Ajuste la frĂ©quence selon la plage horaire, avec prĂ©fenĂȘtre 1h et gestion minuit.""" + now = dt_util.now() + h_start, m_start = map(int, time_start.split(":")) + h_end, m_end = map(int, time_end.split(":")) + + start = now.replace(hour=h_start, minute=m_start, second=0, microsecond=0) + end = now.replace(hour=h_end, minute=m_end, second=0, microsecond=0) + + if end <= start: + end += timedelta(days=1) + + pre_start = start - timedelta(hours=1) + + if now < pre_start: + start -= timedelta(days=1) + end -= timedelta(days=1) + pre_start -= timedelta(days=1) + + in_fast_mode = pre_start <= now <= end + + interval_minutes = ( + self.update_interval_minutes + if in_fast_mode + else self.outside_interval_minutes + ) + new_interval = timedelta(minutes=interval_minutes) + + if self.update_interval != new_interval: + _LOGGER.debug( + "Update interval: %s â %s minutes", + ( + None + if self.update_interval is None + else self.update_interval.total_seconds() / 60 + ), + interval_minutes, + ) + return new_interval + + return new_interval + + async def _async_update_data(self) -> dict[str, Any]: + """RĂ©cupĂšre les donnĂ©es de l'API SNCF.""" + + if not self.entry.subentries: + _LOGGER.warning("Pas de subentries configurĂ©s") + return {} + + update_intervals = [] + trains = {} + max_retries = 3 # nombre de tentatives + retry_delay = 2 # secondes entre les tentatives + for subentry_id, entry in self.entry.subentries.items(): + _LOGGER.debug(entry.title) + departure = entry.data[CONF_FROM] + arrival = entry.data[CONF_TO] + time_start = entry.data[CONF_TIME_START] + time_end = entry.data[CONF_TIME_END] + + update_intervals.append(self._adjust_update_interval(time_start, time_end)) + datetime_str = self._build_datetime_param(time_start, time_end) + journeys = None + for attempt in range(1, max_retries + 1): + try: + journeys = await self.api_client.fetch_journeys( + departure, arrival, datetime_str, count=20 #By defaults = 10 + ) + if journeys is not None: + break # succĂšs, on sort du retry + except (ClientError, asyncio.TimeoutError, RuntimeError) as err: + _LOGGER.warning( + "Erreur rĂ©seau lors de la rĂ©cupĂ©ration des trajets (tentative %d/%d) : %s", + attempt, + max_retries, + err, + ) + await asyncio.sleep(retry_delay) + + if journeys is None or not isinstance(journeys, list): + _LOGGER.error("Aucune donnĂ©e reçue de l'API SNCF pour le trajet ") + continue + + trains[subentry_id] = [ + j + for j in journeys + if isinstance(j, dict) and len(j.get("sections", [])) == 1 + ] + + if update_intervals: + new_interval = min(update_intervals) + if self.update_interval != new_interval: + self.update_interval = new_interval + _LOGGER.debug( + "Coordinator update interval set to %s minutes", + self.update_interval.total_seconds() / 60, + ) + + return trains \ No newline at end of file diff --git a/diagnostics.py b/diagnostics.py new file mode 100644 index 0000000..846d3f9 --- /dev/null +++ b/diagnostics.py @@ -0,0 +1,45 @@ +"""Diagnostics support for SNCF integration.""" + +from __future__ import annotations +from typing import Any +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_registry import async_redact_data +from .const import DOMAIN, CONF_API_KEY + +TO_REDACT = {CONF_API_KEY} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: dict[str, Any] = {} + + data["config_entry"] = { + "title": entry.title, + "data": async_redact_data(entry.data, TO_REDACT), + "options": async_redact_data(entry.options, TO_REDACT), + "entry_id": entry.entry_id, + "version": entry.version, + } + + coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) + if coordinator: + data["coordinator"] = { + "departure": getattr(coordinator, "departure", None), + "arrival": getattr(coordinator, "arrival", None), + "time_start": getattr(coordinator, "time_start", None), + "time_end": getattr(coordinator, "time_end", None), + "update_interval": str(getattr(coordinator, "update_interval", None)), + "last_update_success": getattr(coordinator, "last_update_success", None), + "last_update_time": str( + getattr(coordinator, "last_update_success_time", None) + ), + "data_sample": ( + coordinator.data[:3] + if hasattr(coordinator, "data") and coordinator.data + else None + ), + } + return data diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..a091505 --- /dev/null +++ b/helpers.py @@ -0,0 +1,49 @@ +"""Helpers for component.""" + +from datetime import datetime +from typing import Any + +from homeassistant.util import dt as dt_util + + +def parse_datetime(dt_str: str) -> datetime | None: + """Parse string to datetime.""" + if not dt_str: + return None + + try: + dt = dt_util.parse_datetime(dt_str) + return dt_util.as_local(dt) if dt else None + except (ValueError, TypeError): + return None + + +def format_time(dt_str: str) -> str: + """Format a Navitia datetime string as dd/mm/YYYY - HH:MM.""" + dt = parse_datetime(dt_str) + return dt.strftime("%d/%m/%Y - %H:%M") if dt else "N/A" + + +def get_train_num(journey: dict[str, Any]) -> str: + """Extract the commercial train number.""" + trip_num = journey.get("trip_short_name") + if trip_num: + return trip_num + + sections = journey.get("sections", []) + if sections: + infos = sections[0].get("display_informations", {}) + return infos.get("trip_short_name") or infos.get("num", "") + + return "" + + +def get_duration(journey: dict[str, Any]) -> int: + """Compute journey duration in minutes.""" + dep = parse_datetime(journey.get("departure_date_time", "")) + arr = parse_datetime(journey.get("arrival_date_time", "")) + + if dep and arr: + return int((arr - dep).total_seconds() / 60) + + return 0 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cef92cd --- /dev/null +++ b/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "sncf_trains", + "name": "SNCF Trains", + "after_dependencies": [ + "http" + ], + "codeowners": [ + "@Master13011" + ], + "config_flow": true, + "dependencies": [ + "frontend" + ], + "documentation": "https://github.com/Master13011/SNCF-API-HA", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/Master13011/SNCF-API-HA/issues", + "loggers": [ + "custom_components.sncf_trains" + ], + "requirements": [], + "single_config_entry": true, + "version": "1.3.0" +} diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..3518a57 --- /dev/null +++ b/sensor.py @@ -0,0 +1,240 @@ +"""Sensors for trains hours.""" + +from typing import Any + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SncfDataConfigEntry +from .const import ( + ATTRIBUTION, + CONF_ARRIVAL_NAME, + CONF_DEPARTURE_NAME, + CONF_FROM, + CONF_TO, + DOMAIN, +) +from .coordinator import SncfUpdateCoordinator +from .helpers import format_time, get_duration, get_train_num, parse_datetime + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SncfDataConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SNCF entities from a config entry.""" + + coordinator: SncfUpdateCoordinator = entry.runtime_data + + # Capteur global "Trajets" + async_add_entities([SncfJourneySensor(coordinator)], update_before_add=True) + + for subentry in entry.subentries.values(): + journeys = coordinator.data.get(subentry.subentry_id, []) + display_count = min(len(journeys), subentry.data.get("train_count", 0)) + sensors = [] + + # Capteurs individuels pour chaque train + for idx in range(display_count): + sensors.append(SncfTrainSensor(coordinator, subentry.subentry_id, idx)) + + # Capteur rĂ©sumĂ© ligne par ligne + sensors.append(SncfAllTrainsLineSensor(coordinator, subentry.subentry_id)) + + async_add_entities( + sensors, config_subentry_id=subentry.subentry_id, update_before_add=True + ) + + +class SncfJourneySensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): + """Main SNCF sensor: number of direct journeys & summary.""" + + _attr_has_entity_name = True + _attr_name = "Trajets" + _attr_icon = "mdi:train" + _attr_native_unit_of_measurement = "trajets" + + def __init__(self, coordinator: SncfUpdateCoordinator) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"sncf_trains_{coordinator.entry.entry_id}" + self._attr_device_info = { + "identifiers": {(DOMAIN, coordinator.entry.entry_id)}, + "name": "SNCF", + "manufacturer": "Master13011", + "model": "API", + "entry_type": DeviceEntryType.SERVICE, + } + self._attr_native_value = len(coordinator.data) + + @callback + def _handle_coordinator_update(self) -> None: + self._attr_native_value = len(self.coordinator.data) + self.async_write_ha_state() + + +class SncfTrainSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): + """Sensor for an individual train.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:train" + _attr_attribution = ATTRIBUTION + _attr_device_class = SensorDeviceClass.TIMESTAMP + + def __init__(self, coordinator, train_id: str, journey_id: int) -> None: + super().__init__(coordinator) + self.tid = train_id + self.jid = journey_id + entry = self.coordinator.entry.subentries[train_id] + journey = coordinator.data[train_id][journey_id] + section = journey.get("sections", [{}])[0] + departure_time = parse_datetime(section.get("base_departure_date_time", "")) + + self.departure = entry.data[CONF_FROM] + self.arrival = entry.data[CONF_TO] + + self._attr_name = f"Train {journey_id + 1}" + self._attr_unique_id = f"{entry.subentry_id}_{journey_id}" + self._attr_extra_state_attributes = self._extra_attributes(journey) + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.subentry_id)}, + "name": f"SNCF {entry.data[CONF_DEPARTURE_NAME]} â {entry.data[CONF_ARRIVAL_NAME]}", + "manufacturer": "Master13011", + "model": "API", + "entry_type": DeviceEntryType.SERVICE, + } + self._attr_native_value = departure_time + + @callback + def _handle_coordinator_update(self) -> None: + journey = self.coordinator.data[self.tid][self.jid] + section = journey.get("sections", [{}])[0] + self._attr_native_value = parse_datetime(section.get("base_departure_date_time", "")) + self._attr_extra_state_attributes = self._extra_attributes(journey) + self.async_write_ha_state() + + def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]: + section = journey.get("sections", [{}])[0] + + # 1. Calcul du retard + arr_dt = parse_datetime(journey.get("arrival_date_time", "")) + base_arr_dt = parse_datetime(section.get("base_arrival_date_time")) + delay_arr = int((arr_dt - base_arr_dt).total_seconds() / 60) if arr_dt and base_arr_dt else 0 + + dep_dt = parse_datetime(journey.get("departure_date_time", "")) + base_dep_dt = parse_datetime(section.get("base_departure_date_time")) + delay_dep = int((dep_dt - base_dep_dt).total_seconds() / 60) if dep_dt and base_dep_dt else 0 + + delay = max(delay_arr, delay_dep) + + # 2. DĂ©tection d'annulation et Cause + status = journey.get("status", "") + section_status = section.get("status", "") + is_canceled = (status == "NO_SERVICE" or section_status == "NO_SERVICE") + + # Extraction de la cause du retard/problĂšme + delay_cause = section.get("cause", "") + if not delay_cause: + # On cherche dans les messages de perturbation globaux + messages = journey.get("messages", []) + if messages: + delay_cause = messages[0].get("text", "") + + # 3. Plan de vol structurĂ© avec dĂ©tection des modifications (added/deleted) + stops_schedule = [] + route_details = "" + show_routes = self.coordinator.entry.subentries[self.tid].data.get("show_route_details", False) + + if show_routes: + stops_data = section.get("stop_date_times", []) + stops_list = [] + for stop in stops_data: + stop_name = stop.get("stop_point", {}).get("name", "") + # On rĂ©cupĂšre l'horaire rĂ©el + raw_time = stop.get("departure_date_time", stop.get("arrival_date_time", "")) + formatted_time = format_time(raw_time) if raw_time else "" + + # DĂ©tection du statut de l'arrĂȘt + stop_effect = stop.get("stop_time_effect", "unchanged") # added, deleted, delayed, unchanged + + if stop_name and formatted_time: + prefix = "" + if stop_effect == "deleted": prefix = "[SUPPRIMĂ] " + if stop_effect == "added": prefix = "[NOUVEAU] " + + stops_list.append(f"{prefix}{stop_name} ({formatted_time})") + + just_time = formatted_time.split(" - ")[-1] if " - " in formatted_time else formatted_time + stops_schedule.append({ + "name": stop_name, + "time": just_time, + "effect": stop_effect + }) + + route_details = " â ".join(stops_list) + + return { + "departure_time": format_time(journey.get("departure_date_time", "")), + "arrival_time": format_time(journey.get("arrival_date_time", "")), + "base_departure_time": format_time(section.get("base_departure_date_time")), + "base_arrival_time": format_time(section.get("base_arrival_time")), + "delay_minutes": delay, + "delay_cause": delay_cause, + "duration_minutes": get_duration(journey), + "has_delay": delay > 0, + "canceled": is_canceled, + "route_details": route_details, + "stops_schedule": stops_schedule, + "direction": section.get("display_informations", {}).get("direction", ""), + "physical_mode": section.get("display_informations", {}).get("physical_mode", ""), + "train_num": get_train_num(journey), + } + + +class SncfAllTrainsLineSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): + """Sensor that aggregates all trains on a single line per attribute.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:train" + _attr_attribution = ATTRIBUTION + + def __init__(self, coordinator: SncfUpdateCoordinator, train_id: str) -> None: + super().__init__(coordinator) + self.tid = train_id + self._attr_name = "Tous les trains (ligne)" + self._attr_unique_id = f"{train_id}_all_trains_line" + self._attr_device_info = { + "identifiers": {(DOMAIN, train_id)}, + "name": "SNCF", + "manufacturer": "Master13011", + "model": "API", + "entry_type": DeviceEntryType.SERVICE, + } + + @callback + def _handle_coordinator_update(self) -> None: + journeys = self.coordinator.data.get(self.tid, []) + departure_times = [] + delays = [] + overall_has_delay = False + + for journey in journeys: + section = journey.get("sections", [{}])[0] + dep_dt = parse_datetime(journey.get("departure_date_time", "")) + base_dep_dt = parse_datetime(section.get("base_departure_date_time")) + delay = int((dep_dt - base_dep_dt).total_seconds() / 60) if dep_dt and base_dep_dt else 0 + + departure_times.append(format_time(journey.get("departure_date_time", ""))) + delays.append(str(delay)) + if delay > 0: overall_has_delay = True + + self._attr_extra_state_attributes = { + "departure_time": "; ".join(departure_times), + "delay_minutes": "; ".join(delays), + "has_delay": overall_has_delay, + } + self._attr_native_value = len(journeys) + self.async_write_ha_state() \ No newline at end of file diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..fe26c0f --- /dev/null +++ b/strings.json @@ -0,0 +1,85 @@ +{ + "config": { + "step": { + "user": { + "title": "Please set SNCF API Key" + } + }, + "error": { + "invalid_api_key": "API Key not found. Please retry." + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "description": "Set updated and outside intervals", + "data": { + "update_interval": "Update interval (minutes)", + "outside_interval": "Outside interval (minutes)" + } + } + } + }, + "config_subentries": { + "train": { + "initiate_flow": { + "user": "Add journey", + "reconfigure": "Reconfigure a journey" + }, + "entry_type": "Train", + "step": { + "departure_city": { + "title": "Select departure city", + "data": { + "departure_city": "City name" + } + }, + "departure_station": { + "title": "Select departure station", + "data": { + "departure_station": "Station name" + } + }, + "arrival_city": { + "title": "Select arrival city", + "data": { + "departure_city": "City name" + } + }, + "arrival_station": { + "title": "Select arrival station", + "data": { + "departure_station": "Station name" + } + }, + "time_range": { + "title": "Range hours", + "data": { + "time_start": "Time start", + "time_end": "Time end", + "train_count": "Trains count" + } + }, + "reconfigure": { + "title": "Range hours", + "data": { + "time_start": "Time start", + "time_end": "Time end", + "train_count": "Trains count" + } + } + }, + "error": { + "no_stations": "No station for this city" + }, + "abort": { + "already_configured_as_entry": "Already train is configured", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + } + } +} \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..43578bf --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from custom_components.sncf_trains.const import DOMAIN, CONF_API_KEY + + +@pytest.mark.asyncio +async def test_config_flow_happy_path(hass): + """Test config flow with valid API key and stations.""" + mock_api = AsyncMock() + mock_api.search_stations = AsyncMock( + side_effect=[ + [{"id": "stop_area:dep", "name": "Paris Gare de Lyon"}], # departure city + [{"id": "stop_area:arr", "name": "Lyon Part Dieu"}], # arrival city + ] + ) + + with patch( + "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api + ): + # Step 1: saisie API key + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "valid_key"} + ) + assert result["type"] == "form" + assert result["step_id"] == "departure_city" + + # Step 2: ville dĂ©part + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"departure_city": "Paris"} + ) + assert result["type"] == "form" + assert result["step_id"] == "departure_station" + + # Step 3: station dĂ©part + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"departure_station": "stop_area:dep"} + ) + assert result["type"] == "form" + assert result["step_id"] == "arrival_city" + + # Step 4: ville arrivĂ©e + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"arrival_city": "Lyon"} + ) + assert result["type"] == "form" + assert result["step_id"] == "arrival_station" + + # Step 5: station arrivĂ©e + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"arrival_station": "stop_area:arr"} + ) + assert result["type"] == "form" + assert result["step_id"] == "time_range" + + # Step 6: plage horaire + finalisation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"time_start": "07:00", "time_end": "10:00"} + ) + assert result["type"] == "create_entry" + assert result["title"] == "SNCF: Paris Gare de Lyon â Lyon Part Dieu" + assert result["data"]["departure_name"] == "Paris Gare de Lyon" + + +@pytest.mark.asyncio +async def test_config_flow_invalid_api_key(hass): + """Test config flow with invalid API key.""" + mock_api = AsyncMock() + mock_api.search_stations = AsyncMock(return_value=None) + + with patch( + "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bad_key"} + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "invalid_api_key" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..1e233f1 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import AsyncMock +from datetime import timedelta +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.sncf_trains.coordinator import SncfUpdateCoordinator + + +@pytest.mark.asyncio +async def test_coordinator_success(hass): + """Test coordinator fetches journeys successfully.""" + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="06:00", + time_end="09:00", + update_interval=5, + outside_interval=30, + ) + + data = await coordinator._async_update_data() + assert data == [{"id": "j1"}] + mock_api.fetch_journeys.assert_called_once() + assert isinstance(coordinator.update_interval, timedelta) + + +@pytest.mark.asyncio +async def test_coordinator_api_failure(hass): + """Test coordinator raises UpdateFailed when API fails.""" + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(side_effect=Exception("API error")) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="06:00", + time_end="09:00", + ) + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +@pytest.mark.asyncio +async def test_coordinator_adjust_interval(hass): + """Test that update interval adjusts inside and outside time range.""" + + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="00:00", + time_end="23:59", + update_interval=5, + outside_interval=30, + ) + + # Forcing inside time range (always true here) + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(minutes=5) + + # Fake outside range by setting opposite times + coordinator.time_start = "23:59" + coordinator.time_end = "00:00" + + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(minutes=30) diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..9b73d42 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,92 @@ +{ + "title": "Trains SNCF", + "config": { + "step": { + "user": { + "title": "ClĂ© API SNCF", + "description": "Entrez votre clĂ© API SNCF pour commencer.", + "data": { + "api_key": "ClĂ© API" + } + } + }, + "error": { + "invalid_api_key": "ClĂ© API invalide. Veuillez vĂ©rifier et rĂ©essayer." + }, + "abort": { + "reconfigure_successful": "Compose reconfigurĂ© avec succĂšs" + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "description": "DĂ©finissez les intervalles de mise Ă jour.", + "data": { + "update_interval": "Intervalle pendant la plage horaire (minutes)", + "outside_interval": "Intervalle en dehors de la plage horaire (minutes)" + } + } + } + }, + "config_subentries": { + "train": { + "initiate_flow": { + "user": "Ajouter un trajet", + "reconfigure": "Reconfigurer un trajet" + }, + "entry_type": "Train", + "step": { + "departure_city": { + "title": "Choisissez la ville de dĂ©part", + "data": { + "departure_city": "Nom de la ville" + } + }, + "departure_station": { + "title": "Choisissez la gare de dĂ©part", + "data": { + "departure_station": "Nom de la gare" + } + }, + "arrival_city": { + "title": "Choisissez la ville d'arrivĂ©e", + "data": { + "arrival_city": "Nom de la ville" + } + }, + "arrival_station": { + "title": "Choisissez la gare d'arrivĂ©e", + "data": { + "arrival_station": "Nom de la gare" + } + }, + "time_range": { + "title": "Choisissez la plage horaire", + "data": { + "time_start": "Heure de dĂ©part", + "time_end": "Heure d'arrivĂ©", + "train_count": "Nombre de trains", + "show_route_details": "Afficher les arrĂȘts intermĂ©diaires" + } + }, + "reconfigure": { + "title": "Choisissez la plage horaire", + "data": { + "time_start": "Heure de dĂ©part", + "time_end": "Heure d'arrivĂ©", + "train_count": "Nombre de trains", + "show_route_details": "Afficher les arrĂȘts intermĂ©diaires" + } + } + }, + "error": { + "no_stations": "Aucune gare trouvĂ©e pour cette ville." + }, + "abort": { + "already_configured_as_entry": "Ce trajet est dĂ©jĂ configurĂ©.", + "reconfigure_successful": "Trajet reconfigurĂ© avec succĂšs" + } + } + } +} \ No newline at end of file diff --git a/www/sncf-train-card.js b/www/sncf-train-card.js new file mode 100644 index 0000000..cf4ad81 --- /dev/null +++ b/www/sncf-train-card.js @@ -0,0 +1,568 @@ +// Ajouter au registre des cartes personnalisĂ©es +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'sncf-train-card', + name: 'SNCF Train Card', + preview: true, + description: 'Carte personnalisĂ©e animĂ©e pour afficher les trains SNCF avec radar de ligne.' +}); + +// --- CLASSE DE L'ĂDITEUR VISUEL --- +class SncfTrainCardEditor extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + setConfig(config) { + this._config = { ...config }; + this.render(); + } + + render() { + if (!this._config) return; + + this.shadowRoot.innerHTML = ` + ++ + + `; + + // Ăcouteurs d'Ă©vĂ©nements pour mettre Ă jour la config en direct + this.shadowRoot.querySelectorAll('input').forEach(input => { + input.addEventListener('change', this.valueChanged.bind(this)); + if (input.type === 'text' || input.type === 'number') { + input.addEventListener('input', this.valueChanged.bind(this)); // Pour mise Ă jour fluide + } + }); + } + + valueChanged(ev) { + if (!this._config || !this.hass) return; + const target = ev.target; + let value = target.type === 'checkbox' ? target.checked : target.value; + + if (target.type === 'number') { + value = Number(value); + } + + if (this._config[target.id] === value) return; + + this._config = { ...this._config, [target.id]: value }; + + const event = new CustomEvent('config-changed', { + detail: { config: this._config }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); + } +} + +customElements.define('sncf-train-card-editor', SncfTrainCardEditor); + + +// --- CLASSE DE LA CARTE PRINCIPALE --- +class SncfTrainCard extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.updateInterval = null; + this.lastTrainSignature = null; + this._lastRenderTime = 0; + } + + // Lier l'Ă©diteur Ă la carte + static getConfigElement() { + return document.createElement("sncf-train-card-editor"); + } + + static getStubConfig() { + return { + title: "Trains SNCF", + device_id: "", + train_lines: 3, + animation_duration: 30, + show_route_details: true + }; + } + + setConfig(config) { + if (!config.device_id) { + throw new Error('Vous devez dĂ©finir le device_id'); + } + + const previousDeviceId = this.config ? this.config.device_id : null; + const deviceIdChanged = previousDeviceId && previousDeviceId !== config.device_id; + + this.config = { + device_id: config.device_id, + train_lines: config.train_lines !== undefined ? config.train_lines : 3, + title: config.title || 'Trains SNCF', + train_emoji: config.train_emoji || 'đ ', + train_emoji_axial_symmetry: config.train_emoji_axial_symmetry !== false, + train_station_emoji: config.train_station_emoji || 'đ', + animation_duration: config.animation_duration || 30, + update_interval: config.update_interval || 30000, + show_route_details: config.show_route_details !== undefined ? config.show_route_details : false, + ...config + }; + + if (deviceIdChanged) { + this.stopUpdateTimer(); + this.startUpdateTimer(); + } + + this.render(); + } + + set hass(hass) { + const previousHass = this._hass; + this._hass = hass; + + if (this.config && previousHass) { + this.checkForTrainUpdates(previousHass, hass); + } else { + this.render(); + } + } + + async checkForTrainUpdates(previousHass, currentHass) { + try { + const currentTrains = await this.getTrainEntities(); + const currentSignature = this.createTrainSignature(currentTrains); + + if (currentSignature !== this.lastTrainSignature) { + this.lastTrainSignature = currentSignature; + this.render(); + } + } catch (error) { + this.render(); + } + } + + createTrainSignature(trains) { + return trains.map(train => + `${train.entity_id}:${train.attributes.departure_time}:${train.attributes.delay_minutes || 0}:${train.attributes.has_delay || false}` + ).join('|'); + } + + connectedCallback() { + this.startUpdateTimer(); + } + + disconnectedCallback() { + this.stopUpdateTimer(); + } + + startUpdateTimer() { + this.stopUpdateTimer(); + this.updateInterval = setInterval(async () => { + if (this._hass) { + this._lastRenderTime = 0; + await this.render(); + } + }, this.config.update_interval); + } + + stopUpdateTimer() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + async getTrainEntities() { + if (!this._hass) return []; + + try { + const allEntityRegistry = await this._hass.callWS({ + type: 'config/entity_registry/list' + }); + + const deviceEntities = allEntityRegistry.filter(entityInfo => + entityInfo.device_id === this.config.device_id + ); + + if (!deviceEntities || deviceEntities.length === 0) return []; + + const trainEntities = deviceEntities + .filter(entityInfo => entityInfo.entity_id.includes('train')) + .map(entityInfo => this._hass.states[entityInfo.entity_id]) + .filter(entity => entity && entity.attributes && entity.attributes.departure_time); + + const currentTime = new Date(); + const upcomingTrains = trainEntities.filter(entity => { + const departureTime = this.parseTime(entity.attributes.departure_time); + return departureTime >= currentTime; + }); + + return upcomingTrains + .sort((a, b) => this.parseTime(a.attributes.departure_time) - this.parseTime(b.attributes.departure_time)) + .slice(0, this.config.train_lines); + + } catch (error) { + return []; + } + } + + parseTime(departureTime) { + if (!departureTime) return new Date(0); + + if (departureTime.includes('/') && departureTime.includes(' - ')) { + const parts = departureTime.split(' - '); + if (parts.length === 2) { + const dateComponents = parts[0].split('/'); + if (dateComponents.length === 3) { + const day = parseInt(dateComponents[0]); + const month = parseInt(dateComponents[1]) - 1; + const year = parseInt(dateComponents[2]); + + const timeComponents = parts[1].split(':'); + if (timeComponents.length === 2) { + const hour = parseInt(timeComponents[0]); + const minute = parseInt(timeComponents[1]); + return new Date(year, month, day, hour, minute); + } + } + } + } + return new Date(departureTime); + } + + calculateTrainPosition(departureTime, currentTime) { + if (!departureTime) return -10; + const departure = this.parseTime(departureTime); + if (isNaN(departure.getTime())) return -10; + + const now = currentTime || new Date(); + const diffMinutes = (departure - now) / (1000 * 60); + const maxMinutes = this.config.animation_duration; + + if (diffMinutes > maxMinutes) return -10; + if (diffMinutes <= 0) return 100; + + return ((maxMinutes - diffMinutes) / maxMinutes) * 100; + } + + formatTime(timeString) { + if (!timeString) return 'N/A'; + const time = this.parseTime(timeString); + if (isNaN(time.getTime())) return 'Format invalide'; + return time.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + } + + calculateRealArrivalTime(departureTime, delayMinutes) { + if (!departureTime || !delayMinutes || delayMinutes === 0) return null; + const originalTime = this.parseTime(departureTime); + const realTime = new Date(originalTime.getTime() + (delayMinutes * 60000)); + return realTime.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + } + + getTrainColor(delayMinutes, hasDelay) { + if (!hasDelay || delayMinutes === 0) return '#4caf50'; + return '#f44336'; + } + + async render() { + if (!this._hass || !this.config) return; + + const now = Date.now(); + if (now - this._lastRenderTime < 1000) return; + this._lastRenderTime = now; + + const trains = await this.getTrainEntities(); + + if (trains.length === 0) { + this.shadowRoot.innerHTML = ` ++ + ++ ++ + ++ +++ ++ + +++ + ++++ ++ + +++ + +++ + ++ ++ + +++ `; + return; + } + + const currentTime = new Date(); + const trainLinesHTML = this.renderTrainLines(trains, currentTime); + + this.shadowRoot.innerHTML = ` + + + Aucun train trouvĂ© pour ce device_id.+ + `; + } + + renderTrainLines(trains, currentTime) { + return trains.map((train) => { + const position = this.calculateTrainPosition(train.attributes.departure_time, currentTime); + const delayMinutes = train.attributes.delay_minutes || 0; + const hasDelay = train.attributes.has_delay || false; + const trainColor = this.getTrainColor(delayMinutes, hasDelay); + const formattedTime = this.formatTime(train.attributes.departure_time); + const realArrivalTime = this.calculateRealArrivalTime(train.attributes.departure_time, delayMinutes); + + // Extraction du plan de vol (tableau) + const stopsSchedule = train.attributes.stops_schedule || []; + + // Construction de la barre graphique "MĂ©tro" + let timelineHTML = ''; + if (this.config.show_route_details && stopsSchedule.length > 0) { + const stopsHTML = stopsSchedule.map(stop => ` ++++ ${trainLinesHTML} +${this.config.title}+ ++ `).join(''); + + timelineHTML = ` +${stop.time}+${stop.name}++ ++ `; + } + + return ` ++ ${stopsHTML} ++++ `; + }).join(''); + } +} + +customElements.define('sncf-train-card', SncfTrainCard); \ No newline at end of file From 8575c75cbe24cd3370c4f24e9725a84715aa2e0f Mon Sep 17 00:00:00 2001 From: ProBreizh35 <75017525+ProBreizh35@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:08:20 +0200 Subject: [PATCH 05/14] alpha --- .github/workflows/lint.yml | 42 ------------------- .github/workflows/release.yaml | 35 ---------------- .github/workflows/validate-for-hacs.yml | 19 --------- .github/workflows/validate-with-hassfest.yml | 16 ------- __pycache__/__init__.cpython-314.pyc | Bin 6572 -> 0 bytes __pycache__/api.cpython-314.pyc | Bin 8535 -> 0 bytes __pycache__/calendar.cpython-314.pyc | Bin 10122 -> 0 bytes __pycache__/config_flow.cpython-314.pyc | Bin 17179 -> 0 bytes __pycache__/const.cpython-314.pyc | Bin 1068 -> 0 bytes __pycache__/coordinator.cpython-314.pyc | Bin 9611 -> 0 bytes __pycache__/diagnostics.cpython-314.pyc | Bin 2247 -> 0 bytes __pycache__/helpers.cpython-314.pyc | Bin 2999 -> 0 bytes __pycache__/sensor.cpython-314.pyc | Bin 14235 -> 0 bytes 13 files changed, 112 deletions(-) delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/release.yaml delete mode 100644 .github/workflows/validate-for-hacs.yml delete mode 100644 .github/workflows/validate-with-hassfest.yml delete mode 100644 __pycache__/__init__.cpython-314.pyc delete mode 100644 __pycache__/api.cpython-314.pyc delete mode 100644 __pycache__/calendar.cpython-314.pyc delete mode 100644 __pycache__/config_flow.cpython-314.pyc delete mode 100644 __pycache__/const.cpython-314.pyc delete mode 100644 __pycache__/coordinator.cpython-314.pyc delete mode 100644 __pycache__/diagnostics.cpython-314.pyc delete mode 100644 __pycache__/helpers.cpython-314.pyc delete mode 100644 __pycache__/sensor.cpython-314.pyc diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e6f56e6..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: "Lint" - -on: - push: - branches: - - "main" - pull_request: - branches: - - "main" - -jobs: - ruff: - name: "Ruff" - runs-on: "ubuntu-latest" - steps: - - name: "Checkout the repository" - uses: "actions/checkout@v6.0.1" - - - name: "Set up Python" - uses: actions/setup-python@v6.1.0 - with: - python-version: "3.13" - cache: "pip" - - - name: "Install requirements" - run: python3 -m pip install -r requirements.txt types-pytz - - - name: "Lint" - run: python3 -m ruff check . - - - name: "Format" - run: python3 -m ruff format . - - - name: "Run Black" - run: black --check . - - - name: "Run Mypy" - run: | - mypy . || echo "Mypy finished with errors but continuing" - - - name: "Pylint" - run: pylint custom_components || true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 2da9b2f..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: "Release" - -on: - release: - types: - - "published" - -permissions: {} - -jobs: - release: - name: "Release" - runs-on: "ubuntu-latest" - permissions: - contents: write - steps: - - name: "Checkout the repository" - uses: "actions/checkout@v6.0.1" - - - name: "Adjust version number" - shell: "bash" - run: | - yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ - "${{ github.workspace }}/custom_components/sncf_trains/manifest.json" - - - name: "ZIP the integration directory" - shell: "bash" - run: | - cd "${{ github.workspace }}/custom_components/sncf_trains" - zip sncf_trains_ha.zip -r ./ - - - name: "Upload the ZIP file to the release" - uses: softprops/action-gh-release@v2.5.0 - with: - files: ${{ github.workspace }}/custom_components/sncf_trains/sncf_trains_ha.zip diff --git a/.github/workflows/validate-for-hacs.yml b/.github/workflows/validate-for-hacs.yml deleted file mode 100644 index 4d9e6d6..0000000 --- a/.github/workflows/validate-for-hacs.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Validate for HACS - -on: - push: - branches: - - "main" - pull_request: - branches: - - "main" - -jobs: - validate-hacs: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v6" - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" diff --git a/.github/workflows/validate-with-hassfest.yml b/.github/workflows/validate-with-hassfest.yml deleted file mode 100644 index 2770d1a..0000000 --- a/.github/workflows/validate-with-hassfest.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Validate with hassfest - -on: - push: - branches: - - "main" - pull_request: - branches: - - "main" - -jobs: - validate-hassfest: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v6" - - uses: "home-assistant/actions/hassfest@master" diff --git a/__pycache__/__init__.cpython-314.pyc b/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0b57dba9d495ecef31c48dc17ebc583847a57077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6572 zcmcH-?{8Dr^*;OAetvcwCnmoW;t-%1h|@x#A%PH=B-oGy_a(TUYWnKh4`Og^@4lBp zqD_k0X-c(Cg{cx$Lu$IA6|_p@LqGJxq_tB2fVfU&_bN++ + ${timelineHTML} + ++ ${position >= 0 && position <= 100 ? ` ++ ++ ${this.config.train_emoji} ++ ` : ''} +++${this.config.train_station_emoji}++++ ${hasDelay && realArrivalTime ? ` ++${formattedTime}+${realArrivalTime}+ ` : `${formattedTime}`} ++ ${hasDelay ? `+${delayMinutes}min` : 'Ă l\'heure'} ++0gkoqBN$pKY>wOYwNJ1L- zB9gd}DQMQrod|GKh!0vc3&YJJYtW|If`TTnHXpJFMNMS5CFBS?H7CQZp_-seb1~c& zstvj|H^YTcU9evB0M1GF(6(TM)(~ve8iP$*Q?OZU4z_45!B(v`*rv4w+qL#!ht|Pp z#nAR(r`FktB!}dbY9v<^N^u9xha8&soHP84aj;4ay(5*Df%#2Z_c=JzC9V^l^Mr#& zf2Hn1=Q{e}R%p?7NwwPUb7ts!!srW!^|5Lwx%WFNZK v<>hE zhVPRa0dHdXeyJJo7KZmot$?>N+%Gx1k*{N+ZF)xWPEDQ|^G@SfGVM*KHRS@1X~|65 z@8bv`j%hPcyC-7VY%+Zzj5Al}r{}YZ&qN$2GU@Zl3!`Zb&l8v4n3{W8p>6o3CYYI3 z0;-x+wOCq%4k4V1Y3DO|md+PTBxL1^hGX(YRi48s+Bc=ax^cRqJ{k1Y#n~G2E3s5E z!FJ`d6X%IDlVfrq9Fk9u{*2V=ElCPR0^{;zU}BVbSl7sCI3P`*l}7bWJL?>i&P `FP2 z$>2mX9n&(Hcxd-XOp8_B4v>FC86T&hBmu|-xEa0&hXE|22zt@f|0{$N(ASQh ?+Dr{8-9wgIhuPi6iNlljh*?HVRXQP0wrg{J@q5?X$f{xuBTz%r`r)?B{0NrSz zbKN9#!X7qZPdtVb{);LxU%q^Kp?>qgy1TH#RO~K}vb8qfg;aGLlBseZk~vC`&PuNt zZiL6p&>;LwOjU_hKRK~tMVrHEpBdAI2%kvCHNwA~$xyMgR^Jw;Cnio=j-}HX5JyFp z@lNRKhEMGRu!tTZE9d@}f}0cfY4v^|tNu2^I&m9N#mfwXh5+Ad2=Ncz2V@88ggGMU zF}jYz^a?hVsKw}r(!H}1wU;qwh-u3(A`&N=o-WPf=ZxNyNHSl9-YeXqdE6qx9-=kI z91&ERxv#WuFot|LvZ3+DL*^vsv&>PxOevaoE?Z?RRBu_9yk9K7>BY(gP<91-HLxk; znOBrlEUtK&AdJC9*TiG-8AYB^VhIJSlhZyMuu0Rhq%Ee-r{l8my9@>c?0}pFL!e@M zRwA4TNF(xDX`Gl<4HG+Sj)lfYF}+-n|LsuWP5>%B!S3BUA07I{DsVrG_rpLprJ$zy z8(_lcqU#Oc>%M~6n-hE2>@7F<+&b}|{ZEr0?pn16SA@_5v&mt7giIFeeG0y_B1^;7 z>6`tl{LarDwcpyI?|an^lf8N@(9Hdv3pDZ{p(a3o8qvO^i#9tdu`nx(y|DTnU8;4Z z-2ojw4zqJI^=B8sM3@132`U|>a%`t^4B%GJeOj&dpVsT}H0$0h$y63!qmoRGm@dgt zx(X}Lafb9{O%c=MWVx?I3DU@|m&WJ1(q)Z_aA8}Cu&RRIveZ|)qnqU4koZojW6lbt zfrV=gtWrm~C}krn#u+y6Q8F=&cpkJuU|L~b*s)0^yfLa2)Gc}$hx{Psc4n9f3{ff9 zM`boDpHE^{1HnI!6?KL<@myL<&MGqalo+Yi=`(40E}hE662wD&<{Wc4a*8TiTEV2= z&>c#m&q`cXYa|twwCXfq=PrX$E-lTJLM8U_cwl<$j5IL?){$vZFWduh#49dMHABzt z-CkX%>h#Ij@A_1D5XPBcT>^w^)|d59+~)K3eT%1yVnabZoD&b{#r^`{zryzyTXwGU zji2-O0^gG3TW;(sv>wd09$f7k%eS6f<%31hRS-LJVn;#j&x!pXG<C_wsTKaz zKSAwkkmbm?0 Dhv>)W|$8n%X*oka!k3SXksx(E(;$C&9N+zss!a4>9@}5S0z9i5sNc9Msu7SmN^F ziq7Q0%{- YI8fjRR{4Pv zcYS@_U$_JOd$ztUxcmRchyR<_K0}Yx4S;H`pL$6Ew; $Bz+nxav`9 z?4dH3u^Yo+Ks0E|@)7=t66q+HfW=^)$InCcloey1tYr&INFHpUv~EFJ@z14rBWr_G zKJFCto8}a@prp?wR?7RdZ$a6(iBjBxl0H6IDbMMM(gy)6(emCRLbSYk#2zir9}%PF z86u8o`6eTlX!))p)=IPzvGszg=(A^?5b?B^ma3}4G%VgwB3#52F-vBNmn=`0UKsEu z=rYJ8YeaM+$rf<{6e1RY_J|drc)%WUf@D(9yP?3S x1cCRGGEdvO zEmQqt)5KBr=v{EIq=(AhG(H|4hBWZ9m8gk*E}i`OoFXR^#G)m&l(HZI{Y6C^@Xpao z^M3K`-+LEK-aQLuZ;zL7J|f5yp_5WzdK7-3&uYTSJ_|k#w3xm+VDVCZgw9!0ZfKBD z!KZ-0=fRW*j2MR~Mn#;65N#!t^Rc; @dN3>WJgmuz3#T#J{B4ei%oxc0)W&V0jDOJdQ}dVTQP z;LTs=Jv~c8v9aU&rE8Z~cO1w!9$a!1-P^7U*Myrbd3X1crRb`EW9IdlH!tU1olEAT zr|EkCwf@!i-n{2Y_|s_i zjVo(z@AAI;wL*>Sfmv)3mul`eBYRCjXv+z01;L*a{6%M7v96)Gt*zMDU99iC??Cq6 z?~v6le&<5lb`-a@-+2DUsb%GM@b zfZFFri@;gt_Bu7samsh5g%+ly`9w};*u zT4~*Td(TSq!D4Gyv1R)W+l`+udzLNB&n_=4r|x#$4c;ACX&m~_VcjM!3HMza_HcV{ zRX7f@`S&UnzgGkM#`bU@`9}tzS{Hj$cSgnXK?K0#Vzge!pGIL!FU+Aq7OEXp@;#Mk zwmK6wXp*?y@Dt>JOnMYY|Mi(kn@rzZi6o6=Vksl30}WM{2~QtUn7%cCD6pf}Ri!4B zrogsO!;T#09ud(C}~j-V}Z}ltaV+ej0H*SRCMc62A 2BGl{vBGKA+~gbBqT0Qt{-GskiNKwV#<=6h(z*J$J(+J6rX+(SbQ z9(lxzoaqq)@Q{H<#Pxp7QmLPJ?0Q>%OTIl*@E_0lkFT^3FWU2Hxah7gxDR~dK5+MN z-aW8b^SNXHqVSp1eYN>FlLcp2&e^p*`l-{mvVZtfC;aiy3K__U^^m@K*i2uP4@G!) LJQ5*LWdiwMe#Bv? diff --git a/__pycache__/api.cpython-314.pyc b/__pycache__/api.cpython-314.pyc deleted file mode 100644 index 3d213878ad9b614c670e3371e658ecc288a4da97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8535 zcmb_hYit|Yb-pvhd2mR|dP~;B8cCLIu558_$uG$p#nyXmN%UBvV_3TcO^q$u6v>?# zt}Oupgxv!50zoSOXxC{2)GZR!MzcurZv!MjY(2Jr3Q#KtQYQ|wMS}wVCtKS(P4c7X z+~H6ZZLfEeT!QDGxvx3*+;i{w?wQsipG+Wmul#rHJEesD7d|-2m1Q>n9+)}uHc^=& zV%j-lGVsg}v4fn+4Z2L%AaC*wSjP +P8BEz3x;9hrfHU-hSsY?hdGiWpmxs2` zl136@f_#37&39yroFxy6FK+`HVrQHB64B(OrkGQjaxyrifSwf4igGGq#G(o=R-46i z`dyZADPm|xIxP34BOMy;V+l?^so@xYR!0?BZZmmJON2qi21>q1(TlOzYqt*5S{?O7 z6=tvOXJs|>{@VxVeRD&PJSA(M+D|>Tw};m1_OI0KU#)9f^#s;DM^-#XRz1fuu46iE zEfR%)E}@ZN+BAVgK#^||lTnGuzFlf^l_W`2Pl&XGxq$6UDvLWH&X_LMOG`Og>dKb# z*;1aC!j7SB1= {tM)s2%SI<^=O% zOFqY3gd+ebqjEsm00+ambPsf~M8n>LwuVL1-hzHk$5HhnY{JD7CM>}Yc4D?j0Ac0WoB?PWj< zb`nYhe`)h;z|4{J#R;c|Okf>NA=a_Z5J}{0`~2&PTnm-J%$X6eIw|Z-91X@2Z0mty zM*}X)WoYrs`Yx!Z%F!EuGf=2KkZjYF!r@pV2AZ%c3ny-u(@9rDpE m b@=a4NGqZ3LI1Xf zMIsZ&@~?me)sYzVt%n_hKh@Vvy1050V-zw0I NZV$?5b1^2CmGm$dWBiq~GT@|CIHGHj z&e{6Fy9WV4y{qYwD;fn%fn2Q-nDqoEwNyk0{G}PQ9$J%1g2(Ed-Sv`=v>JLIrj28h z$~3?-#V}zI<$BDVQugM?@7${ZWU-&^QTJL!lMyo#z6`IMN`UXQB#<(WUc=&}$#lZB zd ps2RTA3?j>CInSFfJ7x8n~s^vzK%o3{Yo;Sv>5g*T8!D+b0a !G+KpSe4c2i5v(_8z9W?oO~*Axvgw{lrCqf>yem8~YqWj#5qIOBD~TV~6~)icQK zSAujF$onfTSUb?9fts`=5Hft~ }I*H8p(u? zr!>HHV VH4iyUPaVHu74om!)%#jU& zc+1v2^(&tG+wnC|>!+U9M-}z+Z)SFNeLV2-@n4+Ilm 3GIF4x{?&Z(m*amd#(>;5om1Q{IrsiGF6S z>-0+3>4#mz4|k8ObPfNw<3|VYoc#fJ_vF6{|9k-oZVD^AM?NcSx^wyN*bgt>yLfjv zv%51>+I3Uha8 }TwPq|+KDMpAy!OM ^~Ve#Je`o@2}@SO|)a1o@LccElAZr!-! zy?bf3>fp0xM}Vg&!2fM}NXsbmL JJ+twELs+5iIL_T?0p%pC1Hr zKTy?oTzDXq1OK45yT6Gn)G&SB(n2kV{H~LS`gwk-nCWMvrIHHd_xPY QPHwjM!t$gx)XaX*R#-TxdC6Z+*CWz!7abB ze;~kp;uoO&lK>ATpLB4*Z@XupZKFHg{05L$f>H=B02n2z3n-)VK=JnR#Q>EDWG-ay zBzV>!K!pPnGcW8h4L3Vp{q#d10Y$6&0pMN@OHV;71FsDfmNp jFtrjiW5d*~H~~OT0And0;it-mSd|NLDu6 YazNMYZ%|01w2cO9Il zb7m668*M?Nu#dv(ly5?DDuc}qx0S19W4 dr8vreZa=;Zpx$@bX=TX>IKr;z# z2Ani*bc;2UmgH=4dwXN-Mn4F|Dsw8}qM8Dai%~Pno%5!8JGR8d(GMdz3S>m@M79gb z2_Un3&ZYr)DcX&wrcENK0;r^nC6mf
`GIwB@qEj=xAL|Z0mk^cw+^93o;t-@lR3wcF^&Wn2) V0$mY0yG^wDR{{vefeiUE ztbE=M!`pCyhyAP1P)Gn#O}h6)cnOVN>21Kh8Y4uZm4UYqiV?~YBLKPAzJb|~kNu?g ze)-Sd{AK$9Ew+&W3$2}moB4f20)GdkFF^vVjRaH Ug7y&<9vbV?+dx|Jby0e zl;`<{lnd|%APn%&FZcZ7SMvO9NDK+D^!!3DI3#{ya7fAzb)=+_6wHO4993@f#8NQl z`Jb-`PrQ9R3^u(0y{PU2PYhvpRmuy%Y#Svn0K=zT)DwpaJh5~YZmC}?03+oEV9K2t zOjJ5t5U+X#DbEwj6FKi4%muKvnyd1dyv-9!DY;u#;hiew3OuoAw)%NboQg-Hnqe{T z%nIgAO1t )QZsz+{J&nRo%YnOJ(%lOltC;Bl?^VJfU%tEN0RP|^)8m&O z9IpU=p~wg23$;v7tFTb#LS7M}@4{XNc|U_~S}A`(=oQ@y?M%;+qJ<6_`EC~IB7?ma zc@}ytiujgAxwcQ>7F#O&7;dpkfbu1Vhms|M10KGBp+BIpC>o?1@iUJx C)07{;q&b~=}0_Vvm+e79McUm9)s__N%)?-4+kAX5 7LSL+e#ZX%0(M8fZhv({Sg#|&puG_Q^1Ht#@PUnm zk7`fx%i1#;zOg*TFEP)?7`Vqi#oO<*Qw%t^|3&Xcy%` v6;N+GSn6u=B574mC8ss`HV9wqBMGw&;WqtbZ^!U#V2c>pazmu= z47>w^X45!mFA_ZKs2KN%EfQV|E$M`<*stj`@QCv^dVp+j48#12_ %vYow~h8_MAxrE{7 z;ZlE@T*h$Au+v{Imjll6*5L|&rCiBy+px=DC0F^YfRs!yl(?^7Q{_GDYDp|xhhGU4en?4ammy=$SpZlZjmeouY2D?BF5 z8nxWTbM3@iKVN@Bh>EeWK;2Vu>XxYxiAnC6c#=xKQ7@-h!-6b?qJkuOb&3UEL^(1m zDprJHF)9mCU8)T+6q6(JoKli|e?yGPKv3e3&x!+*6p>^hCM%YZ5RFa>p(`+c*`WAF zBqTylI(L39AwpI2KsbCN9-E3xvsyN2M3i7qtyB4lc$|hKF+q+~);tH@D$m72Q-d(u zZGF84rToO%(UZXeo*xbjoC%H&j0`E2tZHy*Y=A$1fge&UoviXaKQKHRJaP8I=y}C7 zcy ZY1~o2)vU|_?kNfy{@V`|JppCSE zgpacZ1T)-0eT2Km;m0aR `F`-FjD%sM^pZ^BVFG!Z6=VsAZKDlH;0OkVSS&8%%z_k$uY-T762Jm^MmVl{ z6Tv5X_Sk}I62g(Sg7~}e{{uXx$bJf@2WCZablM6)X_aY30x3l0MHR>V+XB9ICUkkXK2j8(C@E4Frr$#d&3cX2z@I zfc!bU;`QUmXI6LvZ|pGu?@Ww$ieUyU6fh6Xh$iAfI2?qEhz!O 059nIScy8MQdd-peK)ra~3t0(j0cY4w(0F(a^PT5J*_}+d z IBL<2w(^SDxLmNfESA3<9f0!Nxdf68JUCESR7;&W)ZpsSV*Z z(7iB8>O;^2K(UANMp(j)0ikfK8dR>@7ztROz%8J26uHRQ*#QWem< F`I9sS_0t_=jWDX()-KE{KzM8}# zUXTp|asXHazSJTcdA)235ZTP5uo#t6T+ xQpb7j^{do25=5=T^82?uHS|mw$T_7&r7luBsmDSN0?KL>Z39&f?omY zRXt0PIpA`E(#RV^lsOx<%9?XTUcF+NjK`yjJ_5`~Ap(Qa4iBXy7-Y^o90|$6ATxVy z3vAPvJy9A8`QzJekWsIt69E47OY#ocv=FE3?X#O!ur-@D;wXFjl}$V14pQHiGPG`% z;7ci~b*Bt1n`QXoB+kmWN53rxoT r9$5kdA0+b?Pi!B2L7P~;cG_*f6!nEHdY~qZfKrV3zEL*BE&PipztVfCD zbts9j$p)otWKPp5CWrH$0XLhMTaLTKq7;G|i*CWaLmVkGeiY2zAUx?1f|CeNA@C!3 z1p!j2Q$&YKM>K%yC!zIuk9=e3Q7N#oU=ez#z_N80fItM0^V~v?F!ci4qb?@Efr{x- z#h9`g5QC7`G+|~fMttW@0G+wO)iPmaZD!VHDQL5?7AtG9VT){MHFj3xU^NaFK$q~P za%q5*%L3#GCp&?qWfVU#@8QJ+6(!JY*!p @T(9K$J}Vmi+v+~V5Z0Rt7+ zZk|K!=0S^@$2O=Q`ykL$v_zw{9AH+-RG~skRiIjR+s$c#VjTxk31hH7p`WoEZJP*E z9WQR+^Ff%svOOz^cF=plmN~Y*1_Y;_kGjt30_10~-2qN>8{jCo0Eub#6P5yw#{?aB zlrePAJBow!!gw(hA6CE-qx)p#r;l<#w~s@&&6{{LZ{e-HZM;+)Wz4Bz`Dc$2zG>Co z*=fZ-UpE{Be=bBL^CBn !-M(QH>lVnVc7V{R79kbVnBPM7;q;P$E*-b zP6;72tW>d&2$C$){Rj5#->(?K42fVp2gZgK8}z1gK^e~`HpM6<6F?0%D*_V%t76Iq zJ?Iw|i%J^_D`m{~1e3AI>q#*f2~%7k#nmY0b3=S!_{0#HF0@Zz>+t{t<4wgOIAK91 z%9Ucxnrg6SsF ^m*UQT&lUTr+S+AxrD zR4kp$IO>;2GLFin3mHf4hdt@~{i*u>8+KEhZPD<1OS3ARXaqGwh*QS@Eo96O>(m|$ z$g%~G3-_||FjFkB(P*cBP_^i*Krssc5^|3zk DVQSN{4Sn_nJfegB`F1gaKo`WCccT0863|d zvFU7pfdQ9QcteOpg~_N$FTqInIJW=HA*lg23%Qkwg575E2{I)c`EQuO6W@62##@VT zJ*upEQdW86+VyLzmiqj9wN1fUvrWMR%=0#d*Wo76DIj6) zSv2>RH`pHQi&GV|mpnw9#YQY EI|>g`Yf;*q<;{2C&Cj9D+1ETRv#L28 z5B)0hZ5pQW9(;xcq}n>~^$W3ZR0Ije)JE71P;gJt_^c}D`E!68HEZLbd%@wi765>I z%54~Ywul0a Scc+OoFV{U6sWcF-hr{?}pIWe7OlINMBH7a^x5)O08zVMghfg102auHp_SH;yY6&-+;gYrfor8_t?k%<*8W%He>DEKdA;rI!{#xj zHZTkQ6>*LphCcKN07%DaX92~WJpm{tNIgx8bQIiES#U~}Lo>n4kiUzGbCRM5gTqWq zehhjPM|%muF&wf{bwgmGsK5eeI1c9GX2qo*<*w2Qjv z;Fuo*fU}{AIGkyFOUmAowzvMy-uk${DdTjdogFD>$C|VAk!$R!QQuIyIQahQ4Lpb3 zxOU^(; 4R;#St^KLi{*0?(`TXsRw=Slee5od1Cf|BI)p|VRYFd_WU%PcJ z-EuV5ax~*=Tn^v9a_dUE`9P}q0Jye;6-$PP&ixyu#9ETJw5BYrzqhpKt(hjw=+l`^ zMIZ7!o2mwncqruc3>pCH;9tKzJIfLpMm3>f1dc*R!^Q-Z9r7@`2s4a@oNypVzp09> zAquHgHCv<}^oqxz%u!B5FR2#*v(`3a=OzL0sH$y4kI#LF)aT$C_WaUXa(#B)+PG58+f`I zm=2u>0PJO<*xOj~4gy#v(efPG?3Ytj4om_wrz|N=1;W0wnX$eD#$gGN(wy7QR8}nx zgX2z@wWrG3*UCJr7Ed+-;^`_(zUIX3Bn 6r+rp0G ziUmvy!!${2gO4dj$mT$x@-pD`IDj7y3?!hEFpOiOR$=sR5&(}{J2v$A+;@OIzqFQK zmv5fC)xP-Fy0v-J2u {h@8y}2ICb~`y!R{jyxqOm%@3U);0N8^^9FD=UQins zG%I@02__^;i46JtXb?t?$HJ1=%G63RXFU|X4%GB-v5N_XE)32%I|0S6eIII7?`M{h zv$F!7lTZsF-Pq~IqUIc{gQ~WHVuzAZyg^X2XYB A%643j{K`748-D$M!q+ z)jcnNTCwIHTq_@1G-WF4KG=P8_wwbty>~m-Dthj7>lM9==Ev0?E3bWg@y^B7{llO2 zu6ah+s?RRkpMg)RzP N!X@-EI*lfmg(Odzw@j2AF@VJ25G>)k}R>m!%9A<{+zKYG|KH` zX}X>;kE5isGKTj_I||Re7U;`bn-&t#L&C+(5hpqM6(OK~mN9fMI|^i%o^Oe) 1BtV#oL=5<+Wb~Oxl{R!rJJ6CrSf0&9bQn @Aru zJBb0fTMNK_^A?Zf-h0gLkw)o1z&F(B`|lCM5F8?WGuTZ`Q72K*2QaJu+k4_+_q=V7 z8}6(G8V20r&Jrm)xI(qlf5QF=0E!-hLdCA;jT30M6$>6a5TnT3=f?fsD$3GK20Y9u zMhNd`C3d?5F=uiT6IpO=M)3qmOAuURUxRyOimC(&_%mluof_itl43GBO_z`c6@q?% z%_boM!B$vJKpU_cLkQ*(XeD5fwMF??%CtC43!wBeWX~)b`_${EypJdeM BegaEJB+4R=C-ses zWtpbdbkp8c)82GbZ>p&`-E=6`bST|)`1;7=iHxf@?dnLmI?}FPDc7#F>tM=t@P26B zb$D?oQ`7jt{LT4?Z3owD`WOAV*8}S{eT)9bjV=G8U-5rbayR*#`FryZ55KbBb9$}u z%;LynN9B!?>m$p%R%RYLUd%MKuZ*rttTh~1GH2>rZ|}afd*$-|-uoSE^)Ec&*6R;1 znIG46-8KKle$T$zhyJL0bgk~}l4)D*sn06by3efDjVzfmd-kRGoJ#FE1qY>-H@Eb< zy%i~^cgeca1^&xbf3yGg)UBzEt6g2s
s9cgEfMN!^3}54|s~yI;!e>boDf z?|<;hr^!#JKl{to?!aSD_ubl$CNl0W^r3eL*WCLvojsYhuFSsvjIZx;r}vY+56o-3 zj<0nNJgsrm)&EgnT2=kDk~H?gl~MDNjbp^wvYJgPLO3yrI|}xL(4Qf|G?}-Y4kA8; z0OR2NEePBpv7{>Ic`3`3S`rlEek#wgR2s$92Ym~{dk9cinH;FsC#T^xcSn}JB+}6k z5)A-g_hrYkS4HR&+0_Fc%q9HRcX(Cw$k6koyl&x@X9mdKZ(#y|qn?;bpXneQxupke z12ORXSO;mcY#m_F+Te(_frqhYwGabuV66PCyUp10EdjusJo}!voBH8via8peo`&-b zsK53b9ne^C%<7?#qS`5~Gw{ &W=jOPGZQ6o3RzSrg#cl zXT&J*Rq|!?*HoOwGw7U} TQV?ie{KH+lg*o zF+xZQpJe8V87kCtHPeo!jiUcn%-MM|v!e#5RtQed0PjSq%F#zi+=u}E0o{$D9|5}! z96^k2_6fw$-KY_#<*0gFd5r!$)Pa#8(j5RBdXD2B5%*W5^mEes1?m1@a{3F>|2Y}> zoQ!==PJV-x=hn!%&x!8~()+|#p0+jo&epK(U$c1@%-@>ew;TUw$piMI0vFNQpXtn8 z