From 97f3d832936830d52f02dd571d56ecabd0fc17d9 Mon Sep 17 00:00:00 2001 From: Joaquin Rodriguez Date: Sat, 14 Feb 2026 17:54:30 -0500 Subject: [PATCH] Add PAA Live Monitor Real-time Polar Alignment Assistant status from Ekos/KStars logs. Watches KStars log files and streams updates via WebSocket. Features: - DMS display format (degrees, arcminutes, arcseconds) matching Ekos - Parses total error from log; uses computed value as fallback - Direction arrows for azimuth and altitude adjustments - Age counter and heartbeat for live connection status - Tail-only parsing when clients connect (avoids replaying old data) - Error messages for files not found, or no update matches - Compative with native and flatpak installs - Auto-detect last session log file - Accuracy target with color coding (green/yellow/red) --- README.md | 26 +++ img/paa-monitor-mobile.jpeg | Bin 0 -> 49152 bytes indiweb/main.py | 30 ++- indiweb/paa_monitor.py | 385 ++++++++++++++++++++++++++++++++++++ indiweb/routes.py | 7 +- indiweb/state.py | 6 +- indiweb/views/form.tpl | 1 + indiweb/views/paa.tpl | 308 +++++++++++++++++++++++++++++ tests/test_paa_monitor.py | 352 +++++++++++++++++++++++++++++++++ 9 files changed, 1110 insertions(+), 5 deletions(-) create mode 100644 img/paa-monitor-mobile.jpeg create mode 100644 indiweb/paa_monitor.py create mode 100644 indiweb/views/paa.tpl create mode 100644 tests/test_paa_monitor.py diff --git a/README.md b/README.md index 847e350..0f8cb23 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,32 @@ Then using your favorite web browser, go to running locally. If the INDI Web Manager is installed on a remote system, simply replace localhost with the hostname or IP address of the remote system. +# PAA Live Monitor + +The PAA (Polar Alignment Assistant) live monitor is an optional feature that +shows **live** polar alignment error from Ekos/KStars. It displays total, +altitude, and azimuth error in degrees-minutes-seconds (DMS) and arcseconds, +with direction arrows; values update as Ekos writes PAA Refresh lines to its +log file. + +PAA Live Monitor on mobile + +To enable the PAA monitor, start INDI Web Manager with `--with-paa`. You may +optionally pass one or more log directories with `--kstars-logs DIR [DIR ...]`. +If `--kstars-logs` is omitted, the application searches (in order): +`~/.local/share/kstars/logs` (native KStars install) and +`~/.var/app/org.kde.kstars/data/kstars/logs` (Flatpak). Example: +`indi-web --with-paa` or `indi-web --with-paa --kstars-logs /path/to/kstars/logs`. + +**Ekos configuration (required):** In Ekos, enable **Log to file** so that PAA +Refresh lines are written to the KStars log. Run the **Polar Alignment +Assistant** in Ekos so the log contains PAA data. Without Log to file enabled, +the monitor will report that no PAA data was found and prompt you to enable it. + +Open the PAA page in the app at [http://localhost:8624/paa](http://localhost:8624/paa) +(when using the default port). Status is also available via REST at +`GET /api/paa/status` and live updates via WebSocket at `/ws/paa` for integration. + # Auto Start If you selected any profile as **Auto Start** then the INDI server shall be diff --git a/img/paa-monitor-mobile.jpeg b/img/paa-monitor-mobile.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..097f339827ae4f9f0d34ef89b37a2598eba58d8b GIT binary patch literal 49152 zcmeFZ1yo(jvM#)Ew-DR|0Rn^scL)$PxP{>E?h*na!QBb&vVhguYmt7dms&y1Oyv70#ns;HodAOH*u000C10d6J%`~X;J zXc%ZHSQr=>I5=2%#D_?T2ndKc571E_65tXM65!(F6O++W5I>?J#mA>)c|!A?j**d( zh=Pram7bH9fsy`J2pAk393lcDHWCsxJqbPu{olTB8Ubjq&@aFjA;2C1z|p`U(7dDmWc* z`y6J$|5funI%JzcyO+Y*!o_6L3OlX@MZhJnb8$kFek<>RE7oiTPHZ_f;yS@IOC}Uz| z_Rsx}1k0I$t6|wx2LLgeQt&%je8sM-&F!`hG3&$e_M-6caIlzgCc!`UFuC|y(U-E< zFK!!eFTAVeUc3aT1VXVZiFb`LS7(eZY6xaP*fvwS9}FPV?kYT`$a!*zu6m6W?tH){ z8R~!9|KhjSzq%t(6JFl7J$O&~UrB!@{(tHJeHH+J^)F<-rvv~X{r)}^cxTc2ll{#8 z#MTLT)BZQn_auOUOM#{~<~WB703?7mC&6tvnHNE`<`wEVj zL%Xwm>D~Syr}MYHL`wViMMl;v+UHynq9e4}r{*TZwigcOt3t)WKOwvb2d$6-Za->Z zM{(RYfSxmF`6E}hBfMVH(yv>Gqv%hMoY`DZsMC87Y{yqaGBfNgS2#4M=1(ilu{i7q zKW$6vC$fmyX-%%J_RpPGa2^k>e6w%Vtla5fzbxIQy*7_^yolqjT@lH&`QluDz_DnS zAPQ0T;)hCb$RyBAqxn$*pd>G!ZJlwvy#e$))@yFhZyaG(GD`_ zvT@|$-|QTyeK$j%X4+ET9~cBv-ME}|bf_xUCKNkW*Eag8YDU5o&#|WVZ2pK{f<4tI zlO+{wlK7_%NVpdO0GZ;?8bAyH=LRZ&cN#eWRQ<--Le~#jxEOg5Rrn@4TC@2`DCs2#>%*kKU?*3i}`@jtV5WcgH0{}<~#g4QN?rq*Q zhnR$4914z7nmF&N`}ZyP+vGWNv}3>qfVqk-RL33SSiUMbA_yhuy7$Z-k2bTT3G!_bsA(=8 z*;&}KQq+z@R9HG)?lg3D_0{PPM%A9pIEmgwJ-GI@@%)gjwD0QfuK)yM&UByZqltK%)*abgrj(Ii zYrh|QEtVhgmuIbzdN6>7z%i*aOjoe(oeA4Cx4m$S zpB?HC(EwsmkaHf@|EMwbD*{lID;HCJ3!?F^tUXFWZDlhCwQj1yqrm`WsWrC6WI;_1 zi@*rhpFKd~(jV8dpQ3-i{rgOi_2D$(a4-Jjbo+Sz8y6VaW1auPERd-O>G*vK_;ZDS zHVgFK0nU2>GOy&NdwuRLqTgG<#ko!0vy17H&9uJeOZ!QF;v83hFA-ONx7(jg0J!%X z#A4tYP6y~<&+oDV{z(JmI(>f91AZ}_ddbv!0HesK=Lj9R#i&Yh$4d|RUx%?GEcNui zQ6Bc7UMK!8=I&VFNY-rjM@Lu#*xp+X9HzS46|HQ>5!SJk=%2P0jQ8yBNi;k6=(pDa z)()$e)!0ho&4gTijfeG#XLkdpSyjc9;29>Rwk^$>RliO-tsH&xvSk-9ETb4_QjGd?82nfV9a1n%dSO`>Wq-sip|+o@%wed@wrph$K^Wc zV4F3ESBqXN=YgXPdCeSgw7t)}Cl>7`)Rc_?Tt!HvZ+ih-wc4xeE~qK@UrhE6rbuF9hi6GV6@%@fQ>B%j(+CEt85rO zZ7JB&X#dTpf3ld}^Tqedzt1F!DGHhNA14t2XoCOm^*?L@NN?ef|AplCpTR??h)P~w zb95q9^ZHS6zb6Mwhwyv-!~qm-l5Boe6$Vvlp8y`9+8D=;lat>bQqaI&eCPR@P4%OR zJ27Alzte8@=9bW%ZaFv;61I62&j0rn{GgRibvTKgYI}ulwX!`U4fCLP5RLfh9eb* zgR3Or=fwGkr9-g)tpJ0C`pEj4!ajc(HC4*#g1f_2JLx}%vB+*$q6l z2t!#ecE$w&w(KcxGX#gWK|H*RI?>Erh?L83{NGlXTqQ0`3l3qY54e>Y?k(&C`Rj%F zG0EeQsSG?9^fIZ`ab7WI*Jq9A;T%9FM z)r_mgo%E)1#XvKINRWC%BU)l>pb`M^GKjGLN5TJ`q3W=&_d!n(;UPaehz8N48nDR4 zkt)j}sxl$g*W(7F`JZdQ+1IlxPG!8v5u&}N{GY$zB`pu7To_h_2Dx?q-fgItsNfD> zn7TU^2R|s|U;ROTxBHI{U}>(uz5WG2fJh`CQF$SMnV?V`e$DW+6+`mv#j8Kz3{y5$ z;>G{20!r#zV%#64SwaU-!pv^}N#9=r`yc0jaMv>84^er^{(uLd!+#n48yjZl^wAAK z6aA9oSE^^pPKWX2#X8h-e21c8B~DeC-H0*xAE;eVN=+wd)ak(z@+68>5HKcTv7nIBC+k{HT+ zgSu;(@6yO>Cv%DK$+_#MdwqY`e%CU@#DM@!aMolu%142)eC_}e@E_8C2Sb97N=>#% zP6PWQtpLgW4IJtdw;!VJfO!TE+l-j}!X3N?6F}_!#2x-8r2Amt$%& z6I@%cC9UsWoyXKB5=n|kHD3M0q&5GG<{Hu zMXTJJ87kx4aRsF(J|*c#UVrc(knV!l`C z`)vCAmihS@{66qoFan&1y}wA9|1FOGUm?G=0OO$euhPK3F_r#AzVNN1Nx<$+vEZMg zK;h0U9k-hi;0|YTPkxW{YJmNrDSJ~IYbPR4H+11L8L_*{5B{IIr69g^3#zmyHgDTQ zP0xqCHM^f#7ZdL(y2pP{{@-I8?ha6b0ld*U(#!BEZkp3~;-+DFol<@W1VM-8m@@(eFq|0`HyaP~GnK{tj5V5QIN+!cF*ulXQRlflEXx9%k;Jc}Q3k(wpAQ-T=IRA^rb;lNaC<$UQLsAl@(Bf_e6bQQYVG zNbHXA+wSqFn?o-v-#wrH<)o;)*sc9VssIJ;_uceIYu~lZk5+kn-TSV*ZyC^7MqPAd z7wzv3>aLsa((jb-xXH11)^<0c=2Xq;G~RJF-(^-EWbIp{{SIbu$~>gXU?xGKT5#OL zMK`x7-Svl6?}E8HdiHrY-({PlMt#;yUbp6lkbiO$)1<2XtZC^asJp4%=no-(@&Nzr zdk66EVD1p_vw^_4PyS`=cU{r{SR{X0APx9cC_5+$QuI%=={^#WuDh1`u6oMxpLzb^ zrj<%-lW6(7YkMpF87sGGntt(SA`FX!PeUHiYi{)PoS@4UkQvm*F^ zW@@k7*)`C3Z&jVU74>_p^Xz;O^hE95mG2oL{YFo@^R(C$?4f@8C3qjdX7~cZc>rUj~I5zlufNVMzn6i9IJ5x&LAV%e}~Q z-`q_R@q6$Wr@q_c4Xk)*C$YPhn>*z{jQr@1PeH^-?zk^NgLQ)?4fX!PLl*Z{w@NYf z<7CoLBV=p2-^RF);9+-vGYe&vA+hHsX_>)~F2s@NBp+`*!Uukv|5YM|hoxox)Peff z0WPEFE}+3U<;_m$AFuWI8-=S!H&k?i0Zjn(n0Z>5@Bi$qFLBoc3_zssW=?7>^ zna0}!+TQn|zO*zL{I&}C&I2m^N&3m~;}Gy?47c4ut-oTp73=m}-|r8ccaEclOk@Al*NJ^xV#{R1j2i5ut>4jwcB7zF5>4H!sRNSN;*d4N9L zK!bPyNkRvOO3aJ?TuNHgvJm>k`{al8j3EvE^ZSo1iuOnuWHhYYQe;8LLRc_xFfbkf zI0t*f#K-ywGHfa_YJX9fC*n32%Pla3&iSg3j5D*@i5cFs8lcR7gqCu%mJpWYv1h=c zq(?qQHh`T{l=omNyc`PI2~8tR>e9)Q&6)_6R?6)g%=3ur=+R|=%?RFAezSBZ4? zjL(LDNGX-f=kd!M&$i9KW@4=m?0AF%7%iI4|3jABww?QGU;Sl0vkHA8;X54r@b2Qk zhSv|vK3H)cTpWBoJ5;>1O5wy#e=mC#}P>l!gV8bQZ# zXmoDERoMpD8=i3H-C-qn>z^fJTU{!%8*9FLwx&381Hf`^d|SMyY;UOb+$^Ju^HpY4 zIBo0%$`Q)twc+u;T$yFwYbv(~u62)Jn`+~INL568Mhio`7^S%F0A2G&trKBio^V@x zXr(6CVC_kD+%-|kq&)t1ya!D55ts%po<{~=dTPIk`QAy3AcN!r5tF*M_LET;F^m^| zSG-q2O?;T0WCXJ>Dhel|*^XB%wy-iinbDjr4VtMgto|wSF8%A{w zEK}vy!I9ezFuK)hsKT!?EdTRFIE(}j?v_OP_p(0kK(Ks}@;_v+IhG~Xr#V(_cH#%p zMdJsDZ`~s&M^f=9aJ~Yx6=m_e8Tpdi3(4J%KylQe{h?g)mn10xq(hF%6fcEa>RYow z>;(_J)LgqS5Ne%2Gm?&Ft4fv6uc;&ct-{VQl0uF=aM-9+DevDZ>;?X&>35wF>MjYD z6MINbH7x!k-dX$>0(=N^Rzl(}e6Tbj!^r8kO<7YckkV5u@ePm;B#_{%!?z}RjW;j2 zHXqQJ>Il!m7nlcPbAI|W^MWD~mU-y$_cC;zQ*)-6ZoT3+xg*B^Rw40%65K|iQfb*Z z;xp*B#EW~v&-k*j;$@Ql`*Y_+U)U%8R00>SHyj>!r}E9m>RvHMlPBsUn;_R1Ay#!pGPp)n_?Y}%kK!Z<$sYNC~Fkx zUpk9YgB#v-&P$)Lr_?|F?Q^(|-QgvVjd9sGT%phJa?(v2*j^fdXRQR|&WM!8FyVX6 zpaPpd7fw^9D^#Fza`W2ck|ol2)}aj686BJu+Dqn&HsyS@}UJ^kOT1tn3=Wt37o;#?O(iDh)|FZcTAII zk(dY}-v^Tk?*}z#M{fz}gFQeLA;#YMke}Efm$6|+I-l-C9`y26y=80@i29V+xl|}8fSVpbPyfwl(RfeVg2{1ud+lV>O zo+}2U;>7{afW0ZoVQ-jgn&p6Y+!PxGvE(t7`sJu&qI~gn zA7@bVV$*`E;LBGMNo%g4&o!;!E1F&dH)_ZvEMcBi+F+Ys&wiuEk}ox8+wR-%YdMnZ zdtLVWjcZ^?la#&iXcYGIM@(q5mq`&+a@jKJqCaku<{hBOi{pFb1qlfb0|f*1 zJv93nc@dM)q4VB`T~Mgc844Tv89~9<-a}Fii{OxbdRZ;2`R`E|=$l*~!1~}N9Xz{D z_O&DDg8{s%BRec~Np$N5h{T}LbKH)5N`LCUcgtjB7ApiQ?BbfDqT^OEFJ>LT`N8$FCa)=l2Z>Fp)n{d0`zW z%vE1X>9V{=GC$X4K@c8xmT2TkwfpLlnZ}sWhn4D6)6}4;1m)ygNS^Amqd={~AhIXc zB4Z5zj+1CnO);K5AO` zq-z#p%G27l$Th$R)qL*DwbcuLQmnbzBtI{Q%t1BAt2!mf!B-feIHBc-i8ssA?IbjV z@=O*M8LH{!B>{U9yU)jkE+z=EPZTQ!0V#WmSHm<|aHlmBP<}wFp~&rRA#2brE$> z6=G=O^K&sH55X{4dBf)z>``Ddk1e5=Yr>3}TEVmB$}~(KTk4IiXu{{09WaX9gmO?Q zJT;`tqC3At1A=kYW)Hgo0{3F}wjK2vKDt_^DhVYKK#Ok3hT z=RTmMU(mJz7`Vj>)Pn(pc=}W*qQD&qZvbSiHAyVO@Bs{x66%cSx;|V$`;wKdPbe_vi zJZM_V?-mb-Un$Zp#nwfV0pGxui~oS6$(0CamB)&M24(!y66S;0?0#c{3Pqyg^8K^8 zoeNpljdvv4H4TSEXdCqxAuI1FxP$;3a|!%&@!v5741V=%i5}VmzYfm9Y#7Wnko!)wFs~O#1wV6dAupav_tJ zwzYf1W49DJd7a>-0%ie=4`K6r-`9E2e-gn1o}VB3S>M{*+eZ141wY7VEY;Gu+(R;B z;W-P(fbHb9uwt9=UMpi|TNbTQUV|T{z1B$FKpSQSs*v=YCl19s*l3kq_k;u`eNVA2 z+D9AkhCo$M>`vbp9OB?MYKsb63)y~2nNfD;L*>#o{f#_LyB8==Z&z>CF936GV zpIKDf%cLHUaa)w!06rIA)}1O*Tu2bz0IDKCT@Qs-xC-zR`_^Jr>(Rp3KwdU(Xdy@z zIfciNXJ8Z2MB|QJx=JRcFb%Rr?`dMy1g9=IX(do_5V7Ko8}47Aqnssf)!80N9igA= zyL+aWM2JUpA;Z2lgdas0LN(!^uv%7%5z-yy9nLDx6TWO$xg;rPDcG3%V0_J&Rod5Z zt$3ZNCj#-ft$a65Hjo0#{L%x;I!mt^nmi%wHDSMa7*|P8_hfONf_!b++cu*O2rCz{ zf#$JU(zw-x_ZMS(fuB;4Y7Kb+!)Z3LIgSC?S_H~B0LlJ{yyW;^=T{#*N~z)>jhBSmifcA@ z8c~a^3o6u)jkO`LpO2W+kBEiA7LhL{5D=s#zT70V5i*unRf!1G<5^>|C_E$b5mH)< z^HYoEWRb zKDk=rxqguz(ATjxJz$o4w$DJXQ0^#hU~JN_k+WcoKN_G|w&!|aetmjD*|My`(fHM8 zf)5xA7=2s!Ub-+KIYz&1DGssry0k?MtrbTgL1jw9x-KO@)0FE45+qZ1i}vu|f{LuoI;7LJS^yD0O`{j@<_X9DrM&Z(7`<#B)@GK?L zHii9eztnwunKXZK`~eMgIdxfl*O;6pyXzi}un7WDM7HkMHcmU`lF!d#AzMXBprN&l z-3@$WeA(jBbwg!L{WRe98n5u%Q~e;Op9Z4B4^CNnVd8tI-Ev6J>=7;1i-xQVxPw&)pdC4Rw>ZSR`&C{4Rc~1D| zehTBJ?>y8teg_18W;1ltJtWe8)(@AIkiaZSy7*SBpoj<1bfjBqmI;dW+L^Tk+6*`k41 zWF&MC9!)6Qexw&_ii0hz?n@c$m>uKs;XV1sUm{SA5ugnlkq2{(LPbT(;W9$@+sz!1 zYKB#XuoJN{4X~BCiJGM4#9>l`%Pzu;MK}sY4;J^YDTgbKrb^9}%AYDn=5;D8=UDx} zPf)+03=tfE=BrR#V=3tAW$CH7MI{K$-Y|ayK&u~{T47t%w%hZr)1#l&&NH8Pbm2oj zNHN9%i{Ebf+znSj6|=Hj8VY9VA^Vjztw;RB+yFmYevd-i@kcGOv9?oD`$!JEmOI8A@a>It#@`Q*y<;F~h2n z9Amct-aR8%#jU7C&(>Wq=O^@vQ9R_csc(oU8-7!1#?dGozj$ z7D__fGO8PZOxOpZ^Khe7Rk1|6IbY84n4W$GT~3bZzQpuIzfaiZEi&@H8Sx4h6POX+ zhz|lXrNywEg<6ES&?+ZAMyYQAA1;67!>X@woh9&@5d_X>&lRH91i2rppBUc&tgq@6 z0xBLE+e0Z`+J+LePNDM9C$N|s4Inir-T(qiAuMEDr@NJS&o+tnh8u)0I~1qv#tq5m zb9rX4l-M;NKi4nmR^S&XkPO0Ua-LU`&={ky$+TD-nNKy`#>=C*0n})-AnX~GFQk|2 zC* z>@3yQXBPHXlr}a~JyC`dc0OlT;>%$7|59AE8Pw47T#L0pS$`wd97-s+^N> zP*R15Qf!T-Qo(7djs*%PTd*WQ4<&-BN4tUlSS^w;O%iKbNwcwipu`R-+0FGEJ2=_>QLeA{BtKa%S)rqZ_V zj)Aue$wVUKL7Uduek`@Bg4$oz;aM`I8nhm?Qj$yD{CLwWf5eciJ43q`lUjuDjr6Cj zy7R0>n<8iyLvaIb2q(89U>I}y?nZ4oKe;I>isFz$_{TDnS>4%Ty1;9+m`X!`M!2WPn|_gEF0PKj4@1|EuxcIYDX=khj9&A3bx@w1*Ccm zjo)vG`%;#c1{mcHAR*B;nk|-2)=r_bCrF)K@p)!Yc^<0f7fHF3SEB|b?G^#^12KaK z2*{U2to;H98&a;!uc*ERJ|-_bQCev8Vlj|A-WOwvdp0HQjujg{CTaD|7kcxlhWzjs z?dV=Xo*2WY78Vm2OmD3jVNgeRo(%1MS+Fssa0y%SjE`GoO0~)bGSQo9jPWRq`A``0 z=v2g~U1L(%1qCf=S#0v?qs_P;xH>MQw+ zcCC~nC-=x5jR)7XPO?>v@b7a~Ri>;eX(I+9nhktHpCyO(i;$E!14fH`&mi?_r-hfM ztgIgu6;_m@GK027I2lIRrSJ-tamcI`J+TnDm;+lk45Hn-PAU|+tR48M{-L+NQGK8+ z=yY~DLSWM}V;y<;t>~K+bhRVP4IsjkZh)03 zWeMn{z}KyTWgGEE$oz}9(I$~zO!N?kwcx-faYSud7)L?u1g64tdFf?DlQmZfcqePA z3loD0*(aL*MjE@6P3Wy8U-~QzpF|6(2Fj-pY&9W!jy@dwEjE zGrkXymTtjbTj1#E^xvqTg_3^p)R#c0xZ&ZmPa_)VSy?FoU*`RHsfw`2#a=0b&1Je% z$hL}^)`TZSjLg=9kvAE+zYo9)i(7Ul$wA-FNtzieyz+!}iw zAGhC1o{Ph9{A#BtC0q<&xd4y6Ymg!3#635)bjcrN5Um8xEZkWPRW_T9=Z<+&5^Flp z=T(iwY-Jl%g1TQhmv++<)2Ib!cQPOVY7>?mw0{cJ7nUF2EC!mx{s^#qolwOseufCE7Yllz!K6oi!3EvR^+XRK(T zg$W8V$oIvHsrbcO?E$rVRx&TtuJ*JoXOJ&XwI9|61#3-9&xD*6JB5~Jp^WG^;b$yG zOtP37&sMgs_=TJ*zeU*RRc}G)gO_c}mLu*G5yDP-l9yY^o7E_H7&iCW10(0NgWoz3 zn_**f!&l5?&myTC1uvxQyAM1q6*vuqhn|S~l1JvaW#05b@ZA0_ux^-OhI{CFmHxcB zuP?c*P|k+Ds+v7rJj!N6>O3}s#7w%fjD`W?I!0?|&7n%Rey{Ip8WTnu38F(t?a1uV zk$q0$r`JxCwcQWLgXg)(fmNJ~0r8I|U#xs3cv@=vBptO`?VWdQYy>YeYf-NV@N*bX z+@QXzJaUlTbKO!enMmG;x~ti2Ej_dxk`w)hiqS!81wg|V{-=danqvI}Sk_;`thIxz zl^!0fimjem7|crSM!Y^xt!^9lBxOu~FMzJ8hY5_Ph#O-|j*TMkoG?m=*ki;(%eU$u zfWTB&l(!k*hN>a1h!EKR@C|4{|ES41p)^egvW#vx_d4^H0^ zwkj4a!$PqErOK^6i2?C)W`<*#u5a=73D6aVV=*RjPQsJBIIiA5n`9-vP1_S?&IzQc z^&8^(Ed++@%bCdzw{xdvS9>8bUaBsSnP*EX$D`?~*4F7InD|&uY=(m(-Wx!+RpOcY zF|F(k0O2}a_H`>RG$J#{po-FF$ynA=n3s6enMa4%shNx+96n8h*9|~)NSa-LUT;Vs zAPBK=nFpD8*ZbnBp>-})JMYJ@I0Z8uw-eXw`howF3=WLQN`rjRmj_6p2pQ}M08LB# zE?_J={z$=_nURt;Qj9L&seuoM#YGksn`P9wjQgG8CzY)yv{kws4(lEh_0n$ZN`nOv zuz~EC*e;*VYJ8SG3oG7e2dUKnttC3V7PS-1^VM2pQ9YGeQUu!e+_1eN3}w4WEItah zL8BP5P3eV9^$p_GcrGJK2I9{(u2!e+on^L>A}twvoMyTj2_1M(^|%|bp%6k^mm zGvmf5eqh2BJ^?p91mh@~67POyKHUgrS%CGVU%20%uHN1yQd(mFE%UBKQhcfgR|_g{ zAtonHY7DQ%B#N&sy#^L$srxTA z$p})9HU#<3rJjG7keADVQ8CuMU@TdHy#dtPMKGCIR3rHXHSkKcx3L&($REuiuAs!a zhsN;}d|i#;(y^#UF6Wp_%GS9e5VDj%YC^UaEG&1&9)AVZvtubW+{RJhH@lFNgeHtX z%{#^Ta1q(svrbo1<}15W&s*KY=xa54t8$u%6ya6p>b&6RD+1vSX@2Bwo*wyx~L$474{ z&iYz54s3sodl^;&)L$NYektoo9+^T9`2fa3dF=z;*)yRc@hM+(l+%_ zPNsv-MyQ2DNEA9{l!CEv#G+7c2PsRFgEl84V9%}1-I7YlJ65cI)s2rsa@~gEi2%ly)01Q0n^cEQmBihN{Y*5&Rz!ZX(}=R)>-ylbT2m2sUeE~am{f|SnIKhRW}nk~tZ`HsQ`28rQd1AEuk+VXk1 zgM^{BpG03)YMTs-tZjB;6vA``rFs+tK}8Df(;DF>yiOFEv>^KAYR{rHJJ_kkA?_*j zHar#f#+)G90n-TR(4h;z}*|L{Lt`nU?XEP zM=6^*BlMz9CNtNUU1M;7KzZnp-G0Ho&m~5*;KY-2eDPcMlZEim?U^4pqHjAcH+Yracsff^_}$`)iRdovu~DO4j;<_jCa0jZ_n^Vv~L1@(FKY zh1{`Ne6`6{`%%GHvlV0MCVbN9Nve*rIXXdemrVDV>|#dnAFZ2}G1qV*Z3vD@j8qfJ z6w}42Bd%{u4$5@`_a#Dh*Ethz00rae`hr3zK${|-w1anb?&E4beqYZs#i`F_Sg+nS z={U1LBduh2R4o&7xCSIBY=z7`8Tfh~HubJfLz3D~c=zEt;dPkL?hRmkr&-}qA%=IZ zfEG2HqVNC3O3v)HjOVfK-OI-Q1^|N`w3raI7QV-wp^45MK_lV$TJSndybhN#k)fb< zN^tZNx};`44~s%dQajaA+ar!S5SdnhGQM@xHiqRI9~GxLT9PZT8Lw_DFbH~3XTym7 z6?1m)+srq{ToLlG@H9;YI66}-%W9H&Um+M)bj!wJi^q&?TZKl#8OC#w*fV6bnG(4> z@ubLH_wq~27WQ1+zC5Y%g)h8lows-3qjC~eTu$H|e>^fWQxCxrB?P&Ix+nqe!31M`UlJK7~e6;CTS!XMLEB3)V%ooZci*9zh+c#I{B z;zb2R7G`J5`xRhg6;2vyT4SFgpB<_V67j&X<_}kDWkjZzYkCq^8%tSIWq$+@FUD+J z7|D8)8k%9SGS(XJ9HtDVvoAZ?s6CpuG!>lY0tjv-wnNKd!7P0(*sqSk$zE7OQRRta z7B;xRul?eYazGJ3;7u=OSS?EtRY6Zi)z z3*bK|kncNcK^1myC8R4+Wiwkb^A4D9hZb#tI+FoFqR zkkt?m-@I|M8qYjWh;HDab-lK>N`Jt6_YTE)bs>o}w%&jW!k*H{)Y+sup|P+0ngwHpA2+9^F14&j!WAz5$^Wkyyu!(cfx8bp@wx{Pq{R2 zeIsL3$ykAFzCN@!Y2a8w7%aSREL9zCx*HE1jd9t7Y~tewdEW@L z^dAm~eBJS$Nbg^K{*~&{!iA;QhP+i7(lbel0_<5R%7IU-kH=CdV&;wCa0?+l!L;cP zE^OSv!wky|Wv?W&b_}v-+?^ZvvYrxb^7ch$S$bhp`xcHsJo)?bg%VF1Aig<@y|Vkj z?1an}PfE7O@jgEnYnz^?vtXaR00m~iry%gxrh0SH-H!prT8|tgddgDC9#-k5S11ik za!-N@R4KE-+Ow1cI}(gXyYUBM^va(xWU~m!ttbi3`I`oB$@wD>pfP1E5)R8=2!;kT zOoTSbz1T2}N8k1~n*H<$ZKjzr>$z=19KBgni00)}clA?`g zkRP>5-{C8J4@8Onp<;0sW}$KfywNj?)bq!?rQm{%AN<4{NGl(RRI>2ymd?;F;AywPRM!z17x?BYOIjf^iincoo5tXMH=J508nVZAi^ z$+K&BZn7Noh%1wh_c_N-Nwcs#`TUf{koXckZZ)>DIWY+IAn#d>o40X1y(F@((c%~( z{p(Bxv_XU;esY=of@Ww~2KvZ%YVN)_SkAX`x(tB+Vv6U-M10B+)}l=>8bFqhlL%dn!3l~XLyl1zG51t!-de=$-2`X z$*s%m@s)zYX{~rA%I*n0DQJl%!IkyZQ_X)z*BW;GR58Idf{ z>6Q>7w5l`GLJqdg1-=$hJ__j3bWV6yE|V}NLHYsEQurEWg3g)DNRe==XxmFGx=4g9 z6MrUr`?yK=U{A){Kg4bVsQP}t;N2Dq1`9o#9#IW=yT=!P z6^E^Dt@R@RO8WJ!r&?y-6oN(55+m76^!kK>$Ow^rt%CiJ8mD%cwlrIgU$^j*aESSI z`9d?GBTUV^R7FSvo4OkEwLTvwCdrgPl*^l;Nep{f$p?PG5`KV}mRkGSG%S0$ho;fo z1^9|ZM8Ks%_-v9oX2Z<(LL|ug$d8TztgLw_WcNzQ9EBieq+(A-lB|ZsXzG(JjcFrn zc{4>fSwQ2WrE`W{C!WOz%YC>5ZlMLsO39D`5&s0MP$AJRiHVqnHW8EOX}45c80$(?qHR(Qn8Cnj4WO*aDm78J5*Qu{VQ}+MITZWzV`}p>KOyddeCM7i!)J$`=((s;+j_>vm8vhhc z0Q$UTBDxEye9tDge)s(xZg&_i_T)RH7ZKMpX*o>_I;w-V%!-qc8$h+>C+Xp`qC}>oa#cS`>1*)Z z?1aEH@xgTVxc<^Q41E8{=O-NK5=|!*M=B4m!`9Daok$xb*SQn5g>L|QLvK9{ZU9>w zt_N+%9!K&#tJMdk@))vW=SG5aO@qP0my>8Cb}UG%MMtT zK#fd?6Vg1MN1#i0oHuZ^kfE%Huw1f%AR;dUq3w$zNmu+1cI{KaBF3TqZf!c>xz}JI zV;6mZ&_Xf>?wf%dEaxs zyT0@3kF)N&y?X85yIObe?&{iI)m7$DGxm0!<*DUEURAwAI?I-?+EQwsOWmkA$L%|q1>Qu zfnDKsQawbRFt3eI-PX! z^2S8HvsGnEx>f;)_J0o2UbW zCyF2cIWFzA{5{@uNc%i!zN>ZWntt-uDf_P*LE7=_T3qR0%zyd{+L0*nEqz?4EdS8t zxzR=9{fDqZmYJV=!kp5vDqpqz>5VfQ9x@ocdAWnYlhbenbQHMhZAUx?k#1p4g?6Qv-bRLBMKxO#g_8fLY_ThWFObcWI&(KzToO| zVZm-PB2LFktG4od=k+=zsNN&!Vrr|l11RU3Uy|^?K#Sqiyu_=eL651Iq*jWI8$wip$jN{~VJ4#3zaT%a2Ap+!y#ozV8JX|8+IO%76#X zz?p;Wk0Ic;y9a!NPYDm)uV8>B{);CyNM3qZvpP4=6&kjJM5d~~EOn7~y?CXAw^e!kc4$d3hl3j=bV7j9=*>Ar2Qhe;>5AR~Y{r0PGc`uaLa`oY|TF zsp626NE=yp=!(9!RrQO=CHprdWy?#C_RZqH;l#fxYJT1(V=R6e88f^(eQ5fAg^Bl1 z7W)S-es%8ONU7pq6>t3iN&esck~KB0;zZKUKC}{1dwV$t86&&@F2Eowqd3X@6ACx^Y|Ibg^%Ou zrR+?tefq=MZ1ZhjEU4%2R3Eju;T_flJwE1o3D$hh`JzN2$`%+n|0-6XRD@Z4SCfsM zD2?Q?>D8^W@kFYIqC}e2?hUW2>K*#T(T6-b3P0D%PCPC;xr6+V14XXsU#1JzJ`X9K zQ9SnS+jIJHb+7Q_)p+UjZN*))j{|ypEEhdQv9Xlq`zCfCom0~}Yo{My%5hpb&_DCI z3SO$djkg$rJjzl2(HN3>Rw%J4_wHqv{^d(Qp4;ql5=2ZH9=`#@HFNOCx3o^B@XY%D zRVZcifgGjXH))FtwkpJG>jhJ}`fgy4-Cu=B7k%HR_Ky?mo|YOP&;7)G7vUp9Utc+7 zTa{||h%}^6chdI!2FKtoWdiG>T%5dT^HO<;_}~V8J^BHmbf@!4Q)1%2N|xPAON$Sk z&1Y{dO@DoE;Jx8Zg|m53-$W0w?F%p)4biRy#cafVlN(ai9-SN-JO2&XDszZ)f2um~ zto*}l$L!tH1^*0R>z@UV`82Pbv;Rv09iVJ_JzvsI&)c$}@!qrg4JdNS^PhU3=^oUO zRr+W_^qN}BdauAJmF9(Wy2H@->}@llrxpIj|D}NP06v@^Id;d$^(%3pG=&ZP;kUck zLl}ZE00;=e8mI(g&tCjxw-QJOU=dQl9;i!(9U@TJ({#vV5{6EPfI0i=Z^6%*&a&;{s>vs;`&%xB6Nd8|oz*uj zN+TCB0$fEab(?Vz+)bf)I|i!DPowTFAqnO8X7XJ7ks&4O?6=;FbU3>WPz)(5@fZpCLlto76XhA# z5}7v%NZGBDCUJx(f5g1rZc`?xcb0pecmAX8!ugwAX$rFvQd_%l2L@Fs8gaj~`!ytv z5YZ)VWBFHF`lihM!Wpn9s;B$|2s6Mf|$RYIuTZ^MoS# z%s|sm6h>y)Gl&QVc3%6^5U)wqOUYztkJV&!4-p?M`_Y?G+1xuUE4wK=b8mv#La$d+ zgE!;gtfP;{; zx1zfRtfvZ?eykx=XcfSB1OY`Usdj6E5^{XRm1IKaVXzM~7;)3E=QC=!rVJvEpa=Eo8e@y)TDmKzsVjed7p}`KV}cqWqr2J!4&?^zk(YnUIpG!GPf+JOb7x zlC;{v?KET&kU#-foyr;=DNo?SGtu|1m< z`z@TdK5TmMET$QV7)Go^e2)>-*DiaKSyV*5r6I=GdDhLJeV85#{eS6#$^!=dssaB7 zWECRr{xsMkyr~HY4jKq6axb7jKEoQ(a5*6iwVLBN##`USMzX_CYn|)NBwqk$h>aBO zKMT5iD&~tjf2tFFd3Eau=QjYRpxrbKwF?QWuoGhM0}tXTZIemj>L7a+#__M$Auk#i z*WV@yURM7}Ya}b*vtKz%bFu~lq_{yS2_y||+;mu>i7&tJf|1tXT1l%xy0Xy_wow`3 z+aSV6K%t${^*u}{TQ3uqpP*>-O@n;cylFqIsp?hnoJYwMD1=YgmK-PKOMrq;5t$UY z81AV+B!I<*tZOGYq&p%#*`Fqa$EXo$2zmD+7tt|?@6WJpzP_y@v_!O{1-x1Fyai|m zO}TB+szpWnD_0Dd)#KXq;UhLy@uYvntt~36AVHf-WqQRugLffbmLvL{O|7VuNsMLI z3QJB=7`q9AMPrU2!_HDoTUjI|BH5u=Cqed-e#U->UT${R)>qzB5AcwVtI@1GP_d8o z!K4-tR36H%HIrS5y!V~la-8`!*QZM_Y>UL!`qrcDRifOa;PJg$bdR(>?L1j{s!FFb zZFSmc?LmxsUL_Ll^2?lWxzWTEfIse7!2RQ*CZ`)*CsFFWM+7FLh6;_ zW}~=N<}>ZEW*SPQkqOyQ*5{=&vWFEP8;<~#nT>X zGjxgAf-R>)o@|iz7-9#xw!ZKnYQaTP@DLyBrbfW9R%8rjnXV4~I4jD3(=RmPfWs$* z>3GZNoh||ipuQjC`rsWV8%KVXky=Fu4af%7_V?l|B)q2hn$QV7LJx2zQG*@tRghFjXxYp9w-|Cl>`fQ{+cYgQN}-(&rc8GCJb z0s0N7!J>?VE-j-KKrI@~>kB!bz#HhU&-k9BOgXlaZ-a<)g|nXbdsaWXRG1p0R8Z4* z-%vH|P1Em=*pi~wkBFx4)B-&TCckf&g zi*=}*5h+$PMM^7E1}Jy5fT%CJ_!7s{$fnO8pch5Px(9hW;nRlD7!S#lmv#up~D!cDP2{yt_og5XB96RbrE z0D?*}T>}P3iy(L;86ew+K75Mnwot736kgA~Fgja^0mIiwRiwBVkX#sbkFNhdzR6ab zpv$dUk=+q&!~AfP5|zm*24WICW6F)Pax+MYRWZo@g55xLGMEA~)98n0t$NK8vk4V7 zygfju0q4`=;`mC2V>EIod@o`q?#|18-bk&hPNK&~bL`4G!KAy?ib!@fAqrXa%f2)y zIj}EO;Ym>uMuKVYis+I8tL_K2d@K;!m!U`?-t7-n+A?V9I)l120*9ico;cOm1I5!Zb4*E1fT-mANZ! z0sL!s&|3*l?f`>;c;0$(YqZ{0xI`F^rn(7f8o+PHV|)_XoVD%%E`>Vi-cEX^a%bX( z-}yjf7u0GK8Aj=rn^y#<#MS9MQY^XSTqayfJd4|26RihDJ#S%-xR>n&l)SP*CryS> zvOG}1P7}?e)b+$h+%%XC5;WWtaUh#e8>VcG;{{^sB@&u^1`S-ZEIlBla8C?FcqSHy zX$vL3A=|q$f4kV_nn(p}nb5nkR+ zD-qZ#n*P{zN~2h_3$p5^a@dTiL3w#X)40mm z&Pe%$$EXx__7<-b?D+fFoc@~(2(pq@lWXk!AETB3u6|;J@SX*pJy?kihI$YU-KNP|C7RkEE) zm{o~V+eQ4qWw#CMu*J(l&!qK4f^n(r78Q~SVLA@3(ZFoxer7CEiX?DAliUl%v$WB- z0IOsi7X#~vgc~FZDfXtBKi1Wc$tKl#4dyojP)Vu9!0mkJ$s0;+4_qPGv)IX^!crVk zv^hm>9;+NcF@Rpm0bY?pXR3J54bNm-_RnzOtyFUHy03py`Jk$%aNY6QM^n6nHyGcp zlm2^zT}vyO0RlRq4$=lc=_1HYp}=fa)}OB%2@&|UQL^O8Pv&gM!<0a4KkM6cC^qEWvSMeum54p>3|g8wE%B=sxg< zxthkQJVW-v-f>s}2>UcsMt?%-cuDAb?Mha*N;-itR0R2Edoxv3Mc2J8JH3{@&*`1Q zqyCf%i;;*_WsSH_Y`_SIuOVZuH%S+KSm*!?qB4-Mdc`8{q-SVy)e&h+`f6J>O`j{Y zdRD(z>aD_0#n>TG(urp){o=7N;i4id3pH4wo=1ChyLg}67;jz78kM3{b4N>PDs1(D z!%cy~xM#(+Da*S;#TK|L4W|cCB1*|abSD&{XK)xS<0A80k{hO*R&l)+2r$fhxbA%iS1bw&sf?~x}s1lchlp`R) zG(yS~01;UN+vAhItzWwZpQk6pA2ej~rP5yUt-BeGmvBT>!J@*_$Mz1eXXI$(CTFQq z0^Szw#ZR7Xo$|euUdY2klZLZ@`Bls&6 z-_0k*JLm%W2oP?{5E{DYkyasco{$z607*a0mS6t#00D>>0fhiuz}8t<)e;Ucak;sn znd}gtylCTeq@z7RIdKoWV(ytCi9`6NJ^hr;F%*PKJCvw%fMG9mp7z!Y^WEqW0T!0{ zxLY)R!nIAZLPqc45ak1+0JJK7_WdGU5{ys{%17CbAgWEp-T-F>le`X7Qlch;L4-)J zlvfs66JTt&VgOZCm~oH-L^p~7*k#|=Q?ivBZchoPsw12@!4d~QG1HsKi(h^q1TFL; z_vkK-CmCS$oLSC=m`V5uoMZ<;yg0`Ovd*hn|3-)W^Velw=S$_-e`Xr#Ke3whkElQD z5sLJD?4z3pmp0tI#3geX?j&%IQ2dt%;A3~u$irAo?}SEOdNFIW?F~MYOsph3*^tds zo&6Um7Y~L8Chjdt9DjXqu9REg#wpUSFm!c`m%9FCi?FKLmYyjT7u@7s1bO1I6* z&*Q-~2tbqr1jNHj9mvm@l5dFk3RZ11yYZ%2=>NWN*Vv2EF1rTP^wqG^lp#H{fr?Zl%yZ ztkj4na+hwD@!ab$tL`RGs#r*KhJqtbr&^**$mJymWY&p(QXUX0qBI=Nz-jF9P}<$h zk6UwInCkTRtL4FQ!Qv9bQ^hd#{*op-#eNjQ+O$JLY5nA-xvdUWTa7Y@Mld_ML0c#{O+8S3%SJc7gLl*H&R$e)c<*kD)w4(y0NrJju zX5K4U2c5h*Th4TAcE4@XCC56;m_qp8_pw7}^R6(1q2qhvc;?ENcA<70>jF%R}RY1MDz65o9x&^pY*#bD^7^3i67qx71AJiM( zY$WzZo0ATY0p$A|pgt8||Cu}rAE;Nvr{(zEa-AI;$%Xw)JyllyutE0RRI%Gi71e5m zsrEs$wS0OE|N76lrJ+cL)%3A&$8i3?_0CuFWyBODaiigDz%&-RJ0npxC8khAcqh0R znhk0;L{Xwr@si`BbhDtH!5(2%gpk-?2l5j6HUvOj97G1dbl%2KDJHghR->m~u!yYO znNp_)>W_0liR)N8aW{)jh_>a4V7g*Ov>@;R0gk9CHNP!{K*_JKI+e4Rw|^)udSE8} zYm)p&XdG`-+dAVKG;&JLKvU-!kJIz~1trgO)=(X9rRPW_om7y@Fjwa78rgwfbQYP= zeFIE3@|Ggl8X*9iFl1*dvM&&ppa8s%x3OPkn4GWzySQY%G~|}&29&0fAYrM)!Vm!{ zsh=>iqBu?`Wnx&HYCE`tlYuaen{o3;y0RjPN-#ij=@KwagP_7^;1oV?;Y#R-3)uC zEAwluM=>tgmw~vs45GtU-Df=!9{l*%-JF2l$d7)gG|!ENyQX#HutQz$U~$o4sS{3N zM0D8c&k03EK-OIpc8OL;UDO(e`ht{cPiF0sL`_+=jHjuoVMj$ZQWPAfigOba_jz$h_D$gM ztYJOq9wb>!5-`p(oit!WNc+lA9!T8FXG4aWKt^q_bla}*YDiJtDHcJGXO6>fTI{qxkPq zHgDY*x&D|BGaxgud8*y2R(lc$t_{phzeE%Q2cW6G60q)7SpiKx>N^IYzN6iTU z1=@iE19bKl^7^KspdAQaPs;G<31fbcj_1}cad<#u)g-sjA}4O5WG%PI&b}F!8A!1~fePL|dLy}g(bS;j2q$5e{0(xp z_?z3Xp^E8+D&uS(5(>p@r3LB2oWa>*{tdvik068}!XG*?lLBB*!hw- zPN$M3F~ zlmtX1?_1&VYxBuG<|SyA{4xiw%En1x8Rk?%@u_k^B)_i>Q+-w&ZWF{uE$F^In_bVo z;%7@hnl4A%*vWW|C}WLfX-g=C`_26tsZH@d>5}VCzp-)o1){|O4atka0ie^wiCZDH zjZ;iELC-Fq{lM*6sdqlezTynR4>QDFpxO&$34=hVPBEWj-T~%Z+b*C6+v|o5TI@u+ z;2nHIx}>yQ_SQ;Icuq{7BY9mwoU@*e5&Fg-Xu$K*B64RDj<`;#XzR?lz^K zpP`;I<^Pta3H~diHOK#6c1mk8O1(fCLYV7?jePo7ob8cf!~hh`6nDM(HEJA4+mF8v zZY-KnNAEuy6r^72V=BkWkF>%VH<_h*Xhq@}D{7-_HlQ|uk(`Ng!X;j6rnrI)7-*ze z#ZSw27iwe4szFS&0T%s1juTAlKNuyVfI~yOt0=~6h-G2|!w?{rP=I5<%nfJ(4~%{m z90Oo|98P8HoPokM)pWJ3EN8&118taPKDPbJmsPA>K7MWYEb(>WyXPgJ4!HU+MnS;P zu(}`{g-ziTJ4V~ss3(NEI$hI0T1vdC} z7XF0X7C1nWm{}~LB@^4H%O|sz0`%5Xgx?b=o>EdsAB6_XE3HvQ1n;tqA4E<`LOFtC z8Nib43Bb8N*h2dS+t|%!tx!DvAS2Fg0z6VBc^0}*e9|V8DzNup&z@S%?PM}(f=85J zxt9!@Ds8p<^TYoa9^>SR`mT2P-dNM`Ea?B|&LR+h{bH8r)IsZ8^?QSkN_Xv*{C4Sk zkLLat9yt)BIA2Cx%WgW9ft;LYl~@k501T_v4*-GSK(PGfzd6hRvcEXY!O4d(uj0M~ zESDL}V}7@eN%|Lq8HP`00F}h8Ew~kA;j?MCcrB?Ft0PHZwgY~P<=k|Z4h@Xk^@T6K zvwAX8=LlV91~}3ftL(2am~Qd$zv0->8V1FW1rO;yj7d@thqRJu6#}`!$V+BEd=l_< z>)9rci8r4UujZB!=|xns(kgjDq-gZsD^lTl3!OyABPkQc*3uXiat1A4CCcpPAKz$W zl>u=D{01aFbnOk=x*N<_G5F!397H_0hOc-BoQ9LtRZFwIkb4{o*NC?qp}CX;1(55nC>eq^Q`K9zH{t*LrYPeJMGNgnuNFUXzQx z$*`3HjBE@P`;94aU}b7Wby)~}zI!a(D@tqzc%Olc1L)sk@5m;)^UfjH zO2dm&GNz=i`|*wJlEcDhdFsF&Ukd;my;RjaBqS^J!ES=e{1qcPl# zR3^Q2tKcW{nA1s>7)O73_A>phQTO$@cCB4in1-%GkvB>pD7tA5Qa-`{~RQ4&eghXCZv@151>u$hNdO zs!c2ePy&?y$X$uSPg+q?Y4@=2w>1@9m47n6iCA!}9v~+Dba<-3r;JghV`_<{2Z)=Q zI)xJBod5tYORg06(lrk=;JxF`jr5E&lAQ_KJ1vD93rAcp8>8=)M<;oz`F<;Pe`g+j z807kwcQK%%Ft|_1(Va|oud5P^y=z#iB3yC~yNtU7!&cfu4kA99&NoaaNXU96?sS&*D5J({3Vlfb!3G}#) zW)X*)WhcqME6Fob$dc;MlOwE8bL$t0U<{H)JSs@N?n*7al&la^b3 zL+ySnWn3@g3GaVhIL?}s`)JLu94}LGyX|L2<>Z63d*1E0`zR=foJ3n~h`}A|7wY&Z z6weD^&xv@nzLABc53orbuFc_e#FmjJGsH{tN`O!j6v_-7^qP^Aj<3ECd@D=A7-j0k zqs@9)igcecYM3OSWvYNtPDt*$4tBtrT5>>Dpo91UwnvQ13(dbMvmhIa2qz_LBHE^s z&dr>|gOTF?E^{ZZw94ybca`^^KNm|Xk zZl05i@$lO!+=s66Uve|5r77ya5EfXw?;#`_U=-~30cErwxyh@`fRfplY!D*% zr}~Cby0a$R{`_u{WS|*k)eMcKhkc&E5_S zJ78F2UYncLAaq^zMSt3}QpQg46nUOXXjE)QfddMXA95o0jt>9je%m6UXQ5}w660wb zQc7lEGb+>5S_Ex1xNQ7GxBt)475_?y612pso;=Ohp2;tl)_9cmjeEtQte?+O^te=xeSlM>r$LBQ4 zZBUOti%Z;q0b<__4#nP))`Dlg_GA;~w~bIL-MoZa2pG5HaMB73P=tX^J?e-Pe4tr5 z-9eF#*p?ice@gJjYPL)JMaw+zU1-mC*-B7J3nP*wxS zmIOm;AAm{}cyDBcod1cLPh0{20~tq5p0R!B*7iH@?@>a_ve_KBTM8HWUz zGQ3qh>I?CXVXXh|L4d`*W>t+(lEz3y>gfRzSxVg|r6Hk#Favq58uyK9U;QWb=VWKK z4tSP`jyFcPR3)>_gVnw!@q@*xgyS<4FLa`xV!f(l#y!A-xQbX=8&Xj*?rAiqUhVKG zYOwo70J@leT}v{z@CeLobYPtZ-CsKmNoQECzE=K7UiW_O z!kD3g6C!#1!aWn2#1rjBJ->q&)imgSSUMfn-aW+;n26(1|3z8xv`;AN{XEyVETU8z z#(1~4GMoB5EnS_d8{&Q_COu_wA=ZYPsc{{m4*~uD?69AjkPXXrc4|?ahjA`sj=}yf zHG8HVhdpTF(UfYh6$vUEsv-lwr5_*iJfZl^&X~^h8iyt+lI5mA`R$$>HY)oP|*=~k3VVrgdZ8t_mvntIh5sbDVAjZ4i!9s zjPo|xy7>*@{WDP+bnN`#r27{mzk*vp$$J5tLJb3oMr*1}VFG<7WKYc>V4hG=#)wam z&I{R=QOg_2+B_LTp=2E?2s<9A2%1?cx%ZhY+|(JQG5q*A@*7~TVn66F*GY~;iOcim z4&~|emY$%Q$_slg`Ga$;as_(X)G4G=$bNwj6iCKhZ;-Ewn@6E$5If6_Jnr9s^Jsh^ z2r0zaO*f4^N#7{ax|Ma`gp5){Yea2;i%AV76Cw~UJ$xOx{RaVT7K~)Cr~L zGK_~WcFY0Qa*2McmeqHs^@W_-NW|u`iA0}Y+)wl55AgsKu31yF_w2Q&LVX)PlFkCp z>{=Rc+nO^If|H!bX$b+`tMy>Djw$h|Wl}JAhJ?6jIo0|ky1p{!=sma$ER?%$sH2`IyvbA@p;uqnBWpz8vhe4`%lBnn= zlZVfbj{Xg)1z|O^#wG4|>HLj~iPn~{^(;=6IUc3|-*)jWzX5}YmY3uIx7~2|v%Do6 zlb;HSOQScn|HRqno|fT92RJXNn_b@Q;kZyO{k?dE+Xzc6*}cLy*oeXrAMkbxnRAkN zeh`}1XIi4`zw()s-peH?LsVvM=VKptYaO@#w4tN_QFO2x9)W0W+Y3XCa?^co6-Sv7 zCdh0K@x_4P`k(dT+;Ui>jmYwrrUf6!epPx+YcX=%%ljMuD1lgM2_ zu2r#@5&wn3a(7$A{f6S7YwS+o!lFhKdT#Ck}}@6btKSJ zHQArM;Fw0CsA9_+)9tSLRIN7+Nb7gTD#CxEs8`tI<%$4Pr?$PsB6Uh1Zr*jT5X|-RIwInDp0d z_Q#71a(iFZr%3s#Y_sNk*Lz$tX{Rod|TdGK(?(U-Zymd)=M zNkEui%SuLL9*@^dd(r$sr)PNVsK#}x?SOTo-=oA1{Y7!OxXp+cbPg0cWBc9p{iby~ z@*BCvqk^im@3W2Tf2RIHzf0)4isN3H#jRA9_4DC?;lXAgAz4{#EJRQoQ7x#QBzzy_R^if?>MK`}Sw58JH z`F>*R6tH%2p^Wa=Elsne=5ML_a`5NX4g>A&0vg_yww-?vqXs;N6B}rbxt%x);PR%q zEeWCo$O%cynBIjnHD-HaCYiY?&erbFi^N;t9!;g$|GJE4Ygj8wlO|{4NPHg5Z+K(& zRFQUQ)%~d-w#h#+((!KjOY`{2ZS#-T$-|qyt$&a(K^!Ht>SkkZ%E@Tjtt{xrfAT-H zo#j@053?LR#;~{HQY{v4{#jBsu7(FMtP0hai!>8rzd>dv7&!0j4&aNk64kGu$_eIY z*{nS7EK*Yw8`6yVJI?T5+osJjlGC}^4ZFM`YXw{bRE!rs7~hm+2v|3`ZL_!Q*>)b- z^qz@xfv>|OvhQQa4uh+FC7FN7k?LN{7ExF{R&qK__YuNDe^V6}p*VzegAc}~(nP3t z3FPi-tIc0Cs9~C?+K+;k99s=Y)#JCZ%3<(ApIR8RQ|T_<<+={FaB4c~RI?P^%00a7 zVjJvz)@|jW!{p^X7O7Z+Ls%A8% z{hMJ*#Bx>lM33GZPhzqG_nQW#N2BfHjeqswU)by4q4+;!Ic?#*?{zNLrd4&l^7C&N z-5p2o-#p;|FA^NfT`5-m1{f$r>nsWB?7s*cR{eMH(ElDiSOeH(vg_R~0J>ON*U>2t zXV_QHgvPXLqUcPt7EK39IwoSjd@-8&uXYBKuDgPXt_|{C<$kn z56P7tsHg#f$JYSbnyw3c6nUwYGHaq#tzNHw_+@jni#ME6vF(KW_o;pVD*j>kwwJr6 z$&~E7Nt0BU>-u}<$1#Mq=t+9}znl3la^>Pd0`gJQvsC#W&FuM7%+cGS_u-)Vqi{_r z*ogoFu+c$cHdv8I(aY+jVUjG_u!A>E{Vo;&)TAfn+eE8`y`oE4LQ>{+Yk#(lI-24r zagq3<_wFZ2YJb-^8!a7)xDpukK(>?htvg8f;IA);FD*Sroi+CIlpxq$IX^HkBbLc0 z_`)lX^i7;75r>7pmQpKBAa-$_h!T9ChA&HGxXko8*b33GM;C5P(fdad8h zF%m@d@Dc*3E)ugtR6Hahfe>t20o@v!sn4{%*rpu`agTljtijDqtpbVu_sL2crM}?1 zN~F-|CW$#z`pFxD7C}zIltT}&)=B_TUdebdX*)Np`Ih@)YYe{k#J~o*DLyBK(D?QjpFc>j|&f zamtxAujf|#0tTR4HG1M=6jF4c>N3`% zQVV%hpu16_fj0xFi!ZxR_a17a7d$BRe6os?XrY8EsUXQs0@j1DUtsrhHT*fSAhTGk zFaP8iNk1-G(uR?|De~!^(=tNt3_bh%+e0rzYw=V*J5RmMFNC3I_?RVYW@yAwB%W{j zaB}8nsQ6Z429sY)LK^fe-qqkJO1y~BMLONv*YUNBxMQVLw%+8_TXRHZz{9(B>0Gk%MX_DQycfCjoiH~!L|I4*gG9A zMd*hG*<)?p%yVQ$DcSK&fzk6-J$RxEOwIJSEKZ7@h?6_We2itAy3i?2sx+lu7Z6@W zleGS-(or4)H}!+j6V@WJ3tarU%!lPouNZ^8J`>@79TSy_iE`h3nsxhi1PXtgnr$dH z#zjYR$}mfY|5I7n%TIx>m>97-YH=~p5FHtrQdu9|yMuMQ?5tX4iSMz-?Yl#N2*05o zV&YtD`wSVovPz(*wEy9yjtk+m??})GzDGurOin&@0)(nL%|dcOVHsSPdY5S$p9L8* zUE@UId+fZjJpCdr{%nY~C)bXhZEmSq2hTWb&@CJ6es<;cOm2(_qgLHlpL8ID0ZL<$ zURlHib}DZjEra{KBSk6Qq&9nIGI4RE)5zk6jb46d&D+qNs*?8^yepP4DDb$7q?W9m znlp}dQmgjI%W+ALg-EH2N1WdSj6$1se`e!>5C6=kV@UQEl~%>6;ob=&{}?6OXy-~R@DuLd}2%b~q4DH5mi0>d^>xR{G2uklBA|Gxs10({Jzwhx}kBS~BHtpt}#GIICsY2#^lN*VkMC|HxM=F*!hH~Wg_$7SOI=JhRV z48Mts6{t;zBE)ta;+vHm3}D?c zd(ifhh^#Nia(amMDw(7q(@;htJT6y1Lpq|Gq1mPoG4@Qg7h#@G_dnd%xYaq~LMcw! zaPS%?*aS79a1gP4ALdu!w>u- zf8dF+cwE4AV6YGj@Y%F4PzAGsv(jzyO3{yc&S9=j9@ly~Rpjm#xcRBfunL3Sb%ind z%`0P)PI-6kw+$UNhxJ9=aU4j@iV7LR7tBrL8 z%@e;%!C*>B#ok1HgoB|0f7Y5<*)=gOI{~+1^7(|dj zOkEmoC>esQWK-b1^BZu{`|$Y`8qe>t1XvbgGjB|Gj9X;ysWQSU(?20mng|XF zt?!t$gBAla<4-J|YIU-f%P##9RGxkS9xEE-O}Z@3c+5IO>2vTx8yiL3YadhTVTbq- zVONxE|77uiXaBuf*{p8gPY|z6Kf$7WSkQ-xwH$t0LMH z#%D$cgs;j=s7z>7BrlX?ADd-|Y7xeVd=G20XftON?`+ZtC}dVX=(JU+lFWOPW$1+q zfLXGjs^(E|6Yxx2N`3T+Yg%OzP0jO=8pw!1zHLMLhovml5CywMK!Q2|}UFrv@_?_InYy$duY9-aTM6=aV z!Mpwb;;*QOh|Qx*f#E1RVek$UsF+eqX@-DsjKC+mq{nsZ}6z zq$jH#(WzxEleX4fi76{`b`Fti3yART`-&5_$3=OXSqBJu4EXrIf2%cuN!9dO!gT0k z7c48{-Uw$JfodPtz=55Jj9=WO-O80@820)bkn0E9)Sh;KNq>H*iwjJvA3SR6+#0mc zXiXn5#oCM$GYXISVjaL1a?(HSPOE8HC61q!BOD!fMXf#BasCYSW= zBii!>aXV`s(7M428pBte-vxb)%Jy3gr&&BhafS?MC4Mm3B^kG(YpKeET94J26bQW0 z!ibru!S^khpS&Lc{$f+Q4x7K4r7|&#N8YXp9Htwn-}i{n1w{Ks*tv`^cW{(a;Q>5@ zr%R?yqpx_sZrjl*#$mjed7?p`H09%@Xi;AozxV;ub1H znSfC;z}|XXLlrWB%wbVR`KxkDTJPg5cn^!e7Ta{!Idv{D%5)R)N`tae&10qRBr0Oe zg+KpF`@S})DOAr<#>~ywT~l@VLB;;I#E;xS#+ZJY@1{e7-M#vWxz}7%9tWE^>lUN3 zx;Ikx?1N9x?Syo?B}QKI4;U@`eD2~rF7sdAozmx%28{#fgok zFkatEBqmb0=*Fq$PHB-@PisDKPwI+mRIKcrByS;nh|3k5BH&`|)}$s}&n)BhHcvw{ z-E&^))YX=BlUMW6blOqY2&Cv|Ke2YYncY>)!Um?))*QTGW~}n^bsf++G#))p`#GekjC zDBUDYl#fhTqHbx6Oi?J-+K_(g2eIu6z9M8R;o6PglnPc2wKbFVilk*}rPyAt^n2%& zy)$lyGl9`7E3(&}_C|<6rkB61JNgWG=v47?GySRUp{%c_BPvw6nT^0DZbfqH<*h*KKuyy|^ zBEr5VcEcI)Z;lZA%6H48Ar|k0H(ve*l*za6L~s>`Y)?F5v?7(%Tfha~8_8)RJ;*P4 z6urXb`gh>+-vkwZ;{E`${|^4~^nVomDbxAy`dC*#_8ww&(agZhrHFKHd2dgk8BN%x z$REnvC|dg00C`7+J{UWdU-_V&c{z* z=e|fZkgqL`f5baEnvb2!c~%_2bt*%AaXYaX%nF+3&orJJdi2nsU2Zgbts}hw zjy8k|iV9ke6mEuW3m;T86jtQCxc}wtuF^!~$P_a*W4f5 z{d(i_>FdXM5f9!_>|VH6zI8kJIlJC*r7sEq7f8m)2AU0p>_N!uXKy{-X_z!gF<TZ2?)~ol z=G=4s+_^u#dk(cLxl`yt8c|DJLYP(HZN0}Ri?KRdhL97;6G=0Q-y?-p!mdnLGDVfA z+`XcU94<28rHRWMue%#$ND%Tig`A@1Lx9{s#!^YIQr7L!0wTH91A`jqyJQsG-S-vu z3^35vIz6jqae)O;<iNJ~(&L{#vUB>K&-3r$~oNb-fT%x$Op0jFes_ zRIUXyCNclnHLv9;DZvb_*pk%xa#k*}2kF7(FQ>je@D^BfsBVL zFALGhSpdZlCQn4}H(!9?Ql?P1kgJ-ZBUZDuSn0p9sykIpzZQYdpG+`~Q@EuTjh0-o z$2oaWISJ*49{?zOdet3BV$d=>(8wN>*$^S%#*j?BDHa+|AiNf_w?=){Pug@lK<$O? zOCQflTs?rh&mzt=%#$5^Lpjy;h|=M(^9)nnoXNj)?eLdMo^1sstDf`@cJMO`_By>H zE8Ez4F58_2Pgnard^EB$yqe-0Ht5H}oy$9=^R@x8_EaOI?InBu2h8k4)YDs&9wKB> z>UG(|$rKZ0-8%-k`o6KBFgU|{ITqdNwie!-tu6j0GD<~7Bg5w(q}dmBP>1mg)h~FI z`T7YRVDCd@vz>yNlYu2nH_FSzA;z&4ik2&coHM!*1f;u z8T_Ls;3BDmP9J)wpZ@rlB7S}4#;N#-JAMQ!{G?)}vyfS6WZM5AL>gQAOf)gKdUNwK zV@ZZ-9atiNyM9I2KE#8n9mC1>&*G4L#rX?E<8$R54Ax8;@KS)jifv^H(|l?y1u}C` zZyoxoA`_rzL{U)8KSW+Uj`GGdSK8DGg9*M#tNUla=Z1zob^d>Pf zS%FdvgGxL9d*Lgf6k+(JePRVYOfmdJz{h1auogp;QZdCa{2jO2FAeNXjao0<&=+|h zU2)+(Z+xqK=3H&D1q+{eudZj0t6$Ol?9AIr>zUqmGP`Fm{;X{$Ua8=dsKA;tWy3uT z^g=4TO^s}|;~+NC+yigq1}PfJv;9bQr4)lqDm%rLPMoN03O<_?8o2vJB9AFKU}>n0 zdl5Le*nBhQ_hCss#>GHD=+C@3Fgi2lQB7%bXzm_aHeSgV3j-l(Sf59G^mDnR(+;XR zhNWiBdG{xi04j!NwaoV!SeT+*QmAa;{nQzDJ}rBrWy>`JuQw9pq_zA*p}hWpJ9wg- zTjNq2y*tq$RJkgaTDoqG{Tn&lIB7$GO%*)O<16v(AblvlSQ$E~tFxF*n!`3hF$ zln~7`$>;8X)B3mvSVUAxV&^)o>SV7VMFPUu^~$>dfps|S8w6{8Y)fM3yd|wQ6r9Z4 zj1WvXLTn&A6>7Wua8dBxh9pIjpU2cTNQr9RV%qI!D;ESVC`TU?;{91+FsVsZ{}Hr1#vj^<1bD7YP3bA z$i;0jczfi`P}1G;Pt5dF_}T79_!;dp_#c}3I9R#gzG`c8$2}m>_w0zEJ?v}YgElSb HA7g(42W_zc literal 0 HcmV?d00001 diff --git a/indiweb/main.py b/indiweb/main.py index 1a6c8c3..2917100 100644 --- a/indiweb/main.py +++ b/indiweb/main.py @@ -4,6 +4,7 @@ import logging import os import socket +from contextlib import asynccontextmanager import uvicorn from fastapi.middleware.cors import CORSMiddleware @@ -15,6 +16,7 @@ from .device import Device from .driver import INDI_DATA_DIR, DriverCollection from .indi_server import INDI_CONFIG_DIR, INDI_FIFO, INDI_PORT, IndiServer +from .paa_monitor import PaaMonitor, _default_kstars_log_dirs, paa_router from .routes import router, start_profile from .state import AppState, IndiWebApp @@ -55,6 +57,10 @@ def _build_parser(): help='HTTP server [standalone|apache] (default: standalone') parser.add_argument('--sudo', '-S', action='store_true', help='Run poweroff/reboot commands with sudo') + parser.add_argument('--kstars-logs', default=None, nargs='+', + help='KStars/Ekos log directory/ies. Default: search native and Flatpak locations') + parser.add_argument('--with-paa', action='store_true', + help='Enable the PAA (Polar Alignment Assistant) live monitor') return parser @@ -73,6 +79,14 @@ def parse_args(argv=None): return args +@asynccontextmanager +async def _lifespan(app: IndiWebApp): + """Application lifespan: clean up PAA monitor on shutdown.""" + yield + if app.state.paa_monitor is not None: + await app.state.paa_monitor.shutdown() + + def create_app(argv=None): """ Create and configure the FastAPI application. @@ -92,8 +106,11 @@ def create_app(argv=None): format='%(asctime)s - %(levelname)s: %(message)s', level=logging_level) else: - logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', - level=logging_level) + from uvicorn.logging import DefaultFormatter + handler = logging.StreamHandler() + handler.setFormatter(DefaultFormatter("%(levelprefix)s %(message)s")) + logging.root.addHandler(handler) + logging.root.setLevel(logging_level) logging.debug("command line arguments: " + str(vars(args))) collection = DriverCollection(args.xmldir) @@ -104,7 +121,11 @@ def create_app(argv=None): collection.parse_custom_drivers(db.get_custom_drivers()) templates = Jinja2Templates(directory=views_path) - app = IndiWebApp(title="INDI Web Manager", version=__version__) + paa_monitor = None + if getattr(args, 'with_paa', False): + kstars_log_dirs = args.kstars_logs or [str(d) for d in _default_kstars_log_dirs()] + paa_monitor = PaaMonitor(kstars_log_dirs) + app = IndiWebApp(title="INDI Web Manager", version=__version__, lifespan=_lifespan) app.state = AppState( db=db, collection=collection, @@ -115,6 +136,7 @@ def create_app(argv=None): hostname=socket.gethostname(), saved_profile=None, active_profile="", + paa_monitor=paa_monitor, ) app.add_middleware( @@ -128,6 +150,8 @@ def create_app(argv=None): app.mount("/favicon.ico", StaticFiles(directory=views_path), name="favicon.ico") app.include_router(router) + if paa_monitor is not None: + app.include_router(paa_router) return app diff --git a/indiweb/paa_monitor.py b/indiweb/paa_monitor.py new file mode 100644 index 0000000..a24a82e --- /dev/null +++ b/indiweb/paa_monitor.py @@ -0,0 +1,385 @@ +"""Polar Alignment Assistant (PAA) live monitor for Ekos/KStars logs.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from pathlib import Path + +from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, JSONResponse + +from .routes import get_state, get_state_ws +from .state import AppState + +# KStars Qt log format. Arcseconds: " or \" +# Groups: 1=ts, 2-4=az dms, 5-7=alt dms, 8-10=total dms +PAA_PATTERN = re.compile(r""" + \[ \d{4}-\d{2}-\d{2} T # [YYYY-MM-DDT + (\d{2}:\d{2}:\d{2}\.\d{3}) # (1) HH:MM:SS.mmm timestamp + \s+ [^\]]+ \] # rest of bracket header ] + .* PAA\ Refresh # PAA Refresh marker + .* Corrected\ az: \s* # azimuth label + (-?\d{1,2}) [°] \s* (\d{1,2}) ' \s* (\d{1,2}) # (2-4) az DMS: deg° min' sec + (?:" | \\") # arcsec terminator: " or \" + .* alt: \s* # altitude label + (-?\d{1,2}) [°] \s* (\d{1,2}) ' \s* (\d{1,2}) # (5-7) alt DMS: deg° min' sec + (?:" | \\") # arcsec terminator + .* total: \s* # total label + (\d{1,2}) [°] \s* (\d{1,2}) ' \s* (\d{1,2}) # (8-10) total DMS: deg° min' sec + (?:" | \\") # arcsec terminator +""", re.VERBOSE) + +# Date directory pattern YYYY-MM-DD +DATE_DIR_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + +# Re-discovery: when cached file hasn't been modified for this long, check for newer files +REDISCOVER_AFTER_SEC = 60 + +# Age after which PAA data is considered stale (no new updates) +STALE_THRESHOLD_SEC = 30 + +logger = logging.getLogger(__name__) + +# Default KStars log locations (native and Flatpak) +def _default_kstars_log_dirs() -> list[Path]: + """Return default KStars log directories to search (native first, then Flatpak).""" + home = Path.home() + return [ + home / ".local" / "share" / "kstars" / "logs", + home / ".var" / "app" / "org.kde.kstars" / "data" / "kstars" / "logs", + ] + + +def _match_to_dict(match: tuple, file_mtime: float) -> dict: + """Build PAA result dict from regex match groups. Passes DMS as display strings.""" + az_deg_str = match[1] + alt_deg_str = match[4] + az_direction = "left" if az_deg_str.lstrip().startswith("-") else "right" + alt_direction = "down" if alt_deg_str.lstrip().startswith("-") else "up" + # Format DMS display strings: DD° MM' SS" + az = f"{abs(int(az_deg_str)):02d}° {int(match[2]):02d}' {int(match[3]):02d}\"" + alt = f"{abs(int(alt_deg_str)):02d}° {int(match[5]):02d}' {int(match[6]):02d}\"" + total = f"{int(match[7]):02d}° {int(match[8]):02d}' {int(match[9]):02d}\"" + total_arcsec = int(match[7]) * 3600 + int(match[8]) * 60 + int(match[9]) + return { + "timestamp": match[0], + "az": az, + "alt": alt, + "total": total, + "total_arcsec": total_arcsec, + "az_direction": az_direction, + "alt_direction": alt_direction, + "file_mtime": file_mtime, + } + + +class PaaMonitor: + """Log watcher and WebSocket manager for PAA live updates.""" + + def __init__(self, log_base_dirs: str | list[str]) -> None: + """Initialize with one or more log base directories to search. + When a list, searches all and uses the most recently modified log. + Supports native (~/.local/share/kstars/logs) and Flatpak + (~/.var/app/org.kde.kstars/data/kstars/logs) locations. + """ + if isinstance(log_base_dirs, str): + self._log_base_dirs = [Path(log_base_dirs).expanduser()] + else: + self._log_base_dirs = [Path(d).expanduser() for d in log_base_dirs] + self._clients: set[WebSocket] = set() + self._monitor_task: asyncio.Task | None = None + self._cached_log_path: str | None = None + self._last_entry_mtime: float = 0 + self._last_no_match_log: tuple[str, float] = ("", 0) # (path, time) for throttling + self._last_diagnostic: str = "" # User-facing message when discovery/parse fails + self._tail_path: str | None = None # path for which _tail_offset applies + self._tail_offset: int = 0 # byte offset: only read lines after this (set on first connect) + + def _find_latest_log_in_dir(self, base_dir: Path) -> tuple[str | None, str]: + """Find the most recent .txt log in a single base directory. + Returns (path, diagnostic). path is None on failure. + """ + if not base_dir.exists(): + return None, f"Log directory does not exist: {base_dir}" + + date_dirs = [] + for entry in base_dir.iterdir(): + if entry.is_dir() and DATE_DIR_PATTERN.match(entry.name): + date_dirs.append(entry) + date_dirs.sort(key=lambda d: d.name, reverse=True) + + if not date_dirs: + return None, f"No date subdirectories (YYYY-MM-DD) in {base_dir}" + + latest_dir = date_dirs[0] + txt_files = list(latest_dir.glob("*.txt")) + if not txt_files: + return None, f"No .txt files in {latest_dir}" + + latest_file = max(txt_files, key=os.path.getmtime) + return str(latest_file), "" + + def _find_latest_log(self) -> str | None: + """Discover the most recent Ekos log file across all configured base directories. + Searches native and Flatpak locations, picks the newest by mtime. + """ + candidates: list[tuple[str, float]] = [] # (path, mtime) + diagnostics: list[str] = [] + + for base_dir in self._log_base_dirs: + path, diag = self._find_latest_log_in_dir(base_dir) + if path: + try: + mtime = os.path.getmtime(path) + candidates.append((path, mtime)) + except OSError: + pass + elif diag: + diagnostics.append(diag) + + if candidates: + path = max(candidates, key=lambda x: x[1])[0] + self._last_diagnostic = "" + return path + + if len(self._log_base_dirs) == 1: + self._last_diagnostic = diagnostics[0] if diagnostics else f"Log directory does not exist: {self._log_base_dirs[0]}" + else: + checked = ", ".join(str(d) for d in self._log_base_dirs) + self._last_diagnostic = f"No KStars log directory found. Checked: {checked}" + logger.info("PAA monitor: %s", self._last_diagnostic) + return None + + def _should_rediscover(self) -> bool: + """Check if we should re-run log discovery (new session, etc).""" + if self._cached_log_path is None: + return True + if not os.path.exists(self._cached_log_path): + return True + mtime = os.path.getmtime(self._cached_log_path) + age = time.time() - mtime + if age > REDISCOVER_AFTER_SEC: + return True + return False + + def _get_current_log_path(self) -> str | None: + """Get the log file to read, using cache when appropriate.""" + if self._should_rediscover(): + path = self._find_latest_log() + if path: + self._cached_log_path = path + else: + self._cached_log_path = None + self._tail_path = None + return self._cached_log_path + + def _parse_latest(self) -> dict | None: + """Read the discovered log file and extract the latest PAA entry from tail only. + Only considers lines after connection (tail). + """ + path = self._get_current_log_path() + if not path: + return None # _last_diagnostic already set by _find_latest_log + + try: + if self._tail_path is None or self._tail_path != path: + self._tail_path = path + if self._clients: + self._tail_offset = os.path.getsize(path) if os.path.exists(path) else 0 + else: + self._tail_offset = 0 + with open(path, "r", encoding="utf-8", errors="replace") as f: + f.seek(self._tail_offset) + content = f.read() + self._tail_offset = f.tell() # Next poll reads from here + + if not content: + return None # No new data written since last poll -- expected during tail + + matches = PAA_PATTERN.findall(content) + + if not matches: + # Content was written but contained no PAA lines. + # Throttle: log at most once per 30s per path to avoid flooding. + now = time.time() + if self._last_no_match_log[0] != path or (now - self._last_no_match_log[1]) > 30: + self._last_diagnostic = ( + f"No PAA data in {path}. Enable Ekos 'Log to file' and run PAA." + ) + logger.debug( + "PAA monitor: no regex match in %s (pattern: PAA Refresh, Corrected az: DD° MM' SS\", alt:, total:)", + path, + ) + self._last_no_match_log = (path, now) + return None + + self._last_diagnostic = "" + last_match = matches[-1] + file_mtime = os.path.getmtime(path) + logger.debug( + "PAA monitor: parsed ts=%s az=%s alt=%s", + last_match[0], last_match[1], last_match[4], + ) + return _match_to_dict(last_match, file_mtime) + except (OSError, ValueError) as e: + self._last_diagnostic = f"Error reading log: {e}" + logger.warning("PAA monitor: parse error reading %s: %s", path, e) + return None + + async def _broadcast(self, message: dict) -> None: + """Send JSON message to all connected clients, remove dead connections.""" + dead = set() + payload = json.dumps(message) + for ws in list(self._clients): + try: + await ws.send_text(payload) + except Exception: + logger.debug("Dropping dead PAA WebSocket client") + dead.add(ws) + for ws in dead: + self._clients.discard(ws) + + def _entry_payload(self, entry: dict) -> dict: + """Copy entry for client (exclude file_mtime).""" + return {k: v for k, v in entry.items() if k != "file_mtime"} + + async def _monitor_loop(self) -> None: + """Poll logs periodically and broadcast updates to WebSocket clients. + Only broadcasts full update when a new PAA match is found; otherwise sends heartbeat. + Poll interval adapts: 1.5s active, 3s stale, 5s waiting. + """ + last_entry: dict | None = None + while self._clients: + entry = self._parse_latest() + now = time.time() + poll_delay = 1.5 # default: active + + if entry: + age = now - entry["file_mtime"] + last_entry = entry + self._last_entry_mtime = entry["file_mtime"] + msg = self._entry_payload(entry) + + if age > STALE_THRESHOLD_SEC: + msg.update(type="status", state="stale", message=f"No new PAA data for {int(age)}s") + await self._broadcast(msg) + poll_delay = 3.0 + else: + msg.update(type="update", state="active") + await self._broadcast(msg) + else: + if last_entry: + age = now - self._last_entry_mtime + if age > STALE_THRESHOLD_SEC: + msg = self._entry_payload(last_entry) + msg.update(type="status", state="stale", message=f"No new PAA data for {int(age)}s") + await self._broadcast(msg) + poll_delay = 3.0 + else: + await self._broadcast({"type": "heartbeat"}) + else: + message_text = ( + self._last_diagnostic if self._last_diagnostic else "Waiting for PAA data..." + ) + await self._broadcast({ + "type": "status", + "state": "waiting", + "message": message_text, + }) + poll_delay = 5.0 + + await asyncio.sleep(poll_delay) + + def connect(self, ws: WebSocket) -> None: + """Register a WebSocket client and start monitor loop if first client.""" + self._clients.add(ws) + if len(self._clients) == 1 and (self._monitor_task is None or self._monitor_task.done()): + self._tail_path = None # Force fresh tail offset for new connection + self._monitor_task = asyncio.create_task(self._monitor_loop()) + + def disconnect(self, ws: WebSocket) -> None: + """Unregister a WebSocket client.""" + self._clients.discard(ws) + + async def shutdown(self) -> None: + """Cancel the monitor task if running. Call from app shutdown/lifespan.""" + if self._monitor_task and not self._monitor_task.done(): + self._monitor_task.cancel() + try: + await self._monitor_task + except asyncio.CancelledError: + pass + self._clients.clear() + + def get_status(self) -> dict: + """Return current PAA status as a serializable dict (read-only, no tail mutation). + + Unlike ``_parse_latest`` this performs a standalone full-file read so it + can be called safely from the REST endpoint without interfering with the + WebSocket tail state. + """ + path = self._find_latest_log() + if not path: + return { + "state": "waiting", + "message": self._last_diagnostic or "Waiting for PAA data...", + } + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + matches = PAA_PATTERN.findall(content) + if not matches: + return { + "state": "waiting", + "message": f"No PAA data in {path}. Enable Ekos 'Log to file' and run PAA.", + } + last_match = matches[-1] + file_mtime = os.path.getmtime(path) + entry = _match_to_dict(last_match, file_mtime) + payload = {k: v for k, v in entry.items() if k != "file_mtime"} + return {"state": "active", **payload} + except (OSError, ValueError) as e: + return {"state": "waiting", "message": f"Error reading log: {e}"} + + +paa_router = APIRouter() + + +@paa_router.get("/paa", response_class=HTMLResponse) +async def paa_page(request: Request, state: AppState = Depends(get_state)): + """Render the PAA monitor page.""" + return state.templates.TemplateResponse(request, "paa.tpl", {}) + + +@paa_router.get("/api/paa/status", tags=["PAA"]) +async def paa_status(state: AppState = Depends(get_state)): + """Get current PAA status (REST API).""" + monitor = state.paa_monitor + if monitor is None: + return JSONResponse({"state": "disabled"}) + return JSONResponse(monitor.get_status()) + + +@paa_router.websocket("/ws/paa") +async def paa_websocket(websocket: WebSocket, state: AppState = Depends(get_state_ws)): + """WebSocket endpoint for live PAA updates.""" + monitor = state.paa_monitor + if monitor is None: + await websocket.close() + return + await websocket.accept() + monitor.connect(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + except Exception: + logger.debug("PAA WebSocket connection error", exc_info=True) + finally: + monitor.disconnect(websocket) diff --git a/indiweb/routes.py b/indiweb/routes.py index c41aaea..518e64c 100644 --- a/indiweb/routes.py +++ b/indiweb/routes.py @@ -9,7 +9,7 @@ from threading import Timer from typing import cast -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket from fastapi.responses import HTMLResponse, JSONResponse from importlib_metadata import version @@ -30,6 +30,11 @@ def get_state(request: Request) -> AppState: return cast(AppState, request.app.state) +def get_state_ws(websocket: WebSocket) -> AppState: + """Extract typed app state from WebSocket.""" + return cast(AppState, websocket.app.state) + + def get_db(request: Request) -> Database: state: AppState = get_state(request) return state.db diff --git a/indiweb/state.py b/indiweb/state.py index 5d4dc5a..aaa2508 100644 --- a/indiweb/state.py +++ b/indiweb/state.py @@ -3,11 +3,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from fastapi import FastAPI from fastapi.templating import Jinja2Templates +if TYPE_CHECKING: + from .paa_monitor import PaaMonitor + from .database import Database from .device import Device from .driver import DriverCollection @@ -27,6 +30,7 @@ class AppState: hostname: str saved_profile: str | None active_profile: str + paa_monitor: PaaMonitor | None = None class IndiWebApp(FastAPI): diff --git a/indiweb/views/form.tpl b/indiweb/views/form.tpl index 8c32f03..30b006d 100644 --- a/indiweb/views/form.tpl +++ b/indiweb/views/form.tpl @@ -27,6 +27,7 @@

