From a568083ccef106ef584645142cfd6ad2a4341a0c Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Sun, 1 Mar 2026 04:16:27 +0700 Subject: [PATCH] UI_test_updated --- Dockerfile | 2 +- README.md | 63 +++- __pycache__/service.cpython-311.pyc | Bin 34064 -> 46516 bytes ...e_integration.cpython-311-pytest-8.2.2.pyc | Bin 15340 -> 47233 bytes config.template.json | 35 +- docker-compose.yml | 8 +- docker/config.docker.json | 85 +++++ docker/config.docker.test.json | 35 +- service.py | 310 ++++++++++++++++- test_service_integration.py | 321 +++++++++++++++++- web/app.js | 197 ++++++++++- web/index.html | 193 +++++++---- web/styles.css | 199 ++++++++--- 13 files changed, 1307 insertions(+), 141 deletions(-) create mode 100644 docker/config.docker.json diff --git a/Dockerfile b/Dockerfile index a5f0ec1..746eb73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 8081 -CMD ["python", "service.py", "--config", "docker/config.docker.json"] +CMD ["python", "service.py", "--config", "docker/config.docker.test.json"] diff --git a/README.md b/README.md index 8091190..86da6c6 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,11 @@ docker compose --profile test up --build ``` Открыть: -- UI: `http://localhost:8081/ui` -- Полный результат: `http://localhost:8081/result` -- Частоты: `http://localhost:8081/frequencies` -- Полученные output-sink данные: `http://localhost:8080/latest` +- UI: `http://127.0.0.1:38081/ui` +- Полный результат: `http://127.0.0.1:38081/result` +- Частоты: `http://127.0.0.1:38081/frequencies` +- Полученные output-sink данные (изнутри сети контейнеров): + - `docker compose --profile test exec output-sink wget -qO- http://127.0.0.1:8080/latest` Остановить: ```bash @@ -89,6 +90,11 @@ cp config.template.json config.json docker compose --profile prod up --build ``` +Доступ к API/UI в `prod`: +- `http://127.0.0.1:38082/ui` +- `http://127.0.0.1:38082/result` +- `http://127.0.0.1:38082/frequencies` + Остановить: ```bash docker compose --profile prod down @@ -104,7 +110,8 @@ docker compose --profile prod down Дополнительно: - `GET /result` возвращает `output_delivery`. - `GET /frequencies` тоже возвращает `output_delivery`. -- `GET http://localhost:8080/latest` показывает, что именно принял output-sink. +- `docker compose --profile test logs output-sink -f` показывает факт приема. +- `GET /latest` на `output-sink` доступен изнутри docker-сети. ## Конфиг (основные поля) @@ -179,4 +186,48 @@ python service.py --config config.json ``` UI: -- `http://127.0.0.1:8081/ui` +- `http://127.0.0.1:38081/ui` + +## Защита write-endpoints токеном + +Для защиты изменений состояния можно задать токен в конфиге: + +```json +{ + "runtime": { + "write_api_token": "change-me" + } +} +``` + +После этого `POST /refresh` и `POST /config` требуют токен в одном из заголовков: +- `X-API-Token: ` +- `Authorization: Bearer ` + +Что важно: +- `GET` endpoints остаются без токена. +- `GET /config` отдает `runtime.write_api_token` в редактированном виде (`""`) и флаг `write_api_token_set`. +- В UI во вкладке `Servers` есть поле `Write API token (session only)`: + - токен хранится только в памяти браузера; + - используется для `POST /refresh` и `POST /config`. + +## Фильтры входных данных по каждому серверу + +Для каждого ресивера в `input.receivers[]` можно задать `input_filter`: + +```json +{ + "input_filter": { + "enabled": true, + "min_frequency_mhz": 430.0, + "max_frequency_mhz": 440.0, + "min_rssi_dbm": -80.0, + "max_rssi_dbm": -40.0 + } +} +``` + +Смысл: +- фильтр применяется отдельно к данным каждого ресивера до триангуляции; +- участвуют только измерения, попавшие в диапазоны частоты и RSSI; +- если после фильтрации у ресивера нет данных, цикл расчета возвращает ошибку. diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 3ea6cfa19bae614df779dd49bf1f1345d0dcd60d..61d27a23e7b364ff214414f95cd0d777df0e7e01 100644 GIT binary patch delta 20945 zcmc(HX?Ppgb?6NCeIY;qBme>+!3E+jilnHOS}2KHD9MtwP?lhd14^K{c>r1>0|u0% zCbX-5rhSuIN#urc99xN%RE?6pIZOW|iaLwS!ugfp^)MZd^a8A2$pd=(~K(IBps=(Xe97JYF_fHf|ZTj9Uk- z}I(2 zR~WA66$!(<2LIWnv~vfzjex$E8{js>vx7UxZGq>yP$#$b0>$Gk5+>9I|Le%1O)k|_ zEGId_1PY8-v!a`M2y;e}K%`mLoee3M)XXF`HQ3IH~pUf*On%7{k89)RZ;q~$j z@JO42!=Z_2h!2kNljFh3v!nbvh+cF1&y_)Gya$5(1_YZBY(}sZ!8QN^1>cTgu?h^W zMPNqI0U#jZyWo*lk56);F)X^IVqI`*h>wJVG?m{8m{?lme*&0iQYPCAn&-*O+V|N` zGOO!nJ8u7(?#t|>ADO)4;YcJran3su4vlf%`i~^_Ede>LFs(j1GZpIN`AMEOB`q5t zik?ra!jX~iL^v7>sQ5l8o!^CE4}yLKd&&0j9NN zfZ!m4LjYjlk7F38BcS}%xsD>zqW~gG0ABGDslpqiy$)UsN6!Z@gyJ(Id5njKBGY_m z94Lk#ggjVHybZ+s^m>He@gEB^2eoaSMQ?-uogo@Hqe)8h5lw=NC z<1u)Dfhiqq4%Si(hWnWJ${_|Oy6p^4#{;Q0C3vQZ z7N+eqJoqzWIEdlL0noi5Q8v5{5OC1b>OF(O{(<0uJ%d0Krw}bJ5;6u0lZvgUb1F`$ zKfB)Y4GNy0eQ;Jokm&;aN1g;Q&wQX^%GRb-+KfbL&}2-E)srgsrOKR{3VoU49s}S$ zg81dV^E(#GGcHD@O&9_RCEh8cbwQU>Y7&OVgc5Iv_{>bw=uN1+83m)#FY1<+u7uJh zB5F;a=}Br`37JcrXdNFK4uzkDoe6f1v^M`1=7x)Ai$Mk@Rp455NXePwE^=(v>WE3^ zC1GYxl3P%K!^9*}8F{>^B4CLkxo0n=7FcJ}7p1SjqJ1sDaGwGiB0p8z$;q=u*_<@{ zQYSN3Aq~y@I7LhXwBwGdb0uL7oPxa3RMAYCrWik~MY@a0AcyjyIh2^gvpR2tW*7z1a77+0J#U6{y7~#czZWq3ba8?z`L4I?T%h8wzq~$7wXfgx`Q@sAg4k ze=n@jNgPic41O$*a0QGB*8(=TVcX7*RV1284)-v1qumW;zSe+N*bq zS!u%+&+bCGNevcHCy$%X)0posU?BoeQ7~mycg((Jvu~;8R&~Xv-HzzwA$H>jiB+3O>i#6@w#D@#xY80OFlA91{%35~^c&?Y`hocK zNq1OqhgTf#YolM7_}s*eGp`>@R_zq3b|xKtf}^iE;b^jIyHK?~>DVDSb^y0EC^8Iy z`S>p2v?@cw;1iV9OUDvTTZP)*Wo2(d*$V@g(uJ-$6S|Oi-;+v=n)?iZ3?or%aL+a@ zObAN%l49xOg12i~*_BXs(QKzM+bQvWFq^Jy{y=t9*AZ&`Z2U^{4+;oKAz)J0 zS>|NLn|_St6cyw-);>BXKgWO+#(?~`i;4)!IaN#7=PsFm)!i zU4pg?*JgEm#b{aFpEUXeqc5TI{R{|sGSRXvPp1i4InwY0aTdol&y6afBD-Mb_pdw< zbJu8|-;=6t0VdSL>4a%F+{+Xux^e;fn$lTQ zCfAZGVOkIOGKDwxi%&5QF;u2!jn5dbxt1;^9BoNeyP#@M&^x8I%pas=PTEaf0ep)Q)IDhbUTQpikpU4K*oP9E9Zk~po*Gd1hZF6$r(8l#WRS=XoVtPEp4*^q0DO1!@<{$)=dl@5W3qC=H%h&GNi|?lA!F zBUsRVpdnZ7A5)-~!LPsF?s$Ujq+O&fSP!uQmbc(TmX@@%iLyFHz_f@A0IZyOzJc7S z=w$zm=qq=D?s~LxQyiCSS`MPcEFha>z87$J>JkEf+$p$Sn>lZmSr%i^EIbTtRq zLleWHh*m8n;NMZ_WLX#M&~TF&%v>L`yxJ0^7{Rls{AI-{{w1hE#*$CC&a$1i|K9bxQva7A zMr^V2d}xRZ@sY2vX=OA#9-5qvek7-c3qrgXw8e9*_PJRfsrOBE;#`T8f;1CQ^R&E| zG4>M(kSIXu3q1k*RVXqwIS~o*m&p%(ZZ9=oSb`&==g>63)z(aVoIiNI46q2CIw=o*H<{;i-kEg0pf4cq-u?4M{4loHM~w&9Sh` zY-#Pb>FDH<5dUO&I5a@*8$8OpY3=aj#7OuYkYMyYe+6QA2S&@to?5dOWqB#8D1O~- zuJ$d7ELzSmq^hA>!kLhRNtX1moU3IqCTM_YA8RJP4Ts4LXOum~Mm^+yLmz7*|Jcwh zg~nh817PO&jc6@-xuHgCru38lf91_$SPfS4-pnN+(0a9=tssOb>hqD*upTPGZhxeg5 zyqF`tI>)Mgaf;=9rLBN&;t12LWDiw66gRJw#F)`SzI?6wMZF⁢`h)i1|#>JYpWt zS%hcGg?Ss~GLxw$duw%Rb3vbEMs0b#BgM_&d>2@<&{%6PsuAN5n>R*{$wJ;`zAiE0ZtF->$6XyXnU=$Ttd zx~XzI*ZR=1+Dc1#iUom98`FTm_L(_dNr^ggrYTU~z8XKKUGh?9d87?h*Lhu5S~7@wzaP`WfY;R5s)_D{B_gn`H zWpGU{S4s7aG?pBzGCu-1O%KIs&c#9<`6a<^xVf>k>yW=Shsl94EwG8~Y8m%#hq$zE zHaNwHMnDDt)|OUHO-8~|u=0Ry>o3TUTaMX!5y^Ow2YW3DpW$G1@MWdy{8;*Fhb+-Jw&AA{7qAs`^5k|sS#;M15V zOtkHF>?B#+z9-%$@^qhrSJopr8S^I)PpO!^JV=PRHVUubf9-FFf6zX@rJ(H|IgjoIu-nB+S3#S>Z`V6}iefi@VhWu`)C7#0Q)(oRl zm!*sr!Ps<5x@_!77&}&M-jwwMjE21oM#EkPMiX-xjE2ej+aleF3a$H>jr$YE{j{)C zs|o`R+sD?CFLwS!p;sF+jGjEzHQ2Y}?@iS^$fdQmdkld4 z2r_CFG=>3iPpZ+wsSAKi0ocgDcU=V~;py&U8<&7+V8Q%Jp?n%C_~-HgCb~UjYF7#lOM>^qo+Y7MhlgPKGCV1=3))O!6P= zo$TjHMbFj)sD2G&bnZeL1|fs}$i9PY4` zF?;Euk0dWTF>~@#JaV_k*{O))aY^=yfkkLEUlyF($yRCYN<2EMV2F2vJqWVxO+_jH zL(wQ=DHcw^_%8#ax9UNocZs22T`DllY2e(!JZ2+**JK)CnG!Kq4NX+%8_bMqa^aim zRgKlcaOq-dkeD`66PJF@5Yu6?gE4(fAI%G;Alia}7BfT(Lj`YuQ&iDSO=;nr_8~&f z<`|2`$H6F8avT6}`a;?8R&;Q{8Lvy}u;=>`Q$_PKHZ)6FIy5C|tA-6^EFBzkw6BV0 zfl96(j^-s?C{$8>TCrqAC5)WeWN@uQDr$8e$>J<6sHiVloUd=PoHWLa5n~AsFp?Ut1N4Q;-8=tZ=JKp>@iF6 z$Z+MGAP1P*ibiY6j%H=G?HnU6=jRW5_C9Qdloe+xXp=E}Ntaho1lpV9oc+4IbSR+X z{6p8Ft)PAuQz>*XKffi*sW`rf80o^Yjf!e5=7a+B>ndj99M=_dj+hB&urB6U-L{zd za#OS?H@J`)jS8R;8H?l05I5E9JK&fz!wUN!G4p&W$yb(^3MLH{zq8k9RW1}omXWnPSpiPS`|FIae)P#!fBe9Co37D&lRnA{oT{l%V9`x|^L`-~#PdL=kgB1F3+*+e^4fbIb{1MN7;@%pP0(2zcRvw%sb81r#(i zkmgWX77eO6!eGgs!3Y$gW*gxMd1BIAY(e#og+`*@p%JRp79Ga7KuJ`gdk%Ad8cULi zhHyMWic>3cT9Y$e2gi8{;(ZFh&oKl?4LFB9P9Bd00_NUx7{+-PbxEoyVH4A4I93j3 z&oF|+lc1%iZK9$b;#S22D%!#I!SUeG*ces6_)n5&cXpIXXwhkfhyeAUBX93)*z*Id z?}u0mco#&%-1N{`a6GM_qHb_mQ#YL3pGGw78D9nxDPNAj0sxNV#3a(#=T@c=p@Jm` z`_?tmXglIL5KqqcVB!V@$ZrZBbOAN_dS6{_+6F2gxGg}J3d$;+sq^_Vu;iciS>trr zX;t7L$?)$%X-DCV=Rz0`|G3$}z+~PY5e`MbNx~}{q`_N@^NLEVmmiuq7lJWJVoR10 z;I0V~lNd%?3pYW_(~`*xyn$TUwJzSp)BaHPA5He6syvUOZy|UI!5o4Y5zrB!^nvO-J&F;v zX%!C0E{IDjB9lCfC4UtW$`F(zC``9HH{Fr6rUn!eE;KSUJr?E7*fRMP=p|7)b8y5p z4jt+s-MedkwHIUt3%`fpmk54=;O_vW%Y$d9!(&`9Yc0r~OZ^NH{vN^qM(}e4|A^q9 z5Zp(AA`kx$00M5Rb-^+qZ|tt?lVTrB*Gk%iE2MbYii`Rp7V$*{lxjbTAxf7|6YHMe zX*-4QmjUGMD%s|xwRl7|9f8FL1_YRMCgKJ_-rQp$i9Is>m*E){`KvvR+AlzRBHb$V z(jex3i=tf(?tJZJO@Bw+vo>X_OjTA1m944THaKSULJ|NtX7d^nI#))+cWh=Jol=bA2Ghsa`-m8^j1!G@xqbX_Y z7Hr)MyHn(9e^cXKn*)NaaO|hkgW3xqX=+^5T^moC>@OIeH{5MlmkM-#SDg%OBmds- z^QhJ3Ht<_4x80L!&7i&m$P_>&*|fKHtG{C<(3EK2oD6Ic0$WyE+HW-_Teb=$Mt8zk3vQdA4rUm$85#uOZcTlvx)IDIsld^d`qr0Y-`exd{mJ@mLjATo z_4}6V_a*BO2=xc1m02X%&*5-^fS2L~t9=73rzBPq*tqOhk;fJeY#8*FKwl6Av7T5~3dA%j&s7sZT z(Sdq{i7B&v+5gDWJM@&w7XYKJ-{xEZjl42B5 z1N1q2Me}DI3IQ?NAa3!N?19Hb@A5q}nt#G=7#F+A`ZwY}|Y( zH*eWAHaR>r7TE#;F;fgkAcCsmJoBy4>pQ-``#ZbiZ|r$DlsJ0w2V*}POKv(PY&w87_E({e5fAC7BBpW1@jEWM_>8PM}^)oD5Mq|wB>A8}T zkj2=j1kYJb{F&kr0t=mQ(7u@|rGuyfippNaK5(no8sGY~WK>Qvj&d1zRZuHyym(DP zYQ7|KN&VrJs8+?;=OkC3gh^rPG=r##<0YO7;hfL|9dU~O8DIYo01!|=gJCMTP)oEF zUa6}MM*lv5+0OWXYQDg;W_WUZYRgrev9Ce_AA-{iqiRM@n+10r&K0|FspiYkq`gV7 zH)WKN0tWbsnx&3cyI<*hck~O6ez3Abb`4YE798tV?5=A^ zmZ}7IThiVx*xNzIsaOYx0u}3ifq;hY+MR;E;}`PWJB=&?YNp(gQI({Mgkzx6V_G&m zIW?13aiMr<3WG&ny)hgdKZ1P-DB0)7l#119DI70Rci(`P3Y(%>sLRTOAO}L(>L|KE zQ?FlrUj2e^Fd{|nS?w$nXVUtdmtX|3c`C6Y)kd^X|Kr7HP}rVENSXEh##tM|8QKg0 zW{4iQbl`!EGi9k<7yzeQhwIaGOUIM;=D1*QhOZ5*-aA(Rveloc?Mhm^1#9<$dc|0A zE%4=|N#h#9xCS#jQWpE-y1R}_a0#@yGK^9U(Et{9tXSOFhQGWnX=xBF4S=ezNSPcd zXVn+FKi9pqCFxu%IM*(K^P&6N*w>DHtMvo}ad%}1Y z?qv$!;DlY-pcoA>Wpdv!`Ik-pr6WmG0Az`TDez&+2yM!>@@E#?;#^|QV|ZImCoQK1 z%jpGGYEAcoE>T{WRMn^2*8j2Y8@kuyXOeCEg|__|-;`7}ue5jFy7b4fZ^YghNE|($ z>^>oMpGdZ!6xvU||5zy59zwk4q^c!V)A;J9mp0wfBx^PbH5R!^_ zIG*%&3f@kLJ)VR^JYV2d*GsM&=aar}!3RxPJONIYcOCvaj)wTMqv3`#>F5+3oeO$& z%N~a@*RBz?Ft|pLFRkJW)}v-n%ya@D_3hU>D?@NH!tcDtNB~j zq`gb9cfp`gtc)bLM2i-yr@kp8hxmKAenDmi*-UlK1A_`9nki#Uu7t`-7tZZ}Iru5| z_!3Yc*dB1_Oe?|>IP{P58-O6w>b^_EAu1v9RJo(NDe84;*~Ou7lpat655ZlC|1N;y zFWUGI$&U^@B`-*B|NQXJ*xkQG9I6st#{9;tE(o8(;YTeqU>l${6ws(5NtHkat{V8q z<2F)$v_`TdAsdd~IJyK;V0-9-2lYDR-@?!p1P%m$3g9b}LKuuQSht8vF*ZV}(0&?5 z(_%nFypPt#We`md^>_`2a46^k>D=00$JUa##?h;4Dji@o2l~s`(}f9DCw1WTVIg z^NxNtDtAb)=)l#_PF?$CoP)ad$+-&Z(x>1m={KTE&Pj1CfUC-etq@jo<-n+1AJr1m z$=Yq=nDJ0e2^W?@1m(hGFcjE;r{$v%C0=uEj0K4iJO{x7N$#EUkkDuuip-bY_DT?= zOJzpB7}LgqTv?iqhdt!hW*dAnY9QZr+X;M)q~$x{1W3U#zayz2|L>3o(*%8&DsDnrCpJM$Z#D`j^sM(;X3*Eij9HZD6GZyb1iZPK|#aBhKZ zA2;{vrA@c0lEz-a*jtpr<-NZDj;m?e)pR5H`q`vwo8a2EZ~!)P?vAl~*;t)4`URu^ zj4lO?jGb9KP|$4REk%yW{Cv_H-pZ>jlsHMcs)AiOz2o>S3uoxxJK?LM+Y?RX5Mu;~ z4G9&84R)0OIMFBh6A9UQ@@a7eT_!(1`F-{^LI&&imga&D#D{Yx=cdP}B54KqY@uyv z0t$qrLN%8WNebqu;%a24c1Yflkp5GTflI(EryAHn^4_Uu!E@bQUm<~mZQmf0vI`8Q z6M-5gnpeUY#!uy7a`&+|^4_SCmD1xyr1}k~r&&9>b$TPKBY$+!21=#qIZGPO3@Ciq zDg%j~@mApHf@46$PlkYUM0X>Ue)%f$%`^UHx~XFy(E2;XfrE226v>h&KaKIKi=neY zesVI(*FGO~!?(dz!QF}k63#kuA-JI{Ba`XvDO1HAQ|+>;cIo_`x~}EAu4LVMp>BQB z)FYUB67>EMWCf7s^mYnJB3Nr&RAl53f$Be)xZLE!AhAcvVEo=x)^BTN-fXSjUM_vh z$^s;dj|Xa(Ac;SMU6YCf{z+`0Y=}P>iHLGAPU&AizN7hnVf%2Y0$uCM7Isk@DcP)0 z5Tl!wOHKxccCaeY-spC6``bfK3Ht&u4KI>sz>j!_3y@i^zQ&q63WbdH0zJ}LIFjPZ zuPaFVn3cRYF7Jlgp{2hcg$i<4L9s50#b(g!Y1OC>&-qB=F@pj=!%-}$@C4RpFI7wC zlqCnN%BTx98b9yl95E&MAj5_ZUrsnSL77ocwrF@j=_V{4+?BaXOeD`;sIaIxB_x=0 zXyCH!Bwnsn>Ehg6RhY%@NCF-*5(>mG0}@}2!>zeE#DlDSE;v2KBbkN&FMxv47p7(f zaValHd&R7>6%5U2SUv!Vuv7}k^@xbEv%1!t867P4q6$d0KU9K&8du`^BB^?qPoud% zQnrc)e*Vu9jqc2NCXlwQa-BrkCfB~V5S2;~m$3cVQho+&E8~rrR|T$q^n(g|Fc7DV zpZ^_1Jp&*OU*wKYJ{dyy&G03jE~s`a!iV6G5Iljnw1OIZ^&>!*DYg%*2dRs08#atZ z!GaD=T75nmoe~Ytmmvj?Vy4gf$HK!9g>Loyzau)`{?W()+Zero1CSq#^eBIZl)qkj zTYb(WiLdSXO+?CNQ z(bNjyo*c}!;1iWK;AU*pRCpJ@R+?Fc@0E?-6|etg&DYl5+;HpMHz$&fkh?GG-6eQ; zE$UNMjd!YAm#c7Rui7B&sM@foMHS(~*FceI6D)1`A;xhwWpB8#FJbS7JJqxCnl|C@ zOgg$&dN#j)PT2Bjvger4b8K;6!rPg&c9HS0ooywb3U7CHLU&8naNZc=Rl*wH4t`L% zMJ$OpNn)r%37^Gr@Rd7h1;?S-S&eN`!at{GijNIJ8c{rdxC%aT=alHf;h2+vOE^<( zUV|+d=#ZADg7VZ*-WSJgwj<0%=92Uza}hp|<}_JznOH5NX~k-7@U1m&Y1!)n@*ak- z!_wS3K-0%0aB68N#`!$+qD;(ONlO;9;Hht(FJz5z+6xwsaZU<7Dr`i|D0+#}3s`~y zHxc+?!svC8$qPy+O1s%>6zQB^(82mSIW|d;EjP#H&;$wXW{auB7e^MY#_z`2LO09A zmg0{yn8)ls%c|{4QIcIjvpcHzJx{Ag^P|FVEAe(wU_nwVSB}#mS+Y{M2QVTkBbwq| zuv=MiCxgy6lKwHDxhdNlc&zG>t*Aro0d)|W9=)Kv{DAOVq8+(-*f1eQbi1fTvu9h$ z06TMd@XH8x&T+G%)C-3*-K-?46Af0*$+?O>G;)QSICpW)Tot)BT%q=4ef}#crFmf@ zd~^@C$(~j(xp5RwJlF!XS=!Tfaq8sNoFrOL#wVOC(q{|is={27ITe;@fyTgxb2SA@ z;rzHR;(^+M@N>BP(A|2?^h8eV3~?Lszc4ZhF5MGP4vmF5F9^zBDz1X^ME7#Kcm5uh za2`OKZ3{2LbJo_M&9Gw5+PptBaSjAvWRA0CedANn883Xxw0#;RW$)QZZYKO9 zW~&5r*yg-h%L`OB=^X?krM@;8(2IPS=^D&SSXi6ZBD$`z0ygzp2O>@3$`WNiTt zWfT0fm`FEp+?hm1Ky6HU)qsjz>GA{HP6T%x9N5*rJGlMe&Xd9ICy(|WNy~Y#BZc^H zFwE9s-fKD)J)AVQ|CAG8k%PB+4f?#-uL$`TY3_fo;-fa;k;YdoRP7O}}6I&SqD?NBRSg z9Kt_RS@*k{AGtLL9MT`v*B!7(-?hjgJdm@PAeDg{Om!qm@Kn7Du^_2YB}@F0ff`r- z6j2pLsFxw4$Ph!*WmMZ(olC}}h`%b9KPb}mAxt`q;1L9L0Zzm*7)3yZwWl#e5a202 zo-x290{=`_Wj16#VB9k_!NE%8k3$-sq(zni2M9d(mysCM6=1RZ6~L=e*TD(4x{mzw zv^Q>2?*dYK0QyI>=$-hHfb1m#!WZzu< z9*P&h9CW{eo70@3cd|EUzOh8`tkSJ_!=x48LMa+YlIN+6_)H$0d07HKC~A@bY;;+gtE!6%1yPSU!VO1W><;l6twbRUs3kQ zPAKNNe3NKHFb-DpMU1_KUoExj zi+)L6wD#!`WWNokHPifm0M2$;z?U!w9fX%Lgvg?k->RyzE8$H@&+TO|!BpnLIO2is zv?drFnT~?TSTM*-v8Y!8hu?94*6t7K8r+fP^nE(y!ckqyql4h|FP# z((XD8?L=@K0q)h}4*)!gp?L(02wp_+5`ym|coPAxO>wy(>G5>;L%OFH5pl)iih_e% z3w-u8Jr>%+e-9AhhF_(SlJTdy$aCCBQ-c`zLn9 z+sy0<*v1}!4Nev;Ii+BehXoE^3U;zq7Jde;6s%(!TQka5)&X|x2LT7a`nMH6=0QN# nUD>qvV{#U@pNGSHH2!{ZYD^oEvcRh!fMZJXohy&XP=);e;Pt5Z delta 10547 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 TDju$B$qJSSD8XvLmi+%ezxepK delta 2224 zcma)7ZERCj7{2HB-nQ%4`mv6716L{wTt3FI%jg(`O*e-b8*GS###q|jyRC5BZs&GN zXUGbfA3sPG&ky{g!C-)iqNIFCB&0tK3HoRJFd^5NB__s<7{$NDMBn$eBO8mxliugP z=Q;1!Iq%zZPyWgO_J-@C(`n;i{2f1+dVb9rSAg#%XEsRFylP2XW7c$4tSW7b*$5|d zvPre49WjR?6V(}W0!LI=%*AuPoGi?6vUq{#xXbXAN32>lD^-eH@r>}nMUorgWy_2Z z^U7AmdDOQ^%T-G?r^~c#mdS`&wt;%ha&>TG2iCuWbpTttf^`DBY6a^8Hn4)N26lIF z1?>j9P9_I9$+Ljj4M-;4)RoEWLU(F7rwe;iT23Oms8E`v#Ud{dGR3|&t@GJI8FU3r zQAa2qHnp=qOrylk4ha)JHxRRgdf=rIfDrYvAB8pkY9vs*z&)t;ozBl$1GI)Y#dW?_ zK+bYBfC`Hi-Qs$$7YWOv#k+P;KOr_cQJvNUNCNe+FU1h?vN^FaSum)v<1tfi^+UW(I{4su0~jkfG!Oiih-$h5p3+F`IH@+X0eqvaB+8n1YuGSI#3t}`y6XEv|a7SFr4Is>CH;{re3>Ox`8H-E2-?gVL+JzUc? z-35~FNGl0#k}7E>lhbH7F2Vz2f2k}K221osm{(@|Ds<;yY1TwLVS)A_^dew0=r%Q> z<>E$<=}@i5d`E{wro4?C#`D`GE!5EwLa{|Uph>)L0urHR%9YSGh31UKR!Kv$6|BuE zEtJVZ+fLP@Su=PjGlpVBsyJyz{m2x1_{=7#M{R?=C5E|(xkOS`bV0?=7pR6wW^nbXy5z;Z&?$ry08)SsvWc~;jRw?2!`0#mLhcewLc zn7|k)(Ghr6=u~G}DA7)OjP-5qaAVh3azbf_eYiQ?_!fw>Lb+MrL)z%r4?xlc0Bn5d z`}xgL@iI(L6<2Q=IH<)0G2wf zZ{8zb1@8UgShJsy&)NNr!Q$C%QPP4PPOl(5@l8`O)x|?Y`v!W;Aqo%z+nRobaEzHE z;r|G>E7B@n2gP&6eB?ggG=Mg32oVI_(9;30Ay@Hzy!90i-Y9VQlK7JgalZ9c?`>c! z7vfyarUKkntbcpc3%F~kYDpU;&|4B=2H_&YC4^Z7tip=(xwE}X{00`T6ou$*-u-lr zY;H%uT*>eE9X- z^`}@DiaVtk@u^Gyv2|xUD^IG*E_xSM2|U^jz(*!R$OHUz6Ei69{t<%lnfV9Vs{Dok diff --git a/config.template.json b/config.template.json index fa59ef3..cc9ec3f 100644 --- a/config.template.json +++ b/config.template.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -15,12 +15,16 @@ "listen_host": "0.0.0.0", "listen_port": 8081, "poll_interval_s": 1.0, + "write_api_token": "", "output_server": { "enabled": false, "ip": "192.168.1.100", "port": 8080, "path": "/triangulation", - "timeout_s": 3.0 + "timeout_s": 3.0, + "frequency_filter_enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 0.0 } }, "input": { @@ -35,7 +39,14 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://10.0.0.11:9000/measurements" + "source_url": "http://10.0.0.11:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } }, { "receiver_id": "r1", @@ -44,7 +55,14 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://10.0.0.12:9000/measurements" + "source_url": "http://10.0.0.12:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } }, { "receiver_id": "r2", @@ -53,7 +71,14 @@ "y": 8.0, "z": 0.0 }, - "source_url": "http://10.0.0.13:9000/measurements" + "source_url": "http://10.0.0.13:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } } ] } diff --git a/docker-compose.yml b/docker-compose.yml index 866c460..54bb61c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: triangulation-test command: ["python", "service.py", "--config", "docker/config.docker.test.json"] ports: - - "8081:8081" + - "127.0.0.1:38081:8081" depends_on: - receiver-r0 - receiver-r1 @@ -40,8 +40,8 @@ services: build: . container_name: output-sink command: ["python", "docker/mock_output_sink.py", "--port", "8080"] - ports: - - "8080:8080" + expose: + - "8080" profiles: ["test"] triangulation-prod: @@ -49,7 +49,7 @@ services: container_name: triangulation-prod command: ["python", "service.py", "--config", "/app/config.json"] ports: - - "8081:8081" + - "127.0.0.1:38082:8081" volumes: - ./config.json:/app/config.json:ro profiles: ["prod"] diff --git a/docker/config.docker.json b/docker/config.docker.json new file mode 100644 index 0000000..78202cd --- /dev/null +++ b/docker/config.docker.json @@ -0,0 +1,85 @@ +{ + "model": { + "tx_power_dbm": 20.0, + "tx_gain_dbi": 0.0, + "rx_gain_dbi": 0.0, + "path_loss_exponent": 2.0, + "reference_distance_m": 1.0, + "min_distance_m": 0.001 + }, + "solver": { + "tolerance": 0.001, + "z_preference": "positive" + }, + "runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "write_api_token": "", + "output_server": { + "enabled": true, + "ip": "output-sink", + "port": 8080, + "path": "/triangulation", + "timeout_s": 3.0, + "frequency_filter_enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 0.0 + } + }, + "input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "receivers": [ + { + "receiver_id": "r0", + "center": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://receiver-r0:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } + }, + { + "receiver_id": "r1", + "center": { + "x": 10.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://receiver-r1:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } + }, + { + "receiver_id": "r2", + "center": { + "x": 0.0, + "y": 8.0, + "z": 0.0 + }, + "source_url": "http://receiver-r2:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } + } + ] + } +} diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index 2fbdfa4..78202cd 100644 --- a/docker/config.docker.test.json +++ b/docker/config.docker.test.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -15,12 +15,16 @@ "listen_host": "0.0.0.0", "listen_port": 8081, "poll_interval_s": 1.0, + "write_api_token": "", "output_server": { "enabled": true, "ip": "output-sink", "port": 8080, "path": "/triangulation", - "timeout_s": 3.0 + "timeout_s": 3.0, + "frequency_filter_enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 0.0 } }, "input": { @@ -35,7 +39,14 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://receiver-r0:9000/measurements" + "source_url": "http://receiver-r0:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } }, { "receiver_id": "r1", @@ -44,7 +55,14 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://receiver-r1:9000/measurements" + "source_url": "http://receiver-r1:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } }, { "receiver_id": "r2", @@ -53,7 +71,14 @@ "y": 8.0, "z": 0.0 }, - "source_url": "http://receiver-r2:9000/measurements" + "source_url": "http://receiver-r2:9000/measurements", + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } } ] } diff --git a/service.py b/service.py index 1cad317..a3e1b17 100644 --- a/service.py +++ b/service.py @@ -1,12 +1,13 @@ from __future__ import annotations import argparse +import copy +import hmac import json import math import mimetypes import statistics import threading -import time from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -22,13 +23,16 @@ from triangulation import ( ) Point3D = Tuple[float, float, float] +MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST. +HZ_IN_MHZ = 1_000_000.0 def _load_json(path: str) -> Dict[str, object]: file_path = Path(path) if not file_path.exists(): raise SystemExit(f"Config file not found: {path}") - with file_path.open("r", encoding="utf-8") as fh: + # Accept optional UTF-8 BOM to avoid startup failures with edited JSON files. + with file_path.open("r", encoding="utf-8-sig") as fh: data = json.load(fh) if not isinstance(data, dict): raise SystemExit("Config root must be a JSON object.") @@ -80,6 +84,113 @@ def _float_from_measurement( raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.") +def _float_with_key_from_measurement( + item: Dict[str, object], + keys: Sequence[str], + field_name: str, + source_label: str, + row_index: int, +) -> Tuple[str, float]: + for key in keys: + if key in item: + value = _float_from_measurement( + item=item, + keys=(key,), + field_name=field_name, + source_label=source_label, + row_index=row_index, + ) + return key, value + raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.") + + +def _parse_frequency_hz_from_measurement( + row: Dict[str, object], + source_label: str, + row_index: int, +) -> float: + key, value = _float_with_key_from_measurement( + row, + keys=( + "frequency_hz", + "freq_hz", + "frequency_mhz", + "freq_mhz", + "frequency", + "freq", + ), + field_name="frequency", + source_label=source_label, + row_index=row_index, + ) + if key in ("frequency_hz", "freq_hz"): + return value + if key in ("frequency_mhz", "freq_mhz"): + return value * HZ_IN_MHZ + # For generic fields "frequency"/"freq" default to MHz in this project. + # Keep backward compatibility: very large values are treated as Hz. + if value >= 10_000_000.0: + return value + return value * HZ_IN_MHZ + + +def _parse_receiver_input_filter( + receiver_obj: Dict[str, object], receiver_id: str +) -> Dict[str, object]: + filter_obj = receiver_obj.get("input_filter", {}) + if filter_obj is None: + filter_obj = {} + if not isinstance(filter_obj, dict): + raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") + + min_freq_mhz_raw = filter_obj.get("min_frequency_mhz") + max_freq_mhz_raw = filter_obj.get("max_frequency_mhz") + if min_freq_mhz_raw is None and "min_frequency_hz" in filter_obj: + min_freq_mhz_raw = float(filter_obj["min_frequency_hz"]) / HZ_IN_MHZ + if max_freq_mhz_raw is None and "max_frequency_hz" in filter_obj: + max_freq_mhz_raw = float(filter_obj["max_frequency_hz"]) / HZ_IN_MHZ + + parsed = { + "enabled": bool(filter_obj.get("enabled", False)), + "min_frequency_mhz": float(min_freq_mhz_raw if min_freq_mhz_raw is not None else 0.0), + "max_frequency_mhz": float(max_freq_mhz_raw if max_freq_mhz_raw is not None else 1_000_000_000.0), + "min_rssi_dbm": float(filter_obj.get("min_rssi_dbm", -200.0)), + "max_rssi_dbm": float(filter_obj.get("max_rssi_dbm", 50.0)), + } + if parsed["max_frequency_mhz"] < parsed["min_frequency_mhz"]: + raise ValueError( + f"receiver '{receiver_id}': input_filter.max_frequency_mhz must be >= min_frequency_mhz." + ) + if parsed["max_rssi_dbm"] < parsed["min_rssi_dbm"]: + raise ValueError( + f"receiver '{receiver_id}': input_filter.max_rssi_dbm must be >= min_rssi_dbm." + ) + return parsed + + +def _apply_receiver_input_filter( + measurements: Sequence[Tuple[float, float]], + receiver_filter: Dict[str, object], +) -> List[Tuple[float, float]]: + if not bool(receiver_filter.get("enabled", False)): + return list(measurements) + + min_frequency_mhz = float(receiver_filter["min_frequency_mhz"]) + max_frequency_mhz = float(receiver_filter["max_frequency_mhz"]) + min_rssi_dbm = float(receiver_filter["min_rssi_dbm"]) + max_rssi_dbm = float(receiver_filter["max_rssi_dbm"]) + + filtered = [] + for frequency_hz, rssi_dbm in measurements: + frequency_mhz = frequency_hz / HZ_IN_MHZ + if not (min_frequency_mhz <= frequency_mhz <= max_frequency_mhz): + continue + if not (min_rssi_dbm <= rssi_dbm <= max_rssi_dbm): + continue + filtered.append((frequency_hz, rssi_dbm)) + return filtered + + def parse_source_payload( payload: object, source_label: str, @@ -110,10 +221,8 @@ def parse_source_payload( for row_index, row in enumerate(raw_items, start=1): if not isinstance(row, dict): raise ValueError(f"{source_label}: row #{row_index} must be an object.") - frequency_hz = _float_from_measurement( - row, - keys=("frequency_hz", "freq_hz", "frequency", "freq"), - field_name="frequency_hz", + frequency_hz = _parse_frequency_hz_from_measurement( + row=row, source_label=source_label, row_index=row_index, ) @@ -210,6 +319,7 @@ class AutoService: raise ValueError("solver.z_preference must be 'positive' or 'negative'.") self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0)) + self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip() output_obj = runtime_obj.get("output_server", {}) if output_obj is None: output_obj = {} @@ -221,8 +331,34 @@ class AutoService: self.output_port = int(output_obj.get("port", 8080)) self.output_path = str(output_obj.get("path", "/triangulation")) self.output_timeout_s = float(output_obj.get("timeout_s", 3.0)) + self.output_frequency_filter_enabled = bool( + output_obj.get("frequency_filter_enabled", False) + ) + min_frequency_mhz_raw = output_obj.get("min_frequency_mhz") + max_frequency_mhz_raw = output_obj.get("max_frequency_mhz") + if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj: + min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ + if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj: + max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ + self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0) + self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0) + self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ + self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ if self.output_enabled and not self.output_ip: raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.") + if self.output_frequency_filter_enabled: + if self.output_min_frequency_mhz <= 0.0: + raise ValueError( + "runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if self.output_max_frequency_mhz <= 0.0: + raise ValueError( + "runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if self.output_max_frequency_mhz < self.output_min_frequency_mhz: + raise ValueError( + "runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz." + ) self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0)) self.aggregation = str(input_obj.get("aggregation", "median")) @@ -246,6 +382,10 @@ class AutoService: "receiver_id": str(receiver["receiver_id"]), "center": _center_from_obj(receiver), "source_url": str(receiver["source_url"]), + "input_filter": _parse_receiver_input_filter( + receiver_obj=receiver, + receiver_id=str(receiver["receiver_id"]), + ), } ) self.receivers = parsed_receivers @@ -273,7 +413,6 @@ class AutoService: self.poll_thread.join(timeout=2.0) def refresh_once(self) -> None: - spheres_all: List[Sphere] = [] receiver_payloads: List[Dict[str, object]] = [] grouped_by_receiver: List[Dict[float, List[Tuple[float, float]]]] = [] @@ -281,22 +420,30 @@ class AutoService: receiver_id = str(receiver["receiver_id"]) center = receiver["center"] source_url = str(receiver["source_url"]) - measurements = _fetch_measurements( + raw_measurements = _fetch_measurements( source_url, timeout_s=self.source_timeout_s, expected_receiver_id=receiver_id, ) + receiver_filter = receiver["input_filter"] + measurements = _apply_receiver_input_filter( + raw_measurements, receiver_filter=receiver_filter + ) + if not measurements: + raise RuntimeError( + f"receiver '{receiver_id}': no measurements left after input_filter." + ) grouped = _group_by_frequency(measurements) grouped_by_receiver.append(grouped) radius_m = aggregate_radius(measurements, model=self.model, method=self.aggregation) - spheres_all.append(Sphere(center=center, radius=radius_m)) samples = [] for frequency_hz, amplitude_dbm in measurements: samples.append( { "frequency_hz": frequency_hz, + "frequency_mhz": frequency_hz / HZ_IN_MHZ, "amplitude_dbm": amplitude_dbm, "distance_m": rssi_to_distance_m( amplitude_dbm=amplitude_dbm, @@ -312,11 +459,15 @@ class AutoService: "center": {"x": center[0], "y": center[1], "z": center[2]}, "source_url": source_url, "aggregation": self.aggregation, + "input_filter": receiver_filter, + "raw_samples_count": len(raw_measurements), + "filtered_samples_count": len(measurements), "radius_m_all_freq": radius_m, "samples": samples, } ) + # Only compare homogeneous measurements: same frequency across all receivers. common_frequencies = ( set(grouped_by_receiver[0].keys()) & set(grouped_by_receiver[1].keys()) @@ -356,6 +507,7 @@ class AutoService: receiver_payloads[index].setdefault("per_frequency", []).append( { "frequency_hz": frequency_hz, + "frequency_mhz": frequency_hz / HZ_IN_MHZ, "radius_m": spheres_for_frequency[index].radius, "residual_m": residual, "samples_count": len(grouped_by_receiver[index][frequency_hz]), @@ -364,6 +516,7 @@ class AutoService: row = { "frequency_hz": frequency_hz, + "frequency_mhz": frequency_hz / HZ_IN_MHZ, "position": { "x": result.point[0], "y": result.point[1], @@ -383,6 +536,7 @@ class AutoService: payload = { "timestamp_utc": datetime.now(timezone.utc).isoformat(), "selected_frequency_hz": best_row["frequency_hz"], + "selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ, "position": best_row["position"], "exact": best_row["exact"], "rmse_m": best_row["rmse_m"], @@ -403,13 +557,36 @@ class AutoService: self.last_error = "" if self.output_enabled: + output_payload = self._build_output_payload(payload) + if output_payload is None: + with self.state_lock: + self.last_output_delivery = { + "enabled": True, + "status": "skipped", + "http_status": None, + "response_body": "No frequencies in configured output range", + "sent_at_utc": datetime.now(timezone.utc).isoformat(), + "target": { + "ip": self.output_ip, + "port": self.output_port, + "path": self.output_path, + }, + "frequency_filter": { + "enabled": self.output_frequency_filter_enabled, + "min_frequency_mhz": self.output_min_frequency_mhz, + "max_frequency_mhz": self.output_max_frequency_mhz, + }, + } + return + status_code, response_body = send_payload_to_server( server_ip=self.output_ip, - payload=payload, + payload=output_payload, port=self.output_port, path=self.output_path, timeout_s=self.output_timeout_s, ) + # Keep delivery diagnostics in snapshot so UI/API can show transport health. with self.state_lock: self.last_output_delivery = { "enabled": True, @@ -422,6 +599,11 @@ class AutoService: "port": self.output_port, "path": self.output_path, }, + "frequency_filter": { + "enabled": self.output_frequency_filter_enabled, + "min_frequency_mhz": self.output_min_frequency_mhz, + "max_frequency_mhz": self.output_max_frequency_mhz, + }, } if status_code < 200 or status_code >= 300: raise RuntimeError( @@ -429,6 +611,55 @@ class AutoService: f"HTTP {status_code}, body={response_body}" ) + def _build_output_payload(self, payload: Dict[str, object]) -> Optional[Dict[str, object]]: + if not self.output_frequency_filter_enabled: + return payload + + # Keep internal calculations unchanged, but limit data sent to output server by frequency. + payload_copy = copy.deepcopy(payload) + table_obj = payload_copy.get("frequency_table") + if not isinstance(table_obj, list): + return None + + filtered_rows = [] + for row in table_obj: + if not isinstance(row, dict): + continue + frequency_hz = row.get("frequency_hz") + if not isinstance(frequency_hz, (int, float)): + continue + if self.output_min_frequency_hz <= float(frequency_hz) <= self.output_max_frequency_hz: + filtered_rows.append(row) + if not filtered_rows: + return None + + best_row = min(filtered_rows, key=lambda row: float(row.get("rmse_m", float("inf")))) + payload_copy["frequency_table"] = filtered_rows + payload_copy["selected_frequency_hz"] = best_row.get("frequency_hz") + payload_copy["selected_frequency_mhz"] = float(best_row.get("frequency_hz", 0.0)) / HZ_IN_MHZ + payload_copy["position"] = best_row.get("position") + payload_copy["exact"] = best_row.get("exact") + payload_copy["rmse_m"] = best_row.get("rmse_m") + + receivers_obj = payload_copy.get("receivers") + if isinstance(receivers_obj, list): + for receiver in receivers_obj: + if not isinstance(receiver, dict): + continue + per_frequency = receiver.get("per_frequency") + if not isinstance(per_frequency, list): + continue + receiver["per_frequency"] = [ + row + for row in per_frequency + if isinstance(row, dict) + and isinstance(row.get("frequency_hz"), (int, float)) + and self.output_min_frequency_hz + <= float(row["frequency_hz"]) + <= self.output_max_frequency_hz + ] + return payload_copy + def _poll_loop(self) -> None: while not self.stop_event.is_set(): try: @@ -450,6 +681,22 @@ class AutoService: def _make_handler(service: AutoService): class ServiceHandler(BaseHTTPRequestHandler): + def _is_write_authorized(self) -> bool: + expected_token = service.write_api_token + if not expected_token: + return True + + header_token = self.headers.get("X-API-Token", "") + if hmac.compare_digest(header_token, expected_token): + return True + + authorization = self.headers.get("Authorization", "") + if authorization.lower().startswith("bearer "): + bearer_token = authorization[7:].strip() + if hmac.compare_digest(bearer_token, expected_token): + return True + return False + def _write_bytes( self, status_code: int, @@ -473,7 +720,10 @@ def _make_handler(service: AutoService): def _write_static(self, relative_path: str) -> None: web_root = Path(__file__).resolve().parent / "web" file_path = (web_root / relative_path).resolve() - if not str(file_path).startswith(str(web_root.resolve())): + # Protect against path traversal outside /web. + try: + file_path.relative_to(web_root.resolve()) + except ValueError: self._write_json(404, {"error": "not_found"}) return if not file_path.exists() or not file_path.is_file(): @@ -554,6 +804,7 @@ def _make_handler(service: AutoService): "status": "ok", "updated_at_utc": snapshot["updated_at_utc"], "selected_frequency_hz": payload.get("selected_frequency_hz"), + "selected_frequency_mhz": payload.get("selected_frequency_mhz"), "frequency_table": payload.get("frequency_table", []), "output_delivery": snapshot["output_delivery"], }, @@ -561,12 +812,18 @@ def _make_handler(service: AutoService): return if path == "/config": + public_config = json.loads(json.dumps(service.config)) + runtime_obj = public_config.get("runtime") + if isinstance(runtime_obj, dict): + if "write_api_token" in runtime_obj: + runtime_obj["write_api_token"] = "" + runtime_obj["write_api_token_set"] = bool(service.write_api_token) self._write_json( 200, { "status": "ok", "config_path": service.config_path, - "config": service.config, + "config": public_config, }, ) return @@ -575,12 +832,34 @@ def _make_handler(service: AutoService): def do_POST(self) -> None: path = parse.urlparse(self.path).path + if not self._is_write_authorized(): + self._write_json( + 401, + {"status": "error", "error": "unauthorized: missing or invalid API token"}, + ) + return + if path == "/config": try: content_length = int(self.headers.get("Content-Length", "0")) except ValueError: self._write_json(400, {"status": "error", "error": "Invalid Content-Length"}) return + if content_length <= 0: + self._write_json(400, {"status": "error", "error": "Empty request body"}) + return + if content_length > MAX_CONFIG_BODY_BYTES: + self._write_json( + 413, + { + "status": "error", + "error": ( + f"Config payload too large: {content_length} bytes, " + f"max is {MAX_CONFIG_BODY_BYTES}" + ), + }, + ) + return body = self.rfile.read(content_length) if content_length > 0 else b"" try: new_config = json.loads(body.decode("utf-8")) @@ -591,6 +870,13 @@ def _make_handler(service: AutoService): self._write_json(400, {"status": "error", "error": "Config must be JSON object"}) return + # Avoid accidental token wipe when /config GET response is redacted in clients. + runtime_obj = new_config.get("runtime") + if isinstance(runtime_obj, dict) and service.write_api_token: + incoming_token = str(runtime_obj.get("write_api_token", "")).strip() + if not incoming_token: + runtime_obj["write_api_token"] = service.write_api_token + try: AutoService(new_config) except Exception as exc: diff --git a/test_service_integration.py b/test_service_integration.py index e9e5298..a750d67 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -1,6 +1,7 @@ import json +import threading from typing import Any, Dict, List -from urllib import error +from urllib import error, request as urllib_request import pytest @@ -79,6 +80,14 @@ def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, objec monkeypatch.setattr(service.request, "urlopen", _fake_urlopen) +def _start_api_server_for_test(svc: service.AutoService): + http_server = service.ThreadingHTTPServer(("127.0.0.1", 0), service._make_handler(svc)) + thread = threading.Thread(target=http_server.serve_forever, daemon=True) + thread.start() + host, port = http_server.server_address + return http_server, thread, f"http://{host}:{port}" + + def test_refresh_once_builds_frequency_table_for_common_frequencies( monkeypatch: pytest.MonkeyPatch, ): @@ -205,3 +214,313 @@ def test_refresh_once_propagates_source_http_error(monkeypatch: pytest.MonkeyPat svc = service.AutoService(config) with pytest.raises(RuntimeError, match="Cannot reach 'http://r1.local/measurements': connection refused"): svc.refresh_once() + + +def test_output_delivery_is_disabled_when_output_server_off(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + snapshot = svc.snapshot() + assert snapshot["output_delivery"]["enabled"] is False + assert snapshot["output_delivery"]["status"] == "disabled" + + +def test_parse_source_payload_rejects_non_finite_values(): + payload = {"measurements": [{"frequency_hz": float("inf"), "rssi_dbm": -60.0}]} + with pytest.raises(ValueError, match="must be finite"): + service.parse_source_payload(payload, source_label="source_url=test") + + +def test_parse_source_payload_accepts_frequency_mhz(): + payload = {"measurements": [{"frequency_mhz": 868.1, "rssi_dbm": -60.0}]} + parsed = service.parse_source_payload(payload, source_label="source_url=test") + assert parsed[0][0] == pytest.approx(868_100_000.0) + + +def test_parse_source_payload_treats_generic_frequency_as_mhz(): + payload = {"measurements": [{"frequency": 433.92, "rssi_dbm": -60.0}]} + parsed = service.parse_source_payload(payload, source_label="source_url=test") + assert parsed[0][0] == pytest.approx(433_920_000.0) + + +def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + with pytest.raises(error.HTTPError) as exc_info: + urllib_request.urlopen(f"{base_url}/static/../service.py") + assert exc_info.value.code == 404 + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_config_rejects_empty_body(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + req = urllib_request.Request( + url=f"{base_url}/config", + method="POST", + data=b"", + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(error.HTTPError) as exc_info: + urllib_request.urlopen(req) + body = exc_info.value.read().decode("utf-8") + assert exc_info.value.code == 400 + assert "Empty request body" in body + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_config_rejects_too_large_payload(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + huge_payload = b"{" + b" " * (service.MAX_CONFIG_BODY_BYTES + 10) + b"}" + req = urllib_request.Request( + url=f"{base_url}/config", + method="POST", + data=huge_payload, + headers={"Content-Type": "application/json"}, + ) + try: + urllib_request.urlopen(req) + raise AssertionError("Expected request rejection for oversized payload") + except error.HTTPError as exc: + assert exc.code == 413 + except ConnectionAbortedError: + # On some Windows stacks, server closes connection before status line is read. + pass + except OSError as exc: + if getattr(exc, "winerror", None) == 10053: + pass + else: + raise + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_refresh_requires_write_token_when_configured(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["write_api_token"] = "secret" # type: ignore[index] + svc = service.AutoService(config) + svc.refresh_once = lambda: None # type: ignore[method-assign] + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + unauthorized_req = urllib_request.Request( + url=f"{base_url}/refresh", + method="POST", + data=b"{}", + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(error.HTTPError) as exc_info: + urllib_request.urlopen(unauthorized_req) + assert exc_info.value.code == 401 + + authorized_req = urllib_request.Request( + url=f"{base_url}/refresh", + method="POST", + data=b"{}", + headers={"Content-Type": "application/json", "X-API-Token": "secret"}, + ) + with urllib_request.urlopen(authorized_req) as response: + assert response.status == 200 + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_config_get_redacts_write_token(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["write_api_token"] = "secret" # type: ignore[index] + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + with urllib_request.urlopen(f"{base_url}/config") as response: + body = response.read().decode("utf-8") + payload = json.loads(body) + runtime = payload["config"]["runtime"] + assert runtime["write_api_token"] == "" + assert runtime["write_api_token_set"] is True + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_output_payload_is_filtered_by_frequency_range(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["output_server"]["enabled"] = True # type: ignore[index] + config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] + config["runtime"]["output_server"]["min_frequency_mhz"] = 800.0 # type: ignore[index] + config["runtime"]["output_server"]["max_frequency_mhz"] = 900.0 # type: ignore[index] + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_hz": 433_920_000.0, "rssi_dbm": -60.0}, + {"frequency_hz": 868_100_000.0, "rssi_dbm": -64.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0}, + {"frequency_hz": 868_100_000.0, "rssi_dbm": -66.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0}, + {"frequency_hz": 868_100_000.0, "rssi_dbm": -65.0}, + ] + }, + } + _install_urlopen(monkeypatch, responses) + + captured = {} + + def _fake_send_payload_to_server(**kwargs): + captured["payload"] = kwargs["payload"] + return 200, "ok" + + monkeypatch.setattr(service, "send_payload_to_server", _fake_send_payload_to_server) + + svc = service.AutoService(config) + svc.refresh_once() + + sent_payload = captured["payload"] + freq_rows = sent_payload["frequency_table"] + assert len(freq_rows) == 1 + assert freq_rows[0]["frequency_hz"] == 868_100_000.0 + assert sent_payload["selected_frequency_hz"] == 868_100_000.0 + + +def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["output_server"]["enabled"] = True # type: ignore[index] + config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] + config["runtime"]["output_server"]["min_frequency_mhz"] = 2_000.0 # type: ignore[index] + config["runtime"]["output_server"]["max_frequency_mhz"] = 3_000.0 # type: ignore[index] + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0}]}, + } + _install_urlopen(monkeypatch, responses) + monkeypatch.setattr( + service, + "send_payload_to_server", + lambda **_: (_ for _ in ()).throw(AssertionError("send_payload_to_server must not be called")), + ) + + svc = service.AutoService(config) + svc.refresh_once() + snapshot = svc.snapshot() + assert snapshot["output_delivery"]["status"] == "skipped" + + +def test_config_validation_rejects_invalid_frequency_filter_range(): + config = _base_config() + config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] + config["runtime"]["output_server"]["min_frequency_mhz"] = 900.0 # type: ignore[index] + config["runtime"]["output_server"]["max_frequency_mhz"] = 800.0 # type: ignore[index] + with pytest.raises(ValueError, match="max_frequency_mhz must be >= min_frequency_mhz"): + service.AutoService(config) + + +def test_receiver_input_filter_applies_per_server(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + # Keep only ~433.92 MHz and tighter RSSI range for r0. + config["input"]["receivers"][0]["input_filter"] = { # type: ignore[index] + "enabled": True, + "min_frequency_mhz": 430.0, + "max_frequency_mhz": 440.0, + "min_rssi_dbm": -61.0, + "max_rssi_dbm": -59.0, + } + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_mhz": 433.92, "rssi_dbm": -60.0}, + {"frequency_mhz": 868.1, "rssi_dbm": -60.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_mhz": 433.92, "rssi_dbm": -63.0}, + {"frequency_mhz": 868.1, "rssi_dbm": -66.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_mhz": 433.92, "rssi_dbm": -62.0}, + {"frequency_mhz": 868.1, "rssi_dbm": -65.0}, + ] + }, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + payload = svc.snapshot()["payload"] + assert payload is not None + assert len(payload["frequency_table"]) == 1 + assert payload["frequency_table"][0]["frequency_mhz"] == pytest.approx(433.92, abs=1e-6) + assert payload["receivers"][0]["raw_samples_count"] == 2 + assert payload["receivers"][0]["filtered_samples_count"] == 1 + + +def test_receiver_input_filter_empty_result_raises(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["input"]["receivers"][0]["input_filter"] = { # type: ignore[index] + "enabled": True, + "min_frequency_mhz": 433.0, + "max_frequency_mhz": 434.0, + "min_rssi_dbm": -10.0, + "max_rssi_dbm": -5.0, + } + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -62.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -61.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="no measurements left after input_filter"): + svc.refresh_once() + + +def test_receiver_input_filter_validation_rejects_invalid_rssi_range(): + config = _base_config() + config["input"]["receivers"][1]["input_filter"] = { # type: ignore[index] + "enabled": True, + "min_frequency_mhz": 430.0, + "max_frequency_mhz": 440.0, + "min_rssi_dbm": -30.0, + "max_rssi_dbm": -80.0, + } + with pytest.raises(ValueError, match="max_rssi_dbm must be >= min_rssi_dbm"): + service.AutoService(config) diff --git a/web/app.js b/web/app.js index 5ffcd66..c3c2899 100644 --- a/web/app.js +++ b/web/app.js @@ -2,7 +2,13 @@ const state = { result: null, frequencies: null, health: null, + config: null, + writeToken: "", + activeSection: "overview", + selectedReceiverIndex: 0, + receiverDrafts: [], }; +const HZ_IN_MHZ = 1_000_000; function byId(id) { return document.getElementById(id); @@ -14,6 +20,93 @@ function fmt(value, digits = 6) { return Number.isFinite(value) ? value.toFixed(digits) : String(value); } +function hzToMhz(value) { + if (value === null || value === undefined) return null; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + return numeric / HZ_IN_MHZ; +} + +function authHeaders() { + const token = state.writeToken || ""; + if (!token) return {}; + return { + "X-API-Token": token, + Authorization: `Bearer ${token}`, + }; +} + +function setActiveSection(section) { + state.activeSection = section; + document.querySelectorAll(".panel").forEach((panel) => { + panel.classList.toggle("panel-active", panel.id === `section-${section}`); + }); + document.querySelectorAll(".menu-item").forEach((item) => { + item.classList.toggle("menu-item-active", item.dataset.section === section); + }); +} + +function setMenuOpen(isOpen) { + byId("menu-list").classList.toggle("menu-list-open", isOpen); +} + +function normalizeReceiverDraft(receiver) { + const filter = receiver?.input_filter || {}; + return { + receiver_id: receiver?.receiver_id || "", + source_url: receiver?.source_url || "", + input_filter: { + enabled: Boolean(filter.enabled), + min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0, + max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0, + min_rssi_dbm: filter.min_rssi_dbm ?? -200, + max_rssi_dbm: filter.max_rssi_dbm ?? 50, + }, + }; +} + +function saveCurrentReceiverDraftFromInputs() { + const idx = state.selectedReceiverIndex; + if (!state.receiverDrafts[idx]) return; + state.receiverDrafts[idx] = { + ...state.receiverDrafts[idx], + source_url: byId("rx-url").value.trim(), + input_filter: { + enabled: byId("rx-filter-enabled").value === "true", + min_frequency_mhz: Number(byId("rx-min-freq").value), + max_frequency_mhz: Number(byId("rx-max-freq").value), + min_rssi_dbm: Number(byId("rx-min-rssi").value), + max_rssi_dbm: Number(byId("rx-max-rssi").value), + }, + }; +} + +function renderSelectedReceiverDraft() { + const draft = state.receiverDrafts[state.selectedReceiverIndex]; + if (!draft) return; + byId("rx-url").value = draft.source_url; + byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled)); + byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz; + byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz; + byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm; + byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm; +} + +function fillReceiverSelect() { + const select = byId("receiver-select"); + select.innerHTML = ""; + state.receiverDrafts.forEach((draft, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = draft.receiver_id || `receiver_${index + 1}`; + select.appendChild(option); + }); + if (state.selectedReceiverIndex >= state.receiverDrafts.length) { + state.selectedReceiverIndex = 0; + } + select.value = String(state.selectedReceiverIndex); +} + async function getJson(url) { const res = await fetch(url); const data = await res.json().catch(() => ({})); @@ -26,7 +119,7 @@ async function getJson(url) { async function postJson(url, payload) { const res = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); @@ -55,7 +148,9 @@ function render() { return; } - byId("selected-freq").textContent = fmt(data.selected_frequency_hz, 1); + const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz); + byId("selected-freq").textContent = + selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`; byId("pos-x").textContent = fmt(data.position?.x); byId("pos-y").textContent = fmt(data.position?.y); byId("pos-z").textContent = fmt(data.position?.z); @@ -71,7 +166,7 @@ function render() { .map( (row) => ` - ${fmt(row.frequency_hz, 1)} + ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} ${fmt(row.position?.x)} ${fmt(row.position?.y)} ${fmt(row.position?.z)} @@ -102,10 +197,14 @@ async function refreshNow() { async function loadConfig() { try { const config = await getJson("/config"); + state.config = config.config || null; byId("config-editor").value = JSON.stringify(config.config, null, 2); + fillServerForm(); byId("config-state").textContent = "config: loaded"; + byId("servers-state").textContent = "servers: loaded"; } catch (err) { byId("config-state").textContent = `config: ${err.message}`; + byId("servers-state").textContent = `servers: ${err.message}`; } } @@ -114,6 +213,7 @@ async function saveConfig() { try { const parsed = JSON.parse(raw); const result = await postJson("/config", parsed); + state.config = parsed; byId("config-state").textContent = result.restart_required ? "config: saved, restart required" : "config: saved"; @@ -122,14 +222,105 @@ async function saveConfig() { } } +function fillServerForm() { + const cfg = state.config; + if (!cfg) return; + const receivers = cfg.input?.receivers || []; + state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver)); + fillReceiverSelect(); + renderSelectedReceiverDraft(); + const out = cfg.runtime?.output_server || {}; + byId("write-token").value = ""; + byId("out-enabled").value = String(Boolean(out.enabled)); + byId("out-freq-filter-enabled").value = String(Boolean(out.frequency_filter_enabled)); + const minMhz = out.min_frequency_mhz ?? hzToMhz(out.min_frequency_hz) ?? 0; + const maxMhz = out.max_frequency_mhz ?? hzToMhz(out.max_frequency_hz) ?? 0; + byId("out-min-freq").value = minMhz; + byId("out-max-freq").value = maxMhz; + byId("out-ip").value = out.ip || ""; + byId("out-port").value = out.port ?? 8080; + byId("out-path").value = out.path || "/triangulation"; + byId("out-timeout").value = out.timeout_s ?? 3.0; +} + +async function saveServers() { + try { + if (!state.config) { + await loadConfig(); + } + saveCurrentReceiverDraftFromInputs(); + const cfg = structuredClone(state.config); + cfg.input = cfg.input || {}; + cfg.input.receivers = cfg.input.receivers || [{}, {}, {}]; + cfg.runtime = cfg.runtime || {}; + cfg.runtime.output_server = cfg.runtime.output_server || {}; + + for (let i = 0; i < cfg.input.receivers.length; i += 1) { + const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]); + cfg.input.receivers[i].source_url = draft.source_url; + cfg.input.receivers[i].input_filter = { ...draft.input_filter }; + } + cfg.runtime.output_server.enabled = byId("out-enabled").value === "true"; + cfg.runtime.output_server.frequency_filter_enabled = + byId("out-freq-filter-enabled").value === "true"; + cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value); + cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value); + cfg.runtime.output_server.ip = byId("out-ip").value.trim(); + cfg.runtime.output_server.port = Number(byId("out-port").value); + cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation"; + cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value); + + const result = await postJson("/config", cfg); + state.config = cfg; + byId("config-editor").value = JSON.stringify(cfg, null, 2); + byId("servers-state").textContent = result.restart_required + ? "servers: saved, restart required" + : "servers: saved"; + } catch (err) { + byId("servers-state").textContent = `servers: ${err.message}`; + } +} + function bindUi() { byId("refresh-now").addEventListener("click", refreshNow); byId("load-config").addEventListener("click", loadConfig); byId("save-config").addEventListener("click", saveConfig); + byId("load-servers").addEventListener("click", loadConfig); + byId("save-servers").addEventListener("click", saveServers); + byId("write-token").addEventListener("input", (event) => { + state.writeToken = event.target.value; + }); + byId("receiver-select").addEventListener("change", (event) => { + saveCurrentReceiverDraftFromInputs(); + state.selectedReceiverIndex = Number(event.target.value); + renderSelectedReceiverDraft(); + }); + byId("menu-toggle").addEventListener("click", () => { + const open = !byId("menu-list").classList.contains("menu-list-open"); + setMenuOpen(open); + }); + document.querySelectorAll(".menu-item").forEach((item) => { + item.addEventListener("click", () => { + setActiveSection(item.dataset.section); + setMenuOpen(false); + }); + }); + document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if ( + target.closest("#menu-toggle") || + target.closest("#menu-list") + ) { + return; + } + setMenuOpen(false); + }); } async function boot() { bindUi(); + setActiveSection(state.activeSection); await loadConfig(); await loadAll(); setInterval(loadAll, 2000); diff --git a/web/index.html b/web/index.html index 760d9bf..21259d6 100644 --- a/web/index.html +++ b/web/index.html @@ -9,78 +9,149 @@
-
-
+ +
+
+ -
-
-

