From e0839fce8299e0f0a6398af8d72c617f07ebefd8 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 27 Feb 2024 15:40:06 +0000 Subject: [PATCH] Map: Add Spy Server and Kiwi SDR. Add weather and satellite overlays. --- doc/img/Map_plugin_railway_legend.png | Bin 0 -> 23571 bytes doc/img/Map_plugin_seamarks_legend.png | Bin 0 -> 61168 bytes plugins/feature/map/CMakeLists.txt | 12 +- plugins/feature/map/cesiuminterface.cpp | 22 + plugins/feature/map/cesiuminterface.h | 2 + plugins/feature/map/czml.cpp | 1 + plugins/feature/map/icons.qrc | 25 +- plugins/feature/map/icons/anchor.png | Bin 0 -> 1722 bytes plugins/feature/map/icons/cloud.png | Bin 0 -> 389 bytes plugins/feature/map/icons/earthsat.png | Bin 0 -> 1940 bytes plugins/feature/map/icons/layers.png | Bin 0 -> 2194 bytes plugins/feature/map/icons/precipitation.png | Bin 0 -> 420 bytes plugins/feature/map/icons/railway.png | Bin 0 -> 1721 bytes plugins/feature/map/icons/waypoints.png | Bin 0 -> 1770 bytes plugins/feature/map/map.cpp | 175 +--- plugins/feature/map/map.h | 33 +- plugins/feature/map/map.qrc | 3 + plugins/feature/map/mapgui.cpp | 849 +++++++++++++++++++- plugins/feature/map/mapgui.h | 80 +- plugins/feature/map/mapguinowebengine.ui | 80 ++ plugins/feature/map/mapguiwebengine.ui | 185 ++++- plugins/feature/map/mapmodel.cpp | 5 + plugins/feature/map/mapmodel.h | 6 +- plugins/feature/map/mapsettings.cpp | 82 +- plugins/feature/map/mapsettings.h | 30 +- plugins/feature/map/mapsettingsdialog.cpp | 38 +- plugins/feature/map/mapsettingsdialog.h | 5 + plugins/feature/map/mapsettingsdialog.ui | 19 +- plugins/feature/map/maptileserver.cpp | 18 + plugins/feature/map/maptileserver.h | 436 ++++++++++ plugins/feature/map/osmtemplateserver.h | 89 +- plugins/feature/map/readme.md | 75 +- plugins/feature/map/webserver.cpp | 4 +- sdrbase/util/kiwisdrlist.cpp | 198 +++++ sdrbase/util/kiwisdrlist.h | 78 ++ sdrbase/util/nasaglobalimagery.cpp | 326 ++++++++ sdrbase/util/nasaglobalimagery.h | 98 +++ sdrbase/util/rainviewer.cpp | 130 +++ sdrbase/util/rainviewer.h | 55 ++ sdrbase/util/spyserverlist.cpp | 164 ++++ sdrbase/util/spyserverlist.h | 75 ++ sdrbase/util/waypoints.cpp | 141 ++++ sdrbase/util/waypoints.h | 80 ++ 43 files changed, 3348 insertions(+), 271 deletions(-) create mode 100644 doc/img/Map_plugin_railway_legend.png create mode 100644 doc/img/Map_plugin_seamarks_legend.png create mode 100644 plugins/feature/map/icons/anchor.png create mode 100644 plugins/feature/map/icons/cloud.png create mode 100644 plugins/feature/map/icons/earthsat.png create mode 100644 plugins/feature/map/icons/layers.png create mode 100644 plugins/feature/map/icons/precipitation.png create mode 100644 plugins/feature/map/icons/railway.png create mode 100644 plugins/feature/map/icons/waypoints.png create mode 100644 plugins/feature/map/maptileserver.cpp create mode 100644 plugins/feature/map/maptileserver.h create mode 100644 sdrbase/util/kiwisdrlist.cpp create mode 100644 sdrbase/util/kiwisdrlist.h create mode 100644 sdrbase/util/nasaglobalimagery.cpp create mode 100644 sdrbase/util/nasaglobalimagery.h create mode 100644 sdrbase/util/rainviewer.cpp create mode 100644 sdrbase/util/rainviewer.h create mode 100644 sdrbase/util/spyserverlist.cpp create mode 100644 sdrbase/util/spyserverlist.h create mode 100644 sdrbase/util/waypoints.cpp create mode 100644 sdrbase/util/waypoints.h diff --git a/doc/img/Map_plugin_railway_legend.png b/doc/img/Map_plugin_railway_legend.png new file mode 100644 index 0000000000000000000000000000000000000000..e72664cdb973f8b1074badb16b6379c15886e236 GIT binary patch literal 23571 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 VvZG-b+tX7#x3PhRekIg3=0C*_r{Vwr literal 0 HcmV?d00001 diff --git a/doc/img/Map_plugin_seamarks_legend.png b/doc/img/Map_plugin_seamarks_legend.png new file mode 100644 index 0000000000000000000000000000000000000000..c988e3f63365234566a03838b994a5e7c6d69571 GIT binary patch literal 61168 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 literal 0 HcmV?d00001 diff --git a/plugins/feature/map/CMakeLists.txt b/plugins/feature/map/CMakeLists.txt index a4ffd64c8..975546db9 100644 --- a/plugins/feature/map/CMakeLists.txt +++ b/plugins/feature/map/CMakeLists.txt @@ -58,6 +58,7 @@ if(NOT SERVER_MODE) mapmodel.cpp mapitem.cpp mapwebsocketserver.cpp + maptileserver.cpp cesiuminterface.cpp czml.cpp map.qrc @@ -78,6 +79,7 @@ if(NOT SERVER_MODE) mapmodel.h mapitem.h mapwebsocketserver.h + maptileserver.h cesiuminterface.h czml.h ) @@ -86,12 +88,12 @@ if(NOT SERVER_MODE) set(TARGET_LIB_GUI "sdrgui") set(INSTALL_FOLDER ${INSTALL_PLUGINS_DIR}) - if(Qt${QT_DEFAULT_MAJOR_VERSION}WebEngineCore_FOUND) - set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Positioning Qt::Location Qt::WebEngineCore Qt::WebEngineWidgets) - elseif(Qt${QT_DEFAULT_MAJOR_VERSION}WebEngine_FOUND) - set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Positioning Qt::Location Qt::WebEngine Qt::WebEngineCore Qt::WebEngineWidgets) + if(Qt${QT_DEFAULT_MAJOR_VERSION}WebEngine_FOUND) + set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Svg Qt::Positioning Qt::Location Qt::WebEngine Qt::WebEngineCore Qt::WebEngineWidgets) + elseif(Qt${QT_DEFAULT_MAJOR_VERSION}WebEngineCore_FOUND) + set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Svg Qt::SvgWidgets Qt::Positioning Qt::Location Qt::WebEngineCore Qt::WebEngineWidgets) else() - set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Positioning Qt::Location) + set(TARGET_LIB "Qt::Widgets" Qt::Quick Qt::QuickWidgets Qt::Svg Qt::Positioning Qt::Location) endif() else() set(TARGET_NAME mapsrv) diff --git a/plugins/feature/map/cesiuminterface.cpp b/plugins/feature/map/cesiuminterface.cpp index 097a3266e..e9a3e39a2 100644 --- a/plugins/feature/map/cesiuminterface.cpp +++ b/plugins/feature/map/cesiuminterface.cpp @@ -177,6 +177,28 @@ void CesiumInterface::showfoF2(bool show) send(obj); } +void CesiumInterface::showLayer(const QString& layer, bool show) +{ + QJsonObject obj { + {"command", "showLayer"}, + {"layer", layer}, + {"show", show} + }; + send(obj); +} + +void CesiumInterface::setLayerSettings(const QString& layer, const QStringList& settings, const QList& values) +{ + QJsonObject obj { + {"command", "setLayerSettings"}, + {"layer", layer}, + }; + for (int i = 0; i < settings.size(); i++) { + obj.insert(settings[i], QJsonValue::fromVariant(values[i])); + } + send(obj); +} + void CesiumInterface::updateImage(const QString &name, float east, float west, float north, float south, float altitude, const QString &data) { QJsonObject obj { diff --git a/plugins/feature/map/cesiuminterface.h b/plugins/feature/map/cesiuminterface.h index 359929ed3..418d0bf07 100644 --- a/plugins/feature/map/cesiuminterface.h +++ b/plugins/feature/map/cesiuminterface.h @@ -68,6 +68,8 @@ public: void setAntiAliasing(const QString &antiAliasing); void showMUF(bool show); void showfoF2(bool show); + void showLayer(const QString& layer, bool show); + void setLayerSettings(const QString& layer, const QStringList& settings, const QList& values); void updateImage(const QString &name, float east, float west, float north, float south, float altitude, const QString &data); void removeImage(const QString &name); void removeAllImages(); diff --git a/plugins/feature/map/czml.cpp b/plugins/feature/map/czml.cpp index 1ba0f1bb0..002e1e5d1 100644 --- a/plugins/feature/map/czml.cpp +++ b/plugins/feature/map/czml.cpp @@ -475,6 +475,7 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) if ((mapItem->m_group == "Beacons") || (mapItem->m_group == "AM") || (mapItem->m_group == "FM") || (mapItem->m_group == "DAB") || (mapItem->m_group == "NavAid") + || (mapItem->m_group == "Waypoints") ) { displayDistanceMax = 1000000; } else if ((mapItem->m_group == "Station") || (mapItem->m_group == "Radar") || (mapItem->m_group == "Radio Time Transmitters")) { diff --git a/plugins/feature/map/icons.qrc b/plugins/feature/map/icons.qrc index 46b171f4c..cd287def8 100644 --- a/plugins/feature/map/icons.qrc +++ b/plugins/feature/map/icons.qrc @@ -1,11 +1,18 @@ - - icons/groundtracks.png - icons/clock.png - icons/ibp.png - icons/muf.png - icons/fof2.png - icons/controltower.png - icons/vor.png - + + icons/groundtracks.png + icons/clock.png + icons/ibp.png + icons/muf.png + icons/fof2.png + icons/controltower.png + icons/vor.png + icons/precipitation.png + icons/anchor.png + icons/cloud.png + icons/layers.png + icons/railway.png + icons/waypoints.png + icons/earthsat.png + diff --git a/plugins/feature/map/icons/anchor.png b/plugins/feature/map/icons/anchor.png new file mode 100644 index 0000000000000000000000000000000000000000..39400706b578aa0a76ed8acc23b862b61c2da484 GIT binary patch literal 1722 zcmbVNe{2&~9KXS~bR`4+Fb-iOm)k%Vz205dt?P}Bjdjoo-OnZroVx4XYkRD{yWQQ^ zZjp#!<`%XfFw{ghCw7TZTtEVmDL)h)WH^72AQOo;2-`4GaGH#vGT(J=gJx>vl6UXD zd!P6De1E*}yN=58^{M)$dIUjI%j~vl*e}tVE*ZYV0qbqprAYS83hY18T0+Li;F}1N zc$s(BsH3!koPklKuCfIL*sFyOGKY2Lpf+pPDI<`pvweB zWds(K*{m3J(!lc^&6+L1o$I1-w-GS7(QUHejEQpNES2xd%cDrf?4n}(Z6e#Gr82gk z`=9$OWFFQM+j8en8l?16#$&!a;?y9ur4W?dlu?pD_;mv(@e2zG z@RpL{b;K{ZQ-vT2ZDqD%r@iCH{fD=HRQW*LO#9If9qsl&LHE^F6QgG7Rzi9x{N%(k zbuv3$fARI3Vbpu0_PLWq>$6Su*^^DTyd@idU3clOwD*eg`E-5*^zO`NG3LOYNeB{9N!ZU|=>JNn%E$F-YaSyYI-ZL-bZ2I+# z9fqtw?E1`~7VENlT6(7%4-NMof7ict>5E0v-3tp>oOq0W>Dq?DyqQ-*{ULFeWngD* z-6NA@_}TPvw$*fZ!Fy@QTSdP(*Y>421A6=5K2t)n!T3T^T|)BSl|x5+Mz46NcWw;n zs&{99(Zyf%o#{F9{0E^eS>sPsXO_%QJ^yu|`_*q&3|_diVcW5v`$JW2^D@uxX}mpj zT$r!h-rM!tS7Z7h@~D}?#}VCT`paa8sPAG zPuF)A3~hhy^gcuLvBar^GpBeRzOrin)PwDbNvCnLYjMR$TKc(>tV>DZFAs)ae&Z>8 W*k1NsQq6Mh?^0G;ZW~xzzvEABeOx&J literal 0 HcmV?d00001 diff --git a/plugins/feature/map/icons/cloud.png b/plugins/feature/map/icons/cloud.png new file mode 100644 index 0000000000000000000000000000000000000000..7a69d809539a314ab27ad4f3fae1be83999e98fc GIT binary patch literal 389 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=fpjwnGbX*?9R<`N<>}%W;^F;va;#vpfj|oQp9fibqL}=BCdM#tIq5F(I=rRP>w!n=yjH`-KfWrx(*5{OKY8YX zn3>BCh$lXIrLOs87OSDN^l^{o=P9?dc4XY%#&h^X+fVlB*3z_|m68VSpSX_ronzmo zog;om{tPEGH_xVDSN8?1p5CH1|IW0U_J_A*Ke0wzzIiR{cUjC~|F&2Wem37Xhwh#_ zBE{DbwKpaFlYY`tbJ-QY4qfxo5&yqHb-_fZ#|y9Tu3h9|w2OK7#A$CkHj1_wzhf=$ hh_$e^Y*F~ZTt0D@h}2h3c3^NZc)I$ztaD0e0sx4Qn&l2l@za3kn2pk3;a-JNDJ{Szx|p%4k%t$$5SXFVNPdD&rn6_zC5*O; zqjRhDMzX4uR8dH33cSLJ0|F~85U|r~vGKT5jg0f+VC?;jBJemwC{-irUO~7(XMi(U zj)qlIxrCI-FgQsiH7gR7w3&>DF&U;n<#JS}lE^V!DZ{ZOc=ADj97mb)b(&R^Vqm65 zN(8}umzImRw9tPS=u5+=4> zP$R(92_LL>AFYj_j1!0$>Ll!_Tq^VWG!CRlAI@IRS;oaF5~VG)m9_~yz{-7CdkHJB zdF_KYFAz48&eEt6;G~pcC|qt*DitO(DKW{Fi4sDtluMF{Bub)?B`8#M0%1x_^zGNM zWVtt$Q~Rm^xIc$uKrIoL|IOpAFz*?_wG0okQ#n}zxpdj&$il$mX~zlDdz{q>>CGce zA(Nk({~dt|T1N>DNT<*51cGPHf`j1b^kNXGe?2-Vup9N(+=L70U%eY&d!|08;GpQe zL*I1+CcX=cwt-uU1J_aei>omR@+->Hq#Lz0XB#%`(3ORMzNOe4vFFRUu-;Gh9L@gl zJMpS>clB$xb2T$BEoYx!T=_!IAu}i5CGL31T%R4WdzW)aS-H5Est7=f zIPaL~vA=(HKw7FtW%#`V8gvc1H+ss#Vpjnq2VW)J#u$qQCRT)3$9!@T|}ltN2nHQ zjqT#vD@)hjB-7^0mT`5ON0?_rN^}}_rNWckikt3e!`o9vMX_yI>}aHW_ubla>p#v@ zH@&OtdHMQYao48rPno;B8ueejmznSS!0j=f@ORF+wrgkOE31~a?%Ph}4F3d`H}xIT zuj%Q%zIh;cB=7DC!`|$s{*K$*Qg7Nl%9^+XzpCzRS1+o*Q8tWwav$fs1yNRM>%#Z7 o-L0o%W0S}Dc=7VUM))^T&&D^G^+B`yz5mUuOugpR%A&1*0DnBZmjD0& literal 0 HcmV?d00001 diff --git a/plugins/feature/map/icons/layers.png b/plugins/feature/map/icons/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..89e904527fc4c945744f99127e67706a93305beb GIT binary patch literal 2194 zcmbVO3sBQ`9InqP=VKxqPEljrrcTp5+LSg}8B&C96j91#GL$q)A<`xx3A8A_=LQb( zF()?#Ii1eo19fh`hmYag;ZElCoDYWcaq9V)x9O?q{)N)H9XfBlOY=|i{lD+~J@UI0 zB_+mn4eA}F(P+9_<1J&sxtH(g)DisGd(02Osk0RSz5>qWzN5{2`KqANblAk(Q`J;k z0>y}KEzOD!PV03`0HM(gkMc@1lg+7+gUjRvGkkm39vI?TGdzy8VKym-%i`lF%Uts0 zL_0G%n=!F))Cg#}mjVQCPNgBQ+a)NJ*9C5EzBw6iGt<9}MJV)=7=EjPi?tD>Iy>suG2w z9*;-s(P>3F6U9v?6N(WiK_CEuDEWd)dl5ky*ur4p6h`JHl@|rb$4EQGT-6K%tIaXE zr2ws<`11rZhI(lU#kH6(rY0cE1aMNW>}nEc8I*HzZcb1YfW-q?DN9sEB};q`b>Q(o z1VC(GDMJC9W;0xFR-FV8S=~q&O)o483t9;BN&e0MmuJrbU1|>#y6vEBFj7TTSXBR zVQN8vPGM=4ei4pkD5og9X%HdrrZYKI5;9>ZkT)tubcr&M19o&x+BO?y6%>^g7|v=j z!yrg4&$E=CadNcY5rGf{jv=H`k0B1mNgxqU633042|Nnyw}?!xuav(1&27z!4B+@% zRZkjNqk+T`2SL&Zt<$rJ!(cQZ3`aZl9D$QYf^Bg#TIRuFz(MnEzLk8ul(K)nCyN85tyjBQK@pv*hnBhMOpvz!Y$Wt~z7;y}#janjip`!{ zb?xuI|Kr-93?_>fGC43HQP>v;>YF6Zr9l5(rKV@kOq~`u7-ha?V2FZ?z!>HPKrMqI z8{@g1tkJYBv|6I=@kJYcwiPZ(33=tty^;arOO6zVw$pWH&UOlpdDZw$Q2X40BenXF zn#KFv(L|<>31Qr_V}af zm0`6FrmIh*?k~SrHl@}LQK@2F&DO$Rhi~tJ8|;?iZQRPwx-^7*`NM=dzK`oNGa@D` zG2^ZB^nKB;{m$@r%4^Ev6MyNtAiRLOq1iXg^W<`>bnD~56!eYNDYotz#S?Md;hOsTA1T9$TXM`_vV?1cKB zu0ylz)kTbV@v3#bteKbTQ77Nrd}vYHg#G0!@-IK#`<^~nKcoMhRfX4FN6-IcwjO_5 z-npq=Slts(MyEZKsxICglXG@JLFuojZ%nxUQP8N{ zGv4gJwZY-oy>7*hN@s2tX53NfdfQ`CNY5jUQQf~i9NunCT<(}#gSLMwA|j)9svo@=s%OU1aITJoA`5W*hjP&}gi&iI%F78MFQZOVu6u literal 0 HcmV?d00001 diff --git a/plugins/feature/map/icons/precipitation.png b/plugins/feature/map/icons/precipitation.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2a8a6a080cb85334d50a841c1e118155413122 GIT binary patch literal 420 zcmV;V0bBlwP)(^cR!KxbR7i=nRf|=GFc2JV;OIa)5G#-lqy##!1xvW=fD*0*tU&A_ zc5qfO_x7Cd^1OV&!+9^jOR_tgon-@(rO}g|c#TPGS^GPCB+vjj0(j8!JAhH&Er4fz ze*x$KOi?!p=7!`+G4DxMkv)~xsJ%_hSI)VOZb1sWVE+|K%)sZnnBQRZvYAo@cHB3T zP8){>PQx^YaPn1okt>P!D8Wpu9>6K3zF>BP-XUrg#^n_{w?RvPZc`D8b>7I55PelF7x)+Tn5Xx7eO5F@Y(`UbGSaXy=(dZYOy zLDndPNgU=9alh%gv(^TIOA)dLNw1wKJ!(7BICQAFSww94it+;Rb^ z6I@LSsB9|pa831`ok#PwA-Q215(I$8BH@5vROv8-MtEsB)<5GY5`k#-4C>GYk!p82 zQXnY+v71SZBM1`7h9hehm1DO*jZg$-!HF!KvSAcK6C`c3A<+kgatiOIE1X49F*sw; zI!%*l91n#;=8(lKDL$OE+wC|(;S_~I1g18M8XLw$H6zC01S+QpvL;9(qBF9!Qcz=1 zXzG#=0Xa@9s?j*Xh~Z&Y#z`}w`xF84TpT9{6@NsW=WyT$0U&BB#FBBWTqkLgS|{Cy zI==ij0T^4iJFc;;ErCE>Le&Zzpc~PEEQ?k>jWWP1fGPzQ4iq*(Gc$B=WV%2BtR^WQ zN%F@sRUR`Lp=@RgvZ+cCc`2lBzGnk)vKnAem^CX#kQkZcAuY7cPE*!BODMO*3*N^2 zpsZOj6g(+BtFixv<2l+ZDFGIGC0(jUSdjz8 zP6mZengxNUvu(A6m$i5?3vaVwT(;GV*#VV<@iseWAuUAPPi1UB|3CLX zs|c``SpNg_=oO})0oo;~FguOW5~u_(Mn`@DiKLxoIsG^@D5vKU@M!e2@W2sRq7Bsn zNVVrJZpj7wpWa2*?y1ih92UJkj9)i!62Gv32yZC` zUPl{=PcIt`2`?8r9UfQb?02IjyLP6no*x@*PjjWTJ#lvE?651jt@5s8z`F3%#PLru z*CcoCY`&NxqPtt4$+@+qC+n5Vj?doQuxEejb?a2~jj3;#-rlm7@6Qj9-P)G-+hXow z?x~daRflI*woKPNyz%@a9rFoWTW32`dixr33iovkObuQXXBPOAFC4ABk@v^KlXEM= zJw0>oo49b8T+rGZ}|8`+4^gVLVH{3yQvPWDD#r3DCMK^gmb?3 z$DO|%lqTLIsjFmjr@42;m3k+8Z*%)Xl4(A1ApX?k!G?hxS#pJ)Qi2#P7)U=W5+;naN%mV@r1LcYI|t8X79OZva6 Q*MBa>g=Nmu`Omli1+%MJtpET3 literal 0 HcmV?d00001 diff --git a/plugins/feature/map/icons/waypoints.png b/plugins/feature/map/icons/waypoints.png new file mode 100644 index 0000000000000000000000000000000000000000..b8aafaccc3c0550d8294807e5d1f157c3716e74a GIT binary patch literal 1770 zcmbVN4NMbP96w}3Kp}4PgUz{J4rQXW_t8Ror_~^3(99yi2ElY2*Sl9ZX?w@rl@=nn z58?!ZnoKjzNKmIHo5tZBegrlqAk3-2vM(Zuy39E%TDQ_wL^B z{eJ(?ca4Pwc}a=SB_artWXrb}!7&Bi^Wx*+(|Jpu28RSGzf?hx8RxV+rt$kN6A1Fe zWx-LRme|)aoaoiFyyyb@pjU!u1X;N{D6w26P*E2s7kn0MqO%)A1>S;f$g~r7DF;*t z`L!}wUt8edYAZRK$5yXGR|XkK-~}p+2E87i!UQc?h?jwL?K6&{A&6RO!Ll_$w8UPB z=7=&tX+5dq2!cdSaAwS+%ru>WQUqndi7cGT)KLUOkW6MK8on?nC-ZKm$og_v48B>g z3RRUD91jEn`hY<%%H=po(=<*{I7R6oLZ{UDR5qydDX9?#D^NIDkW@kRp&BFW68)+L zgQiaV;FY4ZJ|!F{7%@D^N;s(}G@n91o{Qonzw8N#^BfL5zzck;0CY4d~Jhf{zyiO8P?^fR$B&1%p{L>IhOt85|T$Sr$W58>dlrkr&)G ze@9UaVPG=N5frQxo>kd@!|@#B7G*CBJrulbIlv`fIfh2##^i_|QHFBR979OkZf9&h zMP+>)uvsk_bW$$}Ji`$*?WSn6jsz^wxeP{=j&`#~o!i8dyvb~G5iT~e-zsu`EtQe| z{Qum)P8MJ@YoTU zrVUg8Nc!mfP9qf2tp->bWS7H0{b%Xm&~9AoxoH>hzj_y1d#FDnaM<+PGwXtQPS-ie{Yf;!XH8}yC3LHMX? z`XuAQE4OdSTkyjl-0a_f>{4H?vFZhJDs5lmn7P4puj$0Sti9Z##@hCj`*%{>+LL1a z)REXdEsILF^_sUIDrx@w!x^>5pYE)RSx}XXx6hn)I5DL!X7d*bU45yo{g`LX?R7D_ zs`iCNcREwfEpYZG&2CH_Xfa&rF&;fz@9f!Tb}qd;H*dM^;#gc^*{bS8zon*^`LB2X zl(zMF@{yvV4#d#@?1ie?ry7iBKI=~>XRRPVPTu$Cl9A?jT&}BAgJkxL*Jc;T+unaS zCaz(;?!e?r^}Ewf1rGSznzNs8jUV38l79aw=e!L)*5amgF9(*rakFXE zwQ02U+NR;IL}6;V^K9MM{Xgz`?bEG0xq{2BvGQ%?*TlT_2ijIlHBF73|K)N$a{QFH VXW{tV?b_eSmRn#wwx;Z@KLGHHc=-ST literal 0 HcmV?d00001 diff --git a/plugins/feature/map/map.cpp b/plugins/feature/map/map.cpp index 76c4abaf5..ed12fe3d4 100644 --- a/plugins/feature/map/map.cpp +++ b/plugins/feature/map/map.cpp @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2021-2023 Jon Beniston, M7RCE // +// Copyright (C) 2021-2024 Jon Beniston, M7RCE // // Copyright (C) 2021-2023 Edouard Griffiths, F4EXB // // Copyright (C) 2022 Jiří Pinkava // // // @@ -47,6 +47,7 @@ const char* const Map::m_featureId = "Map"; Map::Map(WebAPIAdapterInterface *webAPIAdapterInterface) : Feature(m_featureIdURI, webAPIAdapterInterface), + m_availableChannelOrFeatureHandler(MapSettings::m_pipeURIs, QStringList{"mapitems"}), m_multiplier(0.0) { qDebug("Map::Map: webAPIAdapterInterface: %p", webAPIAdapterInterface); @@ -61,33 +62,33 @@ Map::Map(WebAPIAdapterInterface *webAPIAdapterInterface) : &Map::networkManagerFinished ); QObject::connect( - MainCore::instance(), - &MainCore::featureAdded, + &m_availableChannelOrFeatureHandler, + &AvailableChannelOrFeatureHandler::channelsOrFeaturesChanged, this, - &Map::handleFeatureAdded + &Map::channelsOrFeaturesChanged ); QObject::connect( - MainCore::instance(), - &MainCore::channelAdded, + &m_availableChannelOrFeatureHandler, + &AvailableChannelOrFeatureHandler::messageEnqueued, this, - &Map::handleChannelAdded + &Map::handlePipeMessageQueue ); - QTimer::singleShot(2000, this, SLOT(scanAvailableChannelsAndFeatures())); + m_availableChannelOrFeatureHandler.scanAvailableChannelsAndFeatures(); } Map::~Map() { QObject::disconnect( - MainCore::instance(), - &MainCore::featureAdded, + &m_availableChannelOrFeatureHandler, + &AvailableChannelOrFeatureHandler::channelsOrFeaturesChanged, this, - &Map::handleFeatureAdded + &Map::channelsOrFeaturesChanged ); QObject::disconnect( - MainCore::instance(), - &MainCore::channelAdded, + &m_availableChannelOrFeatureHandler, + &AvailableChannelOrFeatureHandler::messageEnqueued, this, - &Map::handleChannelAdded + &Map::handlePipeMessageQueue ); QObject::disconnect( m_networkManager, @@ -435,156 +436,22 @@ QDateTime Map::getMapDateTime() } } -void Map::scanAvailableChannelsAndFeatures() +void Map::channelsOrFeaturesChanged(const QStringList& renameFrom, const QStringList& renameTo) { - qDebug("Map::scanAvailableChannelsAndFeatures"); - std::vector& featureSets = MainCore::instance()->getFeatureeSets(); - m_availableChannelOrFeatures.clear(); - - for (const auto& featureSet : featureSets) - { - for (int fei = 0; fei < featureSet->getNumberOfFeatures(); fei++) - { - Feature *feature = featureSet->getFeatureAt(fei); - - if (MapSettings::m_pipeURIs.contains(feature->getURI()) && !m_availableChannelOrFeatures.contains(feature)) - { - qDebug("Map::scanAvailableChannelsAndFeatures: store feature %d:%d %s (%p)", - featureSet->getIndex(), fei, qPrintable(feature->getURI()), feature); - registerPipe(feature); - MapSettings::AvailableChannelOrFeature availableItem = - MapSettings::AvailableChannelOrFeature{ - "F", - featureSet->getIndex(), - fei, - feature->getIdentifier(), - feature - }; - m_availableChannelOrFeatures[feature] = availableItem; - } - } - } - - std::vector& deviceSets = MainCore::instance()->getDeviceSets(); - - for (const auto& deviceSet : deviceSets) - { - DSPDeviceSourceEngine *deviceSourceEngine = deviceSet->m_deviceSourceEngine; - DSPDeviceMIMOEngine *deviceMimoEngine = deviceSet->m_deviceMIMOEngine; - - if ((deviceSourceEngine) || (deviceMimoEngine)) - { - for (int chi = 0; chi < deviceSet->getNumberOfChannels(); chi++) - { - ChannelAPI *channel = deviceSet->getChannelAt(chi); - - if (MapSettings::m_pipeURIs.contains(channel->getURI()) && !m_availableChannelOrFeatures.contains(channel)) - { - qDebug("Map::scanAvailableChannelsAndFeatures: store channel %d:%d %s (%p)", - deviceSet->getIndex(), chi, qPrintable(channel->getURI()), channel); - registerPipe(channel); - MapSettings::AvailableChannelOrFeature availableItem = - MapSettings::AvailableChannelOrFeature{ - "R", - deviceSet->getIndex(), - chi, - channel->getIdentifier(), - channel}; - m_availableChannelOrFeatures[channel] = availableItem; - } - } - } - } - - notifyUpdate(); + m_availableChannelOrFeatures = m_availableChannelOrFeatureHandler.getAvailableChannelOrFeatureList(); + notifyUpdate(renameFrom, renameTo); } -void Map::handleFeatureAdded(int featureSetIndex, Feature *feature) -{ - FeatureSet *featureSet = MainCore::instance()->getFeatureeSets()[featureSetIndex]; - - if (MapSettings::m_pipeURIs.contains(feature->getURI())) - { - qDebug("Map::handleFeatureAdded: featureSetIndex: %d:%d feature: %s (%p)", - featureSetIndex, feature->getIndexInFeatureSet(), qPrintable(feature->getURI()), feature); - registerPipe(feature); - MapSettings::AvailableChannelOrFeature availableItem = - MapSettings::AvailableChannelOrFeature{ - "F", - featureSet->getIndex(), - feature->getIndexInFeatureSet(), - feature->getIdentifier(), - feature - }; - m_availableChannelOrFeatures[feature] = availableItem; - notifyUpdate(); - } -} - -void Map::handleChannelAdded(int deviceSetIndex, ChannelAPI *channel) -{ - DeviceSet *deviceSet = MainCore::instance()->getDeviceSets()[deviceSetIndex]; - DSPDeviceSourceEngine *deviceSourceEngine = deviceSet->m_deviceSourceEngine; - - if (deviceSourceEngine && MapSettings::m_pipeURIs.contains(channel->getURI())) - { - qDebug("Map::handleChannelAdded: deviceSetIndex: %d:%d channel: %s (%p)", - deviceSetIndex, channel->getIndexInDeviceSet(), qPrintable(channel->getURI()), channel); - registerPipe(channel); - MapSettings::AvailableChannelOrFeature availableItem = - MapSettings::AvailableChannelOrFeature{ - "R", - deviceSet->getIndex(), - channel->getIndexInDeviceSet(), - channel->getIdentifier(), - channel - }; - m_availableChannelOrFeatures[channel] = availableItem; - notifyUpdate(); - } -} - -void Map::registerPipe(QObject *object) -{ - qDebug("Map::registerPipe: register %s (%p)", qPrintable(object->objectName()), object); - MessagePipes& messagePipes = MainCore::instance()->getMessagePipes(); - ObjectPipe *pipe = messagePipes.registerProducerToConsumer(object, this, "mapitems"); - MessageQueue *messageQueue = qobject_cast(pipe->m_element); - QObject::connect( - messageQueue, - &MessageQueue::messageEnqueued, - this, - [=](){ this->handlePipeMessageQueue(messageQueue); }, - Qt::QueuedConnection - ); - QObject::connect( - pipe, - &ObjectPipe::toBeDeleted, - this, - &Map::handleMessagePipeToBeDeleted - ); -} - -void Map::notifyUpdate() +void Map::notifyUpdate(const QStringList& renameFrom, const QStringList& renameTo) { if (getMessageQueueToGUI()) { - MsgReportAvailableChannelOrFeatures *msg = MsgReportAvailableChannelOrFeatures::create(); - msg->getItems() = m_availableChannelOrFeatures.values(); + MsgReportAvailableChannelOrFeatures *msg = MsgReportAvailableChannelOrFeatures::create(renameFrom, renameTo); + msg->getItems() = m_availableChannelOrFeatures; getMessageQueueToGUI()->push(msg); } } -void Map::handleMessagePipeToBeDeleted(int reason, QObject* object) -{ - if ((reason == 0) && m_availableChannelOrFeatures.contains(object)) // producer - { - qDebug("Map::handleMessagePipeToBeDeleted: removing channel or feature at (%p)", object); - m_availableChannelOrFeatures.remove(object); - notifyUpdate(); - } -} - void Map::handlePipeMessageQueue(MessageQueue* messageQueue) { Message* message; diff --git a/plugins/feature/map/map.h b/plugins/feature/map/map.h index a2867ee13..6f68f557a 100644 --- a/plugins/feature/map/map.h +++ b/plugins/feature/map/map.h @@ -1,7 +1,7 @@ /////////////////////////////////////////////////////////////////////////////////// // Copyright (C) 2020, 2022 Edouard Griffiths, F4EXB // // Copyright (C) 2020 Kacper Michajłow // -// Copyright (C) 2021-2022 Jon Beniston, M7RCE // +// Copyright (C) 2021-2024 Jon Beniston, M7RCE // // Copyright (C) 2022 Jiří Pinkava // // // // This program is free software; you can redistribute it and/or modify // @@ -28,6 +28,8 @@ #include "feature/feature.h" #include "util/message.h" +#include "availablechannelorfeaturehandler.h" +#include "maincore.h" #include "mapsettings.h" @@ -110,17 +112,23 @@ public: MESSAGE_CLASS_DECLARATION public: - QList& getItems() { return m_availableChannelOrFeatures; } + AvailableChannelOrFeatureList& getItems() { return m_availableChannelOrFeatures; } + const QStringList& getRenameFrom() const { return m_renameFrom; } + const QStringList& getRenameTo() const { return m_renameTo; } - static MsgReportAvailableChannelOrFeatures* create() { - return new MsgReportAvailableChannelOrFeatures(); + static MsgReportAvailableChannelOrFeatures* create(const QStringList& renameFrom, const QStringList& renameTo) { + return new MsgReportAvailableChannelOrFeatures(renameFrom, renameTo); } private: - QList m_availableChannelOrFeatures; + AvailableChannelOrFeatureList m_availableChannelOrFeatures; + QStringList m_renameFrom; + QStringList m_renameTo; - MsgReportAvailableChannelOrFeatures() : - Message() + MsgReportAvailableChannelOrFeatures(const QStringList& renameFrom, const QStringList& renameTo) : + Message(), + m_renameFrom(renameFrom), + m_renameTo(renameTo) {} }; @@ -176,7 +184,8 @@ public: private: MapSettings m_settings; - QHash m_availableChannelOrFeatures; + AvailableChannelOrFeatureList m_availableChannelOrFeatures; + AvailableChannelOrFeatureHandler m_availableChannelOrFeatureHandler; QNetworkAccessManager *m_networkManager; QNetworkRequest m_networkRequest; @@ -184,8 +193,7 @@ private: void applySettings(const MapSettings& settings, const QList& settingsKeys, bool force = false); void webapiFormatFeatureReport(SWGSDRangel::SWGFeatureReport& response); void webapiReverseSendSettings(const QList& featureSettingsKeys, const MapSettings& settings, bool force); - void registerPipe(QObject *object); - void notifyUpdate(); + void notifyUpdate(const QStringList& renameFrom, const QStringList& renameTo); QDateTime m_mapDateTime; QDateTime m_systemDateTime; @@ -194,10 +202,7 @@ private: private slots: void networkManagerFinished(QNetworkReply *reply); - void scanAvailableChannelsAndFeatures(); - void handleFeatureAdded(int featureSetIndex, Feature *feature); - void handleChannelAdded(int deviceSetIndex, ChannelAPI *channel); - void handleMessagePipeToBeDeleted(int reason, QObject* object); + void channelsOrFeaturesChanged(const QStringList& renameFrom, const QStringList& renameTo); void handlePipeMessageQueue(MessageQueue* messageQueue); }; diff --git a/plugins/feature/map/map.qrc b/plugins/feature/map/map.qrc index ef95138e4..d2a1e2807 100644 --- a/plugins/feature/map/map.qrc +++ b/plugins/feature/map/map.qrc @@ -8,6 +8,8 @@ map/antennadab.png map/antennafm.png map/antennaam.png + map/antennakiwi.png + map/antennaspyserver.png map/ionosonde.png map/VOR.png map/VOR-DME.png @@ -21,6 +23,7 @@ map/airport_medium.png map/airport_small.png map/heliport.png + map/waypoint.png map/map3d.html diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index f1c9df910..cfdd1f438 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2021-2023 Jon Beniston, M7RCE // +// Copyright (C) 2021-2024 Jon Beniston, M7RCE // // Copyright (C) 2021-2022 Edouard Griffiths, F4EXB // // // // This program is free software; you can redistribute it and/or modify // @@ -24,6 +24,10 @@ #include #include #include +#include +#include +#include +#include #ifdef QT_WEBENGINE_FOUND #include @@ -35,7 +39,11 @@ #include "gui/basicfeaturesettingsdialog.h" #include "gui/dialogpositioner.h" #include "mainwindow.h" +#include "device/deviceset.h" +#include "device/deviceapi.h" +#include "dsp/devicesamplesource.h" #include "device/deviceuiset.h" +#include "device/deviceenumerator.h" #include "util/units.h" #include "util/maidenhead.h" #include "util/morse.h" @@ -50,6 +58,9 @@ #include "mapgui.h" #include "SWGMapItem.h" #include "SWGTargetAzimuthElevation.h" +#include "SWGDeviceSettings.h" +#include "SWGKiwiSDRSettings.h" +#include "SWGRemoteTCPInputSettings.h" MapGUI* MapGUI::create(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *feature) { @@ -140,7 +151,7 @@ bool MapGUI::handleMessage(const Message& message) for (int i = 0; i < m_availableChannelOrFeatures.size(); i++) { - if (m_availableChannelOrFeatures[i].m_source == msgMapItem.getPipeSource()) + if (m_availableChannelOrFeatures[i].m_object == msgMapItem.getPipeSource()) { for (int j = 0; j < MapSettings::m_pipeTypes.size(); j++) { @@ -192,7 +203,12 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur m_beaconDialog(this), m_ibpBeaconDialog(this), m_radioTimeDialog(this), - m_cesium(nullptr) + m_cesium(nullptr), + m_legend(nullptr), + m_nasaWidget(nullptr), + m_legendWidget(nullptr), + m_descriptionWidget(nullptr), + m_overviewWidget(nullptr) { m_feature = feature; setAttribute(Qt::WA_DeleteOnClose, true); @@ -214,10 +230,36 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur ui->map->setFormat(format); } + createNASAGlobalImageryView(); + connect(&m_nasaGlobalImagery, &NASAGlobalImagery::dataUpdated, this, &MapGUI::nasaGlobalImageryDataUpdated); + connect(&m_nasaGlobalImagery, &NASAGlobalImagery::metaDataUpdated, this, &MapGUI::nasaGlobalImageryMetaDataUpdated); + connect(&m_nasaGlobalImagery, &NASAGlobalImagery::legendAvailable, this, &MapGUI::nasaGlobalImageryLegendAvailable); + connect(&m_nasaGlobalImagery, &NASAGlobalImagery::htmlAvailable, this, &MapGUI::nasaGlobalImageryHTMLAvailable); + m_nasaGlobalImagery.getData(); + m_nasaGlobalImagery.getMetaData(); + + createLayersMenu(); + displayToolbar(); + connect(screen(), &QScreen::orientationChanged, this, &MapGUI::orientationChanged); +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) + screen()->setOrientationUpdateMask(Qt::PortraitOrientation + | Qt::LandscapeOrientation + | Qt::InvertedPortraitOrientation + | Qt::InvertedLandscapeOrientation); +#endif + clearWikiMediaOSMCache(); + m_rainViewer = new RainViewer(); + connect(m_rainViewer, &RainViewer::pathUpdated, this, &MapGUI::pathUpdated); + m_rainViewer->getPathPeriodically(); + + m_mapTileServerPort = 60602; + m_mapTileServer = new MapTileServer(m_mapTileServerPort); + m_mapTileServer->setThunderforestAPIKey(thunderforestAPIKey()); + m_mapTileServer->setMaptilerAPIKey(maptilerAPIKey()); m_osmPort = 0; - m_templateServer = new OSMTemplateServer(thunderforestAPIKey(), maptilerAPIKey(), m_osmPort); + m_templateServer = new OSMTemplateServer(thunderforestAPIKey(), maptilerAPIKey(), m_mapTileServerPort, m_osmPort); // Web server to serve dynamic files from QResources m_webPort = 0; @@ -253,6 +295,9 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur connect(this, SIGNAL(customContextMenuRequested(const QPoint &)), this, SLOT(onMenuDialogCalled(const QPoint &))); connect(getInputMessageQueue(), SIGNAL(messageEnqueued()), this, SLOT(handleInputMessages())); + connect(&m_kiwiSDRList, &KiwiSDRList::dataUpdated, this, &MapGUI::kiwiSDRUpdated); + connect(&m_spyServerList, &SpyServerList::dataUpdated, this, &MapGUI::spyServerUpdated); + #ifdef QT_WEBENGINE_FOUND QWebEngineSettings *settings = ui->web->settings(); settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, true); @@ -320,12 +365,17 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur addNavAids(); addAirspace(); addAirports(); + addWaypoints(); addNavtex(); addVLF(); + addKiwiSDR(); + addSpyServer(); displaySettings(); applySettings(true); + connect(&m_objectMapModel, &ObjectMapModel::linkClicked, this, &MapGUI::linkClicked); + connect(&m_redrawMapTimer, &QTimer::timeout, this, &MapGUI::redrawMap); m_redrawMapTimer.setSingleShot(true); ui->map->installEventFilter(this); @@ -342,12 +392,18 @@ MapGUI::~MapGUI() disconnect(&m_redrawMapTimer, &QTimer::timeout, this, &MapGUI::redrawMap); m_redrawMapTimer.stop(); //m_cesium->deleteLater(); + delete m_rainViewer; delete m_cesium; if (m_templateServer) { m_templateServer->close(); delete m_templateServer; } + if (m_mapTileServer) + { + m_mapTileServer->close(); + delete m_mapTileServer; + } if (m_webServer) { m_webServer->close(); @@ -542,6 +598,124 @@ void MapGUI::addRadar() update(m_map, &radarMapItem, "Radar"); } +QString MapGUI::formatFrequency(qint64 frequency) const +{ + QString s = QString::number(frequency); + if (s.endsWith("000000000")) { + return s.left(s.size() - 9) + " GHz"; + } else if (s.endsWith("000000")) { + return s.left(s.size() - 6) + " MHz"; + } else if (s.endsWith("000")) { + return s.left(s.size() - 3) + " kHz"; + } + return s + " Hz"; +} + +void MapGUI::addKiwiSDR() +{ + m_kiwiSDRList.getDataPeriodically(); +} + +void MapGUI::kiwiSDRUpdated(const QList& sdrs) +{ + for (const auto& sdr : sdrs) + { + SWGSDRangel::SWGMapItem kiwiMapItem; + kiwiMapItem.setName(new QString(sdr.m_name)); + kiwiMapItem.setLatitude(sdr.m_latitude); + kiwiMapItem.setLongitude(sdr.m_longitude); + kiwiMapItem.setAltitude(sdr.m_altitude); + kiwiMapItem.setImage(new QString("antennakiwi.png")); + kiwiMapItem.setImageRotation(0); + QString url = QString("sdrangel-kiwisdr://%1").arg(sdr.m_url); + QString antenna = sdr.m_antenna; + if (!sdr.m_antennaConnected) { + antenna.append(" (Not connected)"); + } + QString text = QString("KiwiSDR\nName: %1\nHW: %2\nUsers: %3/%4\nFrequency: %5 - %6\nAntenna: %7\nSNR: %8 dB\nURL: %9") + .arg(sdr.m_name) + .arg(sdr.m_sdrHW) + .arg(sdr.m_users) + .arg(sdr.m_usersMax) + .arg(formatFrequency(sdr.m_lowFrequency)) + .arg(formatFrequency(sdr.m_highFrequency)) + .arg(antenna) + .arg(sdr.m_snr) + .arg(QString("%2").arg(url).arg(sdr.m_url)) + ; + kiwiMapItem.setText(new QString(text)); + kiwiMapItem.setModel(new QString("antenna.glb")); + kiwiMapItem.setFixedPosition(true); + kiwiMapItem.setOrientation(0); + QString band = "HF"; + if (sdr.m_highFrequency > 300000000) { + band = "UHF"; + } else if (sdr.m_highFrequency > 320000000) { // Technically 30MHz, but many HF Kiwis list up to 32MHz + band = "VHF"; + } + QString label = QString("Kiwi %1").arg(band); + kiwiMapItem.setLabel(new QString(label)); + kiwiMapItem.setLabelAltitudeOffset(4.5); + kiwiMapItem.setAltitudeReference(1); + update(m_map, &kiwiMapItem, "KiwiSDR"); + } +} + +void MapGUI::addSpyServer() +{ + m_spyServerList.getDataPeriodically(); +} + +void MapGUI::spyServerUpdated(const QList& sdrs) +{ + for (const auto& sdr : sdrs) + { + SWGSDRangel::SWGMapItem spyServerMapItem; + + QString address = QString("%1:%2").arg(sdr.m_streamingHost).arg(sdr.m_streamingPort); + spyServerMapItem.setName(new QString(address)); + spyServerMapItem.setLatitude(sdr.m_latitude); + spyServerMapItem.setLongitude(sdr.m_longitude); + spyServerMapItem.setAltitude(0); + spyServerMapItem.setImage(new QString("antennaspyserver.png")); + spyServerMapItem.setImageRotation(0); + QString url = QString("sdrangel-spyserver://%1").arg(address); + QString text = QString("SpyServer\nDescription: %1\nHW: %2\nUsers: %3/%4\nFrequency: %5 - %6\nAntenna: %7\nOnline: %8\nURL: %9") + .arg(sdr.m_generalDescription) + .arg(sdr.m_deviceType) + .arg(sdr.m_currentClientCount) + .arg(sdr.m_maxClients) + .arg(formatFrequency(sdr.m_minimumFrequency)) + .arg(formatFrequency(sdr.m_maximumFrequency)) + .arg(sdr.m_antennaType) + .arg(sdr.m_online ? "Yes" : "No") + .arg(QString("%2").arg(url).arg(address)) + ; + spyServerMapItem.setText(new QString(text)); + spyServerMapItem.setModel(new QString("antenna.glb")); + spyServerMapItem.setFixedPosition(true); + spyServerMapItem.setOrientation(0); + QStringList bands; + if (sdr.m_minimumFrequency < 30000000) { + bands.append("HF"); + } + if ((sdr.m_minimumFrequency < 300000000) && (sdr.m_maximumFrequency > 30000000)) { + bands.append("VHF"); + } + if ((sdr.m_minimumFrequency < 3000000000) && (sdr.m_maximumFrequency > 300000000)) { + bands.append("UHF"); + } + if (sdr.m_maximumFrequency > 3000000000) { + bands.append("SHF"); + } + QString label = QString("SpyServer %1").arg(bands.join(" ")); + spyServerMapItem.setLabel(new QString(label)); + spyServerMapItem.setLabelAltitudeOffset(4.5); + spyServerMapItem.setAltitudeReference(1); + update(m_map, &spyServerMapItem, "SpyServer"); + } +} + // Ionosonde stations void MapGUI::addIonosonde() { @@ -612,6 +786,24 @@ void MapGUI::foF2Updated(const QJsonDocument& document) } } +void MapGUI::pathUpdated(const QString& radarPath, const QString& satellitePath) +{ + m_radarPath = radarPath; + m_satellitePath = satellitePath; + m_mapTileServer->setRadarPath(radarPath); + m_mapTileServer->setSatellitePath(satellitePath); + if (m_settings.m_displayRain || m_settings.m_displayClouds) + { + clearOSMCache(); + applyMap2DSettings(true); + } + if (m_cesium) + { + m_cesium->setLayerSettings("rain", {"path", "show"}, {radarPath, m_settings.m_displayRain}); + m_cesium->setLayerSettings("clouds", {"path", "show"}, {satellitePath, m_settings.m_displayClouds}); + } +} + void MapGUI::addBroadcast() { QFile file(":/map/data/transmitters.csv"); @@ -1033,6 +1225,39 @@ void MapGUI::addAirports() } } +void MapGUI::addWaypoints() +{ + m_waypoints = Waypoints::getWaypoints(); + if (m_waypoints) + { + QHashIterator i(*m_waypoints); + while (i.hasNext()) + { + i.next(); + const Waypoint *waypoint = i.value(); + + SWGSDRangel::SWGMapItem waypointMapItem; + waypointMapItem.setName(new QString(waypoint->m_name)); + waypointMapItem.setLatitude(waypoint->m_latitude); + waypointMapItem.setLongitude(waypoint->m_longitude); + waypointMapItem.setAltitude(0); + waypointMapItem.setImage(new QString("waypoint.png")); + waypointMapItem.setImageRotation(0); + QStringList list; + list.append(QString("Waypoint: %1").arg(waypoint->m_name)); + waypointMapItem.setText(new QString(list.join("\n"))); + //waypointMapItem.setModel(new QString("waypoint.glb")); // No such model currently + waypointMapItem.setFixedPosition(true); + waypointMapItem.setOrientation(0); + waypointMapItem.setLabel(new QString(waypoint->m_name)); + waypointMapItem.setLabelAltitudeOffset(4.5); + waypointMapItem.setAltitude(Units::feetToMetres(25000)); + waypointMapItem.setAltitudeReference(1); + update(m_map, &waypointMapItem, "Waypoints"); + } + } +} + void MapGUI::navAidsUpdated() { addNavAids(); @@ -1048,6 +1273,10 @@ void MapGUI::airportsUpdated() addAirports(); } +void MapGUI::waypointsUpdated() +{ + addWaypoints(); +} void MapGUI::addNavtex() { @@ -1097,6 +1326,135 @@ void MapGUI::blockApplySettings(bool block) m_doApplySettings = !block; } +void MapGUI::nasaGlobalImageryDataUpdated(const QList& dataSets) +{ + m_nasaDataSets = dataSets; + m_nasaDataSetsHash.clear(); + ui->nasaGlobalImageryIdentifier->blockSignals(true); + ui->nasaGlobalImageryIdentifier->clear(); + for (const auto& dataSet : m_nasaDataSets) + { + ui->nasaGlobalImageryIdentifier->addItem(dataSet.m_identifier); + m_nasaDataSetsHash.insert(dataSet.m_identifier, dataSet); + } + ui->nasaGlobalImageryIdentifier->blockSignals(false); + ui->nasaGlobalImageryIdentifier->setCurrentIndex(ui->nasaGlobalImageryIdentifier->findText(m_settings.m_nasaGlobalImageryIdentifier)); +} + +void MapGUI::createNASAGlobalImageryView() +{ + m_nasaWidget = new QWidget(); + m_nasaWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + + m_legendWidget = new QSvgWidget(); + + QPalette pal = QPalette(); + pal.setColor(QPalette::Window, Qt::white); + m_legendWidget->setAutoFillBackground(true); + m_legendWidget->setPalette(pal); + m_nasaWidget->setAutoFillBackground(true); + m_nasaWidget->setPalette(pal); + + m_descriptionWidget = new QTextEdit(); + m_descriptionWidget->setReadOnly(true); + m_descriptionWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + + m_overviewWidget = new QTableWidget(NASA_ROWS, 2); + m_overviewWidget->setItem(NASA_TITLE, 0, new QTableWidgetItem("Title")); + m_overviewWidget->setItem(NASA_TITLE, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_SUBTITLE, 0, new QTableWidgetItem("Subtitle")); + m_overviewWidget->setItem(NASA_SUBTITLE, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_DEFAULT_DATE, 0, new QTableWidgetItem("Default Date")); + m_overviewWidget->setItem(NASA_DEFAULT_DATE, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_START_DATE, 0, new QTableWidgetItem("Start Date")); + m_overviewWidget->setItem(NASA_START_DATE, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_END_DATE, 0, new QTableWidgetItem("End Date")); + m_overviewWidget->setItem(NASA_END_DATE, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_PERIOD, 0, new QTableWidgetItem("Period")); + m_overviewWidget->setItem(NASA_PERIOD, 1, new QTableWidgetItem("")); + m_overviewWidget->setItem(NASA_LAYER_GROUP, 0, new QTableWidgetItem("Group")); + m_overviewWidget->setItem(NASA_LAYER_GROUP, 1, new QTableWidgetItem("")); + m_overviewWidget->horizontalHeader()->setStretchLastSection(true); + m_overviewWidget->horizontalHeader()->hide(); + m_overviewWidget->verticalHeader()->hide(); + m_overviewWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + m_overviewWidget->setSelectionMode(QAbstractItemView::NoSelection); + + QHBoxLayout *hbox = new QHBoxLayout(); + hbox->addWidget(m_overviewWidget); + hbox->addWidget(m_legendWidget, 0, Qt::AlignHCenter | Qt::AlignTop); + hbox->addWidget(m_descriptionWidget); + hbox->setContentsMargins(0, 0, 0, 0); + + m_nasaWidget->setLayout(hbox); + ui->splitter->addWidget(m_nasaWidget); + + // Limit size of widget, otherwise the splitter makes it bigger than the maps for some unknown reason + m_nasaWidget->setMaximumHeight(m_overviewWidget->sizeHint().height()); + + m_nasaWidget->show(); +} + +void MapGUI::displayNASAMetaData() +{ + if (m_nasaMetaData.m_layers.contains(m_settings.m_nasaGlobalImageryIdentifier)) + { + const NASAGlobalImagery::Layer& layer = m_nasaMetaData.m_layers.value(m_settings.m_nasaGlobalImageryIdentifier); + const NASAGlobalImagery::DataSet& dataSet = m_nasaDataSetsHash.value(m_settings.m_nasaGlobalImageryIdentifier); + + m_overviewWidget->item(NASA_TITLE, 1)->setText(layer.m_title); + m_overviewWidget->item(NASA_SUBTITLE, 1)->setText(layer.m_subtitle); + m_overviewWidget->item(NASA_DEFAULT_DATE, 1)->setText(dataSet.m_defaultDateTime); + m_overviewWidget->item(NASA_START_DATE, 1)->setText(layer.m_startDate.date().toString("yyyy-MM-dd")); + if (layer.m_endDate.isValid()) { + m_overviewWidget->item(NASA_END_DATE, 1)->setText(layer.m_endDate.date().toString("yyyy-MM-dd")); + } else if (layer.m_ongoing) { + m_overviewWidget->item(NASA_END_DATE, 1)->setText("Ongoing"); + } else { + m_overviewWidget->item(NASA_END_DATE, 1)->setText(""); + } + m_overviewWidget->item(NASA_PERIOD, 1)->setText(layer.m_layerPeriod); + m_overviewWidget->item(NASA_LAYER_GROUP, 1)->setText(layer.m_layerGroup); + } + else + { + qDebug() << "MapGUI::displayNASAMetaData: No metadata for " << m_settings.m_nasaGlobalImageryIdentifier; + m_overviewWidget->item(NASA_TITLE, 1)->setText(""); + m_overviewWidget->item(NASA_SUBTITLE, 1)->setText(""); + m_overviewWidget->item(NASA_DEFAULT_DATE, 1)->setText(""); + m_overviewWidget->item(NASA_START_DATE, 1)->setText(""); + m_overviewWidget->item(NASA_END_DATE, 1)->setText(""); + m_overviewWidget->item(NASA_PERIOD, 1)->setText(""); + m_overviewWidget->item(NASA_LAYER_GROUP, 1)->setText(""); + } +} + +void MapGUI::nasaGlobalImageryMetaDataUpdated(const NASAGlobalImagery::MetaData& metaData) +{ + m_nasaMetaData = metaData; + displayNASAMetaData(); +} + +void MapGUI::nasaGlobalImageryLegendAvailable(const QString& url, const QByteArray& data) +{ + if (m_legendWidget) + { + m_legendWidget->load(data); + if (m_legend && (m_legend->m_height > 0)) + { + m_legendWidget->setFixedSize(m_legend->m_width, m_legend->m_height); + m_nasaWidget->updateGeometry(); + } + } +} + +void MapGUI::nasaGlobalImageryHTMLAvailable(const QString& url, const QByteArray& data) +{ + if (m_descriptionWidget) { + m_descriptionWidget->setHtml(data); + } +} + QString MapGUI::osmCachePath() { return QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + "/QtLocation/5.8/tiles/osm/sdrangel_map"; @@ -1105,10 +1463,13 @@ QString MapGUI::osmCachePath() void MapGUI::clearOSMCache() { // Delete all cached custom tiles when user changes the URL. Is there a better way to do this? + // Now deletes all cached tiles, to remove overlays QDir dir(osmCachePath()); if (dir.exists()) { - QStringList filenames = dir.entryList({"osm_100-l-8-*.png"}); + // FIXME: Restore this - and use custom URL for overlays!!! + //QStringList filenames = dir.entryList({"osm_100-l-8-*.png"}); // 8=custom URL + QStringList filenames = dir.entryList({"osm_100-l-*"}); for (const auto& filename : filenames) { QFile file(dir.filePath(filename)); @@ -1174,6 +1535,12 @@ void MapGUI::applyMap2DSettings(bool reloadMap) zoom = 10.0; } + // Update API keys in servers + m_templateServer->setThunderforestAPIKey(thunderforestAPIKey()); + m_templateServer->setMaptilerAPIKey(maptilerAPIKey()); + m_mapTileServer->setThunderforestAPIKey(thunderforestAPIKey()); + m_mapTileServer->setMaptilerAPIKey(maptilerAPIKey()); + // Create the map using the specified provider QQmlProperty::write(item, "smoothing", MainCore::instance()->getSettings().getMapSmoothing()); QQmlProperty::write(item, "mapProvider", m_settings.m_mapProvider); @@ -1288,6 +1655,36 @@ bool MapGUI::eventFilter(QObject *obj, QEvent *event) return FeatureGUI::eventFilter(obj, event); } +void MapGUI::orientationChanged(Qt::ScreenOrientation orientation) +{ + // Need a delay before geometry() reflects new orientation + // https://bugreports.qt.io/browse/QTBUG-109127 + QTimer::singleShot(200, [this]() { + displayToolbar(); + }); +} + +void MapGUI::displayToolbar() +{ + // Replace buttons with menu when window gets narrow + bool narrow = this->screen()->availableGeometry().width() < 400; + ui->layersMenu->setVisible(narrow); + bool overlayButtons = !narrow && ((m_settings.m_mapProvider == "osm") || m_settings.m_map3DEnabled); + ui->displayRain->setVisible(overlayButtons); + ui->displayClouds->setVisible(overlayButtons); + ui->displaySeaMarks->setVisible(overlayButtons); + ui->displayRailways->setVisible(overlayButtons); + ui->displayNASAGlobalImagery->setVisible(overlayButtons); + ui->displayMUF->setVisible(!narrow && m_settings.m_map3DEnabled); + ui->displayfoF2->setVisible(!narrow && m_settings.m_map3DEnabled); +} + +void MapGUI::setEnableOverlay() +{ + bool enable = m_settings.m_displayClouds || m_settings.m_displayRain || m_settings.m_displaySeaMarks || m_settings.m_displayRailways || m_settings.m_displayNASAGlobalImagery; + m_templateServer->setEnableOverlay(enable); +} + MapSettings::MapItemSettings *MapGUI::getItemSettings(const QString &group) { if (m_settings.m_itemSettings.contains(group)) { @@ -1388,6 +1785,12 @@ void MapGUI::applyMap3DSettings(bool reloadMap) m_cesium->getDateTime(); m_cesium->showMUF(m_settings.m_displayMUF); m_cesium->showfoF2(m_settings.m_displayfoF2); + m_cesium->showLayer("rain", m_settings.m_displayRain); + m_cesium->showLayer("clouds", m_settings.m_displayClouds); + m_cesium->showLayer("seaMarks", m_settings.m_displaySeaMarks); + m_cesium->showLayer("railways", m_settings.m_displayRailways); + m_cesium->showLayer("nasaGlobalImagery", m_settings.m_displayNASAGlobalImagery); + applyNASAGlobalImagerySettings(); m_objectMapModel.allUpdated(); m_imageMapModel.allUpdated(); m_polygonMapModel.allUpdated(); @@ -1471,6 +1874,19 @@ void MapGUI::init3DMap() m_cesium->showMUF(m_settings.m_displayMUF); m_cesium->showfoF2(m_settings.m_displayfoF2); + + m_cesium->showLayer("rain", m_settings.m_displayRain); + m_cesium->showLayer("clouds", m_settings.m_displayClouds); + m_cesium->showLayer("seaMarks", m_settings.m_displaySeaMarks); + m_cesium->showLayer("railways", m_settings.m_displayRailways); + applyNASAGlobalImagerySettings(); + + if (!m_radarPath.isEmpty()) { + m_cesium->setLayerSettings("rain", {"path", "show"}, {m_radarPath, m_settings.m_displayRain}); + } + if (!m_satellitePath.isEmpty()) { + m_cesium->setLayerSettings("clouds", {"path", "show"}, {m_satellitePath, m_settings.m_displayClouds}); + } #endif } @@ -1483,8 +1899,32 @@ void MapGUI::displaySettings() ui->displayNames->setChecked(m_settings.m_displayNames); ui->displaySelectedGroundTracks->setChecked(m_settings.m_displaySelectedGroundTracks); ui->displayAllGroundTracks->setChecked(m_settings.m_displayAllGroundTracks); + ui->displayRain->setChecked(m_settings.m_displayRain); + m_displayRain->setChecked(m_settings.m_displayRain); + m_mapTileServer->setDisplayRain(m_settings.m_displayRain); + ui->displayClouds->setChecked(m_settings.m_displayClouds); + m_displayClouds->setChecked(m_settings.m_displayClouds); + m_mapTileServer->setDisplayClouds(m_settings.m_displayClouds); + ui->displaySeaMarks->setChecked(m_settings.m_displaySeaMarks); + m_displaySeaMarks->setChecked(m_settings.m_displaySeaMarks); + m_mapTileServer->setDisplaySeaMarks(m_settings.m_displaySeaMarks); + ui->displayRailways->setChecked(m_settings.m_displayRailways); + m_displayRailways->setChecked(m_settings.m_displayRailways); + m_mapTileServer->setDisplayRailways(m_settings.m_displayRailways); + ui->displayNASAGlobalImagery->setChecked(m_settings.m_displayNASAGlobalImagery); + m_displayNASAGlobalImagery->setChecked(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryIdentifier->setVisible(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryOpacity->setVisible(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryOpacityText->setVisible(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryOpacity->setValue(m_settings.m_nasaGlobalImageryOpacity); + if (m_nasaWidget) { + m_nasaWidget->setVisible(m_settings.m_displayNASAGlobalImagery); + } + m_mapTileServer->setDisplayNASAGlobalImagery(m_settings.m_displayNASAGlobalImagery); ui->displayMUF->setChecked(m_settings.m_displayMUF); + m_displayMUF->setChecked(m_settings.m_displayMUF); ui->displayfoF2->setChecked(m_settings.m_displayfoF2); + m_displayfoF2->setChecked(m_settings.m_displayfoF2); m_objectMapModel.setDisplayNames(m_settings.m_displayNames); m_objectMapModel.setDisplaySelectedGroundTracks(m_settings.m_displaySelectedGroundTracks); m_objectMapModel.setDisplayAllGroundTracks(m_settings.m_displayAllGroundTracks); @@ -1492,6 +1932,8 @@ void MapGUI::displaySettings() m_imageMapModel.updateItemSettings(m_settings.m_itemSettings); m_polygonMapModel.updateItemSettings(m_settings.m_itemSettings); m_polylineMapModel.updateItemSettings(m_settings.m_itemSettings); + setEnableOverlay(); + displayToolbar(); applyMap2DSettings(true); applyMap3DSettings(true); getRollupContents()->restoreState(m_rollupState); @@ -1575,12 +2017,195 @@ void MapGUI::on_displayAllGroundTracks_clicked(bool checked) m_objectMapModel.setDisplayAllGroundTracks(checked); } +void MapGUI::on_displayRain_clicked(bool checked) +{ + if (this->sender() != ui->displayRain) { + ui->displayRain->setChecked(checked); + } + if (this->sender() != m_displayRain) { + m_displayRain->setChecked(checked); + } + m_settings.m_displayRain = checked; + m_mapTileServer->setDisplayRain(m_settings.m_displayRain); + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + if (m_cesium) { + m_cesium->showLayer("rain", m_settings.m_displayRain); + } +} + +void MapGUI::on_displayClouds_clicked(bool checked) +{ + if (this->sender() != ui->displayClouds) { + ui->displayClouds->setChecked(checked); + } + if (this->sender() != m_displayClouds) { + m_displayClouds->setChecked(checked); + } + m_settings.m_displayClouds = checked; + m_mapTileServer->setDisplayClouds(m_settings.m_displayClouds); + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + if (m_cesium) { + m_cesium->showLayer("clouds", m_settings.m_displayClouds); + } +} + +void MapGUI::on_displaySeaMarks_clicked(bool checked) +{ + if (this->sender() != ui->displaySeaMarks) { + ui->displaySeaMarks->setChecked(checked); + } + if (this->sender() != m_displaySeaMarks) { + m_displaySeaMarks->setChecked(checked); + } + m_settings.m_displaySeaMarks = checked; + m_mapTileServer->setDisplaySeaMarks(m_settings.m_displaySeaMarks); + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + if (m_cesium) { + m_cesium->showLayer("seaMarks", m_settings.m_displaySeaMarks); + } +} + +void MapGUI::on_displayRailways_clicked(bool checked) +{ + if (this->sender() != ui->displayRailways) { + ui->displayRailways->setChecked(checked); + } + if (this->sender() != m_displayRailways) { + m_displayRailways->setChecked(checked); + } + m_settings.m_displayRailways = checked; + m_mapTileServer->setDisplayRailways(m_settings.m_displayRailways); + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + if (m_cesium) { + m_cesium->showLayer("railways", m_settings.m_displayRailways); + } +} + +void MapGUI::on_displayNASAGlobalImagery_clicked(bool checked) +{ + if (this->sender() != ui->displayNASAGlobalImagery) { + ui->displayNASAGlobalImagery->setChecked(checked); + } + if (this->sender() != m_displayNASAGlobalImagery) { + m_displayNASAGlobalImagery->setChecked(checked); + } + m_settings.m_displayNASAGlobalImagery = checked; + ui->nasaGlobalImageryIdentifier->setVisible(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryOpacity->setVisible(m_settings.m_displayNASAGlobalImagery); + ui->nasaGlobalImageryOpacityText->setVisible(m_settings.m_displayNASAGlobalImagery); + if (m_nasaWidget) { + m_nasaWidget->setVisible(m_settings.m_displayNASAGlobalImagery); + } + m_mapTileServer->setDisplayNASAGlobalImagery(m_settings.m_displayNASAGlobalImagery); + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + if (m_cesium) { + m_cesium->showLayer("NASAGlobalImagery", m_settings.m_displayNASAGlobalImagery); + } +} + +void MapGUI::on_nasaGlobalImageryIdentifier_currentIndexChanged(int index) +{ + if ((index >= 0) && (index < m_nasaDataSets.size())) + { + m_settings.m_nasaGlobalImageryIdentifier = m_nasaDataSets[index].m_identifier; + // MODIS_Terra_Aerosol/default/2014-04-09/GoogleMapsCompatible_Level6 + QString date = "default"; // FIXME: Get from 3D map + QString path = QString("%1/default/%2/%3").arg(m_settings.m_nasaGlobalImageryIdentifier).arg(date).arg(m_nasaDataSets[index].m_tileMatrixSet); + m_mapTileServer->setNASAGlobalImageryPath(path); + QString format = m_nasaDataSets[index].m_format; + if (format == "image/jpeg") { + m_mapTileServer->setNASAGlobalImageryFormat("jpeg"); + } else { + m_mapTileServer->setNASAGlobalImageryFormat("png"); + } + setEnableOverlay(); + clearOSMCache(); + applyMap2DSettings(true); + applyNASAGlobalImagerySettings(); + } +} + +void MapGUI::applyNASAGlobalImagerySettings() +{ + int index = ui->nasaGlobalImageryIdentifier->currentIndex(); + + // Update 3D map + if (m_cesium) + { + if ((index >= 0) && (index < m_nasaDataSets.size())) + { + QString format = m_nasaDataSets[index].m_format; + QString extension = (format == "image/jpeg") ? "jpg" : "png"; + // Does cesium want epsg3857 or epsg4326 + //QString url = QString("https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/%1/default/{Time}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.%2").arg(m_settings.m_nasaGlobalImageryIdentifier).arg(extension); + //QString url = QString("https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/%1/default/default/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.%2").arg(m_settings.m_nasaGlobalImageryIdentifier).arg(extension); + QString url = QString("https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/%1/default/{Time}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.%2").arg(m_settings.m_nasaGlobalImageryIdentifier).arg(extension); + QString show = m_settings.m_displayNASAGlobalImagery ? "true" : "false"; + + m_cesium->setLayerSettings("NASAGlobalImagery", + {"url", "tileMatrixSet", "format", "show", "opacity", "dates"}, + {url, m_nasaDataSets[index].m_tileMatrixSet, format, m_settings.m_displayNASAGlobalImagery, m_settings.m_nasaGlobalImageryOpacity, m_nasaDataSets[index].m_dates}); + } + } + + // Update NASA table / legend / description + if ((index >= 0) && (m_nasaDataSets[index].m_legends.size() > 0)) + { + m_legend = &m_nasaDataSets[index].m_legends[0]; + m_nasaGlobalImagery.downloadLegend(*m_legend); + m_descriptionWidget->setHtml(""); + if (m_nasaMetaData.m_layers.contains(m_settings.m_nasaGlobalImageryIdentifier)) + { + QString url = m_nasaMetaData.m_layers.value(m_settings.m_nasaGlobalImageryIdentifier).m_descriptionURL; + m_nasaGlobalImagery.downloadHTML(url); + } + displayNASAMetaData(); + m_nasaWidget->setVisible(m_settings.m_displayNASAGlobalImagery); + } + else + { + if (m_nasaWidget) { + m_nasaWidget->setVisible(false); + } + } +} + +void MapGUI::on_nasaGlobalImageryOpacity_valueChanged(int value) +{ + m_settings.m_nasaGlobalImageryOpacity = value; + ui->nasaGlobalImageryOpacityText->setText(QString("%1%").arg(m_settings.m_nasaGlobalImageryOpacity)); + + if (m_cesium) + { + m_cesium->setLayerSettings("NASAGlobalImagery", + {"opacity"}, + {m_settings.m_nasaGlobalImageryOpacity} + ); + } +} + void MapGUI::on_displayMUF_clicked(bool checked) { + if (this->sender() != ui->displayMUF) { + ui->displayMUF->setChecked(checked); + } + if (this->sender() != m_displayMUF) { + m_displayMUF->setChecked(checked); + } m_settings.m_displayMUF = checked; // Only call show if disabling, so we don't get two updates // (as getMUFPeriodically results in a call to showMUF when the data is available) - m_giro->getMUFPeriodically(m_settings.m_displayMUF ? 15 : 0); + m_giro->getMUFPeriodically(m_settings.m_displayMUF ? 15 : 0); if (m_cesium && !m_settings.m_displayMUF) { m_cesium->showMUF(m_settings.m_displayMUF); } @@ -1588,6 +2213,12 @@ void MapGUI::on_displayMUF_clicked(bool checked) void MapGUI::on_displayfoF2_clicked(bool checked) { + if (this->sender() != ui->displayfoF2) { + ui->displayfoF2->setChecked(checked); + } + if (this->sender() != m_displayfoF2) { + m_displayfoF2->setChecked(checked); + } m_settings.m_displayfoF2 = checked; m_giro->getfoF2Periodically(m_settings.m_displayfoF2 ? 15 : 0); if (m_cesium && !m_settings.m_displayfoF2) { @@ -1595,6 +2226,55 @@ void MapGUI::on_displayfoF2_clicked(bool checked) } } +void MapGUI::createLayersMenu() +{ + QMenu *menu = new QMenu(); + + m_displayRain = menu->addAction("Weather radar"); + m_displayRain->setCheckable(true); + m_displayRain->setToolTip("Display weather radar (rain/snow)"); + connect(m_displayRain, &QAction::triggered, this, &MapGUI::on_displayRain_clicked); + + m_displayClouds = menu->addAction("Satellite IR"); + m_displayClouds->setCheckable(true); + m_displayClouds->setToolTip("Display satellite infra-red (clouds)"); + connect(m_displayClouds, &QAction::triggered, this, &MapGUI::on_displayClouds_clicked); + + m_displaySeaMarks = menu->addAction("Sea marks"); + m_displaySeaMarks->setCheckable(true); + m_displaySeaMarks->setToolTip("Display sea marks"); + //QIcon seaMarksIcon; + //seaMarksIcon.addPixmap(QPixmap("://map/icons/anchor.png"), QIcon::Normal, QIcon::On); + //displaySeaMarks->setIcon(seaMarksIcon); + connect(m_displaySeaMarks, &QAction::triggered, this, &MapGUI::on_displaySeaMarks_clicked); + + m_displayRailways = menu->addAction("Railways"); + m_displayRailways->setCheckable(true); + m_displayRailways->setToolTip("Display railways"); + connect(m_displayRailways, &QAction::triggered, this, &MapGUI::on_displayRailways_clicked); + + m_displayNASAGlobalImagery = menu->addAction("NASA Global Imagery"); + m_displayNASAGlobalImagery->setCheckable(true); + m_displayNASAGlobalImagery->setToolTip("Display NASA Global Imagery"); + connect(m_displayNASAGlobalImagery, &QAction::triggered, this, &MapGUI::on_displayNASAGlobalImagery_clicked); + + m_displayMUF = menu->addAction("MUF"); + m_displayMUF->setCheckable(true); + m_displayMUF->setToolTip("Display Maximum Usable Frequency contours"); + connect(m_displayMUF, &QAction::triggered, this, &MapGUI::on_displayMUF_clicked); + + m_displayfoF2 = menu->addAction("foF2"); + m_displayfoF2->setCheckable(true); + m_displayfoF2->setToolTip("Display F2 layer critical frequency contours"); + connect(m_displayfoF2, &QAction::triggered, this, &MapGUI::on_displayfoF2_clicked); + + ui->layersMenu->setMenu(menu); +} + +void MapGUI::on_layersMenu_clicked() +{ +} + void MapGUI::on_find_returnPressed() { find(ui->find->text().trimmed()); @@ -1781,6 +2461,7 @@ void MapGUI::on_displaySettings_clicked() if (dialog.m_osmURLChanged) { clearOSMCache(); } + displayToolbar(); applyMap2DSettings(dialog.m_map2DSettingsChanged); applyMap3DSettings(dialog.m_map3DSettingsChanged); m_settingsKeys.append(dialog.m_settingsKeysChanged); @@ -1858,6 +2539,11 @@ void MapGUI::receivedCesiumEvent(const QJsonObject &obj) m_map->setMapDateTime(mapDateTime, systemDateTime, canAnimate && shouldAnimate ? multiplier : 0.0); } } + else if (event == "link") + { + QString url = obj.value("url").toString(); + linkClicked(url); + } } else { @@ -1865,6 +2551,150 @@ void MapGUI::receivedCesiumEvent(const QJsonObject &obj) } } +// Handle link clicked in 2D Map Text box or 3D map infobox. +void MapGUI::linkClicked(const QString& url) +{ + if (url.startsWith("sdrangel-kiwisdr://")) + { + QString kiwiURL = url.mid(19); + openKiwiSDR(kiwiURL); + } + else if (url.startsWith("sdrangel-spyserver://")) + { + QString spyServerURL = url.mid(21); + openSpyServer(spyServerURL); + } +} + +// Open a KiwiSDR RX device +void MapGUI::openKiwiSDR(const QString& url) +{ + // Create DeviceSet + MainCore *mainCore = MainCore::instance(); + unsigned int deviceSetIndex = mainCore->getDeviceSets().size(); + MainCore::MsgAddDeviceSet *msg = MainCore::MsgAddDeviceSet::create(0); + mainCore->getMainMessageQueue()->push(msg); + + // Switch to KiwiSDR + int nbSamplingDevices = DeviceEnumerator::instance()->getNbRxSamplingDevices(); + bool found = false; + QString hwType = "KiwiSDR"; + for (int i = 0; i < nbSamplingDevices; i++) + { + const PluginInterface::SamplingDevice *samplingDevice; + + samplingDevice = DeviceEnumerator::instance()->getRxSamplingDevice(i); + + if (!hwType.isEmpty() && (hwType != samplingDevice->hardwareId)) { + continue; + } + + int direction = 0; + MainCore::MsgSetDevice *msg = MainCore::MsgSetDevice::create(deviceSetIndex, i, direction); + mainCore->getMainMessageQueue()->push(msg); + found = true; + break; + } + if (!found) + { + qCritical() << "MapGUI::openKiwiSDR: Failed to find KiwiSDR"; + return; + } + + // Wait until device is created - is there a better way? + DeviceSet *deviceSet = nullptr; + do + { + QTime dieTime = QTime::currentTime().addMSecs(100); + while (QTime::currentTime() < dieTime) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + } + if (mainCore->getDeviceSets().size() > deviceSetIndex) + { + deviceSet = mainCore->getDeviceSets()[deviceSetIndex]; + } + } + while (!deviceSet); + + // Move to same workspace + //getWorkspaceIndex(); + + // Set address setting + QStringList deviceSettingsKeys = {"serverAddress"}; + SWGSDRangel::SWGDeviceSettings response; + response.init(); + SWGSDRangel::SWGKiwiSDRSettings *deviceSettings = response.getKiwiSdrSettings(); + deviceSettings->setServerAddress(new QString(url)); + QString errorMessage; + deviceSet->m_deviceAPI->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); +} + +// Open a RemoteTCPInput device to use for SpyServer +void MapGUI::openSpyServer(const QString& url) +{ + // Create DeviceSet + MainCore *mainCore = MainCore::instance(); + unsigned int deviceSetIndex = mainCore->getDeviceSets().size(); + MainCore::MsgAddDeviceSet *msg = MainCore::MsgAddDeviceSet::create(0); + mainCore->getMainMessageQueue()->push(msg); + + // Switch to RemoteTCPInput + int nbSamplingDevices = DeviceEnumerator::instance()->getNbRxSamplingDevices(); + bool found = false; + QString hwType = "RemoteTCPInput"; + for (int i = 0; i < nbSamplingDevices; i++) + { + const PluginInterface::SamplingDevice *samplingDevice; + + samplingDevice = DeviceEnumerator::instance()->getRxSamplingDevice(i); + + if (!hwType.isEmpty() && (hwType != samplingDevice->hardwareId)) { + continue; + } + + int direction = 0; + MainCore::MsgSetDevice *msg = MainCore::MsgSetDevice::create(deviceSetIndex, i, direction); + mainCore->getMainMessageQueue()->push(msg); + found = true; + break; + } + if (!found) + { + qCritical() << "MapGUI::openSpyServer: Failed to find RemoteTCPInput"; + return; + } + + // Wait until device is created - is there a better way? + DeviceSet *deviceSet = nullptr; + do + { + QTime dieTime = QTime::currentTime().addMSecs(100); + while (QTime::currentTime() < dieTime) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + } + if (mainCore->getDeviceSets().size() > deviceSetIndex) + { + deviceSet = mainCore->getDeviceSets()[deviceSetIndex]; + } + } + while (!deviceSet); + + // Move to same workspace + //getWorkspaceIndex(); + + // Set address/port setting + QStringList address = url.split(":"); + QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol"}; + SWGSDRangel::SWGDeviceSettings response; + response.init(); + SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response.getRemoteTcpInputSettings(); + deviceSettings->setDataAddress(new QString(address[0])); + deviceSettings->setDataPort(address[1].toInt()); + deviceSettings->setProtocol(new QString("Spy Server")); + QString errorMessage; + deviceSet->m_deviceAPI->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); +} + #ifdef QT_WEBENGINE_FOUND void MapGUI::fullScreenRequested(QWebEngineFullScreenRequest fullScreenRequest) { @@ -1940,6 +2770,13 @@ void MapGUI::makeUIConnections() QObject::connect(ui->displayNames, &ButtonSwitch::clicked, this, &MapGUI::on_displayNames_clicked); QObject::connect(ui->displayAllGroundTracks, &ButtonSwitch::clicked, this, &MapGUI::on_displayAllGroundTracks_clicked); QObject::connect(ui->displaySelectedGroundTracks, &ButtonSwitch::clicked, this, &MapGUI::on_displaySelectedGroundTracks_clicked); + QObject::connect(ui->displayRain, &ButtonSwitch::clicked, this, &MapGUI::on_displayRain_clicked); + QObject::connect(ui->displayClouds, &ButtonSwitch::clicked, this, &MapGUI::on_displayClouds_clicked); + QObject::connect(ui->displaySeaMarks, &ButtonSwitch::clicked, this, &MapGUI::on_displaySeaMarks_clicked); + QObject::connect(ui->displayRailways, &ButtonSwitch::clicked, this, &MapGUI::on_displayRailways_clicked); + QObject::connect(ui->displayNASAGlobalImagery, &ButtonSwitch::clicked, this, &MapGUI::on_displayNASAGlobalImagery_clicked); + QObject::connect(ui->nasaGlobalImageryIdentifier, qOverload(&QComboBox::currentIndexChanged), this, &MapGUI::on_nasaGlobalImageryIdentifier_currentIndexChanged); + QObject::connect(ui->nasaGlobalImageryOpacity, qOverload(&QDial::valueChanged), this, &MapGUI::on_nasaGlobalImageryOpacity_valueChanged); QObject::connect(ui->displayMUF, &ButtonSwitch::clicked, this, &MapGUI::on_displayMUF_clicked); QObject::connect(ui->displayfoF2, &ButtonSwitch::clicked, this, &MapGUI::on_displayfoF2_clicked); QObject::connect(ui->find, &QLineEdit::returnPressed, this, &MapGUI::on_find_returnPressed); diff --git a/plugins/feature/map/mapgui.h b/plugins/feature/map/mapgui.h index 145c4621b..b541f4318 100644 --- a/plugins/feature/map/mapgui.h +++ b/plugins/feature/map/mapgui.h @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2021-2023 Jon Beniston, M7RCE // +// Copyright (C) 2021-2024 Jon Beniston, M7RCE // // Copyright (C) 2022 Edouard Griffiths, F4EXB // // // // This program is free software; you can redistribute it and/or modify // @@ -22,6 +22,7 @@ #include #include #include +#include #include #ifdef QT_WEBENGINE_FOUND #include @@ -40,7 +41,13 @@ #include "util/azel.h" #include "util/openaip.h" #include "util/ourairportsdb.h" +#include "util/waypoints.h" +#include "util/rainviewer.h" +#include "util/nasaglobalimagery.h" +#include "util/kiwisdrlist.h" +#include "util/spyserverlist.h" #include "settings/rollupstate.h" +#include "availablechannelorfeaturehandler.h" #include "SWGMapItem.h" @@ -50,6 +57,7 @@ #include "mapradiotimedialog.h" #include "cesiuminterface.h" #include "osmtemplateserver.h" +#include "maptileserver.h" #include "webserver.h" #include "mapmodel.h" @@ -63,6 +71,7 @@ namespace Ui { class MapGUI; struct Beacon; +class QSvgWidget; struct RadioTimeTransmitter { QString m_callsign; @@ -165,8 +174,11 @@ public: void addAirspace(const Airspace *airspace, const QString& group, int cnt); void addAirspace(); void addAirports(); + void addWaypoints(); void addNavtex(); void addVLF(); + void addKiwiSDR(); + void addSpyServer(); void find(const QString& target); void track3D(const QString& target); Q_INVOKABLE void supportedMapsChanged(); @@ -181,7 +193,7 @@ private: QList m_settingsKeys; RollupState m_rollupState; bool m_doApplySettings; - QList m_availableChannelOrFeatures; + AvailableChannelOrFeatureList m_availableChannelOrFeatures; Map* m_map; MessageQueue m_inputMessageQueue; @@ -201,17 +213,43 @@ private: MapRadioTimeDialog m_radioTimeDialog; quint16 m_osmPort; OSMTemplateServer *m_templateServer; + quint16 m_mapTileServerPort; + MapTileServer *m_mapTileServer; QTimer m_redrawMapTimer; GIRO *m_giro; QHash m_ionosondeStations; QSharedPointer> m_navAids; QSharedPointer> m_airspaces; QSharedPointer> m_airportInfo; + QSharedPointer> m_waypoints; QGeoCoordinate m_lastFullUpdatePosition; + KiwiSDRList m_kiwiSDRList; + SpyServerList m_spyServerList; CesiumInterface *m_cesium; WebServer *m_webServer; quint16 m_webPort; + RainViewer *m_rainViewer; + NASAGlobalImagery m_nasaGlobalImagery; + QList m_nasaDataSets; + QHash m_nasaDataSetsHash; + NASAGlobalImagery::MetaData m_nasaMetaData; + QAction *m_displaySeaMarks; + QAction *m_displayRailways; + QAction *m_displayRain; + QAction *m_displayClouds; + QAction *m_displayNASAGlobalImagery; + QAction *m_displayMUF; + QAction *m_displayfoF2; + + QString m_radarPath; + QString m_satellitePath; + + NASAGlobalImagery::Legend *m_legend; + QWidget *m_nasaWidget; + QSvgWidget *m_legendWidget; + QTableWidget *m_overviewWidget; + QTextEdit *m_descriptionWidget; explicit MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *feature, QWidget* parent = nullptr); virtual ~MapGUI(); @@ -232,11 +270,31 @@ private: QString cesiumIonAPIKey() const; void redrawMap(); void makeUIConnections(); + void createLayersMenu(); + void displayToolbar(); + void setEnableOverlay(); + void applyNASAGlobalImagerySettings(); + void createNASAGlobalImageryView(); + void displayNASAMetaData(); + void openKiwiSDR(const QString& url); + void openSpyServer(const QString& url); + QString formatFrequency(qint64 frequency) const; static QString getDataDir(); static const QList m_radioTimeTransmitters; static const QList m_vlfTransmitters; + enum NASARow { + NASA_TITLE, + NASA_SUBTITLE, + NASA_DEFAULT_DATE, + NASA_START_DATE, + NASA_END_DATE, + NASA_PERIOD, + NASA_LAYER_GROUP, + NASA_ROWS + }; + private slots: void init3DMap(); void onMenuDialogCalled(const QPoint &p); @@ -245,8 +303,16 @@ private slots: void on_displayNames_clicked(bool checked=false); void on_displayAllGroundTracks_clicked(bool checked=false); void on_displaySelectedGroundTracks_clicked(bool checked=false); + void on_displayRain_clicked(bool checked=false); + void on_displayClouds_clicked(bool checked=false); + void on_displaySeaMarks_clicked(bool checked=false); + void on_displayRailways_clicked(bool checked=false); void on_displayMUF_clicked(bool checked=false); void on_displayfoF2_clicked(bool checked=false); + void on_displayNASAGlobalImagery_clicked(bool checked=false); + void on_nasaGlobalImageryIdentifier_currentIndexChanged(int index); + void on_nasaGlobalImageryOpacity_valueChanged(int index); + void on_layersMenu_clicked(); void on_find_returnPressed(); void on_maidenhead_clicked(); void on_deleteAll_clicked(); @@ -258,6 +324,7 @@ private slots: void receivedCesiumEvent(const QJsonObject &obj); virtual void showEvent(QShowEvent *event) override; virtual bool eventFilter(QObject *obj, QEvent *event) override; + void orientationChanged(Qt::ScreenOrientation orientation); #ifdef QT_WEBENGINE_FOUND void fullScreenRequested(QWebEngineFullScreenRequest fullScreenRequest); void renderProcessTerminated(QWebEnginePage::RenderProcessTerminationStatus terminationStatus, int exitCode); @@ -270,9 +337,18 @@ private slots: void giroDataUpdated(const GIRO::GIROStationData& data); void mufUpdated(const QJsonDocument& document); void foF2Updated(const QJsonDocument& document); + void pathUpdated(const QString& radarPath, const QString& satellitePath); + void nasaGlobalImageryDataUpdated(const QList& dataSets); + void nasaGlobalImageryMetaDataUpdated(const NASAGlobalImagery::MetaData& metaData); + void nasaGlobalImageryLegendAvailable(const QString& url, const QByteArray& data); + void nasaGlobalImageryHTMLAvailable(const QString& url, const QByteArray& data); void navAidsUpdated(); void airspacesUpdated(); void airportsUpdated(); + void waypointsUpdated(); + void kiwiSDRUpdated(const QList& sdrs); + void spyServerUpdated(const QList& sdrs); + void linkClicked(const QString& url); }; diff --git a/plugins/feature/map/mapguinowebengine.ui b/plugins/feature/map/mapguinowebengine.ui index 93377861c..dc455fc85 100644 --- a/plugins/feature/map/mapguinowebengine.ui +++ b/plugins/feature/map/mapguinowebengine.ui @@ -171,6 +171,86 @@ + + + + Display weather radar (rain/snow) + + + ^ + + + + :/map/icons/precipitation.png:/map/icons/precipitation.png + + + true + + + true + + + + + + + Display sea marks + + + ^ + + + + :/map/icons/anchor.png:/map/icons/anchor.png + + + true + + + true + + + + + + + Display satellite infra-red (clouds) + + + ^ + + + + :/map/icons/cloud.png:/map/icons/cloud.png + + + true + + + true + + + + + + + Display railways + + + ^ + + + + :/map/icons/railway.png:/map/icons/railway.png + + + true + + + true + + + diff --git a/plugins/feature/map/mapguiwebengine.ui b/plugins/feature/map/mapguiwebengine.ui index b9a3d4898..3bfb11781 100644 --- a/plugins/feature/map/mapguiwebengine.ui +++ b/plugins/feature/map/mapguiwebengine.ui @@ -6,8 +6,8 @@ 0 0 - 481 - 507 + 1282 + 293 @@ -18,7 +18,7 @@ - 462 + 0 0 @@ -39,13 +39,13 @@ 0 0 - 480 + 1221 41 - 480 + 300 0 @@ -165,10 +165,104 @@ + + + + + + + + :/map/icons/layers.png:/map/icons/layers.png + + + QToolButton::InstantPopup + + + + + + + Display satellite infra-red (clouds) + + + ^ + + + + :/map/icons/cloud.png:/map/icons/cloud.png + + + true + + + true + + + + + + + Display weather radar (rain/snow) + + + ^ + + + + :/map/icons/precipitation.png:/map/icons/precipitation.png + + + true + + + true + + + + + + + Display sea marks + + + ^ + + + + :/map/icons/anchor.png:/map/icons/anchor.png + + + true + + + true + + + + + + + Display railways + + + ^ + + + + :/map/icons/railway.png:/map/icons/railway.png + + + true + + + true + + + - Display MUF contours + Display MUF (Maximum Usable Frequency) contours (3D only) ^ @@ -188,7 +282,7 @@ - Display foF2 contours + Display foF2 (F2 layer critical frequency) contours (3D only) ^ @@ -205,6 +299,71 @@ + + + + Display NASA GIBS data + + + ^ + + + + :/map/icons/earthsat.png:/map/icons/earthsat.png + + + true + + + true + + + + + + + + 200 + 0 + + + + NASA GIBS data + + + + + + + + 24 + 24 + + + + NASA GIBS image opacity (3D only) + + + 100 + + + 100 + + + + + + + + 34 + 0 + + + + 100% + + + @@ -392,12 +551,6 @@ QWidget
QtQuickWidgets/QQuickWidget
- - QWebEngineView - QWidget -
QtWebEngineWidgets/QWebEngineView
- 1 -
ButtonSwitch QToolButton @@ -409,6 +562,12 @@
gui/rollupcontents.h
1
+ + QWebEngineView + QWidget +
QtWebEngineWidgets/QWebEngineView
+ 1 +
find diff --git a/plugins/feature/map/mapmodel.cpp b/plugins/feature/map/mapmodel.cpp index 58f107546..5d56b978e 100644 --- a/plugins/feature/map/mapmodel.cpp +++ b/plugins/feature/map/mapmodel.cpp @@ -619,6 +619,11 @@ Q_INVOKABLE void ObjectMapModel::moveToBack(int oldRow) } } +Q_INVOKABLE void ObjectMapModel::link(const QString& url) +{ + emit linkClicked(url); +} + QVariant ObjectMapModel::data(const QModelIndex &index, int role) const { int row = index.row(); diff --git a/plugins/feature/map/mapmodel.h b/plugins/feature/map/mapmodel.h index 2a038f39c..46ad4e570 100644 --- a/plugins/feature/map/mapmodel.h +++ b/plugins/feature/map/mapmodel.h @@ -238,6 +238,8 @@ public: Q_INVOKABLE void moveToFront(int oldRow); Q_INVOKABLE void moveToBack(int oldRow); + Q_INVOKABLE void link(const QString& link); + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; bool setData(const QModelIndex &index, const QVariant& value, int role = Qt::EditRole) override; @@ -248,7 +250,6 @@ public: Q_INVOKABLE QStringList getDeviceSets() const; Q_INVOKABLE void setFrequency(qint64 frequency, const QString& deviceSet); - Q_INVOKABLE void viewChanged(double bottomLeftLongitude, double bottomRightLongitude); bool isSelected3D(const ObjectMapItem *item) const @@ -264,6 +265,9 @@ public: //public slots: // void update3DMap(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override; +signals: + void linkClicked(const QString& url); + protected: void playAnimations(ObjectMapItem *item); MapItem *newMapItem(const QObject *sourcePipe, const QString &group, MapSettings::MapItemSettings *itemSettings, SWGSDRangel::SWGMapItem *mapItem) override; diff --git a/plugins/feature/map/mapsettings.cpp b/plugins/feature/map/mapsettings.cpp index c280d95c7..577d7e769 100644 --- a/plugins/feature/map/mapsettings.cpp +++ b/plugins/feature/map/mapsettings.cpp @@ -60,15 +60,21 @@ const QStringList MapSettings::m_pipeURIs = { QStringLiteral("sdrangel.feature.vorlocalizer") }; -// GUI combo box should match ordering in this list const QStringList MapSettings::m_mapProviders = { QStringLiteral("osm"), QStringLiteral("esri"), - QStringLiteral("mapbox"), QStringLiteral("mapboxgl"), QStringLiteral("maplibregl") }; +// Names as used in combo box in settings dialog +const QStringList MapSettings::m_mapProviderNames = { + QStringLiteral("OpenStreetMap"), + QStringLiteral("ESRI"), + QStringLiteral("MapboxGL"), + QStringLiteral("MapLibreGL") +}; + MapSettings::MapSettings() : m_rollupState(nullptr) { @@ -144,6 +150,13 @@ MapSettings::MapSettings() : m_itemSettings.insert("Airspace (Wave)", new MapItemSettings("Airspace (Wave)", false, QColor(255, 0, 0, 0x20), false, false, 11)); m_itemSettings.insert("Airspace (Airports)", new MapItemSettings("Airspace (Airports)", false, QColor(0, 0, 255, 0x20), false, false, 11)); + MapItemSettings *waypointsSettings = new MapItemSettings("Waypoints", false, QColor(255, 0, 255), false, true, 11); + waypointsSettings->m_filterDistance = 500000; + m_itemSettings.insert("Waypoints", waypointsSettings); + + m_itemSettings.insert("KiwiSDR", new MapItemSettings("KiwiSDR", true, QColor(0, 255, 0), false, true, 8)); + m_itemSettings.insert("SpyServer", new MapItemSettings("SpyServer", true, QColor(0, 0, 255), false, true, 8)); + resetToDefaults(); } @@ -184,6 +197,12 @@ void MapSettings::resetToDefaults() m_antiAliasing = "None"; m_displayMUF = false; m_displayfoF2 = false; + m_displayRain = false; + m_displayClouds = false; + m_displaySeaMarks = false; + m_displayNASAGlobalImagery = false; + m_nasaGlobalImageryIdentifier = ""; + m_nasaGlobalImageryOpacity = 50; m_workspaceIndex = 0; m_checkWXAPIKey = ""; } @@ -229,6 +248,13 @@ QByteArray MapSettings::serialize() const s.writeBool(35, m_displayMUF); s.writeBool(36, m_displayfoF2); + s.writeBool(37, m_displayRain); + s.writeBool(38, m_displayClouds); + s.writeBool(39, m_displaySeaMarks); + s.writeBool(40, m_displayRailways); + s.writeBool(41, m_displayNASAGlobalImagery); + s.writeString(42, m_nasaGlobalImageryIdentifier); + s.writeS32(43, m_nasaGlobalImageryOpacity); s.writeString(46, m_checkWXAPIKey); @@ -308,6 +334,13 @@ bool MapSettings::deserialize(const QByteArray& data) d.readBool(35, &m_displayMUF, false); d.readBool(36, &m_displayfoF2, false); + d.readBool(37, &m_displayRain, false); + d.readBool(38, &m_displayClouds, false); + d.readBool(39, &m_displaySeaMarks, false); + d.readBool(40, &m_displayRailways, false); + d.readBool(41, &m_displayNASAGlobalImagery, false); + d.readString(42, &m_nasaGlobalImageryIdentifier, ""); + d.readS32(43, &m_nasaGlobalImageryOpacity, 50); d.readString(46, &m_checkWXAPIKey, ""); @@ -556,9 +589,30 @@ void MapSettings::applySettings(const QStringList& settingsKeys, const MapSettin if (settingsKeys.contains("displayMUF")) { m_displayMUF = settings.m_displayMUF; } - if (settingsKeys.contains("misplayfoF2")) { + if (settingsKeys.contains("displayfoF2")) { m_displayfoF2 = settings.m_displayfoF2; } + if (settingsKeys.contains("displayRain")) { + m_displayRain = settings.m_displayRain; + } + if (settingsKeys.contains("displayClouds")) { + m_displayClouds = settings.m_displayClouds; + } + if (settingsKeys.contains("displaySeaMarks")) { + m_displaySeaMarks = settings.m_displaySeaMarks; + } + if (settingsKeys.contains("displayRailways")) { + m_displayRailways = settings.m_displayRailways; + } + if (settingsKeys.contains("displayNASAGlobalImagery")) { + m_displayNASAGlobalImagery = settings.m_displayNASAGlobalImagery; + } + if (settingsKeys.contains("nasaGlobalImageryIdentifier")) { + m_nasaGlobalImageryIdentifier = settings.m_nasaGlobalImageryIdentifier; + } + if (settingsKeys.contains("nasaGlobalImageryOpacity")) { + m_nasaGlobalImageryOpacity = settings.m_nasaGlobalImageryOpacity; + } if (settingsKeys.contains("workspaceIndex")) { m_workspaceIndex = settings.m_workspaceIndex; } @@ -646,10 +700,30 @@ QString MapSettings::getDebugString(const QStringList& settingsKeys, bool force) if (settingsKeys.contains("displayfoF2") || force) { ostr << " m_displayfoF2: " << m_displayfoF2; } + if (settingsKeys.contains("displayRain") || force) { + ostr << " m_displayRain: " << m_displayRain; + } + if (settingsKeys.contains("displayClouds") || force) { + ostr << " m_displayClouds: " << m_displayClouds; + } + if (settingsKeys.contains("displaySeaMarks") || force) { + ostr << " m_displaySeaMarks: " << m_displaySeaMarks; + } + if (settingsKeys.contains("displayRailways") || force) { + ostr << " m_displayRailways: " << m_displayRailways; + } + if (settingsKeys.contains("displayNASAGlobalImagery") || force) { + ostr << " m_displayNASAGlobalImagery: " << m_displayNASAGlobalImagery; + } + if (settingsKeys.contains("nasaGlobalImageryIdentifier") || force) { + ostr << " m_nasaGlobalImageryIdentifier: " << m_nasaGlobalImageryIdentifier.toStdString(); + } + if (settingsKeys.contains("nasaGlobalImageryOpacity") || force) { + ostr << " m_nasaGlobalImageryOpacity: " << m_nasaGlobalImageryOpacity; + } if (settingsKeys.contains("workspaceIndex") || force) { ostr << " m_workspaceIndex: " << m_workspaceIndex; } return QString(ostr.str().c_str()); } - diff --git a/plugins/feature/map/mapsettings.h b/plugins/feature/map/mapsettings.h index 36a2e2067..07bad8e1d 100644 --- a/plugins/feature/map/mapsettings.h +++ b/plugins/feature/map/mapsettings.h @@ -2,7 +2,7 @@ // Copyright (C) 2012 maintech GmbH, Otto-Hahn-Str. 15, 97204 Hoechberg, Germany // // written by Christian Daniel // // Copyright (C) 2015-2020, 2022 Edouard Griffiths, F4EXB // -// Copyright (C) 2020-2023 Jon Beniston, M7RCE // +// Copyright (C) 2020-2024 Jon Beniston, M7RCE // // // // This program is free software; you can redistribute it and/or modify // // it under the terms of the GNU General Public License as published by // @@ -66,26 +66,6 @@ struct MapSettings quint32 m_3DColor; }; - struct AvailableChannelOrFeature - { - QString m_kind; //!< "R" for channel, "F" for feature - int m_superIndex; - int m_index; - QString m_type; - QObject *m_source; - - AvailableChannelOrFeature() = default; - AvailableChannelOrFeature(const AvailableChannelOrFeature&) = default; - AvailableChannelOrFeature& operator=(const AvailableChannelOrFeature&) = default; - bool operator==(const AvailableChannelOrFeature& a) const { - return (m_kind == a.m_kind) - && (m_superIndex == a.m_superIndex) - && (m_index == a.m_index) - && (m_type == a.m_type) - && (m_source == a.m_source); - } - }; - bool m_displayNames; QString m_mapProvider; QString m_thunderforestAPIKey; @@ -121,6 +101,13 @@ struct MapSettings bool m_displayMUF; // Plot MUF contours bool m_displayfoF2; // Plot foF2 contours + bool m_displayRain; + bool m_displayClouds; + bool m_displaySeaMarks; + bool m_displayRailways; + bool m_displayNASAGlobalImagery; + QString m_nasaGlobalImageryIdentifier; + int m_nasaGlobalImageryOpacity; QString m_checkWXAPIKey; //!< checkwxapi.com API key @@ -142,6 +129,7 @@ struct MapSettings static const QStringList m_pipeURIs; static const QStringList m_mapProviders; + static const QStringList m_mapProviderNames; }; Q_DECLARE_METATYPE(MapSettings::MapItemSettings *); diff --git a/plugins/feature/map/mapsettingsdialog.cpp b/plugins/feature/map/mapsettingsdialog.cpp index 2aec4c43b..062e27db3 100644 --- a/plugins/feature/map/mapsettingsdialog.cpp +++ b/plugins/feature/map/mapsettingsdialog.cpp @@ -120,8 +120,14 @@ MapSettingsDialog::MapSettingsDialog(MapSettings *settings, QWidget* parent) : #if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) ui->mapProvider->clear(); ui->mapProvider->addItem("OpenStreetMap"); +#else +#ifdef WIN32 + ui->mapProvider->removeItem(ui->mapProvider->findText("MapboxGL")); // Not supported on Windows #endif - ui->mapProvider->setCurrentIndex(MapSettings::m_mapProviders.indexOf(settings->m_mapProvider)); + ui->mapProvider->removeItem(ui->mapProvider->findText("ESRI")); // Currently broken https://bugreports.qt.io/browse/QTBUG-121228 +#endif + const QString mapProviderName = MapSettings::m_mapProviderNames[MapSettings::m_mapProviders.indexOf(settings->m_mapProvider)]; + ui->mapProvider->setCurrentIndex(ui->mapProvider->findText(mapProviderName)); ui->thunderforestAPIKey->setText(settings->m_thunderforestAPIKey); ui->maptilerAPIKey->setText(settings->m_maptilerAPIKey); ui->mapBoxAPIKey->setText(settings->m_mapBoxAPIKey); @@ -198,6 +204,7 @@ MapSettingsDialog::MapSettingsDialog(MapSettings *settings, QWidget* parent) : connect(&m_ourAirportsDB, &OurAirportsDB::downloadProgress, this, &MapSettingsDialog::downloadProgress); connect(&m_ourAirportsDB, &OurAirportsDB::downloadError, this, &MapSettingsDialog::downloadError); connect(&m_ourAirportsDB, &OurAirportsDB::downloadAirportInformationFinished, this, &MapSettingsDialog::downloadAirportInformationFinished); + connect(&m_waypoints, &Waypoints::downloadWaypointsFinished, this, &MapSettingsDialog::downloadWaypointsFinished); #ifndef QT_WEBENGINE_FOUND ui->map3DSettings->setVisible(false); @@ -219,7 +226,7 @@ MapSettingsDialog::~MapSettingsDialog() void MapSettingsDialog::accept() { - QString mapProvider = MapSettings::m_mapProviders[ui->mapProvider->currentIndex()]; + QString mapProvider = MapSettings::m_mapProviders[MapSettings::m_mapProviderNames.indexOf(ui->mapProvider->currentText())]; QString osmURL = ui->osmURL->text(); QString mapBoxStyles = ui->mapBoxStyles->text(); QString mapBoxAPIKey = ui->mapBoxAPIKey->text(); @@ -515,6 +522,19 @@ void MapSettingsDialog::on_getAirspacesDB_clicked() } } +void MapSettingsDialog::on_getWaypoints_clicked() +{ + // Don't try to download while already in progress + if (m_progressDialog == nullptr) + { + m_progressDialog = new QProgressDialog(this); + m_progressDialog->setMaximum(1); + m_progressDialog->setCancelButton(nullptr); + m_progressDialog->setWindowFlag(Qt::WindowCloseButtonHint, false); + m_waypoints.downloadWaypoints(); + } +} + void MapSettingsDialog::downloadingURL(const QString& url) { if (m_progressDialog) @@ -581,3 +601,17 @@ void MapSettingsDialog::downloadAirportInformationFinished() } } +void MapSettingsDialog::downloadWaypointsFinished() +{ + if (m_progressDialog) { + m_progressDialog->setLabelText("Reading waypoints."); + } + emit waypointsUpdated(); + if (m_progressDialog) + { + m_progressDialog->close(); + delete m_progressDialog; + m_progressDialog = nullptr; + } +} + diff --git a/plugins/feature/map/mapsettingsdialog.h b/plugins/feature/map/mapsettingsdialog.h index b966a5586..f6fe3af64 100644 --- a/plugins/feature/map/mapsettingsdialog.h +++ b/plugins/feature/map/mapsettingsdialog.h @@ -29,6 +29,7 @@ #include "gui/httpdownloadmanagergui.h" #include "util/openaip.h" #include "util/ourairportsdb.h" +#include "util/waypoints.h" #include "ui_mapsettingsdialog.h" #include "mapsettings.h" @@ -104,6 +105,7 @@ private: QProgressDialog *m_progressDialog; OpenAIP m_openAIP; OurAirportsDB m_ourAirportsDB; + Waypoints m_waypoints; void unzip(const QString &filename); @@ -114,6 +116,7 @@ private slots: void on_downloadModels_clicked(); void on_getAirportDB_clicked(); void on_getAirspacesDB_clicked(); + void on_getWaypoints_clicked(); void downloadComplete(const QString &filename, bool success, const QString &url, const QString &errorMessage); void downloadingURL(const QString& url); void downloadProgress(qint64 bytesRead, qint64 totalBytes); @@ -121,11 +124,13 @@ private slots: void downloadAirspaceFinished(); void downloadNavAidsFinished(); void downloadAirportInformationFinished(); + void downloadWaypointsFinished(); signals: void navAidsUpdated(); void airspacesUpdated(); void airportsUpdated(); + void waypointsUpdated(); private: Ui::MapSettingsDialog* ui; diff --git a/plugins/feature/map/mapsettingsdialog.ui b/plugins/feature/map/mapsettingsdialog.ui index 53f2c962c..472c22f7f 100644 --- a/plugins/feature/map/mapsettingsdialog.ui +++ b/plugins/feature/map/mapsettingsdialog.ui @@ -83,11 +83,6 @@ ESRI
- - - Mapbox - - MapboxGL @@ -337,6 +332,20 @@
+ + + + Download aviation waypoints (3MB) + + + + + + + :/map/icons/waypoints.png:/map/icons/waypoints.png + + + diff --git a/plugins/feature/map/maptileserver.cpp b/plugins/feature/map/maptileserver.cpp new file mode 100644 index 000000000..932442f77 --- /dev/null +++ b/plugins/feature/map/maptileserver.cpp @@ -0,0 +1,18 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "maptileserver.h" diff --git a/plugins/feature/map/maptileserver.h b/plugins/feature/map/maptileserver.h new file mode 100644 index 000000000..802a2c65e --- /dev/null +++ b/plugins/feature/map/maptileserver.h @@ -0,0 +1,436 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2021, 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_MAPTILESERVER_H_ +#define INCLUDE_MAPTILESERVER_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class MapTileServer : public QTcpServer +{ + Q_OBJECT +private: + QString m_thunderforestAPIKey; + QString m_maptilerAPIKey; + QNetworkAccessManager m_manager; + QMutex m_mutex; + + struct TileJob { + QTcpSocket* m_socket; + QList m_urls; + QHash m_images; + QString m_format; + }; + QList m_tileJobs; + QHash m_replies; + + QNetworkDiskCache *m_cache; + + QString m_radarPath; + QString m_satellitePath; + QString m_nasaGlobalImageryPath; + QString m_nasaGlobalImageryFormat; + bool m_displayRain; + bool m_displayClouds; + bool m_displaySeaMarks; + bool m_displayRailways; + bool m_displayNASAGlobalImagery; + +public: + // port - port to listen on / is listening on. Use 0 for any free port. + MapTileServer(quint16 &port, QObject* parent = 0) : + QTcpServer(parent), + m_thunderforestAPIKey(""), + m_maptilerAPIKey(""), + m_radarPath(""), + m_satellitePath(""), + m_nasaGlobalImageryPath(""), + m_nasaGlobalImageryFormat(""), + m_displayRain(false), + m_displayClouds(false), + m_displaySeaMarks(false), + m_displayRailways(false), + m_displayNASAGlobalImagery(false) + { + connect(&m_manager, &QNetworkAccessManager::finished, this, &MapTileServer::downloadFinished); + listen(QHostAddress::Any, port); + port = serverPort(); + + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + QDir writeableDir(locations[0]); + if (!writeableDir.mkpath(QStringLiteral("cache") + QDir::separator() + QStringLiteral("maptiles"))) { + qDebug() << "Failed to create cache/maptiles"; + } + + m_cache = new QNetworkDiskCache(); + m_cache->setCacheDirectory(locations[0] + QDir::separator() + QStringLiteral("cache") + QDir::separator() + QStringLiteral("maptiles")); + m_cache->setMaximumCacheSize(1000000000); + m_manager.setCache(m_cache); + } + + ~MapTileServer() + { + disconnect(&m_manager, &QNetworkAccessManager::finished, this, &MapTileServer::downloadFinished); + delete m_cache; + } + + void setThunderforestAPIKey(const QString& thunderforestAPIKey) + { + m_thunderforestAPIKey = thunderforestAPIKey; + } + + void setMaptilerAPIKey(const QString& maptilerAPIKey) + { + m_maptilerAPIKey = maptilerAPIKey; + } + + void setRadarPath(const QString& radarPath) + { + m_radarPath = radarPath; + } + + void setSatellitePath(const QString& satellitePath) + { + m_satellitePath = satellitePath; + } + + void setNASAGlobalImageryPath(const QString& nasaGlobalImageryPath) + { + m_nasaGlobalImageryPath = nasaGlobalImageryPath; + } + + void setNASAGlobalImageryFormat(const QString& nasaGlobalImageryFormat) + { + m_nasaGlobalImageryFormat = nasaGlobalImageryFormat; + } + + void setDisplaySeaMarks(bool displaySeaMarks) + { + m_displaySeaMarks = displaySeaMarks; + } + + void setDisplayRailways(bool displayRailways) + { + m_displayRailways = displayRailways; + } + + void setDisplayRain(bool displayRain) + { + m_displayRain = displayRain; + } + + void setDisplayClouds(bool displayClouds) + { + m_displayClouds = displayClouds; + } + + void setDisplayNASAGlobalImagery(bool displayNASAGlobalImagery) + { + m_displayNASAGlobalImagery = displayNASAGlobalImagery; + } + + void incomingConnection(qintptr socket) override + { + QTcpSocket* s = new QTcpSocket(this); + connect(s, SIGNAL(readyRead()), this, SLOT(readClient())); + connect(s, SIGNAL(disconnected()), this, SLOT(discardClient())); + s->setSocketDescriptor(socket); + //addPendingConnection(socket); + } + + bool isHttpRedirect(QNetworkReply *reply) + { + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + // 304 is file not changed, but maybe we did + return (status >= 301 && status <= 308); + } + + QNetworkReply *download(const QUrl &url) + { + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setRawHeader("User-Agent", "SDRangel"); // Required by a.tile.openstreetmap.org + + // Don't cache rainviwer data as it's dynamic + if (!url.toString().contains("tilecache.rainviewer")) { + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); + } + + QNetworkReply *reply = m_manager.get(request); + connect(reply, &QNetworkReply::sslErrors, this, &MapTileServer::sslErrors); + //qDebug() << "MapTileServer: Downloading from " << url; + return reply; + } + + QImage combine(const TileJob *job) + { + // Don't use job->m_images[job->m_urls[0]].size() as not always valid (E.g. map tiler can return http 204 - no content) + // Do we need to support 512x512? + QImage image(QSize(256, 256), QImage::Format_ARGB32_Premultiplied); + image.fill(qPremultiply(QColor(0, 0, 0, 0).rgba())); + QPainter painter(&image); + + for (int i = 0; i < job->m_images.size(); i++) { + const QImage &img = job->m_images[job->m_urls[i]]; + //qDebug() << "Image format " << i << " is " << img.format() << img.size(); + } + + for (int i = 0; i < job->m_images.size(); i++) { + const QImage &img = job->m_images[job->m_urls[i]]; + //img.save(QString("in%1.png").arg(i), "PNG"); + if (img.format() != QImage::Format_Invalid) { + painter.drawImage(image.rect(), img); + } + } + + return image; + } + + void replyImage(QTcpSocket* socket, const QImage& image, const QString& format) + { + QByteArray ba; + QBuffer buffer(&ba); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, qPrintable(format)); + + //qDebug() << "socket: " << socket << "thread:" << QThread::currentThread(); + socket->write("HTTP/1.0 200 Ok\r\n" + "Content-Type: image/png\r\n" + "\r\n"); + socket->write(buffer.buffer()); + socket->close(); + + if (socket->state() == QTcpSocket::UnconnectedState) { + delete socket; + } + } + + void replyError(QTcpSocket* socket) + { + QTextStream os(socket); + os.setAutoDetectUnicode(true); + os << "HTTP/1.0 404 Not Found\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "Not found\r\n"; + socket->close(); + + if (socket->state() == QTcpSocket::UnconnectedState) { + delete socket; + } + } + +private slots: + + void readClient() + { + QMutexLocker locker(&m_mutex); + + QTcpSocket* socket = (QTcpSocket*)sender(); + if (socket->canReadLine()) + { + QString line = socket->readLine(); + qDebug() << "HTTP Request: " << line; + QStringList tokens = QString(line).split(QRegularExpression("[ \r\n][ \r\n]*")); + if (tokens[0] == "GET") + { + QString xml = ""; + + // Create multiple requests for each image + // https://wiki.openstreetmap.org/wiki/Raster_tile_providers + // rain radar: https://tilecache.rainviewer.com/v2/radar/{timestamp=1705359600}/{size=256}/z/x/y/{color=0}/{options=1_1}.png + + // "GET /street/1/2/3.png HTTP/1.1\r\n" + const QRegularExpression re("\\/([A-Za-z0-9\\-_]+)\\/([0-9]+)\\/([0-9]+)\\/([0-9]+).(png|jpg)"); + QRegularExpressionMatch match = re.match(tokens[1]); + if (match.hasMatch()) + { + QString map, x, y, z, format; + + map = match.captured(1); + z = match.captured(2); + x = match.captured(3); + y = match.captured(4); + format = match.captured(5); + + TileJob *job = new TileJob; + //qDebug() << "Created job" << job << "socket:" << socket << "thread:" << QThread::currentThread() ; + job->m_socket = socket; + if (format == "png") { + job->m_format = "PNG"; + } else { + job->m_format = "JPG"; + } + + // This should match code in OSMTemplateServer::readClient + QString baseMapURL; + if (map == "street") { + baseMapURL = QString("https://tile.openstreetmap.org/%3/%1/%2.png").arg(x).arg(y).arg(z); + } else if (map == "satellite") { + baseMapURL = QString("https://api.maptiler.com/tiles/satellite-v2/%3/%1/%2.jpg?key=%4").arg(x).arg(y).arg(z).arg(m_maptilerAPIKey); + } else if ((map == "dark_nolabels") || (map == "light_nolabels")) { + baseMapURL = QString("http://1.basemaps.cartocdn.com/%4/%3/%1/%2.png").arg(x).arg(y).arg(z).arg(map); + } else { + baseMapURL = QString("http://a.tile.thunderforest.com/%4/%3/%1/%2.png?apikey=%5").arg(x).arg(y).arg(z).arg(map).arg(m_thunderforestAPIKey); + } + + job->m_urls.append(baseMapURL); + if (m_displaySeaMarks) { + job->m_urls.append(QString("https://tiles.openseamap.org/seamark/%3/%1/%2.png").arg(x).arg(y).arg(z)); + } + if (m_displayRailways) { + job->m_urls.append(QString("https://a.tiles.openrailwaymap.org/standard/%3/%1/%2.png").arg(x).arg(y).arg(z)); + } + if (m_displayNASAGlobalImagery && !m_nasaGlobalImageryPath.isEmpty()) { + job->m_urls.append(QString("https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/%4/%3/%2/%1.%5").arg(x).arg(y).arg(z).arg(m_nasaGlobalImageryPath).arg(m_nasaGlobalImageryFormat)); // x,y reversed compared to others + } + if (m_displayClouds && !m_satellitePath.isEmpty()) { + job->m_urls.append(QString("https://tilecache.rainviewer.com%4/256/%3/%1/%2/0/0_0.png").arg(x).arg(y).arg(z).arg(m_satellitePath)); + } + if (m_displayRain && !m_radarPath.isEmpty()) { + job->m_urls.append(QString("https://tilecache.rainviewer.com%4/256/%3/%1/%2/4/1_1.png").arg(x).arg(y).arg(z).arg(m_radarPath)); + } + m_tileJobs.append(job); + for (const auto& url : job->m_urls) + { + QNetworkReply *reply = download(QUrl(url)); + m_replies.insert(reply, job); + } + } + else + { + replyError(socket); + } + } + } + } + + void discardClient() + { + QTcpSocket* socket = (QTcpSocket*)sender(); + //qDebug() << "discardClient socket:" << socket; + socket->deleteLater(); + for (auto job : m_tileJobs) { + if (job->m_socket == socket) { + //qDebug() << "Socket closed on active job. job: " << job << "socket" << socket; + job->m_socket = nullptr; + } + } + } + + void downloadFinished(QNetworkReply *reply) + { + QMutexLocker locker(&m_mutex); + //QString url = reply->url().toEncoded().constData(); + QString url = reply->request().url().toEncoded().constData(); // reply->url() may differ if redirection occured, so use requested + + if (!isHttpRedirect(reply)) + { + QByteArray data = reply->readAll(); + QImage image; + if (!reply->error()) + { + if (!image.loadFromData(data)) + { + qDebug() << "MapTileServer::downloadFinished: Failed to load image: " << url; + } + else + { + bool cached = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); + //qDebug() << "Downloaded " << url << "as" << image.size() << "cached:" << cached; + } + } + else + { + qDebug() << "MapTileServer::downloadFinished: Error: " << reply->error() << "for" << url; + } + + bool found = false; + TileJob *job = m_replies[reply]; + if (!m_tileJobs.contains(job)) { + qDebug() << "job has been deleted!"; + } + for (const auto& jobURL : job->m_urls) + { + if (jobURL == url) + { + job->m_images.insert(url, image); + if (job->m_urls.size() == job->m_images.size()) + { + // All images available + QImage combinedImage = combine(job); + if (job->m_socket) + { + replyImage(job->m_socket, combinedImage, job->m_format); + job->m_socket = nullptr; + m_tileJobs.removeAll(job); + delete job; + //qDebug() << "Delete job" << job; + } + else + { + qDebug() << "Socket was null. URL: " << url << "job:" << job; + } + } + found = true; + break; + } + } + if (!found) { + qDebug() << "MapTileServer::downloadFinished: Failed to match URL: " << url; + } + } + else + { + qDebug() << "MapTileServer::downloadFinished: Redirect"; + } + reply->deleteLater(); + m_replies.remove(reply); + } + + void sslErrors(const QList &sslErrors) + { + for (const QSslError &error : sslErrors) + { + qCritical() << "MapTileServer: SSL error" << (int)error.error() << ": " << error.errorString(); + #ifdef ANDROID + // On Android 6 (but not on 12), we always seem to get: "The issuer certificate of a locally looked up certificate could not be found" + // which causes downloads to fail, so ignore + if (error.error() == QSslError::UnableToGetLocalIssuerCertificate) + { + QNetworkReply *reply = qobject_cast(sender()); + QList errorsThatCanBeIgnored; + errorsThatCanBeIgnored << QSslError(QSslError::UnableToGetLocalIssuerCertificate, error.certificate()); + reply->ignoreSslErrors(errorsThatCanBeIgnored); + } + #endif + } + } + +}; + +#endif diff --git a/plugins/feature/map/osmtemplateserver.h b/plugins/feature/map/osmtemplateserver.h index 73430acb5..a871002d0 100644 --- a/plugins/feature/map/osmtemplateserver.h +++ b/plugins/feature/map/osmtemplateserver.h @@ -29,13 +29,17 @@ class OSMTemplateServer : public QTcpServer private: QString m_thunderforestAPIKey; QString m_maptilerAPIKey; + quint16 m_tileServerPort; + bool m_overlay; public: // port - port to listen on / is listening on. Use 0 for any free port. - OSMTemplateServer(const QString &thunderforestAPIKey, const QString &maptilerAPIKey, quint16 &port, QObject* parent = 0) : + OSMTemplateServer(const QString &thunderforestAPIKey, const QString &maptilerAPIKey, quint16 tileServerPort, quint16 &port, QObject* parent = 0) : QTcpServer(parent), m_thunderforestAPIKey(thunderforestAPIKey), - m_maptilerAPIKey(maptilerAPIKey) + m_maptilerAPIKey(maptilerAPIKey), + m_tileServerPort(tileServerPort), + m_overlay(false) { listen(QHostAddress::Any, port); port = serverPort(); @@ -50,13 +54,24 @@ public: //addPendingConnection(socket); } + void setThunderforestAPIKey(const QString& thunderforestAPIKey) + { + m_thunderforestAPIKey = thunderforestAPIKey; + } + + void setMaptilerAPIKey(const QString& maptilerAPIKey) + { + m_maptilerAPIKey = maptilerAPIKey; + } + + void setEnableOverlay(bool enableOverlay) + { + m_overlay = enableOverlay; + } + private slots: void readClient() { - QStringList map({"/cycle", "/cycle-hires", "/hiking", "/hiking-hires", "/night-transit", "/night-transit-hires", "/terrain", "/terrain-hires", "/transit", "/transit-hires"}); - QStringList mapId({"thf-cycle", "thf-cycle-hires", "thf-hike", "thf-hike-hires", "thf-nighttransit", "thf-nighttransit-hires", "thf-landsc", "thf-landsc-hires", "thf-transit", "thf-transit-hires"}); - QStringList mapUrl({"cycle", "cycle", "outdoors", "outdoors", "transport-dark", "transport-dark", "landscape", "landscape", "transport", "transport"}); - QTcpSocket* socket = (QTcpSocket*)sender(); if (socket->canReadLine()) { @@ -67,33 +82,43 @@ private slots: { bool hires = tokens[1].contains("hires"); QString hiresURL = hires ? "@2x" : ""; - QString xml; + QString xml, url; if ((tokens[1] == "/street") || (tokens[1] == "/street-hires")) { + if (m_overlay) { + url = QString("http://127.0.0.1:%1/street/%z/%x/%y.png").arg(m_tileServerPort); + } else { + url = "https://tile.openstreetmap.org/%z/%x/%y.png"; + } xml = QString("\ - {\ - \"UrlTemplate\" : \"https://tile.openstreetmap.org/%z/%x/%y.png\",\ - \"ImageFormat\" : \"png\",\ - \"QImageFormat\" : \"Indexed8\",\ - \"ID\" : \"wmf-intl-1x\",\ - \"MaximumZoomLevel\" : 19,\ - \"MapCopyRight\" : \"OpenStreetMap\",\ - \"DataCopyRight\" : \"\"\ - }"); + {\ + \"UrlTemplate\" : \"%1\",\ + \"ImageFormat\" : \"png\",\ + \"QImageFormat\" : \"Indexed8\",\ + \"ID\" : \"wmf-intl-1x\",\ + \"MaximumZoomLevel\" : 19,\ + \"MapCopyRight\" : \"OpenStreetMap\",\ + \"DataCopyRight\" : \"\"\ + }").arg(url); } else if (tokens[1] == "/satellite") { + if (m_overlay) { + url = QString("http://127.0.0.1:%1/satellite/%z/%x/%y.jpg").arg(m_tileServerPort); + } else { + url = QString("https://api.maptiler.com/tiles/satellite-v2/%z/%x/%y%1.jpg?key=%2").arg(hiresURL).arg(m_maptilerAPIKey); + } xml = QString("\ {\ \"Enabled\" : true,\ - \"UrlTemplate\" : \"https://api.maptiler.com/tiles/satellite/%z/%x/%y%1.jpg?key=%2\",\ + \"UrlTemplate\" : \"%1\",\ \"ImageFormat\" : \"jpg\",\ \"QImageFormat\" : \"RGB888\",\ \"ID\" : \"usgs-l7\",\ - \"MaximumZoomLevel\" : 20,\ + \"MaximumZoomLevel\" : 22,\ \"MapCopyRight\" : \"Maptiler\",\ \"DataCopyRight\" : \"\"\ - }").arg(hiresURL).arg(m_maptilerAPIKey); + }").arg(url); } else if (tokens[1].contains("transit")) { @@ -103,32 +128,46 @@ private slots: // Use CartoDB maps without labels for aviation maps int idx = map.indexOf(tokens[1]); + if (m_overlay) { + url = QString("http://127.0.0.1:%1/%2/%z/%x/%y.png").arg(m_tileServerPort).arg(mapUrl[idx]); + } else { + url = QString("http://1.basemaps.cartocdn.com/%2/%z/%x/%y.png%1").arg(hiresURL).arg(mapUrl[idx]); + } xml = QString("\ {\ - \"UrlTemplate\" : \"http://1.basemaps.cartocdn.com/%2/%z/%x/%y.png%1\",\ + \"UrlTemplate\" : \"%1\",\ \"ImageFormat\" : \"png\",\ \"QImageFormat\" : \"Indexed8\",\ - \"ID\" : \"%3\",\ + \"ID\" : \"%2\",\ \"MaximumZoomLevel\" : 20,\ \"MapCopyRight\" : \"CartoDB\",\ \"DataCopyRight\" : \"\"\ - }").arg(hiresURL).arg(mapUrl[idx]).arg(mapId[idx]); + }").arg(url).arg(mapId[idx]); } else { + QStringList map({"/cycle", "/cycle-hires", "/hiking", "/hiking-hires", "/night-transit", "/night-transit-hires", "/terrain", "/terrain-hires", "/transit", "/transit-hires"}); + QStringList mapId({"thf-cycle", "thf-cycle-hires", "thf-hike", "thf-hike-hires", "thf-nighttransit", "thf-nighttransit-hires", "thf-landsc", "thf-landsc-hires", "thf-transit", "thf-transit-hires"}); + QStringList mapUrl({"cycle", "cycle", "outdoors", "outdoors", "transport-dark", "transport-dark", "landscape", "landscape", "transport", "transport"}); + int idx = map.indexOf(tokens[1]); if (idx != -1) { + if (m_overlay) { + url = QString("http://127.0.0.1:%1/%2/%z/%x/%y.png").arg(m_tileServerPort).arg(mapUrl[idx]); + } else { + url = QString("http://a.tile.thunderforest.com/%1/%z/%x/%y%3.png?apikey=%2").arg(mapUrl[idx]).arg(m_thunderforestAPIKey).arg(hiresURL); + } xml = QString("\ {\ - \"UrlTemplate\" : \"http://a.tile.thunderforest.com/%1/%z/%x/%y%4.png?apikey=%2\",\ + \"UrlTemplate\" : \"%1\",\ \"ImageFormat\" : \"png\",\ \"QImageFormat\" : \"Indexed8\",\ - \"ID\" : \"%3\",\ + \"ID\" : \"%2\",\ \"MaximumZoomLevel\" : 20,\ \"MapCopyRight\" : \"Thunderforest\",\ \"DataCopyRight\" : \"OpenStreetMap contributors\"\ - }").arg(mapUrl[idx]).arg(m_thunderforestAPIKey).arg(mapId[idx]).arg(hiresURL); + }").arg(url).arg(mapId[idx]); } } QTextStream os(socket); diff --git a/plugins/feature/map/readme.md b/plugins/feature/map/readme.md index 584223cc8..6f6f57cf3 100644 --- a/plugins/feature/map/readme.md +++ b/plugins/feature/map/readme.md @@ -11,7 +11,7 @@ On top of this, it can plot data from other plugins, such as: * Satellites from the Satellite Tracker, * Weather imagery from APT Demodulator, * The Sun, Moon and Stars from the Star Tracker, -* Weather balloons from the RadioSonde feature, +* Weather balloons from the Radiosonde feature, * RF Heat Maps from the Heap Map channel, * Radials and estimated position from the VOR localizer feature, * ILS course line and glide path from the ILS Demodulator. @@ -25,10 +25,15 @@ As well as internet data sources: * Radio time transmitters, * GRAVES radar, * Ionosonde station data, -* Navtex transmitters. -* VLF transmitters. +* Navtex transmitters, +* VLF transmitters, +* KiwiSDRs, +* Weather radar, +* Satellite infra-red data (clouds), +* Sea marks, +* Satellite imagery from NASA GIBS (Global Imagery Browse Services). -It can also create tracks showing the path aircraft, ships and APRS objects have taken, as well as predicted paths for satellites. +It can also create tracks showing the path aircraft, ships, radiosondes and APRS objects have taken, as well as predicted paths for satellites. ![2D Map feature](../../../doc/img/Map_plugin_beacons.png) @@ -90,6 +95,34 @@ When clicked, opens the Radio Time Transmitters dialog. ![Radio Time transmitters dialog](../../../doc/img/Map_plugin_radiotime_dialog.png) +

Display Satellite Infrared

+ +When checked, satellite infrared measurements (10.3um) are downloaded from the internet and are overlaid on the maps. +This essentially shows cloud cover. The images are updated every 10 minutes. + +The data is similar to that which can be received using the [APT Demodulator](../../channelrx/demodapt/readme.md) in the Thermal-infrared (10.3-11.3 um) channel. + +

Display Weather Radar

+ +When checked, weather radar measurements are downloaded form the internet and are overlaid on the maps. +This shows rain and other forms of precipitation. +The images are updated every 10 minutes. + +Green, yellow and red are rain, with red being the most intense. +Light blue through dark blue is snow, with dark blue being the most intense. + +

Display Sea Marks

+ +When checked, sea marks are overlaid on the maps. + +![Sea Marks Legend](../../../doc/img/Map_plugin_seamarks_legend.png) + +

Display Railways

+ +When checked, railway routes are overlaid on the maps. + +![Railway Legend](../../../doc/img/Map_plugin_railway_legend.png) +

7: Display MUF Contours

When checked, contours will be downloaded and displayed on the 3D map, showing the MUF (Maximum Usable Frequency) for a 3000km path that reflects off the ionosphere. @@ -100,6 +133,23 @@ The contours will be updated every 15 minutes. The latest contour data will alwa When checked, contours will be downloaded and displayed on the 3D map, showing coF2 (F2 layer critical frequency), the maximum frequency at which radio waves will be reflected vertically from the F2 region of the ionosphere. The contours will be updated every 15 minutes. The latest contour data will always be displayed, irrespective of the time set on the 3D Map. +

Display NASA GIBS Data

+ +When checked, enables overlay of data from NASA GIBS (Global Imagery Browse Services). This includes a vast array of Earth observation satellite data, +such as land and sea temperatures, atmospheric conditions, flux measurements and the like. +Details of available data products can be found [here](https://nasa-gibs.github.io/gibs-api-docs/available-visualizations/#visualization-product-catalog). + +For some data sets, GIBS has data spanning many decades. The data period may be hours, days or months. The 3D map will attemp to show data from the closest time set in the 3D map's timescale. +The 2D map will only show data from the default date (which is displayed in the table at the bottom). + +

NASA GIBS Data

+ +Selects which data from NASA GIBS to overlay on the maps. + +

NASA GIBS Opacity

+ +Sets the opacity used for the NASA GIBS overlay image overlay on the 3D map. Lower values make the image more transparent. +

8: Display Names

When checked, names of objects are displayed in a bubble next to each object. @@ -205,8 +255,17 @@ Mapbox: https://www.mapbox.com/ Cesium: https://www.cesium.com Bing: https://www Ionosonde data and MUF/coF2 contours from [KC2G](https://prop.kc2g.com/) with source data from [GIRO](https://giro.uml.edu/) and [NOAA NCEI](https://www.ngdc.noaa.gov/stp/iono/ionohome.html). -Icons made by Google from Flaticon https://www.flaticon.com -World icons created by turkkub from Flaticon https://www.flaticon.com +Sea Marks are from OpenSeaMap: https://www.openseamap.org/ + +Railways are from OpenRailwayMap: https://www.openrailwaymap.org/ + +Weather radar and satellite data is from RainViewer: https://www.rainviewer + +Icons made by Google from Flaticon: https://www.flaticon.com +World icons created by turkkub from Flaticon: https://www.flaticon.com +Layers and Boat icons created by Freepik from Flaticon: https://www.flaticon.com +Railway icons created by Prosymbols Premium from Flaticon: https://www.flaticon.com +Satellite icons created by SyafriStudio from Flaticon: https://www.flaticon.com 3D models are by various artists under a variety of licenses. See: https://github.com/srcejon/sdrangel-3d-models @@ -218,8 +277,8 @@ If you wish to contribute a 3D model, see the https://github.com/srcejon/sdrange Full details of the API can be found in the Swagger documentation. Here is a quick example of how to centre the map on an object from the command line: - curl -X POST "http://127.0.0.1:8091/sdrangel/featureset/0/feature/0/actions" -d '{"featureType": "Map", "MapActions": { "find": "M7RCE" }}' + curl -X POST "http://127.0.0.1:8091/sdrangel/featureset/feature/0/actions" -d '{"featureType": "Map", "MapActions": { "find": "M7RCE" }}' And to centre the map at a particular latitude and longitude: - curl -X POST "http://127.0.0.1:8091/sdrangel/featureset/0/feature/0/actions" -d '{"featureType": "Map", "MapActions": { "find": "51.2 0.0" }}' + curl -X POST "http://127.0.0.1:8091/sdrangel/featureset/feature/0/actions" -d '{"featureType": "Map", "MapActions": { "find": "51.2 0.0" }}' diff --git a/plugins/feature/map/webserver.cpp b/plugins/feature/map/webserver.cpp index 474056fff..546697e36 100644 --- a/plugins/feature/map/webserver.cpp +++ b/plugins/feature/map/webserver.cpp @@ -97,7 +97,7 @@ void WebServer::addFile(const QString &path, const QByteArray &data) void WebServer::sendFile(QTcpSocket* socket, const QByteArray &data, MimeType *mimeType, const QString &path) { - QString header = QString("HTTP/1.0 200 Ok\r\nContent-Type: %1\r\n\r\n").arg(mimeType->m_type); + QString header = QString("HTTP/1.0 200 Ok\r\nContent-Type: %1\r\nAccess-Control-Allow-Headers: *\r\nAccess-Control-Allow-Methods: *\r\nAccess-Control-Allow-Origin: *\r\n\r\n").arg(mimeType->m_type); if (mimeType->m_binary) { // Send file as binary @@ -125,7 +125,7 @@ void WebServer::readClient() if (socket->canReadLine()) { QString line = socket->readLine(); - //qDebug() << "WebServer HTTP Request: " << line; + qDebug() << "WebServer HTTP Request: " << line; QStringList tokens = QString(line).split(QRegularExpression("[ \r\n][ \r\n]*")); if (tokens[0] == "GET") diff --git a/sdrbase/util/kiwisdrlist.cpp b/sdrbase/util/kiwisdrlist.cpp new file mode 100644 index 000000000..01fbddcaa --- /dev/null +++ b/sdrbase/util/kiwisdrlist.cpp @@ -0,0 +1,198 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "kiwisdrlist.h" + +#include +#include +#include +#include +#include +#include +#include + +KiwiSDRList::KiwiSDRList() +{ + m_networkManager = new QNetworkAccessManager(); + QObject::connect(m_networkManager, &QNetworkAccessManager::finished, this, &KiwiSDRList::handleReply); + + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + QDir writeableDir(locations[0]); + if (!writeableDir.mkpath(QStringLiteral("cache") + QDir::separator() + QStringLiteral("kiwisdr"))) { + qDebug() << "Failed to create cache/kiwisdr"; + } + + m_cache = new QNetworkDiskCache(); + m_cache->setCacheDirectory(locations[0] + QDir::separator() + QStringLiteral("cache") + QDir::separator() + QStringLiteral("kiwisdr")); + m_cache->setMaximumCacheSize(100000000); + m_networkManager->setCache(m_cache); + + connect(&m_timer, &QTimer::timeout, this, &KiwiSDRList::update); +} + +KiwiSDRList::~KiwiSDRList() +{ + QObject::disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &KiwiSDRList::handleReply); + delete m_networkManager; +} + +void KiwiSDRList::getData() +{ + QUrl url(QString("http://kiwisdr.com/public/")); + m_networkManager->get(QNetworkRequest(url)); +} + +void KiwiSDRList::getDataPeriodically(int periodInMins) +{ + m_timer.setInterval(periodInMins*60*1000); + m_timer.start(); + update(); +} + +void KiwiSDRList::update() +{ + getData(); +} + +void KiwiSDRList::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QString url = reply->url().toEncoded().constData(); + QByteArray bytes = reply->readAll(); + + handleHTML(url, bytes); + } + else + { + qDebug() << "KiwiSDRList::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "KiwiSDRList::handleReply: reply is null"; + } +} + +void KiwiSDRList::handleHTML(const QString& url, const QByteArray& bytes) +{ + QList sdrs; + QString html(bytes); + QRegularExpression div("
(.*?)<\\/div>", QRegularExpression::DotMatchesEverythingOption); + QRegularExpressionMatchIterator divItr = div.globalMatch(html); + + if (divItr.hasNext()) + { + while (divItr.hasNext()) + { + QRegularExpressionMatch divMatch = divItr.next(); + QString divText = divMatch.captured(1); + QRegularExpression urlRe("a href='(.*?)'"); + QRegularExpressionMatch urlMatch = urlRe.match(divText); + + if (urlMatch.hasMatch()) + { + KiwiSDR sdr; + + sdr.m_url = urlMatch.captured(1); + + QRegularExpression element(""); + QRegularExpressionMatchIterator elementItr = element.globalMatch(divText); + while(elementItr.hasNext()) + { + QRegularExpressionMatch elementMatch = elementItr.next(); + QString key = elementMatch.captured(1); + QString value = elementMatch.captured(2); + + if (key == "name") + { + sdr.m_name = value; + } + else if (key == "sdr_hw") + { + sdr.m_sdrHW = value; + } + else if (key == "bands") + { + QRegularExpression freqRe("([\\d]+)-([\\d]+)"); + QRegularExpressionMatch freqMatch = freqRe.match(value); + + if (freqMatch.hasMatch()) + { + sdr.m_lowFrequency = freqMatch.captured(1).toInt(); + sdr.m_highFrequency = freqMatch.captured(2).toInt(); + } + } + else if (key == "users") + { + sdr.m_users = value.toInt(); + } + else if (key == "users_max") + { + sdr.m_usersMax = value.toInt(); + } + else if (key == "gps") + { + QRegularExpression gpsRe("([\\d.+-]+), ([\\d.+-]+)"); + QRegularExpressionMatch gpsMatch = gpsRe.match(value); + + if (gpsMatch.hasMatch()) + { + sdr.m_latitude = gpsMatch.captured(1).toFloat(); + sdr.m_longitude = gpsMatch.captured(2).toFloat(); + } + } + else if (key == "asl") + { + sdr.m_altitude = value.toInt(); + } + else if (key == "loc") + { + sdr.m_location = value.toInt(); + } + else if (key == "antenna") + { + sdr.m_antenna = value; + } + else if (key == "ant_connected") + { + sdr.m_antennaConnected = value == "1"; + } + else if (key == "snr") + { + sdr.m_snr = value; + } + } + + sdrs.append(sdr); + } + else + { + qDebug() << "KiwiSDRPublic::handleHTML: No URL found in:\n" << divText; + } + } + } + else + { + qDebug() << "KiwiSDRPublic::handleHTML: No cl-info found in:\n" << html; + } + + emit dataUpdated(sdrs); +} diff --git a/sdrbase/util/kiwisdrlist.h b/sdrbase/util/kiwisdrlist.h new file mode 100644 index 000000000..a6da7f731 --- /dev/null +++ b/sdrbase/util/kiwisdrlist.h @@ -0,0 +1,78 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_KIWISDRLIST_H +#define INCLUDE_KIWISDRLIST_H + +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QNetworkDiskCache; + +// Gets a list of public Kiwi SDRs from http://kiwisdr.com/public/ +class SDRBASE_API KiwiSDRList : public QObject +{ + Q_OBJECT + +public: + + struct KiwiSDR { + QString m_url; + QString m_status; // Only seems to be 'active' + QString m_offline; // Only seems to be 'no' + QString m_name; + QString m_sdrHW; + qint64 m_lowFrequency; + qint64 m_highFrequency; + int m_users; + int m_usersMax; + float m_latitude; + float m_longitude; + int m_altitude; // Above sea level (Not sure if ft or m) + QString m_location; + QString m_antenna; + bool m_antennaConnected; + QString m_snr; + }; + + KiwiSDRList(); + ~KiwiSDRList(); + + void getData(); + void getDataPeriodically(int periodInMins=4); + +public slots: + void handleReply(QNetworkReply* reply); + void update(); + +signals: + void dataUpdated(const QList& sdrs); // Emitted when data are available. + +private: + QNetworkAccessManager *m_networkManager; + QNetworkDiskCache *m_cache; + QTimer m_timer; // Timer for periodic updates + + void handleHTML(const QString& url, const QByteArray& bytes); + +}; + +#endif /* INCLUDE_KIWISDRLIST_H */ diff --git a/sdrbase/util/nasaglobalimagery.cpp b/sdrbase/util/nasaglobalimagery.cpp new file mode 100644 index 000000000..93851516f --- /dev/null +++ b/sdrbase/util/nasaglobalimagery.cpp @@ -0,0 +1,326 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "nasaglobalimagery.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +NASAGlobalImagery::NASAGlobalImagery() +{ + m_networkManager = new QNetworkAccessManager(); + QObject::connect(m_networkManager, &QNetworkAccessManager::finished, this, &NASAGlobalImagery::handleReply); + + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + QDir writeableDir(locations[0]); + if (!writeableDir.mkpath(QStringLiteral("cache") + QDir::separator() + QStringLiteral("nasaglobalimagery"))) { + qDebug() << "Failed to create cache/nasaglobalimagery"; + } + + m_cache = new QNetworkDiskCache(); + m_cache->setCacheDirectory(locations[0] + QDir::separator() + QStringLiteral("cache") + QDir::separator() + QStringLiteral("nasaglobalimagery")); + m_cache->setMaximumCacheSize(100000000); + m_networkManager->setCache(m_cache); +} + +NASAGlobalImagery::~NASAGlobalImagery() +{ + QObject::disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &NASAGlobalImagery::handleReply); + delete m_networkManager; +} + +void NASAGlobalImagery::getData() +{ + QUrl url(QString("https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/1.0.0/WMTSCapabilities.xml")); + m_networkManager->get(QNetworkRequest(url)); +} + +void NASAGlobalImagery::getMetaData() +{ + QUrl url(QString("https://worldview.earthdata.nasa.gov/config/wv.json")); + m_networkManager->get(QNetworkRequest(url)); +} + +void NASAGlobalImagery::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QString url = reply->url().toEncoded().constData(); + QByteArray bytes = reply->readAll(); + + if (url.endsWith(".xml")) { + handleXML(bytes); + } else if (url.endsWith(".svg")) { + handleSVG(url, bytes); + } else if (url.endsWith(".json")) { + handleJSON(bytes); + } else if (url.endsWith(".html")) { + handleHTML(url, bytes); + } else { + qDebug() << "NASAGlobalImagery::handleReply: unexpected URL: " << url; + } + } + else + { + qDebug() << "NASAGlobalImagery::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "NASAGlobalImagery::handleReply: reply is null"; + } +} + +void NASAGlobalImagery::handleXML(const QByteArray& bytes) +{ + QXmlStreamReader xmlReader(bytes); + QList dataSets; + + while (!xmlReader.atEnd() && !xmlReader.hasError()) + { + while (xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("Capabilities")) + { + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("Contents")) + { + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("Layer")) + { + QString identifier; + QString colorMap; + QList legends; + QString tileMatrixSet; + QStringList urls; + QString format; + QString defaultDateTime; + QStringList dates; + + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("Identifier")) + { + identifier = xmlReader.readElementText(); + } + else if (xmlReader.name() == QLatin1String("TileMatrixSetLink")) + { + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("TileMatrixSet")) + { + tileMatrixSet = xmlReader.readElementText(); + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + else if (xmlReader.name() == QLatin1String("Style")) + { + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("LegendURL")) + { + Legend legend; + + legend.m_url = xmlReader.attributes().value("xlink:href").toString(); + legend.m_width = (int)xmlReader.attributes().value("width").toFloat(); + legend.m_height = (int)xmlReader.attributes().value("height").toFloat(); + //qDebug() << legend.m_url << legend.m_width << legend.m_height; + legends.append(legend); + xmlReader.skipCurrentElement(); + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + else if (xmlReader.name() == QLatin1String("Metadata")) + { + colorMap = xmlReader.attributes().value("xlink:href").toString(); + xmlReader.skipCurrentElement(); + } + else if (xmlReader.name() == QLatin1String("Format")) + { + format = xmlReader.readElementText(); + } + else if (xmlReader.name() == QLatin1String("ResourceURL")) + { + QString url = xmlReader.attributes().value("template").toString(); + if (!url.isEmpty()) { + urls.append(url); + } + xmlReader.skipCurrentElement(); + } + else if (xmlReader.name() == QLatin1String("Dimension")) + { + while(xmlReader.readNextStartElement()) + { + if (xmlReader.name() == QLatin1String("Default")) + { + defaultDateTime = xmlReader.readElementText(); + } + else if (xmlReader.name() == QLatin1String("Value")) + { + dates.append(xmlReader.readElementText()); + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + else + { + xmlReader.skipCurrentElement(); + } + } + + // Some layers are application/vnd.mapbox-vector-tile + if (!identifier.isEmpty() && !tileMatrixSet.isEmpty() && ((format == "image/png") || (format == "image/jpeg"))) + { + DataSet dataSet; + dataSet.m_identifier = identifier; + dataSet.m_legends = legends; + dataSet.m_tileMatrixSet = tileMatrixSet; + dataSet.m_format = format; + dataSet.m_defaultDateTime = defaultDateTime; + dataSet.m_dates = dates; + dataSets.append(dataSet); + + //qDebug() << "Adding layer" << identifier << colorMap << legends << tileMatrixSet; + } + + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + else if (xmlReader.name() == QLatin1String("ServiceMetadataURL")) + { + // Empty? + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + else + { + xmlReader.skipCurrentElement(); + } + } + } + // Ignore "Premature end of document." here even if ok + if (!xmlReader.atEnd() && xmlReader.hasError()) + { + qDebug() << "NASAGlobalImagery::handleReply: Error parsing XML: " << xmlReader.errorString(); + } + + emit dataUpdated(dataSets); +} + +void NASAGlobalImagery::downloadLegend(const Legend& legend) +{ + QUrl url(legend.m_url); + m_networkManager->get(QNetworkRequest(url)); +} + +void NASAGlobalImagery::downloadHTML(const QString& urlString) +{ + QUrl url(urlString); + m_networkManager->get(QNetworkRequest(url)); +} + +void NASAGlobalImagery::handleSVG(const QString& url, const QByteArray& bytes) +{ + emit legendAvailable(url, bytes); +} + +void NASAGlobalImagery::handleJSON(const QByteArray& bytes) +{ + MetaData metaData; + + QJsonDocument document = QJsonDocument::fromJson(bytes); + if (document.isObject()) + { + QJsonObject obj = document.object(); + if (obj.contains(QStringLiteral("layers"))) + { + for (const auto& oRef : obj.value(QStringLiteral("layers")).toObject()) + { + Layer layer; + QJsonObject o = oRef.toObject(); + + if (o.contains(QStringLiteral("id"))) { + layer.m_identifier = o.value(QStringLiteral("id")).toString(); + } + if (o.contains(QStringLiteral("title"))) { + layer.m_title = o.value(QStringLiteral("title")).toString(); + } + if (o.contains(QStringLiteral("subtitle"))) { + layer.m_subtitle = o.value(QStringLiteral("subtitle")).toString(); + } + if (o.contains(QStringLiteral("description"))) { + layer.m_descriptionURL = "https://worldview.earthdata.nasa.gov/config/metadata/layers/" + o.value(QStringLiteral("description")).toString() + ".html"; + } + if (o.contains(QStringLiteral("startDate"))) { + layer.m_startDate = QDateTime::fromString(o.value(QStringLiteral("startDate")).toString(), Qt::ISODate); + } + if (o.contains(QStringLiteral("endDate"))) { + layer.m_endDate = QDateTime::fromString(o.value(QStringLiteral("endDate")).toString(), Qt::ISODate); + } + if (o.contains(QStringLiteral("ongoing"))) { + layer.m_ongoing = o.value(QStringLiteral("ongoing")).toBool(); + } + if (o.contains(QStringLiteral("layerPeriod"))) { + layer.m_layerPeriod = o.value(QStringLiteral("layerPeriod")).toString(); + } + if (o.contains(QStringLiteral("layergroup"))) { + layer.m_layerGroup = o.value(QStringLiteral("layergroup")).toString(); + } + if (!layer.m_identifier.isEmpty()) { + metaData.m_layers.insert(layer.m_identifier, layer); + } + } + } + } + + emit metaDataUpdated(metaData); +} + +void NASAGlobalImagery::handleHTML(const QString& url, const QByteArray& bytes) +{ + emit htmlAvailable(url, bytes); +} diff --git a/sdrbase/util/nasaglobalimagery.h b/sdrbase/util/nasaglobalimagery.h new file mode 100644 index 000000000..9408c5bcc --- /dev/null +++ b/sdrbase/util/nasaglobalimagery.h @@ -0,0 +1,98 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_NASAGLOBALIMAGERY_H +#define INCLUDE_NASAGLOBALIMAGERY_H + +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QNetworkDiskCache; + +// NASA GIBS (Global Imagery Browse Server) API (https://nasa-gibs.github.io/gibs-api-docs/) +// Gets details of available data sets for use on maps +// Also supports cached download of .svg legends +class SDRBASE_API NASAGlobalImagery : public QObject +{ + Q_OBJECT + +public: + + struct Legend { + QString m_url; // Typically to .svg file + int m_width; + int m_height; + }; + + struct DataSet { + QString m_identifier; + QList m_legends; + QString m_tileMatrixSet; + QString m_format; + QString m_defaultDateTime; + QStringList m_dates; + }; + + struct Layer { + QString m_identifier; + QString m_title; + QString m_subtitle; + QString m_descriptionURL; + QDateTime m_startDate; + QDateTime m_endDate; + bool m_ongoing; + QString m_layerPeriod; + QString m_layerGroup; + }; + + struct MetaData { + QHash m_layers; + }; + + NASAGlobalImagery(); + ~NASAGlobalImagery(); + + void getData(); + void getMetaData(); + void downloadLegend(const Legend& legend); + void downloadHTML(const QString& url); + +public slots: + void handleReply(QNetworkReply* reply); + +signals: + void dataUpdated(const QList& dataSets); // Emitted when paths to new data are available. + void metaDataUpdated(const MetaData& metaData); + void legendAvailable(const QString& url, const QByteArray data); + void htmlAvailable(const QString& url, const QByteArray data); + +private: + QNetworkAccessManager *m_networkManager; + QNetworkDiskCache *m_cache; + + void handleXML(const QByteArray& bytes); + void handleSVG(const QString& url, const QByteArray& bytes); + void handleJSON(const QByteArray& bytes); + void handleHTML(const QString& url, const QByteArray& bytes); + +}; + +#endif /* INCLUDE_NASAGLOBALIMAGERY_H */ diff --git a/sdrbase/util/rainviewer.cpp b/sdrbase/util/rainviewer.cpp new file mode 100644 index 000000000..8d761e323 --- /dev/null +++ b/sdrbase/util/rainviewer.cpp @@ -0,0 +1,130 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "rainviewer.h" + +#include +#include +#include +#include +#include +#include + +RainViewer::RainViewer() +{ + connect(&m_timer, &QTimer::timeout, this, &RainViewer::update); + m_networkManager = new QNetworkAccessManager(); + QObject::connect(m_networkManager, &QNetworkAccessManager::finished, this, &RainViewer::handleReply); +} + +RainViewer::~RainViewer() +{ + m_timer.stop(); + QObject::disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &RainViewer::handleReply); + delete m_networkManager; +} + +void RainViewer::getPathPeriodically(int periodInMins) +{ + // Rain maps updated every 10mins + m_timer.setInterval(periodInMins*60*1000); + m_timer.start(); + update(); +} + +void RainViewer::update() +{ + getPath(); +} + +void RainViewer::getPath() +{ + QUrl url(QString("https://api.rainviewer.com/public/weather-maps.json")); + m_networkManager->get(QNetworkRequest(url)); +} + +void RainViewer::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QJsonDocument document = QJsonDocument::fromJson(reply->readAll()); + if (document.isObject()) + { + QJsonObject obj = document.object(); + QString radarPath = ""; + QString satellitePath = ""; + + if (obj.contains(QStringLiteral("radar"))) + { + QJsonValue val = obj.value(QStringLiteral("radar")); + QJsonObject mainObj = val.toObject(); + if (mainObj.contains(QStringLiteral("past"))) + { + QJsonArray past = mainObj.value(QStringLiteral("past")).toArray(); + if (past.size() > 0) + { + QJsonObject mostRecent = past.last().toObject(); + if (mostRecent.contains(QStringLiteral("path"))) { + radarPath = mostRecent.value(QStringLiteral("path")).toString(); + } + } + } + } + else + { + qDebug() << "RainViewer::handleReply: Object doesn't contain a radar: " << obj; + } + if (obj.contains(QStringLiteral("satellite"))) + { + QJsonValue val = obj.value(QStringLiteral("satellite")); + QJsonObject mainObj = val.toObject(); + if (mainObj.contains(QStringLiteral("infrared"))) + { + QJsonArray ir = mainObj.value(QStringLiteral("infrared")).toArray(); + if (ir.size() > 0) + { + QJsonObject mostRecent = ir.last().toObject(); + if (mostRecent.contains(QStringLiteral("path"))) { + satellitePath = mostRecent.value(QStringLiteral("path")).toString(); + } + } + } + } + else + { + qDebug() << "RainViewer::handleReply: Object doesn't contain a satellite: " << obj; + } + emit pathUpdated(radarPath, satellitePath); + } + else + { + qDebug() << "RainViewer::handleReply: Document is not an object: " << document; + } + } + else + { + qDebug() << "RainViewer::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "RainViewer::handleReply: reply is null"; + } +} diff --git a/sdrbase/util/rainviewer.h b/sdrbase/util/rainviewer.h new file mode 100644 index 000000000..4a5acb983 --- /dev/null +++ b/sdrbase/util/rainviewer.h @@ -0,0 +1,55 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RAINVIEWER_H +#define INCLUDE_RAINVIEWER_H + +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; + +// RainViewer API wrapper (https://www.rainviewer.com/) +// Gets details of currently available weather radar and satellite IR data +class SDRBASE_API RainViewer : public QObject +{ + Q_OBJECT + +public: + RainViewer(); + ~RainViewer(); + + void getPath(); + void getPathPeriodically(int periodInMins=15); + +public slots: + void update(); + void handleReply(QNetworkReply* reply); + +signals: + void pathUpdated(const QString& radarPath, const QString& satellitePath); // Emitted when paths to new data are available. + +private: + QTimer m_timer; // Timer for periodic updates + QNetworkAccessManager *m_networkManager; + +}; + +#endif /* INCLUDE_RAINVIEWER_H */ diff --git a/sdrbase/util/spyserverlist.cpp b/sdrbase/util/spyserverlist.cpp new file mode 100644 index 000000000..bd8143c5a --- /dev/null +++ b/sdrbase/util/spyserverlist.cpp @@ -0,0 +1,164 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "spyserverlist.h" + +#include +#include +#include +#include +#include +#include +#include + +SpyServerList::SpyServerList() +{ + m_networkManager = new QNetworkAccessManager(); + QObject::connect(m_networkManager, &QNetworkAccessManager::finished, this, &SpyServerList::handleReply); + + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + QDir writeableDir(locations[0]); + if (!writeableDir.mkpath(QStringLiteral("cache") + QDir::separator() + QStringLiteral("spyserver"))) { + qDebug() << "Failed to create cache/spyserver"; + } + + m_cache = new QNetworkDiskCache(); + m_cache->setCacheDirectory(locations[0] + QDir::separator() + QStringLiteral("cache") + QDir::separator() + QStringLiteral("spyserver")); + m_cache->setMaximumCacheSize(100000000); + m_networkManager->setCache(m_cache); + + connect(&m_timer, &QTimer::timeout, this, &SpyServerList::update); +} + +SpyServerList::~SpyServerList() +{ + QObject::disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &SpyServerList::handleReply); + delete m_networkManager; +} + +void SpyServerList::getData() +{ + QUrl url(QString("https://airspy.com/directory/status.json")); + m_networkManager->get(QNetworkRequest(url)); +} + +void SpyServerList::getDataPeriodically(int periodInMins) +{ + m_timer.setInterval(periodInMins*60*1000); + m_timer.start(); + update(); +} + +void SpyServerList::update() +{ + getData(); +} + +void SpyServerList::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QString url = reply->url().toEncoded().constData(); + QByteArray bytes = reply->readAll(); + + handleJSON(url, bytes); + } + else + { + qDebug() << "SpyServerList::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "SpyServerList::handleReply: reply is null"; + } +} + +void SpyServerList::handleJSON(const QString& url, const QByteArray& bytes) +{ + QList sdrs; + QJsonDocument document = QJsonDocument::fromJson(bytes); + + if (document.isObject()) + { + QJsonObject obj = document.object(); + if (obj.contains(QStringLiteral("servers"))) + { + QJsonArray servers = obj.value(QStringLiteral("servers")).toArray(); + + for (auto valRef : servers) + { + if (valRef.isObject()) + { + QJsonObject serverObj = valRef.toObject(); + SpyServer sdr; + + if (serverObj.contains(QStringLiteral("generalDescription"))) { + sdr.m_generalDescription = serverObj.value(QStringLiteral("generalDescription")).toString(); + } + if (serverObj.contains(QStringLiteral("deviceType"))) { + sdr.m_deviceType = serverObj.value(QStringLiteral("deviceType")).toString(); + } + if (serverObj.contains(QStringLiteral("streamingHost"))) { + sdr.m_streamingHost = serverObj.value(QStringLiteral("streamingHost")).toString(); + } + if (serverObj.contains(QStringLiteral("streamingPort"))) { + sdr.m_streamingPort = serverObj.value(QStringLiteral("streamingPort")).toInt(); + } + if (serverObj.contains(QStringLiteral("currentClientCount"))) { + sdr.m_currentClientCount = serverObj.value(QStringLiteral("currentClientCount")).toInt(); + } + if (serverObj.contains(QStringLiteral("maxClients"))) { + sdr.m_maxClients = serverObj.value(QStringLiteral("maxClients")).toInt(); + } + if (serverObj.contains(QStringLiteral("antennaType"))) { + sdr.m_antennaType = serverObj.value(QStringLiteral("antennaType")).toString(); + } + if (serverObj.contains(QStringLiteral("antennaLocation"))) + { + QJsonObject location = serverObj.value(QStringLiteral("antennaLocation")).toObject(); + sdr.m_latitude = location.value(QStringLiteral("lat")).toDouble(); + sdr.m_longitude = location.value(QStringLiteral("long")).toDouble(); + } + if (serverObj.contains(QStringLiteral("minimumFrequency"))) { + sdr.m_minimumFrequency = serverObj.value(QStringLiteral("minimumFrequency")).toInt(); + } + if (serverObj.contains(QStringLiteral("maximumFrequency"))) { + sdr.m_maximumFrequency = serverObj.value(QStringLiteral("maximumFrequency")).toInt(); + } + if (serverObj.contains(QStringLiteral("fullControlAllowed"))) { + sdr.m_fullControlAllowed = serverObj.value(QStringLiteral("fullControlAllowed")).toBool(); + } + if (serverObj.contains(QStringLiteral("online"))) { + sdr.m_online = serverObj.value(QStringLiteral("online")).toBool(); + } + + sdrs.append(sdr); + } + } + } + } + else + { + qDebug() << "SpyServerList::handleJSON: Doc doesn't contain an object:\n" << document; + } + + emit dataUpdated(sdrs); +} diff --git a/sdrbase/util/spyserverlist.h b/sdrbase/util/spyserverlist.h new file mode 100644 index 000000000..53c92dac0 --- /dev/null +++ b/sdrbase/util/spyserverlist.h @@ -0,0 +1,75 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_SPYSERVERLIST_H +#define INCLUDE_SPYSERVERLIST_H + +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QNetworkDiskCache; + +// Gets a list of public SpyServers from https://airspy.com/directory/status.json +class SDRBASE_API SpyServerList : public QObject +{ + Q_OBJECT + +public: + + struct SpyServer { + QString m_generalDescription; + QString m_deviceType; + QString m_streamingHost; // IP addrss + int m_streamingPort; // IP port + int m_currentClientCount; + int m_maxClients; + QString m_antennaType; + float m_latitude; + float m_longitude; + qint64 m_minimumFrequency; + qint64 m_maximumFrequency; + bool m_fullControlAllowed; + bool m_online; + }; + + SpyServerList(); + ~SpyServerList(); + + void getData(); + void getDataPeriodically(int periodInMins=2); + +public slots: + void handleReply(QNetworkReply* reply); + void update(); + +signals: + void dataUpdated(const QList& sdrs); // Emitted when data are available. + +private: + QNetworkAccessManager *m_networkManager; + QNetworkDiskCache *m_cache; + QTimer m_timer; // Timer for periodic updates + + void handleJSON(const QString& url, const QByteArray& bytes); + +}; + +#endif /* INCLUDE_SPYSERVERLIST_H */ diff --git a/sdrbase/util/waypoints.cpp b/sdrbase/util/waypoints.cpp new file mode 100644 index 000000000..00835e86c --- /dev/null +++ b/sdrbase/util/waypoints.cpp @@ -0,0 +1,141 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include + +#include "waypoints.h" +#include "csv.h" + +QHash *Waypoint::readCSV(const QString &filename) +{ + QHash *waypoints = new QHash(); + QFile file(filename); + + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + QTextStream in(&file); + QString error; + + QStringList cols; + while(CSV::readRow(in, &cols)) + { + Waypoint *waypoint = new Waypoint(); + waypoint->m_name = cols[0]; + waypoint->m_latitude = cols[1].toFloat(); + waypoint->m_longitude = cols[2].toFloat(); + waypoints->insert(waypoint->m_name, waypoint); + } + + file.close(); + } + else + { + qDebug() << "Waypoint::readCSV: Could not open " << filename << " for reading."; + } + return waypoints; +} + +QSharedPointer> Waypoints::m_waypoints; + +QDateTime Waypoints::m_waypointsModifiedDateTime; + +Waypoints::Waypoints(QObject *parent) : + QObject(parent) +{ + connect(&m_dlm, &HttpDownloadManager::downloadComplete, this, &Waypoints::downloadFinished); +} + +Waypoints::~Waypoints() +{ + disconnect(&m_dlm, &HttpDownloadManager::downloadComplete, this, &Waypoints::downloadFinished); +} + +QString Waypoints::getDataDir() +{ + // Get directory to store app data in + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + // First dir is writable + return locations[0]; +} + +QString Waypoints::getWaypointsFilename() +{ + return getDataDir() + "/" + "waypoints.csv"; +} + +void Waypoints::downloadWaypoints() +{ + QString filename = getWaypointsFilename(); + QString urlString = WAYPOINTS_URL; + QUrl dbURL(urlString); + qDebug() << "Waypoints::downloadWaypoints: Downloading " << urlString; + emit downloadingURL(urlString); + m_dlm.download(dbURL, filename); +} + +void Waypoints::downloadFinished(const QString& filename, bool success) +{ + if (!success) { + qDebug() << "Waypoints::downloadFinished: Failed: " << filename; + } + + if (filename == getWaypointsFilename()) + { + emit downloadWaypointsFinished(); + } + else + { + qDebug() << "Waypoints::downloadFinished: Unexpected filename: " << filename; + emit downloadError(QString("Unexpected filename: %1").arg(filename)); + } +} + +// Read waypoints +QHash *Waypoints::readWaypoints() +{ + return Waypoint::readCSV(getWaypointsFilename()); +} + +QSharedPointer> Waypoints::getWaypoints() +{ + QDateTime filesDateTime = getWaypointsModifiedDateTime(); + + if (!m_waypoints || (filesDateTime > m_waypointsModifiedDateTime)) + { + // Using shared pointer, so old object, if it exists, will be deleted, when no longer used + m_waypoints = QSharedPointer>(readWaypoints()); + m_waypointsModifiedDateTime = filesDateTime; + } + return m_waypoints; +} + +// Gets the date and time the waypoint file was most recently modified +QDateTime Waypoints::getWaypointsModifiedDateTime() +{ + QFileInfo fileInfo(getWaypointsFilename()); + return fileInfo.lastModified(); +} + +// Find a waypoint by name +const Waypoint *Waypoints::findWayPoint(const QString& name) +{ + if (m_waypoints->contains(name)) { + return m_waypoints->value(name); + } else { + return nullptr; + } +} diff --git a/sdrbase/util/waypoints.h b/sdrbase/util/waypoints.h new file mode 100644 index 000000000..3df2c6eb3 --- /dev/null +++ b/sdrbase/util/waypoints.h @@ -0,0 +1,80 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2021-2024 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_WAYPOINTS_H +#define INCLUDE_WAYPOINTS_H + +#include +#include +#include + +#include +#include + +#include "export.h" + +#include "util/units.h" +#include "util/httpdownloadmanager.h" + +#define WAYPOINTS_URL "https://github.com/srcejon/aviationwaypoints/waypoints.csv" + +// Aviation waypoints +struct SDRBASE_API Waypoint { + + QString m_name; // Typically 5 characters + float m_latitude; + float m_longitude; + + static QHash *readCSV(const QString &filename); +}; + +class SDRBASE_API Waypoints : public QObject { + Q_OBJECT + +public: + Waypoints(QObject* parent = nullptr); + ~Waypoints(); + + void downloadWaypoints(); + + static const Waypoint* findWayPoint(const QString& name); + static QSharedPointer> getWaypoints(); + +private: + HttpDownloadManager m_dlm; + + static QSharedPointer> m_waypoints; + + static QDateTime m_waypointsModifiedDateTime; + + static QHash *readWaypoints(); + + static QString getDataDir(); + static QString getWaypointsFilename(); + static QDateTime getWaypointsModifiedDateTime(); + +public slots: + void downloadFinished(const QString& filename, bool success); + +signals: + void downloadingURL(const QString& url); + void downloadError(const QString& error); + void downloadWaypointsFinished(); + +}; + +#endif // INCLUDE_WAYPOINTS_H