{{hostname}} INDI Web Manager

+ PAA API
diff --git a/indiweb/views/paa.tpl b/indiweb/views/paa.tpl new file mode 100644 index 0000000..7940584 --- /dev/null +++ b/indiweb/views/paa.tpl @@ -0,0 +1,308 @@ + + + + + + PAA Live Monitor + + + +
+
+ + + Connecting... + + + +
+ +
+
+
Total
+
+
+
+
+
Altitude
+
+
+
+
+
Azimuth
+
+
+
+
+ +
+ + +
+ + ← Back to INDI Web Manager + + + + diff --git a/tests/test_paa_monitor.py b/tests/test_paa_monitor.py new file mode 100644 index 0000000..8f1ff83 --- /dev/null +++ b/tests/test_paa_monitor.py @@ -0,0 +1,352 @@ +"""Tests for PAA (Polar Alignment Assistant) monitor.""" + +import os + +import pytest +from fastapi.testclient import TestClient + +from indiweb.paa_monitor import ( + DATE_DIR_PATTERN, + PAA_PATTERN, + PaaMonitor, +) + +# --- Regex tests --- + + +def test_paa_pattern_matches_real_ekos_format(): + """PAA regex matches real Ekos Qt log format (DMS values).""" + line = ( + '[2026-02-14T16:31:36.864 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(1): Corrected az: 01° 06\' 18" alt: 00° 47\' 11" total: 01° 21\' 23""' + ) + m = PAA_PATTERN.search(line) + assert m is not None + assert m.group(1) == "16:31:36.864" + assert m.group(2) == "01" + assert m.group(3) == "06" + assert m.group(4) == "18" + assert m.group(5) == "00" + assert m.group(6) == "47" + assert m.group(7) == "11" + assert m.group(8) == "01" + assert m.group(9) == "21" + assert m.group(10) == "23" + + +def test_paa_pattern_matches_negative_azimuth(): + """PAA regex captures negative DMS (southern hemisphere).""" + line = ( + '[2026-02-14T12:00:00.000 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(1): Corrected az: -01° 30\' 00" alt: 00° 47\' 11" total: 01° 21\' 23""' + ) + m = PAA_PATTERN.search(line) + assert m is not None + assert m.group(2) == "-01" + assert m.group(5) == "00" + + +def test_paa_parses_negative_zero_dms(tmp_path): + """PaaMonitor preserves sign when parsing -00deg 30' 10" (int('-00') loses sign).""" + logs_dir = tmp_path / "kstars_logs" + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir(parents=True) + log_file = date_dir / "ekos.txt" + # -00° 30' 10" (az), 00° 47' 11" for alt + log_file.write_text( + '[2026-02-14T22:15:30.123 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(1): Corrected az: -00° 30\' 10" alt: 00° 47\' 11" total: 00° 57\' 45""\n' + ) + monitor = PaaMonitor(str(logs_dir)) + entry = monitor._parse_latest() + assert entry is not None + assert entry["az_direction"] == "left" + assert "00° 30' 10\"" in entry["az"] + assert entry["alt"] == "00° 47' 11\"" + + +def test_paa_pattern_rejects_non_paa_line(): + """PAA regex does not match lines without PAA Refresh.""" + line = '[2026-02-14T22:15:30.123 EST INFO ] - "Some other log message"' + assert PAA_PATTERN.search(line) is None + + +def test_date_dir_pattern(): + """Date directory pattern matches YYYY-MM-DD.""" + assert DATE_DIR_PATTERN.match("2026-02-14") + assert DATE_DIR_PATTERN.match("2024-01-01") + assert not DATE_DIR_PATTERN.match("2026-2-14") + assert not DATE_DIR_PATTERN.match("logs") + + +# --- PaaMonitor tests --- + + +@pytest.fixture +def tmp_kstars_logs(tmp_path): + """Create temp KStars log directory with sample PAA content (real Ekos format).""" + logs_dir = tmp_path / "kstars_logs" + logs_dir.mkdir() + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir() + log_file = date_dir / "ekos_session.txt" + log_file.write_text( + '[2026-02-14T22:15:30.123 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(1): Corrected az: 01° 06\' 18" alt: 00° 47\' 11" total: 01° 21\' 23""\n' + '[2026-02-14T22:15:32.456 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(2): Corrected az: 01° 06\' 24" alt: 00° 47\' 10" total: 01° 21\' 27""\n' + ) + return str(logs_dir) + + +def test_monitor_find_latest_log(tmp_kstars_logs): + """PaaMonitor discovers latest log file in date directory.""" + monitor = PaaMonitor(tmp_kstars_logs) + path = monitor._find_latest_log() + assert path is not None + assert "2026-02-14" in path + assert path.endswith(".txt") + + +def test_monitor_parse_latest(tmp_kstars_logs): + """PaaMonitor parses latest PAA entry from log file (real DMS format).""" + monitor = PaaMonitor(tmp_kstars_logs) + entry = monitor._parse_latest() + assert entry is not None + assert entry["timestamp"] == "22:15:32.456" + assert entry["az"] == "01° 06' 24\"" + assert entry["alt"] == "00° 47' 10\"" + assert entry["total"] == "01° 21' 27\"" + + +def test_monitor_searches_multiple_locations(tmp_path): + """PaaMonitor searches all configured dirs and picks the newest log.""" + native_logs = tmp_path / "native" / "kstars" / "logs" / "2026-02-14" + flatpak_logs = tmp_path / "flatpak" / "kstars" / "logs" / "2026-02-14" + native_logs.mkdir(parents=True) + flatpak_logs.mkdir(parents=True) + native_log = native_logs / "old.txt" + flatpak_log = flatpak_logs / "new.txt" + paa_line = '[2026-02-14T22:15:30.123 EST INFO ] - "PAA Refresh(1): Corrected az: 01° 06\' 18" alt: 00° 47\' 11" total: 01° 21\' 23"\n' + native_log.write_text(paa_line) + flatpak_log.write_text(paa_line) + # Make flatpak log newer + import time + time.sleep(0.01) + flatpak_log.touch() + # Base dirs are the "logs" dirs that contain YYYY-MM-DD subdirs + base_dirs = [str(native_logs.parent), str(flatpak_logs.parent)] + monitor = PaaMonitor(base_dirs) + path = monitor._find_latest_log() + assert path is not None + assert "flatpak" in path + entry = monitor._parse_latest() + assert entry is not None + + +def test_monitor_no_logs_dir(): + """PaaMonitor returns None when log directory does not exist.""" + monitor = PaaMonitor("/nonexistent/path/to/logs") + assert monitor._find_latest_log() is None + assert monitor._parse_latest() is None + + +def test_monitor_empty_logs_dir(tmp_path): + """PaaMonitor returns None when directory has no date subdirs.""" + empty = tmp_path / "empty_logs" + empty.mkdir() + monitor = PaaMonitor(str(empty)) + assert monitor._find_latest_log() is None + assert monitor._parse_latest() is None + + +def test_monitor_rejects_format_without_degree_symbols(tmp_path): + """Monitor returns None when log format lacks required degree/arcmin symbols.""" + logs_dir = tmp_path / "kstars_logs" + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir(parents=True) + log_file = date_dir / "odd_format.txt" + log_file.write_text( + '[2026-02-14T22:15:30.123 EST INFO ] - "PAA Refresh(1): Corrected az: 01 06 18 alt: 00 47 11 total: 01 21 23"\n' + ) + monitor = PaaMonitor(str(logs_dir)) + entry = monitor._parse_latest() + assert entry is None + + +def test_monitor_tail_ignores_existing_content_when_client_connected(tmp_path): + """With a client connected, only content appended after connect is parsed (tail).""" + logs_dir = tmp_path / "kstars_logs" + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir(parents=True) + log_file = date_dir / "ekos.txt" + paa_line = ( + '[2026-02-14T22:15:30.123 EST INFO ] - ' + '"PAA Refresh(1): Corrected az: 01° 06\' 18" alt: 00° 47\' 11" total: 01° 21\' 23"\n' + ) + log_file.write_text(paa_line) + monitor = PaaMonitor(str(logs_dir)) + monitor._clients.add(object()) # Simulate connected client + entry = monitor._parse_latest() + assert entry is None # Tail is empty (content existed before "connect") + monitor._clients.clear() + + +# --- API and WebSocket tests --- + + +@pytest.fixture +def paa_client(tmp_conf, xmldir, tmp_path): + """TestClient with PAA enabled and temp kstars logs (real Ekos format).""" + from indiweb.main import create_app + + logs_dir = tmp_path / "kstars_logs" + logs_dir.mkdir() + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir() + log_file = date_dir / "ekos.txt" + log_file.write_text( + '[2026-02-14T22:15:30.123 EST INFO ][ org.kde.kstars.ekos.align] - ' + '"PAA Refresh(1): Corrected az: 00° 06\' 00" alt: 00° 12\' 00" total: 00° 13\' 25""\n' + ) + + fifo_path = os.path.join(tmp_conf, "indi_fifo") + argv = [ + "--conf", tmp_conf, + "--fifo", fifo_path, + "--xmldir", xmldir, + "--indi-port", "17624", + "--with-paa", + "--kstars-logs", str(logs_dir), + ] + app = create_app(argv) + return TestClient(app) + + +def test_paa_status_api_returns_active_when_data(paa_client): + """GET /api/paa/status returns active state with PAA data when log has content.""" + r = paa_client.get("/api/paa/status") + assert r.status_code == 200 + data = r.json() + assert data["state"] == "active" + assert "az" in data + assert "alt" in data + assert "total" in data + assert "total_arcsec" in data + assert "az_direction" in data + assert "alt_direction" in data + + +def test_paa_status_api_returns_waiting_when_no_logs(tmp_conf, xmldir, tmp_path): + """GET /api/paa/status returns waiting state when log directory does not exist.""" + from indiweb.main import create_app + + nonexistent = tmp_path / "nonexistent_logs" + assert not nonexistent.exists() + fifo_path = os.path.join(tmp_conf, "indi_fifo") + argv = [ + "--conf", tmp_conf, "--fifo", fifo_path, "--xmldir", xmldir, + "--indi-port", "17624", "--with-paa", "--kstars-logs", str(nonexistent), + ] + app = create_app(argv) + client = TestClient(app) + r = client.get("/api/paa/status") + assert r.status_code == 200 + data = r.json() + assert data["state"] == "waiting" + assert "does not exist" in data["message"] + + +def test_paa_page_renders(paa_client): + """GET /paa returns HTML page.""" + r = paa_client.get("/paa") + assert r.status_code == 200 + assert b"PAA" in r.content + assert b"altitude" in r.content.lower() + assert b"azimuth" in r.content.lower() + + +def test_paa_websocket_shows_diagnostic_when_log_dir_missing(tmp_conf, xmldir, tmp_path): + """WebSocket sends diagnostic message when log directory does not exist.""" + from indiweb.main import create_app + + nonexistent_logs = tmp_path / "nonexistent_kstars_logs" + assert not nonexistent_logs.exists() + fifo_path = os.path.join(tmp_conf, "indi_fifo") + argv = [ + "--conf", tmp_conf, + "--fifo", fifo_path, + "--xmldir", xmldir, + "--indi-port", "17624", + "--with-paa", + "--kstars-logs", str(nonexistent_logs), + ] + app = create_app(argv) + client = TestClient(app) + with client.websocket_connect("/ws/paa") as ws: + msg = ws.receive_json() + assert msg["type"] == "status" + assert msg["state"] == "waiting" + assert "does not exist" in msg["message"] + assert str(nonexistent_logs) in msg["message"] + + +def test_paa_websocket_connects(paa_client): + """WebSocket /ws/paa accepts connection and sends messages.""" + with paa_client.websocket_connect("/ws/paa") as ws: + # Should receive at least one message (update, status, or heartbeat) + msg = ws.receive_json() + assert "type" in msg + assert msg["type"] in ("update", "status", "heartbeat") + if msg["type"] == "update": + assert "az" in msg + assert "alt" in msg + assert "total" in msg + assert "total_arcsec" in msg + assert "az_direction" in msg + assert "alt_direction" in msg + elif msg["type"] == "status": + assert "state" in msg + assert "message" in msg + + +def test_paa_websocket_receives_heartbeat(tmp_conf, xmldir, tmp_path): + """WebSocket can receive heartbeat (sent when no new match, has last_entry).""" + from indiweb.main import create_app + + logs_dir = tmp_path / "kstars_logs" + date_dir = logs_dir / "2026-02-14" + date_dir.mkdir(parents=True) + log_file = date_dir / "ekos.txt" + paa_line = ( + '[2026-02-14T22:15:30.123 EST INFO ] - ' + '"PAA Refresh(1): Corrected az: 01° 06\' 18" alt: 00° 47\' 11" total: 01° 21\' 23"\n' + ) + log_file.write_text(paa_line) + fifo_path = os.path.join(tmp_conf, "indi_fifo") + argv = [ + "--conf", tmp_conf, + "--fifo", fifo_path, + "--xmldir", xmldir, + "--indi-port", "17624", + "--with-paa", + "--kstars-logs", str(logs_dir), + ] + app = create_app(argv) + client = TestClient(app) + with client.websocket_connect("/ws/paa") as ws: + msg1 = ws.receive_json() + assert msg1["type"] in ("status", "update", "heartbeat") + if msg1["type"] == "update": + pass # Got update from tail (content was written before connect - no, we have clients so tail) + msgs = [msg1] + for _ in range(5): + try: + m = ws.receive_json() + msgs.append(m) + if m["type"] == "heartbeat": + break + except Exception: + break + types = [m["type"] for m in msgs] + assert any(t in ("heartbeat", "status") for t in types), f"Expected heartbeat or status, got: {types}"