Итоговая позиция

-
-
Selected Freq: -
-
X: -
-
Y: -
-
Z: -
-
RMSE: -
-
-
+
+
+
+

RF Positioning Dashboard

+

Мониторинг и контроль расчета пересечения 3 сфер.

+
+ +
+
-
-

Ресиверы

-
-
-
+
+

Итоговая позиция

+
+
Selected Freq: -
+
X: -
+
Y: -
+
Z: -
+
RMSE: -
+
+
+
-
-

Отправка на конечный сервер

-
-
+
+
+

Таблица пересечений по частотам

+
+ + + + + + + + + + + + +
Frequency (MHz)XYZRMSEExact
+
+
+
-
-

Таблица пересечений по частотам

-
- - - - - - - - - - - - -
Frequency (Hz)XYZRMSEExact
-
-
+
+
+

Ресиверы

+
+
+
-
-

Конфигурация

-

- Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен - перезапуск для применения. -

-
- - - config: n/a -
- +
+
+

Отправка на конечный сервер

+
+
+
+ +
+
+

Настройка серверов

+

Изменения сохраняются в конфиг и требуют перезапуска сервиса.

+
+ + + + + + + + + + + + + + + + +
+
+ + + servers: n/a +
+
+
+ +
+
+

Конфигурация (Raw JSON)

+
+ + + config: n/a +
+ +
+
+ - + diff --git a/web/styles.css b/web/styles.css index 769cc92..1b27417 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,11 +1,11 @@ :root { - --bg: #f4f6f8; - --card: #ffffff; - --text: #101418; - --muted: #5b6872; - --line: #dbe2e8; - --accent: #0e6e6b; - --accent-soft: #d9f2f1; + --bg: #f2f4f7; + --card: #ffffffd4; + --text: #10161d; + --muted: #5f6f7d; + --line: #d8e0e7; + --accent: #0f766e; + --accent-soft: #e6f7f4; } * { @@ -16,47 +16,69 @@ body { margin: 0; font-family: "Segoe UI", "Noto Sans", sans-serif; color: var(--text); - background: radial-gradient(circle at 20% 20%, #eef7ff 0%, var(--bg) 45%), - linear-gradient(180deg, #f8fbff, #f4f6f8); + background: linear-gradient(160deg, #f9fafc, #eef4f7 45%, #f2f4f7); min-height: 100vh; + overflow-x: hidden; } -.container { - width: min(1100px, 94vw); - margin: 32px auto; +.app-shell { + width: min(1240px, 96vw); + margin: 24px auto; display: grid; - gap: 18px; + grid-template-columns: 280px 1fr; + gap: 16px; position: relative; z-index: 2; } .card { - background: color-mix(in oklab, var(--card), transparent 8%); + background: var(--card); border: 1px solid var(--line); border-radius: 16px; - padding: 18px; - box-shadow: 0 12px 30px rgba(16, 20, 24, 0.06); - animation: rise 450ms ease both; + padding: 16px; + backdrop-filter: blur(8px); + box-shadow: 0 14px 35px rgba(16, 22, 29, 0.06); + animation: rise 420ms ease both; } -.hero h1 { - margin: 6px 0; - font-size: clamp(1.2rem, 3vw, 1.9rem); +.side-nav { + position: sticky; + top: 16px; + height: fit-content; + display: grid; + gap: 12px; } .kicker { margin: 0; text-transform: uppercase; - letter-spacing: 0.12em; + letter-spacing: 0.14em; color: var(--accent); font-weight: 700; - font-size: 0.78rem; + font-size: 0.74rem; +} + +.side-title { + margin: 0; + font-size: 1.3rem; } -.grid { +.content-area { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; +} + +.panel { + display: none; + animation: fadeSlide 220ms ease; +} + +.panel-active { + display: grid; + gap: 16px; +} + +.hero h2 { + margin: 0 0 8px; } .hero-actions, @@ -74,11 +96,12 @@ body { border-radius: 10px; padding: 8px 12px; cursor: pointer; - transition: transform 150ms ease, background-color 150ms ease; + transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease; } .btn:hover { transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1); } .btn-primary { @@ -87,12 +110,60 @@ body { color: #fff; } -.badge { +.menu-wrap { + position: relative; +} + +.menu-toggle { + width: 100%; +} + +.menu-list { + display: none; + position: absolute; + left: 0; + right: 0; + top: calc(100% + 8px); border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + box-shadow: 0 10px 28px rgba(16, 22, 29, 0.1); + padding: 6px; + z-index: 20; +} + +.menu-list-open { + display: grid; + gap: 5px; +} + +.menu-item { + border: 1px solid transparent; + background: #f7fafb; + color: var(--text); + border-radius: 8px; + padding: 8px 10px; + text-align: left; + cursor: pointer; +} + +.menu-item-active { background: var(--accent-soft); + border-color: color-mix(in oklab, var(--accent), #fff 70%); +} + +.side-meta { + display: grid; + gap: 6px; +} + +.badge { + border: 1px solid var(--line); + background: #f3f9fb; border-radius: 999px; padding: 4px 10px; - font-size: 0.82rem; + font-size: 0.8rem; + width: fit-content; } .result-box { @@ -134,12 +205,12 @@ tbody tr { } tbody tr:hover { - background: #f5fbfb; + background: #f4fbfa; } .editor { width: 100%; - min-height: 280px; + min-height: 320px; border: 1px solid var(--line); border-radius: 12px; padding: 10px; @@ -149,35 +220,56 @@ tbody tr:hover { margin-top: 10px; } +.server-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; +} + +.server-grid label { + display: grid; + gap: 6px; + font-size: 0.88rem; +} + +.server-grid input, +.server-grid select { + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 9px; + font-size: 0.9rem; + background: #fff; +} + .bg-glow { position: fixed; width: 360px; height: 360px; border-radius: 50%; - filter: blur(48px); - opacity: 0.42; + filter: blur(55px); + opacity: 0.35; pointer-events: none; z-index: 1; - animation: drift 12s ease-in-out infinite alternate; + animation: drift 10s ease-in-out infinite alternate; } .bg-glow-a { - background: #a2e9db; - top: -90px; - right: -70px; + background: #8de4d5; + top: -110px; + right: -80px; } .bg-glow-b { - background: #c4dcff; - bottom: -120px; + background: #a9c9ff; + bottom: -130px; left: -90px; - animation-delay: 1.4s; + animation-delay: 1.2s; } @keyframes rise { from { opacity: 0; - transform: translateY(7px); + transform: translateY(8px); } to { opacity: 1; @@ -185,17 +277,38 @@ tbody tr:hover { } } +@keyframes fadeSlide { + from { + opacity: 0; + transform: translateX(7px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @keyframes drift { from { transform: translate(0, 0) scale(1); } to { - transform: translate(30px, -15px) scale(1.12); + transform: translate(26px, -16px) scale(1.1); + } +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + } + + .side-nav { + position: static; } } -@media (max-width: 800px) { - .grid { +@media (max-width: 740px) { + .server-grid { grid-template-columns: 1fr; } }