From 8bf1a2f8032718fbc252b6cc2fa66b3b3f96321e Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 10:44:30 +0100 Subject: [PATCH 01/20] Remote TCP updates: Add support for public list of SDRangel servers that can be displayed on Map. Add FLAC and zlib IQ compression. Add IQ squelch for compression. Add remote device/antenna position and direction reporting. Add text messaging. --- .appveyor.yml | 2 +- cmake/Modules/FindFLAC.cmake | 31 + debian/control | 1 + external/CMakeLists.txt | 31 + .../channelrx/remotetcpsink/CMakeLists.txt | 24 +- plugins/channelrx/remotetcpsink/readme.md | 94 +- .../remotetcpsink/remotetcpprotocol.h | 41 +- .../channelrx/remotetcpsink/remotetcpsink.cpp | 173 +- .../channelrx/remotetcpsink/remotetcpsink.h | 125 +- .../remotetcpsink/remotetcpsinkbaseband.cpp | 14 +- .../remotetcpsink/remotetcpsinkbaseband.h | 4 +- .../remotetcpsink/remotetcpsinkgui.cpp | 305 +++- .../remotetcpsink/remotetcpsinkgui.h | 30 +- .../remotetcpsink/remotetcpsinkgui.ui | 438 ++++- .../remotetcpsink/remotetcpsinksettings.cpp | 223 +++ .../remotetcpsink/remotetcpsinksettings.h | 37 +- .../remotetcpsinksettingsdialog.cpp | 370 ++++ .../remotetcpsinksettingsdialog.h | 65 + .../remotetcpsinksettingsdialog.ui | 675 +++++++ .../remotetcpsink/remotetcpsinksink.cpp | 1563 ++++++++++++++--- .../remotetcpsink/remotetcpsinksink.h | 118 +- plugins/channelrx/remotetcpsink/socket.cpp | 160 ++ plugins/channelrx/remotetcpsink/socket.h | 89 + plugins/feature/map/mapgui.cpp | 283 ++- plugins/feature/map/mapgui.h | 17 +- plugins/feature/map/mapsettings.cpp | 10 +- .../remotetcpinput/remotetcpinput.cpp | 96 +- .../remotetcpinput/remotetcpinput.h | 114 +- .../remotetcpinput/remotetcpinputgui.cpp | 381 +++- .../remotetcpinput/remotetcpinputgui.h | 26 +- .../remotetcpinput/remotetcpinputgui.ui | 408 ++++- .../remotetcpinput/remotetcpinputsettings.cpp | 68 +- .../remotetcpinput/remotetcpinputsettings.h | 7 + .../remotetcpinputtcphandler.cpp | 1492 +++++++++++++--- .../remotetcpinput/remotetcpinputtcphandler.h | 120 +- sdrbase/CMakeLists.txt | 2 + sdrbase/util/sdrangelserverlist.cpp | 183 ++ sdrbase/util/sdrangelserverlist.h | 81 + .../api/swagger/include/RemoteTCPInput.yaml | 9 + .../qt5/client/SWGRemoteTCPInputReport.cpp | 7 +- .../code/qt5/client/SWGRemoteTCPInputReport.h | 12 +- 41 files changed, 7155 insertions(+), 774 deletions(-) create mode 100644 cmake/Modules/FindFLAC.cmake create mode 100644 plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp create mode 100644 plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h create mode 100644 plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.ui create mode 100644 plugins/channelrx/remotetcpsink/socket.cpp create mode 100644 plugins/channelrx/remotetcpsink/socket.h create mode 100644 sdrbase/util/sdrangelserverlist.cpp create mode 100644 sdrbase/util/sdrangelserverlist.h diff --git a/.appveyor.yml b/.appveyor.yml index bce5c11b26..e7893e0893 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -84,7 +84,7 @@ for: qml-module-qtlocation qml-module-qtpositioning qml-module-qtquick-window2 qml-module-qtquick-dialogs \ qml-module-qtquick-controls qml-module-qtquick-controls2 qml-module-qtquick-layouts qml-module-qtgraphicaleffects \ libqt5serialport5-dev qtdeclarative5-dev qtpositioning5-dev qtlocation5-dev \ - libqt5charts5-dev libqt5texttospeech5-dev libqt5gamepad5-dev libqt5svg5-dev libfaad-dev zlib1g-dev \ + libqt5charts5-dev libqt5texttospeech5-dev libqt5gamepad5-dev libqt5svg5-dev libfaad-dev libflac-dev zlib1g-dev \ libusb-1.0-0-dev libhidapi-dev libboost-all-dev libasound2-dev libopencv-dev libopencv-imgcodecs-dev \ libxml2-dev bison flex ffmpeg libpostproc-dev libavcodec-dev libavformat-dev \ libopus-dev libcodec2-dev libairspy-dev libhackrf-dev \ diff --git a/cmake/Modules/FindFLAC.cmake b/cmake/Modules/FindFLAC.cmake new file mode 100644 index 0000000000..f6af1b52a0 --- /dev/null +++ b/cmake/Modules/FindFLAC.cmake @@ -0,0 +1,31 @@ +IF(NOT FLAC_FOUND) + INCLUDE(FindPkgConfig) + PKG_CHECK_MODULES(PC_FLAC flac) + + FIND_PATH( + FLAC_INCLUDE_DIR + NAMES FLAC/stream_encoder.h + HINTS ${PC_FLAC_INCLUDE_DIRS} + PATHS /usr/local/include + /usr/include + ) + + FIND_LIBRARY( + FLAC_LIBRARY + NAMES FLAC + libFLAC + HINTS ${FLAC_DIR}/lib + ${PC_FLAC_LIBRARY_DIRS} + PATHS /usr/local/lib + /usr/lib + /usr/lib64 + ) + + message(STATUS "FLAC LIBRARY " ${FLAC_LIBRARY}) + message(STATUS "FLAC INCLUDE DIR " ${FLAC_INCLUDE_DIR}) + + INCLUDE(FindPackageHandleStandardArgs) + FIND_PACKAGE_HANDLE_STANDARD_ARGS(FLAC DEFAULT_MSG FLAC_LIBRARY FLAC_INCLUDE_DIR) + MARK_AS_ADVANCED(FLAC_LIBRARY FLAC_INCLUDE_DIR) + +ENDIF(NOT FLAC_FOUND) diff --git a/debian/control b/debian/control index 710d236a62..2498d2c482 100644 --- a/debian/control +++ b/debian/control @@ -45,6 +45,7 @@ Build-Depends: debhelper (>= 9), flex, ffmpeg, libfaad-dev, + libflac-dev, libavcodec-dev, libavformat-dev, libopus-dev, diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index 618a858999..1b1070471a 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -851,6 +851,37 @@ if(ENABLE_FEATURE_SATELLITETRACKER OR ENABLE_CHANNELRX_DEMODAPT) endif () endif () +if(ENABLE_CHANNELRX_REMOTETCPSINK) + if (WIN32) + set(FLAC_LIBRARIES "${SDRANGEL_BINARY_LIB_DIR}/FLAC.lib" CACHE INTERNAL "") + elseif (LINUX) + set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/lib${LIB_SUFFIX}/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") + elseif (EMSCRIPTEN) + set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC/libFLAC.a" CACHE INTERNAL "") + endif() + ExternalProject_Add(flac + GIT_REPOSITORY https://github.com/xiph/flac.git + PREFIX "${EXTERNAL_BUILD_LIBRARIES}/flac" + CMAKE_ARGS ${COMMON_CMAKE_ARGS} -DINSTALL_MANPAGES=OFF -D=BUILD_SHARED_LIBS=ON -DWITH_FORTIFY_SOURCE=OFF -DWITH_STACK_PROTECTOR=PFF -DBUILD_PROGRAMS=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF -DWITH_OGG=OFF -DBUILD_DOCS=OFF + BUILD_BYPRODUCTS "${FLAC_LIBRARIES}" + INSTALL_COMMAND "" + TEST_COMMAND "" + ) + ExternalProject_Get_Property(flac source_dir binary_dir) + set_global(FLAC_DEPENDS flac) + set_global_cache(FLAC_FOUND ON) + set(FLAC_EXTERNAL ON CACHE INTERNAL "") + set(FLAC_INCLUDE_DIR "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac/include" CACHE INTERNAL "") + if (WIN32) + install(FILES "${SDRANGEL_BINARY_BIN_DIR}/FLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" DESTINATION "${INSTALL_LIB_DIR}") + elseif (APPLE) + set(FLAC_LIBRARIES "${binary_dir}/flac/FLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") + install(DIRECTORY "${binary_dir}/libFLAC" DESTINATION "${INSTALL_LIB_DIR}" + FILES_MATCHING PATTERN "libFLAC*${CMAKE_SHARED_LIBRARY_SUFFIX}") + set(MACOS_EXTERNAL_LIBS_FIXUP "${MACOS_EXTERNAL_LIBS_FIXUP};${binary_dir}/libFLAC") + endif () +endif () + # For Morse Decoder feature if(ENABLE_FEATURE_MORSEDECODER) if (WIN32) diff --git a/plugins/channelrx/remotetcpsink/CMakeLists.txt b/plugins/channelrx/remotetcpsink/CMakeLists.txt index 2b42dc98e9..4d1884a499 100644 --- a/plugins/channelrx/remotetcpsink/CMakeLists.txt +++ b/plugins/channelrx/remotetcpsink/CMakeLists.txt @@ -7,6 +7,7 @@ set(remotetcpsink_SOURCES remotetcpsinksettings.cpp remotetcpsinkwebapiadapter.cpp remotetcpsinkplugin.cpp + socket.cpp ) set(remotetcpsink_HEADERS @@ -17,10 +18,13 @@ set(remotetcpsink_HEADERS remotetcpsinkwebapiadapter.h remotetcpsinkplugin.h remotetcpprotocol.h + socket.h ) include_directories( ${CMAKE_SOURCE_DIR}/swagger/sdrangel/code/qt5/client + ${FLAC_INCLUDE_DIR} + ${ZLIB_INCLUDE_DIRS} ) if(NOT SERVER_MODE) @@ -28,10 +32,13 @@ if(NOT SERVER_MODE) ${remotetcpsink_SOURCES} remotetcpsinkgui.cpp remotetcpsinkgui.ui + remotetcpsinksettingsdialog.cpp + remotetcpsinksettingsdialog.ui ) set(remotetcpsink_HEADERS ${remotetcpsink_HEADERS} remotetcpsinkgui.h + remotetcpsinksettingsdialog.h ) set(TARGET_NAME ${PLUGINS_PREFIX}remotetcpsink) set(TARGET_LIB "Qt::Widgets") @@ -44,16 +51,25 @@ else() set(INSTALL_FOLDER ${INSTALL_PLUGINSSRV_DIR}) endif() -add_library(${TARGET_NAME} SHARED - ${remotetcpsink_SOURCES} -) +if(NOT Qt6_FOUND) + add_library(${TARGET_NAME} ${remotetcpsink_SOURCES}) +else() + qt_add_plugin(${TARGET_NAME} CLASS_NAME RemoteTCPSinkPlugin ${remotetcpsink_SOURCES}) +endif() + +if(NOT BUILD_SHARED_LIBS) + set_property(GLOBAL APPEND PROPERTY STATIC_PLUGINS_PROPERTY ${TARGET_NAME}) +endif() -target_link_libraries(${TARGET_NAME} +target_link_libraries(${TARGET_NAME} PRIVATE Qt::Core + Qt::WebSockets ${TARGET_LIB} sdrbase ${TARGET_LIB_GUI} swagger + ${FLAC_LIBRARIES} + ${ZLIB_LIBRARIES} ) install(TARGETS ${TARGET_NAME} DESTINATION ${INSTALL_FOLDER}) diff --git a/plugins/channelrx/remotetcpsink/readme.md b/plugins/channelrx/remotetcpsink/readme.md index aefe35deb4..1c10da99ff 100644 --- a/plugins/channelrx/remotetcpsink/readme.md +++ b/plugins/channelrx/remotetcpsink/readme.md @@ -1,11 +1,25 @@ -

Remote TCP sink channel plugin

+

Remote TCP Sink Channel Plugin

Introduction

-This plugin sends I/Q samples from the baseband via TCP/IP across a network to a client application. -The client application could be SDRangel using the [Remote TCP Input](../../samplesource/remotetcpinput/readme.md) plugin or a rtl_tcp compatible application. +The Remote TCP Sink Channel plugin sends I/Q samples from the baseband via TCP/IP or a Secure WebSocket across a network to a client application. +The client application could be SDRangel using the [Remote TCP Input](../../samplesource/remotetcpinput/readme.md) plugin or an rtl_tcp compatible application. This means that applications using rtl_tcp protocol can connect to the wide variety of SDRs supported by SDRangel. +While the plugin supports the RTL0 protocol for compatibility with older applications, the newer SDRA protocol supports the following additional features: + +- Different bit depths (8, 16, 24 or 32), +- Additional settings, such as decimation, frequency offset and channel gain, +- Device settings can be sent to the client for display, +- IQ compression, using FLAC or zlib, to reduce network bandwidth, +- IQ squelch, to reduce network bandwidth when no signal is being received, +- Real-time forwarding of device/antenna position and direction to client, +- Text messaging between clients and server. + +The Remote TCP Sink can support multiple clients connected simultaneously, with a user-defined maximum client limit. Clients can also have a time limit applied. + +Connection details can optionally be sent to a public database at https://sdrangel.org to allow operation as a WebSDR. Public servers are viewable on the [Map Feature](../../feature/map/readme.md). +

Interface

![Remote TCP sink channel plugin GUI](../../../doc/img/RemoteTCPSink.png) @@ -20,25 +34,85 @@ This is used to select the desired part of the signal when the channel sample ra Sets a gain figure in dB that is applied to I/Q samples before transmission via TCP/IP. This option may be useful for amplifying very small signals from SDRs with high-dynamic range (E.g. 24-bits), when the network sample bit-depth is 8-bits. -

3: Sample rate

+

3: Channel power

+ +Average total power in dB relative to a +/- 1.0 amplitude signal received in the pass band. + +

4: Level meter in dB

+ + - top bar (green): average value + - bottom bar (blue green): instantaneous peak value + - tip vertical bar (bright green): peak hold value + +

5: IQ Squelch

+ +Check to enable IQ squelch. When IQ squelch is enabled, if the channel power falls below the specified power level (6), +the plugin will squelch (suppress) all the signal and noise in the channel, +so that it can be transmitted at a very high compression ratio, reducing network bandwidth. + +This option is particularly suitable for packetised data, where the client doesn't need to receive the noise between packets. + +

6: IQ Squelch power level

+ +Sets the power level in dB, below which, IQ data will be squelched. + +

7: IQ Squelch gate time

+ +Sets the IQ squelch gate time. The units can be us (microseconds), ms (milliseconds) or s (seconds). + +

8: IQ Squelch indicator

+ +When IQ squelch is enabled, the icon will have a green background when a signal above the power level (6) is being transmitted and a grey background when the signal is squelched. + +

9: Sample rate

Specifies the channel and network sample rate in samples per second. If this is different from the baseband sample rate, the baseband signal will be decimated to the specified rate. -

4: Sample bit depth

+

10: Sample bit depth

Specifies number of bits per I/Q sample transmitted via TCP/IP. -

5: IP address

+

11: IP address

IP address of the local network interface on which the server will listen for TCP/IP connections from network clients. Use 0.0.0.0 for any interface. -

6: Port

+

12: Port

TCP port on which the server will listen for connections. -

7: Protocol

+

13: Protocol

-Specifies the protocol used for sending IQ samples and metadata to clients via TCP/IP. +Specifies the protocol used for sending IQ samples and metadata to clients: - RTL0: Compatible with rtl_tcp - limited to 8-bit IQ data. -- SDRA: Enhanced version of protocol that allows device settings to be sent to clients and for higher bit depths to be used (8, 16, 24 and 32). +- SDRA: Enhanced version of protocol via TCP Socket. +- SDRA wss: SDRA protocol via a Secure Web Socket instead of a TCP Socket. You should use this with the WebAssembly version of SDRangel. + +

14: Display Settings

+ +Click to open the Settings Dialog. + +

15: Remote Control

+ +When checked, remote clients will be able to change device settings. When unchecked, client requests to change settings will be ignored. + +

16: TX

+ +When pressed, the text message (18) will be transmitted to the clients specified by (17). + +

17: TX Address

+ +Specifies the TCP/IP address and port of the client that the message should be transmitted to, or ALL, if it should be transmitted to all clients. + +

18: TX Message

+ +Specifies a text message to transmit to clients, when the TX button (16) is pressed. + +

19: RX Messages

+ +Displays text messages received from clients. + +

20: Connection Log

+ +Displays a the IP addresses and TCP port numbers of clients that have connected, along with when they connected and disconnected +and how long they were connected for. diff --git a/plugins/channelrx/remotetcpsink/remotetcpprotocol.h b/plugins/channelrx/remotetcpsink/remotetcpprotocol.h index 4803a21905..fb9e7b02d4 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpprotocol.h +++ b/plugins/channelrx/remotetcpsink/remotetcpprotocol.h @@ -105,13 +105,23 @@ class RemoteTCPProtocol setChannelFreqOffset = 0xc4, setChannelGain = 0xc5, setSampleBitDepth = 0xc6, // Bit depth for samples sent over network + setIQSquelchEnabled = 0xc7, + setIQSquelch = 0xc8, + setIQSquelchGate = 0xc9, //setAntenna? //setLOOffset? + sendMessage = 0xd0, + sendBlacklistedMessage = 0xd1, + dataIQ = 0xf0, // Uncompressed IQ data + dataIQFLAC = 0xf1, // IQ data compressed with FLAC + dataIQzlib = 0xf2, // IQ data compressed with zlib + dataPosition = 0xf3, // Lat, Long, Alt of anntenna + dataDirection = 0xf4 // Az/El of antenna }; static const int m_rtl0MetaDataSize = 12; static const int m_rsp0MetaDataSize = 45; - static const int m_sdraMetaDataSize = 64; + static const int m_sdraMetaDataSize = 128; static void encodeInt16(quint8 *p, qint16 data) { @@ -132,7 +142,7 @@ class RemoteTCPProtocol encodeUInt32(p, (quint32)data); } - static qint16 extractInt16(quint8 *p) + static qint16 extractInt16(const quint8 *p) { qint16 data; data = (p[1] & 0xff) @@ -140,7 +150,7 @@ class RemoteTCPProtocol return data; } - static quint32 extractUInt32(quint8 *p) + static quint32 extractUInt32(const quint8 *p) { quint32 data; data = (p[3] & 0xff) @@ -150,7 +160,7 @@ class RemoteTCPProtocol return data; } - static qint32 extractInt32(quint8 *p) + static qint32 extractInt32(const quint8 *p) { return (qint32)extractUInt32(p); } @@ -167,7 +177,7 @@ class RemoteTCPProtocol p[7] = data & 0xff; } - static quint64 extractUInt64(quint8 *p) + static quint64 extractUInt64(const quint8 *p) { quint64 data; data = (p[7] & 0xff) @@ -181,6 +191,27 @@ class RemoteTCPProtocol return data; } + static void encodeFloat(quint8 *p, float data) + { + quint32 t; + + memcpy(&t, &data, 4); + + encodeUInt32(p, t); + } + + static float extractFloat(const quint8 *p) + { + quint32 t; + float f; + + t = extractUInt32(p); + + memcpy(&f, &t, 4); + + return f; + } + }; #endif /* PLUGINS_CHANNELRX_REMOTETCPSINK_REMOTETCPPROTOCOL_H_ */ diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp index 999ee8765f..bd9a5084ec 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include "SWGChannelSettings.h" #include "SWGWorkspaceInfo.h" @@ -33,13 +35,16 @@ #include "dsp/devicesamplemimo.h" #include "device/deviceapi.h" #include "settings/serializable.h" +#include "channel/channelwebapiutils.h" #include "maincore.h" #include "remotetcpsinkbaseband.h" MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgConfigureRemoteTCPSink, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportConnection, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportDisconnect, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportBW, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgSendMessage, Message) const char* const RemoteTCPSink::m_channelIdURI = "sdrangel.channel.remotetcpsink"; const char* const RemoteTCPSink::m_channelId = "RemoteTCPSink"; @@ -47,7 +52,9 @@ const char* const RemoteTCPSink::m_channelId = "RemoteTCPSink"; RemoteTCPSink::RemoteTCPSink(DeviceAPI *deviceAPI) : ChannelAPI(m_channelIdURI, ChannelAPI::StreamSingleSink), m_deviceAPI(deviceAPI), - m_basebandSampleRate(0) + m_basebandSampleRate(0), + m_clients(0), + m_removeRequest(nullptr) { setObjectName(m_channelId); @@ -77,7 +84,21 @@ RemoteTCPSink::RemoteTCPSink(DeviceAPI *deviceAPI) : RemoteTCPSink::~RemoteTCPSink() { - qDebug("RemoteTCPSinkBaseband::~RemoteTCPSink"); + qDebug("RemoteTCPSink::~RemoteTCPSink"); + + // Wait until remove listing request is finished + if (m_removeRequest && !m_removeRequest->isFinished()) + { + qDebug() << "RemoteTCPSink::~RemoteTCPSink: Waiting for remove listing request to finish"; + QEventLoop loop; + connect(m_removeRequest, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + } + + if (m_basebandSink->isRunning()) { + stop(); + } + QObject::disconnect( m_networkManager, &QNetworkAccessManager::finished, @@ -88,10 +109,6 @@ RemoteTCPSink::~RemoteTCPSink() m_deviceAPI->removeChannelSinkAPI(this); m_deviceAPI->removeChannelSink(this); - if (m_basebandSink->isRunning()) { - stop(); - } - m_basebandSink->deleteLater(); } @@ -130,6 +147,8 @@ void RemoteTCPSink::start() if (m_basebandSampleRate != 0) { m_basebandSink->setBasebandSampleRate(m_basebandSampleRate); } + + updatePublicListing(); } void RemoteTCPSink::stop() @@ -138,6 +157,9 @@ void RemoteTCPSink::stop() m_basebandSink->stopWork(); m_thread.quit(); m_thread.wait(); + if (m_settings.m_public) { + removePublicListing(m_settings.m_publicAddress, m_settings.m_publicPort); + } } bool RemoteTCPSink::handleMessage(const Message& cmd) @@ -146,7 +168,7 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) { MsgConfigureRemoteTCPSink& cfg = (MsgConfigureRemoteTCPSink&) cmd; qDebug() << "RemoteTCPSink::handleMessage: MsgConfigureRemoteTCPSink"; - applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRemoteChange()); + applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRestartRequired()); return true; } @@ -166,6 +188,28 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) return true; } + else if (MsgSendMessage::match(cmd)) + { + MsgSendMessage& msg = (MsgSendMessage&) cmd; + + // Forward to the sink + m_basebandSink->getInputMessageQueue()->push(MsgSendMessage::create(msg.getAddress(), msg.getPort(), msg.getCallsign(), msg.getText(), msg.getBroadcast())); + return true; + } + else if (MsgReportConnection::match(cmd)) + { + MsgReportConnection& msg = (MsgReportConnection&) cmd; + m_clients = msg.getClients(); + updatePublicListing(); + return true; + } + else if (MsgReportDisconnect::match(cmd)) + { + MsgReportDisconnect& msg = (MsgReportDisconnect&) cmd; + m_clients = msg.getClients(); + updatePublicListing(); + return true; + } else { return false; @@ -208,7 +252,7 @@ void RemoteTCPSink::setCenterFrequency(qint64 frequency) } } -void RemoteTCPSink::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool remoteChange) +void RemoteTCPSink::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool restartRequired) { qDebug() << "RemoteTCPSink::applySettings:" << " settingsKeys: " << settingsKeys @@ -221,7 +265,7 @@ void RemoteTCPSink::applySettings(const RemoteTCPSinkSettings& settings, const Q << " m_protocol: " << settings.m_protocol << " m_streamIndex: " << settings.m_streamIndex << " force: " << force - << " remoteChange: " << remoteChange; + << " restartRequired: " << restartRequired; if (settingsKeys.contains("streamIndex")) { @@ -236,7 +280,7 @@ void RemoteTCPSink::applySettings(const RemoteTCPSinkSettings& settings, const Q } } - MsgConfigureRemoteTCPSink *msg = MsgConfigureRemoteTCPSink::create(settings, settingsKeys, force, remoteChange); + MsgConfigureRemoteTCPSink *msg = MsgConfigureRemoteTCPSink::create(settings, settingsKeys, force, restartRequired); m_basebandSink->getInputMessageQueue()->push(msg); if (settings.m_useReverseAPI) @@ -256,11 +300,31 @@ void RemoteTCPSink::applySettings(const RemoteTCPSinkSettings& settings, const Q sendChannelSettings(pipes, settingsKeys, settings, force); } + // Do we need to remove old listing + bool removeListing = false; + if (m_settings.m_public) + { + if ((settingsKeys.contains("public") || force) && !settings.m_public) { + removeListing = true; + } + if ((settingsKeys.contains("publicAddress") || force) && (settings.m_publicAddress != m_settings.m_publicAddress)) { + removeListing = true; + } + if ((settingsKeys.contains("publicPort") || force) && (settings.m_publicPort != m_settings.m_publicPort)) { + removeListing = true; + } + } + if (removeListing) { + removePublicListing(m_settings.m_publicAddress, m_settings.m_publicPort); + } + if (force) { m_settings = settings; } else { m_settings.applySettings(settingsKeys, settings); } + + updatePublicListing(); } int RemoteTCPSink::webapiSettingsGet( @@ -571,6 +635,10 @@ void RemoteTCPSink::networkManagerFinished(QNetworkReply *reply) qDebug("RemoteTCPSink::networkManagerFinished: reply:\n%s", answer.toStdString().c_str()); } + if (reply == m_removeRequest) { + m_removeRequest = nullptr; + } + reply->deleteLater(); } @@ -586,3 +654,88 @@ void RemoteTCPSink::handleIndexInDeviceSetChanged(int index) .arg(index); m_basebandSink->setFifoLabel(fifoLabel); } + +void RemoteTCPSink::removePublicListing(const QString& address, quint16 port) +{ + QUrl url = QUrl("https://sdrangel.org/websdr/removedb.php"); + + QNetworkRequest request; + request.setUrl(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QJsonObject json; + json.insert("address", address); + json.insert("port", port); + + QJsonDocument doc(json); + QByteArray data = doc.toJson(); + + m_removeRequest = m_networkManager->post(request, data); +} + +void RemoteTCPSink::updatePublicListing() +{ + if (!m_settings.m_public || !m_thread.isRunning()) { + return; + } + + QUrl url = QUrl("https://sdrangel.org/websdr/updatedb.php"); + + QNetworkRequest request; + request.setUrl(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + // Get device position + float latitude, longitude, altitude; + + if (!ChannelWebAPIUtils::getDevicePosition(getDeviceSetIndex(), latitude, longitude, altitude)) + { + latitude = MainCore::instance()->getSettings().getLatitude(); + longitude = MainCore::instance()->getSettings().getLongitude(); + altitude = MainCore::instance()->getSettings().getAltitude(); + } + // FIXME: Optionally slightly obfuscate position + + // Get antenna direction + double azimuth = m_settings.m_azimuth; + double elevation = m_settings.m_elevation; + if (!m_settings.m_isotropic && !m_settings.m_rotator.isEmpty() && (m_settings.m_rotator != "None")) + { + unsigned int rotatorFeatureSetIndex; + unsigned int rotatorFeatureIndex; + + if (MainCore::getFeatureIndexFromId(m_settings.m_rotator, rotatorFeatureSetIndex, rotatorFeatureIndex)) + { + ChannelWebAPIUtils::getFeatureReportValue(rotatorFeatureSetIndex, rotatorFeatureIndex, "currentAzimuth", azimuth); + ChannelWebAPIUtils::getFeatureReportValue(rotatorFeatureSetIndex, rotatorFeatureIndex, "currentElevation", elevation); + } + } + + QString device = MainCore::instance()->getDevice(getDeviceSetIndex())->getHardwareId(); + + QJsonObject json; + json.insert("address", m_settings.m_publicAddress); + json.insert("port", m_settings.m_publicPort); + json.insert("minFrequency", m_settings.m_minFrequency); + json.insert("maxFrequency", m_settings.m_maxFrequency); + json.insert("maxSampleRate", m_settings.m_maxSampleRate); + json.insert("device", device); + json.insert("antenna", m_settings.m_antenna); + json.insert("remoteControl", (int) m_settings.m_remoteControl); + json.insert("stationName", MainCore::instance()->getSettings().getStationName()); + json.insert("location", m_settings.m_location); + json.insert("latitude", latitude); + json.insert("longitude", longitude); + json.insert("altitude", altitude); + json.insert("isotropic", (int) m_settings.m_isotropic); + json.insert("azimuth", azimuth); + json.insert("elevation", elevation); + json.insert("clients", m_clients); + json.insert("maxClients", m_settings.m_maxClients); + json.insert("timeLimit", m_settings.m_timeLimit); + + QJsonDocument doc(json); + QByteArray data = doc.toJson(); + + m_networkManager->post(request, data); +} diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.h b/plugins/channelrx/remotetcpsink/remotetcpsink.h index 34ce5a6d0d..f36923dd72 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.h @@ -42,25 +42,25 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { const RemoteTCPSinkSettings& getSettings() const { return m_settings; } const QList& getSettingsKeys() const { return m_settingsKeys; } bool getForce() const { return m_force; } - bool getRemoteChange() const { return m_remoteChange; } + bool getRestartRequired() const { return m_restartRequired; } - static MsgConfigureRemoteTCPSink* create(const RemoteTCPSinkSettings& settings, const QList& settingsKeys, bool force, bool remoteChange = false) + static MsgConfigureRemoteTCPSink* create(const RemoteTCPSinkSettings& settings, const QList& settingsKeys, bool force, bool restartRequired = false) { - return new MsgConfigureRemoteTCPSink(settings, settingsKeys, force, remoteChange); + return new MsgConfigureRemoteTCPSink(settings, settingsKeys, force, restartRequired); } private: RemoteTCPSinkSettings m_settings; QList m_settingsKeys; bool m_force; - bool m_remoteChange; // This change of settings was requested by a remote client, so no need to restart server + bool m_restartRequired; - MsgConfigureRemoteTCPSink(const RemoteTCPSinkSettings& settings, const QList& settingsKeys, bool force, bool remoteChange) : + MsgConfigureRemoteTCPSink(const RemoteTCPSinkSettings& settings, const QList& settingsKeys, bool force, bool restartRequired) : Message(), m_settings(settings), m_settingsKeys(settingsKeys), m_force(force), - m_remoteChange(remoteChange) + m_restartRequired(restartRequired) { } }; @@ -69,39 +69,117 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { public: int getClients() const { return m_clients; } + const QHostAddress& getAddress() const { return m_address; } + int getPort() const { return m_port; } - static MsgReportConnection* create(int clients) + + static MsgReportConnection* create(int clients, const QHostAddress& address, quint16 port) { - return new MsgReportConnection(clients); + return new MsgReportConnection(clients, address, port); } private: int m_clients; + QHostAddress m_address; + quint16 m_port; - MsgReportConnection(int clients) : + MsgReportConnection(int clients, const QHostAddress& address, quint16 port) : Message(), - m_clients(clients) + m_clients(clients), + m_address(address), + m_port(port) { } }; - // Message to report actual transmit bandwidth in bits per second + class MsgReportDisconnect : public Message { + MESSAGE_CLASS_DECLARATION + + public: + int getClients() const { return m_clients; } + const QHostAddress& getAddress() const { return m_address; } + quint16 getPort() const { return m_port; } + + static MsgReportDisconnect* create(int clients, const QHostAddress& address, quint16 port) + { + return new MsgReportDisconnect(clients, address, port); + } + + private: + int m_clients; + QHostAddress m_address; + quint16 m_port; + + MsgReportDisconnect(int clients, const QHostAddress& address, quint16 port) : + Message(), + m_clients(clients), + m_address(address), + m_port(port) + { } + }; + + // Message to report actual transmit bandwidth in bits per second and compression ratio class MsgReportBW : public Message { MESSAGE_CLASS_DECLARATION public: float getBW() const { return m_bw; } + float getNetworkBW() const { return m_networkBW; } + qint64 getBytesUncompressed() const { return m_bytesUncompressed; } + qint64 getBytesCompressed() const { return m_bytesCompressed; } + qint64 getBytesTransmitted() const { return m_bytesTransmitted; } - static MsgReportBW* create(float bw) + static MsgReportBW* create(float bw, float networkBW, qint64 bytesUncompressed, qint64 bytesCompressed, qint64 bytesTransmitted) { - return new MsgReportBW(bw); + return new MsgReportBW(bw, networkBW, bytesUncompressed, bytesCompressed, bytesTransmitted); } private: float m_bw; + float m_networkBW; + qint64 m_bytesUncompressed; + qint64 m_bytesCompressed; + qint64 m_bytesTransmitted; + + MsgReportBW(float bw, float networkBW, qint64 bytesUncompressed, qint64 bytesCompressed, qint64 bytesTransmitted) : + Message(), + m_bw(bw), + m_networkBW(networkBW), + m_bytesUncompressed(bytesUncompressed), + m_bytesCompressed(bytesCompressed), + m_bytesTransmitted(bytesTransmitted) + { } + }; + + // Send a text message to a client (or received message from client) + class MsgSendMessage : public Message { + MESSAGE_CLASS_DECLARATION - MsgReportBW(float bw) : + public: + QHostAddress getAddress() const { return m_address; } + quint16 getPort() const { return m_port; } + const QString& getCallsign() const { return m_callsign; } + const QString& getText() const { return m_text; } + bool getBroadcast() const { return m_broadcast; } + + static MsgSendMessage* create(QHostAddress address, quint16 port, const QString& callsign, const QString& text, bool broadcast) + { + return new MsgSendMessage(address, port, callsign, text, broadcast); + } + + private: + QHostAddress m_address; + quint16 m_port; + QString m_callsign; + QString m_text; + bool m_broadcast; + + MsgSendMessage(QHostAddress address, quint16 port, const QString& callsign, const QString& text, bool broadcast) : Message(), - m_bw(bw) + m_address(address), + m_port(port), + m_callsign(callsign), + m_text(text), + m_broadcast(broadcast) { } }; @@ -164,6 +242,16 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { uint32_t getNumberOfDeviceStreams() const; int getBasebandSampleRate() const { return m_basebandSampleRate; } + bool getSquelchOpen() const { return m_basebandSink && m_basebandSink->getSquelchOpen(); } + + void getMagSqLevels(double& avg, double& peak, int& nbSamples) + { + if (m_basebandSink) { + m_basebandSink->getMagSqLevels(avg, peak, nbSamples); + } else { + avg = 0.0; peak = 0.0; nbSamples = 1; + } + } void setMessageQueueToGUI(MessageQueue* queue) final { ChannelAPI::setMessageQueueToGUI(queue); m_basebandSink->setMessageQueueToGUI(queue); @@ -183,8 +271,11 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { QNetworkAccessManager *m_networkManager; QNetworkRequest m_networkRequest; + int m_clients; // Number of clients currently connected + QNetworkReply *m_removeRequest; + virtual bool handleMessage(const Message& cmd); - void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool remoteChange = false); + void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool restartRequired = false); void webapiReverseSendSettings(const QStringList& channelSettingsKeys, const RemoteTCPSinkSettings& settings, bool force); void sendChannelSettings( const QList& pipes, @@ -198,6 +289,8 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { const RemoteTCPSinkSettings& settings, bool force ); + void removePublicListing(const QString& address, quint16 port); + void updatePublicListing(); private slots: void networkManagerFinished(QNetworkReply *reply); diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp index c23ce09857..95373af9b7 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp @@ -128,7 +128,7 @@ bool RemoteTCPSinkBaseband::handleMessage(const Message& cmd) RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (RemoteTCPSink::MsgConfigureRemoteTCPSink&) cmd; qDebug() << "RemoteTCPSinkBaseband::handleMessage: MsgConfigureRemoteTCPSink"; - applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRemoteChange()); + applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRestartRequired()); return true; } @@ -141,13 +141,21 @@ bool RemoteTCPSinkBaseband::handleMessage(const Message& cmd) return true; } + else if (RemoteTCPSink::MsgSendMessage::match(cmd)) + { + RemoteTCPSink::MsgSendMessage& msg = (RemoteTCPSink::MsgSendMessage&) cmd; + + m_sink.sendMessage(msg.getAddress(), msg.getPort(), msg.getCallsign(), msg.getText(), msg.getBroadcast()); + + return true; + } else { return false; } } -void RemoteTCPSinkBaseband::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool remoteChange) +void RemoteTCPSinkBaseband::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool restartRequired) { qDebug() << "RemoteTCPSinkBaseband::applySettings:" << "m_channelSampleRate:" << settings.m_channelSampleRate @@ -160,7 +168,7 @@ void RemoteTCPSinkBaseband::applySettings(const RemoteTCPSinkSettings& settings, m_sink.applyChannelSettings(m_channelizer->getChannelSampleRate(), m_channelizer->getChannelFrequencyOffset()); } - m_sink.applySettings(settings, settingsKeys, force, remoteChange); + m_sink.applySettings(settings, settingsKeys, force, restartRequired); if (force) { m_settings = settings; } else { diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.h b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.h index 88ffe7337f..a3710b6c4e 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.h @@ -47,6 +47,8 @@ class RemoteTCPSinkBaseband : public QObject MessageQueue *getInputMessageQueue() { return &m_inputMessageQueue; } //!< Get the queue for asynchronous inbound communication int getChannelSampleRate() const; + void getMagSqLevels(double& avg, double& peak, int& nbSamples) { m_sink.getMagSqLevels(avg, peak, nbSamples); } + bool getSquelchOpen() const { return m_sink.getSquelchOpen(); } void setMessageQueueToGUI(MessageQueue *messageQueue) { m_sink.setMessageQueueToGUI(messageQueue); } void setMessageQueueToChannel(MessageQueue *messageQueue) { m_sink.setMessageQueueToChannel(messageQueue); } void setBasebandSampleRate(int sampleRate); @@ -64,7 +66,7 @@ class RemoteTCPSinkBaseband : public QObject QRecursiveMutex m_mutex; bool handleMessage(const Message& cmd); - void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool remoteChange = false); + void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool restartRequired = false); private slots: void handleInputMessages(); diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp index 3f3ca8ae6e..09fabb8702 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2022-2023 Jon Beniston, M7RCE // +// Copyright (C) 2022-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 // @@ -15,19 +15,25 @@ // along with this program. If not, see . // /////////////////////////////////////////////////////////////////////////////////// -#include #include #include +#include #include "device/deviceuiset.h" #include "gui/basicchannelsettingsdialog.h" #include "gui/dialpopup.h" #include "gui/dialogpositioner.h" +#include "gui/perioddial.h" #include "dsp/dspcommands.h" +#include "util/db.h" +#include "maincore.h" #include "remotetcpsinkgui.h" #include "remotetcpsink.h" #include "ui_remotetcpsinkgui.h" +#include "remotetcpsinksettingsdialog.h" + +const QString RemoteTCPSinkGUI::m_dateTimeFormat = "yyyy.MM.dd hh:mm:ss"; RemoteTCPSinkGUI* RemoteTCPSinkGUI::create(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *channelRx) { @@ -102,11 +108,74 @@ QString RemoteTCPSinkGUI::displayScaledF(float value, char type, int precision, } } +void RemoteTCPSinkGUI::resizeTable() +{ + QDateTime dateTime = QDateTime::currentDateTime(); + QString dateTimeString = dateTime.toString(m_dateTimeFormat); + int row = ui->connections->rowCount(); + ui->connections->setRowCount(row + 1); + ui->connections->setItem(row, CONNECTIONS_COL_ADDRESS, new QTableWidgetItem("255.255.255.255")); + ui->connections->setItem(row, CONNECTIONS_COL_PORT, new QTableWidgetItem("65535")); + ui->connections->setItem(row, CONNECTIONS_COL_CONNECTED, new QTableWidgetItem(dateTimeString)); + ui->connections->setItem(row, CONNECTIONS_COL_DISCONNECTED, new QTableWidgetItem(dateTimeString)); + ui->connections->setItem(row, CONNECTIONS_COL_TIME, new QTableWidgetItem("1000 d")); + ui->connections->resizeColumnsToContents(); + ui->connections->removeRow(row); +} + +void RemoteTCPSinkGUI::addConnection(const QHostAddress& address, int port) +{ + QDateTime dateTime = QDateTime::currentDateTime(); + + int row = ui->connections->rowCount(); + ui->connections->setRowCount(row + 1); + + ui->connections->setItem(row, CONNECTIONS_COL_ADDRESS, new QTableWidgetItem(address.toString())); + ui->connections->setItem(row, CONNECTIONS_COL_PORT, new QTableWidgetItem(QString::number(port))); + ui->connections->setItem(row, CONNECTIONS_COL_CONNECTED, new QTableWidgetItem(dateTime.toString(m_dateTimeFormat))); + ui->connections->setItem(row, CONNECTIONS_COL_DISCONNECTED, new QTableWidgetItem("")); + ui->connections->setItem(row, CONNECTIONS_COL_TIME, new QTableWidgetItem("")); +} + +void RemoteTCPSinkGUI::removeConnection(const QHostAddress& address, int port) +{ + QString addressString = address.toString(); + QString portString = QString::number(port); + + for (int row = 0; row < ui->connections->rowCount(); row++) + { + if ((ui->connections->item(row, CONNECTIONS_COL_ADDRESS)->text() == addressString) + && (ui->connections->item(row, CONNECTIONS_COL_PORT)->text() == portString) + && (ui->connections->item(row, CONNECTIONS_COL_DISCONNECTED)->text().isEmpty())) + { + QDateTime connected = QDateTime::fromString(ui->connections->item(row, CONNECTIONS_COL_CONNECTED)->text(), m_dateTimeFormat); + QDateTime disconnected = QDateTime::currentDateTime(); + QString dateTimeString = disconnected.toString(m_dateTimeFormat); + QString time; + int secs = connected.secsTo(disconnected); + if (secs < 60) { + time = QString("%1 s").arg(secs); + } else if (secs < 60 * 60) { + time = QString("%1 m").arg(secs / 60); + } else if (secs < 60 * 60 * 24) { + time = QString("%1 h").arg(secs / 60 / 60); + } else { + time = QString("%1 d").arg(secs / 60 / 60 / 24); + } + + ui->connections->item(row, CONNECTIONS_COL_DISCONNECTED)->setText(dateTimeString); + ui->connections->item(row, CONNECTIONS_COL_TIME)->setText(time); + break; + } + } +} + bool RemoteTCPSinkGUI::handleMessage(const Message& message) { if (RemoteTCPSink::MsgConfigureRemoteTCPSink::match(message)) { const RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (RemoteTCPSink::MsgConfigureRemoteTCPSink&) message; + if ((cfg.getSettings().m_channelSampleRate != m_settings.m_channelSampleRate) || (cfg.getSettings().m_sampleBits != m_settings.m_sampleBits)) { m_bwAvg.reset(); @@ -126,14 +195,52 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) else if (RemoteTCPSink::MsgReportConnection::match(message)) { const RemoteTCPSink::MsgReportConnection& report = (RemoteTCPSink::MsgReportConnection&) message; - ui->clients->setText(QString("%1").arg(report.getClients())); + + ui->clients->setText(QString("%1/%2").arg(report.getClients()).arg(m_settings.m_maxClients)); + QString ip = QString("%1:%2").arg(report.getAddress().toString()).arg(report.getPort()); + if (ui->txAddress->findText(ip) == -1) { + ui->txAddress->addItem(ip); + } + addConnection(report.getAddress(), report.getPort()); + + return true; + } + else if (RemoteTCPSink::MsgReportDisconnect::match(message)) + { + const RemoteTCPSink::MsgReportDisconnect& report = (RemoteTCPSink::MsgReportDisconnect&) message; + + ui->clients->setText(QString("%1/%2").arg(report.getClients()).arg(m_settings.m_maxClients)); + QString ip = QString("%1:%2").arg(report.getAddress().toString()).arg(report.getPort()); + int idx = ui->txAddress->findText(ip); + if (idx != -1) { + ui->txAddress->removeItem(idx); + } + removeConnection(report.getAddress(), report.getPort()); + return true; } else if (RemoteTCPSink::MsgReportBW::match(message)) { const RemoteTCPSink::MsgReportBW& report = (RemoteTCPSink::MsgReportBW&) message; + m_bwAvg(report.getBW()); - ui->bw->setText(QString("%1bps").arg(displayScaledF(m_bwAvg.instantAverage(), 'f', 3, true))); + m_networkBWAvg(report.getNetworkBW()); + + QString text = QString("%1bps").arg(displayScaledF(m_bwAvg.instantAverage(), 'f', 1, true)); + + if (!m_settings.m_iqOnly && (report.getBytesUncompressed() > 0)) + { + float compressionSaving = 1.0f - (report.getBytesCompressed() / (float) report.getBytesUncompressed()); + m_compressionAvg(compressionSaving); + + QString compressionText = QString(" %1%").arg((int) std::round(m_compressionAvg.instantAverage() * 100.0f)); + text.append(compressionText); + } + + QString networkBWText = QString(" %1bps").arg(displayScaledF(m_networkBWAvg.instantAverage(), 'f', 1, true)); + text.append(networkBWText); + + ui->bw->setText(text); return true; } else if (DSPSignalNotification::match(message)) @@ -150,6 +257,25 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) return true; } + else if (RemoteTCPSink::MsgSendMessage::match(message)) + { + RemoteTCPSink::MsgSendMessage& msg = (RemoteTCPSink::MsgSendMessage&) message; + QString address = QString("%1:%2").arg(msg.getAddress().toString()).arg(msg.getPort()); + QString callsign = msg.getCallsign(); + QString text = msg.getText(); + bool broadcast = msg.getBroadcast(); + + // Display received message in GUI + ui->messages->addItem(QString("%1/%2> %3").arg(address).arg(callsign).arg(text)); + ui->messages->scrollToBottom(); + + // Forward to other clients + if (broadcast) { + m_remoteSink->getInputMessageQueue()->push(RemoteTCPSink::MsgSendMessage::create(msg.getAddress(), msg.getPort(), callsign, text, broadcast)); + } + + return true; + } else { return false; @@ -157,12 +283,14 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } RemoteTCPSinkGUI::RemoteTCPSinkGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *channelrx, QWidget* parent) : - ChannelGUI(parent), - ui(new Ui::RemoteTCPSinkGUI), - m_pluginAPI(pluginAPI), - m_deviceUISet(deviceUISet), - m_basebandSampleRate(0), - m_deviceCenterFrequency(0) + ChannelGUI(parent), + ui(new Ui::RemoteTCPSinkGUI), + m_pluginAPI(pluginAPI), + m_deviceUISet(deviceUISet), + m_basebandSampleRate(0), + m_deviceCenterFrequency(0), + m_tickCount(0), + m_squelchOpen(false) { setAttribute(Qt::WA_DeleteOnClose, true); m_helpURL = "plugins/channelrx/remotetcpsink/readme.md"; @@ -177,6 +305,8 @@ RemoteTCPSinkGUI::RemoteTCPSinkGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISe m_remoteSink->setMessageQueueToGUI(getInputMessageQueue()); m_basebandSampleRate = m_remoteSink->getBasebandSampleRate(); + connect(&MainCore::instance()->getMasterTimer(), SIGNAL(timeout()), this, SLOT(tick())); + m_channelMarker.blockSignals(true); m_channelMarker.setColor(m_settings.m_rgbColor); m_channelMarker.setCenterFrequency(0); @@ -189,12 +319,17 @@ RemoteTCPSinkGUI::RemoteTCPSinkGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISe m_deviceUISet->addChannelMarker(&m_channelMarker); + ui->txAddress->clear(); + ui->txAddress->addItem("All"); + ui->channelSampleRate->setColorMapper(ColorMapper(ColorMapper::GrayGreenYellow)); ui->channelSampleRate->setValueRange(8, 0, 99999999); ui->deltaFrequencyLabel->setText(QString("%1f").arg(QChar(0x94, 0x03))); ui->deltaFrequency->setColorMapper(ColorMapper(ColorMapper::GrayGold)); ui->deltaFrequency->setValueRange(false, 7, -9999999, 9999999); + ui->channelPowerMeter->setColorTheme(LevelMeterSignalDB::ColorGreenAndBlue); +#ifndef __EMSCRIPTEN__ // Add all IP addresses for (const QHostAddress& address: QNetworkInterface::allAddresses()) { @@ -202,11 +337,14 @@ RemoteTCPSinkGUI::RemoteTCPSinkGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISe ui->dataAddress->addItem(address.toString()); } } +#endif connect(&m_channelMarker, SIGNAL(changedByCursor()), this, SLOT(channelMarkerChangedByCursor())); connect(&m_channelMarker, SIGNAL(highlightedByCursor()), this, SLOT(channelMarkerHighlightedByCursor())); connect(getInputMessageQueue(), SIGNAL(messageEnqueued()), this, SLOT(handleSourceMessages())); + resizeTable(); + displaySettings(); makeUIConnections(); applyAllSettings(); @@ -269,12 +407,39 @@ void RemoteTCPSinkGUI::displaySettings() ui->dataAddress->addItem(m_settings.m_dataAddress); } ui->dataAddress->setCurrentText(m_settings.m_dataAddress); - ui->dataPort->setText(tr("%1").arg(m_settings.m_dataPort)); + ui->dataPort->setValue(m_settings.m_dataPort); ui->protocol->setCurrentIndex((int)m_settings.m_protocol); + ui->remoteControl->setChecked(m_settings.m_remoteControl); + ui->squelchEnabled->setChecked(m_settings.m_squelchEnabled); + displayIQOnly(); + displaySquelch(); getRollupContents()->restoreState(m_rollupState); blockApplySettings(false); } +void RemoteTCPSinkGUI::displayIQOnly() +{ + ui->messagesLayout->setEnabled(!m_settings.m_iqOnly); + ui->sendMessage->setEnabled(!m_settings.m_iqOnly); + ui->txAddress->setEnabled(!m_settings.m_iqOnly); + ui->txMessage->setEnabled(!m_settings.m_iqOnly); + ui->messagesContainer->setVisible(!m_settings.m_iqOnly); +} + +void RemoteTCPSinkGUI::displaySquelch() +{ + ui->squelch->setValue(m_settings.m_squelch); + ui->squelchText->setText(QString::number(m_settings.m_squelch)); + ui->squelch->setEnabled(m_settings.m_squelchEnabled); + ui->squelchText->setEnabled(m_settings.m_squelchEnabled); + ui->squelchUnits->setEnabled(m_settings.m_squelchEnabled); + + ui->squelchGate->setValue(m_settings.m_squelchGate); + ui->squelchGate->setEnabled(m_settings.m_squelchEnabled); + + ui->audioMute->setEnabled(m_settings.m_squelchEnabled); +} + void RemoteTCPSinkGUI::displayRateAndShift() { m_channelMarker.setCenterFrequency(m_settings.m_inputFrequencyOffset); @@ -430,27 +595,114 @@ void RemoteTCPSinkGUI::on_dataAddress_currentIndexChanged(int index) applySetting("dataAddress"); } -void RemoteTCPSinkGUI::on_dataPort_editingFinished() +void RemoteTCPSinkGUI::on_dataPort_valueChanged(int value) { - bool dataOk; - int dataPort = ui->dataPort->text().toInt(&dataOk); + m_settings.m_dataPort = value; + applySetting("dataPort"); +} - if((!dataOk) || (dataPort < 1024) || (dataPort > 65535)) +void RemoteTCPSinkGUI::on_protocol_currentIndexChanged(int index) +{ + m_settings.m_protocol = (RemoteTCPSinkSettings::Protocol)index; + applySetting("protocol"); +} + +void RemoteTCPSinkGUI::on_remoteControl_toggled(bool checked) +{ + m_settings.m_remoteControl = checked; + applySetting("remoteControl"); +} + +void RemoteTCPSinkGUI::on_squelchEnabled_toggled(bool checked) +{ + m_settings.m_squelchEnabled = checked; + applySetting("squelchEnabled"); + displaySquelch(); +} + +void RemoteTCPSinkGUI::on_squelch_valueChanged(int value) +{ + m_settings.m_squelch = value; + ui->squelchText->setText(QString::number(m_settings.m_squelch)); + applySetting("squelch"); +} + +void RemoteTCPSinkGUI::on_squelchGate_valueChanged(double value) +{ + m_settings.m_squelchGate = value; + applySetting("squelchGate"); +} + +void RemoteTCPSinkGUI::on_displaySettings_clicked() +{ + RemoteTCPSinkSettingsDialog dialog(&m_settings); + + new DialogPositioner(&dialog, true); + if (dialog.exec() == QDialog::Accepted) { - return; + applySettings(dialog.getSettingsKeys()); + displayIQOnly(); } - else +} + +void RemoteTCPSinkGUI::on_sendMessage_clicked() +{ + QString message = ui->txMessage->text().trimmed(); + if (!message.isEmpty()) { - m_settings.m_dataPort = dataPort; + ui->messages->addItem(QString("< %1").arg(message)); + ui->messages->scrollToBottom(); + bool broadcast = ui->txAddress->currentText() == "All"; + QHostAddress address; + quint16 port = 0; + if (!broadcast) + { + QStringList parts = ui->txAddress->currentText().split(':'); + address = QHostAddress(parts[0]); + port = parts[1].toInt(); + } + QString callsign = MainCore::instance()->getSettings().getStationName(); + m_remoteSink->getInputMessageQueue()->push(RemoteTCPSink::MsgSendMessage::create(address, port, callsign, message, broadcast)); } +} - applySetting("dataPort"); +void RemoteTCPSinkGUI::on_txMessage_returnPressed() +{ + on_sendMessage_clicked(); + ui->txMessage->selectAll(); } -void RemoteTCPSinkGUI::on_protocol_currentIndexChanged(int index) +void RemoteTCPSinkGUI::tick() { - m_settings.m_protocol = (RemoteTCPSinkSettings::Protocol)index; - applySetting("protocol"); + double magsqAvg, magsqPeak; + int nbMagsqSamples; + m_remoteSink->getMagSqLevels(magsqAvg, magsqPeak, nbMagsqSamples); + double powDbAvg = CalcDb::dbPower(magsqAvg); + double powDbPeak = CalcDb::dbPower(magsqPeak); + + ui->channelPowerMeter->levelChanged( + (100.0f + powDbAvg) / 100.0f, + (100.0f + powDbPeak) / 100.0f, + nbMagsqSamples); + + if (m_tickCount % 4 == 0) { + ui->channelPower->setText(tr("%1").arg(powDbAvg, 0, 'f', 1)); + } + + bool squelchOpen = m_remoteSink->getSquelchOpen() || !m_settings.m_squelchEnabled; + + if (squelchOpen != m_squelchOpen) + { + /*if (squelchOpen) { + ui->audioMute->setStyleSheet("QToolButton { background-color : green; }"); + } else { + ui->audioMute->setStyleSheet("QToolButton { background:rgb(79,79,79); }"); + }*/ + ui->audioMute->setChecked(!squelchOpen); + m_squelchOpen = squelchOpen; + } + + m_tickCount++; } void RemoteTCPSinkGUI::makeUIConnections() @@ -461,8 +713,15 @@ void RemoteTCPSinkGUI::makeUIConnections() QObject::connect(ui->sampleBits, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPSinkGUI::on_sampleBits_currentIndexChanged); QObject::connect(ui->dataAddress->lineEdit(), &QLineEdit::editingFinished, this, &RemoteTCPSinkGUI::on_dataAddress_editingFinished); QObject::connect(ui->dataAddress, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPSinkGUI::on_dataAddress_currentIndexChanged); - QObject::connect(ui->dataPort, &QLineEdit::editingFinished, this, &RemoteTCPSinkGUI::on_dataPort_editingFinished); + QObject::connect(ui->dataPort, QOverload::of(&QSpinBox::valueChanged), this, &RemoteTCPSinkGUI::on_dataPort_valueChanged); QObject::connect(ui->protocol, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPSinkGUI::on_protocol_currentIndexChanged); + QObject::connect(ui->remoteControl, &ButtonSwitch::toggled, this, &RemoteTCPSinkGUI::on_remoteControl_toggled); + QObject::connect(ui->squelchEnabled, &ButtonSwitch::toggled, this, &RemoteTCPSinkGUI::on_squelchEnabled_toggled); + QObject::connect(ui->squelch, &QDial::valueChanged, this, &RemoteTCPSinkGUI::on_squelch_valueChanged); + QObject::connect(ui->squelchGate, &PeriodDial::valueChanged, this, &RemoteTCPSinkGUI::on_squelchGate_valueChanged); + QObject::connect(ui->displaySettings, &QToolButton::clicked, this, &RemoteTCPSinkGUI::on_displaySettings_clicked); + QObject::connect(ui->sendMessage, &QToolButton::clicked, this, &RemoteTCPSinkGUI::on_sendMessage_clicked); + QObject::connect(ui->txMessage, &QLineEdit::returnPressed, this, &RemoteTCPSinkGUI::on_txMessage_returnPressed); } void RemoteTCPSinkGUI::updateAbsoluteCenterFrequency() diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.h b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.h index 1dc58bd614..c44933b265 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.h @@ -25,6 +25,7 @@ #include #include +#include #include "dsp/channelmarker.h" #include "channel/channelgui.h" @@ -82,9 +83,23 @@ public slots: bool m_doApplySettings; RemoteTCPSink* m_remoteSink; + uint32_t m_tickCount; + bool m_squelchOpen; MessageQueue m_inputMessageQueue; MovingAverageUtil m_bwAvg; + MovingAverageUtil m_compressionAvg; + MovingAverageUtil m_networkBWAvg; + + enum ConnectionsCol { + CONNECTIONS_COL_ADDRESS, + CONNECTIONS_COL_PORT, + CONNECTIONS_COL_CONNECTED, + CONNECTIONS_COL_DISCONNECTED, + CONNECTIONS_COL_TIME + }; + + static const QString m_dateTimeFormat; explicit RemoteTCPSinkGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel, QWidget* parent = 0); virtual ~RemoteTCPSinkGUI(); @@ -94,11 +109,16 @@ public slots: void applySettings(const QStringList& settingsKeys, bool force = false); void applyAllSettings(); void displaySettings(); + void displayIQOnly(); + void displaySquelch(); void displayRateAndShift(); bool handleMessage(const Message& message); void makeUIConnections(); QString displayScaledF(float value, char type, int precision, bool showMult); void updateAbsoluteCenterFrequency(); + void resizeTable(); + void addConnection(const QHostAddress& address, int port); + void removeConnection(const QHostAddress& address, int port); void leaveEvent(QEvent*); void enterEvent(EnterEventType*); @@ -111,10 +131,18 @@ private slots: void on_sampleBits_currentIndexChanged(int index); void on_dataAddress_editingFinished(); void on_dataAddress_currentIndexChanged(int index); - void on_dataPort_editingFinished(); + void on_dataPort_valueChanged(int value); void on_protocol_currentIndexChanged(int index); + void on_remoteControl_toggled(bool checked); + void on_squelchEnabled_toggled(bool checked); + void on_squelch_valueChanged(int value); + void on_squelchGate_valueChanged(double value); + void on_displaySettings_clicked(); + void on_sendMessage_clicked(); + void on_txMessage_returnPressed(); void onWidgetRolled(QWidget* widget, bool rollDown); void onMenuDialogCalled(const QPoint& p); + void tick(); }; #endif /* PLUGINS_CHANNELRX_REMOTETCPSINK_REMOTETCPSINKGUI_H_ */ diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui index 28963c6be4..72308406ef 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui @@ -7,7 +7,7 @@ 0 0 360 - 147 + 646 @@ -24,7 +24,7 @@ - 560 + 1000 16777215 @@ -41,8 +41,8 @@ 0 0 - 340 - 141 + 361 + 191 @@ -174,8 +174,217 @@ + + + + Qt::Vertical + + + + + + + + 30 + 0 + + + + Channel power + + + Qt::RightToLeft + + + 0.0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + dB + + + + + + + + + + + dB + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + Liberation Mono + 8 + + + + Level meter (dB) top trace: average, bottom trace: instantaneous peak, tip: peak hold + + + + + + + + + + + Check to enable IQ squelch + + + SQ + + + + + + + Qt::Vertical + + + + + + + + 24 + 24 + + + + IQ squelch power level in dB + + + -150 + + + 0 + + + 1 + + + + + + + + 32 + 0 + + + + -150 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + dB + + + + + + + Qt::Vertical + + + + + + + + 40 + 0 + + + + IQ squelch gate time + + + + + + + Qt::Vertical + + + + + + + false + + + Indicates when IQ squelch is open + + + ... + + + + :/sound_on.png + :/sound_off.png:/sound_on.png + + + true + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + @@ -325,21 +534,15 @@ - - - - 50 - 16777215 - - + TCP port for server to listen for connections on - - 00000 + + 1024 - - 0 + + 65535 @@ -365,9 +568,18 @@ + + + 80 + 0 + + Protocol to transmit data with + + 0 + RTL0 @@ -378,12 +590,41 @@ SDRA + + + SDRA wss + + + + + + + + Display settings dialog + + + + + + + :/listing.png:/listing.png + + + + + Allow remote control + + + RC + + + @@ -407,10 +648,10 @@ - Number of clients connected + Number of clients connected / maximum clients - 0 + 0/0 @@ -431,10 +672,14 @@ - Transmit bandwidth for a single TCP connection averaged over the last 10 seconds in bits per second + - Channel IQ bandwidth in bits per second +- Compression saving in percent +- Total outgoing network bandwidth in bits per second + +Values are averaged over the last 10 seconds - 0.000Mbps + 0.0Mbps 0% 0.0Mbps @@ -442,8 +687,151 @@ + + + + 0 + 200 + 351 + 191 + + + + Messages + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + TX + + + + + + + + 140 + 0 + + + + + 127.127.127.127:1234 + + + + + + + + + + + + + + + + + + + + + + 0 + 410 + 351 + 191 + + + + Connection Log + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Address + + + IP address of client + + + + + Port + + + TCP port number of client + + + + + Connected + + + Date and time client connected + + + + + Disconnected + + + Date and time client disconnected + + + + + Time + + + Time client was connected for + + + + + + + + + + ButtonSwitch + QToolButton +
gui/buttonswitch.h
+
RollupContents QWidget @@ -456,12 +844,24 @@
gui/valuedialz.h
1
+ + LevelMeterSignalDB + QWidget +
gui/levelmeter.h
+ 1 +
ValueDial QWidget
gui/valuedial.h
1
+ + PeriodDial + QWidget +
gui/perioddial.h
+ 1 +
diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettings.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksettings.cpp index 19aeb6d26e..3eabba5c4a 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksettings.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettings.cpp @@ -38,6 +38,31 @@ void RemoteTCPSinkSettings::resetToDefaults() m_dataAddress = "0.0.0.0"; m_dataPort = 1234; m_protocol = SDRA; + m_iqOnly = false; + m_compression = FLAC; + m_compressionLevel = 5; + m_blockSize = 16384; + m_squelchEnabled = false; + m_squelch = -100.0f; + m_squelchGate = 0.001f; + m_remoteControl = true; + m_maxClients = 4; + m_timeLimit = 0; + m_maxSampleRate = 10000000; + m_certificate = ""; + m_key = ""; + m_public = false; + m_publicAddress = ""; + m_publicPort = 1234; + m_minFrequency = 0; + m_maxFrequency = 2000000000; + m_antenna = ""; + m_location = ""; + m_ipBlacklist = QStringList(); + m_isotropic = true; + m_azimuth = 0.0f; + m_elevation = 0.0f; + m_rotator = "None"; m_rgbColor = QColor(140, 4, 4).rgb(); m_title = "Remote TCP sink"; m_channelMarker = nullptr; @@ -62,6 +87,33 @@ QByteArray RemoteTCPSinkSettings::serialize() const s.writeString(5, m_dataAddress); s.writeU32(6, m_dataPort); s.writeS32(7, (int)m_protocol); + s.writeBool(42, m_iqOnly); + s.writeS32(29, m_compression); + s.writeS32(38, m_compressionLevel); + s.writeS32(39, m_blockSize); + s.writeBool(40, m_squelchEnabled); + s.writeFloat(41, m_squelch); + s.writeFloat(43, m_squelchGate); + s.writeBool(23, m_remoteControl); + s.writeS32(24, m_maxClients); + s.writeS32(25, m_timeLimit); + s.writeS32(28, m_maxSampleRate); + s.writeString(26, m_certificate); + s.writeString(27, m_key); + + s.writeBool(30, m_public); + s.writeString(31, m_publicAddress); + s.writeS32(32, m_publicPort); + s.writeS64(33, m_minFrequency); + s.writeS64(34, m_maxFrequency); + s.writeString(35, m_antenna); + s.writeString(37, m_location); + s.writeList(36, m_ipBlacklist); + s.writeBool(44, m_isotropic); + s.writeFloat(45, m_azimuth); + s.writeFloat(46, m_elevation); + s.writeString(47, m_rotator); + s.writeU32(8, m_rgbColor); s.writeString(9, m_title); s.writeBool(10, m_useReverseAPI); @@ -115,6 +167,33 @@ bool RemoteTCPSinkSettings::deserialize(const QByteArray& data) m_dataPort = 1234; } d.readS32(7, (int *)&m_protocol, (int)SDRA); + d.readBool(42, &m_iqOnly, false); + d.readS32(29, (int *)&m_compression, (int)FLAC); + d.readS32(38, &m_compressionLevel, 5); + d.readS32(39, &m_blockSize, 16384); + d.readBool(40, &m_squelchEnabled, false); + d.readFloat(41, &m_squelch, -100.0f); + d.readFloat(43, &m_squelchGate, 0.001f); + d.readBool(23, &m_remoteControl, true); + d.readS32(24, &m_maxClients, 4); + d.readS32(25, &m_timeLimit, 0); + d.readS32(28, &m_maxSampleRate, 10000000); + + d.readString(26, &m_certificate, ""); + d.readString(27, &m_key, ""); + + d.readBool(30, &m_public, false); + d.readString(31, &m_publicAddress, ""); + d.readS32(32, &m_publicPort, 1234); + d.readS64(33, &m_minFrequency, 0); + d.readS64(34, &m_maxFrequency, 2000000000); + d.readString(35, &m_antenna, ""); + d.readString(37, &m_location, ""); + d.readList(36, &m_ipBlacklist); + d.readBool(44, &m_isotropic, true); + d.readFloat(45, &m_azimuth, 0.0f); + d.readFloat(46, &m_elevation, 0.0f); + d.readString(47, &m_rotator, "None"); d.readU32(8, &m_rgbColor, QColor(0, 255, 255).rgb()); d.readString(9, &m_title, "Remote TCP sink"); @@ -182,6 +261,78 @@ void RemoteTCPSinkSettings::applySettings(const QStringList& settingsKeys, const if (settingsKeys.contains("protocol")) { m_protocol = settings.m_protocol; } + if (settingsKeys.contains("iqOnly")) { + m_iqOnly = settings.m_iqOnly; + } + if (settingsKeys.contains("compression")) { + m_compression = settings.m_compression; + } + if (settingsKeys.contains("compressionLevel")) { + m_compressionLevel = settings.m_compressionLevel; + } + if (settingsKeys.contains("blockSize")) { + m_blockSize = settings.m_blockSize; + } + if (settingsKeys.contains("squelchEnabled")) { + m_squelchEnabled = settings.m_squelchEnabled; + } + if (settingsKeys.contains("squelch")) { + m_squelch = settings.m_squelch; + } + if (settingsKeys.contains("squelchGate")) { + m_squelchGate = settings.m_squelchGate; + } + if (settingsKeys.contains("remoteControl")) { + m_remoteControl = settings.m_remoteControl; + } + if (settingsKeys.contains("maxClients")) { + m_maxClients = settings.m_maxClients; + } + if (settingsKeys.contains("timeLimit")) { + m_timeLimit = settings.m_timeLimit; + } + if (settingsKeys.contains("maxSampleRate")) { + m_maxSampleRate = settings.m_maxSampleRate; + } + if (settingsKeys.contains("certificate")) { + m_certificate = settings.m_certificate; + } + if (settingsKeys.contains("key")) { + m_key = settings.m_key; + } + if (settingsKeys.contains("public")) { + m_public = settings.m_public; + } + if (settingsKeys.contains("publicAddress")) { + m_publicAddress = settings.m_publicAddress; + } + if (settingsKeys.contains("publicPort")) { + m_publicPort = settings.m_publicPort; + } + if (settingsKeys.contains("minFrequency")) { + m_minFrequency = settings.m_minFrequency; + } + if (settingsKeys.contains("maxFrequency")) { + m_maxFrequency = settings.m_maxFrequency; + } + if (settingsKeys.contains("antenna")) { + m_antenna = settings.m_antenna; + } + if (settingsKeys.contains("ipBlacklist")) { + m_ipBlacklist = settings.m_ipBlacklist; + } + if (settingsKeys.contains("isotrophic")) { + m_isotropic = settings.m_isotropic; + } + if (settingsKeys.contains("azimuth")) { + m_azimuth = settings.m_azimuth; + } + if (settingsKeys.contains("elevation")) { + m_elevation = settings.m_elevation; + } + if (settingsKeys.contains("rotator")) { + m_rotator = settings.m_rotator; + } if (settingsKeys.contains("rgbColor")) { m_rgbColor = settings.m_rgbColor; } @@ -239,6 +390,78 @@ QString RemoteTCPSinkSettings::getDebugString(const QStringList& settingsKeys, b if (settingsKeys.contains("protocol") || force) { ostr << " m_protocol: " << m_protocol; } + if (settingsKeys.contains("iqOnly") || force) { + ostr << " m_iqOnly: " << m_iqOnly; + } + if (settingsKeys.contains("compression") || force) { + ostr << " m_compression: " << m_compression; + } + if (settingsKeys.contains("compressionLevel") || force) { + ostr << " m_compressionLevel: " << m_compressionLevel; + } + if (settingsKeys.contains("blockSize") || force) { + ostr << " m_blockSize: " << m_blockSize; + } + if (settingsKeys.contains("squelchEnabled") || force) { + ostr << " m_squelchEnabled: " << m_squelchEnabled; + } + if (settingsKeys.contains("squelch") || force) { + ostr << " m_squelch: " << m_squelch; + } + if (settingsKeys.contains("squelchGate") || force) { + ostr << " m_squelchGate: " << m_squelchGate; + } + if (settingsKeys.contains("remoteControl") || force) { + ostr << " m_remoteControl: " << m_remoteControl; + } + if (settingsKeys.contains("maxClients") || force) { + ostr << " m_maxClients: " << m_maxClients; + } + if (settingsKeys.contains("timeLimit") || force) { + ostr << " m_timeLimit: " << m_timeLimit; + } + if (settingsKeys.contains("maxSampleRate") || force) { + ostr << " m_maxSampleRate: " << m_maxSampleRate; + } + if (settingsKeys.contains("certificate") || force) { + ostr << " m_certificate: " << m_certificate.toStdString(); + } + if (settingsKeys.contains("key") || force) { + ostr << " m_key: " << m_key.toStdString(); + } + if (settingsKeys.contains("public") || force) { + ostr << " m_public: " << m_public; + } + if (settingsKeys.contains("publicAddress") || force) { + ostr << " m_publicAddress: " << m_publicAddress.toStdString(); + } + if (settingsKeys.contains("publicPort") || force) { + ostr << " m_publicPort: " << m_publicPort; + } + if (settingsKeys.contains("minFrequency") || force) { + ostr << " m_minFrequency: " << m_minFrequency; + } + if (settingsKeys.contains("maxFrequency") || force) { + ostr << " m_maxFrequency: " << m_maxFrequency; + } + if (settingsKeys.contains("antenna") || force) { + ostr << " m_antenna: " << m_antenna.toStdString(); + } + if (settingsKeys.contains("ipBlacklist") || force) { + ostr << " m_ipBlacklist: " << m_ipBlacklist.join(" ").toStdString(); + } + if (settingsKeys.contains("isotrophic") || force) { + ostr << " m_isotropic: " << m_isotropic; + } + if (settingsKeys.contains("azimuth") || force) { + ostr << " m_azimuth: " << m_azimuth; + } + if (settingsKeys.contains("elevation") || force) { + ostr << " m_elevation: " << m_elevation; + } + if (settingsKeys.contains("rotator") || force) { + ostr << " m_rotator: " << m_rotator.toStdString(); + } if (settingsKeys.contains("rgbColor") || force) { ostr << " m_rgbColor: " << m_rgbColor; } diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettings.h b/plugins/channelrx/remotetcpsink/remotetcpsinksettings.h index 7c54ea47ed..74900631e7 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksettings.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettings.h @@ -24,6 +24,7 @@ #include #include +#include class Serializable; @@ -31,7 +32,13 @@ struct RemoteTCPSinkSettings { enum Protocol { RTL0, // Compatible with rtl_tcp - SDRA // SDRangel remote TCP protocol which extends rtl_tcp + SDRA, // SDRangel remote TCP protocol which extends rtl_tcp + SDRA_WSS // SDRA using WebSocket Secure + }; + + enum Compressor { + FLAC, + ZLIB }; qint32 m_channelSampleRate; @@ -41,6 +48,34 @@ struct RemoteTCPSinkSettings QString m_dataAddress; uint16_t m_dataPort; enum Protocol m_protocol; + bool m_iqOnly; // Send uncompressed IQ only (No position or messages) + Compressor m_compression; // How IQ stream is compressed + int m_compressionLevel; + int m_blockSize; + bool m_squelchEnabled; + float m_squelch; + float m_squelchGate; // In seconds + bool m_remoteControl; // Whether remote control is enabled + int m_maxClients; + int m_timeLimit; // Time limit per connection in minutes, if server busy. 0 = no limit. + int m_maxSampleRate; + + QString m_certificate; // SSL certificate + QString m_key; // SSL key + + bool m_public; // Whether to list publically + QString m_publicAddress; // IP address / host for public listing + int m_publicPort; // What port number for public listing + qint64 m_minFrequency; // Minimum frequency for public listing + qint64 m_maxFrequency; // Maximum frequency for public listing + QString m_antenna; // Anntenna description for public listing + QString m_location; // Anntenna location for public listing + QStringList m_ipBlacklist; // List of IP addresses to refuse connections from + bool m_isotropic; // Antenna is isotropic + float m_azimuth; // Antenna azimuth angle + float m_elevation; // Antenna elevation angle + QString m_rotator; // Id of Rotator Controller feature to get az/el from + quint32 m_rgbColor; QString m_title; int m_streamIndex; //!< MIMO channel. Not relevant when connected to SI (single Rx). diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp new file mode 100644 index 0000000000..b9b22a107e --- /dev/null +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp @@ -0,0 +1,370 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 + +#include "remotetcpsinksettingsdialog.h" +#include "ui_remotetcpsinksettingsdialog.h" + +RemoteTCPSinkSettingsDialog::RemoteTCPSinkSettingsDialog(RemoteTCPSinkSettings *settings, QWidget* parent) : + QDialog(parent), + ui(new Ui::RemoteTCPSinkSettingsDialog), + m_settings(settings), + m_availableRotatorHandler({"sdrangel.feature.gs232controller"}) +{ + ui->setupUi(this); + + ui->maxClients->setValue(m_settings->m_maxClients); + ui->timeLimit->setValue(m_settings->m_timeLimit); + ui->maxSampleRate->setValue(m_settings->m_maxSampleRate); + ui->iqOnly->setChecked(m_settings->m_iqOnly); + + ui->compressor->setCurrentIndex((int) m_settings->m_compression); + ui->compressionLevel->setValue(m_settings->m_compressionLevel); + ui->blockSize->setCurrentIndex(ui->blockSize->findText(QString::number(m_settings->m_blockSize))); + + ui->certificate->setText(m_settings->m_certificate); + ui->key->setText(m_settings->m_key); + + ui->publicListing->setChecked(m_settings->m_public); + ui->publicAddress->setText(m_settings->m_publicAddress); + ui->publicPort->setValue(m_settings->m_publicPort); + ui->minFrequency->setValue(m_settings->m_minFrequency / 1000000); + ui->maxFrequency->setValue(m_settings->m_maxFrequency / 1000000); + ui->antenna->setText(m_settings->m_antenna); + ui->location->setText(m_settings->m_location); + ui->isotropic->setChecked(m_settings->m_isotropic); + ui->azimuth->setValue(m_settings->m_azimuth); + ui->elevation->setValue(m_settings->m_elevation); + ui->rotator->setCurrentText(m_settings->m_rotator); + + for (const auto& ip : m_settings->m_ipBlacklist) { + ui->ipBlacklist->addItem(ip); + } + + QObject::connect( + &m_availableRotatorHandler, + &AvailableChannelOrFeatureHandler::channelsOrFeaturesChanged, + this, + &RemoteTCPSinkSettingsDialog::rotatorsChanged + ); + m_availableRotatorHandler.scanAvailableChannelsAndFeatures(); +} + +RemoteTCPSinkSettingsDialog::~RemoteTCPSinkSettingsDialog() +{ + delete ui; +} + +void RemoteTCPSinkSettingsDialog::accept() +{ + if (!isValid()) { + return; + } + + QDialog::accept(); + + if (ui->maxClients->value() != m_settings->m_maxClients) + { + m_settings->m_maxClients = ui->maxClients->value(); + m_settingsKeys.append("maxClients"); + } + if (ui->timeLimit->value() != m_settings->m_timeLimit) + { + m_settings->m_timeLimit = ui->timeLimit->value(); + m_settingsKeys.append("timeLimit"); + } + if (ui->maxSampleRate->value() != m_settings->m_maxSampleRate) + { + m_settings->m_maxSampleRate = ui->maxSampleRate->value(); + m_settingsKeys.append("maxSampleRate"); + } + if (ui->iqOnly->isChecked() != m_settings->m_iqOnly) + { + m_settings->m_iqOnly = ui->iqOnly->isChecked(); + m_settingsKeys.append("iqOnly"); + } + RemoteTCPSinkSettings::Compressor compressor = (RemoteTCPSinkSettings::Compressor) ui->compressor->currentIndex(); + if (compressor != m_settings->m_compression) + { + m_settings->m_compression = compressor; + m_settingsKeys.append("compression"); + } + if (ui->compressionLevel->value() != m_settings->m_compressionLevel) + { + m_settings->m_compressionLevel = ui->compressionLevel->value(); + m_settingsKeys.append("compressionLevel"); + } + int blockSize = ui->blockSize->currentText().toInt(); + if (blockSize != m_settings->m_blockSize) + { + m_settings->m_blockSize = blockSize; + m_settingsKeys.append("blockSize"); + } + if (ui->certificate->text() != m_settings->m_certificate) + { + m_settings->m_certificate = ui->certificate->text(); + m_settingsKeys.append("certificate"); + } + if (ui->key->text() != m_settings->m_key) + { + m_settings->m_key = ui->key->text(); + m_settingsKeys.append("key"); + } + if (ui->publicListing->isChecked() != m_settings->m_public) + { + m_settings->m_public = ui->publicListing->isChecked(); + m_settingsKeys.append("public"); + } + if (ui->publicAddress->text() != m_settings->m_publicAddress) + { + m_settings->m_publicAddress = ui->publicAddress->text(); + m_settingsKeys.append("publicAddress"); + } + if (ui->publicPort->value() != m_settings->m_publicPort) + { + m_settings->m_publicPort = ui->publicPort->value(); + m_settingsKeys.append("publicPort"); + } + qint64 minFrequency = ui->minFrequency->value() * 1000000; + if (minFrequency != m_settings->m_minFrequency) + { + m_settings->m_minFrequency = minFrequency; + m_settingsKeys.append("minFrequency"); + } + qint64 maxFrequency = ui->maxFrequency->value() * 1000000; + if (maxFrequency != m_settings->m_maxFrequency) + { + m_settings->m_maxFrequency = maxFrequency; + m_settingsKeys.append("maxFrequency"); + } + if (ui->antenna->text() != m_settings->m_antenna) + { + m_settings->m_antenna = ui->antenna->text(); + m_settingsKeys.append("antenna"); + } + if (ui->location->text() != m_settings->m_location) + { + m_settings->m_location = ui->location->text(); + m_settingsKeys.append("location"); + } + if (ui->isotropic->isChecked() != m_settings->m_isotropic) + { + m_settings->m_isotropic = ui->isotropic->isChecked(); + m_settingsKeys.append("isotropic"); + } + if (ui->azimuth->value() != m_settings->m_azimuth) + { + m_settings->m_azimuth = ui->azimuth->value(); + m_settingsKeys.append("azimuth"); + } + if (ui->elevation->value() != m_settings->m_elevation) + { + m_settings->m_elevation = ui->elevation->value(); + m_settingsKeys.append("elevation"); + } + if (ui->rotator->currentText() != m_settings->m_rotator) + { + m_settings->m_rotator = ui->rotator->currentText(); + m_settingsKeys.append("rotator"); + } + QStringList ipBlacklist; + for (int i = 0; i < ui->ipBlacklist->count(); i++) + { + QString ip = ui->ipBlacklist->item(i)->text().trimmed(); + if (!ip.isEmpty()) { + ipBlacklist.append(ip); + } + } + if (ipBlacklist != m_settings->m_ipBlacklist) + { + m_settings->m_ipBlacklist = ipBlacklist; + m_settingsKeys.append("ipBlacklist"); + } +} + +void RemoteTCPSinkSettingsDialog::on_browseCertificate_clicked() +{ + QString fileName = QFileDialog::getOpenFileName(this, tr("Select SSL Certificate"), + "", + tr("SSL certificate (*.cert *.pem)")); + if (!fileName.isEmpty()) { + ui->certificate->setText(fileName); + } +} + +void RemoteTCPSinkSettingsDialog::on_browseKey_clicked() +{ + QString fileName = QFileDialog::getOpenFileName(this, tr("Select SSL Key"), + "", + tr("SSL key (*.key *.pem)")); + if (!fileName.isEmpty()) { + ui->key->setText(fileName); + } +} + +void RemoteTCPSinkSettingsDialog::on_addIP_clicked() +{ + QListWidgetItem *item = new QListWidgetItem("1.1.1.1"); + item->setFlags(Qt::ItemIsEditable | item->flags()); + ui->ipBlacklist->addItem(item); + item->setSelected(true); +} + +void RemoteTCPSinkSettingsDialog::on_removeIP_clicked() +{ + qDeleteAll(ui->ipBlacklist->selectedItems()); +} + +void RemoteTCPSinkSettingsDialog::on_publicListing_toggled() +{ + displayValid(); + displayEnabled(); +} + +void RemoteTCPSinkSettingsDialog::on_publicAddress_textChanged() +{ + displayValid(); +} + +void RemoteTCPSinkSettingsDialog::on_compressor_currentIndexChanged(int index) +{ + if (index == 0) + { + // FLAC settings + ui->compressionLevel->setMaximum(8); + ui->blockSize->clear(); + ui->blockSize->addItem("4096"); + ui->blockSize->addItem("16384"); + ui->blockSize->setCurrentIndex(1); + } + else if (index == 1) + { + // zlib settings + ui->compressionLevel->setMaximum(9); + ui->blockSize->clear(); + ui->blockSize->addItem("4096"); + ui->blockSize->addItem("8192"); + ui->blockSize->addItem("16384"); + ui->blockSize->addItem("32768"); + ui->blockSize->setCurrentIndex(3); + } +} + +void RemoteTCPSinkSettingsDialog::on_iqOnly_toggled(bool checked) +{ + ui->compressionSettings->setEnabled(!checked); +} + +void RemoteTCPSinkSettingsDialog::on_isotropic_toggled(bool checked) +{ + displayEnabled(); +} + +void RemoteTCPSinkSettingsDialog::on_rotator_currentIndexChanged(int index) +{ + (void) index; + + displayEnabled(); +} + +bool RemoteTCPSinkSettingsDialog::isValid() +{ + bool valid = true; + + if (ui->publicListing->isChecked() && ui->publicAddress->text().isEmpty()) { + valid = false; + } + + return valid; +} + +void RemoteTCPSinkSettingsDialog::displayValid() +{ + bool valid = isValid(); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(valid); +} + +void RemoteTCPSinkSettingsDialog::displayEnabled() +{ + bool enabled = ui->publicListing->isChecked(); + bool none = ui->rotator->currentText() == "None"; + bool isotropic = ui->isotropic->isChecked(); + + ui->publicAddressLabel->setEnabled(enabled); + ui->publicAddress->setEnabled(enabled); + ui->publicPort->setEnabled(enabled); + ui->frequencyLabel->setEnabled(enabled); + ui->minFrequency->setEnabled(enabled); + ui->maxFrequency->setEnabled(enabled); + ui->frequencyUnits->setEnabled(enabled); + ui->antennaLabel->setEnabled(enabled); + ui->antenna->setEnabled(enabled); + ui->locationLabel->setEnabled(enabled); + ui->location->setEnabled(enabled); + ui->isotropicLabel->setEnabled(enabled); + ui->isotropic->setEnabled(enabled); + ui->rotatorLabel->setEnabled(enabled && !isotropic); + ui->rotator->setEnabled(enabled && !isotropic); + ui->directionLabel->setEnabled(enabled && !isotropic && none); + ui->azimuthLabel->setEnabled(enabled && !isotropic && none); + ui->azimuth->setEnabled(enabled && !isotropic && none); + ui->elevationLabel->setEnabled(enabled && !isotropic && none); + ui->elevation->setEnabled(enabled && !isotropic && none); +} + +void RemoteTCPSinkSettingsDialog::rotatorsChanged(const QStringList& renameFrom, const QStringList& renameTo) +{ + AvailableChannelOrFeatureList rotators = m_availableRotatorHandler.getAvailableChannelOrFeatureList(); + updateRotatorList(rotators, renameFrom, renameTo); +} + +void RemoteTCPSinkSettingsDialog::updateRotatorList(const AvailableChannelOrFeatureList& rotators, const QStringList& renameFrom, const QStringList& renameTo) +{ + // Update rotator settting if it has been renamed + if (renameFrom.contains(m_settings->m_rotator)) { + m_settings->m_rotator = renameTo[renameFrom.indexOf(m_settings->m_rotator)]; + } + + // Update list of rotators + ui->rotator->blockSignals(true); + ui->rotator->clear(); + ui->rotator->addItem("None"); + + for (const auto& rotator : rotators) { + ui->rotator->addItem(rotator.getLongId()); + } + + // Rotator feature can be created after this plugin, so select it + // if the chosen rotator appears + int rotatorIndex = ui->rotator->findText(m_settings->m_rotator); + + if (rotatorIndex >= 0) + { + ui->rotator->setCurrentIndex(rotatorIndex); + } + else + { + ui->rotator->setCurrentIndex(0); // return to None + } + + ui->rotator->blockSignals(false); + + displayEnabled(); +} \ No newline at end of file diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h new file mode 100644 index 0000000000..c136abf23a --- /dev/null +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h @@ -0,0 +1,65 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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_REMOTETCPSINKSETTINGSDIALOG_H +#define INCLUDE_REMOTETCPSINKSETTINGSDIALOG_H + +#include + +#include "availablechannelorfeaturehandler.h" + +#include "remotetcpsinksettings.h" + +namespace Ui { + class RemoteTCPSinkSettingsDialog; +} + +class RemoteTCPSinkSettingsDialog : public QDialog { + Q_OBJECT +public: + explicit RemoteTCPSinkSettingsDialog(RemoteTCPSinkSettings *settings, QWidget* parent = nullptr); + ~RemoteTCPSinkSettingsDialog(); + + const QStringList& getSettingsKeys() const { return m_settingsKeys; }; + +private slots: + void accept(); + void on_browseCertificate_clicked(); + void on_browseKey_clicked(); + void on_addIP_clicked(); + void on_removeIP_clicked(); + void on_publicListing_toggled(); + void on_publicAddress_textChanged(); + void on_compressor_currentIndexChanged(int index); + void on_iqOnly_toggled(bool checked); + void on_isotropic_toggled(bool checked); + void on_rotator_currentIndexChanged(int index); + void rotatorsChanged(const QStringList& renameFrom, const QStringList& renameTo); + +private: + Ui::RemoteTCPSinkSettingsDialog *ui; + RemoteTCPSinkSettings *m_settings; + QStringList m_settingsKeys; + AvailableChannelOrFeatureHandler m_availableRotatorHandler; + + bool isValid(); + void displayValid(); + void displayEnabled(); + void updateRotatorList(const AvailableChannelOrFeatureList& rotators, const QStringList& renameFrom, const QStringList& renameTo); +}; + +#endif // INCLUDE_REMOTETCPSINKSETTINGSDIALOG_H diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.ui b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.ui new file mode 100644 index 0000000000..269659df7d --- /dev/null +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.ui @@ -0,0 +1,675 @@ + + + RemoteTCPSinkSettingsDialog + + + + 0 + 0 + 409 + 947 + + + + + 9 + + + + Settings + + + + + + Server Settings + + + + + + Maximum channel sample rate + + + 2700 + + + 100000000 + + + 10000000 + + + + + + + Time Limit + + + + + + + Max Clients + + + + + + + Connection time limit in minutes if max clients reached. 0 for no limit. + + + 100000 + + + + + + + Max Ch. Sample Rate + + + + + + + S/s + + + + + + + Maximum number of simultaneous clients + + + 0 + + + 10000 + + + + + + + mins + + + + + + + IQ only + + + + + + + Transmit uncompressed IQ only. Disables compression, position and messaging support. + + + + + + + + + + + + + Compression + + + + + + Compressor + + + + + + + + FLAC + + + + + zlib + + + + + + + + Compression Level + + + + + + + 0 - Least compression. 8 - Most compression Higher compression requires more CPU. + + + 8 + + + + + + + Block size + + + + + + + + 4096 + + + + + 16384 + + + + + + + + + + + SSL Settings + + + + + + SSL certificate + + + + + + + ... + + + + :/load.png:/load.png + + + + + + + Key + + + + + + + SSL certificate key + + + + + + + + 0 + 0 + + + + Certificate + + + + + + + ... + + + + :/load.png:/load.png + + + + + + + + + + Public Directory + + + + + + false + + + Antenna + + + + + + + false + + + Minimum recommend frequency in MHz + + + 20000 + + + + + + + false + + + Publically accessible port number + + + 1024 + + + 65535 + + + 1234 + + + + + + + false + + + Direction + + + + + + + false + + + Town and country where antenna is located + + + 255 + + + + + + + List Server + + + + + + + false + + + Isotropic + + + + + + + + + false + + + Az + + + + + + + false + + + Antenna azimuth in degrees + + + 3 + + + 360.000000000000000 + + + + + + + Qt::Vertical + + + + + + + false + + + El + + + + + + + false + + + Antenna elevation in degrees + + + 3 + + + -90.000000000000000 + + + 90.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + false + + + Maximum recommend frequency in MHz + + + 20000 + + + + + + + false + + + Address + + + + + + + false + + + Location + + + + + + + false + + + MHz + + + + + + + false + + + Publically accessible IP address or hostname + + + + + + + false + + + Check to indicate an antenna that is isotropic (non-directional) + + + + + + + + + + Whether to list the server as publically accessible + + + + + + + + + + false + + + Frequency Range + + + + + + + false + + + Antenna description + + + 255 + + + + + + + false + + + Rotator + + + + + + + false + + + Rotator feature to get antenna direction from + + + + None + + + + + + + + + + + IP Blacklist + + + + + + List of IP addresses from which connections should not be allowed + + + QAbstractItemView::MultiSelection + + + + + + + + + + + + + + + + + - + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + maxClients + timeLimit + maxSampleRate + iqOnly + compressor + compressionLevel + blockSize + certificate + browseCertificate + key + browseKey + publicListing + publicAddress + publicPort + minFrequency + maxFrequency + antenna + location + isotropic + rotator + azimuth + elevation + ipBlacklist + addIP + removeIP + + + + + + + buttonBox + accepted() + RemoteTCPSinkSettingsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + RemoteTCPSinkSettingsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp index b00072440c..3e985cc7b6 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2022-2023 Jon Beniston, M7RCE // +// Copyright (C) 2022-2024 Jon Beniston, M7RCE // // Copyright (C) 2022 Jiří Pinkava // // // // This program is free software; you can redistribute it and/or modify // @@ -18,6 +18,9 @@ #include #include +#include +#include +#include #include "channel/channelwebapiutils.h" #include "device/deviceapi.h" @@ -26,22 +29,69 @@ #include "remotetcpsinksink.h" #include "remotetcpsink.h" +static FLAC__StreamEncoderWriteStatus flacWriteCallback(const FLAC__StreamEncoder *encoder, const FLAC__byte buffer[], size_t bytes, uint32_t samples, uint32_t currentFrame, void *clientData) +{ + RemoteTCPSinkSink *sink = (RemoteTCPSinkSink*) clientData; + + return sink->flacWrite(encoder, buffer, bytes, samples, currentFrame); +} + RemoteTCPSinkSink::RemoteTCPSinkSink() : - m_running(false), - m_messageQueueToGUI(nullptr), - m_messageQueueToChannel(nullptr), - m_channelFrequencyOffset(0), - m_channelSampleRate(48000), - m_linearGain(1.0f), - m_server(nullptr) + m_running(false), + m_messageQueueToGUI(nullptr), + m_messageQueueToChannel(nullptr), + m_channelFrequencyOffset(0), + m_channelSampleRate(48000), + m_linearGain(1.0f), + m_server(nullptr), + m_webSocketServer(nullptr), + m_encoder(nullptr), + m_zStreamInitialised(false), + m_zInBuf(m_zBufSize, '\0'), + m_zOutBuf(m_zBufSize, '\0'), + m_zInBufCount(0), + m_bytesUncompressed(0), + m_bytesCompressed(0), + m_bytesTransmitted(0), + m_squelchLevel(-150.0f), + m_squelchCount(0), + m_squelchOpen(false), + m_squelchDelayLine(48000/2), + m_magsq(0.0f), + m_magsqSum(0.0f), + m_magsqPeak(0.0f), + m_magsqCount(0), + m_centerFrequency(0.0), + m_ppmCorrection(0), + m_biasTeeEnabled(false), + m_directSampling(false), + m_agc(false), + m_dcOffsetRemoval(false), + m_iqCorrection(false), + m_devSampleRate(0), + m_log2Decim(0), + m_gain(), + m_rfBW(0), + m_timer(this), + m_azimuth(std::numeric_limits::quiet_NaN()), + m_elevation(std::numeric_limits::quiet_NaN()) { qDebug("RemoteTCPSinkSink::RemoteTCPSinkSink"); applySettings(m_settings, QStringList(), true); applyChannelSettings(m_channelSampleRate, m_channelFrequencyOffset, true); + + // Get updated when position changes + connect(&MainCore::instance()->getSettings(), &MainSettings::preferenceChanged, this, &RemoteTCPSinkSink::preferenceChanged); + + m_timer.setSingleShot(false); + m_timer.setInterval(500); + connect(&m_timer, &QTimer::timeout, this, &RemoteTCPSinkSink::checkDeviceSettings); } RemoteTCPSinkSink::~RemoteTCPSinkSink() { + qDebug("RemoteTCPSinkSink::~RemoteTCPSinkSink"); + disconnect(&MainCore::instance()->getSettings(), &MainSettings::preferenceChanged, this, &RemoteTCPSinkSink::preferenceChanged); stop(); } @@ -71,6 +121,7 @@ void RemoteTCPSinkSink::started() QMutexLocker mutexLocker(&m_mutex); startServer(); disconnect(thread(), SIGNAL(started()), this, SLOT(started())); + m_timer.start(); } void RemoteTCPSinkSink::finished() @@ -78,6 +129,7 @@ void RemoteTCPSinkSink::finished() QMutexLocker mutexLocker(&m_mutex); stopServer(); disconnect(thread(), SIGNAL(finished()), this, SLOT(finished())); + m_timer.stop(); m_running = false; } @@ -119,19 +171,27 @@ void RemoteTCPSinkSink::feed(const SampleVector::const_iterator& begin, const Sa } } + for (const auto client : m_clients) { + client->flush(); + } + QDateTime currentDateTime = QDateTime::currentDateTime(); if (m_bwDateTime.isValid()) { - QDateTime currentDateTime = QDateTime::currentDateTime(); qint64 msecs = m_bwDateTime.msecsTo(currentDateTime) ; - if (msecs > 1000) + if (msecs >= 1000) { - float bw = (8*m_bwBytes)/(msecs/1000.0f); + float secs = msecs / 1000.0f; + float bw = (8 * m_bwBytes) / secs; + float networkBW = (8 * m_bytesTransmitted) / secs; if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgReportBW::create(bw)); + m_messageQueueToGUI->push(RemoteTCPSink::MsgReportBW::create(bw, networkBW, m_bytesUncompressed, m_bytesCompressed, m_bytesTransmitted)); } m_bwDateTime = currentDateTime; m_bwBytes = bytes; + m_bytesUncompressed = 0; + m_bytesCompressed = 0; + m_bytesTransmitted = 0; } else { @@ -140,73 +200,226 @@ void RemoteTCPSinkSink::feed(const SampleVector::const_iterator& begin, const Sa } else { - m_bwDateTime = QDateTime::currentDateTime(); + m_bwDateTime = currentDateTime; m_bwBytes = bytes; } } } +static qint32 clamp8(qint32 x) +{ + x = std::max(x, -128); + x = std::min(x, 127); + return x; +} + +static qint32 clamp16(qint32 x) +{ + x = std::max(x, -32768); + x = std::min(x, 32767); + return x; +} + +static qint32 clamp24(qint32 x) +{ + x = std::max(x, -8388608); + x = std::min(x, 8388607); + return x; +} + void RemoteTCPSinkSink::processOneSample(Complex &ci) { - if (m_settings.m_sampleBits == 8) + // Apply gain + ci = ci * m_linearGain; + + // Calculate channel power + Real re = ci.real(); + Real im = ci.imag(); + Real magsq = (re*re + im*im) / (SDR_RX_SCALED*SDR_RX_SCALED); + m_movingAverage(magsq); + m_magsq = m_movingAverage.asDouble(); + m_magsqSum += magsq; + m_magsqPeak = std::max(magsq, m_magsqPeak); + m_magsqCount++; + + // Squelch + if (m_settings.m_squelchEnabled) { - // Transmit data as per rtl_tcp - Interleaved unsigned 8-bit IQ - quint8 iqBuf[2]; - iqBuf[0] = ((int)(ci.real() / SDR_RX_SCALEF * 256.0f * m_linearGain)) + 128; - iqBuf[1] = ((int)(ci.imag() / SDR_RX_SCALEF * 256.0f * m_linearGain)) + 128; - for (auto client : m_clients) { - client->write((const char *)iqBuf, sizeof(iqBuf)); + // Convert gate time from seconds to samples + int squelchGate = m_settings.m_squelchGate * m_channelSampleRate; + + m_squelchDelayLine.write(ci); + + if (m_magsq < m_squelchLevel) + { + if (m_squelchCount > 0) { + m_squelchCount--; + } } - } - else if (m_settings.m_sampleBits == 16) - { - // Interleaved little-endian signed 16-bit IQ - quint8 iqBuf[2*2]; - qint32 i, q; - i = ((qint32)(ci.real() / SDR_RX_SCALEF * 65536.0f * m_linearGain)); - q = ((qint32)(ci.imag() / SDR_RX_SCALEF * 65536.0f * m_linearGain)); - iqBuf[1] = (i >> 8) & 0xff; - iqBuf[0] = i & 0xff; - iqBuf[3] = (q >> 8) & 0xff; - iqBuf[2] = q & 0xff; - for (auto client : m_clients) { - client->write((const char *)iqBuf, sizeof(iqBuf)); + else + { + m_squelchCount = squelchGate; + } + m_squelchOpen = m_squelchCount > 0; + + if (m_squelchOpen) { + ci = m_squelchDelayLine.readBack(squelchGate); + } else { + ci = 0.0; } } - else if (m_settings.m_sampleBits == 24) + + if (!m_settings.m_iqOnly && (m_settings.m_compression == RemoteTCPSinkSettings::FLAC)) { - // Interleaved little-endian signed 24-bit IQ - quint8 iqBuf[3*2]; - qint32 i, q; - i = ((qint32)(ci.real() * m_linearGain)); - q = ((qint32)(ci.imag() * m_linearGain)); - iqBuf[2] = (i >> 16) & 0xff; - iqBuf[1] = (i >> 8) & 0xff; - iqBuf[0] = i & 0xff; - iqBuf[5] = (q >> 16) & 0xff; - iqBuf[4] = (q >> 8) & 0xff; - iqBuf[3] = q & 0xff; - for (auto client : m_clients) { - client->write((const char *)iqBuf, sizeof(iqBuf)); + // Compress using FLAC + FLAC__int32 iqBuf[2]; + + if (m_settings.m_sampleBits == 8) + { + iqBuf[0] = (qint32) (ci.real() / 65536.0f); + iqBuf[1] = (qint32) (ci.imag() / 65536.0f); + iqBuf[0] = clamp8(iqBuf[0]); + iqBuf[1] = clamp8(iqBuf[1]); + } + else if (m_settings.m_sampleBits == 16) + { + iqBuf[0] = (qint32) (ci.real() / 256.0f); + iqBuf[1] = (qint32) (ci.imag() / 256.0f); + iqBuf[0] = clamp16(iqBuf[0]); + iqBuf[1] = clamp16(iqBuf[1]); + } + else if (m_settings.m_sampleBits == 24) + { + iqBuf[0] = (qint32) ci.real(); + iqBuf[1] = (qint32) ci.imag(); + iqBuf[0] = clamp24(iqBuf[0]); + iqBuf[1] = clamp24(iqBuf[1]); + } + else + { + iqBuf[0] = (qint32) ci.real(); + iqBuf[1] = (qint32) ci.imag(); + } + int bytes = 2 * m_settings.m_sampleBits / 8; + m_bytesUncompressed += bytes; + + if (m_encoder && !FLAC__stream_encoder_process_interleaved(m_encoder, iqBuf, 1)) { // Number of samples in one channel + qDebug() << "RemoteTCPSinkSink::processOneSample: FLAC failed to encode:" << FLAC__stream_encoder_get_state(m_encoder); } } else { - // Interleaved little-endian signed 32-bit IQ quint8 iqBuf[4*2]; - qint32 i, q; - i = ((qint32)(ci.real() * m_linearGain)); - q = ((qint32)(ci.imag() * m_linearGain)); - iqBuf[3] = (i >> 24) & 0xff; - iqBuf[2] = (i >> 16) & 0xff; - iqBuf[1] = (i >> 8) & 0xff; - iqBuf[0] = i & 0xff; - iqBuf[7] = (q >> 24) & 0xff; - iqBuf[6] = (q >> 16) & 0xff; - iqBuf[5] = (q >> 8) & 0xff; - iqBuf[4] = q & 0xff; - for (auto client : m_clients) { - client->write((const char *)iqBuf, sizeof(iqBuf)); + + if (m_settings.m_sampleBits == 8) + { + // Transmit data as per rtl_tcp - Interleaved unsigned 8-bit IQ + iqBuf[0] = clamp8((qint32) (ci.real() / 65536.0f)) + 128; + iqBuf[1] = clamp8((qint32) (ci.imag() / 65536.0f)) + 128; + } + else if (m_settings.m_sampleBits == 16) + { + // Interleaved little-endian signed 16-bit IQ + qint32 i, q; + i = clamp16((qint32) (ci.real() / 256.0f)); + q = clamp16((qint32) (ci.imag() / 256.0f)); + iqBuf[1] = (i >> 8) & 0xff; + iqBuf[0] = i & 0xff; + iqBuf[3] = (q >> 8) & 0xff; + iqBuf[2] = q & 0xff; + } + else if (m_settings.m_sampleBits == 24) + { + // Interleaved little-endian signed 24-bit IQ + qint32 i, q; + i = clamp24((qint32) ci.real()); + q = clamp24((qint32) ci.imag()); + iqBuf[2] = (i >> 16) & 0xff; + iqBuf[1] = (i >> 8) & 0xff; + iqBuf[0] = i & 0xff; + iqBuf[5] = (q >> 16) & 0xff; + iqBuf[4] = (q >> 8) & 0xff; + iqBuf[3] = q & 0xff; + } + else + { + // Interleaved little-endian signed 32-bit IQ + qint32 i, q; + i = (qint32) ci.real(); + q = (qint32) ci.imag(); + iqBuf[3] = (i >> 24) & 0xff; + iqBuf[2] = (i >> 16) & 0xff; + iqBuf[1] = (i >> 8) & 0xff; + iqBuf[0] = i & 0xff; + iqBuf[7] = (q >> 24) & 0xff; + iqBuf[6] = (q >> 16) & 0xff; + iqBuf[5] = (q >> 8) & 0xff; + iqBuf[4] = q & 0xff; + } + + int bytes = 2 * m_settings.m_sampleBits / 8; + m_bytesUncompressed += bytes; + + if (!m_settings.m_iqOnly && (m_settings.m_compression == RemoteTCPSinkSettings::ZLIB)) + { + if (m_zStreamInitialised) + { + // Store in block buffer + memcpy(&m_zInBuf.data()[m_zInBufCount], iqBuf, bytes); + m_zInBufCount += bytes; + + if (m_zInBufCount >= m_settings.m_blockSize) + { + // Compress using zlib + m_zStream.next_in = (Bytef *) m_zInBuf.data(); + m_zStream.avail_in = m_zInBufCount; + m_zStream.next_out = (Bytef *) m_zOutBuf.data(); + m_zStream.avail_out = m_zOutBuf.size(); + int ret = deflate(&m_zStream, Z_FINISH); + + if (ret == Z_STREAM_END) { + deflateReset(&m_zStream); + } else if (ret != Z_OK) { + qDebug() << "Failed to deflate" << ret; + } + if (m_zStream.avail_in != 0) { + qDebug() << "Warning: Data still in input buffer"; + } + int compressedBytes = m_zOutBuf.size() - m_zStream.avail_out; + + //qDebug() << "zlib ret" << ret << "m_settings.m_blockSize" << m_settings.m_blockSize << "m_zInBufCount" << m_zInBufCount << "compressedBytes" << compressedBytes << "avail_in" << m_zStream.avail_in << "avail_out" << m_zStream.avail_out << " % " << round(100.0 * compressedBytes / (float) m_zInBufCount ); + + m_zInBufCount = 0; + + // Send to clients + + int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); + char header[1+4]; + + header[0] = (char) RemoteTCPProtocol::dataIQzlib; + RemoteTCPProtocol::encodeUInt32((quint8 *) &header[1], compressedBytes); + + for (int i = 0; i < clients; i++) + { + m_clients[i]->write(header, sizeof(header)); + m_bytesTransmitted += sizeof(header); + m_clients[i]->write((const char *)m_zOutBuf.data(), compressedBytes); + m_bytesTransmitted += compressedBytes; + } + m_bytesCompressed += sizeof(header) + compressedBytes; + } + } + } + else + { + // Send uncompressed + int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); + + for (int i = 0; i < clients; i++) + { + m_clients[i]->write((const char *)iqBuf, bytes); + m_bytesTransmitted += bytes; + } } } } @@ -232,10 +445,15 @@ void RemoteTCPSinkSink::applyChannelSettings(int channelSampleRate, int channelF m_channelSampleRate = channelSampleRate; m_channelFrequencyOffset = channelFrequencyOffset; + + m_squelchDelayLine.resize(m_settings.m_squelchGate * m_channelSampleRate + 1); } -void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool remoteChange) +void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force, bool restartRequired) { + bool initFLAC = false; + bool initZLib = false; + QMutexLocker mutexLocker(&m_mutex); qDebug() << "RemoteTCPSinkSink::applySettings:" << settings.getDebugString(settingsKeys, force) << " force: " << force; @@ -252,14 +470,98 @@ void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, con m_interpolatorDistanceRemain = m_interpolatorDistance; } + // Update time limit for connected clients + if (settingsKeys.contains("timeLimit") && (m_settings.m_timeLimit != settings.m_timeLimit)) + { + if (settings.m_timeLimit > 0) + { + // Set new timelimit + for (int i = 0; i < m_timers.size(); i++) { + m_timers[i]->setInterval(settings.m_timeLimit * 60 * 1000); + } + // Start timers if they weren't previously started + if (m_settings.m_timeLimit == 0) + { + for (int i = 0; i < std::min((int) m_timers.size(), m_settings.m_maxClients); i++) { + m_timers[i]->start(); + } + } + } + else + { + // Stop any existing timers + for (int i = 0; i < m_timers.size(); i++) { + m_timers[i]->stop(); + } + } + } + + if ((settingsKeys.contains("compressionLevel") && (settings.m_compressionLevel != m_settings.m_compressionLevel)) + || (settingsKeys.contains("sampleBits") && (settings.m_sampleBits != m_settings.m_sampleBits)) + || (settingsKeys.contains("blockSize") && (settings.m_blockSize != m_settings.m_blockSize)) + || (settingsKeys.contains("channelSampleRate") && (settings.m_channelSampleRate != m_settings.m_channelSampleRate)) + || force) + { + initFLAC = true; + } + + if ((settingsKeys.contains("compressionLevel") && (settings.m_compressionLevel != m_settings.m_compressionLevel)) + || force) + { + initZLib = true; + } + + if (settingsKeys.contains("squelch") || force) + { + m_squelchLevel = std::pow(10.0, settings.m_squelch / 10.0); + m_movingAverage.reset(); + m_squelchCount = 0; + } + + if (settingsKeys.contains("squelchGate") || force) { + m_squelchDelayLine.resize(settings.m_squelchGate * m_channelSampleRate + 1); + } + // Do clients need to reconnect to get these updated settings? + // settingsKeys will be empty if force is set bool restart = (settingsKeys.contains("dataAddress") && (m_settings.m_dataAddress != settings.m_dataAddress)) || (settingsKeys.contains("dataPort") && (m_settings.m_dataPort != settings.m_dataPort)) + || (settingsKeys.contains("certificate") && (m_settings.m_certificate != settings.m_certificate)) + || (settingsKeys.contains("key") && (m_settings.m_key!= settings.m_key)) || (settingsKeys.contains("sampleBits") && (m_settings.m_sampleBits != settings.m_sampleBits)) || (settingsKeys.contains("protocol") && (m_settings.m_protocol != settings.m_protocol)) - || ( !remoteChange - && (settingsKeys.contains("channelSampleRate") && (m_settings.m_channelSampleRate != settings.m_channelSampleRate)) - ); + || (settingsKeys.contains("compression") && (m_settings.m_compression != settings.m_compression)) + || (settingsKeys.contains("remoteControl") && (m_settings.m_remoteControl != settings.m_remoteControl)) + || initFLAC + || restartRequired + ; + + if (!restart && (m_settings.m_protocol != RemoteTCPSinkSettings::RTL0) && !m_settings.m_iqOnly) + { + // Forward settings to clients if they've changed + if ((settingsKeys.contains("channelSampleRate") || force) && (settings.m_channelSampleRate != m_settings.m_channelSampleRate)) { + sendCommand(RemoteTCPProtocol::setChannelSampleRate, settings.m_channelSampleRate); + } + if ((settingsKeys.contains("inputFrequencyOffset") || force) && (settings.m_inputFrequencyOffset != m_settings.m_inputFrequencyOffset)) { + sendCommand(RemoteTCPProtocol::setChannelFreqOffset, settings.m_inputFrequencyOffset); + } + if ((settingsKeys.contains("gain") || force) && (settings.m_gain != m_settings.m_gain)) { + sendCommand(RemoteTCPProtocol::setChannelGain, settings.m_gain); + } + if ((settingsKeys.contains("sampleBits") || force) && (settings.m_sampleBits != m_settings.m_sampleBits)) { + sendCommand(RemoteTCPProtocol::setSampleBitDepth, settings.m_sampleBits); + } + if ((settingsKeys.contains("squelchEnabled") || force) && (settings.m_squelchEnabled != m_settings.m_squelchEnabled)) { + sendCommand(RemoteTCPProtocol::setIQSquelchEnabled, (quint32) settings.m_squelchEnabled); + } + if ((settingsKeys.contains("squelch") || force) && (settings.m_squelch != m_settings.m_squelch)) { + sendCommandFloat(RemoteTCPProtocol::setIQSquelch, settings.m_squelch); + } + if ((settingsKeys.contains("squelchGate") || force) && (settings.m_squelchGate != m_settings.m_squelchGate)) { + sendCommandFloat(RemoteTCPProtocol::setIQSquelchGate, settings.m_squelchGate); + } + // m_remoteControl rather than restart? + } if (force) { m_settings = settings; @@ -267,51 +569,183 @@ void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, con m_settings.applySettings(settingsKeys, settings); } - if (m_running && restart) { + if (m_running && (restart || force)) { startServer(); } + + if (initFLAC && (m_settings.m_compression == RemoteTCPSinkSettings::FLAC)) + { + if (m_encoder) + { + // Delete existing decoder + FLAC__stream_encoder_finish(m_encoder); + FLAC__stream_encoder_delete(m_encoder); + m_encoder = nullptr; + m_flacHeader.clear(); + } + + // Create FLAC encoder + FLAC__StreamEncoderInitStatus init_status; + m_encoder = FLAC__stream_encoder_new(); + if (m_encoder) + { + const int maxSampleRate = 176400; // Spec says max is 655350, but doesn't seem to work + FLAC__bool ok = true; + + ok &= FLAC__stream_encoder_set_verify(m_encoder, false); + ok &= FLAC__stream_encoder_set_compression_level(m_encoder, m_settings.m_compressionLevel); + ok &= FLAC__stream_encoder_set_channels(m_encoder, 2); + ok &= FLAC__stream_encoder_set_bits_per_sample(m_encoder, m_settings.m_sampleBits); + // We'll get FLAC__STREAM_ENCODER_INIT_STATUS_NOT_STREAMABLE if we use the real sample rate + if (m_settings.m_channelSampleRate < maxSampleRate) { + ok &= FLAC__stream_encoder_set_sample_rate(m_encoder, m_settings.m_channelSampleRate); + } else { + ok &= FLAC__stream_encoder_set_sample_rate(m_encoder, maxSampleRate); + } + ok &= FLAC__stream_encoder_set_total_samples_estimate(m_encoder, 0); + //ok &= FLAC__stream_encoder_set_do_mid_side_stereo(m_encoder, false); + // FLAC__MAX_BLOCK_SIZE is 65536 + // However, FLAC__format_blocksize_is_subset says anything over 16384 is not streamable + // Also, if sampleRate <= 48000, then max block size is 4608 + if (FLAC__format_blocksize_is_subset(m_settings.m_blockSize, m_settings.m_channelSampleRate)) { + ok &= FLAC__stream_encoder_set_blocksize(m_encoder, m_settings.m_blockSize); + } else { + ok &= FLAC__stream_encoder_set_blocksize(m_encoder, 4096); + } + if (ok) + { + init_status = FLAC__stream_encoder_init_stream(m_encoder, flacWriteCallback, nullptr, nullptr, nullptr, this); + if (init_status != FLAC__STREAM_ENCODER_INIT_STATUS_OK) + { + qDebug() << "RemoteTCPSinkSink::applySettings: Error initializing FLAC encoder:" << FLAC__StreamEncoderInitStatusString[init_status]; + FLAC__stream_encoder_delete(m_encoder); + m_encoder = nullptr; + } + } + else + { + qDebug() << "RemoteTCPSinkSink::applySettings: Failed to configure FLAC encoder"; + FLAC__stream_encoder_delete(m_encoder); + m_encoder = nullptr; + } + } + else + { + qDebug() << "RemoteTCPSinkSink::applySettings: Failed to allocate FLAC encoder"; + } + + m_bytesUncompressed = 0; + m_bytesCompressed = 0; + } + + if (initZLib && (m_settings.m_compression == RemoteTCPSinkSettings::ZLIB)) + { + // Intialise zlib compression + m_zStream.zalloc = Z_NULL; + m_zStream.zfree = Z_NULL; + m_zStream.opaque = Z_NULL; + m_zStream.data_type = Z_BINARY; + int windowBits = log2(m_settings.m_blockSize); + + if (Z_OK == deflateInit2(&m_zStream, m_settings.m_compressionLevel, Z_DEFLATED, windowBits, 9, Z_DEFAULT_STRATEGY)) + { + m_zStreamInitialised = true; + } + else + { + qDebug() << "RemoteTCPSinkSink::applySettings: deflateInit failed"; + m_zStreamInitialised = false; + } + + m_bytesUncompressed = 0; + m_bytesCompressed = 0; + } + } void RemoteTCPSinkSink::startServer() { stopServer(); - m_server = new QTcpServer(this); - if (!m_server->listen(QHostAddress(m_settings.m_dataAddress), m_settings.m_dataPort)) + if (m_settings.m_protocol == RemoteTCPSinkSettings::SDRA_WSS) { - qCritical() << "RemoteTCPSink failed to listen on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; - // FIXME: Report to GUI? +#ifndef QT_NO_OPENSSL + m_webSocketServer = new QWebSocketServer(QStringLiteral("Remote TCP Sink"), + QWebSocketServer::SecureMode, + this); + QSslConfiguration sslConfiguration; + qDebug() << "RemoteTCPSinkSink::startServer: SSL config: " << m_settings.m_certificate << m_settings.m_key; + QFile certFile(m_settings.m_certificate); + QFile keyFile(m_settings.m_key); + certFile.open(QIODevice::ReadOnly); + keyFile.open(QIODevice::ReadOnly); + QSslCertificate certificate(&certFile, QSsl::Pem); + QSslKey sslKey(&keyFile, QSsl::Rsa, QSsl::Pem); + certFile.close(); + keyFile.close(); + sslConfiguration.setPeerVerifyMode(QSslSocket::VerifyNone); + sslConfiguration.setLocalCertificate(certificate); + sslConfiguration.setPrivateKey(sslKey); + m_webSocketServer->setSslConfiguration(sslConfiguration); + + QHostAddress address(m_settings.m_dataAddress); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 4, 0) + m_webSocketServer->setSupportedSubprotocols({"binary"}); // Chrome wont connect without this - "Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received" +#endif + if (!m_webSocketServer->listen(address, m_settings.m_dataPort)) + { + qCritical() << "RemoteTCPSink failed to listen on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; + // FIXME: Report to GUI? + } + else + { + qInfo() << "RemoteTCPSink listening on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; + connect(m_webSocketServer, &QWebSocketServer::newConnection, this, &RemoteTCPSinkSink::acceptWebConnection); + connect(m_webSocketServer, &QWebSocketServer::sslErrors, this, &RemoteTCPSinkSink::onSslErrors); + } +#else + qWarning("RemoteTCPSinkSink::startServer: SSL is not supported"); +#endif } else { - qInfo() << "RemoteTCPSink listening on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; - connect(m_server, &QTcpServer::newConnection, this, &RemoteTCPSinkSink::acceptConnection); + m_server = new QTcpServer(this); + if (!m_server->listen(QHostAddress(m_settings.m_dataAddress), m_settings.m_dataPort)) + { + qCritical() << "RemoteTCPSink failed to listen on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; + // FIXME: Report to GUI? + } + else + { + qInfo() << "RemoteTCPSink listening on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; + connect(m_server, &QTcpServer::newConnection, this, &RemoteTCPSinkSink::acceptTCPConnection); + } } } void RemoteTCPSinkSink::stopServer() { - for (auto client : m_clients) - { - qDebug() << "RemoteTCPSinkSink::stopServer: Closing connection to client"; - client->close(); - delete client; - } - if (m_clients.size() > 0) - { - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgReportConnection::create(0)); - } - m_clients.clear(); + // Close connections to any existing clients + while (m_clients.size() > 0) { + m_clients[0]->close(); // This results in disconnected() being called, where we delete and remove from m_clients } + // Close server sockets if (m_server) { - qDebug() << "RemoteTCPSinkSink::stopServer: Closing old server"; + qDebug() << "RemoteTCPSinkSink::stopServer: Closing old socket server"; m_server->close(); - delete m_server; + m_server->deleteLater(); m_server = nullptr; } + if (m_webSocketServer) + { + qDebug() << "RemoteTCPSinkSink::stopServer: Closing old web socket server"; + m_webSocketServer->close(); + m_webSocketServer->deleteLater(); + m_webSocketServer = nullptr; + } } RemoteTCPProtocol::Device RemoteTCPSinkSink::getDevice() @@ -398,90 +832,218 @@ RemoteTCPProtocol::Device RemoteTCPSinkSink::getDevice() return RemoteTCPProtocol::UNKNOWN; } -void RemoteTCPSinkSink::acceptConnection() +void RemoteTCPSinkSink::acceptWebConnection() +{ + QMutexLocker mutexLocker(&m_mutex); + QWebSocket *client = m_webSocketServer->nextPendingConnection(); + + connect(client, &QWebSocket::binaryMessageReceived, this, &RemoteTCPSinkSink::processCommand); + connect(client, &QWebSocket::disconnected, this, &RemoteTCPSinkSink::disconnected); + qDebug() << "RemoteTCPSinkSink::acceptWebConnection: client connected"; + + // https://bugreports.qt.io/browse/QTBUG-125874 + QTimer::singleShot(200, this, [this, client] () { + QMutexLocker mutexLocker(&m_mutex); + m_clients.append(new WebSocket(client)); + acceptConnection(m_clients.last()); + }); +} + +void RemoteTCPSinkSink::acceptTCPConnection() { QMutexLocker mutexLocker(&m_mutex); QTcpSocket *client = m_server->nextPendingConnection(); - if (!client) { - qDebug() << "RemoteTCPSinkSink::acceptConnection: client is nullptr"; - return; - } - m_clients.append(client); - connect(client, &QIODevice::readyRead, this, &RemoteTCPSinkSink::processCommand); - connect(client, SIGNAL(disconnected()), this, SLOT(disconnected())); + connect(client, &QTcpSocket::disconnected, this, &RemoteTCPSinkSink::disconnected); #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) connect(client, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPSinkSink::errorOccurred); #else connect(client, &QAbstractSocket::errorOccurred, this, &RemoteTCPSinkSink::errorOccurred); #endif - qDebug() << "RemoteTCPSinkSink::acceptConnection: client connected"; + qDebug() << "RemoteTCPSinkSink::acceptTCPConnection: client connected"; + + QTimer::singleShot(200, this, [this, client] () { + QMutexLocker mutexLocker(&m_mutex); + m_clients.append(new TCPSocket(client)); + acceptConnection(m_clients.last()); + }); +} + +FLAC__StreamEncoderWriteStatus RemoteTCPSinkSink::flacWrite(const FLAC__StreamEncoder *encoder, const FLAC__byte buffer[], size_t bytes, uint32_t samples, uint32_t currentFrame) +{ + char header[1+4]; +//qDebug() << "RemoteTCPSinkSink::flacWrite bytes" << bytes << "samples" << samples; + // Save FLAC header for clients that connect later + if ((currentFrame == 0) && (samples == 0)) + { + m_flacHeader.append((const char *) buffer, bytes); + + // Write complete header to all clients + if (m_flacHeader.size() == m_flacHeaderSize) + { + header[0] = (char) RemoteTCPProtocol::dataIQFLAC; + RemoteTCPProtocol::encodeUInt32((quint8 *) &header[1], m_flacHeader.size()); + + for (auto client : m_clients) + { + client->write(header, sizeof(header)); + client->write(m_flacHeader.constData(), m_flacHeader.size()); + m_bytesTransmitted += sizeof(header) + m_flacHeader.size(); + client->flush(); + } + } + } + else + { + // Send compressed IQ data to max number of clients + header[0] = (char) RemoteTCPProtocol::dataIQFLAC; + RemoteTCPProtocol::encodeUInt32((quint8 *) &header[1], bytes); + int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); + for (int i = 0; i < clients; i++) + { + Socket *client = m_clients[i]; + client->write(header, sizeof(header)); + client->write((const char *) buffer, bytes); + m_bytesTransmitted += sizeof(header) + bytes; + client->flush(); + } + } + m_bytesCompressed += sizeof(header) + bytes; + + return FLAC__STREAM_ENCODER_WRITE_STATUS_OK; +} + +void RemoteTCPSinkSink::acceptConnection(Socket *client) +{ if (m_settings.m_protocol == RemoteTCPSinkSettings::RTL0) { quint8 metaData[RemoteTCPProtocol::m_rtl0MetaDataSize] = {'R', 'T', 'L', '0'}; RemoteTCPProtocol::encodeUInt32(&metaData[4], getDevice()); // Tuner ID RemoteTCPProtocol::encodeUInt32(&metaData[8], 1); // Gain stages client->write((const char *)metaData, sizeof(metaData)); + m_bytesTransmitted += sizeof(metaData); + client->flush(); } else { quint8 metaData[RemoteTCPProtocol::m_sdraMetaDataSize] = {'S', 'D', 'R', 'A'}; RemoteTCPProtocol::encodeUInt32(&metaData[4], getDevice()); - // Send device/channel settings, so they can be displayed in the remote GUI - double centerFrequency = 0.0; - qint32 ppmCorrection = 0; - quint32 flags = 0; - int biasTeeEnabled = false; - int directSampling = false; - int agc = false; - int dcOffsetRemoval = false; - int iqCorrection = false; - qint32 devSampleRate = 0; - qint32 log2Decim = 0; - qint32 gain[4] = {0, 0, 0, 0}; - qint32 rfBW = 0; - - ChannelWebAPIUtils::getCenterFrequency(m_deviceIndex, centerFrequency); - ChannelWebAPIUtils::getLOPpmCorrection(m_deviceIndex, ppmCorrection); - ChannelWebAPIUtils::getDevSampleRate(m_deviceIndex, devSampleRate); - ChannelWebAPIUtils::getSoftDecim(m_deviceIndex, log2Decim); + // Send device/channel settings, so they can be displayed in the remote GUI + ChannelWebAPIUtils::getCenterFrequency(m_deviceIndex, m_centerFrequency); + ChannelWebAPIUtils::getLOPpmCorrection(m_deviceIndex, m_ppmCorrection); + ChannelWebAPIUtils::getDevSampleRate(m_deviceIndex, m_devSampleRate); + ChannelWebAPIUtils::getSoftDecim(m_deviceIndex, m_log2Decim); for (int i = 0; i < 4; i++) { - ChannelWebAPIUtils::getGain(m_deviceIndex, i, gain[i]); - } - ChannelWebAPIUtils::getRFBandwidth(m_deviceIndex, rfBW); - ChannelWebAPIUtils::getBiasTee(m_deviceIndex, biasTeeEnabled); - ChannelWebAPIUtils::getDeviceSetting(m_deviceIndex, "noModMode", directSampling); - ChannelWebAPIUtils::getAGC(m_deviceIndex, agc); - ChannelWebAPIUtils::getDCOffsetRemoval(m_deviceIndex, dcOffsetRemoval); - ChannelWebAPIUtils::getIQCorrection(m_deviceIndex, iqCorrection); - flags = (iqCorrection << 4) - | (dcOffsetRemoval << 3) - | (agc << 2) - | (directSampling << 1) - | biasTeeEnabled; - - RemoteTCPProtocol::encodeUInt64(&metaData[8], (quint64)centerFrequency); - RemoteTCPProtocol::encodeUInt32(&metaData[16], ppmCorrection); + ChannelWebAPIUtils::getGain(m_deviceIndex, i, m_gain[i]); + } + ChannelWebAPIUtils::getRFBandwidth(m_deviceIndex, m_rfBW); + ChannelWebAPIUtils::getBiasTee(m_deviceIndex, m_biasTeeEnabled); + ChannelWebAPIUtils::getDeviceSetting(m_deviceIndex, "noModMode", m_directSampling); + ChannelWebAPIUtils::getAGC(m_deviceIndex, m_agc); + ChannelWebAPIUtils::getDCOffsetRemoval(m_deviceIndex, m_dcOffsetRemoval); + ChannelWebAPIUtils::getIQCorrection(m_deviceIndex, m_iqCorrection); + + quint32 flags = ((!m_settings.m_iqOnly) << 7) + | (m_settings.m_remoteControl << 6) + | (m_settings.m_squelchEnabled << 5) + | (m_iqCorrection << 4) + | (m_dcOffsetRemoval << 3) + | (m_agc << 2) + | (m_directSampling << 1) + | m_biasTeeEnabled; + + RemoteTCPProtocol::encodeUInt64(&metaData[8], (quint64)m_centerFrequency); + RemoteTCPProtocol::encodeUInt32(&metaData[16], m_ppmCorrection); RemoteTCPProtocol::encodeUInt32(&metaData[20], flags); - RemoteTCPProtocol::encodeUInt32(&metaData[24], devSampleRate); - RemoteTCPProtocol::encodeUInt32(&metaData[28], log2Decim); - RemoteTCPProtocol::encodeInt16(&metaData[32], gain[0]); - RemoteTCPProtocol::encodeInt16(&metaData[34], gain[1]); - RemoteTCPProtocol::encodeInt16(&metaData[36], gain[2]); - RemoteTCPProtocol::encodeInt16(&metaData[38], gain[3]); - RemoteTCPProtocol::encodeUInt32(&metaData[40], rfBW); + RemoteTCPProtocol::encodeUInt32(&metaData[24], m_devSampleRate); + RemoteTCPProtocol::encodeUInt32(&metaData[28], m_log2Decim); + RemoteTCPProtocol::encodeInt16(&metaData[32], m_gain[0]); + RemoteTCPProtocol::encodeInt16(&metaData[34], m_gain[1]); + RemoteTCPProtocol::encodeInt16(&metaData[36], m_gain[2]); + RemoteTCPProtocol::encodeInt16(&metaData[38], m_gain[3]); + RemoteTCPProtocol::encodeUInt32(&metaData[40], m_rfBW); RemoteTCPProtocol::encodeInt32(&metaData[44], m_settings.m_inputFrequencyOffset); RemoteTCPProtocol::encodeUInt32(&metaData[48], m_settings.m_gain); RemoteTCPProtocol::encodeUInt32(&metaData[52], m_settings.m_channelSampleRate); RemoteTCPProtocol::encodeUInt32(&metaData[56], m_settings.m_sampleBits); + RemoteTCPProtocol::encodeUInt32(&metaData[60], 1); // Protocol revision. 0=64 byte meta data, 1=128 byte meta data + RemoteTCPProtocol::encodeFloat(&metaData[64], m_settings.m_squelch); + RemoteTCPProtocol::encodeFloat(&metaData[68], m_settings.m_squelchGate); // Send API port? Not accessible via MainCore client->write((const char *)metaData, sizeof(metaData)); + m_bytesTransmitted += sizeof(metaData); + client->flush(); + + // Inform client if they are in a queue + if (!m_settings.m_iqOnly && (m_clients.size() > m_settings.m_maxClients)) { + sendQueuePosition(client, m_clients.size() - m_settings.m_maxClients); + } + + // Send existing FLAC header to new client + if (!m_settings.m_iqOnly && (m_settings.m_compression == RemoteTCPSinkSettings::FLAC) && (m_flacHeader.size() == m_flacHeaderSize)) + { + char header[1+4]; + + header[0] = (char) RemoteTCPProtocol::dataIQFLAC; + RemoteTCPProtocol::encodeUInt32((quint8 *) &header[1], m_flacHeader.size()); + client->write(header, sizeof(header)); + client->write(m_flacHeader.constData(), m_flacHeader.size()); + m_bytesTransmitted += sizeof(header) + m_flacHeader.size(); + client->flush(); + } + + // Send position / direction of antenna + sendPosition(); + if (m_settings.m_isotropic) { + sendDirection(true, std::numeric_limits::quiet_NaN(), std::numeric_limits::quiet_NaN()); + } else if (m_settings.m_rotator == "None") { + sendDirection(false, m_settings.m_azimuth, m_settings.m_elevation); + } else { + sendRotatorDirection(true); + } } + + // Create timer to disconnect client after timelimit reached + QTimer *timer = new QTimer(); + timer->setSingleShot(true); + timer->callOnTimeout(this, [this, client] () { + qDebug() << "Disconnecting" << client->peerAddress() << "as time limit reached"; + if (m_settings.m_compression) { + sendTimeLimit(client); + } + client->close(); + }); + if (m_settings.m_timeLimit > 0) + { + timer->setInterval(m_settings.m_timeLimit * 60 * 1000); + // Only start timer if we will receive data immediately + if (m_clients.size() <= m_settings.m_maxClients) { + timer->start(); + } + } + m_timers.append(timer); + + // Close connection if blacklisted + for (const auto& ip : m_settings.m_ipBlacklist) + { + QHostAddress address(ip); + if (address == client->peerAddress()) + { + qDebug() << "Disconnecting" << client->peerAddress() << "as blacklisted"; + if (m_settings.m_compression) { + sendBlacklisted(client); + } + client->close(); + break; + } + } + + m_messageQueueToChannel->push(RemoteTCPSink::MsgReportConnection::create(m_clients.size(), client->peerAddress(), client->peerPort())); if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgReportConnection::create(m_clients.size())); + m_messageQueueToGUI->push(RemoteTCPSink::MsgReportConnection::create(m_clients.size(), client->peerAddress(), client->peerPort())); } } @@ -489,14 +1051,53 @@ void RemoteTCPSinkSink::disconnected() { QMutexLocker mutexLocker(&m_mutex); qDebug() << "RemoteTCPSinkSink::disconnected"; - QTcpSocket *client = (QTcpSocket*)sender(); - client->deleteLater(); - m_clients.removeAll(client); - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgReportConnection::create(m_clients.size())); + QObject *object = sender(); + QMutableListIterator itr(m_clients); + + // Remove from list of clients + int idx = 0; + while (itr.hasNext()) + { + Socket *socket = itr.next(); + if (socket->socket() == object) + { + itr.remove(); + delete m_timers.takeAt(idx); + m_messageQueueToChannel->push(RemoteTCPSink::MsgReportDisconnect::create(m_clients.size(), socket->peerAddress(), socket->peerPort())); + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgReportDisconnect::create(m_clients.size(), socket->peerAddress(), socket->peerPort())); + } + socket->deleteLater(); + break; + } + else + { + idx++; + } + } + + // Start timer for next waiting client + if ((idx < m_settings.m_maxClients) && (m_settings.m_timeLimit > 0)) + { + int newActiveIdx = m_settings.m_maxClients - 1; + if (newActiveIdx < m_clients.size()) { + m_timers[newActiveIdx]->start(); + } + } + + // Update other clients waiting with current queue position + for (int i = m_settings.m_maxClients; i < m_clients.size(); i++) { + sendQueuePosition(m_clients[i], i - m_settings.m_maxClients + 1); } } +#ifndef QT_NO_OPENSSL +void RemoteTCPSinkSink::onSslErrors(const QList &errors) +{ + qWarning() << "RemoteTCPSinkSink::onSslErrors: " << errors; +} +#endif + void RemoteTCPSinkSink::errorOccurred(QAbstractSocket::SocketError socketError) { qDebug() << "RemoteTCPSinkSink::errorOccurred: " << socketError; @@ -507,180 +1108,606 @@ void RemoteTCPSinkSink::errorOccurred(QAbstractSocket::SocketError socketError) }*/ } +Socket *RemoteTCPSinkSink::getSocket(QObject *object) const +{ + for (const auto client : m_clients) + { + if (client->socket() == object) { + return client; + } + } + + return nullptr; +} + void RemoteTCPSinkSink::processCommand() { QMutexLocker mutexLocker(&m_mutex); - QTcpSocket *client = (QTcpSocket*)sender(); + Socket *client = getSocket(sender()); RemoteTCPSinkSettings settings = m_settings; - quint8 cmd[5]; + while (client && (client->bytesAvailable() >= (qint64)sizeof(cmd))) { int len = client->read((char *)cmd, sizeof(cmd)); + if (len == sizeof(cmd)) { - switch (cmd[0]) + if (cmd[0] == RemoteTCPProtocol::sendMessage) { - case RemoteTCPProtocol::setCenterFrequency: + quint32 msgLen = RemoteTCPProtocol::extractUInt32(&cmd[1]); + try + { + char *buf = new char[msgLen]; + len = client->read((char *)buf, msgLen); + if (len == msgLen) + { + bool broadcast = (bool) buf[0]; + int i; + for (i = 1; i < (int) msgLen; i++) + { + if (buf[i] == '\0') { + break; + } + } + QString callsign = QString::fromUtf8(&buf[1]); + QString text = QString::fromUtf8(&buf[i+1]); + + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgSendMessage::create(client->peerAddress(), client->peerPort(), callsign, text, broadcast)); + } + } + else + { + qDebug() << "RemoteTCPSinkSink::processCommand: sendMessage: Failed to read" << msgLen; + } + delete[] buf; + } + catch(std::bad_alloc&) + { + qDebug() << "RemoteTCPSinkSink::processCommand: sendMessage - Failed to allocate" << msgLen; + } + } + else if (!m_settings.m_remoteControl) { - quint64 centerFrequency = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set center frequency " << centerFrequency; - ChannelWebAPIUtils::setCenterFrequency(m_deviceIndex, (double)centerFrequency); - break; + qDebug() << "RemoteTCPSinkSink::processCommand: Ignoring command from client as remote control disabled"; } - case RemoteTCPProtocol::setSampleRate: + else { - int sampleRate = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set sample rate " << sampleRate; - ChannelWebAPIUtils::setDevSampleRate(m_deviceIndex, sampleRate); - if (m_settings.m_protocol == RemoteTCPSinkSettings::RTL0) + switch (cmd[0]) + { + case RemoteTCPProtocol::setCenterFrequency: + { + quint64 centerFrequency = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set center frequency " << centerFrequency; + ChannelWebAPIUtils::setCenterFrequency(m_deviceIndex, (double)centerFrequency); + break; + } + case RemoteTCPProtocol::setSampleRate: + { + int sampleRate = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set sample rate " << sampleRate; + ChannelWebAPIUtils::setDevSampleRate(m_deviceIndex, sampleRate); + if (m_settings.m_protocol == RemoteTCPSinkSettings::RTL0) + { + // Match channel sample rate with device sample rate for RTL0 protocol + ChannelWebAPIUtils::setSoftDecim(m_deviceIndex, 0); + settings.m_channelSampleRate = sampleRate; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false)); + } + } + break; + } + case RemoteTCPProtocol::setTunerGainMode: + // SDRangel's rtlsdr sample source always has this fixed as 1, so nothing to do + break; + case RemoteTCPProtocol::setTunerGain: // gain is gain in 10th of a dB + { + int gain = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set gain " << gain; + ChannelWebAPIUtils::setGain(m_deviceIndex, 0, gain); + break; + } + case RemoteTCPProtocol::setFrequencyCorrection: + { + int ppm = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set LO ppm correction " << ppm; + ChannelWebAPIUtils::setLOPpmCorrection(m_deviceIndex, ppm); + break; + } + case RemoteTCPProtocol::setTunerIFGain: + { + int v = RemoteTCPProtocol::extractUInt32(&cmd[1]); + int gain = (int)(qint16)(v & 0xffff); + int stage = (v >> 16) & 0xffff; + ChannelWebAPIUtils::setGain(m_deviceIndex, stage, gain); + break; + } + case RemoteTCPProtocol::setAGCMode: + { + int agc = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set AGC " << agc; + ChannelWebAPIUtils::setAGC(m_deviceIndex, agc); + break; + } + case RemoteTCPProtocol::setDirectSampling: + { + int ds = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set direct sampling " << ds; + ChannelWebAPIUtils::patchDeviceSetting(m_deviceIndex, "noModMode", ds); // RTLSDR only + break; + } + case RemoteTCPProtocol::setBiasTee: + { + int biasTee = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set bias tee " << biasTee; + ChannelWebAPIUtils::setBiasTee(m_deviceIndex, biasTee); + break; + } + case RemoteTCPProtocol::setTunerBandwidth: + { + int rfBW = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set tuner bandwidth " << rfBW; + ChannelWebAPIUtils::setRFBandwidth(m_deviceIndex, rfBW); + break; + } + case RemoteTCPProtocol::setDecimation: + { + int dec = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set decimation " << dec; + ChannelWebAPIUtils::setSoftDecim(m_deviceIndex, dec); + break; + } + case RemoteTCPProtocol::setDCOffsetRemoval: + { + int dc = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set DC offset removal " << dc; + ChannelWebAPIUtils::setDCOffsetRemoval(m_deviceIndex, dc); + break; + } + case RemoteTCPProtocol::setIQCorrection: + { + int iq = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set IQ correction " << iq; + ChannelWebAPIUtils::setIQCorrection(m_deviceIndex, iq); + break; + } + case RemoteTCPProtocol::setChannelSampleRate: + { + int channelSampleRate = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set channel sample rate " << channelSampleRate; + bool restartRequired; + if (channelSampleRate <= m_settings.m_maxSampleRate) + { + settings.m_channelSampleRate = channelSampleRate; + restartRequired = false; + } + else + { + settings.m_channelSampleRate = m_settings.m_maxSampleRate; + restartRequired = true; // Need to restart so client gets max sample rate + } + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, restartRequired)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, restartRequired)); + } + break; + } + case RemoteTCPProtocol::setChannelFreqOffset: + { + int offset = (int)RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set channel input frequency offset " << offset; + settings.m_inputFrequencyOffset = offset; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"inputFrequencyOffset"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"inputFrequencyOffset"}, false)); + } + break; + } + case RemoteTCPProtocol::setChannelGain: + { + int gain = (int)RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set channel gain " << gain; + settings.m_gain = gain; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"gain"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"gain"}, false)); + } + break; + } + case RemoteTCPProtocol::setSampleBitDepth: + { + int bits = RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set sample bit depth " << bits; + settings.m_sampleBits = bits; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"sampleBits"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"sampleBits"}, false)); + } + break; + } + case RemoteTCPProtocol::setIQSquelchEnabled: + { + bool enabled = (bool) RemoteTCPProtocol::extractUInt32(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set IQ squelch enabled " << enabled; + settings.m_squelchEnabled = enabled; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelchEnabled"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelchEnabled"}, false)); + } + break; + } + case RemoteTCPProtocol::setIQSquelch: + { + float squelch = RemoteTCPProtocol::extractFloat(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set IQ squelch " << squelch; + settings.m_squelch = squelch; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelch"}, false)); + } + if (m_messageQueueToChannel) { + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelch"}, false)); + } + break; + } + case RemoteTCPProtocol::setIQSquelchGate: { - // Match channel sample rate with device sample rate for RTL0 protocol - ChannelWebAPIUtils::setSoftDecim(m_deviceIndex, 0); - settings.m_channelSampleRate = sampleRate; + float squelchGate = RemoteTCPProtocol::extractFloat(&cmd[1]); + qDebug() << "RemoteTCPSinkSink::processCommand: set IQ squelch gate " << squelchGate; + settings.m_squelchGate = squelchGate; if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, true)); + m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelchGate"}, false)); } if (m_messageQueueToChannel) { - m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, true)); + m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"squelchGate"}, false)); } + break; + } + default: + qDebug() << "RemoteTCPSinkSink::processCommand: unknown command " << cmd[0]; + break; } - break; } - case RemoteTCPProtocol::setTunerGainMode: - // SDRangel's rtlsdr sample source always has this fixed as 1, so nothing to do - break; - case RemoteTCPProtocol::setTunerGain: // gain is gain in 10th of a dB - { - int gain = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set gain " << gain; - ChannelWebAPIUtils::setGain(m_deviceIndex, 0, gain); - break; + } + else + { + qDebug() << "RemoteTCPSinkSink::processCommand: read only " << len << " bytes - Expecting " << sizeof(cmd); + } + } +} + +void RemoteTCPSinkSink::sendCommand(RemoteTCPProtocol::Command cmdId, quint32 value) +{ + QMutexLocker mutexLocker(&m_mutex); + quint8 cmd[5]; + + cmd[0] = (quint8) cmdId; + RemoteTCPProtocol::encodeUInt32(&cmd[1], value); + + for (const auto client : m_clients) + { + qint64 len = client->write((char *) cmd, sizeof(cmd)); + if (len != sizeof(cmd)) { + qDebug() << "RemoteTCPSinkSink::sendCommand: Failed to write all of message:" << len; + } + m_bytesTransmitted += sizeof(cmd); + client->flush(); + } +} + +void RemoteTCPSinkSink::sendCommandFloat(RemoteTCPProtocol::Command cmdId, float value) +{ + QMutexLocker mutexLocker(&m_mutex); + quint8 cmd[5]; + + cmd[0] = (quint8) cmdId; + RemoteTCPProtocol::encodeFloat(&cmd[1], value); + + for (const auto client : m_clients) + { + qint64 len = client->write((char *) cmd, sizeof(cmd)); + if (len != sizeof(cmd)) { + qDebug() << "RemoteTCPSinkSink::sendCommand: Failed to write all of message:" << len; + } + m_bytesTransmitted += sizeof(cmd); + client->flush(); + } +} + +void RemoteTCPSinkSink::sendMessage(QHostAddress address, quint16 port, const QString& callsign, const QString& text, bool broadcast) +{ + qint64 len; + char cmd[1+4+1]; + QByteArray callsignBytes = callsign.toUtf8(); + QByteArray textBytes = text.toUtf8(); + QByteArray bytes; + + bytes.append(callsignBytes); + bytes.append('\0'); + bytes.append(textBytes); + bytes.append('\0'); + + cmd[0] = (char) RemoteTCPProtocol::sendMessage; + RemoteTCPProtocol::encodeUInt32((quint8*) &cmd[1], bytes.size() + 1); + cmd[5] = (char) broadcast; + + for (const auto client : m_clients) + { + bool addressMatch = (address == client->peerAddress()) && (port == client->peerPort()); + if ((broadcast && !addressMatch) || (!broadcast && addressMatch)) + { + len = client->write(cmd, sizeof(cmd)); + if (len != sizeof(cmd)) { + qDebug() << "RemoteTCPSinkSink::sendMessage: Failed to write all of message header:" << len; } - case RemoteTCPProtocol::setFrequencyCorrection: - { - int ppm = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set LO ppm correction " << ppm; - ChannelWebAPIUtils::setLOPpmCorrection(m_deviceIndex, ppm); - break; + len = client->write(bytes.data(), bytes.size()); + if (len != bytes.size()) { + qDebug() << "RemoteTCPSinkSink::sendMessage: Failed to write all of message:" << len; } - case RemoteTCPProtocol::setTunerIFGain: + m_bytesTransmitted += sizeof(cmd) + bytes.size(); + client->flush(); + qDebug() << "RemoteTCPSinkSink::sendMessage:" << client->peerAddress() << client->peerPort() << text; + } + } +} + +void RemoteTCPSinkSink::sendQueuePosition(Socket *client, int position) +{ + QString callsign = MainCore::instance()->getSettings().getStationName(); + + sendMessage(client->peerAddress(), client->peerPort(), callsign, QString("Server busy. You are number %1 in the queue.").arg(position), false); +} + +void RemoteTCPSinkSink::sendBlacklisted(Socket *client) +{ + char cmd[1+4]; + + cmd[0] = (char) RemoteTCPProtocol::sendBlacklistedMessage; + RemoteTCPProtocol::encodeUInt32((quint8*) &cmd[1], 0); + + qint64 len = client->write(cmd, sizeof(cmd)); + if (len != sizeof(cmd)) { + qDebug() << "RemoteTCPSinkSink::sendBlacklisted: Failed to write all of message:" << len; + } + m_bytesTransmitted += sizeof(cmd); + client->flush(); +} + +void RemoteTCPSinkSink::sendTimeLimit(Socket *client) +{ + QString callsign = MainCore::instance()->getSettings().getStationName(); + + sendMessage(client->peerAddress(), client->peerPort(), callsign, "Time limit reached.", false); +} + +void RemoteTCPSinkSink::sendPosition(float latitude, float longitude, float altitude) +{ + char msg[1+4+4+4+4]; + msg[0] = (char) RemoteTCPProtocol::dataPosition; + RemoteTCPProtocol::encodeUInt32((quint8 *) &msg[1], 4+4+4); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4], latitude); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4], longitude); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4+4], altitude); + + int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); + for (int i = 0; i < clients; i++) + { + Socket *client = m_clients[i]; + client->write(msg, sizeof(msg)); + m_bytesTransmitted += sizeof(msg); + client->flush(); + } +} + +void RemoteTCPSinkSink::sendDirection(bool isotropic, float azimuth, float elevation) +{ + char msg[1+4+4+4+4]; + msg[0] = (char) RemoteTCPProtocol::dataDirection; + RemoteTCPProtocol::encodeUInt32((quint8 *) &msg[1], 4+4+4); + RemoteTCPProtocol::encodeUInt32((quint8 *) &msg[1+4], isotropic); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4], azimuth); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4+5], elevation); + + int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); + for (int i = 0; i < clients; i++) + { + Socket *client = m_clients[i]; + client->write(msg, sizeof(msg)); + m_bytesTransmitted += sizeof(msg); + client->flush(); + } +} + +void RemoteTCPSinkSink::sendPosition() +{ + float latitude = MainCore::instance()->getSettings().getLatitude(); + float longitude = MainCore::instance()->getSettings().getLongitude(); + float altitude = MainCore::instance()->getSettings().getAltitude(); + + // Use device postion in preference to My Position + ChannelWebAPIUtils::getDevicePosition(m_deviceIndex, latitude, longitude, altitude); + + sendPosition(latitude, longitude, altitude); +} + +void RemoteTCPSinkSink::sendRotatorDirection(bool force) +{ + unsigned int rotatorFeatureSetIndex; + unsigned int rotatorFeatureIndex; + + if (MainCore::getFeatureIndexFromId(m_settings.m_rotator, rotatorFeatureSetIndex, rotatorFeatureIndex)) + { + double azimuth; + double elevation; + + if (ChannelWebAPIUtils::getFeatureReportValue(rotatorFeatureSetIndex, rotatorFeatureIndex, "currentAzimuth", azimuth) + && ChannelWebAPIUtils::getFeatureReportValue(rotatorFeatureSetIndex, rotatorFeatureIndex, "currentElevation", elevation)) + { + if (force || ((azimuth != m_azimuth) || (elevation != m_elevation))) { - int v = RemoteTCPProtocol::extractUInt32(&cmd[1]); - int gain = (int)(qint16)(v & 0xffff); - int stage = (v >> 16) & 0xffff; - ChannelWebAPIUtils::setGain(m_deviceIndex, stage, gain); - break; + sendDirection(false, (float) azimuth, (float) elevation); + m_azimuth = azimuth; + m_elevation = elevation; } - case RemoteTCPProtocol::setAGCMode: + } + } +} + +void RemoteTCPSinkSink::preferenceChanged(int elementType) +{ + Preferences::ElementType pref = (Preferences::ElementType)elementType; + + if ((pref == Preferences::Latitude) || (pref == Preferences::Longitude) || (pref == Preferences::Altitude)) { + sendPosition(); + } +} + +// Poll for changes to device settings - FIXME: Need a signal from DeviceAPI - reverseAPI? +void RemoteTCPSinkSink::checkDeviceSettings() +{ + if ((m_settings.m_protocol != RemoteTCPSinkSettings::RTL0) && !m_settings.m_iqOnly) + { + // Forward device settings to clients if they've changed + + double centerFrequency; + if (ChannelWebAPIUtils::getCenterFrequency(m_deviceIndex, centerFrequency)) + { + if (centerFrequency != m_centerFrequency) { - int agc = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set AGC " << agc; - ChannelWebAPIUtils::setAGC(m_deviceIndex, agc); - break; + m_centerFrequency = centerFrequency; + sendCommand(RemoteTCPProtocol::setCenterFrequency, m_centerFrequency); } - case RemoteTCPProtocol::setDirectSampling: + } + + int ppmCorrection; + if (ChannelWebAPIUtils::getLOPpmCorrection(m_deviceIndex, ppmCorrection)) + { + if (ppmCorrection != m_ppmCorrection) { - int ds = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set direct sampling " << ds; - ChannelWebAPIUtils::patchDeviceSetting(m_deviceIndex, "noModMode", ds); // RTLSDR only - break; + m_ppmCorrection = ppmCorrection; + sendCommand(RemoteTCPProtocol::setFrequencyCorrection, m_ppmCorrection); } - case RemoteTCPProtocol::setBiasTee: + } + + int biasTeeEnabled; + if (ChannelWebAPIUtils::getBiasTee(m_deviceIndex, biasTeeEnabled)) + { + if (biasTeeEnabled != m_biasTeeEnabled) { - int biasTee = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set bias tee " << biasTee; - ChannelWebAPIUtils::setBiasTee(m_deviceIndex, biasTee); - break; + m_biasTeeEnabled = biasTeeEnabled; + sendCommand(RemoteTCPProtocol::setBiasTee, m_biasTeeEnabled); } - case RemoteTCPProtocol::setTunerBandwidth: + } + + int directSampling; + if (ChannelWebAPIUtils::getDeviceSetting(m_deviceIndex, "noModMode", directSampling)) + { + if (directSampling != m_directSampling) { - int rfBW = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set tuner bandwidth " << rfBW; - ChannelWebAPIUtils::setRFBandwidth(m_deviceIndex, rfBW); - break; + m_directSampling = directSampling; + sendCommand(RemoteTCPProtocol::setDirectSampling, m_directSampling); } - case RemoteTCPProtocol::setDecimation: + } + + int agc; + if (ChannelWebAPIUtils::getAGC(m_deviceIndex, agc)) + { + if (agc != m_agc) { - int dec = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set decimation " << dec; - ChannelWebAPIUtils::setSoftDecim(m_deviceIndex, dec); - break; + m_agc = agc; + sendCommand(RemoteTCPProtocol::setAGCMode, m_agc); } - case RemoteTCPProtocol::setDCOffsetRemoval: + } + + int dcOffsetRemoval; + if (ChannelWebAPIUtils::getDCOffsetRemoval(m_deviceIndex, dcOffsetRemoval)) + { + if (dcOffsetRemoval != m_dcOffsetRemoval) { - int dc = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set DC offset removal " << dc; - ChannelWebAPIUtils::setDCOffsetRemoval(m_deviceIndex, dc); - break; + m_dcOffsetRemoval = dcOffsetRemoval; + sendCommand(RemoteTCPProtocol::setDCOffsetRemoval, m_dcOffsetRemoval); } - case RemoteTCPProtocol::setIQCorrection: + } + + int iqCorrection; + if (ChannelWebAPIUtils::getIQCorrection(m_deviceIndex, iqCorrection)) + { + if (iqCorrection != m_iqCorrection) { - int iq = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set IQ correction " << iq; - ChannelWebAPIUtils::setIQCorrection(m_deviceIndex, iq); - break; + m_iqCorrection = iqCorrection; + sendCommand(RemoteTCPProtocol::setIQCorrection, m_iqCorrection); } - case RemoteTCPProtocol::setChannelSampleRate: + } + + qint32 devSampleRate; + if (ChannelWebAPIUtils::getDevSampleRate(m_deviceIndex, devSampleRate)) + { + if (devSampleRate != m_devSampleRate) { - int channelSampleRate = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set channel sample rate " << channelSampleRate; - settings.m_channelSampleRate = channelSampleRate; - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, true)); - } - if (m_messageQueueToChannel) { - m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"channelSampleRate"}, false, true)); - } - break; + m_devSampleRate = devSampleRate; + sendCommand(RemoteTCPProtocol::setSampleRate, m_devSampleRate); } - case RemoteTCPProtocol::setChannelFreqOffset: + } + + qint32 log2Decim; + if (ChannelWebAPIUtils::getSoftDecim(m_deviceIndex, log2Decim)) + { + if (log2Decim != m_log2Decim) { - int offset = (int)RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set channel input frequency offset " << offset; - settings.m_inputFrequencyOffset = offset; - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"inputFrequencyOffset"}, false, true)); - } - if (m_messageQueueToChannel) { - m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"inputFrequencyOffset"}, false, true)); - } - break; + m_log2Decim = log2Decim; + sendCommand(RemoteTCPProtocol::setDecimation, m_log2Decim); } - case RemoteTCPProtocol::setChannelGain: + } + + + qint32 rfBW; + if (ChannelWebAPIUtils::getRFBandwidth(m_deviceIndex, rfBW)) + { + if (rfBW != m_rfBW) { - int gain = (int)RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set channel gain " << gain; - settings.m_gain = gain; - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"gain"}, false, true)); - } - if (m_messageQueueToChannel) { - m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"gain"}, false, true)); - } - break; + m_rfBW = rfBW; + sendCommand(RemoteTCPProtocol::setTunerBandwidth, m_rfBW); } - case RemoteTCPProtocol::setSampleBitDepth: + } + + for (int i = 0; i < 4; i++) + { + qint32 gain; + if (ChannelWebAPIUtils::getGain(m_deviceIndex, i, gain)) { - int bits = RemoteTCPProtocol::extractUInt32(&cmd[1]); - qDebug() << "RemoteTCPSinkSink::processCommand: set sample bit depth " << bits; - settings.m_sampleBits = bits; - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"sampleBits"}, false, true)); - } - if (m_messageQueueToChannel) { - m_messageQueueToChannel->push(RemoteTCPSink::MsgConfigureRemoteTCPSink::create(settings, {"sampleBits"}, false, true)); + if (gain != m_gain[i]) + { + m_gain[i] = gain; + if (i == 0) + { + sendCommand(RemoteTCPProtocol::setTunerGain, gain); + } + else + { + int v = (gain & 0xffff) | (i << 16); + sendCommand(RemoteTCPProtocol::setTunerIFGain, v); + } } - break; - } - default: - qDebug() << "RemoteTCPSinkSink::processCommand: unknown command " << cmd[0]; - break; } } - else - { - qDebug() << "RemoteTCPSinkSink::processCommand: read only " << len << " bytes - Expecting " << sizeof(cmd); + + if (!m_settings.m_isotropic && !m_settings.m_rotator.isEmpty() && (m_settings.m_rotator != "None")) { + sendRotatorDirection(false); } + } } diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.h b/plugins/channelrx/remotetcpsink/remotetcpsinksink.h index d012bdc6de..dd5f347392 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.h @@ -1,5 +1,5 @@ /////////////////////////////////////////////////////////////////////////////////// -// Copyright (C) 2022-2023 Jon Beniston, M7RCE // +// Copyright (C) 2022-2024 Jon Beniston, M7RCE // // Copyright (C) 2022 Jiří Pinkava // // // // This program is free software; you can redistribute it and/or modify // @@ -23,16 +23,25 @@ #include #include #include +#include #include +#include + +#include +#include #include "dsp/channelsamplesink.h" #include "dsp/nco.h" #include "dsp/interpolator.h" #include "util/messagequeue.h" +#include "util/movingaverage.h" +#include "util/doublebufferfifo.h" #include "remotetcpsinksettings.h" #include "remotetcpprotocol.h" +#include "socket.h" + class DeviceSampleSource; class RemoteTCPSinkSink : public QObject, public ChannelSampleSink { @@ -47,22 +56,75 @@ class RemoteTCPSinkSink : public QObject, public ChannelSampleSink { void stop(); void init(); - void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool remoteChange = false); + void applySettings(const RemoteTCPSinkSettings& settings, const QStringList& settingsKeys, bool force = false, bool restartRequired = false); void applyChannelSettings(int channelSampleRate, int channelFrequencyOffset, bool force = false); void setDeviceIndex(uint32_t deviceIndex) { m_deviceIndex = deviceIndex; } void setChannelIndex(uint32_t channelIndex) { m_channelIndex = channelIndex; } void setMessageQueueToGUI(MessageQueue *queue) { m_messageQueueToGUI = queue; } void setMessageQueueToChannel(MessageQueue *queue) { m_messageQueueToChannel = queue; } + void acceptConnection(Socket *client); + Socket *getSocket(QObject *object) const; + void sendCommand(RemoteTCPProtocol::Command cmd, quint32 value); + void sendCommandFloat(RemoteTCPProtocol::Command cmd, float value); + void sendMessage(QHostAddress address, quint16 port, const QString& callsign, const QString& text, bool broadcast); + + FLAC__StreamEncoderWriteStatus flacWrite(const FLAC__StreamEncoder *encoder, const FLAC__byte buffer[], size_t bytes, uint32_t samples, uint32_t currentFrame); + + bool getSquelchOpen() const { return m_squelchOpen; } + + void getMagSqLevels(double& avg, double& peak, int& nbSamples) + { + if (m_magsqCount > 0) + { + m_magsq = m_magsqSum / m_magsqCount; + m_magSqLevelStore.m_magsq = m_magsq; + m_magSqLevelStore.m_magsqPeak = m_magsqPeak; + } + + avg = m_magSqLevelStore.m_magsq; + peak = m_magSqLevelStore.m_magsqPeak; + nbSamples = m_magsqCount == 0 ? 1 : m_magsqCount; + + m_magsqSum = 0.0f; + m_magsqPeak = 0.0f; + m_magsqCount = 0; + } + +private: + void sendQueuePosition(Socket *client, int position); + void sendBlacklisted(Socket *client); + void sendTimeLimit(Socket *client); + void sendPosition(float latitude, float longitude, float altitude); + void sendDirection(bool isotropic, float azimuth, float elevation); + void sendPosition(); + void sendRotatorDirection(bool force); private slots: - void acceptConnection(); + void acceptTCPConnection(); + void acceptWebConnection(); void disconnected(); void errorOccurred(QAbstractSocket::SocketError socketError); void processCommand(); void started(); void finished(); +#ifndef QT_NO_OPENSSL + void onSslErrors(const QList &errors); +#endif + void preferenceChanged(int elementType); + void checkDeviceSettings(); private: + + struct MagSqLevelsStore + { + MagSqLevelsStore() : + m_magsq(1e-12), + m_magsqPeak(1e-12) + {} + double m_magsq; + double m_magsqPeak; + }; + RemoteTCPSinkSettings m_settings; bool m_running; MessageQueue *m_messageQueueToGUI; @@ -76,7 +138,9 @@ private slots: QRecursiveMutex m_mutex; QTcpServer *m_server; - QList m_clients; + QWebSocketServer *m_webSocketServer; + QList m_clients; + QList m_timers; QDateTime m_bwDateTime; //!< For calculating TX bandwidth qint64 m_bwBytes; @@ -86,6 +150,52 @@ private slots: Real m_interpolatorDistance; Real m_interpolatorDistanceRemain; + // FLAC compression + FLAC__StreamEncoder *m_encoder; + QByteArray m_flacHeader; + static const int m_flacHeaderSize = 4 + 38 + 51; + + // Zlib compression + z_stream m_zStream; + bool m_zStreamInitialised; + QByteArray m_zInBuf; + QByteArray m_zOutBuf; + int m_zInBufCount; + static const int m_zBufSize = 32768+128; // + + qint64 m_bytesUncompressed; + qint64 m_bytesCompressed; + qint64 m_bytesTransmitted; + + Real m_squelchLevel; + int m_squelchCount; + bool m_squelchOpen; + DoubleBufferFIFO m_squelchDelayLine; + double m_magsq; + double m_magsqSum; + double m_magsqPeak; + int m_magsqCount; + MagSqLevelsStore m_magSqLevelStore; + MovingAverageUtil m_movingAverage; + + // Device settings + double m_centerFrequency; + qint32 m_ppmCorrection; + int m_biasTeeEnabled; + int m_directSampling; + int m_agc; + int m_dcOffsetRemoval; + int m_iqCorrection; + qint32 m_devSampleRate; + qint32 m_log2Decim; + qint32 m_rfBW; + qint32 m_gain[4]; + QTimer m_timer; + + // Rotator setttings + double m_azimuth; + double m_elevation; + void startServer(); void stopServer(); void processOneSample(Complex &ci); diff --git a/plugins/channelrx/remotetcpsink/socket.cpp b/plugins/channelrx/remotetcpsink/socket.cpp new file mode 100644 index 0000000000..694f2503fe --- /dev/null +++ b/plugins/channelrx/remotetcpsink/socket.cpp @@ -0,0 +1,160 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 "socket.h" + +Socket::Socket(QObject *socket, QObject *parent) : + QObject(parent), + m_socket(socket) +{ +} + +Socket::~Socket() +{ + delete m_socket; +} + +TCPSocket::TCPSocket(QTcpSocket *socket) : + Socket(socket) +{ +} + +qint64 TCPSocket::write(const char *data, qint64 length) +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->write(data, length); +} + +qint64 TCPSocket::read(char *data, qint64 length) +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->read(data, length); +} + +void TCPSocket::close() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + socket->close(); +} + +qint64 TCPSocket::bytesAvailable() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->bytesAvailable(); +} + +void TCPSocket::flush() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + socket->flush(); +} + +QHostAddress TCPSocket::peerAddress() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->peerAddress(); +} + +quint16 TCPSocket::peerPort() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->peerPort(); +} + +WebSocket::WebSocket(QWebSocket *socket) : + Socket(socket) +{ + m_rxBuffer.reserve(64000); + m_txBuffer.reserve(64000); + connect(socket, &QWebSocket::binaryFrameReceived, this, &WebSocket::binaryFrameReceived); +} + + qint64 WebSocket::write(const char *data, qint64 length) +{ + //QWebSocket *socket = qobject_cast(m_socket); + //return socket->sendBinaryMessage(QByteArray(data, length)); + + m_txBuffer.append(data, length); + return length; +} + +void WebSocket::flush() +{ + QWebSocket *socket = qobject_cast(m_socket); + if (m_txBuffer.size() > 0) + { + qint64 len = socket->sendBinaryMessage(m_txBuffer); + if (len != m_txBuffer.size()) { + qDebug() << "WebSocket::flush: Failed to send all of message" << len << "/" << m_txBuffer.size(); + } + m_txBuffer.clear(); + } + socket->flush(); +} + +qint64 WebSocket::read(char *data, qint64 length) +{ + length = std::min(length, (qint64)m_rxBuffer.size()); + + memcpy(data, m_rxBuffer.constData(), length); + + m_rxBuffer = m_rxBuffer.mid(length); // Yep, not very efficient + + return length; +} + +void WebSocket::close() +{ + QWebSocket *socket = qobject_cast(m_socket); + + socket->close(); +} + +qint64 WebSocket::bytesAvailable() +{ + return m_rxBuffer.size(); +} + +void WebSocket::binaryFrameReceived(const QByteArray &frame, bool isLastFrame) +{ + (void) isLastFrame; + + m_rxBuffer.append(frame); +} + +QHostAddress WebSocket::peerAddress() +{ + QWebSocket *socket = qobject_cast(m_socket); + + return socket->peerAddress(); +} + +quint16 WebSocket::peerPort() +{ + QWebSocket *socket = qobject_cast(m_socket); + + return socket->peerPort(); +} diff --git a/plugins/channelrx/remotetcpsink/socket.h b/plugins/channelrx/remotetcpsink/socket.h new file mode 100644 index 0000000000..48ddc35408 --- /dev/null +++ b/plugins/channelrx/remotetcpsink/socket.h @@ -0,0 +1,89 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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_SOCKET_H_ +#define INCLUDE_SOCKET_H_ + +#include +#include + +// Class to allow easy use of either QTCPSocket or QWebSocket +class Socket : public QObject { + Q_OBJECT +protected: + Socket(QObject *socket, QObject *parent=nullptr); + +public: + virtual ~Socket(); + virtual qint64 write(const char *data, qint64 length) = 0; + virtual void flush() = 0; + virtual qint64 read(char *data, qint64 length) = 0; + virtual qint64 bytesAvailable() = 0; + virtual void close() = 0; + virtual QHostAddress peerAddress() = 0; + virtual quint16 peerPort() = 0; + + QObject *socket() { return m_socket; } + +protected: + + QObject *m_socket; + +}; + +class TCPSocket : public Socket { + Q_OBJECT + +public: + + TCPSocket(QTcpSocket *socket) ; + qint64 write(const char *data, qint64 length) override; + void flush() override; + qint64 read(char *data, qint64 length) override; + qint64 bytesAvailable() override; + void close() override; + QHostAddress peerAddress() override; + quint16 peerPort() override; + +}; + +class WebSocket : public Socket { + Q_OBJECT + +public: + + WebSocket(QWebSocket *socket); + qint64 write(const char *data, qint64 length) override; + void flush() override; + qint64 read(char *data, qint64 length) override; + qint64 bytesAvailable() override; + void close() override; + QHostAddress peerAddress() override; + quint16 peerPort() override; + +private slots: + + void binaryFrameReceived(const QByteArray &frame, bool isLastFrame); + +private: + + QByteArray m_rxBuffer; + QByteArray m_txBuffer; + +}; + +#endif // INCLUDE_SOCKET_H_ diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index e5105c7d9c..18f5244abc 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -295,6 +295,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur connect(&m_kiwiSDRList, &KiwiSDRList::dataUpdated, this, &MapGUI::kiwiSDRUpdated); connect(&m_spyServerList, &SpyServerList::dataUpdated, this, &MapGUI::spyServerUpdated); + connect(&m_sdrangelServerList, &SDRangelServerList::dataUpdated, this, &MapGUI::sdrangelServerUpdated); #ifdef QT_WEBENGINE_FOUND QWebEngineSettings *settings = ui->web->settings(); @@ -309,6 +310,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur connect(profile, &QWebEngineProfile::downloadRequested, this, &MapGUI::downloadRequested); #endif +qDebug() << "Get station position"; // Get station position float stationLatitude = MainCore::instance()->getSettings().getLatitude(); float stationLongitude = MainCore::instance()->getSettings().getLongitude(); @@ -320,6 +322,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur m_polygonMapFilter.setPosition(stationPosition); m_polylineMapFilter.setPosition(stationPosition); +qDebug() << "Centre map"; // Centre map at My Position QQuickItem *item = ui->map->rootObject(); QObject *object = item->findChild("map"); @@ -331,6 +334,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur object->setProperty("center", QVariant::fromValue(coords)); } +qDebug() << "Creating antenna"; // Create antenna at My Position m_antennaMapItem.setName(new QString("Station")); m_antennaMapItem.setLatitude(stationLatitude); @@ -358,7 +362,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur setBeacons(beacons); } addIBPBeacons(); - + addNAT(); addRadioTimeTransmitters(); addRadar(); addIonosonde(); @@ -371,6 +375,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur addVLF(); addKiwiSDR(); addSpyServer(); + addSDRangelServer(); displaySettings(); applySettings(true); @@ -490,6 +495,44 @@ void MapGUI::addIBPBeacons() } } +// https://www.icao.int/EURNAT/EUR%20and%20NAT%20Documents/NAT%20Documents/NAT%20Documents/NAT%20Doc%20003/NAT%20Doc003%20-%20HF%20Guidance%20v3.0.0_2015.pdf +// Coords aren't precise +const QList MapGUI::m_natTransmitters = { + {"Bodo", 0, 67.26742f, 14.34990, 0}, + {"Gander", 0, 48.993056f, -54.674444f, 0}, + {"Iceland", 0, 64.08516f, -21.84531f, 0}, + {"New York", 0, 40.881111f, -72.647778f, 0}, + {"Santa Maria", 0, 36.995556f, -25.170556, 0}, + {"Shanwick", 0, 52.75f, -8.933333f, 0}, +}; + +// North Atlantic HF ATC ground stations +void MapGUI::addNAT() +{ + for (int i = 0; i < m_natTransmitters.size(); i++) + { + SWGSDRangel::SWGMapItem natMapItem; + // Need to suffix frequency, as there are multiple becaons with same callsign at different locations + QString name = QString("%1").arg(m_natTransmitters[i].m_callsign); + natMapItem.setName(new QString(name)); + natMapItem.setLatitude(m_natTransmitters[i].m_latitude); + natMapItem.setLongitude(m_natTransmitters[i].m_longitude); + natMapItem.setAltitude(0.0); + natMapItem.setImage(new QString("antenna.png")); + natMapItem.setImageRotation(0); + QString text = QString("NAT ATC Transmitter\nCallsign: %1") + .arg(m_natTransmitters[i].m_callsign); + natMapItem.setText(new QString(text)); + natMapItem.setModel(new QString("antenna.glb")); + natMapItem.setFixedPosition(true); + natMapItem.setOrientation(0); + natMapItem.setLabel(new QString(name)); + natMapItem.setLabelAltitudeOffset(4.5); + natMapItem.setAltitudeReference(1); + update(m_map, &natMapItem, "NAT ATC Transmitters"); + } +} + void MapGUI::addVLF() { for (int i = 0; i < VLFTransmitters::m_transmitters.size(); i++) @@ -699,6 +742,75 @@ void MapGUI::spyServerUpdated(const QList& sdrs) } } +void MapGUI::addSDRangelServer() +{ + m_sdrangelServerList.getDataPeriodically(); +} + +void MapGUI::sdrangelServerUpdated(const QList& sdrs) +{ + for (const auto& sdr : sdrs) + { + SWGSDRangel::SWGMapItem sdrangelServerMapItem; + + QString address = QString("%1:%2").arg(sdr.m_address).arg(sdr.m_port); + sdrangelServerMapItem.setName(new QString(address)); + sdrangelServerMapItem.setLatitude(sdr.m_latitude); + sdrangelServerMapItem.setLongitude(sdr.m_longitude); + sdrangelServerMapItem.setAltitude(sdr.m_altitude); + sdrangelServerMapItem.setImage(new QString("antennaangel.png")); + sdrangelServerMapItem.setImageRotation(0); + QStringList antenna; + if (!sdr.m_antenna.isEmpty()) { + antenna.append(sdr.m_antenna); + } + if (sdr.m_isotropic) { + antenna.append("Isotropic"); + } else { + antenna.append(QString("Az: %1%3 El: %2%3").arg(sdr.m_azimuth).arg(sdr.m_elevation).arg(QChar(0x00b0))); + } + + QString text = QString("SDRangel\n\nStation: %1\nDevice: %2\nAntenna: %3\nFrequency: %4 - %5\nRemote control: %6\nUsers: %7/%8") + .arg(sdr.m_stationName) + .arg(sdr.m_device) + .arg(antenna.join(" - ")) + .arg(formatFrequency(sdr.m_minFrequency)) + .arg(formatFrequency(sdr.m_maxFrequency)) + .arg(sdr.m_remoteControl ? "Yes" : "No") + .arg(sdr.m_clients) + .arg(sdr.m_maxClients) + ; + if (sdr.m_timeLimit > 0) { + text.append(QString("\nTime limit: %1 mins").arg(sdr.m_timeLimit)); + } + QString url = QString("sdrangel-server://%1").arg(address); + QString link = QString("%2").arg(url).arg(address); + text.append(QString("\nURL: %1").arg(link)); + sdrangelServerMapItem.setText(new QString(text)); + sdrangelServerMapItem.setModel(new QString("antenna.glb")); + sdrangelServerMapItem.setFixedPosition(true); + sdrangelServerMapItem.setOrientation(0); + QStringList bands; + if (sdr.m_minFrequency < 30000000) { + bands.append("HF"); + } + if ((sdr.m_minFrequency < 300000000) && (sdr.m_maxFrequency > 30000000)) { + bands.append("VHF"); + } + if ((sdr.m_minFrequency < 3000000000) && (sdr.m_maxFrequency > 300000000)) { + bands.append("UHF"); + } + if (sdr.m_maxFrequency > 3000000000) { + bands.append("SHF"); + } + QString label = QString("SDRangel %1").arg(bands.join(" ")); + sdrangelServerMapItem.setLabel(new QString(label)); + sdrangelServerMapItem.setLabelAltitudeOffset(4.5); + sdrangelServerMapItem.setAltitudeReference(1); + update(m_map, &sdrangelServerMapItem, "SDRangel"); + } +} + // Ionosonde stations void MapGUI::addIonosonde() { @@ -1582,8 +1694,13 @@ void MapGUI::applyMap2DSettings(bool reloadMap) if (!m_settings.m_osmURL.isEmpty()) { parameters["osm.mapping.custom.host"] = m_settings.m_osmURL; // E.g: "http://a.tile.openstreetmap.fr/hot/" } +#ifdef __EMSCRIPTEN__ + // Default is http://maps-redirect.qt.io/osm/5.8/ and Emscripten needs https + parameters["osm.mapping.providersrepository.address"] = QString("https://sdrangel.beniston.com/sdrangel/maps/"); +#else // Use our repo, so we can append API key parameters["osm.mapping.providersrepository.address"] = QString("http://127.0.0.1:%1/").arg(m_osmPort); +#endif // Use application specific cache, as other apps may not use API key so will have different images QString cachePath = osmCachePath(); parameters["osm.mapping.cache.directory"] = cachePath; @@ -1685,6 +1802,9 @@ void MapGUI::displayToolbar() bool narrow = this->screen()->availableGeometry().width() < 400; ui->layersMenu->setVisible(narrow); bool overlayButtons = !narrow && ((m_settings.m_mapProvider == "osm") || m_settings.m_map3DEnabled); +#ifdef __EMSCRIPTEN__ + overlayButtons = false; +#endif ui->displayRain->setVisible(overlayButtons); ui->displayClouds->setVisible(overlayButtons); ui->displaySeaMarks->setVisible(overlayButtons); @@ -2600,12 +2720,16 @@ void MapGUI::linkClicked(const QString& url) QString spyServerURL = url.mid(21); openSpyServer(spyServerURL); } + else if (url.startsWith("sdrangel-server://")) + { + QString sdrangelServerURL = url.mid(18); + openSDRangelServer(sdrangelServerURL); + } } -// Open a KiwiSDR RX device -void MapGUI::openKiwiSDR(const QString& url) +bool MapGUI::openKiwiSDRInput() { - // Create DeviceSet + // Create DeviceSet MainCore *mainCore = MainCore::instance(); unsigned int deviceSetIndex = mainCore->getDeviceSets().size(); MainCore::MsgAddDeviceSet *msg = MainCore::MsgAddDeviceSet::create(0); @@ -2634,39 +2758,43 @@ void MapGUI::openKiwiSDR(const QString& url) 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]; - } + return false; } - 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); + return true; } -// Open a RemoteTCPInput device to use for SpyServer -void MapGUI::openSpyServer(const QString& url) +// Open a KiwiSDR RX device +void MapGUI::openKiwiSDR(const QString& url) +{ + m_remoteDeviceAddress = url; + connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); + if (!openKiwiSDRInput()) { + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); + } +} + +void MapGUI::kiwiSDRDeviceSetAdded(int index, DeviceAPI *device) +{ + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); + + // FIXME: Doesn't work if we do it immediately. Settings overwritten? + QTimer::singleShot(200, [=] { + // Set address setting + QStringList deviceSettingsKeys = {"serverAddress"}; + SWGSDRangel::SWGDeviceSettings response; + response.init(); + SWGSDRangel::SWGKiwiSDRSettings *deviceSettings = response.getKiwiSdrSettings(); + deviceSettings->setServerAddress(new QString(m_remoteDeviceAddress)); + QString errorMessage; + device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); + }); +} + +bool MapGUI::openRemoteTCPInput() { // Create DeviceSet MainCore *mainCore = MainCore::instance(); @@ -2696,39 +2824,79 @@ void MapGUI::openSpyServer(const QString& url) } 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]; - } + qCritical() << "MapGUI::openRemoteTCPInput: Failed to find RemoteTCPInput"; + return false; } - while (!deviceSet); // Move to same workspace //getWorkspaceIndex(); - // Set address/port setting + return true; +} + +// Open a RemoteTCPInput device to use for SpyServer +void MapGUI::openSpyServer(const QString& url) +{ 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); + m_remoteDeviceAddress = address[0]; + m_remoteDevicePort = address[1].toInt(); + connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); + if (!openRemoteTCPInput()) { + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); + } +} + +void MapGUI::spyServerDeviceSetAdded(int index, DeviceAPI *device) +{ +qDebug() << "**************** MapGUI::spyServerDeviceSetAdded"; + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); + + // FIXME: Doesn't work if we do it immediately. Settings overwritten? + QTimer::singleShot(200, [=] { + // Set address/port setting + QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; + SWGSDRangel::SWGDeviceSettings response; + response.init(); + SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response.getRemoteTcpInputSettings(); + deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); + deviceSettings->setDataPort(m_remoteDevicePort); + deviceSettings->setProtocol(new QString("Spy Server")); + deviceSettings->setOverrideRemoteSettings(false); + QString errorMessage; + device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); + }); +} + +// Open a RemoteTCPInput device to use for SDRangel +void MapGUI::openSDRangelServer(const QString& url) +{ + QStringList address = url.split(":"); + m_remoteDeviceAddress = address[0]; + m_remoteDevicePort = address[1].toInt(); + connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); + if (!openRemoteTCPInput()) { + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); + } +} + +void MapGUI::sdrangelServerDeviceSetAdded(int index, DeviceAPI *device) +{ + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); + + // FIXME: Doesn't work if we do it immediately. Settings overwritten? + QTimer::singleShot(200, [=] { + // Set address/port setting + QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; + SWGSDRangel::SWGDeviceSettings response; + response.init(); + SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response.getRemoteTcpInputSettings(); + deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); + deviceSettings->setDataPort(m_remoteDevicePort); + deviceSettings->setProtocol(new QString("SDRangel")); + deviceSettings->setOverrideRemoteSettings(false); + QString errorMessage; + device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); + }); } #ifdef QT_WEBENGINE_FOUND @@ -2836,4 +3004,3 @@ void MapGUI::makeUIConnections() QObject::connect(ui->ibpBeacons, &QToolButton::clicked, this, &MapGUI::on_ibpBeacons_clicked); QObject::connect(ui->radiotime, &QToolButton::clicked, this, &MapGUI::on_radiotime_clicked); } - diff --git a/plugins/feature/map/mapgui.h b/plugins/feature/map/mapgui.h index f54bda7788..4188698eb8 100644 --- a/plugins/feature/map/mapgui.h +++ b/plugins/feature/map/mapgui.h @@ -47,6 +47,7 @@ #include "util/nasaglobalimagery.h" #include "util/kiwisdrlist.h" #include "util/spyserverlist.h" +#include "util/sdrangelserverlist.h" #include "settings/rollupstate.h" #include "availablechannelorfeaturehandler.h" @@ -169,6 +170,7 @@ class MapGUI : public FeatureGUI { void addIBPBeacons(); QList getRadioTimeTransmitters() { return m_radioTimeTransmitters; } void addRadioTimeTransmitters(); + void addNAT(); void addRadar(); void addIonosonde(); void addBroadcast(); @@ -182,6 +184,7 @@ class MapGUI : public FeatureGUI { void addVLF(); void addKiwiSDR(); void addSpyServer(); + void addSDRangelServer(); void find(const QString& target); void track3D(const QString& target); Q_INVOKABLE void supportedMapsChanged(); @@ -231,6 +234,7 @@ class MapGUI : public FeatureGUI { QGeoCoordinate m_lastFullUpdatePosition; KiwiSDRList m_kiwiSDRList; SpyServerList m_spyServerList; + SDRangelServerList m_sdrangelServerList; CesiumInterface *m_cesium; WebServer *m_webServer; @@ -257,6 +261,10 @@ class MapGUI : public FeatureGUI { QTableWidget *m_overviewWidget; QTextEdit *m_descriptionWidget; + // Settings for opening a device + QString m_remoteDeviceAddress; + quint16 m_remoteDevicePort; + explicit MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *feature, QWidget* parent = nullptr); virtual ~MapGUI(); @@ -282,13 +290,17 @@ class MapGUI : public FeatureGUI { void applyNASAGlobalImagerySettings(); void createNASAGlobalImageryView(); void displayNASAMetaData(); + bool openKiwiSDRInput(); + bool openRemoteTCPInput(); void openKiwiSDR(const QString& url); void openSpyServer(const QString& url); + void openSDRangelServer(const QString& url); QString formatFrequency(qint64 frequency) const; void updateGIRO(const QDateTime& mapDateTime); static QString getDataDir(); static const QList m_radioTimeTransmitters; + static const QList m_natTransmitters; static const QList m_vlfTransmitters; enum NASARow { @@ -359,9 +371,12 @@ private slots: void airportsUpdated(); void waypointsUpdated(); void kiwiSDRUpdated(const QList& sdrs); + void kiwiSDRDeviceSetAdded(int index, DeviceAPI *device); void spyServerUpdated(const QList& sdrs); + void spyServerDeviceSetAdded(int index, DeviceAPI *device); + void sdrangelServerUpdated(const QList& sdrs); + void sdrangelServerDeviceSetAdded(int index, DeviceAPI *device); void linkClicked(const QString& url); - }; #endif // INCLUDE_FEATURE_MAPGUI_H_ diff --git a/plugins/feature/map/mapsettings.cpp b/plugins/feature/map/mapsettings.cpp index 4ae6b6cbcd..6c1f4a8ab3 100644 --- a/plugins/feature/map/mapsettings.cpp +++ b/plugins/feature/map/mapsettings.cpp @@ -99,6 +99,7 @@ MapSettings::MapSettings() : m_itemSettings.insert("Radiosonde", new MapItemSettings("Radiosonde", true, QColor(102, 0, 102), true, false, 11, modelMinPixelSize)); m_itemSettings.insert("Radio Time Transmitters", new MapItemSettings("Radio Time Transmitters", true, QColor(255, 0, 0), false, true, 8)); m_itemSettings.insert("Radar", new MapItemSettings("Radar", true, QColor(255, 0, 0), false, true, 8)); + m_itemSettings.insert("NAT ATC Transmitters", new MapItemSettings("NAT ATC Transmitters", false, QColor(255, 0, 0), false, true, 8)); m_itemSettings.insert("FT8Demod", new MapItemSettings("FT8Demod", true, QColor(0, 192, 255), true, true, 8)); m_itemSettings.insert("HeatMap", new MapItemSettings("HeatMap", true, QColor(102, 40, 220), true, true, 11)); m_itemSettings.insert("VLF", new MapItemSettings("VLF", false, QColor(255, 0, 0), false, true, 8)); @@ -157,8 +158,13 @@ MapSettings::MapSettings() : 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)); + bool showOtherServers = true; +#ifdef __EMSCRIPTEN__ + showOtherServers = false; // Can't use without proxy +#endif + m_itemSettings.insert("KiwiSDR", new MapItemSettings("KiwiSDR", showOtherServers, QColor(0, 255, 0), false, true, 8)); + m_itemSettings.insert("SpyServer", new MapItemSettings("SpyServer", showOtherServers, QColor(0, 0, 255), false, true, 8)); + m_itemSettings.insert("SDRangel", new MapItemSettings("SDRangel", true, QColor(255, 0, 255), false, true, 8)); resetToDefaults(); } diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp index 06619e3ace..72a0f75225 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp @@ -40,16 +40,27 @@ MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgConfigureRemoteTCPInput, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgStartStop, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgReportTCPBuffer, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgSaveReplay, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgSendMessage, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgReportPosition, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPInput::MsgReportDirection, Message) RemoteTCPInput::RemoteTCPInput(DeviceAPI *deviceAPI) : m_deviceAPI(deviceAPI), m_settings(), m_remoteInputTCPPHandler(nullptr), - m_deviceDescription("RemoteTCPInput") + m_deviceDescription("RemoteTCPInput"), + m_running(false), + m_latitude(std::numeric_limits::quiet_NaN()), + m_longitude(std::numeric_limits::quiet_NaN()), + m_altitude(std::numeric_limits::quiet_NaN()), + m_isotropic(false), + m_azimuth(std::numeric_limits::quiet_NaN()), + m_elevation(std::numeric_limits::quiet_NaN()) { m_sampleFifo.setLabel(m_deviceDescription); m_sampleFifo.setSize(48000 * 8); - m_remoteInputTCPPHandler = new RemoteTCPInputTCPHandler(&m_sampleFifo, m_deviceAPI); + m_remoteInputTCPPHandler = new RemoteTCPInputTCPHandler(&m_sampleFifo, m_deviceAPI, &m_replayBuffer); m_remoteInputTCPPHandler->moveToThread(&m_thread); m_remoteInputTCPPHandler->setMessageQueueToInput(&m_inputMessageQueue); @@ -66,6 +77,7 @@ RemoteTCPInput::RemoteTCPInput(DeviceAPI *deviceAPI) : RemoteTCPInput::~RemoteTCPInput() { + qDebug() << "RemoteTCPInput::~RemoteTCPInput"; QObject::disconnect( m_networkManager, &QNetworkAccessManager::finished, @@ -84,25 +96,45 @@ void RemoteTCPInput::destroy() void RemoteTCPInput::init() { + qDebug() << "*************** RemoteTCPInput::init"; applySettings(m_settings, QList(), true); } bool RemoteTCPInput::start() { qDebug() << "RemoteTCPInput::start"; + if (m_running) { + qDebug() << "RemoteTCPInput::stop - Already running"; + return true; + } m_remoteInputTCPPHandler->reset(); m_remoteInputTCPPHandler->start(); + qDebug() << "************ RemoteTCPInput::start" << m_settings.m_dataAddress; m_remoteInputTCPPHandler->getInputMessageQueue()->push(RemoteTCPInputTCPHandler::MsgConfigureTcpHandler::create(m_settings, QList(), true)); m_thread.start(); + m_running = true; return true; } void RemoteTCPInput::stop() { qDebug() << "RemoteTCPInput::stop"; + if (!m_running) { + // For wasm, important not to call m_remoteInputTCPPHandler->stop() twice + // as mutex can deadlock when this object is being deleted + qDebug() << "RemoteTCPInput::stop - Not running"; + return; + } m_remoteInputTCPPHandler->stop(); + qDebug() << "RemoteTCPInput::stop1"; m_thread.quit(); + qDebug() << "RemoteTCPInput::stop2"; +#ifndef __EMSCRIPTEN__ + qDebug() << "RemoteTCPInput::stop3"; m_thread.wait(); +#endif + m_running = false; + qDebug() << "RemoteTCPInput::stopped"; } QByteArray RemoteTCPInput::serialize() const @@ -120,6 +152,8 @@ bool RemoteTCPInput::deserialize(const QByteArray& data) success = false; } + qDebug() << "************** RemoteTCPInput::deserialize" << m_settings.m_dataAddress; + MsgConfigureRemoteTCPInput* message = MsgConfigureRemoteTCPInput::create(m_settings, QList(), true); m_inputMessageQueue.push(message); @@ -196,6 +230,7 @@ bool RemoteTCPInput::handleMessage(const Message& message) { qDebug() << "RemoteTCPInput::handleMessage:" << message.getIdentifier(); MsgConfigureRemoteTCPInput& conf = (MsgConfigureRemoteTCPInput&) message; + qDebug() << "*********** RemoteTCPInput::handleMessage MsgConfigureRemoteTCPInput" << m_settings.m_dataAddress; applySettings(conf.getSettings(), conf.getSettingsKeys(), conf.getForce()); return true; } @@ -210,6 +245,42 @@ bool RemoteTCPInput::handleMessage(const Message& message) } return true; } + else if (MsgSaveReplay::match(message)) + { + MsgSaveReplay& cmd = (MsgSaveReplay&) message; + m_replayBuffer.save(cmd.getFilename(), m_settings.m_devSampleRate, getCenterFrequency()); + return true; + } + else if (MsgSendMessage::match(message)) + { + MsgSendMessage& msg = (MsgSendMessage&) message; + m_remoteInputTCPPHandler->getInputMessageQueue()->push(MsgSendMessage::create(msg.getCallsign(), msg.getText(), msg.getBroadcast())); + return true; + } + else if (MsgReportPosition::match(message)) + { + MsgReportPosition& report = (MsgReportPosition&) message; + + m_latitude = report.getLatitude(); + m_longitude = report.getLongitude(); + m_altitude = report.getAltitude(); + + emit positionChanged(m_latitude, m_longitude, m_altitude); + + return true; + } + else if (MsgReportDirection::match(message)) + { + MsgReportDirection& report = (MsgReportDirection&) message; + + m_isotropic = report.getIsotropic(); + m_azimuth = report.getAzimuth(); + m_elevation = report.getElevation(); + + emit directionChanged(m_isotropic, m_azimuth, m_elevation); + + return true; + } else { return false; @@ -242,6 +313,12 @@ void RemoteTCPInput::applySettings(const RemoteTCPInputSettings& settings, const forwardChange = true; } + if ((settingsKeys.contains("channelSampleRate") || force) + && (settings.m_devSampleRate != m_settings.m_devSampleRate)) + { + m_replayBuffer.clear(); + } + mutexLocker.unlock(); if (settings.m_useReverseAPI) @@ -265,6 +342,18 @@ void RemoteTCPInput::applySettings(const RemoteTCPInputSettings& settings, const m_settings.applySettings(settingsKeys, settings); } + if (settingsKeys.contains("replayLength") || settingsKeys.contains("devSampleRate") || force) { + m_replayBuffer.setSize(m_settings.m_replayLength, m_settings.m_devSampleRate); + } + + if (settingsKeys.contains("replayOffset") || settingsKeys.contains("devSampleRate") || force) { + m_replayBuffer.setReadOffset(((unsigned)(m_settings.m_replayOffset * m_settings.m_devSampleRate)) * 2); + } + + if (settingsKeys.contains("replayLoop") || force) { + m_replayBuffer.setLoop(m_settings.m_replayLoop); + } + m_remoteInputTCPPHandler->getInputMessageQueue()->push(RemoteTCPInputTCPHandler::MsgConfigureTcpHandler::create(m_settings, settingsKeys, force)); } @@ -459,6 +548,9 @@ int RemoteTCPInput::webapiReportGet( void RemoteTCPInput::webapiFormatDeviceReport(SWGSDRangel::SWGDeviceReport& response) { response.getRemoteTcpInputReport()->setSampleRate(m_settings.m_channelSampleRate); + response.getRemoteTcpInputReport()->setLatitude(m_latitude); + response.getRemoteTcpInputReport()->setLongitude(m_longitude); + response.getRemoteTcpInputReport()->setAltitude(m_altitude); } void RemoteTCPInput::webapiReverseSendSettings(const QList& deviceSettingsKeys, const RemoteTCPInputSettings& settings, bool force) diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.h b/plugins/samplesource/remotetcpinput/remotetcpinput.h index 56a1e4f373..449109a315 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.h @@ -31,13 +31,14 @@ #include #include "dsp/devicesamplesource.h" +#include "dsp/replaybuffer.h" #include "remotetcpinputsettings.h" +#include "remotetcpinputtcphandler.h" class QNetworkAccessManager; class QNetworkReply; class DeviceAPI; -class RemoteTCPInputTCPHandler; class RemoteTCPInput : public DeviceSampleSource { Q_OBJECT @@ -124,6 +125,100 @@ class RemoteTCPInput : public DeviceSampleSource { { } }; + class MsgSaveReplay : public Message { + MESSAGE_CLASS_DECLARATION + + public: + QString getFilename() const { return m_filename; } + + static MsgSaveReplay* create(const QString& filename) { + return new MsgSaveReplay(filename); + } + + protected: + QString m_filename; + + MsgSaveReplay(const QString& filename) : + Message(), + m_filename(filename) + { } + }; + + class MsgSendMessage : public Message { + MESSAGE_CLASS_DECLARATION + + public: + const QString& getCallsign() const { return m_callsign; } + const QString& getText() const { return m_text; } + bool getBroadcast() const { return m_broadcast; } + + static MsgSendMessage* create(const QString& callsign, const QString& text, bool broadcast) { + return new MsgSendMessage(callsign, text, broadcast); + } + + protected: + QString m_callsign; + QString m_text; + bool m_broadcast; + + MsgSendMessage(const QString& callsign, const QString& text, bool broadcast) : + Message(), + m_callsign(callsign), + m_text(text), + m_broadcast(broadcast) + { } + }; + + class MsgReportPosition : public Message { + MESSAGE_CLASS_DECLARATION + + public: + float getLatitude() const { return m_latitude; } + float getLongitude() const { return m_longitude; } + float getAltitude() const { return m_altitude; } + + static MsgReportPosition* create(float latitude, float longitude, float altitude) { + return new MsgReportPosition(latitude, longitude, altitude); + } + + private: + float m_latitude; + float m_longitude; + float m_altitude; + + MsgReportPosition(float latitude, float longitude, float altitude) : + Message(), + m_latitude(latitude), + m_longitude(longitude), + m_altitude(altitude) + { } + }; + + class MsgReportDirection : public Message { + MESSAGE_CLASS_DECLARATION + + public: + bool getIsotropic() const { return m_isotropic; } + float getAzimuth() const { return m_azimuth; } + float getElevation() const { return m_elevation; } + + static MsgReportDirection* create(bool isotropic, float azimuth, float elevation) { + return new MsgReportDirection(isotropic, azimuth, elevation); + } + + private: + bool m_isotropic; + float m_azimuth; + float m_elevation; + + MsgReportDirection(bool isotropic, float azimuth, float elevation) : + Message(), + m_isotropic(isotropic), + m_azimuth(azimuth), + m_elevation(elevation) + { } + }; + RemoteTCPInput(DeviceAPI *deviceAPI); virtual ~RemoteTCPInput(); virtual void destroy(); @@ -177,6 +272,15 @@ class RemoteTCPInput : public DeviceSampleSource { const QStringList& deviceSettingsKeys, SWGSDRangel::SWGDeviceSettings& response); + void getMagSqLevels(double& avg, double& peak, int& nbSamples) + { + if (m_remoteInputTCPPHandler) { + m_remoteInputTCPPHandler->getMagSqLevels(avg, peak, nbSamples); + } else { + avg = 0.0; peak = 0.0; nbSamples = 1; + } + } + private: DeviceAPI *m_deviceAPI; QRecursiveMutex m_mutex; @@ -185,7 +289,15 @@ class RemoteTCPInput : public DeviceSampleSource { QString m_deviceDescription; QNetworkAccessManager *m_networkManager; QNetworkRequest m_networkRequest; + ReplayBuffer m_replayBuffer; QThread m_thread; + bool m_running; + float m_latitude; // Position of remote device (antenna) + float m_longitude; + float m_altitude; + bool m_isotropic; // Direction of remote anntenna + float m_azimuth; + float m_elevation; void applySettings(const RemoteTCPInputSettings& settings, const QList& settingsKeys, bool force = false); void webapiFormatDeviceReport(SWGSDRangel::SWGDeviceReport& response); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp index b1eec42ea4..27fed839d1 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include "ui_remotetcpinputgui.h" #include "gui/colormapper.h" @@ -30,17 +31,19 @@ #include "dsp/dspcommands.h" #include "device/deviceapi.h" #include "device/deviceuiset.h" +#include "util/db.h" #include "remotetcpinputgui.h" #include "remotetcpinputtcphandler.h" +#include "maincore.h" RemoteTCPInputGui::RemoteTCPInputGui(DeviceUISet *deviceUISet, QWidget* parent) : DeviceGUI(parent), ui(new Ui::RemoteTCPInputGui), m_settings(), - m_sampleSource(0), - m_lastEngineState(DeviceAPI::StNotStarted), + m_sampleSource(nullptr), m_sampleRate(0), m_centerFrequency(0), + m_tickCount(0), m_doApplySettings(true), m_forceSettings(true), m_deviceGains(nullptr), @@ -71,12 +74,15 @@ RemoteTCPInputGui::RemoteTCPInputGui(DeviceUISet *deviceUISet, QWidget* parent) ui->deltaFrequency->setColorMapper(ColorMapper(ColorMapper::GrayGold)); ui->deltaFrequency->setValueRange(false, 7, -9999999, 9999999); + ui->channelPowerMeter->setColorTheme(LevelMeterSignalDB::ColorGreenAndBlue); + connect(this, SIGNAL(customContextMenuRequested(const QPoint &)), this, SLOT(openDeviceSettingsDialog(const QPoint &))); displaySettings(); - connect(&m_statusTimer, SIGNAL(timeout()), this, SLOT(updateStatus())); - m_statusTimer.start(500); + connect(deviceUISet->m_deviceAPI, &DeviceAPI::stateChanged, this, &RemoteTCPInputGui::updateStatus); + updateStatus(); + connect(&m_updateTimer, SIGNAL(timeout()), this, SLOT(updateHardware())); m_sampleSource = (RemoteTCPInput*) m_deviceUISet->m_deviceAPI->getSampleSource(); @@ -89,11 +95,12 @@ RemoteTCPInputGui::RemoteTCPInputGui(DeviceUISet *deviceUISet, QWidget* parent) makeUIConnections(); DialPopup::addPopupsToChildDials(this); m_resizer.enableChildMouseTracking(); + + connect(&MainCore::instance()->getMasterTimer(), SIGNAL(timeout()), this, SLOT(tick())); } RemoteTCPInputGui::~RemoteTCPInputGui() { - m_statusTimer.stop(); m_updateTimer.stop(); delete ui; } @@ -110,6 +117,7 @@ void RemoteTCPInputGui::destroy() void RemoteTCPInputGui::resetToDefaults() { + qDebug() << "*************** RemoteTCPInputGui::resetToDefaults"; m_settings.resetToDefaults(); displaySettings(); m_forceSettings = true; @@ -150,6 +158,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else { m_settings.applySettings(cfg.getSettingsKeys(), cfg.getSettings()); } + qDebug() << "********* RemoteTCPInputGui::handleMessage MsgConfigureRemoteTCPInput" << m_settings.m_dataAddress; blockApplySettings(true); displaySettings(); @@ -224,24 +233,27 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) ui->detectedProtocol->setText(QString("Protocol: %1").arg(report.getProtocol())); // Update GUI so we only show widgets available for the protocol in use - bool sdra = report.getProtocol() == "SDRA"; - bool spyServer = report.getProtocol() == "Spy Server"; - if (spyServer) { + m_sdra = report.getProtocol() == "SDRA"; + m_spyServer = report.getProtocol() == "Spy Server"; + m_remoteControl = report.getRemoteControl(); + m_iqOnly = report.getIQOnly(); + + if (m_spyServer) { m_spyServerGains.m_gains[0].m_max = report.getMaxGain(); } - if ((sdra || spyServer) && (ui->sampleBits->count() < 4)) + if ((m_sdra || m_spyServer) && (ui->sampleBits->count() < 4)) { ui->sampleBits->addItem("16"); ui->sampleBits->addItem("24"); ui->sampleBits->addItem("32"); } - else if (!(sdra || spyServer) && (ui->sampleBits->count() != 1)) + else if (!(m_sdra || m_spyServer) && (ui->sampleBits->count() != 1)) { while (ui->sampleBits->count() > 1) { ui->sampleBits->removeItem(ui->sampleBits->count() - 1); } } - if ((sdra || spyServer) && (ui->decim->count() != 7)) + if ((m_sdra || m_spyServer) && (ui->decim->count() != 7)) { ui->decim->addItem("2"); ui->decim->addItem("4"); @@ -250,26 +262,19 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) ui->decim->addItem("32"); ui->decim->addItem("64"); } - else if (!(sdra || spyServer) && (ui->decim->count() != 1)) + else if (!(m_sdra || m_spyServer) && (ui->decim->count() != 1)) { while (ui->decim->count() > 1) { ui->decim->removeItem(ui->decim->count() - 1); } } - if (!sdra) + if (!m_sdra) { ui->deltaFrequency->setValue(0); ui->channelGain->setValue(0); ui->decimation->setChecked(true); } - ui->deltaFrequencyLabel->setEnabled(sdra); - ui->deltaFrequency->setEnabled(sdra); - ui->deltaUnits->setEnabled(sdra); - ui->channelGainLabel->setEnabled(sdra); - ui->channelGain->setEnabled(sdra); - ui->channelGainText->setEnabled(sdra); - ui->decimation->setEnabled(sdra); - if (sdra) { + if (m_sdra) { ui->centerFrequency->setValueRange(9, 0, 999999999); // Should add transverter control to protocol in the future } else { ui->centerFrequency->setValueRange(7, 0, 9999999); @@ -291,19 +296,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) { ui->devSampleRate->setValueRange(8, 0, 99999999); } - ui->devSampleRateLabel->setEnabled(!spyServer); - ui->devSampleRate->setEnabled(!spyServer); - ui->devSampleRateUnits->setEnabled(!spyServer); - ui->agc->setEnabled(!spyServer); - ui->rfBWLabel->setEnabled(!spyServer); - ui->rfBW->setEnabled(!spyServer); - ui->rfBWUnits->setEnabled(!spyServer); - ui->dcOffset->setEnabled(!spyServer); - ui->iqImbalance->setEnabled(!spyServer); - ui->ppm->setEnabled(!spyServer); - ui->ppmLabel->setEnabled(!spyServer); - ui->ppmText->setEnabled(!spyServer); - + displayEnabled(); displayGains(); return true; } @@ -314,13 +307,33 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) if (report.getConnected()) { m_connectionError = false; - ui->startStop->setStyleSheet("QToolButton { background-color : green; }"); + //ui->startStop->setStyleSheet("QToolButton { background-color : green; }"); } else { m_connectionError = true; - ui->startStop->setStyleSheet("QToolButton { background-color : red; }"); + //ui->startStop->setStyleSheet("QToolButton { background-color : red; }"); } + updateStatus(); + return true; + } + else if (RemoteTCPInput::MsgSendMessage::match(message)) + { + const RemoteTCPInput::MsgSendMessage& msg = (const RemoteTCPInput::MsgSendMessage&) message; + + ui->messages->addItem(QString("%1> %2").arg(msg.getCallsign()).arg(msg.getText())); + ui->messages->scrollToBottom(); + + return true; + } + else if (RemoteTCPInput::MsgReportPosition::match(message)) + { + // Could display in future + return true; + } + else if (RemoteTCPInput::MsgReportDirection::match(message)) + { + // Could display in future return true; } else @@ -329,6 +342,87 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } } +void RemoteTCPInputGui::displayEnabled() +{ + int state = m_deviceUISet->m_deviceAPI->state(); + bool remoteControl; + bool enableMessages; + bool enableSquelchEnable; + bool enableSquelch; + bool sdra; + + if (state == DeviceAPI::StRunning) + { + sdra = m_sdra; + remoteControl = m_remoteControl; + enableMessages = !m_iqOnly; + enableSquelchEnable = !m_iqOnly; + enableSquelch = !m_iqOnly && m_settings.m_squelchEnabled; + } + else + { + sdra = m_settings.m_protocol == "SDRangel"; + remoteControl = m_settings.m_overrideRemoteSettings; + enableMessages = false; + enableSquelchEnable = m_settings.m_overrideRemoteSettings; + enableSquelch = m_settings.m_overrideRemoteSettings && m_settings.m_squelchEnabled; + } + + ui->deltaFrequencyLabel->setEnabled(sdra && remoteControl); + ui->deltaFrequency->setEnabled(sdra && remoteControl); + ui->deltaUnits->setEnabled(sdra && remoteControl); + ui->channelGainLabel->setEnabled(sdra && remoteControl); + ui->channelGain->setEnabled(sdra && remoteControl); + ui->channelGainText->setEnabled(sdra && remoteControl); + ui->decimation->setEnabled(sdra && remoteControl); + + ui->channelSampleRate->setEnabled(m_settings.m_channelDecimation && sdra && remoteControl); + ui->channelSampleRateLabel->setEnabled(m_settings.m_channelDecimation && sdra && remoteControl); + ui->channelSampleRateUnit->setEnabled(m_settings.m_channelDecimation && sdra && remoteControl); + + ui->devSampleRateLabel->setEnabled(!m_spyServer && remoteControl); + ui->devSampleRate->setEnabled(!m_spyServer && remoteControl); + ui->devSampleRateUnits->setEnabled(!m_spyServer && remoteControl); + ui->agc->setEnabled(!m_spyServer && remoteControl); + ui->rfBWLabel->setEnabled(!m_spyServer && remoteControl); + ui->rfBW->setEnabled(!m_spyServer && remoteControl); + ui->rfBWUnits->setEnabled(!m_spyServer && remoteControl); + ui->dcOffset->setEnabled(!m_spyServer && remoteControl); + ui->iqImbalance->setEnabled(!m_spyServer && remoteControl); + ui->ppm->setEnabled(!m_spyServer && remoteControl); + ui->ppmLabel->setEnabled(!m_spyServer && remoteControl); + ui->ppmText->setEnabled(!m_spyServer && remoteControl); + + ui->centerFrequency->setEnabled(remoteControl); + ui->biasTee->setEnabled(remoteControl); + ui->directSampling->setEnabled(remoteControl); + ui->decimLabel->setEnabled(remoteControl); + ui->decim->setEnabled(remoteControl); + ui->gain1Label->setEnabled(remoteControl); + ui->gain1->setEnabled(remoteControl); + ui->gain1Text->setEnabled(remoteControl); + ui->gain2Label->setEnabled(remoteControl); + ui->gain2->setEnabled(remoteControl); + ui->gain2Text->setEnabled(remoteControl); + ui->gain3Label->setEnabled(remoteControl); + ui->gain3->setEnabled(remoteControl); + ui->gain3Text->setEnabled(remoteControl); + ui->sampleBitsLabel->setEnabled(remoteControl); + ui->sampleBits->setEnabled(remoteControl); + ui->sampleBitsUnits->setEnabled(remoteControl); + + ui->squelchEnabled->setEnabled(enableSquelchEnable); + ui->squelch->setEnabled(enableSquelch); + ui->squelchText->setEnabled(enableSquelch); + ui->squelchUnits->setEnabled(enableSquelch); + ui->squelchGate->setEnabled(enableSquelch); + + ui->sendMessage->setEnabled(enableMessages); + ui->txAddress->setEnabled(enableMessages); + ui->txMessage->setEnabled(enableMessages); + ui->messages->setEnabled(enableMessages); +} + void RemoteTCPInputGui::handleInputMessages() { Message* message; @@ -386,12 +480,14 @@ void RemoteTCPInputGui::displaySettings() ui->channelSampleRate->setValue(m_settings.m_channelSampleRate); ui->deviceRateText->setText(tr("%1k").arg(m_settings.m_channelSampleRate / 1000.0)); ui->decimation->setChecked(!m_settings.m_channelDecimation); - ui->channelSampleRate->setEnabled(m_settings.m_channelDecimation); - ui->channelSampleRateLabel->setEnabled(m_settings.m_channelDecimation); - ui->channelSampleRateUnit->setEnabled(m_settings.m_channelDecimation); ui->sampleBits->setCurrentText(QString::number(m_settings.m_sampleBits)); - ui->dataPort->setText(tr("%1").arg(m_settings.m_dataPort)); + ui->squelchEnabled->setChecked(m_settings.m_squelchEnabled); + ui->squelch->setValue(m_settings.m_squelch); + ui->squelchText->setText(QString::number(m_settings.m_squelch)); + ui->squelchGate->setValue(m_settings.m_squelchGate); + + ui->dataPort->setValue(m_settings.m_dataPort); ui->dataAddress->blockSignals(true); ui->dataAddress->clear(); for (const auto& address : m_settings.m_addressList) { @@ -412,6 +508,11 @@ void RemoteTCPInputGui::displaySettings() } displayGains(); + displayReplayLength(); + displayReplayOffset(); + displayReplayStep(); + ui->replayLoop->setChecked(m_settings.m_replayLoop); + displayEnabled(); blockApplySettings(false); } @@ -517,6 +618,7 @@ const QHash R { {RemoteTCPProtocol::RTLSDR_E4000, &m_rtlSDRe4kGains}, {RemoteTCPProtocol::RTLSDR_R820T, &m_rtlSDRR820Gains}, + {RemoteTCPProtocol::RTLSDR_R828D, &m_rtlSDRR820Gains}, {RemoteTCPProtocol::AIRSPY, &m_airspyGains}, {RemoteTCPProtocol::AIRSPY_HF, &m_airspyHFGains}, {RemoteTCPProtocol::BLADE_RF1, &m_baldeRF1Gains}, @@ -609,7 +711,12 @@ void RemoteTCPInputGui::on_startStop_toggled(bool checked) { if (m_doApplySettings) { - m_connectionError = false; + if (m_connectionError) + { + // Clear previous error + m_connectionError = false; + updateStatus(); + } RemoteTCPInput::MsgStartStop *message = RemoteTCPInput::MsgStartStop::create(checked); m_sampleSource->getInputMessageQueue()->push(message); } @@ -789,6 +896,29 @@ void RemoteTCPInputGui::on_sampleBits_currentIndexChanged(int index) sendSettings(); } +void RemoteTCPInputGui::on_squelchEnabled_toggled(bool checked) +{ + m_settings.m_squelchEnabled = checked; + m_settingsKeys.append("squelchEnabled"); + displayEnabled(); + sendSettings(); +} + +void RemoteTCPInputGui::on_squelch_valueChanged(int value) +{ + m_settings.m_squelch = value; + ui->squelchText->setText(QString::number(m_settings.m_squelch)); + m_settingsKeys.append("squelch"); + sendSettings(); +} + +void RemoteTCPInputGui::on_squelchGate_valueChanged(double value) +{ + m_settings.m_squelchGate = value; + m_settingsKeys.append("squelchGate"); + sendSettings(); +} + void RemoteTCPInputGui::on_dataAddress_editingFinished() { m_settings.m_dataAddress = ui->dataAddress->currentText(); @@ -810,17 +940,9 @@ void RemoteTCPInputGui::on_dataAddress_currentIndexChanged(int index) sendSettings(); } -void RemoteTCPInputGui::on_dataPort_editingFinished() +void RemoteTCPInputGui::on_dataPort_valueChanged(int value) { - bool ok; - quint16 udpPort = ui->dataPort->text().toInt(&ok); - - if ((!ok) || (udpPort < 1024)) { - udpPort = 9998; - } - - m_settings.m_dataPort = udpPort; - ui->dataPort->setText(tr("%1").arg(m_settings.m_dataPort)); + m_settings.m_dataPort = value; m_settingsKeys.append("dataPort"); sendSettings(); @@ -831,6 +953,7 @@ void RemoteTCPInputGui::on_overrideRemoteSettings_toggled(bool checked) m_settings.m_overrideRemoteSettings = checked; m_settingsKeys.append("overrideRemoteSettings"); sendSettings(); + displayEnabled(); } void RemoteTCPInputGui::on_preFill_valueChanged(int value) @@ -867,10 +990,14 @@ void RemoteTCPInputGui::updateHardware() void RemoteTCPInputGui::updateStatus() { - int state = m_deviceUISet->m_deviceAPI->state(); - - if (!m_connectionError && (m_lastEngineState != state)) + if (m_connectionError) + { + ui->startStop->setStyleSheet("QToolButton { background-color : red; }"); + } + else { + int state = m_deviceUISet->m_deviceAPI->state(); + switch(state) { case DeviceAPI::StNotStarted: @@ -889,9 +1016,28 @@ void RemoteTCPInputGui::updateStatus() default: break; } + } + displayEnabled(); +} - m_lastEngineState = state; +void RemoteTCPInputGui::tick() +{ + double magsqAvg, magsqPeak; + int nbMagsqSamples; + m_sampleSource->getMagSqLevels(magsqAvg, magsqPeak, nbMagsqSamples); + double powDbAvg = CalcDb::dbPower(magsqAvg); + double powDbPeak = CalcDb::dbPower(magsqPeak); + + ui->channelPowerMeter->levelChanged( + (100.0f + powDbAvg) / 100.0f, + (100.0f + powDbPeak) / 100.0f, + nbMagsqSamples); + + if (m_tickCount % 4 == 0) { + ui->channelPower->setText(tr("%1").arg(powDbAvg, 0, 'f', 1)); } + + m_tickCount++; } void RemoteTCPInputGui::openDeviceSettingsDialog(const QPoint& p) @@ -899,6 +1045,9 @@ void RemoteTCPInputGui::openDeviceSettingsDialog(const QPoint& p) if (m_contextMenuType == ContextMenuDeviceSettings) { BasicDeviceSettingsDialog dialog(this); + dialog.setReplayBytesPerSecond(m_settings.m_devSampleRate * 2 * sizeof(FixReal)); + dialog.setReplayLength(m_settings.m_replayLength); + dialog.setReplayStep(m_settings.m_replayStep); dialog.setUseReverseAPI(m_settings.m_useReverseAPI); dialog.setReverseAPIAddress(m_settings.m_reverseAPIAddress); dialog.setReverseAPIPort(m_settings.m_reverseAPIPort); @@ -908,6 +1057,11 @@ void RemoteTCPInputGui::openDeviceSettingsDialog(const QPoint& p) new DialogPositioner(&dialog, false); dialog.exec(); + m_settings.m_replayLength = dialog.getReplayLength(); + m_settings.m_replayStep = dialog.getReplayStep(); + displayReplayLength(); + displayReplayOffset(); + displayReplayStep(); m_settings.m_useReverseAPI = dialog.useReverseAPI(); m_settings.m_reverseAPIAddress = dialog.getReverseAPIAddress(); m_settings.m_reverseAPIPort = dialog.getReverseAPIPort(); @@ -919,6 +1073,110 @@ void RemoteTCPInputGui::openDeviceSettingsDialog(const QPoint& p) resetContextMenuType(); } +void RemoteTCPInputGui::displayReplayLength() +{ + bool replayEnabled = m_settings.m_replayLength > 0.0f; + if (!replayEnabled) { + ui->replayOffset->setMaximum(0); + } else { + ui->replayOffset->setMaximum(m_settings.m_replayLength * 10 - 1); + } + ui->replayLabel->setEnabled(replayEnabled); + ui->replayOffset->setEnabled(replayEnabled); + ui->replayOffsetText->setEnabled(replayEnabled); + ui->replaySave->setEnabled(replayEnabled); +} + +void RemoteTCPInputGui::displayReplayOffset() +{ + bool replayEnabled = m_settings.m_replayLength > 0.0f; + ui->replayOffset->setValue(m_settings.m_replayOffset * 10); + ui->replayOffsetText->setText(QString("%1s").arg(m_settings.m_replayOffset, 0, 'f', 1)); + ui->replayNow->setEnabled(replayEnabled && (m_settings.m_replayOffset > 0.0f)); + ui->replayPlus->setEnabled(replayEnabled && (std::round(m_settings.m_replayOffset * 10) < ui->replayOffset->maximum())); + ui->replayMinus->setEnabled(replayEnabled && (m_settings.m_replayOffset > 0.0f)); +} + +void RemoteTCPInputGui::displayReplayStep() +{ + QString step; + float intpart; + float frac = modf(m_settings.m_replayStep, &intpart); + if (frac == 0.0f) { + step = QString::number((int)intpart); + } else { + step = QString::number(m_settings.m_replayStep, 'f', 1); + } + ui->replayPlus->setText(QString("+%1s").arg(step)); + ui->replayPlus->setToolTip(QString("Add %1 seconds to time delay").arg(step)); + ui->replayMinus->setText(QString("-%1s").arg(step)); + ui->replayMinus->setToolTip(QString("Remove %1 seconds from time delay").arg(step)); +} + +void RemoteTCPInputGui::on_replayOffset_valueChanged(int value) +{ + m_settings.m_replayOffset = value / 10.0f; + displayReplayOffset(); + m_settingsKeys.append("replayOffset"); + sendSettings(); +} + +void RemoteTCPInputGui::on_replayNow_clicked() +{ + ui->replayOffset->setValue(0); +} + +void RemoteTCPInputGui::on_replayPlus_clicked() +{ + ui->replayOffset->setValue(ui->replayOffset->value() + m_settings.m_replayStep * 10); +} + +void RemoteTCPInputGui::on_replayMinus_clicked() +{ + ui->replayOffset->setValue(ui->replayOffset->value() - m_settings.m_replayStep * 10); +} + +void RemoteTCPInputGui::on_replaySave_clicked() +{ + QFileDialog fileDialog(nullptr, "Select file to save IQ data to", "", "*.wav"); + fileDialog.setAcceptMode(QFileDialog::AcceptSave); + if (fileDialog.exec()) + { + QStringList fileNames = fileDialog.selectedFiles(); + if (fileNames.size() > 0) + { + RemoteTCPInput::MsgSaveReplay *message = RemoteTCPInput ::MsgSaveReplay::create(fileNames[0]); + m_sampleSource->getInputMessageQueue()->push(message); + } + } +} + +void RemoteTCPInputGui::on_replayLoop_toggled(bool checked) +{ + m_settings.m_replayLoop = checked; + m_settingsKeys.append("replayLoop"); + sendSettings(); +} + +void RemoteTCPInputGui::on_sendMessage_clicked() +{ + QString message = ui->txMessage->text().trimmed(); + if (!message.isEmpty()) + { + ui->messages->addItem(QString("< %1").arg(message)); + ui->messages->scrollToBottom(); + bool broadcast = ui->txAddress->currentText() == "All"; + QString callsign = MainCore::instance()->getSettings().getStationName(); + m_sampleSource->getInputMessageQueue()->push(RemoteTCPInput::MsgSendMessage::create(callsign, message, broadcast)); + } +} + +void RemoteTCPInputGui::on_txMessage_returnPressed() +{ + on_sendMessage_clicked(); + ui->txMessage->selectAll(); +} + void RemoteTCPInputGui::makeUIConnections() { QObject::connect(ui->startStop, &ButtonSwitch::toggled, this, &RemoteTCPInputGui::on_startStop_toggled); @@ -940,10 +1198,21 @@ void RemoteTCPInputGui::makeUIConnections() QObject::connect(ui->channelSampleRate, &ValueDial::changed, this, &RemoteTCPInputGui::on_channelSampleRate_changed); QObject::connect(ui->decimation, &ButtonSwitch::toggled, this, &RemoteTCPInputGui::on_decimation_toggled); QObject::connect(ui->sampleBits, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPInputGui::on_sampleBits_currentIndexChanged); + QObject::connect(ui->squelchEnabled, &ButtonSwitch::toggled, this, &RemoteTCPInputGui::on_squelchEnabled_toggled); + QObject::connect(ui->squelch, &QDial::valueChanged, this, &RemoteTCPInputGui::on_squelch_valueChanged); + QObject::connect(ui->squelchGate, &PeriodDial::valueChanged, this, &RemoteTCPInputGui::on_squelchGate_valueChanged); QObject::connect(ui->dataAddress->lineEdit(), &QLineEdit::editingFinished, this, &RemoteTCPInputGui::on_dataAddress_editingFinished); QObject::connect(ui->dataAddress, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPInputGui::on_dataAddress_currentIndexChanged); - QObject::connect(ui->dataPort, &QLineEdit::editingFinished, this, &RemoteTCPInputGui::on_dataPort_editingFinished); + QObject::connect(ui->dataPort, QOverload::of(&QSpinBox::valueChanged), this, &RemoteTCPInputGui::on_dataPort_valueChanged); QObject::connect(ui->overrideRemoteSettings, &ButtonSwitch::toggled, this, &RemoteTCPInputGui::on_overrideRemoteSettings_toggled); QObject::connect(ui->preFill, &QDial::valueChanged, this, &RemoteTCPInputGui::on_preFill_valueChanged); QObject::connect(ui->protocol, QOverload::of(&QComboBox::currentIndexChanged), this, &RemoteTCPInputGui::on_protocol_currentIndexChanged); + QObject::connect(ui->replayOffset, &QSlider::valueChanged, this, &RemoteTCPInputGui::on_replayOffset_valueChanged); + QObject::connect(ui->replayNow, &QToolButton::clicked, this, &RemoteTCPInputGui::on_replayNow_clicked); + QObject::connect(ui->replayPlus, &QToolButton::clicked, this, &RemoteTCPInputGui::on_replayPlus_clicked); + QObject::connect(ui->replayMinus, &QToolButton::clicked, this, &RemoteTCPInputGui::on_replayMinus_clicked); + QObject::connect(ui->replaySave, &QToolButton::clicked, this, &RemoteTCPInputGui::on_replaySave_clicked); + QObject::connect(ui->replayLoop, &ButtonSwitch::toggled, this, &RemoteTCPInputGui::on_replayLoop_toggled); + QObject::connect(ui->sendMessage, &QToolButton::clicked, this, &RemoteTCPInputGui::on_sendMessage_clicked); + QObject::connect(ui->txMessage, &QLineEdit::returnPressed, this, &RemoteTCPInputGui::on_txMessage_returnPressed); } diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.h b/plugins/samplesource/remotetcpinput/remotetcpinputgui.h index 9afcec66ac..b252c45c83 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.h @@ -108,12 +108,11 @@ class RemoteTCPInputGui : public DeviceGUI { QList m_settingsKeys; RemoteTCPInput* m_sampleSource; QTimer m_updateTimer; - QTimer m_statusTimer; - int m_lastEngineState; MessageQueue m_inputMessageQueue; int m_sampleRate; quint64 m_centerFrequency; + uint32_t m_tickCount; bool m_doApplySettings; bool m_forceSettings; @@ -125,6 +124,11 @@ class RemoteTCPInputGui : public DeviceGUI { DeviceGains::GainRange m_spyServerGainRange; DeviceGains m_spyServerGains; + bool m_sdra; + bool m_spyServer; + bool m_remoteControl; + bool m_iqOnly; + static const DeviceGains::GainRange m_rtlSDR34kGainRange; static const DeviceGains m_rtlSDRe4kGains; static const DeviceGains::GainRange m_rtlSDRR820GainRange; @@ -175,9 +179,13 @@ class RemoteTCPInputGui : public DeviceGUI { void blockApplySettings(bool block); void displaySettings(); QString gainText(int stage); + void displayEnabled(); void displayGains(); void displayRemoteSettings(); void displayRemoteShift(); + void displayReplayLength(); + void displayReplayOffset(); + void displayReplayStep(); void sendSettings(); void updateSampleRateAndFrequency(); void applyDecimation(); @@ -206,15 +214,27 @@ private slots: void on_channelSampleRate_changed(quint64 value); void on_decimation_toggled(bool checked); void on_sampleBits_currentIndexChanged(int index); + void on_squelchEnabled_toggled(bool checked); + void on_squelch_valueChanged(int value); + void on_squelchGate_valueChanged(double value); void on_dataAddress_editingFinished(); void on_dataAddress_currentIndexChanged(int index); - void on_dataPort_editingFinished(); + void on_dataPort_valueChanged(int value); void on_overrideRemoteSettings_toggled(bool checked); void on_preFill_valueChanged(int value); void on_protocol_currentIndexChanged(int index); + void on_replayOffset_valueChanged(int value); + void on_replayNow_clicked(); + void on_replayPlus_clicked(); + void on_replayMinus_clicked(); + void on_replaySave_clicked(); + void on_replayLoop_toggled(bool checked); + void on_sendMessage_clicked(); + void on_txMessage_returnPressed(); void updateHardware(); void updateStatus(); void openDeviceSettingsDialog(const QPoint& p); + void tick(); }; #endif // INCLUDE_REMOTETCPINPUTGUI_H diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui b/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui index ec526650d6..c90b15ced2 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui @@ -7,7 +7,7 @@ 0 0 360 - 360 + 586 @@ -19,13 +19,13 @@ 360 - 360 + 500 - 491 - 360 + 533 + 610 @@ -863,6 +863,184 @@ Use to ensure full dynamic range of 8-bit data is used.
+ + + + Qt::Horizontal + + + + + + + + + dB + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + Liberation Mono + 8 + + + + Level meter (dB) top trace: average, bottom trace: instantaneous peak, tip: peak hold + + + + + + + + + + + Check to enable IQ squelch + + + SQ + + + + + + + Qt::Vertical + + + + + + + + 24 + 24 + + + + IQ squelch power level in dB + + + -150 + + + 0 + + + 1 + + + + + + + + 32 + 0 + + + + -150 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + dB + + + + + + + Qt::Vertical + + + + + + + + 40 + 0 + + + + IQ squelch gate time + + + + + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 30 + 0 + + + + Channel power + + + Qt::RightToLeft + + + 0.0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + dB + + + + + @@ -914,33 +1092,18 @@ Use to ensure full dynamic range of 8-bit data is used. - - - true - - - - 60 - 0 - - - - - 60 - 16777215 - - + Remote data port (rtl_tcp defaults to 1234) - - 00000 + + 1024 - - 0 + + 65535 - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 1234 @@ -961,7 +1124,7 @@ Use to ensure full dynamic range of 8-bit data is used. - 75 + 92 0 @@ -1162,6 +1325,182 @@ This should typically be empty. If full, your CPU cannot keep up and data will b + + + + Qt::Horizontal + + + + + + + + + false + + + Click to send message + + + TX + + + + + + + false + + + Who to send message to + + + + Host + + + + + All + + + + + + + + false + + + Message to transmit + + + + + + + + + + + false + + + Messages + + + QListView::Static + + + + + + + + + Qt::Horizontal + + + + + + + + + + 65 + 0 + + + + Time Delay + + + + + + + Replay time delay in seconds + + + 500 + + + Qt::Horizontal + + + + + + + Replay time delay in seconds + + + 0.0s + + + + + + + Set time delay to 0 seconds + + + Now + + + + + + + Add displayed number of seconds to time delay + + + +5s + + + + + + + Remove displayed number of seconds from time delay + + + -5s + + + + + + + Repeatedly replay data in replay buffer + + + + + + + :/playloop.png:/playloop.png + + + + + + + Save replay buffer to a file + + + + + + + :/save.png:/save.png + + + + + @@ -1214,6 +1553,18 @@ This should typically be empty. If full, your CPU cannot keep up and data will b
gui/valuedial.h
1 + + LevelMeterSignalDB + QWidget +
gui/levelmeter.h
+ 1 +
+ + PeriodDial + QWidget +
gui/perioddial.h
+ 1 +
startStop @@ -1232,7 +1583,8 @@ This should typically be empty. If full, your CPU cannot keep up and data will b channelGain decimation sampleBits - dataPort + dataAddress + protocol overrideRemoteSettings preFill diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.cpp index ad52917941..b374b9fc1e 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.cpp @@ -53,6 +53,13 @@ void RemoteTCPInputSettings::resetToDefaults() m_reverseAPIAddress = "127.0.0.1"; m_reverseAPIPort = 8888; m_reverseAPIDeviceIndex = 0; + m_replayOffset = 0.0f; + m_replayLength = 20.0f; + m_replayStep = 5.0f; + m_replayLoop = false; + m_squelchEnabled = false; + m_squelch = -100.0f; + m_squelchGate = 0.001f; } QByteArray RemoteTCPInputSettings::serialize() const @@ -83,11 +90,19 @@ QByteArray RemoteTCPInputSettings::serialize() const s.writeU32(23, m_reverseAPIDeviceIndex); s.writeList(24, m_addressList); s.writeString(25, m_protocol); + s.writeFloat(26, m_replayOffset); + s.writeFloat(27, m_replayLength); + s.writeFloat(28, m_replayStep); + s.writeBool(29, m_replayLoop); for (int i = 0; i < m_maxGains; i++) { s.writeS32(30+i, m_gain[i]); } + s.writeBool(40, m_squelchEnabled); + s.writeFloat(41, m_squelch); + s.writeFloat(42, m_squelchGate); + return s.final(); } @@ -140,10 +155,19 @@ bool RemoteTCPInputSettings::deserialize(const QByteArray& data) d.readList(24, &m_addressList); d.readString(25, &m_protocol, "SDRangel"); + d.readFloat(26, &m_replayOffset, 0.0f); + d.readFloat(27, &m_replayLength, 20.0f); + d.readFloat(28, &m_replayStep, 5.0f); + d.readBool(29, &m_replayLoop, false); + for (int i = 0; i < m_maxGains; i++) { d.readS32(30+i, &m_gain[i], 0); } + d.readBool(40, &m_squelchEnabled, false); + d.readFloat(41, &m_squelch, -100.0f); + d.readFloat(42, &m_squelchGate, 0.001f); + return true; } else @@ -212,7 +236,7 @@ void RemoteTCPInputSettings::applySettings(const QStringList& settingsKeys, cons if (settingsKeys.contains("preFill")) { m_preFill = settings.m_preFill; } - if (settingsKeys.contains("_useReverseAPI")) { + if (settingsKeys.contains("useReverseAPI")) { m_useReverseAPI = settings.m_useReverseAPI; } if (settingsKeys.contains("reverseAPIAddress")) { @@ -230,6 +254,27 @@ void RemoteTCPInputSettings::applySettings(const QStringList& settingsKeys, cons if (settingsKeys.contains("protocol")) { m_protocol = settings.m_protocol; } + if (settingsKeys.contains("replayOffset")) { + m_replayOffset = settings.m_replayOffset; + } + if (settingsKeys.contains("replayLength")) { + m_replayLength = settings.m_replayLength; + } + if (settingsKeys.contains("replayStep")) { + m_replayStep = settings.m_replayStep; + } + if (settingsKeys.contains("replayLoop")) { + m_replayLoop = settings.m_replayLoop; + } + if (settingsKeys.contains("squelchEnabled")) { + m_squelchEnabled = settings.m_squelchEnabled; + } + if (settingsKeys.contains("squelch")) { + m_squelch = settings.m_squelch; + } + if (settingsKeys.contains("squelchGate")) { + m_squelchGate = settings.m_squelchGate; + } for (int i = 0; i < m_maxGains; i++) { @@ -318,6 +363,27 @@ QString RemoteTCPInputSettings::getDebugString(const QStringList& settingsKeys, if (settingsKeys.contains("protocol") || force) { ostr << " m_protocol: " << m_protocol.toStdString(); } + if (settingsKeys.contains("replayOffset") || force) { + ostr << " m_replayOffset: " << m_replayOffset; + } + if (settingsKeys.contains("replayLength") || force) { + ostr << " m_replayLength: " << m_replayLength; + } + if (settingsKeys.contains("replayStep") || force) { + ostr << " m_replayStep: " << m_replayStep; + } + if (settingsKeys.contains("replayLoop") || force) { + ostr << " m_replayLoop: " << m_replayLoop; + } + if (settingsKeys.contains("squelchEnabled") || force) { + ostr << " m_squelchEnabled: " << m_squelchEnabled; + } + if (settingsKeys.contains("squelch") || force) { + ostr << " m_squelch: " << m_squelch; + } + if (settingsKeys.contains("squelchGate") || force) { + ostr << " m_squelchGate: " << m_squelchGate; + } for (int i = 0; i < m_maxGains; i++) { diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h index a95dc97732..c209532c0f 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h @@ -54,6 +54,13 @@ struct RemoteTCPInputSettings uint16_t m_reverseAPIDeviceIndex; QStringList m_addressList; // List of dataAddresses that have been used in the past QString m_protocol; // "SDRangel" or "Spy Server" + float m_replayOffset; //!< Replay offset in seconds + float m_replayLength; //!< Replay buffer size in seconds + float m_replayStep; //!< Replay forward/back step size in seconds + bool m_replayLoop; //!< Replay buffer repeatedly without recording new data + bool m_squelchEnabled; + float m_squelch; + float m_squelchGate; RemoteTCPInputSettings(); void resetToDefaults(); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp index 9eaf92038b..1481f2c45a 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp @@ -17,11 +17,11 @@ // along with this program. If not, see . // /////////////////////////////////////////////////////////////////////////////////// -#include #include #include "device/deviceapi.h" #include "util/message.h" +#include "maincore.h" #include "remotetcpinputtcphandler.h" #include "remotetcpinput.h" @@ -31,41 +31,66 @@ MESSAGE_CLASS_DEFINITION(RemoteTCPInputTCPHandler::MsgReportRemoteDevice, Messag MESSAGE_CLASS_DEFINITION(RemoteTCPInputTCPHandler::MsgReportConnection, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPInputTCPHandler::MsgConfigureTcpHandler, Message) -RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler(SampleSinkFifo *sampleFifo, DeviceAPI *deviceAPI) : +RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler(SampleSinkFifo *sampleFifo, DeviceAPI *deviceAPI, ReplayBuffer *replayBuffer) : m_deviceAPI(deviceAPI), m_running(false), m_dataSocket(nullptr), m_tcpBuf(nullptr), m_sampleFifo(sampleFifo), - m_messageQueueToGUI(0), + m_replayBuffer(replayBuffer), + m_messageQueueToInput(nullptr), + m_messageQueueToGUI(nullptr), m_fillBuffer(true), m_timer(this), m_reconnectTimer(this), m_sdra(false), m_converterBuffer(nullptr), m_converterBufferNbSamples(0), - m_settings() + m_settings(), + m_remoteControl(true), + m_iqOnly(false), + m_decoder(nullptr), + m_zOutBuf(m_zBufSize, '\0'), + m_blacklisted(false), + m_magsq(0.0f), + m_magsqSum(0.0f), + m_magsqPeak(0.0f), + m_magsqCount(0) { m_sampleFifo->setSize(5000000); // Start with large FIFO, to avoid having to resize m_tcpBuf = new char[m_sampleFifo->size()*2*4]; m_timer.setInterval(50); // Previously 125, but this results in an obviously slow spectrum refresh rate connect(&m_reconnectTimer, SIGNAL(timeout()), this, SLOT(reconnect())); m_reconnectTimer.setSingleShot(true); + + // Initialise zlib decompressor + m_zStream.zalloc = Z_NULL; + m_zStream.zfree = Z_NULL; + m_zStream.opaque = Z_NULL; + m_zStream.avail_in = 0; + m_zStream.next_in = Z_NULL; + if (Z_OK != inflateInit(&m_zStream)) { + qDebug() << "RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler: inflateInit failed."; + } } RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler() { + qDebug() << "RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler"; delete[] m_tcpBuf; if (m_converterBuffer) { delete[] m_converterBuffer; } + qDebug() << "RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler cleanup"; cleanup(); + qDebug() << "RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler done"; } void RemoteTCPInputTCPHandler::reset() { QMutexLocker mutexLocker(&m_mutex); m_inputMessageQueue.clear(); + m_blacklisted = false; } // start() is called from DSPDeviceSourceEngine thread @@ -88,6 +113,7 @@ void RemoteTCPInputTCPHandler::start() void RemoteTCPInputTCPHandler::stop() { + qDebug("RemoteTCPInputTCPHandler::stop locking"); QMutexLocker mutexLocker(&m_mutex); qDebug("RemoteTCPInputTCPHandler::stop"); @@ -101,19 +127,21 @@ void RemoteTCPInputTCPHandler::started() // Don't connectToHost until we get settings connect(&m_timer, SIGNAL(timeout()), this, SLOT(processData())); - m_timer.start(); disconnect(thread(), SIGNAL(started()), this, SLOT(started())); } void RemoteTCPInputTCPHandler::finished() { + qDebug("RemoteTCPInputTCPHandler::finished"); QMutexLocker mutexLocker(&m_mutex); m_timer.stop(); disconnect(&m_timer, SIGNAL(timeout()), this, SLOT(processData())); - disconnectFromHost(); + //disconnectFromHost(); + cleanup(); disconnect(thread(), SIGNAL(finished()), this, SLOT(finished())); m_running = false; + qDebug("RemoteTCPInputTCPHandler::finished done"); } void RemoteTCPInputTCPHandler::connectToHost(const QString& address, quint16 port) @@ -133,7 +161,7 @@ void RemoteTCPInputTCPHandler::connectToHost(const QString& address, quint16 por m_dataSocket->connectToHost(address, port); } -void RemoteTCPInputTCPHandler::disconnectFromHost() +/*void RemoteTCPInputTCPHandler::disconnectFromHost() { if (m_dataSocket) { @@ -146,15 +174,31 @@ void RemoteTCPInputTCPHandler::disconnectFromHost() #else disconnect(m_dataSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); #endif - m_dataSocket->disconnectFromHost(); + //m_dataSocket->disconnectFromHost(); cleanup(); } -} +}*/ void RemoteTCPInputTCPHandler::cleanup() { + if (m_decoder) + { + FLAC__stream_decoder_delete(m_decoder); + m_decoder = nullptr; + } if (m_dataSocket) { + qDebug() << "RemoteTCPInputTCPHandler::cleanup: Closing and deleting socket"; + // Disconnect disconnected, so don't get called recursively + disconnect(m_dataSocket, SIGNAL(readyRead()), this, SLOT(dataReadyRead())); + disconnect(m_dataSocket, SIGNAL(connected()), this, SLOT(connected())); + disconnect(m_dataSocket, SIGNAL(disconnected()), this, SLOT(disconnected())); +#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) + disconnect(m_dataSocket, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPInputTCPHandler::errorOccurred); +#else + disconnect(m_dataSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); +#endif + m_dataSocket->close(); m_dataSocket->deleteLater(); m_dataSocket = nullptr; } @@ -176,225 +220,188 @@ void RemoteTCPInputTCPHandler::clearBuffer() else { m_dataSocket->flush(); - m_dataSocket->readAll(); - m_fillBuffer = true; + if (!m_decoder) { // Can't throw away FLAC header + m_dataSocket->readAll(); + m_fillBuffer = true; + } } } } -void RemoteTCPInputTCPHandler::setSampleRate(int sampleRate) +void RemoteTCPInputTCPHandler::sendCommand(RemoteTCPProtocol::Command cmd, quint32 value) { QMutexLocker mutexLocker(&m_mutex); - quint8 request[5]; - request[0] = RemoteTCPProtocol::setSampleRate; - RemoteTCPProtocol::encodeUInt32(&request[1], sampleRate); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); + + request[0] = (quint8) cmd; + RemoteTCPProtocol::encodeUInt32(&request[1], value); + if (m_dataSocket) + { + qint64 len = m_dataSocket->write((char*)request, sizeof(request)); + if (len != sizeof(request)) { + qDebug() << "RemoteTCPInputTCPHandler::sendCommand: Failed to write all of request:" << len; + } + } else { + qDebug() << "RemoteTCPInputTCPHandler::sendCommand: No socket"; } } -void RemoteTCPInputTCPHandler::setCenterFrequency(quint64 frequency) +void RemoteTCPInputTCPHandler::sendCommandFloat(RemoteTCPProtocol::Command cmd, float value) { QMutexLocker mutexLocker(&m_mutex); - quint8 request[5]; - request[0] = RemoteTCPProtocol::setCenterFrequency; - RemoteTCPProtocol::encodeUInt32(&request[1], frequency); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); + + request[0] = (quint8) cmd; + RemoteTCPProtocol::encodeFloat(&request[1], value); + if (m_dataSocket) + { + qint64 len = m_dataSocket->write((char*)request, sizeof(request)); + if (len != sizeof(request)) { + qDebug() << "RemoteTCPInputTCPHandler::sendCommand: Failed to write all of request:" << len; + } + } else { + qDebug() << "RemoteTCPInputTCPHandler::sendCommand: No socket"; } } -void RemoteTCPInputTCPHandler::setTunerAGC(bool agc) +void RemoteTCPInputTCPHandler::setSampleRate(int sampleRate) { - QMutexLocker mutexLocker(&m_mutex); + sendCommand(RemoteTCPProtocol::setSampleRate, sampleRate); +} - quint8 request[5]; - request[0] = RemoteTCPProtocol::setTunerGainMode; - RemoteTCPProtocol::encodeUInt32(&request[1], agc); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } +void RemoteTCPInputTCPHandler::setCenterFrequency(quint64 frequency) +{ + sendCommand(RemoteTCPProtocol::setCenterFrequency, frequency); // FIXME: Can't support >4GHz } -void RemoteTCPInputTCPHandler::setTunerGain(int gain) +void RemoteTCPInputTCPHandler::setTunerAGC(bool agc) { - QMutexLocker mutexLocker(&m_mutex); + sendCommand(RemoteTCPProtocol::setTunerGainMode, agc); +} - quint8 request[5]; - request[0] = RemoteTCPProtocol::setTunerGain; - RemoteTCPProtocol::encodeUInt32(&request[1], gain); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } +void RemoteTCPInputTCPHandler::setTunerGain(int gain) +{ + sendCommand(RemoteTCPProtocol::setTunerGain, gain); } void RemoteTCPInputTCPHandler::setGainByIndex(int index) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setGainByIndex; - RemoteTCPProtocol::encodeUInt32(&request[1], index); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setGainByIndex, index); } void RemoteTCPInputTCPHandler::setFreqCorrection(int correction) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setFrequencyCorrection; - RemoteTCPProtocol::encodeUInt32(&request[1], correction); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setFrequencyCorrection, correction); } void RemoteTCPInputTCPHandler::setIFGain(quint16 stage, quint16 gain) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setTunerIFGain; - RemoteTCPProtocol::encodeUInt32(&request[1], (stage << 16) | gain); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setTunerIFGain, (stage << 16) | gain); } void RemoteTCPInputTCPHandler::setAGC(bool agc) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setAGCMode; - RemoteTCPProtocol::encodeUInt32(&request[1], agc); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setAGCMode, agc); } void RemoteTCPInputTCPHandler::setDirectSampling(bool enabled) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setDirectSampling; - RemoteTCPProtocol::encodeUInt32(&request[1], enabled ? 3 : 0); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setDirectSampling, enabled ? 3 : 0); } void RemoteTCPInputTCPHandler::setDCOffsetRemoval(bool enabled) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setDCOffsetRemoval; - RemoteTCPProtocol::encodeUInt32(&request[1], enabled); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setDCOffsetRemoval, enabled); } void RemoteTCPInputTCPHandler::setIQCorrection(bool enabled) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setIQCorrection; - RemoteTCPProtocol::encodeUInt32(&request[1], enabled); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setIQCorrection, enabled); } void RemoteTCPInputTCPHandler::setBiasTee(bool enabled) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setBiasTee; - RemoteTCPProtocol::encodeUInt32(&request[1], enabled); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setBiasTee, enabled); } void RemoteTCPInputTCPHandler::setBandwidth(int bandwidth) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setTunerBandwidth; - RemoteTCPProtocol::encodeUInt32(&request[1], bandwidth); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setTunerBandwidth, bandwidth); } void RemoteTCPInputTCPHandler::setDecimation(int dec) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setDecimation; - RemoteTCPProtocol::encodeUInt32(&request[1], dec); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setDecimation, dec); } void RemoteTCPInputTCPHandler::setChannelSampleRate(int sampleRate) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setChannelSampleRate; - RemoteTCPProtocol::encodeUInt32(&request[1], sampleRate); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setChannelSampleRate, sampleRate); } void RemoteTCPInputTCPHandler::setChannelFreqOffset(int offset) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setChannelFreqOffset; - RemoteTCPProtocol::encodeUInt32(&request[1], offset); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setChannelFreqOffset, offset); } void RemoteTCPInputTCPHandler::setChannelGain(int gain) { - QMutexLocker mutexLocker(&m_mutex); - - quint8 request[5]; - request[0] = RemoteTCPProtocol::setChannelGain; - RemoteTCPProtocol::encodeUInt32(&request[1], gain); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); - } + sendCommand(RemoteTCPProtocol::setChannelGain, gain); } void RemoteTCPInputTCPHandler::setSampleBitDepth(int sampleBits) +{ + sendCommand(RemoteTCPProtocol::setSampleBitDepth, sampleBits); +} + +void RemoteTCPInputTCPHandler::setSquelchEnabled(bool enabled) +{ + sendCommand(RemoteTCPProtocol::setIQSquelchEnabled, (quint32) enabled); +} + +void RemoteTCPInputTCPHandler::setSquelch(float squelch) +{ + sendCommandFloat(RemoteTCPProtocol::setIQSquelch, squelch); +} + +void RemoteTCPInputTCPHandler::setSquelchGate(float squelchGate) +{ + sendCommandFloat(RemoteTCPProtocol::setIQSquelchGate, squelchGate); +} + +void RemoteTCPInputTCPHandler::sendMessage(const QString& callsign, const QString& text, bool broadcast) { QMutexLocker mutexLocker(&m_mutex); - quint8 request[5]; - request[0] = RemoteTCPProtocol::setSampleBitDepth; - RemoteTCPProtocol::encodeUInt32(&request[1], sampleBits); - if (m_dataSocket) { - m_dataSocket->write((char*)request, sizeof(request)); + if (m_dataSocket) + { + qint64 len; + char cmd[1+4+1]; + QByteArray callsignBytes = callsign.toUtf8(); + QByteArray textBytes = text.toUtf8(); + QByteArray bytes; + + bytes.append(callsignBytes); + bytes.append('\0'); + bytes.append(textBytes); + bytes.append('\0'); + + cmd[0] = (char) RemoteTCPProtocol::sendMessage; + RemoteTCPProtocol::encodeUInt32((quint8*) &cmd[1], bytes.size() + 1); + cmd[5] = (char) broadcast; + + len = m_dataSocket->write(&cmd[0], sizeof(cmd)); + if (len != sizeof(cmd)) { + qDebug() << "RemoteTCPInputTCPHandler::set: Failed to write all of message header:" << len; + } + len = m_dataSocket->write(bytes.data(), bytes.size()); + if (len != bytes.size()) { + qDebug() << "RemoteTCPInputTCPHandler::set: Failed to write all of message:" << len; + } + m_dataSocket->flush(); + qDebug() << "sendMessage" << text; + } else { + qDebug() << "RemoteTCPInputTCPHandler::sendMessage: No socket"; } } @@ -407,8 +414,10 @@ void RemoteTCPInputTCPHandler::spyServerConnect() SpyServerProtocol::encodeUInt32(&request[4], 4+9); SpyServerProtocol::encodeUInt32(&request[8], SpyServerProtocol::ProtocolID); memcpy(&request[8+4], "SDRangel", 9); - if (m_dataSocket) { + if (m_dataSocket) + { m_dataSocket->write((char*)request, sizeof(request)); + m_dataSocket->flush(); } } @@ -421,8 +430,10 @@ void RemoteTCPInputTCPHandler::spyServerSet(int setting, int value) SpyServerProtocol::encodeUInt32(&request[4], 8); SpyServerProtocol::encodeUInt32(&request[8], setting); SpyServerProtocol::encodeUInt32(&request[12], value); - if (m_dataSocket) { + if (m_dataSocket) + { m_dataSocket->write((char*)request, sizeof(request)); + m_dataSocket->flush(); } } @@ -577,12 +588,37 @@ void RemoteTCPInputTCPHandler::applySettings(const RemoteTCPInputSettings& setti } clearBuffer(); } + if (settingsKeys.contains("squelchEnabled") || force) + { + if (m_sdra) { + setSquelchEnabled(settings.m_squelchEnabled); + } + } + if (settingsKeys.contains("squelch") || force) + { + if (m_sdra) { + setSquelch(settings.m_squelch); + } + } + if (settingsKeys.contains("squelchGate") || force) + { + if (m_sdra) { + setSquelchGate(settings.m_squelchGate); + } + } + } + + if (m_dataSocket) { + m_dataSocket->flush(); // Apparently needed for WebAssembly with proxy } // Don't use force, as disconnect can cause rtl_tcp to quit - if (settingsKeys.contains("dataAddress") || settingsKeys.contains("dataPort") || (m_dataSocket == nullptr)) + if (settingsKeys.contains("dataAddress") + || settingsKeys.contains("dataPort") + || (m_dataSocket == nullptr) && !m_blacklisted) { - disconnectFromHost(); + //disconnectFromHost(); + cleanup(); connectToHost(settings.m_dataAddress, settings.m_dataPort); } @@ -593,6 +629,362 @@ void RemoteTCPInputTCPHandler::applySettings(const RemoteTCPInputSettings& setti } } +static FLAC__StreamDecoderReadStatus flacReadCallback(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes, void *clientData) +{ + RemoteTCPInputTCPHandler *handler = (RemoteTCPInputTCPHandler *) clientData; + + return handler->flacRead(decoder, buffer, bytes); +} + +static FLAC__StreamDecoderWriteStatus flacWriteCallback(const FLAC__StreamDecoder *decoder, const FLAC__Frame *frame, const FLAC__int32 *const buffer[], void *clientData) +{ + RemoteTCPInputTCPHandler *handler = (RemoteTCPInputTCPHandler *) clientData; + + return handler->flacWrite(decoder, frame, buffer); +} + +static void flacErrorCallback(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatus status, void *clientData) +{ + RemoteTCPInputTCPHandler *handler = (RemoteTCPInputTCPHandler *) clientData; + + return handler->flacError(decoder, status); +} + +/*FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes) +{ + if (m_dataSocket) + { + qint64 bytesRequested = *bytes; + qint64 bytesRead = std::min(bytesRequested, m_compressedData.size()); + + //bytesRead = m_dataSocket->read((char *) buffer, bytesRequested); + + memcpy(buffer, m_compressedData.constData(), bytesRead); + + qDebug() << "flacRead" << bytesRequested << bytesRead; + + if (bytesRead != -1) + { + *bytes = (size_t) bytesRead; + return FLAC__STREAM_DECODER_READ_STATUS_CONTINUE; + } + else + { + return FLAC__STREAM_DECODER_READ_STATUS_ABORT; + } + } + else + { + return FLAC__STREAM_DECODER_READ_STATUS_ABORT; + } +}*/ + +FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes) +{ + qsizetype bytesRequested = *bytes; + qsizetype bytesRead = std::min(bytesRequested, (qsizetype) m_compressedData.size()); + + memcpy(buffer, m_compressedData.constData(), bytesRead); + m_compressedData.remove(0, bytesRead); + + //qDebug() << "RemoteTCPInputTCPHandler::flacRead bytesRequested" << bytesRequested << "bytesRead" << bytesRead; + if (bytesRead == 0) + { + qDebug() << "RemoteTCPInputTCPHandler::flacRead: Decoder will hang if we can't return data"; + abort(); + } + + *bytes = (size_t) bytesRead; + return FLAC__STREAM_DECODER_READ_STATUS_CONTINUE; +} + +FIFO::FIFO(qsizetype elements) +{ + m_data.resize(elements); + clear(); +} + +qsizetype FIFO::write(quint8 *data, qsizetype elements) +{ + qsizetype writeCount = std::min(elements, m_data.size() - m_fill); + qsizetype remaining = m_data.size() - m_writePtr; + qsizetype len2 = writeCount - remaining; + + //qDebug() << "write" << write << remaining << len2; + + if (len2 < 0) + { + std::memcpy(&m_data.data()[m_writePtr], data, writeCount); + m_writePtr += writeCount; + } + else if (len2 == 0) + { + std::memcpy(&m_data.data()[m_writePtr], data, writeCount); + m_writePtr = 0; + } + else + { + std::memcpy(&m_data.data()[m_writePtr], data, remaining); + std::memcpy(&m_data.data()[0], &data[remaining], len2); + m_writePtr = len2; + } + + m_fill += writeCount; + + return writeCount; +} + +qsizetype FIFO::read(quint8 *data, qsizetype elements) +{ + qsizetype readCount = std::min(elements, m_fill); + qsizetype remaining = m_data.size() - m_readPtr; + qsizetype len2 = readCount - remaining; + + // qDebug() << "read" << read << remaining << len2; + + if (len2 < 0) + { + std::memcpy(data, &m_data.data()[m_readPtr], readCount); + m_readPtr += readCount; + } + else if (len2 == 0) + { + std::memcpy(data, &m_data.data()[m_readPtr], readCount); + m_readPtr = 0; + } + else + { + std::memcpy(&data[0], &m_data.data()[m_readPtr], remaining); + std::memcpy(&data[remaining], &m_data[0], len2); + m_readPtr = len2; + } + + m_fill -= readCount; + + return readCount; +} + +qsizetype FIFO::readPtr(quint8 **data, qsizetype elements) +{ + *data = (quint8 *) &m_data.data()[m_readPtr]; + + return std::min(elements, m_data.size() - m_readPtr); +} + +void FIFO::read(qsizetype elements) +{ + m_readPtr = (m_readPtr + elements) % m_data.size(); + m_fill -= elements; + if (m_fill < 0) + { + qDebug() << "FIFO::read: Underrun"; + m_fill = 0; + } +} + +void FIFO::resize(qsizetype elements) +{ + m_data.resize(elements); + m_data.squeeze(); +} + +void FIFO::clear() +{ + m_writePtr = 0; + m_readPtr = 0; + m_fill = 0; +} + +void RemoteTCPInputTCPHandler::calcPower(const Sample *iq, int nbSamples) +{ + for (int i = 0; i < nbSamples; i++) + { + Real re = iq[i].real();// SDR_RX_SCALED; + Real im = iq[i].imag();// SDR_RX_SCALED; + + Real magsq = (re*re + im*im) / (SDR_RX_SCALED*SDR_RX_SCALED); + m_movingAverage(magsq); + m_magsq = m_movingAverage.asDouble(); + m_magsqSum += magsq; + m_magsqPeak = std::max(magsq, m_magsqPeak); + m_magsqCount++; + } +} + +FLAC__StreamDecoderWriteStatus RemoteTCPInputTCPHandler::flacWrite(const FLAC__StreamDecoder *decoder, const FLAC__Frame *frame, const FLAC__int32 *const buffer[]) +{ + m_uncompressedFrames++; + + int nbSamples = frame->header.blocksize; +//qDebug() << "RemoteTCPInputTCPHandler::flacWrite m_uncompressedFrames" << m_uncompressedFrames << "nbSamples" << nbSamples; + if (nbSamples > (int) m_converterBufferNbSamples) + { + if (m_converterBuffer) { + delete[] m_converterBuffer; + } + m_converterBuffer = new int32_t[nbSamples*2]; + } + + // Convert and interleave samples and output to FIFO + if ((frame->header.bits_per_sample == 8) && (SDR_RX_SAMP_SZ == 24) && (frame->header.channels == 2)) + { + qint32 *out = (qint32 *)m_converterBuffer; + const qint32 *inI = buffer[0]; + const qint32 *inQ = buffer[1]; + + for (int i = 0; i < nbSamples; i++) + { + *out++ = *inI++ << 16; + *out++ = *inQ++ << 16; + } + m_uncompressedData.write(reinterpret_cast(m_converterBuffer), nbSamples*sizeof(Sample)); + } + else if ((frame->header.bits_per_sample == 16) && (SDR_RX_SAMP_SZ == 24) && (frame->header.channels == 2)) + { + qint32 *out = (qint32 *)m_converterBuffer; + const qint32 *inI = buffer[0]; + const qint32 *inQ = buffer[1]; + + for (int i = 0; i < nbSamples; i++) + { + *out++ = *inI++ << 8; + *out++ = *inQ++ << 8; + } + m_uncompressedData.write(reinterpret_cast(m_converterBuffer), nbSamples*sizeof(Sample)); + } + else if ((frame->header.bits_per_sample == 24) && (SDR_RX_SAMP_SZ == 24) && (frame->header.channels == 2)) + { + qint32 *out = (qint32 *)m_converterBuffer; + const qint32 *inI = buffer[0]; + const qint32 *inQ = buffer[1]; + + for (int i = 0; i < nbSamples; i++) + { + *out++ = *inI++; + *out++ = *inQ++; + } + m_uncompressedData.write(reinterpret_cast(m_converterBuffer), nbSamples*sizeof(Sample)); + } + else if ((frame->header.bits_per_sample == 32) && (SDR_RX_SAMP_SZ == 24) && (frame->header.channels == 2)) + { + qint32 *out = (qint32 *)m_converterBuffer; + const qint32 *inI = buffer[0]; + const qint32 *inQ = buffer[1]; + + for (int i = 0; i < nbSamples; i++) + { + *out++ = *inI++; + *out++ = *inQ++; + } + m_uncompressedData.write(reinterpret_cast(m_converterBuffer), nbSamples*sizeof(Sample)); + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::flacWrite: Unsupported format"; + } + + return FLAC__STREAM_DECODER_WRITE_STATUS_CONTINUE; +} + + +// Convert from zlib uncompressed network format to Samples, to uncompressed data FIFO +void RemoteTCPInputTCPHandler::processDecompressedZlibData(const char *inBuf, int nbSamples) +{ + // Ensure conversion buffer is large enough - FIXME: Don't use this buffer - just write in to FIFO + if (nbSamples > (int) m_converterBufferNbSamples) + { + if (m_converterBuffer) { + delete[] m_converterBuffer; + } + m_converterBuffer = new int32_t[nbSamples*2]; + } + + // Convert from network format to Sample + if ((m_settings.m_sampleBits == 8) && (SDR_RX_SAMP_SZ == 16)) + { + const quint8 *in = (const quint8 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = (((qint16)in[is]) - 128) << 8; + } + } + else if ((m_settings.m_sampleBits == 8) && (SDR_RX_SAMP_SZ == 24)) + { + const quint8 *in = (const quint8 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = (((qint32)in[is]) - 128) << 16; + } + } + else if ((m_settings.m_sampleBits == 16) && (SDR_RX_SAMP_SZ == 16)) + { + const qint16 *in = (const qint16 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = in[is]; + } + } + else if ((m_settings.m_sampleBits == 16) && (SDR_RX_SAMP_SZ == 24)) + { + const qint16 *in = (const qint16 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = in[is] << 8; + } + } + else if ((m_settings.m_sampleBits == 24) && (SDR_RX_SAMP_SZ == 24)) + { + const quint8 *in = (const quint8 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = (((in[3*is+2] << 16) | (in[3*is+1] << 8) | in[3*is]) << 8) >> 8; + } + } + else if ((m_settings.m_sampleBits == 24) && (SDR_RX_SAMP_SZ == 16)) + { + const quint8 *in = (const quint8 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = (in[3*is+2] << 8) | in[3*is+1]; + } + } + else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 16)) + { + const qint32 *in = (const qint32 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = in[is] >> 8; + } + } + else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 24)) + { + const qint32 *in = (const qint32 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; + + for (int is = 0; is < nbSamples*2; is++) { + out[is] = in[is]; + } + } + else // invalid size + { + qWarning("RemoteTCPInputTCPHandler::convert: unexpected sample size in stream: %d bits", (int) m_settings.m_sampleBits); + } + + m_uncompressedData.write(reinterpret_cast(m_converterBuffer), nbSamples*sizeof(Sample)); +} + +void RemoteTCPInputTCPHandler::flacError(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatus status) +{ + qDebug() << "RemoteTCPInputTCPHandler::flacError: Error:" << status; +} + void RemoteTCPInputTCPHandler::connected() { QMutexLocker mutexLocker(&m_mutex); @@ -605,9 +997,20 @@ void RemoteTCPInputTCPHandler::connected() m_spyServer = m_settings.m_protocol == "Spy Server"; m_state = HEADER; m_sdra = false; + m_remoteControl = true; + m_iqOnly = true; if (m_spyServer) { spyServerConnect(); } + // Start calls to processData + m_timer.start(); + + /*if (m_dataSocket->bytesAvailable()) { + qDebug() << "Data is already available"; + dataReadyRead(); + } else { + qDebug() << "No data available"; + }*/ } void RemoteTCPInputTCPHandler::reconnect() @@ -628,35 +1031,52 @@ void RemoteTCPInputTCPHandler::disconnected() MsgReportConnection *msg = MsgReportConnection::create(false); m_messageQueueToGUI->push(msg); } - // Try to reconnect - m_reconnectTimer.start(500); + if (!m_blacklisted) + { + // Try to reconnect immediately - it may just be server settings changed + m_reconnectTimer.start(1); + } + else + { + // Stop device so we don't try to reconnect + RemoteTCPInput::MsgStartStop *msg = RemoteTCPInput::MsgStartStop::create(false); + m_messageQueueToInput->push(msg); + } } void RemoteTCPInputTCPHandler::errorOccurred(QAbstractSocket::SocketError socketError) { + QMutexLocker mutexLocker(&m_mutex); qDebug() << "RemoteTCPInputTCPHandler::errorOccurred: " << socketError; - cleanup(); - if (m_messageQueueToGUI) + + // For RemoteHostClosedError, disconnected() will be called afterwards, so don't try to reconnect here + // We try to reconnect here, for errors such as ConnectionRefusedError + if (socketError != QAbstractSocket::RemoteHostClosedError) { - MsgReportConnection *msg = MsgReportConnection::create(false); - m_messageQueueToGUI->push(msg); + cleanup(); + if (m_messageQueueToGUI) + { + MsgReportConnection *msg = MsgReportConnection::create(false); + m_messageQueueToGUI->push(msg); + } + // Try to reconnect + m_reconnectTimer.start(500); } - // Try to reconnect - m_reconnectTimer.start(500); } void RemoteTCPInputTCPHandler::dataReadyRead() { QMutexLocker mutexLocker(&m_mutex); - if (!m_readMetaData && !m_spyServer) - { + if (!m_readMetaData && !m_spyServer) { processMetaData(); - } - else if (!m_readMetaData && m_spyServer) - { + } else if (!m_readMetaData && m_spyServer) { processSpyServerMetaData(); } + + if (m_readMetaData && !m_iqOnly) { + processCommands(); + } } void RemoteTCPInputTCPHandler::processMetaData() @@ -682,19 +1102,13 @@ void RemoteTCPInputTCPHandler::processMetaData() m_device = (RemoteTCPProtocol::Device)RemoteTCPProtocol::extractUInt32(&metaData[4]); if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, protocol)); + m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, protocol, false, true)); } if (m_settings.m_sampleBits != 8) { RemoteTCPInputSettings& settings = m_settings; - settings.m_sampleBits = 8; - QList settingsKeys{"sampleBits"}; - if (m_messageQueueToInput) { - m_messageQueueToInput->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } + settings.m_sampleBits = 8; + sendSettings(settings, {"sampleBits"}); } } else if (protocol == "SDRA") @@ -704,10 +1118,22 @@ void RemoteTCPInputTCPHandler::processMetaData() bytesRead = m_dataSocket->read((char *)&metaData[4], RemoteTCPProtocol::m_sdraMetaDataSize-4); m_device = (RemoteTCPProtocol::Device)RemoteTCPProtocol::extractUInt32(&metaData[4]); + quint32 protocolRevision = RemoteTCPProtocol::extractUInt32(&metaData[60]); + quint32 flags = RemoteTCPProtocol::extractUInt32(&metaData[20]); + if (protocolRevision >= 1) + { + m_iqOnly = !(bool) ((flags >> 7) & 1); + m_remoteControl = (bool) ((flags >> 6) & 1); + } + else + { + m_iqOnly = true; + m_remoteControl = true; + } if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, protocol)); + m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, protocol, m_iqOnly, m_remoteControl)); } - if (!m_settings.m_overrideRemoteSettings) + if (!m_settings.m_overrideRemoteSettings || !m_remoteControl) { // Update local settings to match remote RemoteTCPInputSettings& settings = m_settings; @@ -716,7 +1142,6 @@ void RemoteTCPInputTCPHandler::processMetaData() settingsKeys.append("centerFrequency"); settings.m_loPpmCorrection = RemoteTCPProtocol::extractUInt32(&metaData[16]); settingsKeys.append("loPpmCorrection"); - quint32 flags = RemoteTCPProtocol::extractUInt32(&metaData[20]); settings.m_biasTee = flags & 1; settingsKeys.append("biasTee"); settings.m_directSampling = (flags >> 1) & 1; @@ -752,19 +1177,57 @@ void RemoteTCPInputTCPHandler::processMetaData() settings.m_channelDecimation = true; settingsKeys.append("channelDecimation"); } - if (m_messageQueueToInput) { - m_messageQueueToInput->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); + if (protocolRevision >= 1) + { + settings.m_squelchEnabled = (flags >> 5) & 1; + settingsKeys.append("squelchEnabled"); + settings.m_squelch = RemoteTCPProtocol::extractFloat(&metaData[64]); + settingsKeys.append("squelch"); + settings.m_squelchGate = RemoteTCPProtocol::extractFloat(&metaData[68]); + settingsKeys.append("squelchGate"); + } + sendSettings(settings, settingsKeys); + } + + if (!m_iqOnly) + { + qDebug() << "RemoteTCPInputTCPHandler: Compression enabled"; + // Create FLAC decoder for IQ decompression + m_decoder = FLAC__stream_decoder_new(); + m_remainingSamples = 0; + m_compressedFrames = 0; + m_uncompressedFrames = 0; + + int bytesPerSecond = m_settings.m_channelSampleRate * 2 * sizeof(Sample); + int fifoSize = 2 * m_settings.m_preFill * bytesPerSecond; + m_uncompressedData.resize(fifoSize); + m_uncompressedData.clear(); + + if (m_decoder) + { + FLAC__StreamDecoderInitStatus initStatus; + initStatus = FLAC__stream_decoder_init_stream(m_decoder, flacReadCallback, nullptr, nullptr, nullptr, nullptr, flacWriteCallback, nullptr, flacErrorCallback, this); + if (initStatus != FLAC__STREAM_DECODER_INIT_STATUS_OK) + { + qDebug() << "RemoteTCPInputTCPHandler: Failed to init FLAC decoder: " << initStatus; + } } - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); + else + { + qDebug() << "RemoteTCPInputTCPHandler: Failed to allocate FLAC decoder"; } } + else + { + qDebug() << "RemoteTCPInputTCPHandler: Compression disabled"; + } } else { qDebug() << "RemoteTCPInputTCPHandler::dataReadyRead: Unknown protocol: " << protocol; + m_dataSocket->close(); } - if (m_settings.m_overrideRemoteSettings) + if (m_settings.m_overrideRemoteSettings && m_remoteControl) { // Force settings to be sent to remote device (this needs to be after m_sdra is determined above) applySettings(m_settings, QList(), true); @@ -868,7 +1331,7 @@ void RemoteTCPInputTCPHandler::processSpyServerDevice(const SpyServerProtocol::D break; } if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, "Spy Server", ssDevice->m_maxGainIndex)); + m_messageQueueToGUI->push(MsgReportRemoteDevice::create(m_device, "Spy Server", false, true, ssDevice->m_maxGainIndex)); } RemoteTCPInputSettings& settings = m_settings; @@ -882,12 +1345,7 @@ void RemoteTCPInputTCPHandler::processSpyServerDevice(const SpyServerProtocol::D m_settings.m_log2Decim = settings.m_log2Decim = ssDevice->m_minDecimation; settingsKeys.append("log2Decim"); } - if (m_messageQueueToInput) { - m_messageQueueToInput->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } + sendSettings(settings, settingsKeys); } void RemoteTCPInputTCPHandler::processSpyServerState(const SpyServerProtocol::State* ssState, bool initial) @@ -922,12 +1380,7 @@ void RemoteTCPInputTCPHandler::processSpyServerState(const SpyServerProtocol::St } if (settingsKeys.size() > 0) { - if (m_messageQueueToInput) { - m_messageQueueToInput->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } - if (m_messageQueueToGUI) { - m_messageQueueToGUI->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); - } + sendSettings(settings, settingsKeys); } } } @@ -978,7 +1431,7 @@ void RemoteTCPInputTCPHandler::processSpyServerData(int requiredBytes, bool clea if (!clear) { const int bytesPerIQPair = 2 * m_settings.m_sampleBits / 8; - convert(bytesRead / bytesPerIQPair); + processUncompressedData(&m_tcpBuf[0], bytesRead / bytesPerIQPair); } m_spyServerHeader.m_size -= bytesRead; requiredBytes -= bytesRead; @@ -1013,19 +1466,484 @@ void RemoteTCPInputTCPHandler::processSpyServerData(int requiredBytes, bool clea } } +void RemoteTCPInputTCPHandler::sendSettings(const RemoteTCPInputSettings& settings, const QStringList& settingsKeys) +{ + if (m_messageQueueToInput) { + m_messageQueueToInput->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); + } + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPInput::MsgConfigureRemoteTCPInput::create(settings, settingsKeys)); + } +} + +void RemoteTCPInputTCPHandler::processCommands() +{ + bool done = false; + + while (!done) + { + if (m_state == HEADER) + { + if (m_dataSocket->bytesAvailable() >= 5) + { + quint8 buf[5]; + qint64 bytesRead = m_dataSocket->read((char *) buf, sizeof(buf)); + + if (bytesRead == sizeof(buf)) + { + m_command = (RemoteTCPProtocol::Command) buf[0]; + + switch (m_command) + { + case RemoteTCPProtocol::setCenterFrequency: + { + quint32 centerFrequency = (quint32) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (centerFrequency != m_settings.m_centerFrequency) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_centerFrequency = centerFrequency; + sendSettings(settings, {"centerFrequency"}); + } + break; + } + case RemoteTCPProtocol::setSampleRate: + { + int devSampleRate = RemoteTCPProtocol::extractInt32(&buf[1]); + if (devSampleRate != m_settings.m_devSampleRate) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_devSampleRate = devSampleRate; + sendSettings(settings, {"devSampleRate"}); + } + break; + } + case RemoteTCPProtocol::setTunerGainMode: + { + // Currently fixed as 1 + } + case RemoteTCPProtocol::setTunerGain: + { + int gain = RemoteTCPProtocol::extractUInt32(&buf[1]); + if (gain != m_settings.m_gain[0]) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_gain[0] = gain; + sendSettings(settings, {"gain[0]"}); + } + break; + } + case RemoteTCPProtocol::setFrequencyCorrection: + { + qint32 loPpmCorrection = (qint32) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (loPpmCorrection != m_settings.m_loPpmCorrection) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_loPpmCorrection = loPpmCorrection; + sendSettings(settings, {"loPpmCorrection"}); + } + break; + } + case RemoteTCPProtocol::setTunerIFGain: + { + int v = RemoteTCPProtocol::extractUInt32(&buf[1]); + int gain = (int)(qint16)(v & 0xffff); + int stage = (v >> 16) & 0xffff; + if ((stage < RemoteTCPInputSettings::m_maxGains) && (gain != m_settings.m_gain[stage])) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_gain[stage] = gain; + sendSettings(settings, {QString("gain[%1]").arg(stage)}); + } + break; + } + case RemoteTCPProtocol::setAGCMode: + { + bool agc = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (agc != m_settings.m_agc) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_agc = agc; + sendSettings(settings, {"agc"}); + } + break; + } + case RemoteTCPProtocol::setDirectSampling: + { + bool directSampling = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (directSampling != m_settings.m_directSampling) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_directSampling = directSampling; + sendSettings(settings, {"directSampling"}); + } + break; + } + case RemoteTCPProtocol::setBiasTee: + { + bool biasTee = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (biasTee != m_settings.m_biasTee) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_biasTee = biasTee; + sendSettings(settings, {"biasTee"}); + } + break; + } + case RemoteTCPProtocol::setTunerBandwidth: + { + int rfBW = RemoteTCPProtocol::extractInt32(&buf[1]); + if (rfBW != m_settings.m_rfBW) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_rfBW = rfBW; + sendSettings(settings, {"rfBW"}); + } + break; + } + case RemoteTCPProtocol::setDecimation: + { + int log2Decim = RemoteTCPProtocol::extractInt32(&buf[1]); + if (log2Decim != m_settings.m_log2Decim) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_log2Decim = log2Decim; + sendSettings(settings, {"log2Decim"}); + } + break; + } + case RemoteTCPProtocol::setDCOffsetRemoval: + { + bool dcBlock = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (dcBlock != m_settings.m_dcBlock) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_dcBlock = dcBlock; + sendSettings(settings, {"dcBlock"}); + } + break; + } + case RemoteTCPProtocol::setIQCorrection: + { + bool iqCorrection = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (iqCorrection != m_settings.m_iqCorrection) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_iqCorrection = iqCorrection; + sendSettings(settings, {"iqCorrection"}); + } + break; + } + case RemoteTCPProtocol::setChannelSampleRate: + { + qint32 channelSampleRate = RemoteTCPProtocol::extractInt32(&buf[1]); + if (channelSampleRate != m_settings.m_channelSampleRate) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_channelSampleRate = channelSampleRate; + sendSettings(settings, {"channelSampleRate"}); + } + break; + } + case RemoteTCPProtocol::setChannelFreqOffset: + { + qint32 inputFrequencyOffset = (qint32) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (inputFrequencyOffset != m_settings.m_inputFrequencyOffset) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_inputFrequencyOffset = inputFrequencyOffset; + sendSettings(settings, {"inputFrequencyOffset"}); + } + break; + } + case RemoteTCPProtocol::setChannelGain: + { + qint32 channelGain = RemoteTCPProtocol::extractInt32(&buf[1]); + if (channelGain != m_settings.m_channelGain) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_channelGain = channelGain; + sendSettings(settings, {"channelGain"}); + } + break; + } + case RemoteTCPProtocol::setSampleBitDepth: + { + qint32 sampleBits = RemoteTCPProtocol::extractInt32(&buf[1]); + if (sampleBits != m_settings.m_sampleBits) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_sampleBits = sampleBits; + sendSettings(settings, {"sampleBits"}); + } + break; + } + case RemoteTCPProtocol::setIQSquelchEnabled: + { + bool squelchEnabled = (bool) RemoteTCPProtocol::extractUInt32(&buf[1]); + if (squelchEnabled != m_settings.m_squelchEnabled) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_squelchEnabled = squelchEnabled; + sendSettings(settings, {"squelchEnabled"}); + } + break; + } + case RemoteTCPProtocol::setIQSquelch: + { + float squelch = RemoteTCPProtocol::extractFloat(&buf[1]); + if (squelch != m_settings.m_squelch) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_squelch = squelch; + sendSettings(settings, {"squelch"}); + } + break; + } + case RemoteTCPProtocol::setIQSquelchGate: + { + float squelchGate = RemoteTCPProtocol::extractFloat(&buf[1]); + if (squelchGate != m_settings.m_squelchGate) + { + RemoteTCPInputSettings settings = m_settings; + settings.m_squelchGate = squelchGate; + sendSettings(settings, {"squelchGate"}); + } + break; + } + default: + m_commandLength = RemoteTCPProtocol::extractUInt32(&buf[1]); + m_state = DATA; + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << sizeof(buf); + } + } + else + { + done = true; + } + } + if (m_state == DATA) + { + if (m_dataSocket->bytesAvailable() >= m_commandLength) + { + try + { + switch (m_command) + { + + + case RemoteTCPProtocol::dataIQ: + { + break; + } + + case RemoteTCPProtocol::dataIQFLAC: + { + qsizetype s = m_compressedData.size(); + m_compressedData.resize(s + m_commandLength); + qint64 bytesRead = m_dataSocket->read(&m_compressedData.data()[s], m_commandLength); + m_compressedFrames++; + //qDebug() << "*************************** RemoteTCPProtocol::dataIQFLAC m_compressedData.size()" << m_compressedData.size() << "m_compressedFrames" << m_compressedFrames << "m_uncompressedFrames" << m_uncompressedFrames; + if (bytesRead == m_commandLength) + { + // FLAC encoder writes out 4 (fLaC), 38 (STREAMINFO), 51 (?) byte headers, that are transmitted as one command block, + // then each command block will be a complete audio block (first two bytes will be 0xfff8) + // FLAC__stream_decoder_process_single will keep calling the read callback until it's decoded one metadata or audio block + // so we need to make sure there's enough data that it will be able to return + + bool decodeDone = false; + + while (!decodeDone) + { + //qDebug() << "m_compressedFrames" << m_compressedFrames << "m_uncompressedFrames" << m_uncompressedFrames; + if (m_compressedFrames - 1 > m_uncompressedFrames) + { + if (!FLAC__stream_decoder_process_single(m_decoder)) + { + qDebug() << "FLAC decode failed"; + decodeDone = true; + } + } + else + { + decodeDone = true; + } + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << m_commandLength; + } + break; + } + + case RemoteTCPProtocol::dataIQzlib: + { + if (m_commandLength > m_compressedData.size()) { + m_compressedData.resize(m_commandLength); + } + qint64 bytesRead = m_dataSocket->read(m_compressedData.data(), m_commandLength); + if (bytesRead == m_commandLength) + { + // Decompressing using zlib + m_zStream.next_in = (Bytef *) m_compressedData.data(); + m_zStream.avail_in = m_commandLength; + m_zStream.next_out = (Bytef *) m_zOutBuf.data(); + m_zStream.avail_out = m_zOutBuf.size(); + + int ret = inflate(&m_zStream, Z_NO_FLUSH); + + if (ret == Z_STREAM_END) { + inflateReset(&m_zStream); + // Convert and write to uncompressed data FIFO + int uncompressedBytes = m_zOutBuf.size() - m_zStream.avail_out; + int nbSamples = uncompressedBytes / 2 / (m_settings.m_sampleBits / 8); + processDecompressedZlibData(m_zOutBuf.data(), nbSamples); + } else if (ret == Z_NEED_DICT) { + qDebug() << "zlib needs dict to inflate"; + } else if (ret == Z_DATA_ERROR) { + qDebug() << "zlib data error"; + } else if (ret == Z_MEM_ERROR) { + qDebug() << "zlib mem error"; + } else { + qDebug() << "Unexpected zlib return value" << ret; + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << m_commandLength; + } + break; + } + + case RemoteTCPProtocol::dataPosition: + { + char pos[4+4+4]; + qint64 bytesRead = m_dataSocket->read(pos, m_commandLength); + if (bytesRead == m_commandLength) + { + float latitude = RemoteTCPProtocol::extractFloat((const quint8 *) &pos[0]); + float longitude = RemoteTCPProtocol::extractFloat((const quint8 *) &pos[4]); + float altitude = RemoteTCPProtocol::extractFloat((const quint8 *) &pos[8]); + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Position " << latitude << longitude << altitude; + if (m_messageQueueToInput) { + m_messageQueueToInput->push(RemoteTCPInput::MsgReportPosition::create(latitude, longitude, altitude)); + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << m_commandLength; + } + break; + } + + case RemoteTCPProtocol::dataDirection: + { + char dir[4+4+4]; + qint64 bytesRead = m_dataSocket->read(dir, m_commandLength); + if (bytesRead == m_commandLength) + { + float isotropic = RemoteTCPProtocol::extractUInt32((const quint8 *) &dir[0]); + float azimuth = RemoteTCPProtocol::extractFloat((const quint8 *) &dir[4]); + float elevation = RemoteTCPProtocol::extractFloat((const quint8 *) &dir[8]); + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Direction " << isotropic << azimuth << elevation; + if (m_messageQueueToInput) { + m_messageQueueToInput->push(RemoteTCPInput::MsgReportDirection::create(isotropic, azimuth, elevation)); + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << m_commandLength; + } + break; + } + + case RemoteTCPProtocol::sendMessage: + { + char *buf = new char[m_commandLength]; + qint64 bytesRead = m_dataSocket->read(buf, m_commandLength); + + if (bytesRead == m_commandLength) + { + bool broadcast = (bool) buf[0]; + int i; + for (i = 1; i < (int) m_commandLength; i++) + { + if (buf[i] == '\0') { + break; + } + } + QString callsign = QString::fromUtf8(&buf[1]); + QString text = QString::fromUtf8(&buf[i+1]); + + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Message " << m_dataSocket->peerAddress() << m_dataSocket->peerPort() << callsign << broadcast << text; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPInput::MsgSendMessage::create(callsign, text, broadcast)); + } + } + else + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to read:" << bytesRead << "/" << m_commandLength; + } + delete[] buf; + break; + } + + case RemoteTCPProtocol::sendBlacklistedMessage: + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Disconnecting as blacklisted"; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPInput::MsgSendMessage::create("", "Disconnecting as IP address is blacklisted", false)); + } + m_blacklisted = true; + qDebug() << "set m_blacklisted" << m_blacklisted; + break; + } + + default: + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Unknown command" << m_command; + char *buf = new char[m_commandLength]; + m_dataSocket->read(buf, m_commandLength); + delete[] buf; + break; + } + } + } + catch(std::bad_alloc&) + { + qDebug() << "RemoteTCPInputTCPHandler::processCommands: Failed to allocate memory"; + done = true; + } + m_state = HEADER; + } + else + { + done = true; + } + } + } +} + // QTimer::timeout isn't guaranteed to be called on every timeout, so we need to look at the system clock void RemoteTCPInputTCPHandler::processData() { QMutexLocker mutexLocker(&m_mutex); + if (m_dataSocket && (m_dataSocket->state() == QAbstractSocket::ConnectedState)) { int sampleRate = m_settings.m_channelSampleRate; - int bytesPerIQPair = 2 * m_settings.m_sampleBits / 8; + int bytesPerIQPair = m_iqOnly ? (2 * m_settings.m_sampleBits / 8) : (2 * sizeof(Sample)); int bytesPerSecond = sampleRate * bytesPerIQPair; - if (m_dataSocket->bytesAvailable() < (0.1f * m_settings.m_preFill * bytesPerSecond)) + qint64 bytesAvailable = m_iqOnly ? m_dataSocket->bytesAvailable() : m_uncompressedData.fill(); + + if ((bytesAvailable < (0.1f * m_settings.m_preFill * bytesPerSecond)) && !m_fillBuffer) { - qDebug() << "RemoteTCPInputTCPHandler::processData: Buffering - bytesAvailable:" << m_dataSocket->bytesAvailable(); + qDebug() << "RemoteTCPInputTCPHandler::processData: Buffering - bytesAvailable:" << bytesAvailable; m_fillBuffer = true; } @@ -1033,9 +1951,9 @@ void RemoteTCPInputTCPHandler::processData() // QTcpSockets buffer size should be unlimited - we pretend here it's twice as big as the point we start reading from it if (m_messageQueueToGUI) { - qint64 size = std::max(m_dataSocket->bytesAvailable(), (qint64)(m_settings.m_preFill * bytesPerSecond)); + qint64 size = std::max(bytesAvailable, (qint64)(m_settings.m_preFill * bytesPerSecond)); RemoteTCPInput::MsgReportTCPBuffer *report = RemoteTCPInput::MsgReportTCPBuffer::create( - m_dataSocket->bytesAvailable(), size, m_dataSocket->bytesAvailable() / (float)bytesPerSecond, + bytesAvailable, size, bytesAvailable / (float)bytesPerSecond, m_sampleFifo->fill(), m_sampleFifo->size(), m_sampleFifo->fill() / (float)bytesPerSecond ); m_messageQueueToGUI->push(report); @@ -1045,9 +1963,9 @@ void RemoteTCPInputTCPHandler::processData() // Prime buffer, before we start reading if (m_fillBuffer) { - if (m_dataSocket->bytesAvailable() >= m_settings.m_preFill * bytesPerSecond) + if (bytesAvailable >= m_settings.m_preFill * bytesPerSecond) { - qDebug() << "RemoteTCPInputTCPHandler::processData: Buffer primed - bytesAvailable:" << m_dataSocket->bytesAvailable(); + qDebug() << "RemoteTCPInputTCPHandler::processData: Buffer primed - bytesAvailable:" << bytesAvailable; m_fillBuffer = false; m_prevDateTime = QDateTime::currentDateTime(); factor = 1.0f / 4.0f; // If this is too high, samples can just be dropped downstream @@ -1056,22 +1974,31 @@ void RemoteTCPInputTCPHandler::processData() else { QDateTime currentDateTime = QDateTime::currentDateTime(); - factor = m_prevDateTime.msecsTo(currentDateTime) / 1000.0f; + factor = m_prevDateTime.msecsTo(currentDateTime) / 1000.0f; // FIXME: Close skew.. Actual sample rate may differ m_prevDateTime = currentDateTime; } unsigned int remaining = m_sampleFifo->size() - m_sampleFifo->fill(); - int requiredSamples = (int)std::min((unsigned int)(factor * sampleRate), remaining); + unsigned int maxRequired = (unsigned int) (factor * sampleRate); + int requiredSamples = (int)std::min(maxRequired, remaining); + int overflow = maxRequired - requiredSamples; + if (overflow > 0) { + qDebug() << "Not enough space in FIFO:" << overflow << maxRequired; + } if (!m_fillBuffer) { - if (!m_spyServer) + if (!m_iqOnly) + { + processDecompressedData(requiredSamples); + } + else if (!m_spyServer) { - // rtl_tcp/SDRA stream is just IQ samples if (m_dataSocket->bytesAvailable() >= requiredSamples*bytesPerIQPair) { + // rtl_tcp stream is just IQ samples m_dataSocket->read(&m_tcpBuf[0], requiredSamples*bytesPerIQPair); - convert(requiredSamples); + processUncompressedData(&m_tcpBuf[0], requiredSamples); } } else @@ -1084,9 +2011,57 @@ void RemoteTCPInputTCPHandler::processData() } } +// Copy from decompressed FIFO to replay buffer and sample FIFO +void RemoteTCPInputTCPHandler::processDecompressedData(int requiredSamples) +{ + qint64 requiredBytes = requiredSamples * sizeof(Sample); + + m_replayBuffer->lock(); + + while ((requiredBytes > 0) && !m_uncompressedData.empty()) + { + quint8 *uncompressedPtr; + qsizetype uncompressedBytes = m_uncompressedData.readPtr(&uncompressedPtr, requiredSamples * sizeof(Sample)); + qsizetype uncompressedSamples = 2 * uncompressedBytes / sizeof(Sample); + + // Save data to replay buffer + bool replayEnabled = m_replayBuffer->getSize() > 0; + if (replayEnabled) { + m_replayBuffer->write((FixReal *) uncompressedPtr, (unsigned int) uncompressedSamples); + } + + const FixReal *buf = (FixReal *) uncompressedPtr; + qint32 remaining = uncompressedSamples; + + while (remaining > 0) + { + qint32 len; + + // Choose between live data or replayed data + if (replayEnabled && m_replayBuffer->useReplay()) { + len = m_replayBuffer->read(remaining, buf); + } else { + len = remaining; + } + remaining -= len; + + calcPower(reinterpret_cast(buf), len / 2); + + m_sampleFifo->write((quint8 *) buf, len * sizeof(FixReal)); + } + + m_uncompressedData.read(uncompressedBytes); + requiredBytes -= uncompressedBytes; + } + + m_replayBuffer->unlock(); +} + +// Convert from uncompressed network format to Samples, then copy to replay buffer and sample FIFO // The following code assumes host is little endian -void RemoteTCPInputTCPHandler::convert(int nbSamples) +void RemoteTCPInputTCPHandler::processUncompressedData(const char *inBuf, int nbSamples) { + // Ensure conversion buffer is large enough if (nbSamples > (int) m_converterBufferNbSamples) { if (m_converterBuffer) { @@ -1095,102 +2070,121 @@ void RemoteTCPInputTCPHandler::convert(int nbSamples) m_converterBuffer = new int32_t[nbSamples*2]; } - if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 24) && !m_spyServer) + // Convert from network format to Sample + if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 24) && m_spyServer) { - m_sampleFifo->write(reinterpret_cast(m_tcpBuf), nbSamples*sizeof(Sample)); - } - else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 24) && m_spyServer) - { - float *in = (float *)m_tcpBuf; - qint32 *out = (qint32 *)m_converterBuffer; + const float *in = (const float *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (qint32)(in[is] * SDR_RX_SCALEF); } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 16) && m_spyServer) { - float *in = (float *)m_tcpBuf; - qint16 *out = (qint16 *)m_converterBuffer; + const float *in = (const float *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (qint16)(in[is] * SDR_RX_SCALEF); } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 8) && (SDR_RX_SAMP_SZ == 16)) { - quint8 *in = (quint8 *)m_tcpBuf; - qint16 *out = (qint16 *)m_converterBuffer; + const quint8 *in = (const quint8 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (((qint16)in[is]) - 128) << 8; } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 8) && (SDR_RX_SAMP_SZ == 24)) { - quint8 *in = (quint8 *)m_tcpBuf; - qint32 *out = (qint32 *)m_converterBuffer; + const quint8 *in = (const quint8 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (((qint32)in[is]) - 128) << 16; } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 24) && (SDR_RX_SAMP_SZ == 24)) { - quint8 *in = (quint8 *)m_tcpBuf; - qint32 *out = (qint32 *)m_converterBuffer; + const quint8 *in = (const quint8 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (((in[3*is+2] << 16) | (in[3*is+1] << 8) | in[3*is]) << 8) >> 8; } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 24) && (SDR_RX_SAMP_SZ == 16)) { - quint8 *in = (quint8 *)m_tcpBuf; - qint16 *out = (qint16 *)m_converterBuffer; + const quint8 *in = (const quint8 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = (in[3*is+2] << 8) | in[3*is+1]; } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 16) && (SDR_RX_SAMP_SZ == 24)) { - qint16 *in = (qint16 *)m_tcpBuf; - qint32 *out = (qint32 *)m_converterBuffer; + const qint16 *in = (const qint16 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = in[is] << 8; } - - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); } else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 16)) { - qint32 *in = (qint32 *)m_tcpBuf; - qint16 *out = (qint16 *)m_converterBuffer; + const qint32 *in = (const qint32 *) inBuf; + qint16 *out = (qint16 *) m_converterBuffer; for (int is = 0; is < nbSamples*2; is++) { out[is] = in[is] >> 8; } + } + else if ((m_settings.m_sampleBits == 32) && (SDR_RX_SAMP_SZ == 24)) + { + const qint32 *in = (const qint32 *) inBuf; + qint32 *out = (qint32 *) m_converterBuffer; - m_sampleFifo->write(reinterpret_cast(out), nbSamples*sizeof(Sample)); + for (int is = 0; is < nbSamples*2; is++) { + out[is] = in[is]; + } } else // invalid size { qWarning("RemoteTCPInputTCPHandler::convert: unexpected sample size in stream: %d bits", (int) m_settings.m_sampleBits); } + + qint32 len = nbSamples*2; + + // Save data to replay buffer + m_replayBuffer->lock(); + bool replayEnabled = m_replayBuffer->getSize() > 0; + if (replayEnabled) { + m_replayBuffer->write((const FixReal *) m_converterBuffer, len); + } + + const FixReal *buf = (const FixReal *) m_converterBuffer; + qint32 remaining = len; + + while (remaining > 0) + { + // Choose between live data or replayed data + if (replayEnabled && m_replayBuffer->useReplay()) { + len = m_replayBuffer->read(remaining, buf); + } else { + len = remaining; + } + remaining -= len; + + calcPower(reinterpret_cast(buf), len / 2); + + m_sampleFifo->write(reinterpret_cast(buf), len * sizeof(FixReal)); + } + + m_replayBuffer->unlock(); } void RemoteTCPInputTCPHandler::handleInputMessages() @@ -1214,6 +2208,14 @@ bool RemoteTCPInputTCPHandler::handleMessage(const Message& cmd) applySettings(notif.getSettings(), notif.getSettingsKeys(), notif.getForce()); return true; } + else if (RemoteTCPInput::MsgSendMessage::match(cmd)) + { + RemoteTCPInput::MsgSendMessage& msg = (RemoteTCPInput::MsgSendMessage&) cmd; + + sendMessage(MainCore::instance()->getSettings().getStationName(), msg.getText(), msg.getBroadcast()); + + return true; + } else { return false; diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h index a829c93702..f1b990ca21 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h @@ -26,7 +26,12 @@ #include #include +#include +#include + #include "util/messagequeue.h" +#include "util/movingaverage.h" +#include "dsp/replaybuffer.h" #include "remotetcpinputsettings.h" #include "../../channelrx/remotetcpsink/remotetcpprotocol.h" #include "spyserver.h" @@ -35,6 +40,31 @@ class SampleSinkFifo; class MessageQueue; class DeviceAPI; +class FIFO { +public: + + FIFO(qsizetype elements = 10); + + qsizetype write(quint8 *data, qsizetype elements); + qsizetype read(quint8 *data, qsizetype elements); + qsizetype readPtr(quint8 **data, qsizetype elements); + void read(qsizetype elements); + void resize(qsizetype elements); // Sets capacity + void clear(); + qsizetype fill() const { return m_fill; } // Number of elements in use + bool empty() const { return m_fill == 0; } + bool full() const { return m_fill == m_data.size(); } + +private: + + qsizetype m_readPtr; + qsizetype m_writePtr; + qsizetype m_fill; + + QByteArray m_data; + +}; + class RemoteTCPInputTCPHandler : public QObject { Q_OBJECT @@ -71,22 +101,28 @@ class RemoteTCPInputTCPHandler : public QObject public: RemoteTCPProtocol::Device getDevice() const { return m_device; } QString getProtocol() const { return m_protocol; } + bool getIQOnly() const { return m_iqOnly; } + bool getRemoteControl() const { return m_remoteControl; } int getMaxGain() const { return m_maxGain; } - static MsgReportRemoteDevice* create(RemoteTCPProtocol::Device device, const QString& protocol, int maxGain = 0) + static MsgReportRemoteDevice* create(RemoteTCPProtocol::Device device, const QString& protocol, bool iqOnly, bool remoteControl, int maxGain = 0) { - return new MsgReportRemoteDevice(device, protocol, maxGain); + return new MsgReportRemoteDevice(device, protocol, iqOnly, remoteControl, maxGain); } protected: RemoteTCPProtocol::Device m_device; QString m_protocol; + bool m_iqOnly; + bool m_remoteControl; int m_maxGain; - MsgReportRemoteDevice(RemoteTCPProtocol::Device device, const QString& protocol, int maxGain) : + MsgReportRemoteDevice(RemoteTCPProtocol::Device device, const QString& protocol, bool iqOnly, bool remoteControl, int maxGain) : Message(), m_device(device), m_protocol(protocol), + m_iqOnly(iqOnly), + m_remoteControl(remoteControl), m_maxGain(maxGain) { } }; @@ -111,7 +147,7 @@ class RemoteTCPInputTCPHandler : public QObject { } }; - RemoteTCPInputTCPHandler(SampleSinkFifo* sampleFifo, DeviceAPI *deviceAPI); + RemoteTCPInputTCPHandler(SampleSinkFifo* sampleFifo, DeviceAPI *deviceAPI, ReplayBuffer *replayBuffer); ~RemoteTCPInputTCPHandler(); MessageQueue *getInputMessageQueue() { return &m_inputMessageQueue; } void setMessageQueueToInput(MessageQueue *queue) { m_messageQueueToInput = queue; } @@ -120,6 +156,29 @@ class RemoteTCPInputTCPHandler : public QObject void start(); void stop(); int getBufferGauge() const { return 0; } + void processCommands(); + + FLAC__StreamDecoderReadStatus flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes); + FLAC__StreamDecoderWriteStatus flacWrite(const FLAC__StreamDecoder *decoder, const FLAC__Frame *frame, const FLAC__int32 *const buffer[]); + void flacError(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatus status); + + void getMagSqLevels(double& avg, double& peak, int& nbSamples) + { + if (m_magsqCount > 0) + { + m_magsq = m_magsqSum / m_magsqCount; + m_magSqLevelStore.m_magsq = m_magsq; + m_magSqLevelStore.m_magsqPeak = m_magsqPeak; + } + + avg = m_magSqLevelStore.m_magsq; + peak = m_magSqLevelStore.m_magsqPeak; + nbSamples = m_magsqCount == 0 ? 1 : m_magsqCount; + + m_magsqSum = 0.0f; + m_magsqPeak = 0.0f; + m_magsqCount = 0; + } public slots: void dataReadyRead(); @@ -129,11 +188,22 @@ public slots: private: + struct MagSqLevelsStore + { + MagSqLevelsStore() : + m_magsq(1e-12), + m_magsqPeak(1e-12) + {} + double m_magsq; + double m_magsqPeak; + }; + DeviceAPI *m_deviceAPI; bool m_running; QTcpSocket *m_dataSocket; char *m_tcpBuf; SampleSinkFifo *m_sampleFifo; + ReplayBuffer *m_replayBuffer; MessageQueue m_inputMessageQueue; //!< Queue for asynchronous inbound communication MessageQueue *m_messageQueueToInput; MessageQueue *m_messageQueueToGUI; @@ -148,6 +218,8 @@ public slots: SpyServerProtocol::Header m_spyServerHeader; enum {HEADER, DATA} m_state; //!< FSM for reading Spy Server packets + RemoteTCPProtocol::Command m_command; + quint32 m_commandLength; int32_t *m_converterBuffer; uint32_t m_converterBufferNbSamples; @@ -155,13 +227,38 @@ public slots: QRecursiveMutex m_mutex; RemoteTCPInputSettings m_settings; - void applyTCPLink(const QString& address, quint16 port); + bool m_remoteControl; + bool m_iqOnly; + QByteArray m_compressedData; + + // FLAC decompression + qint64 m_compressedFrames; + qint64 m_uncompressedFrames; + FIFO m_uncompressedData; + FLAC__StreamDecoder *m_decoder; + int m_remainingSamples; + + // Zlib decompression + z_stream m_zStream; + QByteArray m_zOutBuf; + static const int m_zBufSize = 32768+128; // + + bool m_blacklisted; + + double m_magsq; + double m_magsqSum; + double m_magsqPeak; + int m_magsqCount; + MagSqLevelsStore m_magSqLevelStore; + MovingAverageUtil m_movingAverage; + bool handleMessage(const Message& message); - void convert(int nbSamples); void connectToHost(const QString& address, quint16 port); - void disconnectFromHost(); + //void disconnectFromHost(); void cleanup(); void clearBuffer(); + void sendCommand(RemoteTCPProtocol::Command cmd, quint32 value); + void sendCommandFloat(RemoteTCPProtocol::Command cmd, float value); void setSampleRate(int sampleRate); void setCenterFrequency(quint64 frequency); void setTunerAGC(bool agc); @@ -180,6 +277,10 @@ public slots: void setChannelFreqOffset(int offset); void setChannelGain(int gain); void setSampleBitDepth(int sampleBits); + void setSquelchEnabled(bool enabled); + void setSquelch(float squelch); + void setSquelchGate(float squelchGate); + void sendMessage(const QString& callsign, const QString& text, bool broadcast); void applySettings(const RemoteTCPInputSettings& settings, const QList& settingsKeys, bool force = false); void processMetaData(); void spyServerConnect(); @@ -190,6 +291,11 @@ public slots: void processSpyServerDevice(const SpyServerProtocol::Device* ssDevice); void processSpyServerState(const SpyServerProtocol::State* ssState, bool initial); void processSpyServerData(int requiredBytes, bool clear); + void processDecompressedData(int requiredSamples); + void processUncompressedData(const char *inBuf, int nbSamples); + void processDecompressedZlibData(const char *inBuf, int nbSamples); + void calcPower(const Sample *iq, int nbSamples); + void sendSettings(const RemoteTCPInputSettings& settings, const QStringList& settingsKeys); private slots: void started(); diff --git a/sdrbase/CMakeLists.txt b/sdrbase/CMakeLists.txt index 9255a4974f..bb69899956 100644 --- a/sdrbase/CMakeLists.txt +++ b/sdrbase/CMakeLists.txt @@ -266,6 +266,7 @@ set(sdrbase_SOURCES util/rtpsink.cpp util/syncmessenger.cpp util/samplesourceserializer.cpp + util/sdrangelserverlist.cpp util/simpleserializer.cpp util/serialutil.cpp util/solardynamicsobservatory.cpp @@ -526,6 +527,7 @@ set(sdrbase_HEADERS util/rtty.h util/syncmessenger.h util/samplesourceserializer.h + util/sdrangelserverlist.h util/simpleserializer.h util/serialutil.h util/solardynamicsobservatory.h diff --git a/sdrbase/util/sdrangelserverlist.cpp b/sdrbase/util/sdrangelserverlist.cpp new file mode 100644 index 0000000000..7a3bf89335 --- /dev/null +++ b/sdrbase/util/sdrangelserverlist.cpp @@ -0,0 +1,183 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 "sdrangelserverlist.h" + +#include +#include +#include +#include +#include +#include + +SDRangelServerList::SDRangelServerList() +{ + m_networkManager = new QNetworkAccessManager(); + QObject::connect(m_networkManager, &QNetworkAccessManager::finished, this, &SDRangelServerList::handleReply); + + QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + QDir writeableDir(locations[0]); + if (!writeableDir.mkpath(QStringLiteral("cache") + QDir::separator() + QStringLiteral("sdrangelserver"))) { + qDebug() << "Failed to create cache/sdrangelserver"; + } + + m_cache = new QNetworkDiskCache(); + m_cache->setCacheDirectory(locations[0] + QDir::separator() + QStringLiteral("cache") + QDir::separator() + QStringLiteral("sdrangelserver")); + m_cache->setMaximumCacheSize(100000000); + m_networkManager->setCache(m_cache); + + connect(&m_timer, &QTimer::timeout, this, &SDRangelServerList::update); +} + +SDRangelServerList::~SDRangelServerList() +{ + QObject::disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &SDRangelServerList::handleReply); + delete m_networkManager; +} + +void SDRangelServerList::getData() +{ + QUrl url = QUrl("https://sdrangel.org/websdr/websdrs.json"); + m_networkManager->get(QNetworkRequest(url)); +} + +void SDRangelServerList::getDataPeriodically(int periodInMins) +{ + m_timer.setInterval(periodInMins*60*1000); + m_timer.start(); + update(); +} + +void SDRangelServerList::update() +{ + getData(); +} + +void SDRangelServerList::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QString url = reply->url().toEncoded().constData(); + QByteArray bytes = reply->readAll(); + + handleJSON(url, bytes); + } + else + { + qDebug() << "SDRangelServerList::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "SDRangelServerList::handleReply: reply is null"; + } +} + +void SDRangelServerList::handleJSON(const QString& url, const QByteArray& bytes) +{ + (void) url; + + QList sdrs; + QJsonDocument document = QJsonDocument::fromJson(bytes); + + if (document.isArray()) + { + QJsonArray servers = document.array(); + + for (auto valRef : servers) + { + if (valRef.isObject()) + { + QJsonObject serverObj = valRef.toObject(); + SDRangelServer sdr; + + if (serverObj.contains(QStringLiteral("address"))) { + sdr.m_address = serverObj.value(QStringLiteral("address")).toString(); + } + if (serverObj.contains(QStringLiteral("port"))) { + sdr.m_port = serverObj.value(QStringLiteral("port")).toInt(); + } + if (serverObj.contains(QStringLiteral("minFrequency"))) { + sdr.m_minFrequency = serverObj.value(QStringLiteral("minFrequency")).toInt(); + } + if (serverObj.contains(QStringLiteral("maxFrequency"))) { + sdr.m_maxFrequency = serverObj.value(QStringLiteral("maxFrequency")).toInt(); + } + if (serverObj.contains(QStringLiteral("maxSampleRate"))) { + sdr.m_maxSampleRate = serverObj.value(QStringLiteral("maxSampleRate")).toInt(); + } + if (serverObj.contains(QStringLiteral("device"))) { + sdr.m_device = serverObj.value(QStringLiteral("device")).toString(); + } + if (serverObj.contains(QStringLiteral("antenna"))) { + sdr.m_antenna = serverObj.value(QStringLiteral("antenna")).toString(); + } + if (serverObj.contains(QStringLiteral("remoteControl"))) { + sdr.m_remoteControl = serverObj.value(QStringLiteral("remoteControl")).toInt() == 1; + } + if (serverObj.contains(QStringLiteral("stationName"))) { + sdr.m_stationName = serverObj.value(QStringLiteral("stationName")).toString(); + } + if (serverObj.contains(QStringLiteral("location"))) { + sdr.m_location = serverObj.value(QStringLiteral("location")).toString(); + } + if (serverObj.contains(QStringLiteral("latitude"))) { + sdr.m_latitude = serverObj.value(QStringLiteral("latitude")).toDouble(); + } + if (serverObj.contains(QStringLiteral("longitude"))) { + sdr.m_longitude = serverObj.value(QStringLiteral("longitude")).toDouble(); + } + if (serverObj.contains(QStringLiteral("altitude"))) { + sdr.m_altitude = serverObj.value(QStringLiteral("altitude")).toDouble(); + } + if (serverObj.contains(QStringLiteral("isotropic"))) { + sdr.m_isotropic = serverObj.value(QStringLiteral("isotropic")).toInt() == 1; + } + if (serverObj.contains(QStringLiteral("azimuth"))) { + sdr.m_azimuth = serverObj.value(QStringLiteral("azimuth")).toDouble(); + } + if (serverObj.contains(QStringLiteral("elevation"))) { + sdr.m_elevation = serverObj.value(QStringLiteral("elevation")).toDouble(); + } + if (serverObj.contains(QStringLiteral("clients"))) { + sdr.m_clients = serverObj.value(QStringLiteral("clients")).toInt(); + } + if (serverObj.contains(QStringLiteral("maxClients"))) { + sdr.m_maxClients = serverObj.value(QStringLiteral("maxClients")).toInt(); + } + if (serverObj.contains(QStringLiteral("timeLimit"))) { + sdr.m_timeLimit = serverObj.value(QStringLiteral("timeLimit")).toInt(); + } + + sdrs.append(sdr); + } + else + { + qDebug() << "SDRangelServerList::handleJSON: Element not an object:\n" << valRef; + } + } + } + else + { + qDebug() << "SDRangelServerList::handleJSON: Doc doesn't contain an array:\n" << document; + } + + emit dataUpdated(sdrs); +} diff --git a/sdrbase/util/sdrangelserverlist.h b/sdrbase/util/sdrangelserverlist.h new file mode 100644 index 0000000000..e563e13385 --- /dev/null +++ b/sdrbase/util/sdrangelserverlist.h @@ -0,0 +1,81 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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_SDRANGELSERVERLIST_H +#define INCLUDE_SDRANGELSERVERLIST_H + +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QNetworkDiskCache; + +// Gets a list of public SDRangel Servers from https://sdrangel.org/websdr/ +class SDRBASE_API SDRangelServerList : public QObject +{ + Q_OBJECT + +public: + + struct SDRangelServer { + QString m_address; + quint16 m_port; + qint64 m_minFrequency; + qint64 m_maxFrequency; + int m_maxSampleRate; + QString m_device; + QString m_antenna; + bool m_remoteControl; + QString m_stationName; + QString m_location; + float m_latitude; + float m_longitude; + float m_altitude; + bool m_isotropic; + float m_azimuth; + float m_elevation; + int m_clients; + int m_maxClients; + int m_timeLimit; // In minutes + }; + + SDRangelServerList(); + ~SDRangelServerList(); + + void getData(); + void getDataPeriodically(int periodInMins=1); + +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_SDRANGELSERVERLIST_H */ diff --git a/swagger/sdrangel/api/swagger/include/RemoteTCPInput.yaml b/swagger/sdrangel/api/swagger/include/RemoteTCPInput.yaml index 09cc073e60..c27b4e712e 100644 --- a/swagger/sdrangel/api/swagger/include/RemoteTCPInput.yaml +++ b/swagger/sdrangel/api/swagger/include/RemoteTCPInput.yaml @@ -61,3 +61,12 @@ RemoteTCPInputReport: properties: sampleRate: type: integer + latitude: + type: number + format: float + longitude: + type: number + format: float + altitude: + type: number + format: float diff --git a/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.cpp b/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.cpp index 74c4c5abf3..eb50dd12a4 100644 --- a/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.cpp +++ b/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.cpp @@ -1,6 +1,6 @@ /** * SDRangel - * This is the web REST/JSON API of SDRangel SDR software. SDRangel is an Open Source Qt5/OpenGL 3.0+ (4.3+ in Windows) GUI and server Software Defined Radio and signal analyzer in software. It supports Airspy, BladeRF, HackRF, LimeSDR, PlutoSDR, RTL-SDR, SDRplay RSP1 and FunCube --- Limitations and specifcities: * In SDRangel GUI the first Rx device set cannot be deleted. Conversely the server starts with no device sets and its number of device sets can be reduced to zero by as many calls as necessary to /sdrangel/deviceset with DELETE method. * Preset import and export from/to file is a server only feature. * Device set focus is a GUI only feature. * The following channels are not implemented (status 501 is returned): ATV and DATV demodulators, Channel Analyzer NG, LoRa demodulator * The device settings and report structures contains only the sub-structure corresponding to the device type. The DeviceSettings and DeviceReport structures documented here shows all of them but only one will be or should be present at a time * The channel settings and report structures contains only the sub-structure corresponding to the channel type. The ChannelSettings and ChannelReport structures documented here shows all of them but only one will be or should be present at a time --- + * This is the web REST/JSON API of SDRangel SDR software. SDRangel is an Open Source Qt5/OpenGL 3.0+ (4.3+ in Windows) GUI and server Software Defined Radio and signal analyzer in software. It supports Airspy, BladeRF, HackRF, LimeSDR, PlutoSDR, RTL-SDR, SDRplay RSP1 and FunCube --- Limitations and specifcities: * In SDRangel GUI the first Rx device set cannot be deleted. Conversely the server starts with no device sets and its number of device sets can be reduced to zero by as many calls as necessary to /sdrangel/deviceset with DELETE method. * Preset import and export from/to file is a server only feature. * Device set focus is a GUI only feature. * The following channels are not implemented (status 501 is returned): ATV and DATV demodulators, Channel Analyzer NG, LoRa demodulator * The device settings and report structures contains only the sub-structure corresponding to the device type. The DeviceSettings and DeviceReport structures documented here shows all of them but only one will be or should be present at a time * The channel settings and report structures contains only the sub-structure corresponding to the channel type. The ChannelSettings and ChannelReport structures documented here shows all of them but only one will be or should be present at a time --- * * OpenAPI spec version: 7.0.0 * Contact: f4exb06@gmail.com @@ -59,7 +59,7 @@ SWGRemoteTCPInputReport::fromJson(QString &json) { void SWGRemoteTCPInputReport::fromJsonObject(QJsonObject &pJson) { ::SWGSDRangel::setValue(&sample_rate, pJson["sampleRate"], "qint32", ""); - + } QString @@ -79,6 +79,9 @@ SWGRemoteTCPInputReport::asJsonObject() { if(m_sample_rate_isSet){ obj->insert("sampleRate", QJsonValue(sample_rate)); } + obj->insert("latitude", QJsonValue(latitude)); + obj->insert("longitude", QJsonValue(longitude)); + obj->insert("altitude", QJsonValue(altitude)); return obj; } diff --git a/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.h b/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.h index 1dd6df6a8c..92ea400e01 100644 --- a/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.h +++ b/swagger/sdrangel/code/qt5/client/SWGRemoteTCPInputReport.h @@ -1,6 +1,6 @@ /** * SDRangel - * This is the web REST/JSON API of SDRangel SDR software. SDRangel is an Open Source Qt5/OpenGL 3.0+ (4.3+ in Windows) GUI and server Software Defined Radio and signal analyzer in software. It supports Airspy, BladeRF, HackRF, LimeSDR, PlutoSDR, RTL-SDR, SDRplay RSP1 and FunCube --- Limitations and specifcities: * In SDRangel GUI the first Rx device set cannot be deleted. Conversely the server starts with no device sets and its number of device sets can be reduced to zero by as many calls as necessary to /sdrangel/deviceset with DELETE method. * Preset import and export from/to file is a server only feature. * Device set focus is a GUI only feature. * The following channels are not implemented (status 501 is returned): ATV and DATV demodulators, Channel Analyzer NG, LoRa demodulator * The device settings and report structures contains only the sub-structure corresponding to the device type. The DeviceSettings and DeviceReport structures documented here shows all of them but only one will be or should be present at a time * The channel settings and report structures contains only the sub-structure corresponding to the channel type. The ChannelSettings and ChannelReport structures documented here shows all of them but only one will be or should be present at a time --- + * This is the web REST/JSON API of SDRangel SDR software. SDRangel is an Open Source Qt5/OpenGL 3.0+ (4.3+ in Windows) GUI and server Software Defined Radio and signal analyzer in software. It supports Airspy, BladeRF, HackRF, LimeSDR, PlutoSDR, RTL-SDR, SDRplay RSP1 and FunCube --- Limitations and specifcities: * In SDRangel GUI the first Rx device set cannot be deleted. Conversely the server starts with no device sets and its number of device sets can be reduced to zero by as many calls as necessary to /sdrangel/deviceset with DELETE method. * Preset import and export from/to file is a server only feature. * Device set focus is a GUI only feature. * The following channels are not implemented (status 501 is returned): ATV and DATV demodulators, Channel Analyzer NG, LoRa demodulator * The device settings and report structures contains only the sub-structure corresponding to the device type. The DeviceSettings and DeviceReport structures documented here shows all of them but only one will be or should be present at a time * The channel settings and report structures contains only the sub-structure corresponding to the channel type. The ChannelSettings and ChannelReport structures documented here shows all of them but only one will be or should be present at a time --- * * OpenAPI spec version: 7.0.0 * Contact: f4exb06@gmail.com @@ -43,13 +43,21 @@ class SWG_API SWGRemoteTCPInputReport: public SWGObject { qint32 getSampleRate(); void setSampleRate(qint32 sample_rate); - + float getLatitude() { return latitude; } + float getLongitude() { return longitude; } + float getAltitude() { return altitude; } + void setLatitude(float latitude) { this->latitude = latitude; } + void setLongitude(float longitude) { this->longitude = longitude; } + void setAltitude(float altitude) { this->altitude = altitude; } virtual bool isSet() override; private: qint32 sample_rate; bool m_sample_rate_isSet; + float latitude; + float longitude; + float altitude; }; From 5ff0b74b43f9f47e8bef5d83a93b7f2bf49f88bc Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 12:20:43 +0100 Subject: [PATCH 02/20] Add additional type rsion. --- sdrbase/dsp/replaybuffer.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdrbase/dsp/replaybuffer.h b/sdrbase/dsp/replaybuffer.h index bacc966632..f7dd2e4ed6 100644 --- a/sdrbase/dsp/replaybuffer.h +++ b/sdrbase/dsp/replaybuffer.h @@ -209,6 +209,11 @@ class ReplayBuffer { return data; } + qint16 conv(FixReal data) const + { + return data; // FIXME: + } + qint16 conv(float data) const { return (qint16)(data * SDR_RX_SCALEF); From 83f27ac2e267487a7376f355209a47b3619f454e Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 12:23:06 +0100 Subject: [PATCH 03/20] Add period dial. --- sdrgui/CMakeLists.txt | 2 + sdrgui/gui/perioddial.cpp | 123 ++++++++++++++++++++++++++++++++++++++ sdrgui/gui/perioddial.h | 60 +++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 sdrgui/gui/perioddial.cpp create mode 100644 sdrgui/gui/perioddial.h diff --git a/sdrgui/CMakeLists.txt b/sdrgui/CMakeLists.txt index c911490143..5b62544bb3 100644 --- a/sdrgui/CMakeLists.txt +++ b/sdrgui/CMakeLists.txt @@ -74,6 +74,7 @@ set(sdrgui_SOURCES gui/mdiutils.cpp gui/mypositiondialog.cpp gui/nanosecondsdelegate.cpp + gui/perioddial.cpp gui/pluginsdialog.cpp gui/pluginpresetsdialog.cpp gui/presetitem.cpp @@ -201,6 +202,7 @@ set(sdrgui_HEADERS gui/mdiutils.h gui/mypositiondialog.h gui/nanosecondsdelegate.h + gui/perioddial.h gui/physicalunit.h gui/pluginsdialog.h gui/pluginpresetsdialog.h diff --git a/sdrgui/gui/perioddial.cpp b/sdrgui/gui/perioddial.cpp new file mode 100644 index 0000000000..2073007966 --- /dev/null +++ b/sdrgui/gui/perioddial.cpp @@ -0,0 +1,123 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 "perioddial.h" +#include "wrappingdial.h" +#include "wrappingspinbox.h" + +#include +#include + +PeriodDial::PeriodDial(QWidget *parent) : + QWidget(parent) +{ + m_layout = new QHBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + + m_dial = new WrappingDial(); + m_dial->setMinimum(1); + m_dial->setMaximum(999); + m_dial->setMaximumSize(24, 24); + + m_spinBox = new WrappingSpinBox(); + m_spinBox->setMinimum(1); + m_spinBox->setMaximum(999); + m_spinBox->setButtonSymbols(QAbstractSpinBox::NoButtons); + + m_units = new QComboBox(); + m_units->addItem(QString("%1s").arg(QChar(0x3bc))); + m_units->addItem("ms"); + m_units->addItem("s"); + + m_layout->addWidget(m_dial); + m_layout->addWidget(m_spinBox); + m_layout->addWidget(m_units); + + connect(m_dial, &WrappingDial::valueChanged, this, &PeriodDial::on_dial_valueChanged); + connect(m_dial, &WrappingDial::wrapUp, this, &PeriodDial::on_wrapUp); + connect(m_dial, &WrappingDial::wrapDown, this, &PeriodDial::on_wrapDown); + connect(m_spinBox, QOverload::of(&WrappingSpinBox::valueChanged), this, &PeriodDial::on_spinBox_valueChanged); + connect(m_spinBox, &WrappingSpinBox::wrapUp, this, &PeriodDial::on_wrapUp); + connect(m_spinBox, &WrappingSpinBox::wrapDown, this, &PeriodDial::on_wrapDown); + connect(m_units, QOverload::of(&QComboBox::currentIndexChanged), this, &PeriodDial::on_units_currentIndexChanged); +} + +void PeriodDial::on_dial_valueChanged(int dialValue) +{ + m_spinBox->setValue(dialValue); + emit valueChanged(value()); +} + +void PeriodDial::on_spinBox_valueChanged(int boxValue) +{ + m_dial->setValue(boxValue); +} + +void PeriodDial::on_units_currentIndexChanged(int index) +{ + emit valueChanged(value()); +} + +void PeriodDial::on_wrapUp() +{ + int index = m_units->currentIndex(); + if (index < m_units->count() - 1) { + m_units->setCurrentIndex(index + 1); + } +} + +void PeriodDial::on_wrapDown() +{ + int index = m_units->currentIndex(); + if (index > 0) { + m_units->setCurrentIndex(index - 1); + } +} + +void PeriodDial::setValue(double newValue) +{ + double oldValue = value(); + int index; + if (newValue < 1e-3) { + index = 0; + } else if (newValue < 1.0) { + index = 1; + } else { + index = 2; + } + double scale = std::pow(10.0, 3 * (2 - index)); + int mantissa = std::round(newValue * scale); + + bool blocked = blockSignals(true); + m_dial->setValue(mantissa); + m_units->setCurrentIndex(index); + blockSignals(blocked); + + if (newValue != oldValue) { + emit valueChanged(value()); + } +} + +double PeriodDial::value() +{ + int index = -3 * (2 - m_units->currentIndex()); // 0=s -3=ms -6=us + double scale = std::pow(10.0, index); + int mantissa = m_dial->value(); + return mantissa * scale; +} diff --git a/sdrgui/gui/perioddial.h b/sdrgui/gui/perioddial.h new file mode 100644 index 0000000000..18cf9e6ae2 --- /dev/null +++ b/sdrgui/gui/perioddial.h @@ -0,0 +1,60 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 SDRGUI_GUI_PERIODDIAL_H +#define SDRGUI_GUI_PERIODDIAL_H + +#include + +#include "export.h" + +class QHBoxLayout; +class WrappingDial; +class WrappingSpinBox; +class QComboBox; + +// Combines QDial, QSpinBox and QComboBox to allow user to enter time period in s, ms or us +class SDRGUI_API PeriodDial : public QWidget { + Q_OBJECT + +public: + explicit PeriodDial(QWidget *parent = nullptr); + + void setValue(double value); + double value(); + +private: + + QHBoxLayout *m_layout; + WrappingDial *m_dial; + WrappingSpinBox *m_spinBox; + QComboBox *m_units; + +private slots: + + void on_dial_valueChanged(int value); + void on_spinBox_valueChanged(int value); + void on_units_currentIndexChanged(int index); + void on_wrapUp(); + void on_wrapDown(); + +signals: + void valueChanged(double value); + +}; + +#endif // SDRGUI_GUI_PERIODDIAL_H From 3743e7d8313aef85fd87efcb039ea5088d41d626 Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 12:24:19 +0100 Subject: [PATCH 04/20] Fix gcc errors --- .../remotetcpinput/remotetcpinputtcphandler.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp index 1481f2c45a..b2eb03293a 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp @@ -18,6 +18,7 @@ /////////////////////////////////////////////////////////////////////////////////// #include +#include #include "device/deviceapi.h" #include "util/message.h" @@ -615,7 +616,7 @@ void RemoteTCPInputTCPHandler::applySettings(const RemoteTCPInputSettings& setti // Don't use force, as disconnect can cause rtl_tcp to quit if (settingsKeys.contains("dataAddress") || settingsKeys.contains("dataPort") - || (m_dataSocket == nullptr) && !m_blacklisted) + || ((m_dataSocket == nullptr) && !m_blacklisted)) { //disconnectFromHost(); cleanup(); @@ -681,6 +682,8 @@ static void flacErrorCallback(const FLAC__StreamDecoder *decoder, FLAC__StreamDe FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes) { + (void) decoder; + qsizetype bytesRequested = *bytes; qsizetype bytesRead = std::min(bytesRequested, (qsizetype) m_compressedData.size()); @@ -755,7 +758,7 @@ qsizetype FIFO::read(quint8 *data, qsizetype elements) else { std::memcpy(&data[0], &m_data.data()[m_readPtr], remaining); - std::memcpy(&data[remaining], &m_data[0], len2); + std::memcpy(&data[remaining], &m_data.data()[0], len2); m_readPtr = len2; } @@ -813,6 +816,8 @@ void RemoteTCPInputTCPHandler::calcPower(const Sample *iq, int nbSamples) FLAC__StreamDecoderWriteStatus RemoteTCPInputTCPHandler::flacWrite(const FLAC__StreamDecoder *decoder, const FLAC__Frame *frame, const FLAC__int32 *const buffer[]) { + (void) decoder; + m_uncompressedFrames++; int nbSamples = frame->header.blocksize; @@ -982,6 +987,8 @@ void RemoteTCPInputTCPHandler::processDecompressedZlibData(const char *inBuf, in void RemoteTCPInputTCPHandler::flacError(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatus status) { + (void) decoder; + qDebug() << "RemoteTCPInputTCPHandler::flacError: Error:" << status; } @@ -1782,7 +1789,7 @@ void RemoteTCPInputTCPHandler::processCommands() case RemoteTCPProtocol::dataIQzlib: { - if (m_commandLength > m_compressedData.size()) { + if (m_commandLength > (quint32) m_compressedData.size()) { m_compressedData.resize(m_commandLength); } qint64 bytesRead = m_dataSocket->read(m_compressedData.data(), m_commandLength); From 29bf92135d24b3787cfd98a5282a80f2409456d5 Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 14:53:57 +0100 Subject: [PATCH 05/20] Add wrapping dial and spin box widgets. --- sdrgui/CMakeLists.txt | 4 +++ sdrgui/gui/wrappingdial.cpp | 63 ++++++++++++++++++++++++++++++++++ sdrgui/gui/wrappingdial.h | 48 ++++++++++++++++++++++++++ sdrgui/gui/wrappingspinbox.cpp | 53 ++++++++++++++++++++++++++++ sdrgui/gui/wrappingspinbox.h | 47 +++++++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 sdrgui/gui/wrappingdial.cpp create mode 100644 sdrgui/gui/wrappingdial.h create mode 100644 sdrgui/gui/wrappingspinbox.cpp create mode 100644 sdrgui/gui/wrappingspinbox.h diff --git a/sdrgui/CMakeLists.txt b/sdrgui/CMakeLists.txt index 5b62544bb3..d524583d58 100644 --- a/sdrgui/CMakeLists.txt +++ b/sdrgui/CMakeLists.txt @@ -104,6 +104,8 @@ set(sdrgui_SOURCES gui/workspaceselectiondialog.cpp gui/wsspectrumsettingsdialog.cpp gui/wrappingdatetimeedit.cpp + gui/wrappingdial.cpp + gui/wrappingspinbox.cpp dsp/scopevisxy.cpp @@ -235,6 +237,8 @@ set(sdrgui_HEADERS gui/workspaceselectiondialog.h gui/wsspectrumsettingsdialog.h gui/wrappingdatetimeedit.h + gui/wrappingdial.h + gui/wrappingspinbox.h dsp/scopevisxy.h diff --git a/sdrgui/gui/wrappingdial.cpp b/sdrgui/gui/wrappingdial.cpp new file mode 100644 index 0000000000..b21c076b31 --- /dev/null +++ b/sdrgui/gui/wrappingdial.cpp @@ -0,0 +1,63 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 "wrappingdial.h" + +#include + +WrappingDial::WrappingDial(QWidget *parent) : + QDial(parent), + m_wheelEvent(false), + m_wheelUp(false) +{ + setWrapping(true); + + connect(this, &QDial::actionTriggered, this, &WrappingDial::on_actionTriggered); +} + +void WrappingDial::on_actionTriggered(int action) +{ + if (wrapping()) + { + if ( ( (action == QAbstractSlider::SliderSingleStepSub) + || (action == QAbstractSlider::SliderPageStepSub) + || ((action == QAbstractSlider::SliderMove) && m_wheelEvent && !m_wheelUp) + ) + && (value() < sliderPosition())) + { + emit wrapDown(); + } + if ( ( (action == QAbstractSlider::SliderSingleStepAdd) + || (action == QAbstractSlider::SliderPageStepAdd) + || ((action == QAbstractSlider::SliderMove) && m_wheelEvent && m_wheelUp) + ) + && (value() > sliderPosition())) + { + emit wrapUp(); + } + } +} + +// QAbstractSlider just generates SliderMove actions for wheel events, so we can't distinguish between +// wheel and dial being clicked to a new position - so we set a flag here, before passing up the event +void WrappingDial::wheelEvent(QWheelEvent *e) +{ + m_wheelEvent = true; + m_wheelUp = e->angleDelta().y() > 0; + QDial::wheelEvent(e); + m_wheelEvent = false; +} diff --git a/sdrgui/gui/wrappingdial.h b/sdrgui/gui/wrappingdial.h new file mode 100644 index 0000000000..fa3b63ff0b --- /dev/null +++ b/sdrgui/gui/wrappingdial.h @@ -0,0 +1,48 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 SDRGUI_GUI_WRAPPINGDDIAL_H +#define SDRGUI_GUI_WRAPPINGDDIAL_H + +#include + +#include "export.h" + +// Extends QDial to generate a signal when dial wraps +class SDRGUI_API WrappingDial : public QDial { + Q_OBJECT + +public: + explicit WrappingDial(QWidget *parent = nullptr); + +protected: + void wheelEvent(QWheelEvent *e) override; + +private: + bool m_wheelEvent; + bool m_wheelUp; + +private slots: + void on_actionTriggered(int action); + +signals: + void wrapUp(); + void wrapDown(); + +}; + +#endif // SDRGUI_GUI_WRAPPINGDDIAL_H diff --git a/sdrgui/gui/wrappingspinbox.cpp b/sdrgui/gui/wrappingspinbox.cpp new file mode 100644 index 0000000000..3c2c7ee23b --- /dev/null +++ b/sdrgui/gui/wrappingspinbox.cpp @@ -0,0 +1,53 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 "wrappingspinbox.h" + +#include + +WrappingSpinBox::WrappingSpinBox(QWidget *parent) : + QSpinBox(parent), + m_wheelEvent(false), + m_wheelUp(false) +{ + setWrapping(true); +} + +void WrappingSpinBox::stepBy(int steps) +{ + int v = value(); + QSpinBox::stepBy(steps); + if (wrapping()) + { + if (v + steps > maximum()) { + emit wrapUp(); + } + if (v + steps < minimum()) { + emit wrapDown(); + } + } +} + +// QAbstractSlider just generates SliderMove actions for wheel events, so we can't distinguish between +// wheel and dial being clicked to a new position - so we set a flag here, before passing up the event +void WrappingSpinBox::wheelEvent(QWheelEvent *e) +{ + m_wheelEvent = true; + m_wheelUp = e->angleDelta().y() > 0; + QSpinBox::wheelEvent(e); + m_wheelEvent = false; +} diff --git a/sdrgui/gui/wrappingspinbox.h b/sdrgui/gui/wrappingspinbox.h new file mode 100644 index 0000000000..e775ecb86e --- /dev/null +++ b/sdrgui/gui/wrappingspinbox.h @@ -0,0 +1,47 @@ +/////////////////////////////////////////////////////////////////////////////////// +// 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 SDRGUI_GUI_WRAPPINGDSPINBOX_H +#define SDRGUI_GUI_WRAPPINGDSPINBOX_H + +#include + +#include "export.h" + +// Extends QSpinBox to generate a signal when spinbox wraps +class SDRGUI_API WrappingSpinBox : public QSpinBox { + Q_OBJECT + +public: + explicit WrappingSpinBox(QWidget *parent = nullptr); + + void stepBy(int steps) override; + +protected: + void wheelEvent(QWheelEvent *e) override; + +private: + bool m_wheelEvent; + bool m_wheelUp; + +signals: + void wrapUp(); + void wrapDown(); + +}; + +#endif // SDRGUI_GUI_WRAPPINGDSPINBOX_H From 420e8147fa6835e76f358f99be5716d8136f3b6e Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 15:11:58 +0100 Subject: [PATCH 06/20] Fix gcc warnings. --- plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp | 10 ++++++---- plugins/feature/map/mapgui.cpp | 7 ++++++- sdrgui/gui/perioddial.cpp | 2 ++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp index 3e985cc7b6..7e46e37294 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp @@ -70,8 +70,8 @@ RemoteTCPSinkSink::RemoteTCPSinkSink() : m_iqCorrection(false), m_devSampleRate(0), m_log2Decim(0), - m_gain(), m_rfBW(0), + m_gain(), m_timer(this), m_azimuth(std::numeric_limits::quiet_NaN()), m_elevation(std::numeric_limits::quiet_NaN()) @@ -872,8 +872,10 @@ void RemoteTCPSinkSink::acceptTCPConnection() FLAC__StreamEncoderWriteStatus RemoteTCPSinkSink::flacWrite(const FLAC__StreamEncoder *encoder, const FLAC__byte buffer[], size_t bytes, uint32_t samples, uint32_t currentFrame) { + (void) encoder; + char header[1+4]; -//qDebug() << "RemoteTCPSinkSink::flacWrite bytes" << bytes << "samples" << samples; + // Save FLAC header for clients that connect later if ((currentFrame == 0) && (samples == 0)) { @@ -1140,7 +1142,7 @@ void RemoteTCPSinkSink::processCommand() { char *buf = new char[msgLen]; len = client->read((char *)buf, msgLen); - if (len == msgLen) + if (len == (int) msgLen) { bool broadcast = (bool) buf[0]; int i; @@ -1520,7 +1522,7 @@ void RemoteTCPSinkSink::sendDirection(bool isotropic, float azimuth, float eleva RemoteTCPProtocol::encodeUInt32((quint8 *) &msg[1], 4+4+4); RemoteTCPProtocol::encodeUInt32((quint8 *) &msg[1+4], isotropic); RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4], azimuth); - RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4+5], elevation); + RemoteTCPProtocol::encodeFloat((quint8 *) &msg[1+4+4+4], elevation); int clients = std::min((int) m_clients.size(), m_settings.m_maxClients); for (int i = 0; i < clients; i++) diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index 18f5244abc..5c28b261c5 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -2779,6 +2779,8 @@ void MapGUI::openKiwiSDR(const QString& url) void MapGUI::kiwiSDRDeviceSetAdded(int index, DeviceAPI *device) { + (void) index; + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); // FIXME: Doesn't work if we do it immediately. Settings overwritten? @@ -2848,7 +2850,8 @@ void MapGUI::openSpyServer(const QString& url) void MapGUI::spyServerDeviceSetAdded(int index, DeviceAPI *device) { -qDebug() << "**************** MapGUI::spyServerDeviceSetAdded"; + (void) index; + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); // FIXME: Doesn't work if we do it immediately. Settings overwritten? @@ -2881,6 +2884,8 @@ void MapGUI::openSDRangelServer(const QString& url) void MapGUI::sdrangelServerDeviceSetAdded(int index, DeviceAPI *device) { + (void) index; + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); // FIXME: Doesn't work if we do it immediately. Settings overwritten? diff --git a/sdrgui/gui/perioddial.cpp b/sdrgui/gui/perioddial.cpp index 2073007966..e5658e8c81 100644 --- a/sdrgui/gui/perioddial.cpp +++ b/sdrgui/gui/perioddial.cpp @@ -71,6 +71,8 @@ void PeriodDial::on_spinBox_valueChanged(int boxValue) void PeriodDial::on_units_currentIndexChanged(int index) { + (void) index; + emit valueChanged(value()); } From 3a6baf0d1d9dda7b41601a86a82f9fc1d7cfddab Mon Sep 17 00:00:00 2001 From: srcejon Date: Sun, 22 Sep 2024 15:22:07 +0100 Subject: [PATCH 07/20] Fix gcc warning --- plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp index b9b22a107e..599bec544f 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.cpp @@ -273,6 +273,8 @@ void RemoteTCPSinkSettingsDialog::on_iqOnly_toggled(bool checked) void RemoteTCPSinkSettingsDialog::on_isotropic_toggled(bool checked) { + (void) checked; + displayEnabled(); } From 96422ff19f88ea3b6f99fbc2f883fd2ee8c035f0 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 17:20:18 +0100 Subject: [PATCH 08/20] RemoteTCPSink: Report server init errors via GUI. Send protocol to public list. RemoteTCPInput: Add wss protocol support. --- .../channelrx/remotetcpsink/CMakeLists.txt | 2 - plugins/channelrx/remotetcpsink/readme.md | 5 +- .../channelrx/remotetcpsink/remotetcpsink.cpp | 9 ++ .../channelrx/remotetcpsink/remotetcpsink.h | 21 +++ .../remotetcpsink/remotetcpsinkgui.cpp | 8 ++ .../remotetcpsink/remotetcpsinkgui.ui | 50 ++++--- .../remotetcpsink/remotetcpsinksink.cpp | 71 ++++++++-- .../remotetcpsink/remotetcpsinksink.h | 2 +- .../remotetcpinput/CMakeLists.txt | 1 + plugins/samplesource/remotetcpinput/readme.md | 8 +- .../remotetcpinput/remotetcpinput.cpp | 12 +- .../remotetcpinput/remotetcpinputgui.cpp | 32 ++--- .../remotetcpinput/remotetcpinputgui.ui | 116 +++++++++------- .../remotetcpinput/remotetcpinputsettings.h | 2 +- .../remotetcpinputtcphandler.cpp | 130 +++++++++--------- .../remotetcpinput/remotetcpinputtcphandler.h | 9 +- sdrbase/CMakeLists.txt | 2 + sdrbase/util/sdrangelserverlist.cpp | 3 + sdrbase/util/sdrangelserverlist.h | 1 + .../remotetcpsink => sdrbase/util}/socket.cpp | 35 ++++- .../remotetcpsink => sdrbase/util}/socket.h | 14 +- 21 files changed, 349 insertions(+), 184 deletions(-) rename {plugins/channelrx/remotetcpsink => sdrbase/util}/socket.cpp (85%) rename {plugins/channelrx/remotetcpsink => sdrbase/util}/socket.h (88%) diff --git a/plugins/channelrx/remotetcpsink/CMakeLists.txt b/plugins/channelrx/remotetcpsink/CMakeLists.txt index 4d1884a499..a5750a7990 100644 --- a/plugins/channelrx/remotetcpsink/CMakeLists.txt +++ b/plugins/channelrx/remotetcpsink/CMakeLists.txt @@ -7,7 +7,6 @@ set(remotetcpsink_SOURCES remotetcpsinksettings.cpp remotetcpsinkwebapiadapter.cpp remotetcpsinkplugin.cpp - socket.cpp ) set(remotetcpsink_HEADERS @@ -18,7 +17,6 @@ set(remotetcpsink_HEADERS remotetcpsinkwebapiadapter.h remotetcpsinkplugin.h remotetcpprotocol.h - socket.h ) include_directories( diff --git a/plugins/channelrx/remotetcpsink/readme.md b/plugins/channelrx/remotetcpsink/readme.md index 1c10da99ff..d8371111dc 100644 --- a/plugins/channelrx/remotetcpsink/readme.md +++ b/plugins/channelrx/remotetcpsink/readme.md @@ -6,7 +6,7 @@ The Remote TCP Sink Channel plugin sends I/Q samples from the baseband via TCP/I The client application could be SDRangel using the [Remote TCP Input](../../samplesource/remotetcpinput/readme.md) plugin or an rtl_tcp compatible application. This means that applications using rtl_tcp protocol can connect to the wide variety of SDRs supported by SDRangel. -While the plugin supports the RTL0 protocol for compatibility with older applications, the newer SDRA protocol supports the following additional features: +While the plugin supports rtl_tcp's RTL0 protocol for compatibility with older applications, the newer SDRA protocol supports the following additional features: - Different bit depths (8, 16, 24 or 32), - Additional settings, such as decimation, frequency offset and channel gain, @@ -14,7 +14,8 @@ While the plugin supports the RTL0 protocol for compatibility with older applica - IQ compression, using FLAC or zlib, to reduce network bandwidth, - IQ squelch, to reduce network bandwidth when no signal is being received, - Real-time forwarding of device/antenna position and direction to client, -- Text messaging between clients and server. +- Text messaging between clients and server, +- Use of either TCP or WSS (WebSocket Secure Protocol). The Remote TCP Sink can support multiple clients connected simultaneously, with a user-defined maximum client limit. Clients can also have a time limit applied. diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp index bd9a5084ec..ce7f120df1 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp @@ -45,6 +45,7 @@ MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportConnection, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportDisconnect, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgReportBW, Message) MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgSendMessage, Message) +MESSAGE_CLASS_DEFINITION(RemoteTCPSink::MsgError, Message) const char* const RemoteTCPSink::m_channelIdURI = "sdrangel.channel.remotetcpsink"; const char* const RemoteTCPSink::m_channelId = "RemoteTCPSink"; @@ -713,9 +714,17 @@ void RemoteTCPSink::updatePublicListing() QString device = MainCore::instance()->getDevice(getDeviceSetIndex())->getHardwareId(); + QString protocol; + if (m_settings.m_protocol == RemoteTCPSinkSettings::SDRA_WSS) { + protocol = "SDRangel wss"; + } else { + protocol = "SDRangel"; + } + QJsonObject json; json.insert("address", m_settings.m_publicAddress); json.insert("port", m_settings.m_publicPort); + json.insert("protocol", protocol); json.insert("minFrequency", m_settings.m_minFrequency); json.insert("maxFrequency", m_settings.m_maxFrequency); json.insert("maxSampleRate", m_settings.m_maxSampleRate); diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.h b/plugins/channelrx/remotetcpsink/remotetcpsink.h index f36923dd72..b9f491ce74 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.h @@ -183,6 +183,27 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { { } }; + class MsgError : public Message { + MESSAGE_CLASS_DECLARATION + + public: + + const QString& getError() const { return m_error; } + + static MsgError *create(const QString& error) + { + return new MsgError(error); + } + + private: + QString m_error; + + MsgError(const QString& error) : + Message(), + m_error(error) + { } + }; + RemoteTCPSink(DeviceAPI *deviceAPI); virtual ~RemoteTCPSink(); virtual void destroy() { delete this; } diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp index 09fabb8702..c8f3af621f 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "device/deviceuiset.h" #include "gui/basicchannelsettingsdialog.h" @@ -276,6 +277,13 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) return true; } + else if (RemoteTCPSink::MsgError::match(message)) + { + RemoteTCPSink::MsgError& msg = (RemoteTCPSink::MsgError&) message; + QString error = msg.getError(); + QMessageBox::warning(this, "RemoteTCPSink", error, QMessageBox::Ok); + return true; + } else { return false; diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui index 72308406ef..afbeba69d6 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.ui @@ -11,7 +11,7 @@
- + 0 0 @@ -108,7 +108,7 @@ PointingHandCursor - Qt::StrongFocus + Qt::FocusPolicy::StrongFocus Channel frequency shift from center in Hz @@ -125,7 +125,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -177,7 +177,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -193,13 +193,13 @@ Channel power - Qt::RightToLeft + Qt::LayoutDirection::RightToLeft 0.0 - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
@@ -263,7 +263,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -301,7 +301,7 @@ -150 - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
@@ -315,7 +315,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -335,7 +335,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -366,7 +366,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -381,7 +381,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -435,7 +435,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -497,7 +497,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -549,7 +549,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -587,12 +587,12 @@ - SDRA + SDRangel - SDRA wss + SDRangel wss @@ -628,7 +628,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -658,7 +658,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -696,6 +696,12 @@ Values are averaged over the last 10 seconds 191 + + + 0 + 0 + + Messages @@ -759,6 +765,12 @@ Values are averaged over the last 10 seconds 191 + + + 0 + 0 + + Connection Log diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp index 7e46e37294..f3f8fc2dd0 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp @@ -380,10 +380,10 @@ void RemoteTCPSinkSink::processOneSample(Complex &ci) if (ret == Z_STREAM_END) { deflateReset(&m_zStream); } else if (ret != Z_OK) { - qDebug() << "Failed to deflate" << ret; + qDebug() << "RemoteTCPSinkSink::processOneSample: Failed to deflate" << ret; } if (m_zStream.avail_in != 0) { - qDebug() << "Warning: Data still in input buffer"; + qDebug() << "RemoteTCPSinkSink::processOneSample: Data still in input buffer"; } int compressedBytes = m_zOutBuf.size() - m_zStream.avail_out; @@ -497,6 +497,7 @@ void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, con } if ((settingsKeys.contains("compressionLevel") && (settings.m_compressionLevel != m_settings.m_compressionLevel)) + || (settingsKeys.contains("compression") && (settings.m_compression != m_settings.m_compression)) || (settingsKeys.contains("sampleBits") && (settings.m_sampleBits != m_settings.m_sampleBits)) || (settingsKeys.contains("blockSize") && (settings.m_blockSize != m_settings.m_blockSize)) || (settingsKeys.contains("channelSampleRate") && (settings.m_channelSampleRate != m_settings.m_channelSampleRate)) @@ -506,6 +507,7 @@ void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, con } if ((settingsKeys.contains("compressionLevel") && (settings.m_compressionLevel != m_settings.m_compressionLevel)) + || (settingsKeys.contains("compression") && (settings.m_compression != m_settings.m_compression)) || force) { initZLib = true; @@ -670,15 +672,46 @@ void RemoteTCPSinkSink::startServer() if (m_settings.m_protocol == RemoteTCPSinkSettings::SDRA_WSS) { #ifndef QT_NO_OPENSSL - m_webSocketServer = new QWebSocketServer(QStringLiteral("Remote TCP Sink"), - QWebSocketServer::SecureMode, - this); QSslConfiguration sslConfiguration; qDebug() << "RemoteTCPSinkSink::startServer: SSL config: " << m_settings.m_certificate << m_settings.m_key; + if (m_settings.m_certificate.isEmpty()) + { + QString msg = "RemoteTCPSink requires an SSL certificate in order to use wss protocol"; + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } + return; + } + if (m_settings.m_certificate.isEmpty()) + { + QString msg = "RemoteTCPSink requires an SSL key in order to use wss protocol"; + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } + return; + } QFile certFile(m_settings.m_certificate); + if (!certFile.open(QIODevice::ReadOnly)) + { + QString msg = QString("RemoteTCPSink failed to open certificate %1: %2").arg(m_settings.m_certificate).arg(certFile.errorString()); + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } + return; + } QFile keyFile(m_settings.m_key); - certFile.open(QIODevice::ReadOnly); - keyFile.open(QIODevice::ReadOnly); + if (!keyFile.open(QIODevice::ReadOnly)) + { + QString msg = QString("RemoteTCPSink failed to open key %1: %2").arg(m_settings.m_key).arg(keyFile.errorString()); + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } + return; + } QSslCertificate certificate(&certFile, QSsl::Pem); QSslKey sslKey(&keyFile, QSsl::Rsa, QSsl::Pem); certFile.close(); @@ -686,6 +719,10 @@ void RemoteTCPSinkSink::startServer() sslConfiguration.setPeerVerifyMode(QSslSocket::VerifyNone); sslConfiguration.setLocalCertificate(certificate); sslConfiguration.setPrivateKey(sslKey); + + m_webSocketServer = new QWebSocketServer(QStringLiteral("Remote TCP Sink"), + QWebSocketServer::SecureMode, + this); m_webSocketServer->setSslConfiguration(sslConfiguration); QHostAddress address(m_settings.m_dataAddress); @@ -695,8 +732,11 @@ void RemoteTCPSinkSink::startServer() #endif if (!m_webSocketServer->listen(address, m_settings.m_dataPort)) { - qCritical() << "RemoteTCPSink failed to listen on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; - // FIXME: Report to GUI? + QString msg = QString("RemoteTCPSink failed to listen on %1 port %2: %3").arg(m_settings.m_dataAddress).arg(m_settings.m_dataPort).arg(m_webSocketServer->errorString()); + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } } else { @@ -705,7 +745,11 @@ void RemoteTCPSinkSink::startServer() connect(m_webSocketServer, &QWebSocketServer::sslErrors, this, &RemoteTCPSinkSink::onSslErrors); } #else - qWarning("RemoteTCPSinkSink::startServer: SSL is not supported"); + QString msg = "RemoteTCPSink unable to use wss protocol as SSL is not supported"; + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } #endif } else @@ -713,8 +757,11 @@ void RemoteTCPSinkSink::startServer() m_server = new QTcpServer(this); if (!m_server->listen(QHostAddress(m_settings.m_dataAddress), m_settings.m_dataPort)) { - qCritical() << "RemoteTCPSink failed to listen on" << m_settings.m_dataAddress << "port" << m_settings.m_dataPort; - // FIXME: Report to GUI? + QString msg = QString("RemoteTCPSink failed to listen on %1 port %2: %3").arg(m_settings.m_dataAddress).arg(m_settings.m_dataPort).arg(m_webSocketServer->errorString()); + qWarning() << msg; + if (m_messageQueueToGUI) { + m_messageQueueToGUI->push(RemoteTCPSink::MsgError::create(msg)); + } } else { diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.h b/plugins/channelrx/remotetcpsink/remotetcpsinksink.h index dd5f347392..9972565abd 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.h @@ -40,7 +40,7 @@ #include "remotetcpsinksettings.h" #include "remotetcpprotocol.h" -#include "socket.h" +#include "util/socket.h" class DeviceSampleSource; diff --git a/plugins/samplesource/remotetcpinput/CMakeLists.txt b/plugins/samplesource/remotetcpinput/CMakeLists.txt index b7f2693b46..644c5c1443 100644 --- a/plugins/samplesource/remotetcpinput/CMakeLists.txt +++ b/plugins/samplesource/remotetcpinput/CMakeLists.txt @@ -57,6 +57,7 @@ endif() target_link_libraries(${TARGET_NAME} PRIVATE Qt::Core + Qt::WebSockets ${TARGET_LIB} sdrbase ${TARGET_LIB_GUI} diff --git a/plugins/samplesource/remotetcpinput/readme.md b/plugins/samplesource/remotetcpinput/readme.md index 9158dfd408..48f929ebb8 100644 --- a/plugins/samplesource/remotetcpinput/readme.md +++ b/plugins/samplesource/remotetcpinput/readme.md @@ -93,7 +93,7 @@ When unchecked, the channel sample rate can be set to any value. Specifies number of bits per I/Q sample transmitted via TCP/IP. -When the protocol is RTL0, only 8-bits are supported. SDRA and Spy Server protocol supports 8, 16, 24 and 32-bit samples. +When the protocol is RTL0, only 8-bits are supported. SDRangel and Spy Server protocol supports 8, 16, 24 and 32-bit samples.

19: Server IP address

@@ -105,14 +105,16 @@ TCP port on the server to connect to.

21: Protocol

-Selects protocol to use. Set to SDRangel for rtl_tcp, rsp_tcp or SDRangel's own protocol. Alternative, Spy Server can be selected to connect to Spy Servers. +Selects protocol to use. Set to SDRangel for rtl_tcp, rsp_tcp or SDRangel's own protocol. +Set to SDRangel wss to use SDRangel's protocol over WebSocket Secure. +Alternatively, Spy Server can be selected to connect to Spy Servers.

23: Connection settings

Determines which settings are used when connecting. When checked, settings in the RemoteTCPInput GUI are written to the remote device upon connection. -When unchecked, if the remote server is using the SDRA protocol, the RemoteTCPInput GUI will be updated with the current settings from the remote device. +When unchecked, if the remote server is using the SDRangel protocol, the RemoteTCPInput GUI will be updated with the current settings from the remote device. If the remote server is using the RTL0 protocol, the GUI will not be updated, which may mean the two are inconsistent.

24: Pre-fill

diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp index 72a0f75225..ce54ddeb47 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp @@ -96,7 +96,7 @@ void RemoteTCPInput::destroy() void RemoteTCPInput::init() { - qDebug() << "*************** RemoteTCPInput::init"; + qDebug() << "RemoteTCPInput::init"; applySettings(m_settings, QList(), true); } @@ -109,7 +109,6 @@ bool RemoteTCPInput::start() } m_remoteInputTCPPHandler->reset(); m_remoteInputTCPPHandler->start(); - qDebug() << "************ RemoteTCPInput::start" << m_settings.m_dataAddress; m_remoteInputTCPPHandler->getInputMessageQueue()->push(RemoteTCPInputTCPHandler::MsgConfigureTcpHandler::create(m_settings, QList(), true)); m_thread.start(); m_running = true; @@ -122,19 +121,14 @@ void RemoteTCPInput::stop() if (!m_running) { // For wasm, important not to call m_remoteInputTCPPHandler->stop() twice // as mutex can deadlock when this object is being deleted - qDebug() << "RemoteTCPInput::stop - Not running"; return; } m_remoteInputTCPPHandler->stop(); - qDebug() << "RemoteTCPInput::stop1"; m_thread.quit(); - qDebug() << "RemoteTCPInput::stop2"; #ifndef __EMSCRIPTEN__ - qDebug() << "RemoteTCPInput::stop3"; m_thread.wait(); #endif m_running = false; - qDebug() << "RemoteTCPInput::stopped"; } QByteArray RemoteTCPInput::serialize() const @@ -151,9 +145,6 @@ bool RemoteTCPInput::deserialize(const QByteArray& data) m_settings.resetToDefaults(); success = false; } - - qDebug() << "************** RemoteTCPInput::deserialize" << m_settings.m_dataAddress; - MsgConfigureRemoteTCPInput* message = MsgConfigureRemoteTCPInput::create(m_settings, QList(), true); m_inputMessageQueue.push(message); @@ -230,7 +221,6 @@ bool RemoteTCPInput::handleMessage(const Message& message) { qDebug() << "RemoteTCPInput::handleMessage:" << message.getIdentifier(); MsgConfigureRemoteTCPInput& conf = (MsgConfigureRemoteTCPInput&) message; - qDebug() << "*********** RemoteTCPInput::handleMessage MsgConfigureRemoteTCPInput" << m_settings.m_dataAddress; applySettings(conf.getSettings(), conf.getSettingsKeys(), conf.getForce()); return true; } diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp index 27fed839d1..ea52cbf892 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp @@ -117,7 +117,7 @@ void RemoteTCPInputGui::destroy() void RemoteTCPInputGui::resetToDefaults() { - qDebug() << "*************** RemoteTCPInputGui::resetToDefaults"; + qDebug() << "RemoteTCPInputGui::resetToDefaults"; m_settings.resetToDefaults(); displaySettings(); m_forceSettings = true; @@ -158,7 +158,6 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else { m_settings.applySettings(cfg.getSettingsKeys(), cfg.getSettings()); } - qDebug() << "********* RemoteTCPInputGui::handleMessage MsgConfigureRemoteTCPInput" << m_settings.m_dataAddress; blockApplySettings(true); displaySettings(); @@ -304,16 +303,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) { const RemoteTCPInputTCPHandler::MsgReportConnection& report = (RemoteTCPInputTCPHandler::MsgReportConnection&) message; qDebug() << "RemoteTCPInputGui::handleMessage: MsgReportConnection connected: " << report.getConnected(); - if (report.getConnected()) - { - m_connectionError = false; - //ui->startStop->setStyleSheet("QToolButton { background-color : green; }"); - } - else - { - m_connectionError = true; - //ui->startStop->setStyleSheet("QToolButton { background-color : red; }"); - } + m_connectionError = !report.getConnected(); updateStatus(); return true; } @@ -921,14 +911,18 @@ void RemoteTCPInputGui::on_squelchGate_valueChanged(double value) void RemoteTCPInputGui::on_dataAddress_editingFinished() { - m_settings.m_dataAddress = ui->dataAddress->currentText(); - m_settingsKeys.append("dataAddress"); - m_settings.m_addressList.clear(); - for (int i = 0; i < ui->dataAddress->count(); i++) { - m_settings.m_addressList.append(ui->dataAddress->itemText(i)); + QString text = ui->dataAddress->currentText(); + if (text != m_settings.m_dataAddress) + { + m_settings.m_dataAddress = text; + m_settingsKeys.append("dataAddress"); + m_settings.m_addressList.clear(); + for (int i = 0; i < ui->dataAddress->count(); i++) { + m_settings.m_addressList.append(ui->dataAddress->itemText(i)); + } + m_settingsKeys.append("addressList"); + sendSettings(); } - m_settingsKeys.append("addressList"); - sendSettings(); } void RemoteTCPInputGui::on_dataAddress_currentIndexChanged(int index) diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui b/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui index c90b15ced2..75e7f180b4 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.ui @@ -89,7 +89,7 @@ 00000k
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
@@ -100,7 +100,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -134,7 +134,7 @@ PointingHandCursor - Qt::StrongFocus + Qt::FocusPolicy::StrongFocus Center frequency in kHz @@ -153,14 +153,14 @@ kHz - Qt::AlignCenter + Qt::AlignmentFlag::AlignCenter - Qt::Horizontal + Qt::Orientation::Horizontal @@ -199,7 +199,7 @@ 1 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -215,7 +215,7 @@ 0 - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -224,7 +224,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -253,7 +253,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -288,7 +288,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -342,7 +342,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -415,7 +415,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -459,14 +459,14 @@ 40.0dB - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - Qt::Vertical + Qt::Orientation::Vertical @@ -500,7 +500,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -534,7 +534,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -559,7 +559,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -609,7 +609,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -624,7 +624,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -669,7 +669,7 @@ PointingHandCursor - Qt::StrongFocus + Qt::FocusPolicy::StrongFocus Channel shift frequency from center in Hz @@ -686,7 +686,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -804,7 +804,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -866,7 +866,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -921,7 +921,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Vertical + Qt::Orientation::Vertical @@ -959,7 +959,7 @@ Use to ensure full dynamic range of 8-bit data is used. -150 - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -973,7 +973,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Vertical + Qt::Orientation::Vertical @@ -993,14 +993,14 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Vertical + Qt::Orientation::Vertical - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1022,13 +1022,13 @@ Use to ensure full dynamic range of 8-bit data is used. Channel power - Qt::RightToLeft + Qt::LayoutDirection::RightToLeft 0.0 - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -1044,7 +1044,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1065,6 +1065,12 @@ Use to ensure full dynamic range of 8-bit data is used. + + + 0 + 0 + + 120 @@ -1110,7 +1116,7 @@ Use to ensure full dynamic range of 8-bit data is used. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1124,7 +1130,7 @@ Use to ensure full dynamic range of 8-bit data is used. - 92 + 110 0 @@ -1136,6 +1142,11 @@ Use to ensure full dynamic range of 8-bit data is used. SDRangel + + + SDRangel wss + + Spy Server @@ -1200,7 +1211,7 @@ When unchecked, if remote device is using SDRA protocol, local settings are upda - Qt::Vertical + Qt::Orientation::Vertical @@ -1263,14 +1274,14 @@ When unchecked, if remote device is using SDRA protocol, local settings are upda 10.00s - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - Qt::Vertical + Qt::Orientation::Vertical @@ -1328,7 +1339,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1390,7 +1401,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b Messages - QListView::Static + QListView::Movement::Static @@ -1399,7 +1410,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1427,7 +1438,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b 500 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1504,7 +1515,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b - Qt::Horizontal + Qt::Orientation::Horizontal @@ -1520,7 +1531,7 @@ This should typically be empty. If full, your CPU cannot keep up and data will b - Qt::Vertical + Qt::Orientation::Vertical @@ -1548,15 +1559,15 @@ This should typically be empty. If full, your CPU cannot keep up and data will b 1 - ValueDial + LevelMeterSignalDB QWidget -
gui/valuedial.h
+
gui/levelmeter.h
1
- LevelMeterSignalDB + ValueDial QWidget -
gui/levelmeter.h
+
gui/valuedial.h
1
@@ -1583,10 +1594,23 @@ This should typically be empty. If full, your CPU cannot keep up and data will b channelGain decimation sampleBits + squelchEnabled + squelch dataAddress + dataPort protocol overrideRemoteSettings preFill + sendMessage + txAddress + txMessage + messages + replayOffset + replayNow + replayPlus + replayMinus + replayLoop + replaySave diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h index c209532c0f..bb4606fbb9 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputsettings.h @@ -53,7 +53,7 @@ struct RemoteTCPInputSettings uint16_t m_reverseAPIPort; uint16_t m_reverseAPIDeviceIndex; QStringList m_addressList; // List of dataAddresses that have been used in the past - QString m_protocol; // "SDRangel" or "Spy Server" + QString m_protocol; // "SDRangel", "SDRangel wss" or "Spy Server" float m_replayOffset; //!< Replay offset in seconds float m_replayLength; //!< Replay buffer size in seconds float m_replayStep; //!< Replay forward/back step size in seconds diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp index b2eb03293a..fdecae7bad 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp @@ -18,6 +18,7 @@ /////////////////////////////////////////////////////////////////////////////////// #include +#include #include #include "device/deviceapi.h" @@ -36,6 +37,8 @@ RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler(SampleSinkFifo *sampleFifo, D m_deviceAPI(deviceAPI), m_running(false), m_dataSocket(nullptr), + m_tcpSocket(nullptr), + m_webSocket(nullptr), m_tcpBuf(nullptr), m_sampleFifo(sampleFifo), m_replayBuffer(replayBuffer), @@ -82,9 +85,7 @@ RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler() if (m_converterBuffer) { delete[] m_converterBuffer; } - qDebug() << "RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler cleanup"; cleanup(); - qDebug() << "RemoteTCPInputTCPHandler::~RemoteTCPInputTCPHandler done"; } void RemoteTCPInputTCPHandler::reset() @@ -114,7 +115,6 @@ void RemoteTCPInputTCPHandler::start() void RemoteTCPInputTCPHandler::stop() { - qDebug("RemoteTCPInputTCPHandler::stop locking"); QMutexLocker mutexLocker(&m_mutex); qDebug("RemoteTCPInputTCPHandler::stop"); @@ -142,24 +142,40 @@ void RemoteTCPInputTCPHandler::finished() cleanup(); disconnect(thread(), SIGNAL(finished()), this, SLOT(finished())); m_running = false; - qDebug("RemoteTCPInputTCPHandler::finished done"); } -void RemoteTCPInputTCPHandler::connectToHost(const QString& address, quint16 port) +void RemoteTCPInputTCPHandler::connectToHost(const QString& address, quint16 port, const QString& protocol) { - qDebug("RemoteTCPInputTCPHandler::connectToHost: connect to %s:%d", address.toStdString().c_str(), port); - m_dataSocket = new QTcpSocket(this); + qDebug("RemoteTCPInputTCPHandler::connectToHost: connect to %s %s:%d", protocol.toStdString().c_str(), address.toStdString().c_str(), port); m_fillBuffer = true; m_readMetaData = false; - connect(m_dataSocket, SIGNAL(readyRead()), this, SLOT(dataReadyRead())); - connect(m_dataSocket, SIGNAL(connected()), this, SLOT(connected())); - connect(m_dataSocket, SIGNAL(disconnected()), this, SLOT(disconnected())); + if (protocol == "SDRangel wss") + { + m_webSocket = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this); + connect(m_webSocket, &QWebSocket::binaryFrameReceived, this, &RemoteTCPInputTCPHandler::dataReadyRead); + connect(m_webSocket, &QWebSocket::connected, this, &RemoteTCPInputTCPHandler::connected); + connect(m_webSocket, &QWebSocket::disconnected, this, &RemoteTCPInputTCPHandler::disconnected); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(m_webSocket, &QWebSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); +#endif + connect(m_webSocket, &QWebSocket::sslErrors, this, &RemoteTCPInputTCPHandler::sslErrors); + m_webSocket->open(QUrl(QString("wss://%1:%2").arg(address).arg(port))); + m_dataSocket = new WebSocket(m_webSocket); + } + else + { + m_tcpSocket = new QTcpSocket(this); + connect(m_tcpSocket, SIGNAL(readyRead()), this, SLOT(dataReadyRead())); + connect(m_tcpSocket, SIGNAL(connected()), this, SLOT(connected())); + connect(m_tcpSocket, SIGNAL(disconnected()), this, SLOT(disconnected())); #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) - connect(m_dataSocket, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPInputTCPHandler::errorOccurred); + connect(m_tcpSocket, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPInputTCPHandler::errorOccurred); #else - connect(m_dataSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); + connect(m_tcpSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); #endif - m_dataSocket->connectToHost(address, port); + m_tcpSocket->connectToHost(address, port); + m_dataSocket = new TCPSocket(m_tcpSocket); + } } /*void RemoteTCPInputTCPHandler::disconnectFromHost() @@ -187,22 +203,45 @@ void RemoteTCPInputTCPHandler::cleanup() FLAC__stream_decoder_delete(m_decoder); m_decoder = nullptr; } - if (m_dataSocket) + if (m_webSocket) { - qDebug() << "RemoteTCPInputTCPHandler::cleanup: Closing and deleting socket"; + qDebug() << "RemoteTCPInputTCPHandler::cleanup: Closing and deleting web socket"; + disconnect(m_webSocket, &QWebSocket::binaryFrameReceived, this, &RemoteTCPInputTCPHandler::dataReadyRead); + disconnect(m_webSocket, &QWebSocket::connected, this, &RemoteTCPInputTCPHandler::connected); + disconnect(m_webSocket, &QWebSocket::disconnected, this, &RemoteTCPInputTCPHandler::disconnected); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + disconnect(m_webSocket, &QWebSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); +#endif + } + if (m_tcpSocket) + { + qDebug() << "RemoteTCPInputTCPHandler::cleanup: Closing and deleting TCP socket"; // Disconnect disconnected, so don't get called recursively - disconnect(m_dataSocket, SIGNAL(readyRead()), this, SLOT(dataReadyRead())); - disconnect(m_dataSocket, SIGNAL(connected()), this, SLOT(connected())); - disconnect(m_dataSocket, SIGNAL(disconnected()), this, SLOT(disconnected())); + disconnect(m_tcpSocket, SIGNAL(readyRead()), this, SLOT(dataReadyRead())); + disconnect(m_tcpSocket, SIGNAL(connected()), this, SLOT(connected())); + disconnect(m_tcpSocket, SIGNAL(disconnected()), this, SLOT(disconnected())); #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) - disconnect(m_dataSocket, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPInputTCPHandler::errorOccurred); + disconnect(m_tcpSocket, QOverload::of(&QAbstractSocket::error), this, &RemoteTCPInputTCPHandler::errorOccurred); #else - disconnect(m_dataSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); + disconnect(m_tcpSocket, &QAbstractSocket::errorOccurred, this, &RemoteTCPInputTCPHandler::errorOccurred); #endif + } + if (m_dataSocket) + { m_dataSocket->close(); m_dataSocket->deleteLater(); m_dataSocket = nullptr; } + if (m_webSocket) + { + m_webSocket->deleteLater(); + m_webSocket = nullptr; + } + if (m_tcpSocket) + { + m_tcpSocket->deleteLater(); + m_tcpSocket = nullptr; + } } // Clear input buffer when settings change that invalidate the data in it @@ -620,7 +659,7 @@ void RemoteTCPInputTCPHandler::applySettings(const RemoteTCPInputSettings& setti { //disconnectFromHost(); cleanup(); - connectToHost(settings.m_dataAddress, settings.m_dataPort); + connectToHost(settings.m_dataAddress, settings.m_dataPort, settings.m_protocol); } if (force) { @@ -651,35 +690,6 @@ static void flacErrorCallback(const FLAC__StreamDecoder *decoder, FLAC__StreamDe return handler->flacError(decoder, status); } -/*FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes) -{ - if (m_dataSocket) - { - qint64 bytesRequested = *bytes; - qint64 bytesRead = std::min(bytesRequested, m_compressedData.size()); - - //bytesRead = m_dataSocket->read((char *) buffer, bytesRequested); - - memcpy(buffer, m_compressedData.constData(), bytesRead); - - qDebug() << "flacRead" << bytesRequested << bytesRead; - - if (bytesRead != -1) - { - *bytes = (size_t) bytesRead; - return FLAC__STREAM_DECODER_READ_STATUS_CONTINUE; - } - else - { - return FLAC__STREAM_DECODER_READ_STATUS_ABORT; - } - } - else - { - return FLAC__STREAM_DECODER_READ_STATUS_ABORT; - } -}*/ - FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes) { (void) decoder; @@ -690,7 +700,6 @@ FLAC__StreamDecoderReadStatus RemoteTCPInputTCPHandler::flacRead(const FLAC__Str memcpy(buffer, m_compressedData.constData(), bytesRead); m_compressedData.remove(0, bytesRead); - //qDebug() << "RemoteTCPInputTCPHandler::flacRead bytesRequested" << bytesRequested << "bytesRead" << bytesRead; if (bytesRead == 0) { qDebug() << "RemoteTCPInputTCPHandler::flacRead: Decoder will hang if we can't return data"; @@ -821,7 +830,6 @@ FLAC__StreamDecoderWriteStatus RemoteTCPInputTCPHandler::flacWrite(const FLAC__S m_uncompressedFrames++; int nbSamples = frame->header.blocksize; -//qDebug() << "RemoteTCPInputTCPHandler::flacWrite m_uncompressedFrames" << m_uncompressedFrames << "nbSamples" << nbSamples; if (nbSamples > (int) m_converterBufferNbSamples) { if (m_converterBuffer) { @@ -1011,20 +1019,13 @@ void RemoteTCPInputTCPHandler::connected() } // Start calls to processData m_timer.start(); - - /*if (m_dataSocket->bytesAvailable()) { - qDebug() << "Data is already available"; - dataReadyRead(); - } else { - qDebug() << "No data available"; - }*/ } void RemoteTCPInputTCPHandler::reconnect() { QMutexLocker mutexLocker(&m_mutex); if (!m_dataSocket) { - connectToHost(m_settings.m_dataAddress, m_settings.m_dataPort); + connectToHost(m_settings.m_dataAddress, m_settings.m_dataPort, m_settings.m_protocol); } } @@ -1071,6 +1072,12 @@ void RemoteTCPInputTCPHandler::errorOccurred(QAbstractSocket::SocketError socket } } +void RemoteTCPInputTCPHandler::sslErrors(const QList &errors) +{ + qDebug() << "RemoteTCPInputTCPHandler::sslErrors: " << errors; + m_webSocket->ignoreSslErrors(); // FIXME: Add a setting whether to do this? +} + void RemoteTCPInputTCPHandler::dataReadyRead() { QMutexLocker mutexLocker(&m_mutex); @@ -1753,7 +1760,6 @@ void RemoteTCPInputTCPHandler::processCommands() m_compressedData.resize(s + m_commandLength); qint64 bytesRead = m_dataSocket->read(&m_compressedData.data()[s], m_commandLength); m_compressedFrames++; - //qDebug() << "*************************** RemoteTCPProtocol::dataIQFLAC m_compressedData.size()" << m_compressedData.size() << "m_compressedFrames" << m_compressedFrames << "m_uncompressedFrames" << m_uncompressedFrames; if (bytesRead == m_commandLength) { // FLAC encoder writes out 4 (fLaC), 38 (STREAMINFO), 51 (?) byte headers, that are transmitted as one command block, @@ -1940,7 +1946,7 @@ void RemoteTCPInputTCPHandler::processData() { QMutexLocker mutexLocker(&m_mutex); - if (m_dataSocket && (m_dataSocket->state() == QAbstractSocket::ConnectedState)) + if (m_dataSocket && m_dataSocket->isConnected()) { int sampleRate = m_settings.m_channelSampleRate; int bytesPerIQPair = m_iqOnly ? (2 * m_settings.m_sampleBits / 8) : (2 * sizeof(Sample)); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h index f1b990ca21..192873f434 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -31,6 +32,7 @@ #include "util/messagequeue.h" #include "util/movingaverage.h" +#include "util/socket.h" #include "dsp/replaybuffer.h" #include "remotetcpinputsettings.h" #include "../../channelrx/remotetcpsink/remotetcpprotocol.h" @@ -185,6 +187,7 @@ public slots: void connected(); void disconnected(); void errorOccurred(QAbstractSocket::SocketError socketError); + void sslErrors(const QList &errors); private: @@ -200,7 +203,9 @@ public slots: DeviceAPI *m_deviceAPI; bool m_running; - QTcpSocket *m_dataSocket; + Socket *m_dataSocket; + QTcpSocket *m_tcpSocket; + QWebSocket *m_webSocket; char *m_tcpBuf; SampleSinkFifo *m_sampleFifo; ReplayBuffer *m_replayBuffer; @@ -253,7 +258,7 @@ public slots: MovingAverageUtil m_movingAverage; bool handleMessage(const Message& message); - void connectToHost(const QString& address, quint16 port); + void connectToHost(const QString& address, quint16 port, const QString& protocol); //void disconnectFromHost(); void cleanup(); void clearBuffer(); diff --git a/sdrbase/CMakeLists.txt b/sdrbase/CMakeLists.txt index bb69899956..bc66651f6b 100644 --- a/sdrbase/CMakeLists.txt +++ b/sdrbase/CMakeLists.txt @@ -269,6 +269,7 @@ set(sdrbase_SOURCES util/sdrangelserverlist.cpp util/simpleserializer.cpp util/serialutil.cpp + util/socket.cpp util/solardynamicsobservatory.cpp util/sondehub.cpp #util/spinlock.cpp @@ -530,6 +531,7 @@ set(sdrbase_HEADERS util/sdrangelserverlist.h util/simpleserializer.h util/serialutil.h + util/socket.h util/solardynamicsobservatory.h util/sondehub.h #util/spinlock.h diff --git a/sdrbase/util/sdrangelserverlist.cpp b/sdrbase/util/sdrangelserverlist.cpp index 7a3bf89335..eed5e9cd6d 100644 --- a/sdrbase/util/sdrangelserverlist.cpp +++ b/sdrbase/util/sdrangelserverlist.cpp @@ -114,6 +114,9 @@ void SDRangelServerList::handleJSON(const QString& url, const QByteArray& bytes) if (serverObj.contains(QStringLiteral("port"))) { sdr.m_port = serverObj.value(QStringLiteral("port")).toInt(); } + if (serverObj.contains(QStringLiteral("protocol"))) { + sdr.m_protocol = serverObj.value(QStringLiteral("protocol")).toString(); + } if (serverObj.contains(QStringLiteral("minFrequency"))) { sdr.m_minFrequency = serverObj.value(QStringLiteral("minFrequency")).toInt(); } diff --git a/sdrbase/util/sdrangelserverlist.h b/sdrbase/util/sdrangelserverlist.h index e563e13385..9cb08b483d 100644 --- a/sdrbase/util/sdrangelserverlist.h +++ b/sdrbase/util/sdrangelserverlist.h @@ -37,6 +37,7 @@ class SDRBASE_API SDRangelServerList : public QObject struct SDRangelServer { QString m_address; quint16 m_port; + QString m_protocol; qint64 m_minFrequency; qint64 m_maxFrequency; int m_maxSampleRate; diff --git a/plugins/channelrx/remotetcpsink/socket.cpp b/sdrbase/util/socket.cpp similarity index 85% rename from plugins/channelrx/remotetcpsink/socket.cpp rename to sdrbase/util/socket.cpp index 694f2503fe..300a04e6f0 100644 --- a/plugins/channelrx/remotetcpsink/socket.cpp +++ b/sdrbase/util/socket.cpp @@ -49,6 +49,13 @@ qint64 TCPSocket::read(char *data, qint64 length) return socket->read(data, length); } +QByteArray TCPSocket::readAll() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->readAll(); +} + void TCPSocket::close() { QTcpSocket *socket = qobject_cast(m_socket); @@ -84,6 +91,13 @@ quint16 TCPSocket::peerPort() return socket->peerPort(); } +bool TCPSocket::isConnected() +{ + QTcpSocket *socket = qobject_cast(m_socket); + + return socket->state() == QAbstractSocket::ConnectedState; +} + WebSocket::WebSocket(QWebSocket *socket) : Socket(socket) { @@ -126,11 +140,23 @@ qint64 WebSocket::read(char *data, qint64 length) return length; } +QByteArray WebSocket::readAll() +{ + QByteArray b = m_rxBuffer; + + m_rxBuffer.clear(); + + return b; +} + void WebSocket::close() { QWebSocket *socket = qobject_cast(m_socket); - socket->close(); + // Will crash if we call close on unopened socket + if (socket->state() != QAbstractSocket::UnconnectedState) { + socket->close(); + } } qint64 WebSocket::bytesAvailable() @@ -158,3 +184,10 @@ quint16 WebSocket::peerPort() return socket->peerPort(); } + +bool WebSocket::isConnected() +{ + QWebSocket *socket = qobject_cast(m_socket); + + return socket->state() == QAbstractSocket::ConnectedState; +} diff --git a/plugins/channelrx/remotetcpsink/socket.h b/sdrbase/util/socket.h similarity index 88% rename from plugins/channelrx/remotetcpsink/socket.h rename to sdrbase/util/socket.h index 48ddc35408..4e15b6d8bc 100644 --- a/plugins/channelrx/remotetcpsink/socket.h +++ b/sdrbase/util/socket.h @@ -21,8 +21,10 @@ #include #include +#include "export.h" + // Class to allow easy use of either QTCPSocket or QWebSocket -class Socket : public QObject { +class SDRBASE_API Socket : public QObject { Q_OBJECT protected: Socket(QObject *socket, QObject *parent=nullptr); @@ -33,9 +35,11 @@ class Socket : public QObject { virtual void flush() = 0; virtual qint64 read(char *data, qint64 length) = 0; virtual qint64 bytesAvailable() = 0; + virtual QByteArray readAll() = 0; virtual void close() = 0; virtual QHostAddress peerAddress() = 0; virtual quint16 peerPort() = 0; + virtual bool isConnected() = 0; QObject *socket() { return m_socket; } @@ -45,7 +49,7 @@ class Socket : public QObject { }; -class TCPSocket : public Socket { +class SDRBASE_API TCPSocket : public Socket { Q_OBJECT public: @@ -55,13 +59,15 @@ class TCPSocket : public Socket { void flush() override; qint64 read(char *data, qint64 length) override; qint64 bytesAvailable() override; + QByteArray readAll() override; void close() override; QHostAddress peerAddress() override; quint16 peerPort() override; + bool isConnected() override; }; -class WebSocket : public Socket { +class SDRBASE_API WebSocket : public Socket { Q_OBJECT public: @@ -71,9 +77,11 @@ class WebSocket : public Socket { void flush() override; qint64 read(char *data, qint64 length) override; qint64 bytesAvailable() override; + QByteArray readAll() override; void close() override; QHostAddress peerAddress() override; quint16 peerPort() override; + bool isConnected() override; private slots: From 256b01dda4e7e40ae1ed76f06423160c2d3a1496 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 17:30:24 +0100 Subject: [PATCH 09/20] Fix gcc errors. --- plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp index fdecae7bad..ad91f26d19 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp @@ -18,7 +18,6 @@ /////////////////////////////////////////////////////////////////////////////////// #include -#include #include #include "device/deviceapi.h" From 7ed73e6272a069d730e48f5a53e3634a20e6c466 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 18:15:52 +0100 Subject: [PATCH 10/20] Map: Fix Kiwi list. Add SDRangel wss support. --- plugins/feature/map/mapgui.cpp | 201 +++++-------------------- plugins/feature/map/mapgui.h | 8 +- sdrbase/channel/channelwebapiutils.cpp | 85 +++++++++++ sdrbase/channel/channelwebapiutils.h | 24 +++ sdrbase/util/kiwisdrlist.cpp | 5 + 5 files changed, 157 insertions(+), 166 deletions(-) diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index 5c28b261c5..e6b67525ec 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -40,6 +40,7 @@ #include "gui/dialogpositioner.h" #include "device/deviceset.h" #include "device/deviceapi.h" +#include "channel/channelwebapiutils.h" #include "dsp/devicesamplesource.h" #include "device/deviceenumerator.h" #include "util/units.h" @@ -55,8 +56,6 @@ #include "ui_mapgui.h" #include "map.h" #include "mapgui.h" -#include "SWGMapItem.h" -#include "SWGDeviceSettings.h" #include "SWGKiwiSDRSettings.h" #include "SWGRemoteTCPInputSettings.h" @@ -310,7 +309,6 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur connect(profile, &QWebEngineProfile::downloadRequested, this, &MapGUI::downloadRequested); #endif -qDebug() << "Get station position"; // Get station position float stationLatitude = MainCore::instance()->getSettings().getLatitude(); float stationLongitude = MainCore::instance()->getSettings().getLongitude(); @@ -322,7 +320,6 @@ qDebug() << "Get station position"; m_polygonMapFilter.setPosition(stationPosition); m_polylineMapFilter.setPosition(stationPosition); -qDebug() << "Centre map"; // Centre map at My Position QQuickItem *item = ui->map->rootObject(); QObject *object = item->findChild("map"); @@ -334,7 +331,6 @@ qDebug() << "Centre map"; object->setProperty("center", QVariant::fromValue(coords)); } -qDebug() << "Creating antenna"; // Create antenna at My Position m_antennaMapItem.setName(new QString("Station")); m_antennaMapItem.setLatitude(stationLatitude); @@ -770,7 +766,7 @@ void MapGUI::sdrangelServerUpdated(const QList 0) { text.append(QString("\nTime limit: %1 mins").arg(sdr.m_timeLimit)); } - QString url = QString("sdrangel-server://%1").arg(address); + QString url; + if (sdr.m_protocol == "SDRangel wss") { + url = QString("sdrangel-wss-server://%1").arg(address); + } else { + url = QString("sdrangel-server://%1").arg(address); + } QString link = QString("%2").arg(url).arg(address); text.append(QString("\nURL: %1").arg(link)); sdrangelServerMapItem.setText(new QString(text)); @@ -803,7 +805,7 @@ void MapGUI::sdrangelServerUpdated(const QList 3000000000) { bands.append("SHF"); } - QString label = QString("SDRangel %1").arg(bands.join(" ")); + QString label = QString("%1 %2").arg(sdr.m_protocol).arg(bands.join(" ")); sdrangelServerMapItem.setLabel(new QString(label)); sdrangelServerMapItem.setLabelAltitudeOffset(4.5); sdrangelServerMapItem.setAltitudeReference(1); @@ -2720,120 +2722,29 @@ void MapGUI::linkClicked(const QString& url) QString spyServerURL = url.mid(21); openSpyServer(spyServerURL); } - else if (url.startsWith("sdrangel-server://")) + else if (url.startsWith("sdrangel-wss-server://")) { - QString sdrangelServerURL = url.mid(18); - openSDRangelServer(sdrangelServerURL); + QString sdrangelServerURL = url.mid(22); + openSDRangelServer(sdrangelServerURL, true); } -} - -bool MapGUI::openKiwiSDRInput() -{ - // 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) + else if (url.startsWith("sdrangel-server://")) { - qCritical() << "MapGUI::openKiwiSDR: Failed to find KiwiSDR"; - return false; + QString sdrangelServerURL = url.mid(18); + openSDRangelServer(sdrangelServerURL, false); } - - // Move to same workspace - //getWorkspaceIndex(); - - return true; } // Open a KiwiSDR RX device void MapGUI::openKiwiSDR(const QString& url) { m_remoteDeviceAddress = url; - connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); - if (!openKiwiSDRInput()) { - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); - } -} - -void MapGUI::kiwiSDRDeviceSetAdded(int index, DeviceAPI *device) -{ - (void) index; - - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::kiwiSDRDeviceSetAdded); - - // FIXME: Doesn't work if we do it immediately. Settings overwritten? - QTimer::singleShot(200, [=] { - // Set address setting - QStringList deviceSettingsKeys = {"serverAddress"}; - SWGSDRangel::SWGDeviceSettings response; - response.init(); - SWGSDRangel::SWGKiwiSDRSettings *deviceSettings = response.getKiwiSdrSettings(); - deviceSettings->setServerAddress(new QString(m_remoteDeviceAddress)); - QString errorMessage; - device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); - }); -} - -bool MapGUI::openRemoteTCPInput() -{ - // 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); + QStringList deviceSettingsKeys = {"serverAddress"}; + SWGSDRangel::SWGDeviceSettings *response = new SWGSDRangel::SWGDeviceSettings(); + response->init(); + SWGSDRangel::SWGKiwiSDRSettings *deviceSettings = response->getKiwiSdrSettings(); + deviceSettings->setServerAddress(new QString(m_remoteDeviceAddress)); - 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::openRemoteTCPInput: Failed to find RemoteTCPInput"; - return false; - } - - // Move to same workspace - //getWorkspaceIndex(); - - return true; + ChannelWebAPIUtils::addDevice("KiwiSDR", 0, deviceSettingsKeys, response); } // Open a RemoteTCPInput device to use for SpyServer @@ -2842,66 +2753,36 @@ void MapGUI::openSpyServer(const QString& url) QStringList address = url.split(":"); m_remoteDeviceAddress = address[0]; m_remoteDevicePort = address[1].toInt(); - connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); - if (!openRemoteTCPInput()) { - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); - } -} - -void MapGUI::spyServerDeviceSetAdded(int index, DeviceAPI *device) -{ - (void) index; - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::spyServerDeviceSetAdded); + QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; + SWGSDRangel::SWGDeviceSettings *response = new SWGSDRangel::SWGDeviceSettings(); + response->init(); + SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response->getRemoteTcpInputSettings(); + deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); + deviceSettings->setDataPort(m_remoteDevicePort); + deviceSettings->setProtocol(new QString("Spy Server")); + deviceSettings->setOverrideRemoteSettings(false); - // FIXME: Doesn't work if we do it immediately. Settings overwritten? - QTimer::singleShot(200, [=] { - // Set address/port setting - QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; - SWGSDRangel::SWGDeviceSettings response; - response.init(); - SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response.getRemoteTcpInputSettings(); - deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); - deviceSettings->setDataPort(m_remoteDevicePort); - deviceSettings->setProtocol(new QString("Spy Server")); - deviceSettings->setOverrideRemoteSettings(false); - QString errorMessage; - device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); - }); + ChannelWebAPIUtils::addDevice("RemoteTCPInput", 0, deviceSettingsKeys, response); } // Open a RemoteTCPInput device to use for SDRangel -void MapGUI::openSDRangelServer(const QString& url) +void MapGUI::openSDRangelServer(const QString& url, bool wss) { QStringList address = url.split(":"); m_remoteDeviceAddress = address[0]; m_remoteDevicePort = address[1].toInt(); - connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); - if (!openRemoteTCPInput()) { - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); - } -} -void MapGUI::sdrangelServerDeviceSetAdded(int index, DeviceAPI *device) -{ - (void) index; - - disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &MapGUI::sdrangelServerDeviceSetAdded); + QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; + SWGSDRangel::SWGDeviceSettings *response = new SWGSDRangel::SWGDeviceSettings(); + response->init(); + SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response->getRemoteTcpInputSettings(); + deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); + deviceSettings->setDataPort(m_remoteDevicePort); + deviceSettings->setProtocol(new QString(wss ? "SDRangel wss" : "SDRangel")); + deviceSettings->setOverrideRemoteSettings(false); - // FIXME: Doesn't work if we do it immediately. Settings overwritten? - QTimer::singleShot(200, [=] { - // Set address/port setting - QStringList deviceSettingsKeys = {"dataAddress", "dataPort", "protocol", "overrideRemoteSettings"}; - SWGSDRangel::SWGDeviceSettings response; - response.init(); - SWGSDRangel::SWGRemoteTCPInputSettings *deviceSettings = response.getRemoteTcpInputSettings(); - deviceSettings->setDataAddress(new QString(m_remoteDeviceAddress)); - deviceSettings->setDataPort(m_remoteDevicePort); - deviceSettings->setProtocol(new QString("SDRangel")); - deviceSettings->setOverrideRemoteSettings(false); - QString errorMessage; - device->getSampleSource()->webapiSettingsPutPatch(false, deviceSettingsKeys, response, errorMessage); - }); + ChannelWebAPIUtils::addDevice("RemoteTCPInput", 0, deviceSettingsKeys, response); } #ifdef QT_WEBENGINE_FOUND diff --git a/plugins/feature/map/mapgui.h b/plugins/feature/map/mapgui.h index 4188698eb8..dfe20cb6f2 100644 --- a/plugins/feature/map/mapgui.h +++ b/plugins/feature/map/mapgui.h @@ -52,6 +52,7 @@ #include "availablechannelorfeaturehandler.h" #include "SWGMapItem.h" +#include "SWGDeviceSettings.h" #include "mapsettings.h" #include "mapbeacondialog.h" @@ -290,11 +291,9 @@ class MapGUI : public FeatureGUI { void applyNASAGlobalImagerySettings(); void createNASAGlobalImageryView(); void displayNASAMetaData(); - bool openKiwiSDRInput(); - bool openRemoteTCPInput(); void openKiwiSDR(const QString& url); void openSpyServer(const QString& url); - void openSDRangelServer(const QString& url); + void openSDRangelServer(const QString& url, bool wss); QString formatFrequency(qint64 frequency) const; void updateGIRO(const QDateTime& mapDateTime); @@ -371,11 +370,8 @@ private slots: void airportsUpdated(); void waypointsUpdated(); void kiwiSDRUpdated(const QList& sdrs); - void kiwiSDRDeviceSetAdded(int index, DeviceAPI *device); void spyServerUpdated(const QList& sdrs); - void spyServerDeviceSetAdded(int index, DeviceAPI *device); void sdrangelServerUpdated(const QList& sdrs); - void sdrangelServerDeviceSetAdded(int index, DeviceAPI *device); void linkClicked(const QString& url); }; diff --git a/sdrbase/channel/channelwebapiutils.cpp b/sdrbase/channel/channelwebapiutils.cpp index f78f06841c..27e489c660 100644 --- a/sdrbase/channel/channelwebapiutils.cpp +++ b/sdrbase/channel/channelwebapiutils.cpp @@ -33,6 +33,7 @@ #include "maincore.h" #include "device/deviceset.h" #include "device/deviceapi.h" +#include "device/deviceenumerator.h" #include "channel/channelapi.h" #include "channel/channelutils.h" #include "dsp/devicesamplesource.h" @@ -1953,3 +1954,87 @@ bool ChannelWebAPIUtils::addChannel(unsigned int deviceSetIndex, const QString& return false; } } + +// response will be deleted after device is opened. +bool ChannelWebAPIUtils::addDevice(const QString hwType, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response) +{ + return DeviceOpener::open(hwType, direction, settingsKeys, response); +} + +DeviceOpener::DeviceOpener(int deviceIndex, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response) : + m_deviceIndex(deviceIndex), + m_direction(direction), + m_settingsKeys(settingsKeys), + m_response(response), + m_device(nullptr) +{ + connect(MainCore::instance(), &MainCore::deviceSetAdded, this, &DeviceOpener::deviceSetAdded); + // Create DeviceSet + MainCore *mainCore = MainCore::instance(); + m_deviceSetIndex = mainCore->getDeviceSets().size(); + MainCore::MsgAddDeviceSet *msg = MainCore::MsgAddDeviceSet::create(m_direction); + mainCore->getMainMessageQueue()->push(msg); +} + +void DeviceOpener::deviceSetAdded(int index, DeviceAPI *device) +{ + if (index == m_deviceSetIndex) + { + disconnect(MainCore::instance(), &MainCore::deviceSetAdded, this, &DeviceOpener::deviceSetAdded); + + m_device = device; + // Set the correct device type + MainCore::MsgSetDevice *msg = MainCore::MsgSetDevice::create(m_deviceSetIndex, m_deviceIndex, m_direction); + MainCore::instance()->getMainMessageQueue()->push(msg); + // Wait until device has initialised - FIXME: Better way to do this other than polling? + m_timer.setInterval(250); + connect(&m_timer, &QTimer::timeout, this, &DeviceOpener::checkInitialised); + m_timer.start(); + } +} + +void DeviceOpener::checkInitialised() +{ + if (m_device && m_device->getSampleSource() && (m_device->state() >= DeviceAPI::EngineState::StIdle)) + { + m_timer.stop(); + + QString errorMessage; + if (200 != m_device->getSampleSource()->webapiSettingsPutPatch(false, m_settingsKeys, *m_response, errorMessage)) { + qDebug() << "DeviceOpener::checkInitialised: webapiSettingsPutPatch failed: " << errorMessage; + } + + delete m_response; + delete this; + } +} + +bool DeviceOpener::open(const QString hwType, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response) +{ + if (direction) { + return false; // FIXME: Only RX support for now + } + + int nbSamplingDevices = DeviceEnumerator::instance()->getNbRxSamplingDevices(); + bool found = false; + + for (int i = 0; i < nbSamplingDevices; i++) + { + const PluginInterface::SamplingDevice *samplingDevice; + + samplingDevice = DeviceEnumerator::instance()->getRxSamplingDevice(i); + + if (!hwType.isEmpty() && (hwType != samplingDevice->hardwareId)) { + continue; + } + + new DeviceOpener(i, direction, settingsKeys, response); + + return true; + } + if (!found) + { + qCritical() << "DeviceOpener::open: Failed to find device with hwType " << hwType; + return false; + } +} diff --git a/sdrbase/channel/channelwebapiutils.h b/sdrbase/channel/channelwebapiutils.h index 1bfc7e8087..be11eb02bd 100644 --- a/sdrbase/channel/channelwebapiutils.h +++ b/sdrbase/channel/channelwebapiutils.h @@ -23,6 +23,7 @@ #include #include +#include #include "SWGDeviceSettings.h" #include "SWGDeviceReport.h" @@ -36,6 +37,28 @@ class DeviceSet; class Feature; class ChannelAPI; +class DeviceAPI; + +// Use ChannelWebAPIUtils::addDevice rather than this directly +class DeviceOpener : public QObject { + Q_OBJECT +protected: + DeviceOpener(int deviceIndex, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response); +private: + int m_deviceIndex; + int m_direction; + int m_deviceSetIndex; + QStringList m_settingsKeys; + SWGSDRangel::SWGDeviceSettings *m_response; + DeviceAPI *m_device; + QTimer m_timer; + +private slots: + void deviceSetAdded(int index, DeviceAPI *device); + void checkInitialised(); +public: + static bool open(const QString hwType, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response); +}; class SDRBASE_API ChannelWebAPIUtils { @@ -103,6 +126,7 @@ class SDRBASE_API ChannelWebAPIUtils static bool getChannelSettings(ChannelAPI *channel, SWGSDRangel::SWGChannelSettings &channelSettingsResponse); static bool getChannelReport(unsigned int deviceIndex, unsigned int channelIndex, SWGSDRangel::SWGChannelReport &channelReport); static bool addChannel(unsigned int deviceSetIndex, const QString& uri, int direction); + static bool addDevice(const QString hwType, int direction, const QStringList& settingsKeys, SWGSDRangel::SWGDeviceSettings *response); protected: static QString getDeviceHardwareId(unsigned int deviceIndex); }; diff --git a/sdrbase/util/kiwisdrlist.cpp b/sdrbase/util/kiwisdrlist.cpp index 0a01e562b7..695349aca4 100644 --- a/sdrbase/util/kiwisdrlist.cpp +++ b/sdrbase/util/kiwisdrlist.cpp @@ -97,6 +97,11 @@ void KiwiSDRList::handleHTML(const QString& url, const QByteArray& bytes) QList sdrs; QString html(bytes); + + // Strip nested divs, as the following div regexp can't handle them + QRegularExpression divName("
(.*?)<\\/div>", QRegularExpression::DotMatchesEverythingOption); + html.replace(divName, "\\1"); + QRegularExpression div("
(.*?)<\\/div>", QRegularExpression::DotMatchesEverythingOption); QRegularExpressionMatchIterator divItr = div.globalMatch(html); From 9372efe6d254b1645c39a4a3c18160fa1f89d021 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 18:56:17 +0100 Subject: [PATCH 11/20] RemoteTCP: Update docs. --- doc/img/RemoteTCPInput_plugin.png | Bin 24916 -> 60721 bytes doc/img/RemoteTCPSink.png | Bin 15473 -> 34409 bytes doc/img/RemoteTCPSink_settings.png | Bin 0 -> 55064 bytes plugins/channelrx/remotetcpsink/readme.md | 91 ++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 doc/img/RemoteTCPSink_settings.png diff --git a/doc/img/RemoteTCPInput_plugin.png b/doc/img/RemoteTCPInput_plugin.png index e9b32009737a1c19c08df09221d4687be02f1373..5a41c2269c2e8ca893c864efda482415149ee1b8 100644 GIT binary patch literal 60721 zcmafbRX|+LvMz)KcXtm2cMb0D?k*ug2lwFa?t^=9g1ZJM5ZuEI1RsLK9sYCnJ?HL+ zyB}srtzNyltE;Q3zpk1nH5FMjWMX6(7#K8pIVlYo7}zlwm^W33@K8#R`!pT&1nZ_D zD*;nCO$vtIytNfq7KedpNI-cse+Rusa+cF~gMq;Yy#8P}Z7958VE&BAONncFnf&QS z3ep}dT?`L8pb zpTcb>-M+_*l_zv*fe9T;x>j?QF_lB?sDtcJS?VEc_V#n$9o|#83ad{i`*qo6u#4;Y zpJlnAkCk3%g!k~x=lH(7v>ndXGy=J)hmvfK+e z);v-6l$x5F_S=2If0KNhTU#}JeLEk2EF-F@nueHXo({UJI4|;GFNp!o<`)+1*V|oE z)6(82q$DT*`85VUI!b=$`uylE=<$b;hmUX4xhGt-+Z~OR3w1&G{IhG`UILhSA)MC- z)oNn7zBo|NG)cak75$rPl&MxfE-voaSJ?6K@r`8h5cs*-BFW08rW97AcFOBZo*(lh z%k2Pg^5P`B62y6fNH2GJjLbR7k@-`})+x#9u{inNE|zz{m-X-LEN;sY)QPdNM0`=q z@87?3d!3uz-R%y?Xc-zZi6XpVNh&L&TWzvU5JEpBPQv~}e4B*1_3PJ%Kw7`)>FKh$ zV%deUBpPc!V`H+Pi;H7tU)=-+1xv{D?0OOAi{4*fj8d#o1%ttj18C5u+z@hF$<5ko z6Cp=(3Z2&&7#NfknsCl7T*F3Q^U$G+IQ*qNw)wd-zL%o@AR}K4`_&uY^lm~IG->+G z$zt4*8jeOHbJlegPi9H?{=GDDq-MFw_?~ke7DInsFvX!;!9(t2aG6q;MBAnhlmBn7 zf}-QP&Ul;YT=@)R4U5s9!@(G%2e_c&U#;Z z{zPENlq`!)e%>95N-Zd0yNZ*6nZ^hYK;sL93kTPil$O5p)0smo;BmlbF=&o-?7EQY zc)mZj-f+7+$XK|&*ejir>JGXemv>sub1X8IyrIOI7KET2L*r_8q@ zDpFqg*_4kh~8pBZbt)LWS7B`t9zpf5Bn>j0dZ#DwuE#-W|Vm|j3ULb@rO9q)JL$~W;1yo>0fq9xpNPv zat@hs&;pFxsIz@oG$0NT2DrO%)qv#Fj$>9v zR@SoW`W^#fS%^B5*#|`h1-p9)n3KGghVZLo1o{fYzzgi`+0)8QfDSR+)1xz@2ikh5 zuCaUC=%mJWcrcX=AI(4!+%}hi$84~puC+6Dcaqs>^#G^t>vYn>kr3zYKG+o&vPii) z*pR@(JkNPq3V$i5-lx{bFRQDob~mRRoIT&ZsflEIfglb?%ZRC-;pPLQ_LLVl*$#C{ z?)3SCo?HGIOSOiwfltS#)~fT z^zwKG=|1yMcz!;6$$!3}cu67;xHZ2&dwF(BI7FsFD^56rRb^S^QTlpyK>&}65lW7) zl$$v(cCH4jsj8~l9}IVl{qaLdRfwFNP?=i@az%+c*q-3i!yfPXaMCupS#A1sqzn1M zH6u3|*?}+(|F!@t+y}uv5&v*|-E4+&Kuy$~VT8n~OZ)}e02R?Eu&V#Qt>^>M(=I`8 zp>e0jIG}1&X}LNEM;Lts%XqM2OWFOS)CzLR-Lz-V9svdiR#9+Qg2|I_5pz(N;GhaV zuHmTdX;T7?@hbt_ZX|TF=knan{!3Vmsz12#)n1i6nK zF?W!gHXnS~gxIn(vRKCW`gb>Q_swPs3m-t-X{*OLyhuFwF>_iQ$Ue*G3*^K{UKSTfVxqEPF|y_O=fZ*0X3;H-Ll*U*5CL3So)2U(Y#qzb z%W>+-3b-Aln1{m>XVC5QK=)eHtEl8>i7rCFqz+-yt3 zmo?ti4)^LuW|j0Nm%PII=a;A6m&2^A7>7O-$%0-a)NhvSxEpPt329xVSVl+U^+b?F zuzGmKDeh2F=k5ekeJc5TgA_Z`zC_tK9f5OteUfjs@%GF$H-%!kC-62n7*X>(L~}r$ z)M)cy*M}+#pRTsJ{SqWCyFk<_;ttgp^sNor0n)9L9v&SHLKE|S^71pEJ<8qO#?dFV zjk2>(_9g2o)gTiej?a5!By`G%y@1SLA@}epzuAc}Wsu3Kk1$CG34tirEz2e;Z!oaU zEBF%O?akMnQORcU(CH-K2CLYT-BqgkEBI8+lGQzjAYk01&IPgggB9niL${lzO`k6b z?(1Lf*&aUdpSm~X-@`v0ivXi&>KovgRp%Vo>x&9lafx0=qagK5epwZ>D1@S^M;c)n z?LOC^?uw7?DG?=eFKJ!Sr`^jmLFNxPXXP(<1usc5iY&E#u80Q=@Hk3DOYY*hx2URE zwb^kbeDf))A`G3>0npj7z!rF?=&jsbmwPB_Mr!JJA#I2=o>j|k!e|ma1H!%=Am|Y* zc!rdh?u596Vhw}@9S?leKHRy2+C3{iM|Jh2+`g+QRX>R%F+TVaQ{`nuTK3jV%u+hJ zlZ{T@cyGTwlC8wAF1m!yMsTC1FZas_EsJ+Wzpjc4P_8tTaClI*A?1Xozcli&unB8n#?!tDVh?s4>s;Y*Cmb3gjZkJ{MUGv$qs5D)YgLAdY@{^j^BmP*^|z>AsTKk z77a!D!fSX%Y-K3`KoCQZpp-TcN#JexZQxB`Mqeh0(4g;Gx2}o~Y{jDTh&s~l;w{;9 z|Myu~Hsw-v&|y)sEXq6hoa_oSJ!8wTpYiC1mF1;;>wowgtC`HY0pP>VLZvacQG_xWhF-7|@o{MbP{HWR=N{`47Vg4}qqbv11w*y&_F^oy0@V3X1@ zNCz7g(Pn8g@a5?e9*JPR59Fx*foA_L-xgFaGc0So@rtth0OIP)72`9A@gkgkC+Pe4 zGg$oC#Nop%qjUyXxA$Iq<}9?q(9blsXjfcHVAtTBlcNdMJag(dMr`Kq;a``#Xe6QL z9W%TzlVBWn#;sY#U13pbes$}DSth{7c}%LKYgRi5I2=kw zsLV_+P7J8t?jJNgyzAi~0Wbc_J5BqM`Qe8F)y4y`zDSIqz$uJ8cqeHuW*XuPQWNlD zRVK^eva@K|D6KdU_ZSrH_$rB`@AJYTM@1D&^8rJf@F1egx(4yrm&u`z)3!>YI&aR- z3!cFRR%gUMNwbDw9mp>C0!K|awtkdI@ID3G?A!GWwVD~MbnFd?SB$A5DVfeiG1m+} zkot%)-8)8UJ~kd#vx-@317qVa5pUVpjdA%21ZNTIBA9Xy!>|D^z-rviZO|kuz~xQ7 zR+uF-cJ2E^f#A2I%_303SW;p)*3fj=vDb3um; z;<`w5JAH#!X1C^qY2!63{;${wmkSCnMZa4Fyl}Wzt~+kL zx+t&{+wH9u8-W<5cBPdeDtCjg zGas(|<8+&t2}cAcWS@B!(blEz`&nl*Qj=xgp*i+!%nkNktlJ1?62jSXfp|ezQ6eK> zmxcF!Bdog2Ce`KvWV^eohQ_3yD{O>&gm?}v%-RU_eZD||P@H>};5S`(u>-%GAyVwZves&{UMzo}y z%V-ma1>|EZWpW%$GRTEAAZn6IL47YBnKo2n_)K6NKJjIUe&x9s$2LX+0mje01W{I! zcX~CQ7Q@Ki=<~%%uucmAG_FrXgWV9#HJG~Z^`SkT@&&V3{CM>pd36b^$-lTbI8d~~ zsa#s!8>PM`D`IJ`SrNj99#j7LEzw7$0!_TTjwTLM`<|058$HWx;1SR0MCGkg(Lf2~ z7wL`+ojE=s(`Q*ooc4f}PtP$uXr`?mTSl`X!ek}rJp*BiJ2Qg052=u*oGx zxk%hP*S6?!2HcBOw>6_wEdRJP#*8XO<;w}~5Ptehs}NtLjzOM8XDKyLMic*QZ2=sL zYbFs(vM%)b>|BD#lu+LdJR{d;*IA!n*{|aiK0Bds)6&Op!VsKAkP&3iK+A{(TIc84 zv&um&v7g#VqzfGid&xElgxDZwa9-)h%k>#++&i)|fn<=7rKDsf$EVVqUP<{(yyl~`$!*;%+RoGGIngVGwXt-+cMvV!uoq9oOfLK zEAi#N`S`{h?#e0fVQpRyXh>|lnK@Nu3bz#lCfjc$`mBXHVnspM(;si<;~&K5UZ{)K zwAFAxvc$KmAEF+L^)~YfohHm}N5DcxaLMN7H(zhkq=Z>DDkSat;NFMM-jrpjwyJT5#V_xAjzwRtMAta*?|HEA-F43kVUS5J zt!`tCDbGuD<$CQ9ih{oYG$pglDJw+|t_u$ETL*+7 zl%pg}`5=98Bn2p^r=^Xb?hMBeC=MbPmzD_+@(*O18L5L&uZ7vg;eDKO#4$#!cb@v) z2AK^A&DKAR;h-L%@vT$RgFj#pK-rY%hZ%o$Zn#}<(nH5C6XU22>hEASqCk6-8Ws>I z7XUY6To!O7-Vw|@o5E&@hm+E77OPs1F`F16F2#oB<8uDkFV-h!K?pxX?o+vKUo_p< znuR#}*<-Oq4&}{HfeKr5tvQl#`1b6BdX%M)17c8<=ALz>OckwmO&mlul8c(+@^(U} zFotN&L~yP?Y1Zkg4wvt5(8G4Lo~Hbmm^al=`q>g z_PS*Fw`~Teaf9h5-*&5 zp((&0=#C)VF7D!H!Hl<2GzK?;wZY)Uj$7s+CW=ZED{vWZ{eD_ES|B(hcKjpzz-)u} zmCeiu>^;vfvA^V>DId_i${_bJGlEAl+;SZ7kt2NRraJ;MWI=ll_1!wGaX}H~bkl|X zv9_9671QFy#Fkmk_|A^(P!oCN!d;^b&#Bd2re^Ym(i!a+49-0d04~efxW+E2Z&Lg{ zMP%>OT5p;tt61R!Bxs9pQY6ihLl9ovGy^SRr zVZCOB$2=ltkJpR-DiQz+Vr{0?CSXM6Gzd61`Powe;vN3$hP=RY9<(F$DD8lGma1ha z`|b@NzmV=9y3podwX(HMi}n4+HK+^zv&zI3CLsh7BT#TUmMUK~2#{HeOcTB_WTuoC zs#))zk>3-(Ih^ebkA2F+sH1fh+=bH2uBA&Wg#Gi99d-sa95z35m5V#qK}w=zSV^g=?~@!xOUOaN@lz7R=&+pY{<`5fJO8#@UFS0;QQ1SE z7`(5}9HwkuRGiBBBWy{CZiJxL4Kdz@o~KO66)`NDRaeM`z+=$4&bMZmQFijVH#BfG z!KWgG>g$L>^*gFg#&Dykg8&Sn9HYZq%Z`!eqBtm)4vQ=mH}@T><4()^-$8VncspK_ zvD44bQ?E#V)bwn62ss>hRR;DIqF=}`gByRP!~YWFgq~tyc@tmhmO*H=(9W8<4i{Ou|NFW)abY&iNV6 z`|%bJuq#Eh;l6CWi=a@xxM+1Wf-Wl45z6w}>8vD$J_mf$`w}@4yFweg@9XjT1s49Y zBWrI8Lw{Z@TQrbc8UIV_bgRw%c5xHn+>gGcoOU5n{kxW+J)(8wR_Qf_$l&Ti+O;P^ zN*nj!P_WWVuW^ef+0E7d?jVa>id!jr=I7jRe`UaL4T4g1x#C@U8cI4;?D83Z=sQ5GB9=k<=ofII!0)0c=FvW~j1{;n5qVpt60QE0yB)wS=w ztZ+Ob5RQd4p@6(nZt!#k$BFa-R|op3IEBIUK0U#_C!b2(;17L2hqrt`+u=IV;-voR z97jv6!=3C%Dk>)QgH8~vG)%PW3`xG-$AC!ku^&fJ5wbAaHhCOj<4tWYK+=s@_|@-= zNZv9Q+s$3)07jetq%43gS7_`6Yd(v0RP_92{vx!lz~T`}4On4 zk$ZNj^BlmmKc|oWfJ*LHWmfN>iG12b#+~zZgBaFXkjg~MtP2*!J3aG&%T_C%YB@T( z9?hO={!i~#d#-SwN`ZN-SZW!b06+$x>UJo^~n9L{|toL+&9s% z)i2}u^z650KmfYHrY2a>-rj4@MfmATw3kL`Y4l|F8BS=7q%IfagF;r34%yrzuwVVv z&vEglkYq?h6cD0!lo00FXYCX|DZ)N0O6w%(nis0vqNt^RnJ7yJlajtqLL(e>>jU-pN}@Z zgDR@V^p%*IPfraO)sQ}|(zN~VnLXLtTS_s5Zq8QJfoLK&merhj>hLX&QMIV0kJx{z zXp{#78_4BKJ9oWI58d*4zW@Qta|0^ay$610O{360W$6JH#0quYmxnr-KBV7Y@Gadd zUu!jV$jRb^AUJSs`wPGcWmnuABk$k4i~{sUs)-&H==J`ub56dSEDcQ zn#)NlX+OVQz}`Z+@t-J~hjJsYo#po@GQ&fw{hj zefT?7!y=lz)f2L2E(x~cMxxYG{VP#vX0Hl?XySN~zmzkTZ#_|JWyQug@%dCdn;etx zefqLm=I(l;-cdMhV?Y?gG+I=tqa5|IE|iE_b{I7f!mU~rEcMk|ZOVta0q0e-E3siN zaLCb5O-ZGt88=%i(s$o~qR$g{SbArUF~1%SP_*zy`+qC?U$vY2q4(Q|uriw%en}r=3GiuM=pw~lqwGp*>@`b{^f2&qc!%cS zkZ{_C*hgFgesw0_hc;yl$=-i-L9bM5+k}&ud{MIa?<=Z)h~(~5kF8HSqOjb86|v%U z!5MI&ytAUJD(3liROS9|=3}V&pAxsjj*8kTKNa@=mV2%;gdv(rd2W3z=cWjHb)D=R zStW)3Y3XrpLm}q{PSitt>IISkDmVoCg*54Lt=u#z@aknXuFJ;GUUz>1hkBxgG8 zgOW=kmB2Wlc9tVR4jXM2QeO66eG`P+;o-+={jM_hAH`KAI$~WvQ6>hOourc{-0Vsk z2e=bj!$Dug0{YbxWhfQTr+m=%z1KY1`upjkGXec#Eqh=ix$i#g0^Q-o3B;b~6s2U2 z7(qMWPmx@jF#mfhVP4}Q5lmj^oyfyu4B3szgZybCYxego_uGqLFs2>IBod&`8bQaU zkRpL@AglQ7In#d;S(Fc~#YMyIde9M4ES7zryaqPr^KwRo`bUzg6a65hVzj+zc#a2x6`4s!Z?&3hD7hy^3z2-xfhE=Kzw1H?_sZFvL~)ilrGGp-8%xk zfa^C(GrQRcL@2NG3R5n;f|M&!OlwG1`rS!}%;U!Zld8ht5NQM3E|xxAkr7^)+T9}I zjRT4V10Xz4m|3RRZr%lFT%2jHU1>t=_3}^pxp%f*iGS(T1$Np9K79qheli#`+VRH? ze*8m~lN^xF&TWV(af;d@QI+V5G3~I~ zqAtY<7|nJ=Y`xytP8jbPsp#`VaS-zM|M6KZb!b!_DD?KfnBC$_FHM*$?`;fF zHYZ5KM`1VuR*zyIpbRE|7SzQletz3=MgwaZU0vrNa%%jDc=v1M9R^AFUl1DR3(wxariGRGe`FSB_6PbDGkb@Muu2eH= znosK5N$9xN#JoV@H#%EdbqCBR@-q{mF%ORc3%P86;Ov*>cQQgUV*AG0M%}8hlP0s9 z_D|Z&97Quiaj9Ko^R%3IR!IFys-@g=p9cNts709?6w$jHZBv`d29#(^X}3T$YJJ0EOiX? zxdJ&#AH#yV^Vw(%9W!XZ1rWWbu4O%i<6(g4SR;2+?dMsJ0~Sx8NmEy(HJg+U4c|Nt zT7QE|Ey2g6KHU2An5MyC(_B^$VzX$Bf_Kf7FUmewpf^74hD{oZpdMTDvUdZ8n;Tn3 zvN_+-tXwXR7gxR~1YB45&i6h}%J@L$&^WL_BRtM5Moqo*D=YSitg1{+lZ5rO0KsjF zQvs%uXEQMFwDXMpu23((u|x@6nRDAvjp&l~mOTB@XADU%QZ5;f+1XSTMOoc)J0XwR zbq?dyQ2oZ_zJF{|YAUM&J`n`B0mGPE&FG`D(F}m~A1M{P?_|a7Hq4_v$Rh@7vg8fy zweEhK?f}w7p|9*Ut>}}b7Ufl=oMYTPTDSymMIZ=`%7T(QDT?_Bgh;Q4vXdWrRGB~m z9c`i=@x10FW2vdU*&KTq&31Ep!qn~fbt|U*GDvh67V)~f@146$+fiH2V-|*0;FKXy2vyind3m~`oI%BDZ>Q4%Owur-hiO3wfo$97b`Kg`Q}El z*!{0vFTz>I9mBfG4t{UnsNkhao{?tiQg5LtuYa~xVdOYi$wMA0SbsjAPRrzqi9c+<}N4ia_OeFcIm(iF=vTLwmA!)+~qIdd1| znmr|AIc|MI&4Q~h97i7N9i^t7fnoTT#6)|js)Y|C7E7WMHzJ;?_(G)PoC(++XDFEs zT=_aRu1deXe~VzP1)9MTmGe`ezb80R@JXx)Q@+rUdS}bhiJeyAb*pS|Rl{WjYW1xQ z4Uo;H)Bo#=!0hA5ImDE^V@%z>$j10poFSfHdC^B0kY~&}1ZSI>gDn@RG-;BOj$#Z^ zcc=f-(mGWZ?Y)@#{pM|&K(J!YCmU^f-gs!@^}b=P9FM!UrdPPp`#LSHEc4v^)uhrXcc`Jl3W5otfK zN)e7`mAmW5pSs9nh@pU7wokH4r5}<~Llii`#9!`>)B)3bKCLn%*lAR>#by#+VRsTm z9Y^pnL}YCGoHdE!^U6C{gpa$akqTB7##ld*B+V7zl>U)K4weUJDZ<}L9xDYX8oYg? z)A5+Mqu#?!vK3V57SdUJ>xp9b3!xW%77ywnk9(P5+`9xUV~qgr=w^bvi&hxTTYDFDi-pEyJ3wRLW6%%UiIgjl zv_TRUR9Wp%EX$Yw;b<-r_?0$7v?Wb6Ff|@lzCblUk`)~5zH*4FS~j1I%g7`Lqzb=P zr#A5^&kzLfOTTzU4zQ^ZqE5o90F#K~`QKCPocvx`a*QN^F5F+I*kHu_g~hpXS2nE$ z{}8U2@U=LcxkLdH^F3Pi*H%Y4!L1~CmW{%hXOAzftg0X!*F;#~f^f5`7>~4km&~i| z#P@h)2GtdtJ?D4a$N~=7f1fSNxncW4T#YZdRlqGrjNSx)dA4+B3#kC3`4Sxjf z=KFki}j`L@! zNtS<>k@1j=Eh9an>$ccLMyyTOe#xJBQLFQMOJ&LHcxh-5#1W-PER?tpRqw4gc@k{4FsOy{c+$J3< z0%RU}@-UFR&@R|dMORgTb!8>oa~5n0#hgVC@GfyoSIG>|L^vB?}=@|~je+w=iS7j#IetxXr${*)8&Tz)QFMrEgp!e`VQ`bjqlJ7|K_y3qOl zkDH4lF{lKVosaZFU`URQZ>`mGS&nWEZ6yqcS)12U(*BuHGk*YL@pLE#GVr`E1- z_i{zNje;x15G2S;O6W(#q#>B>;v277W4OPHtuF!3qA`#Uqj`SEX3#quMw9<7UcX_) zs$HNly|Gl-CpY=#+ypYGQssk_Ma%&h7){cWmZG>;a>wG!E8xJlQ5!S7)$ETTHEpIZ z!b{?{;a90+`{B;TV~cMt1ZFQ`%Js&lTpCy(!WU{G=@k{KC}f}TnPN|eW5^&EJ32l$ zHRm5t4;i3~SwS}U+3Aql=8fr2+1^69J14txGU{jlo!WO?Z_56wzLhxWn)b7xUsP3g z^MefKB)PZfq5{R+@sCieN)_e2#U-pL2FGB_efLApK()HyqrzT2OHe~PgB9I(WL??-j6WpUv0 z*IfxCvL2xE^qtq|K@FvLYe6XxzcC;XrQ6@A`72y(F9`dR7|2Nt;l7$HW#{ZtImJIOCKuhoey3FHSD5LoTkmBBv)GjLm9BXK9FblD|NEnv8{4SE0 zI8OMo!iTJCF`9(GN;x5wKDCLzsrE+ z48I*d33WA_mj6l=m2V z_58wN@nq9_kSZFp?Yh#$2-Paia?}A2wE29r)Em3_6e6LZ zuJgXw3-~CxKwNCa{{n=P99eyJ!@%AHe2Iz_r4c(=b?B`pY6996Eu!#+LV!1%rHJiZ z{uEHV{eQt1Dd0zcJ;pfs)5H`ZsI*5(1#&g?fCheZf<&S$j?IC z^TCTUmuqDm^T|ABmwaoidl&!G)2)kZcCR-O|Cykl zb3{DnQFeBmX^`mnAF#m`t!LpQA3II*6Oj~^z0n9l3M-#bXb|n}d^Mf=)Vt49yR6=l zHDm&!A|AnI2_(DsJcX}&57&}>k)_XiAb6v{Htm1l;GRN8m5QGpZWcgu#V2ZX_^4k5 zjRl#ZX6nz$7 zAvCJdOTBcTl^+6io-BOi{u68*VcRf5l{xGK4f$S^?Uz?@EPni9L!3STKL3Y!bFVq( z%Gt1cSg<`orI#EDniBOV`{|OWsmr_*);Vb+FhK=nw{Z_feGfwfW9@i2l6cL82*dY0 znX)!DM}vqDo(`@uvbM-PEhP&L=~~Cb{TpUtpP1sv*A)a9^1MX9QOaxeRRUVQG4nnt zUUUsGnIs)>wP(qof(o@%pEWB?QfDr7JTEEgZ-i$o;)L&+b)cLWgT`{7s6x@)o^Qdi zEU){C8U4*qzkmOZ97%^qB^0x_w@*!_-JlCMw}7b+vC1@gJ96=OcQwBqM=Fp!5=TN$ zQXXve=T`%_->vf}C#P~0f|_9v>fjKR%cu*3eQu;eUhKvmE7sih^R)JF&1vD}0|G>p zvpA*8My=vnTi+5_SZBVa6C-e7ozid3MXp+!T_tPr$jn+7@SLyZYC56GIhtzrS-^S- zGADf3p_P-JZMRsVKF`0jyd1$J=>pC6;kFvX^f~Lk0nL{xUUGT1a4NmspDwm;o2E-> z`SeTvc&SHK1?5nKfGHLvJix!S(E3w#;Zs6Q(Z))Q{lNrD#V?g{_Si6DH>n#&RI(1` zxdUPa14BdM!;5VG@TgQIExK5m6O+9L?KNVXg<5NzKVDqwzKQ9FhK!L#317^_Z;1u(=$G*mB~zG9i;txm=H^rf2M6ksk|AU`+2(`cbI|;V%DTFE`eSxs zg-@RtOUujGWRc`9(3)L!^6Ez`K1Y5d>c~Q|#CP}rM`SiiKye?}C%}4}jYA}LvhsXAQHYENQ$rY{e zsLhf@biCYc?z8oqswsQXmLsuIV`3J-=?IDdxwyDEZo64Z-bAH@pof~$Qt9}dV)OLK zOfufd--(R`ysU(BO%@biiRsred2Vg=h=+5@IDdeO6!IkP2?jpHtrQ-Jd*^NHNPzrI zKGzhp%{Y|Tr`kmR7FG=~vrrryMrZV8)Sgy~8xcEQZBbOY9G{-Hfg6Vq5;Cb;vCV&` zWcird#Plur&rkgtv}^aj^l^WRsMmPW+_jtnA<2>gn{Rg`nnXQ97J~=)%Oca&eYsE+ zKbH=6F2~!~aHM`nPD#<%^HI8Yuw+(%V?fQYyS4FUNfQ;2WH8KZyN5B(xp;Gw+p2gn zpn#5LT9Fy{zBO6<0}~11$JjtwMt+RLrf-lEP+U{bpxVTRl0DBtbqXNYxaM9!=IOq@ ztO$iKn{}zIOh)0fLn=k4bS`L7>+|s#J4f+}2EvKJCfzch#UwkxI1HsjPxKx{BLE*| zO;OQKNVimI9=@_fL`39@?=CUllmZC)vftQ}RhlfeBgh!{LQ7`-gBi{~v!pn-P7xm4 zsLM-z69yH>VKG~lP_8B{c2VqfrAfA~cwb&nN%iZ+XeduS_9T9Vd?7i>zVp1;FZlVU z4vEq4?HWGr@BRiWkHx)VeyU@>a_$%7R8lQ!jS0CSj0z6r_%StAnjreDR~PkTV!{Ht zg^y*v0@w0}Juwe?P;7+Qno<(5b5Hm&T;7j89S#E%zOKDpFCe@hg?9u_GLqqMCUfvI zS*0a(*6)e9aK)sV-;=O^(o+ndnrPUj2uCM@DSY5g>~kCPKId(CKu-|mRGR67Pd4`< zZ28sE)nydVaJtc%@%LnnKntB*NKQ6^Jk2tJ5W|9y?k^-cxTciMp#Yr|R<7jNg;4G9 zQ|&U0H)m{ITnh_d7~2XQoG?B_$_nPD%&z%!D1GfGryYb~pVOM3n|+W(nr}E9V;4pt zkqd_=3v#@E5}GrJN|@oDE99;5Yz@81>_K08&m>V7k}De6^Emk$y5OJZx#>Onj7vcB z?)6sbvH!m>5dSTvfpVLW_OIU%E4deeo={Mp)SGVC(zeU&oOPB6WcQlfoj<3(Q+v0fdtGG8@$A9efN28SFG7-M<`*;1GYV=XH zw_xRM0f}$=SCyLOet(Pq)OK0qu;W6Yd29y>)9k1zh(nFjq5XqvHfFR9NTRWYtJEC% zf1*tSX-tpay`d}#4m~Ik@@`y*!_GGp3c^R&d<=x6Rwp~V(n1g1E=I!`+c~^^| zUAs`OIaVSZ%FQLc2NfrjU}*^lKsyMzCo!Ro?Vzr)CRc;WAT-Xspd3@A^z7ohtXc>B z6to}=m&dixw`J@;C(j#-F-B^8M*l>)7iad*xF99uS8ypW%Qbfh76xlGV*nDRch1ER?!1_$^&nH$y*r=@|obH08OK+_Q495j9sgQ_bGymT*~5;0j#h z;aI*&;=AGPSnovYh$tJ(u|I~rqO1Yurcm@qQ7ryKfbHvJy^p&&+dAWur?)b>sw+`7 zQGx6*t&JaYa{9&kn=JqmTfc0`?kIIal~6id*Y)+a2O?A${BTtAvIM-5($cMd$wN#47)~9FLi>vyeeMxlOT2Y%H4x&X1?M%qN);RvP}^S6?VnZhjx6{%%4HK6{uL8i3Ck z;de=MCDM_EUWC~)36IPd{6@s{l=M}wvBRB@J=t{fM1D)13mV80jV&$d^y=3*uBI^) zjz-1xw89ro7c9!Hwf-p<6VSQ#WQSX+<&gX8c|@=&_Q2Px5|MprH@)7Ff8G@}z@P7x zU=navk*+g#;c0(dBr}oe^-g(uaR4q7NmG!bVId0cSPwU!`>P?}{4+dDUgF+aE#4hf zHoq;S^`{-W2CeKXXnrqFJ^yi|nni(iED9~15;zyT$9TMi!;&-1ttBD;;{rl}0 z#ckAOsTozuukqqCGv2F#YQL^HEbW=mo66=&I0h)m77gE;OrLGZvGp>?YJ0_{IC6O? zk_Pe7%KL=!3v zo_X(xHvG6u_R=dnVdL~M9Cgh-Z%Mp4%fDtRg&Tjcj}V5)vnzpxsn=*F_FcLU z(rs<-%jTqqtfNW!^7)2{Ld^;3=GcE)2M>u15)#P0F;}RL$(tZNwA4kt7;Sw?GVzfg zLl_M!&6Usu<}L$4CIn1?hcNL*yNQwpDV|XfH0ro)4puD?)?v_PO!^7YTU+N5jY}N$p!zQ#t|H#RO!T)iYHHL zGKh1@4yA^N0;u4LHK)SytWogOiR=kGcKVZX)p)LiwW3X;s_JojHTZ8MgA~GMk-spv zd_;V=^4YT0{C*>eeO2+A=X#o_+KN?D-p$`g59HSfu?NCG3QMR4t`B5@1O8(*+x0YE z^?Ya7c+VzmjCSxB?iQz{86g9r)6AE}_5|K9>ylKBH2v|{EiEmNycqwQCM(=0 z_ob=2IPE5$oEAffg0B0B&;oZgA%KlKtC_;%$uf;He^AkHV}l3B7JoCDpIf%mxk+cW zTA?lKeW6G=`-kVq?0-MNMM-!PkDVU!6#Ql@P|ZSkmLL&9KZA$)VAcgld5v_7l2Bbd zV^4%Sa{=(2P#s&<8$vWjo;yrDqi0!?m12u13(bWLH}4RLX(@kCSO!UwyIu<{dWy3I zM;{av9Zc32xrB2E|4SMi6)2VLeKo&g0r0EuNicwy77_^wCP#KNrU9~ST`=AuRdM!@ zVXva2=AO|c;-;xo;cUCQnXLAfJCTfx%vE#63)WjaUkRdP5^@j)EoTyComUCTJ{sAq zs#Xrw3?y-jZWH0-;ZZj*Acp#wlj2L8xcjfKkXr$MlaM zKT@48UK88>rDI;J04{Hh=~Biv>2X{k(p%DL=;+{=mX%GMZvwl!^QH?#*=$L6CJTe` zx|4K`FhoUc#LTnV13p77n-nF~vOxy;cZFjrnm9gcdBiXQxG?_;s@OI|}2*{S|L zR>nur}p6fSSH>zb@$Jc@O9^T=_Q??vQx1)3m&1N|V; zh9u#ANy?GD5=~QXE)U+7U)Tx$0aG~ARQj7jl_+_Ia$O*8(O$tpOl{eg_KA|EsJ ziZhh;Vcwb8r6;(xzP6n1BW^4!Q=$DH+x+H>pya0{<-)FZP1e)>_Zs&W-*M4$m_(a` ztulw`S?R%MGa`t84YlVRtCOaq+Od}7ON3Ot=%%pKHny+gnw@krRDD{`3N#ZEXxARv z>4OoVZ3!fO`+w_J0-u$_X!b z0o*2q#`U~&?=IBAFg-rhgSiAbUr9S`p2Z$un&&f{)lrb?c!t@*VMwPj`b?@%=Hq{(A39O-EgZv$HrD%e<15a6V4?ty|*xtDOF7? zSb@5=|CFzqtm@niXN3mWVY?_Iun$X9JAz@#+U15=$og;zqS{MCpgyWsxrLh>mvOr@ z71aUsuNE%<4`FW|5Y_swfhs6S3rKf&qjV_U-67o}Ff^!0cXxMpr%HDW-O>yVjx>1J zxc5GH|IWGR+&>kVS@nJEdEY1A)x^xqIUfXUr>(bc<|fdP{cfaAJtoee2}Y zMTZ%$1oR(4q~qaiz8q{~YP5fj@ZmZBKvWbuA)j-B&kaZarhjLu$l7NWJ8SD$Hn*VK znfG2~^YNfICWn31lub&^X3!Hged^65bfd6v@fGk>d8gPAJ%0Qm5x-U$Y~aG zB*P&@bk67@a}k0`RZ>zC5*H7j{=J1G#>%&a;{We+o?@V8Q3(zO1tEMr8SzL4f+JEL zJ)+o?{EXnOU;f`$q~*$(yuGarOI3YsLj{4-0hM}1pQjhi08n=Pk0bv4*R}0|^4!X9 zu`gVOv={3&4(DT%sQK^$dMaAtDUQBinD(msfBUa)_6M9|siYFD2;j_$hNXsb;$Ee7 zP;11$8s;qUe>RhtDlZ|-A22Harbq+0a^6(@O94~EdlNkzBS7RMcbKZ-0&l$LfjA{TzQERxbutS_tlEgPhe}&e4JhP#0 zRQFvs`+3L^l^i#cLp#Q^L+Wd=v1by|q+TwJZ6R)6#pwB;kT z#w0P6*sv~(ANQ2txUSCnEBiOSn-@Jv??q=Cs>8=;Jedlbrp2uTWfFv9N=_np1X=3+ z6*aPpr=Bkgd-dy6Y_htk{0bJ&bkhdY_inLMcBu?H1T?P=C~mY+0?bm~+#WhoaAEPuK`@0_i0D z?k5@1x$=%8Qare?!H!pG7)NirdDr;svzjVDrfDA5Rc2YRX3&woXG3eBzb=ZcOC_{m zEjD+7Citl&#KLx9MEED&6^DgeQ-~pej9zf+S1GQ z4}qk(k=Kxyo@4kxrDQ4IBAv~|=q#k48{DeO%DO{Ccs@NPHB7_J9yMZZ(PQy-&s?v^ zPPasaskKi%W>r)paW3cus;SD37F~5YW1gPYnwWW?TihD0Eh9^tIj8x4b+?Q%9R8p_ z7-FK zHjtuoee|$M%4fO-DdXd>x3Q}xd-5LlRc9MD9-oiZ0Bcxz^yy{22**fGjPm8TL)@iA4cD}R zd=*t?0)Fm!k1p2lFO0}-OS}B&y?6Dq;i4muxV?+m>wp%D(+3|$N`}0=xc1+?J@%Nt zKM&naNBheJUGN4p=L9oBYpi-pP01NQggWv^lQSBqLv>TS)5kNNo#@!6Le-?+GbG2% z%*9%9f=3r~nesNf;cQA6n+fy!czC^B<|ZA|6q+xtsZG_|O%XNJp5iRwyooTizEspL zK1p>IU}#t8ieryz##Nc+Kh7F9BzB8_Z!|vrcy$%_djJ=|DV|PASyq<^*V}A!S=j5EU;Cahj?zuFphAykz)_8Ps>|D_ zp_~ti{3E49etta<)1vLoHbTCQr_)t7dH3(iRTA~I#TRD<2K9vWuGFc2HBMvyjQ=n# z!LV?0GMNiMvvTyiCQHCQsjQr7CrYHq#T7|1MyKLZ%&J+mDffc}in#DhR8sjVJ~e7N zPSa5Jr#I}61X`F+k+pHYQmr6gp#6-;%v9%=D6psHzpVY_704y1dOlC#Pi&l~T`Tk?HAL%VZWyG&t&iduMcD1MzJeYEWGqodHNK(pxf)G)PhqGJVY*) zLaSL3ofYyDXI6KjzD}pQmCJfh5Nl z4ClSsU2{@~Ww;YZZhikf*(q%F_J^YE>t5HT_3~d|9C;_BGvjn8CHmCFDMxL5o5OWc ztzV6wN%tuQXYM`S6u1dkru@Vd%O<3RoDF)h?*QA{t$LM$>=392C)2TwC8fR=?JKjnQzoYAnBQO#gk?)0-p!5dsmOrLMT43Vj~2z0reMNT;Lc ztw=ba9K@#I{U(NVctoO{=UH7J(VBL6xAiQAebb&@7!mtI7&fBthGvXoN?Zhij5EO# zW8ZZ4sLCMjzuuMyR-HzNbKM>95-dK^{n>X^SRH_U$LV07X$MR)%p}pHW}*;NUWGd8 zYvY}hk`Qa4Jc{j#FLRQ?w`PdwT|snQAv!bis6CP%f%`zf9xlKAB0SOvc*NlEO3TW+ z9R|~;`6PU!MN`lUgxZI=ceVAKQZ5=7T`ic|;>6{6CR+-zX9?#EpPTyUy(2UVsb}n9 z<^R;pL%csA8#F3iG}RNj#3-=;tqR?gkmk>ux;S4&eUp;e{zI}OuXup*rP;Fed1vE? zz^tae$baoXxiuDfX#>O~vd1!fm-1QqHJxsQrwO8fE#TBy_ zsoJ~v!7a%g3QkZ=v*GjmS>U}=_M7N5T}SzPhkZQDxY!m(0upYHki{zlNr<=4d(drM z&z`GU&seK82lJhykm@;9ncMlkK_1L{JHOr#W1)~L>A4_6 zZ4D(~pwFoqpXY}VJ*S3cr;#otNVQKCj3mFm#4T-|>owX-U#+*mC~%@`-#=Qm2N53H z2pVUC>>g;cA-x{$cU|sEQ#0j~)?RVe0KiOet@V%Um^xA0NLA+lTumCu0dJL7@&N*+ zS!$m?dYkf-a!|eyNvn8L^g&-Bf^lhokizjfDKpzD8=jw>=lP`42u(hAJ=vsvVXH6d zbntfp96I@$j*}aLLkyD;fX;-=jQ`#H`$(G7EnQk z)%7gA^^&SwKbmzh)hFwG1TilWgZy}zrc=b?$!B*yBB_~2E>Z6=T*A6v75CzQdc-1m z3@<5>LN?8-Vq;vJTV(-V=X_dk?Iu_|cJ^yYz%zi6^!?Yu%B-k8P?pVpfBE6275#im zELy8EGN0ef3TZroHbSz9FN!QD3*K^bY+?B}g;sq{w+j5r7z zJq8w9ulN-?6CL=ba)!S2QM~<0NfUY@V!dNJZY~pa&HfX->KwbNYSU(uQuL~_$+O&;}Q74FpLD$Ab#8r;rR^f zOWkTdh2K`Of!pam(ud`Kfff2}E_MO9< z?|~GywOWyL{J-p#8ytVA4y335jq<4Z^O^YY0L2SXA^lG#Okm_k9$d9FE8&L-Sp?F2 zZkt*-?0`3f;=y_YDt(1cGv>ih4R^l-2LMVpF(xi9)S4PpF{Sq%RF+j#z|q9E;EIAA zovO6)R=tbdEh&XN@TM^4CGHB4klTY@Kx=C1k})z@NeGBqUtp#!7ADcuKCF zlfX?C3rcrTcmduFZsYD}!^6XQe(;Fko1c3SfCg!BKQ#ohU@9_UbPa^H*~~8YOPmh) zN5h-hrQFc(-y-yF9Qc(qg59UG({LAaeJB0paag{9!Kz+B;tyn8;56Yh$dct-U=s*FFN$m$pm4}f8oz?+#5T}2d<)<(o+h4k}%fGX^CmUu| zAu~v2>y#O+a|zY&rGD3RQN}A85Q{D@W|gg!t(>-{((>39&Dy>%&J=LhRt3a4a(L7C zD+o(EDwcF7I_zuLeQY0NVL=J}PMZNFgao{fCo8RB?8>sTIx+ZofjvcCen~E3K#(}DSak64+N^^cSZo;V43%*~QG@vj+uJp?2My6(k^Fk-DyrjV2W7uMtCDmn z?Cn=8s}0%7R{2B(sdv4FSJLJDysIL&q3Tq1eZ&!$U*bCKKO@+Y1a=pefVeIB(H)l7 zpQ+8n#mmBvYYva0$`=$AsaR^^&1P5T`r8M(m6QZ7PfSHhL_(K+d*EGbk(DfR&wR=P zomN1-==G((*>euq2sEE+m691y2Zs}{Jj@pbb0u=!YrWbrH)2z#5fSD1;^N}A12U|> zKg+}rxsPFOKjIXi#&1>Ej$sj$9J9sVsVV1Aw;0bdLgb|fu>S^!!>r)(1)wdU_=N81 ztz7A>VqRl6t}GzPyOWrAUmvU*)UDcEH>ldMGstb)uC0;z^Z84&34O)DM7LYl?hj@Y zMT^E!DnQ0dKNh^gAW|}rVX0zhND9+IP&};}$zMCqpPlEC7BlBO>I-}>j^^66TH5G3 zBf6MC#X>}g9L_)%`BKM1X<9Q)jO#U>@Jm&9S3dVzPSkjI5OQ^G{m$pJCrU_cr!i-^ z)juJUjhpL5eoksWZLGFx*ox z4R;Rqh7Or$Rn0YwwkR-zzMC4^j>ynXAWa1K(mru@T|Hf^Y-S;aN{CfXn*%l2N#8(76H=0WUM-2#HGeVqKi?gEX|rIRyOjaV$H z()iMtBkY-x;*yCk8nra%OAJ*G>sc}lDw=~n1QH(P^L$vK7~tceOsh_96R7rl9P-7DT^7ui4ivN z3)5Yy)zkpn0*1HP9^SQs>AqX;%4zHt)TU@6ppoe-&JFaC+JKGK%pLOha0i(wRcr7# zH)$PuFlGb zTBnn3+Q&8cew`XXSez|cER>jo$#o=&1$4eCVNL%2~kT*WYlmy^idn6dL_uxqK;>LrX?WDg^bA2?~Is$Lj?KpGF_zO#y62N;T z)Yoy8;HRf1EqM4q_otlictOp$xVXbLU%tSly|E`8OM5LBWO^g%eWU3u;DY zEC2c{6yd^(smo9H=PwTN0gD$qJL;K2>9}6EZ)Zo1+9`~Q4196zfWmWI+u26aP_IFT z(UTaskk>`N(AmpvVgb<}PUroP&%Nn8FJ3m{rJ*jP+GJ5eC;1?JvIp;LP>)`%J80RG z>?D^)t`$KY6v0Rx=y3-n?9=Iq0=SsQTB;F(Hh(34gklJp{E^M~3(*WLC zouY%TG1_e$4uXZm-brBj@O!;p&+gAAKAe@1FT86^^BisoK1sTg@98?^XyT6>W4(Pl z+O}_btc>I^Unj5Z#wRI0rg1Wgptm4k1=OQ+8uKs>=<$(y{CL{h%;+}z-GO%@97hs6 z-q8r^0AwV#a`4Rw98iaox$F!lUSzC4UN*bY!$1R4hmL2)7Y-@>uMdU7fE^!xuyeAQ&k4-M_kNC#Kyc+LU)_tY=1--s9SQ~gwmh2G~WU$0;TEp z(=H~uWg|~S+P#jS;{s4vP|nOEBojLjfa_5URz|-LW7B=7Ih{=h=yqq4zus?H}OE1M>+%(FmytT^%yvWJgOTQ z+q+h#-IeQKNSsnq?|bJ~R#5>c#ejX|bCU%;n6WgP$dyqlSO6Tcpdc=^DVu zx;t5wB;$tq2l)0sSB>YlI?3terrgOOiPO%2;7gJ<$md^Hy%GrcJBb8>efS3q($=^Wze+YzF`^lT_7EL~x zY$n18!!Azw?BhOwqnr6Y^wB2g*!Hk=2tAOr5dM0XK@e`Q zZ*;#7*h1ERdn1gF1YoMW9UD=8J>6Ey>Cm3<*j5WdRwIT2#Nh}ayGapNT2TPY$jIc* z%&050mDniNhY-k5Cz*Nm&_%uKcY0!Q-x5_e?mfNb6Y{l6v+PkDKlHrEdQRN&_Q;be z@Z7#Q-;rA-oUXD^3UW4!-Bsd}NUBC?5EmcA85&2j(S}}~1xn*p!X&UG8CKM1!3wH|Hg@ckVmJ?Pk;Vwd#Mch);pKpM96%8O6^{qwB_eE3o$Od;Fi9 zTBHbsY?D=5=`Xs>&pHZJAIEyE^oxAyk@UMf3Ah&g&G3sRu-*R$$hM)Hye3M?8Z z&t|tMEghYA|41HY1_q>x)j+DjYo=8;nn8g#?=VC&nqLdTQHUg!fDWvmM{)fbpCVa4 zLMO3gB7Sm*eJ3%*UNj-#!Fq^nVUGdsO;rd{+sfoqC$Z>1KZg}Xjx~OukpGY0wyCIq z(VjdY(Ur-qAoLI-FBs=dF!aw%r-ziX!R_mTvO&5$M$XUQ9R_;hD@(5uygt zuZJ57X=sRBKW9+q{T{0s5(*Ov51ap_pBJ_OzF7`X%C~Y`E($d`7k*_<&o2`bP*{9+ zw;<<40A3UAyR3buSrYWY(j0ZDgE_P&cy#DkJXvsK(+2#`NJ1r*f z#Tgz)KbE_vDuFj^7!WI8B+tS^?Obu!W~MsJQYaE>j?PQfkKW>5ISccl%D@t{V_q zf@(~kVOXG&TyhreKvULEE)3*8Qt5VxnRQSAe8F^nRaJ1{OQu%bBnm>bh4TA6b_*|F z@mI)ilH`asU*MU5)xybfgDjeUWHw?CkG&hNDAAzgC)p-(A#UU0eu%*r@tHY3fFNed+5 z&~^Wa%%;TRp__s*{`M%&@)I+QxzF$pbTsV8yU8l2CN|oat#=2v%}z|C4|vo1t8~d6 z7QU==Xl^NmTVVoLq>zL%e5#O3t7G}&TyDci@kM=`PG`{Mfn=hgi7&}(GrU-5%5W5v zf(=lI3o;>~z$ z=@D|$HDkDn;}O(7f2!8L=a%o6ipBGmuT6L}eb9Is-rgO)E<|n4H#XXY&CAf`=a4DW z;Ju3QwaNq7L$avrI0k2T)I*RJ(^J&+*yOV~1Db+E$w%5pX?YWD^y_lVR*EmwL9VVh zVb_)e*8@l@Qy({i*bU$^i{<`YtO@H4hFotDkxD%7nl43$Fo*!-FfZe~t{25eevmaK zKjSYQ{@{gva4G!eO#bkOsw-9|e`O(#Cu$NNmzeb>r}3GzM^yTK`^3X3#UNpvVqQEz zP*ya2E^C(9^#eLpy`9-b(`Y}p`m;LYE_u?Fm*7-H0M!|nT5CYOuh;1PT57r zVQ8q*LCnm|>@t}jfCrOo$W~a{*h2wwxHfz66+3Pauij?{LDf+4i5o6XJ~V<8B6R4M zk+X>$9#XoDSh1UG8P&K_G%Dtf`&*QH8g0V}Gd$&wVmKO>6GefmX^H%P!w#7&`U9^U zLJg#Xj5j~OKr48co$D3ASk8gYr|uEnjcQ|<2W1W9$lNmJeV7u$VNft|)5@h(2Dj)tGO@-Hj#p3gYnH1dxX9U(iL>PFQC$a~GmvXOf(8!*01|uW; zpcP}Us>M2Y#p_CVEd5~p&AVYZ%+bJ=IrAfg>i{e<2N?Kx`WCTNrBbkBN%3FDb~O(1 ztSAi(Qm3`>rb z?-`dY_VWNV24SI0FYq72L3_mJU>19EP`XA3w`Tsv{E+KCe2zEMKAeyq1LHZchx>$( zvYM%5nTJ6mi}{6&6@9?Ao8MpVVLO`2V#{WfoQ$X$zv=$_2@qt%q-|}3d{dk(%ENMbb zH)+RbTKrko_vAH2kODpM8uJP(xHwgr|9k!mp~%M$l9l>`-K=61I)o*uFf&3xSYvK* zltbm%AQuhBcDuzoUWNR!35uvSx$5)|D>8gNoHIgcdnroDQx`towJKRmbS#Pnyw^X= zOneKq=FX)L)n$T_AD^}`dD-07O5>v{zl!f2Pm12c?et{`he{fzOZEgxm3(T>+2OQ! zqR=ki5Dl$s=#lpv+8-_cFWVEa9=isBR6MA-4%%vmg=~RAMeW-wqYm&}so!8(%QjW3 zZm=J>!tfJ9?KN4lPYcG`~HwXlsR9vEoE-mLol1k`)e*#NxaWN2s2na4vPR^z{Z)JfxZ^q6VO0jL5$==LaqJXN~x zehxdeq8C5g_YYibi!u^`1`Bl>7)64invDu3aT3$AA`!PH5NC-z4&tp2n8VingH2#% z@h2))RO%0I5DCgZsviJa>@y10w#G6>rEbDL?+^Zf9R?YY5e3MY{u4AUi1PRTflw{E z@S8!TFldB8Bpv+Mzy2T^0BZvPkqCZqY6`P)KheV-1mVw{%^%nBf{Bd&4YEKm7)A%c zur$UUD?q>W-@j|qgv3lt>v^U~k`37nm(7$%T37{%kd$7NegoX=y8gzIuf-V6(}Qv9 zA_fG>(M6tI+e|4!8NNU1kDWSR!vP|&m&{6jFB0+co~rvSTMbXtVLZpX$^dfR4C|tq zyC=f6vef{%?J>;tqK$Q*0pE)ca8CgMGr!tj{^r&digC!q37P48nvhAd&(c&|2muq= zR9(5zdC??>B}W#AIwKOl6~iXsU)fA{5DjXitu3So1#=4cfFc>03Kp@|_D7qXy$*lz zGyblL8ZR)LEMN)(4;Dz43AznQMT7lJU9!H&)pUT+$8yU>xH5vW`4xyBJ%;0x^G53O z$46rL@~yo-hB0T!(H%a3epGX@?Tu7Avm;$_IjGZ-c;69cHu5M1XRT0}LV(_RLToHW zHLPgCllgzOKG7H$oC99voY;FchyWITFLgz})h(JO=m~!8?G=Er43H6kn)yRyc#xl5 z{-GOEJbL!uIw!+==hN>egQMQL)dJek_ka5^fWCc)>8H)V2n>#H(->H3^_iq(F7|*B>M#ypmN=h}8Wc+^eLuMz5=*DLq10bXTCHFqU^HaXqY~BMcNU zWI~uB09-%sZH-F?wV8BD>%9r1wXvq4qK(4d*xC=Xb4-E#xM0uud}>RE%-j&zO+}zB zeltv&Cn4EYODGN1sr0t@>}iLkEb;;zZ~4#a`oqkMakHh`_YJ zKl&WODUk61=kVCO0oqZ%douGHj1sJS49_;15Bh9%F+=4^AeiEi!I1(eH1p7xz7|Ja z@*0K@?O@$G69`%m0{u{VvSO_Pa>V!iBpQX~$~bFP!w6{_DJ1_?J-lodK;Z+Hl!rNk zd@~Hm{6S(XlRJOvi+>d+{}e7zw%c*)%oh^#n3bfiRbb%N5VilmtkeHi3<2loKn@J$ z(#PZhwf@-!{#D8Rv&}r9yom_{q$6lT|60|5?-`-MY#w~k=^}-ECEo)z=*`5ZtgibV zwrLXgmA8e3g)pC@0M4|*f+!(ScJM&|wYU6TEfqDtbr*^F(Sso(54eEncDA>b#l+x& zky^@HT6kkwLg`(bz*w_mjzXy@j<1>(n ziR^XWhXb?h6h~EW$Bc%3bG%LEE~M99RBcFzNM9wg^jlJV8@R?iFE7CLSNDHw>gr#>`ej8)8ufX zJ2*pMfcXV5$gC*3xq2L!cPY(^pgydEhqiK5pc8*_9XIMvx|YY50(B6>vVIl*4KL zWK6{4{Ae-3pfgpo`eT;c%#z>R4^5-_I3fn{@m6iueynqDQ(3f**Uz2wXC=7>c|tjV zLEqhj#2G$%=?{8$6;|Xqvdn<;0YoKNvhALp3PxtIl_9-bPc%D;fo9{bI z480IUb5=+i!?u`0Q#UALEIm$BKs|I#a*}t@QZuJy#057Hz%0|0XboeoC?wB^s^H4~A zPOg#|zh5F8cM6zb(|t*t>2vlS5!M0+l5M+4u%2;?%A%AksiEij)ZWSfWp-6$R6Y8l%Nn8Tn@nx2HpI`Bt~;+PZdkBl9GLGWvr;VC6t8R2 z!0iU?D9fj?zm1|nNQ=Q{4Mr!)>*x@aRZ3P;5=BO-E6wd17p}t`KuZwg8?4R_zTux@ zK3~>m@Mrf!4&>4>fILKE`Q;#h_ljO&T(dS5Ah*v)S7`A{)=uYMaP}`#s(D21P^hF^ z-FSC%*XC#Pn}@g&S6&~)2D5HUAP@)opn1LmO$_Xka7=ecwldO2$?abwKo^^I5GlnyWF7`VWmN!@DjXUwb|v)o#7! z&b8VJ520E&B_V@du)3mxxvc!>@3j^(E-o$x7KMh*?wqbJ!^gm`kkUZE229c%N7L>L z_J^3xu-l*P!3Xm<-uvf@FX17M=Oav07Yrww?>|{u7FQRzPc{aoMI6VGc3;5+!)p0nqM7I?E#nj+T)-@1LjbqzfflOAW)JogUzkP8rVwl9oLB3+I! z4QY~me1M5}#jq@W=bk6{BSsC4p>sLp`u>C=`tcgcq($&wIixqNSznna8i^qTsoemD zlW=o4ZRL`fZPIL8w)E37w#`dc%Q4o6Wfv#_yJ)E5Tyc(CcGFTVuo8y!2(C zzZaWyTRI-W7bkX>QK^b+l*+D+Z=Rz&o7GZS5{7LkwEhXZDE?*Mf#z+qg7i>RilXGkDI_pM( z0yD@4U&m=9V2xfgz%K!_D1S?eSDg_aM^BkX<5=RU7{lodK0)A|KM21hA}cMF{W`=S zo7+4%M#)GO%Ni4$(_30D^CqUZRX0TnLEF&!Lp7zB(*b~nSPTN(fAdc>5HM#y|84u^ zAq5c9H zG)lAJ_`h=s>vtdOM&XSj)OoOuHPG$xBDv-Vy>pBulDHS^qhVrGgKgdl-KI}yC%V&O zrX(y0eS6GHfJO0VpUZ{E=0psK3U8>2^P>7qI)O3wW_or^LjP=bSxD+`hjzv!jkWZ& zJ^@nA8ZFj0h2u1Mn(oEW;rx?2;dkjx8ZxVoBQGn~$E?Xawjx^|iLqwQ#SOk~)B(bn zn=dCTEBb_j9yKc=_dv`Bn0I$?PfbQ96p9{AB7h4GyjoZwX1hawy(l$zdK2EriM#%_ z;WMGzCvqa!$6sHsAM4>F8}C$M3e(asM(h$-EAALg5tkq>IlJ?nS&}YX=PX)V$E;V&F0M=|^bciVWeXAkWI;!YfMX|@ zSgO~9x=o+WppBaD;Ky38<)|XsHVHZjzxqi5jGq#qezC86$*|Rz(CHoN&PlL&+#hgt zr^i+2dzKD^FpK+1iWO_||7s~fb68$B4=V5}(Xj@#=KK3``0MSgyI*DJl@~=^OeOMB zMplu=iWf&Wg2#V);j8q;zTr2^Wb^}Qmz0YFsDa?Lq+`Z;HMwbavRRst{gX($x^aYiDnt z*neJ>?hr?m+>>%(6yBT2q%{os`f%(BB&y(x>7j#xakrqKX$%$^oJl3PzV3YmhxZW8 z+mTwpf#g8Wl4&tCOT}+}HBbqv>4V|59w{^AScKp5=S_p$QS7k$L>30!NqDEV%%6lz z#l9Ue>D$l~lQlv!2guZf(3-*l5H`QVgy zr5|PWEsLZKLYy!-(+?sHWm@Q?e>{j;TCeqPs2qL)F3B@+e6mOikfmX@%4}u8D3Zh;ABMD?T&7@D4rw32!7-n?^E=9&O~-!ECv?77I41 z@0@>x`nyBvJXDqJH;pNsSTwo60&xEn1=zY$zgh5tAZQ^+3OH0A^freKt4!X(SLp@EwR3p>$biN0qlcpMLT{2dSbbF+QHb-vVPd#r}*4 zY`y_Td&)-f8Mb`pTbFOBL9~7mt^N=B@~`3u&@w#9qL>qY1O}T5JWPu`#QXSh3ib_< zead(Q1{bD2o&}32o0}JAra{nwB52C}1kj+4{Hq`GuSy7?cFoGcp$Im1w=s6XG4WK* z9pVIUeb5yq`G1yW9;o0sOQ2Er|3O88NTv={_85g=Qq?LdU7^3PgbF`U&XRSs`G~mJ zru{D^QxN1mQ1A9qYVlB)RQdTsc5Nl6EvpF>lhL?)m$B5Po?)p+%Xt!r5by`cD{}QS zA(aJ>^f6;@2LcTqv1)wjBNF_|{lr9-`I=m4V5^Q{NMSNBVxg4{~( zPwW8l(ml7VzO$zW1Yk*Z&>ZOhs1|#X9R4;q<^|B&lK-7M;12%YK4K!FC@F+fR5)20 zByXd5^-*{VrPH)(o2D?Z$gFuXr-`DWzP0DBRi?{<(ym2+R6HJU4bWoVzOkVrncPCa zY8Pggr|bck(OcF|o4@#B!znbg=xCq`26VV6C+UR#v;UA0Z|oH-yxym~{4r&mOs+AS zlFl~cwduLO1RffbY?v+{pnfGM1U!mdzOUK(diH7ov!-FegnMmKLBZhsR}Y#@T2{6K z8YU@~EF}-d9eobPfav^Cst#jbGLyNT#E!$<=_`E$5XpDwGi=zU2uY`kw z^R=sCJ%*Z^CG-p;11VIH=K5a5i1@mjZl{7_C8?e%QIq7)&YzxD1fe~HH8{hYK& zS5U*6aKq4`ZTzR`k3N3=N@Sa}JMLW~V|RyESERNSBrtJsO7Y8Qqv2yUwn1t#S-wwf z2qCM@70vsH&&K2c6)kY@17zN_Bv1Jl|I)=PUwTC`cQ?N28wihp8UvW|B(ur%mJ<^T*#h? zhzJ5$Kf$IJR1nq!_g;j%&=)6sE8Z<^aq-^8{`C_?gH@w0Dq5Eb8HWOYq9e5D*+)nl~hzg zMMXslx3dXSgqNHgN~Vyv<{FVd0@^|1{Z)?)e=O2?Fi0!;Un_6QRlHmZAj<)cmZ~kv z>enA;J?(?|znUuaB%Mz?1OR7el*c(MV~O?)&=z3}?$>vt2Fg|JQ6E5)3o2LYdD4KV zI}#XF9TpZ=aD|WII0=bIz^X)txjO$x|K!geGo79jhrGj=xA~67XIg=03Yd%Hdpq&_ zUg;JPWWEKaGC#i8ZLq>bBjM-XzWN5l@b(-0@cZr}e)EQ#8=r{&oLNCZfpZ-Kfq=F9 z#o)ux%wGN{v;D7ch0<>5e*#`FP}s2Nq6wc}E_G#P^7dY33Z)F**e*Z;3WhZcZfV1Q%N=H{~>})wAy5KHQht5kRMS4~F_V zhtjdn1@1}g=bpY|M zz9&4+a`5>RTC8h1`oQaK2u zs)3`j3sq-iJXb*BfP_fOdl{9`DY@^`ga#QTGNLv)>W0X`HUULLO1$_iakk_WRed)M zmipw%mgg3DCKhIZt@?PmS!p~4^yNGs9ua+zyC)bUnM)%zYhXK;Cha<2&L!#j*J_u& z6&a@FF>Hoas%`AhVEY}}_Y5vQgt9$^`pu9fQigP+;tRT=Db8Rs!z6J6%9Cxkb8_$Q zzH?zrUTt;le1LVo2SqCWF+i2I?QdHV~L948zJ@Fl81hgrsZj4?v27DIa&piSGcmywcOCdKYp@RW$ zPXu+FE)*}y@620%@?e}qq(nqUnB8Q-Rc98x3ts5+UKpkC=T6J-AcC*O5F6{ z4-+nBcJk)L&M*Exk|w@g#YiwCMXSwzLW1#LtC9Tpmu4L?7Et_@L-%nw$APJ(+-%Bq3bSGgLCe>-JsiHT+gfN}YQ(hcKdWqNo$cjv9_e z((*v3^pDe?fOlSE&ErKu_P3`G+?C6jVug8zQDvJG?QsqaV^lVE(IjRHhVNV?=O?2? zI;B5h)g8Z_>IE~&tkZBf4HgpHx>Pt|PKx4AWGO7NE2c*+P`ue8>Wv0OxLgv(2XDYr zOGuaSxOia}uD{TiHte>6NH$w7$DGLtH zpp#30fUChmy7#>G5=fjJFXWA?U5wIPna+_cHzh?RW-vNLnnPN-6Yi&^+0Ib^u?2 z6PV7OieQ=~9bBbD`%c`T?SsV|4V0~t-%X-kGGG}F?>Jhe(YRV}_faQc%E&ROTNZ*R z;$%Xbxp2-((%eIM>G%rLIgtBodnd9G37htLVy>7Akg0~1l3Q0k3li~e*PhDJSIm1+ z$h9Z4HE&DcWi@F>-iN`>^wrPp6ofBDbVF77(U9xIbM>|utMcN+Y0;Z{xU1*$^E@FL z6X&O@FVgkXxa9B`gZQHbD)Y5?RmEwj-F-ko%Xt-gx-)BJQ!hP;$7DcLDg;5*R4u%} z3WhoxC0cNcQ1HpYygg9U=Hb4?4sixWA>Um6`;zK>g!70oXgL+Dw&uxHyt9 zQ|{imC`_oj8RGqk-35nByOxExpQ2Xg8_=ir%x5<2&}KdVcD)tJvqnE*PD#~V6H*6A zL&in3-g)wAgx}?|Q?gy)cm96lxU2W8HMK^aoebT&bb9rzCR5P!i>hiI0~J*dy}f;< zx|U^GV-J=0F0rxIyVVIPE9}i)I&RK_ctphUDcjac-G$1Rrb%!6$qyOsjZux|o4xKn z+8!Pjy3;MyNg5e7YgkT?>UGWu{t#nA{x*LgmZbJ}mhjW*oMiO}=Gty}&u3XSLzoWF zZ-P>(O?B~dSv#~dD`gurtCsOV%r4_##As<`m>QB60W`;Y)a&f$4_132Zd6_6 z;8p*~8|2h`r3~>P=5%(R(3leIDP8PSP#T;yo&}wbELzZeYMLP^++afb}vvYk*3fp~A>*T=|ZdbC?H7tTa+5 z4Y1t03@Sf94e3qM9UNUuS~;j|B>TAMe~RzFLYw|P-Cub7`Y8}cc2>nI)xvUcweK7N z|FA&qUksWIF$S`30Zm5n{Z`KuX4LfoCQmFX1l$c$c|QnidYvSM%Xx;X`uu`MI&-LY zeZM2FSe8Kpd^4KX%$@N9%oF)v7>}j$-3HLhE(=^fSunlf9f*roNPiO=R;}xUb0@&j z88*GZ+o!lg#Ckd>n3nDyVdw>kPV)JX(LdVhS0v7>B1JG1j>qQFP00}zBg#E9znINz z=4kdg_YIjil}qwTLLlGrtR1;6FprEym-EsoqPESh6XWrS+yHS$K!R{X1A%A@%JzZD z=bMpb3yUUq+*||s6zEAd!qlRv4SeWWMwwb3B)V8{VZVib z;MOYLmYKZYTPhXzV+RY1PZPtmHOP%8B=}>~>wdV2{M<5qTTDIDe4i$6jD~iuyI1XQ z$9b07zDclo&C^WP9h6s7W^GFQvY$#aK5t&io9AsozhCBGT7Iwf1=TRbU-U}~FF%*O zINR_C$5&Je(M6grT)9V_@tdc2q7A$>y?81pJ>rVS8%0mIjbB9Lp1kB-hWsmHC`mM? z0||=T`bs5sh-U1m;FqV)oPi}d#K5k8DT^1WOYfkja%1ePH;bu8CTY$(e62}SX+>!O#$=p*-zY_SqR%)+ropoh6cr{y&7SB$*FPoVkTjm zz*4~KhgXr3!))mMDs)D8wl}oP!~6y+qtDu0-%Op%KUn4l1vX}l%&f)>SR##$q6^=Sbfqz2~& z60bOG4n=d}GesAKd~Q32)kY9_rokn(P#qTLykRUEwb!kcR#x8}V2ZXZm%&t#-K=qU3`|%lOmYf6ojnT{K&p*BQMHOfj zx=e!DK`#mhqALPKa7sN`1TIXY`?U*R}mPThL%M=_IUPif zMS-%#Eb)lP#jany{VTLIkY7ut7aZs5*YlBT9xWsIjC{;WX=NM$D9`S-G#`qY^c~=`d53h8wrMsjbC}0P_iHYdzL|l6`&yMQlYd3#*v$h7 z7y4sv$AXmaaOQBybpetqDHSba4|Iz7X-JL-?m8BR{P~W#`s5jf{iPt&7|7%k{0^&r zg^(p+bcT#U1u}j-sq_{6J%}iL_~V$E%VIPp{rUbkc8BFAg`(hEW(I~@Q|2EAAMeix zoPAUg_lzkg0NbA@7KZdherbHLYR^+ggE=1)w&M!LqD48!qMId0Vb2+crKa2GOU71P zKimh$8Y_Z-@)S7DPu9`=v>s0Qf7pA=rZ~E`T{u8+O>np1Zo%CxKyY_=3ouv+?iwTn zcZcBa9^7@%;O_o5xvsnF+4oyd?LV+TO)=fmGcBxjuH!reo?YiL0xDPmEu#Q_Fu%`R za<4_o>G@XA3a=`rycKd?M*%?G#+4K>XRORic_Su>&W9{$x_FST-* zL|`_btSGZz6)WlK8pDj&496AKW=BR5ysy=Pdu8K1d%Wk|?_vL;n1G>Z-q8X#-MAxY zm1jJ=LCG8OSM$AiA4%bNu_4k3w>%i0^sL2*n|4>@`{HwF_LBbgWV8Dsx}4c-w()w= zdRv?Cv|O0brRezy{L<#J^Z>x2piTG&+*|LP}I{-M= zKqIN#4{Y1@wxs_Jdf|J_(liDprlcEeGsPSBe?Z8@1tivR4#%^)Y1h5X9AC>kHeDKa z!#$xNl%{Wx>9Na}QOoS!6mYqk!k<7O(aThi-6T2Y_#ZeR6g7d-3c|nlaF%Xo4m=#A z3^o1se1aEGbCL5JQ{M5O5|QIsrp6B8^*EF9WlR!-urCcZ*VNFMH^-Yh3+10(F00*? zW51MsB{Gffeh&ygheK)eO{5&bC;Er5K*G=M3^O&5kHzCY?i(keutR~P!)UhrTlULm zr7riFH><|R#^jN>Y!Y~Kgx4f1(8d#a=epC#HJ)j0O5uU$BopH@E~vkE3>DudW6w73Y7!6Y?qJ)F0eFj;p^4 z$G7y(NR+CK6jWOpp;q$t)1vJB!|*ZJ0M%3xU;%u*GSmPygieIr=U=5l_O8=A7@69^ zcWt#p!&M>2GX}t)>QMOJxl%x@1gf_dgr1uNaBP<{kB}R!{;Q1^2UE^u!F8CQSZ@FV z)+#KLX=xvZA%P=dX>5;c%fWK;@i%xiYIXOx+b6}kszYQ4{4ITtXy(h-T8TP+m#XZ_ z9L$yNlc@iVVgJ9vnjk2Zd}fUS*uDy8UJfkCl?Ip=sE<*{koK}1b`DH5ud;LrJS3ju zNcRPyYy~_ij2is^lUM&gpxr%rgGxn~thmU$HN4gd*ID9!g>cbi)#rnFm|H zuru72##m29oXJ4$;khh09C*o7M&BsF!sn8IhA*aaX4YlAVENf+j<;b8Uo0x+1~kWI zJ>b>9?Msh40ginFyVWq0TV48131ZM0c#_Ix3x{H9e_TMO+a<$1zQ>o0#X9Q3_`b%2 z&BVId>xg|5nALGLHHk!d-RcUr*+2{CadHbaFEHabnYC0~I1ewuE4=!61GL=M zLti;evhf|SLUpfkD!@wa>=&`W7PYwyV%{`rmNOM^O9maehIzcRUl68VcHIzx)#UnC>R zZbC{bYIg8wLIqyrez1oE&coFD80BlyJpV;JGht#q%2GE?3YFCJ4#{6|6swoMLuGgt z6hjMu|84t{^}RsXjL7Z*m1gz!rBXHpKG3xy`>>Q%IkUxk=%@X7%xyxF`ycQ02TRn6 z5;6j#Jdf{0=V}OYTp_F~+l52Si%up`3)&&Xr~R^e1-wAZ!WbcjS6^R5u+F!4>3K8w zbC{2J0jBRxMbU2X3u#~p30q;0uN3$qA&Hi)q(#hlGg$mHu!rtyJ7YF+7y@C}TVZzE zbf)r5h7Ix_f4CRJiHbapo3pgbgKT0aQAV^cP$SOK2zPqDNqJuKiF53r-G2T2uoL+t zvMH~MZ*7mp!(V@;A;Fd)sd5*yP7kxZ2V%S_p?ldUo_B|BYh0ARC(P+DSwkOre|vTw zCZmyUJa;ruCFW!xy}$BpF@X*}|F+EcWc@+ZPFp6tSTAXVVe%!HAv{rLoecSn)`mV! z0dITVD$1$T(xkY`)xGuSQnD3Ug~uH%tq*pJaGPs+71Als91ldUtVfhupDP1r!!1I# z@?J9ar`|V7FK?hxzWst*?357l?@#MPD)F3N65l_4RP+G6Wc1H^eB}i5?WaZ*=4&}s zrMO$>X1lK{dTX*_pxdPK~QX4Rs6F|{sPGB({Vwj zrwkp_OL?)l*nuivpB)S3HD<|`d*RRGFqvGrco0FmWV;Cyzhkvp`jkE8j33KUR)v)2 zhvz3nkf>lnMw#*OgMf;jxH!dBZp$TyqX0PfOMRPKzQoDWKBgA!vFas`2PpEIC?5f- z8ICX}wHE9TkKX+Eu13@mkOP?#dlMt6dlPxrq(G4Qin;GwPt+W3RiEr`Oz6*vg`X~f z=T#lcHg;oA+uTMm%yX}HuB22H{k&6?tXDg?>y3>jQ(*XXQmeu;eXliNyQKD^FSr^X zyFni@arr|p?WE=9xH{GZmZQH24%L_XtA1H=-qGn@@$UH_#%s7;tw+|EroZ||X6Cuk zkzSuQ88O`bZ5;S_hq+Ktcgz#U;d`-HViY(0q9tlLlf>Rp$E^=EJT}C%?o@=n%rIx7 z>zD3lY69*t9lisJ&Ao{yn%hCJh@1F2QJmGL>>dB+`{nh9W6Lh6-7_;(8bc|I zn9fB3nmU{F10(sj<2?S%$DOA80zjrvm+78>cb?n`%&**-^F4XcHBCi|cKbc|`?f5m zP4#iwk>TYQ56o@B=H%nqQ9x`2c&ql>othUYuuqgs(@84K>L5dh8boxq5i-*%b8b z_wcCuW~#7}U?p&PTDO63B^Y?g22%rJ5C9dHhGeQz3e=p5jczY6sbUvEE zP^Mcq!{wk8n*0KIz=d{ilHxTbV|4TrZ?2p4r%(BS=n{|F;47h#A1q9~@2bZRA#Xq^ z?5*1#_r{Ut5hI}eJLm4dh#Es^D@aZ!*~0li)BqTbHgb+X* zQr(jx>xXfX6DkO=Tm7}=y{{Y&~dVc^j9dN6T5KsHydo1Z9CRaPb^o?fUn zpQ>-WVrbe)_q6z(%yO+MSGo?SVBI*=G)-<3`fsC8+z>1Rt=(c%Jk*X z$5T=6KIen7`p?$Z);)836(1H)CYP7#uL3r!t}q&QSSSk=R2=_aq|OLeFLU;gW%pTJ z%J-B17>1lXkGsZz>LvL?v@_p71GHb-e*5dg1#;ph>x$MyRyMXH3_UArYs;g>+RIe% zV^AW!CRt!8^1fTA#?+Y4HQQpVr<;&}Z#&?9R7JVYi4cO}0$%aHS#e_RD^$wKHLw!w zYdUU!@!|F#75mqNW-Ooi#eur=OXx)M=MthWfWh*G}<({1ZXc zl?t%IfpW=x*W6DB5OCoDPsgdvoR98+5;Uc}pKdq;H=N*+ard)MwV8ZRyR*U*qx12z zI_R+KMcnyt)4lU9;u6yk^v=@c<5UhMoiUJI2*zRXR*6Q=bKmAB$p_KsX)-u%|T zXy;S_@OZKh?uh~tW_D*Gvtb_Qga4zb_EHvmS-W=*DT$ax-!R} zK_7idGolvz1v-FqI)$;7=433UIv^?-d%XLHaQ7W5lY2Z-^(Hp>fnZzk5ed3Z5-1#0 z>7=Ny_Dg~2IuWgzQ|{tDHIHn}5)r&(JJ+fx!g#75Cl8kG&UZ6+tu=SsZhH;hTwhi( z+4H4w`bRtmIrmsVFWYl_X!7}u%D)aP=UE&##JE!+g5IIyDYA5@_UM^Ir@(R_LSS;m} zvts?VSXrpHtn19wOfNnMmJ#gM!$mS0Cx<_eJz|0XfP?XDip2Ke`BXcS`xUp{(2;_p zm|GDJB5#U#N~WeXEO{cjmsK*n!Wi**wz8lyZ?9nnkZ4`_`5(}fObV~s6t32Po2<$) zk?=Kszl75Ax~W#Ng}tkMerITKoL_*l)c@^ofZb+A9>8RmEj(O2yZmDuJr2U9<9fv4 zfB56BRmJ+1LrY5rF-kyEa6CaK4Y3Warw|kiv$BaBv``CjM{gz7dUF@p$u!P@dhTUR zK9Nkr&OJq7KgiW3_XwC*=EE3!K@nD1=0>n=cAb+ocLxAo^~soKorZsik;wa6#>Lfj zbs1VEej{gi7>+TrJ6`GcTEAiRk=whPP~ z)D#q&c1{Lyb#-dRB?qhg-92!IzlkQw^tmv0rrZrb(K%{K*gsm!Df%MntfP*CbvPwRGF6+*gicBk*R}+aNsaHg5~3gWLTCxMHQ_ zu^0-p?Q3&w!fI7|MAfiNKa+PR!ieB4M##l7)){c7H#PBCBHbLX$a1c@#IwgGe@ahH zl?5iv+&O(;{w+J&c~@v5eH|r4DBK7wOOmv$KU*RAF0eN9YGE*P_^^_$59J18-e82x9wc=67rhKO%6OG{Sa@jbe7d_Fj zE|VjO7XT=?k;SDA1twC*tcZ?4T2DV;LV-};$rd@%lJh;yU@9B3LSO){`NUoK6n!}< zn`k3ItyLJGsL=G=WfV4*+iU;{OQg4}Rc}WK^z$BEu4dGSYh99i z?*YbdKy4)E7cPiC!IkxEsCcTz5 z`0ySsk>vReHV=v)(kxC!Bi#uv%dIYMxq5&eW9OlL3v3ec)JL*zx#1a%6L7ftfiRf; z@CmW?b_4NpBmZeSnI(`i8gm^v`#Y^ycLd2wWlAimes6p!Uav<`SVBy&LsiDo>gY>wK4;L%_!} zpeuIQyP}1X{lfTZ+~ICWy^YMg@e_a^{B>m65xC)Q{JI)i(k|P+MQvM;-^}Fgpu_&9 zqF~AHfnAq-G%5;>&o~&BI3eA{_K4e(p!orb?f7hZ=?k?7jBG$ab+T2?;|s9YxBt9>Yk$5!I8xCA zy}0cWI-LCI#ifdRnjC&3nH-a2Kiqfxu(3eMX7~ zxt0=xd6B@zq5AiD{l|Wkyn-D9^BT?eBl(xJ!8nmFKCK-MGcGZ+hzpu|zBD_Rs;B}w z`*#0CxvP_)!5=&sl;%VQo04UlIVWwh94X3$HiIrh=hBFToZ}{ok|#%Hb6q2oy3EI1 zpi2wx)p^3TnL}`UyLvrEJPfmMRb7t}SmoFN78YlN>XH{kEu1WSY=&78MylvC^ghRx#hbvFJ=8>x<$Y-l@uk_B33_gLbARRr6n-9% z5Bu`1F5nvW8`N_i-RQhCgiNqzyFt`J`A0$EE|(A1<;JaZ)^#3DH_{l?i8oN8T(rJT zYF_^ok?zj$<>A{#djIo4Cne#!CId~C6)f1CBQ;;QJ>lGHcB-%S`roGrBuDDA z9IeIQK$uI$GH>k7k9RIJze>C^cJMIgoHN(`Pg()?l^y?q+?K{_R%ZvnKWG81k4rW4 zmoLfwoWvKPgf>usBcNkLgMw<@*8+*6cAI33pvFsHrAAK}3EoUcoYb!7_8*t!GYv>{ zF>lOM8(`wvntiq2t!UoQ_lt--L$tWV)O1<9nxjTqqIMh>+BiZ*I`aNCuHg zU7^$X+0N=|3H&^ZVk*~g)~w??qv~|{Zl$Dw_^g?jAEdI1sIKQh!T)@_`Dv_H-Pu#;0>b0 zdGQC8E*w-*&b4r+Hj7|ZJHvZjMd1xM$vN6OsGd)f8j1cX{mC8$5#%p`Id0Fl%xaAp z{hPe7)y2vryo>UD1>bwd`^!f!Q9OMm4@am7Lt$oKr_~k@h$Lj{ac@{uXojnq67^v< z-c5l~Ff_W|^N#PcBnZQ8#TC0uqe^77VX4j*-xl1K=8KKIb1{h>Ukg{REzKNI75B8m|`+WD* zT``?Ib24VA40j^B(>+<}b+Z8#@nH|Tf&>hD!nh(wYk>+xMPp^9A|57AOxv3XvNldi zbHG=1YAWDouD=YV9!CB&ox(3pKfgYsb2jtQfn&v1)xAHW{``Dr-dV)fdn7F;Bs}kr z-25E?su1_W0REAV)g^P=wam4>V*tt-h#j5x}D_L1QFX7qC$N<0xf zDI7_hL3hu2gF+?EUE`p~@g6GR_pPM|$BHGHwtPKSZLRNl+hU2MEN6r8{iBl5ivO`H zd)Ue$gHf^roFxf5ypP33yx5cOG>(fslmfzg1ovhfUh$lF$Wkt~K|fru#xfr#nEUlz zsy82~Z#B(9-zou^UCtK@lNCwcUY4ux9SI?y{7l}@+GNb>GW7yx6T z>{MDMZ_;`Q3s@2|NY;oubtAn#-C|6-_4mc@v%O6>0^!-^1LiHv5fJ|g+~EqxtnPh9 zLh=Q_9(#%pm=eu*0$D}?XajXW|0P2UkccHcQgJ9jHk0v^e8a$4c_=|9HETj^We;N8Hu)DH z0#cy^4_7@h`}|QCV!m*PAE$j-^DxWU`{}!(58_9f5%s+Ufp{5oKp@3 z#SJXP9tOO}yLbW1RhzRw?bw^(2UOi9fY4eWO$Lkk4v$2Gnvf2P8_?)-uTCf7)5>ij z5exhj01_=1@`~{XG^@?-mYFChC~Ay)5q_|mhJm&9#u2ms)O)QX>c{vx3YIWOu!&6L z8^sWH`@HGq*cD!=wN@8`8m!g;9yDQ7w6e@p#P5>yj-E)Ndk?!eyXJxqnm>44tvVj0 z6qaU8apl|#b|;#EruJTZ#^Z+-r(p({5so=UN5?9Kdqu#=O-Ho-%!o7sS6Ne>tg6pN z9t$f3-W(4{veF(b@am}Ry#-W!Ig&A0)ysx(f-iStQvlvU)j9;6(Rd1*`Ib8spIX7Q z0dS;b0VJ)Ji6|vjIliyuO3Q#yymIq2klrJnZ}4%0-Q(iHWZZ0>k4Zx- zwK;ua64B!iBQF`X+XR~wvEGJ2V6`b0qGOUl`**^EQ2Sq+mrtDDoBmIKW?OSt`WcOA zso*qT5d^3n_h_2PI1S?_N?C}q+rg7PDmnxt$0t$9chr$Y<%kjMFL#13l6VpkR9%I~ ztF5VOWm@8Z?E36?kZU@2*jE^*y_{6biyYY_@(1^7>GZ4znM1L4r#Uv2i^WJW%g#3eehx)fT7XZx98Tri z@wymzy3~q6u${dD=Vs`%$F>!fld`g`Q<2hgSq2d2!snmNpgOF2*@c(9eJV2`@D)L? zd7wB+#oq&souF>s;;r~@rLNw#*iZHy_!`-rhCVz|%`IgZ3uXTL=GfB%b6TX}OU%e=4>IeL;6))yfxY`<{OU(a+<3zcAQ)sUaoOj0TXfkX9$%l0f^QA* zJDPp%i4ng%tx3P1LHB0;%Ab1mlri;mDGOSIHM@{<^NuU8IE{RHLT}FvJ0M0fI5nHF z<;0rkvtN}@w+Zp$Z>$PA_N9(d!u3hnVW1M964~Gr}m4%;fXovq9V~V1e0p=^bP11Oli2Z&t|Us;K${BVOE74_|Kxe$KS*J;=Obe;LizTLb-%Y5GF z^5p3v{{gl__>Qsu5y2s`0hJh|exXm@>*T08{SIEY{VdhxM)r#kl0#zrIl+|!9F}pn z)DONvjg6MXxPw3X!ZlCO_qKuLvYlMQ?w%1iI)u)mQQ>w!4-lt17b$<{*r@V6> zT@05lGIbLtbF+%8^JlA-2W^|lmuG}cqw#HD1#YT$0)-he5886_?iO|mJK`n0V9g0! z?sFTX+HyAXcIV&{!MB*>D?6If>>sGKm!>?k2kt^Oc+db z#67eLAqIpl1MN-+!ihy}tV)Z`coQeI6Ofdaq_K{}HTt3MheMu@azpktVRsV3kFN>FI^bAmf7XnODvK^ptW}S-829~$NP$x zd#0R6K-&<%v+V^@siJFNC}!^>d%)S6S~PysNs#1f@~+8q?Bi#@aUn zt1gYRrotQg5K$)^YQfh!n@iUH6aGwIoDq+GObz-*cYLtToQ_^29Ee+!sBl3j%spBD ztM746UzHtBPapayWDq8rKZ_Z#VyK06oI>_6Zo#4-g%&sIvZ=(G&LdC=z_I zJp-g<%HOWO0JZ)1sj@T!#Ku<;W>+4GrRq7hx8^oBxpwsJtjW&|xm${rnhx;69sFN~#I+5XBU|C=uqeh^5M^RU%aEKi9sVdgKWPd1)J3=dUq5*zVd#uu{I zmS?EJDug+Bc#@k4hsLq*CFC=k-Rj-^aOOYEmU-4wx4cBR1~&gRQRb&9UM#C9k5TwZCAl zF;^tZ+{6#l$fefz4&7r~Ec6Y-sb>8O&UiIaL0ce2F`HZtzi`hqYfIZa3!wJuoD`w3wPOwCqsDUCv%0 zom`Fi`pADt%eJ23hJfvd3vM~<9@8N5MWO-9{WOS+{j5PfG<=(|ar+>|o62J*Q@ z%qVjA=bpdQ9ij+f@aLAV-aqAa`Fc5palp+~^y4-o5*1_XdnwiNRh?p^D%YUO$oV;{ zR2PxgIk~+HYTdifa8LRQrz^ezeyRu^V`PGGQsbGB;#0gk1ZzkfX0G-`_3~SPS@`tg20x0FL@l`hPb+yY3t0S7nLU8V=}%0dyj}(_2)ke zUvWQC8(s0iHgT%X%QCfoKsK$c7c)fU{;k)SONe9R9yhN^6=R3)XhEVnH~kGQ-7rQP z{nvH{!{F@`T}{=l&wV3Rhp&!u6om(Ex$fnpKCRf3850A96JOCcpCXv7pK}IYb}a_q z7~j|AbJ{KAe33<;bk?|mO@vOdC2x983mR>*x7?(=^z_6kb%PIyNo5XKT^7uqz;5hB zCYF;)=5=G#1(u-@(8gl?k$X2t)Vo7`jAL>A2<<=;`DGw)pkUn-Lvt#6=$3EWUfpxl zEWudLcGpI6WtW!{O#j^Y346uoAPA3$_D{N?WSTbF`2#+MZ(;k!UH$};cUmnK$*v!M zxa@eS`I1BIOwY~^N-LadILZ_DoPgUi)GD4M&B3a2ivEs04JIEhTl^Aq3oKOIC2kO* z)LmYzud7TW%)DQOf8l}QE-*7V%sTmm1QiXv%)%;r$B#^u|83JEIBB@x-8 z!w)=X_idJ$m&232d)8!C6t-`n`^@xG zIuIH;PWtmRlQx(vg40K|y-=$9%Tas$x-}KM9sd}@)KgKgaB)eADfY=kj->bGdZZwZ zo4}XD1}cJz4gZ}e(@#xNif_GAVQJHY_gKc>!tFS6oi6d+qq?UCFWLJ~bn;L|xN_;c zVIH;}GTC2rU}=9k72yAUI#tlHDNpTtKb7ItlsA49AdQlIhgVYXA(#45J__-!eMA(e z5xZTwi}B9sG5T_(;;Eil7z>fuC$?=b32v}WTK~b`n-=uW`%KVBC zSboN_y&_neIC%~bt4zz-yzIAs23Rbgbf53N&naEJF6Pa5sV=}1=O>R*<4+vFKA|Rp z+fa{n-9hd-LC+sicb>j6>U_nWM}tNp=)*{2)RoD2Sl-#%B4}yb2gHQ@Pk=2`A_&t; zg6`4?9-(82FB3}7BD3D(^q?F^{i`1Vr`=EQEdl13(+DyTViVtwF+!Yil$&s%E}>#h zmdoHdUCfmy=lde&7_j>qZnU5I@3qz5&oLHAxyMhWN64JZB%Bm4YyPNw@c}9X4F~1T zfc7pHXW;fH5bG*8zedU_ZE8wUrd|IDuNUaOX?|({F)5r(t#Qc@+RMwBT1meiB-0|* zncO^i{2pn{3Fl(}qw7u7T!p^E%Q@4F2>wT#Q`~r@1id5!!1}BKl5arc)3YMchrWk- z|I2S`TztKNMkN-=u3MCBqeeXktoWmgVBznxVKrTDn0C$GEN-5fUnJ_EtKBpq&~cWV zVNr=LFG$k(QE<$ASXLiVApk9jGEsIED~J|3nZ-B=gZ+J#Ar$ZhW?-0elGkP08>qeI zi7t5qlCgCDVVFr_E*MsvdDpj?y}`sjIBZbdKmxj^W)W^65|qOU5s%A&1&JSP%*&<1 z%LlL&lpG8TK|+?8DBAs-V^6f-LQdFp@o2kcoerVbhBEBa*}GEE%3vjF10zv-;Usn6 zJBH*_#T$R;&>(o5*bo?NMn0Y1L4Edf*`88pYWf;`=v`)Vj zyOXtab#>>{QD}WiC%X7?M*WjRGBKkD;U~T#!0HhudT0K;K$jNK#J%9W1a0?hU_w{W?ruTjRr#w#$Tu#Z)mf%P&n>hVg7S`ibruzCUS%gOmRX18D9w z+?<-BynzDv#MJ%06AfWCbJRMD(MW)A1WIS-`U)M)`@pZTZ+%JSz?4`-a71YCgjNLa z`dB8z2yXOBiE6}PF~#YTav)8kB@oJ?qJ>oh2a+>>*{)Y1eAc8#CRI(n>`BAE@6g#<$-lKuI);qJ$^8aBh_D~EpN4Qpy zgFZ30cP{vM0U~q&GC|sfiTj5W`HHVR!5!wmw~)jP+M~G@FnJFRyn|G$#MFIB=|#*M z`X2zL1LpHr4kwvc1P12P8Y-0Inmd26J{R>fI`KNhEC_VR_5+Fyy#7_J^H!+u#G@P4T$`b zw#!eL8|K@YVxrt(!mF#E4_{-JM4M1stZW#61A&NNkA0tPVZHq{mV+lcZtQHNr#(3% zS~X;M+sxG_5OP|*rar11e|7@1)$A{F?yfdxWOsi_4yH!_(4_VnG6aNkLe|#QxJUdL zY@pe^eW`b2>`+xT2m79CeW3a_Kmi~6)$eUh?U1;a=!8(DZ7t@+r^vkNDvGI3wb$o$ z;4Q)+k6^>q8vEKgdk|@>qD<GQmh#&(l()tm@rcx!AA}iKZ)bheT1Pd_ zGhFQkxd;6oeNW}CH9e17=N3HEuJp&MXIJrYfS~a&bYj<@;D>Y{bzCv5c=yAC_I^bz za{T=gaXhu5Fjp3EU+{~LTk(imNNb7!=WOq;oMn4ekA3pwfy;ag&=Xp$?u$QL7&pP1 zW@*7j4XSKMe_ARWPa8_M-kIr^nz3?tX`?TmEsmlA6%nyK5OxSW<&wobJAH+i@%)(~ zK9UtxhdR4*7+ZKuS!V1LH`};nYr&KINUx?c0E9zPmspp@Eaul1OOJRv7@|@3Mt8ua z58DXWBpJfhZk)(-uH**3|Ih;cR&d}xut9=Gsa8!_LkWBN=Vnu;%AEF&`VmuZ?H{~p z>B=U(**^o&9UekEP#w+rrlY?bxb`y^ttMO1LT`klP~;y zN=qxUfuA{EISJ z3awq5OJj#H@hNsG#Fc8LXjK$EBWEIK+gHN29r1ELYqnHsT>UBARea!CnAf78srS61 z`R<+VZ!wc%((Wy&YHfYRV$?b*p{d&VnZtkP*CGMEj)EvLhniM{Zc$;RHVSYj-)Xxk z7yf%Esn5j@l%$M5liK=~Ht9Atr7`?kC~l<~E|6hM&{ns?$XAo0@6}&Wj2l0&(Fx=$ zCXOHu8p^Os9)s>G0f6@D4GTt$Nq`ANg-$6q&NTQ|Dws5S&>X)`k_m2=yiWK8t^xY< zQ^*tzA7h)<>a|4e+oNCVweGzXQ!}iLZBooU{x!j@a8A2Knkx3qpR`o{(G-4vmQ-E5 z7tcBkmtub1`+64+ zVA2e#tl-KvDTFB7hGHI_l6|7_^v=3pZJJ<;3i`6JoV~x^)k|}PdL*M;qGeI0Y&!T@ zi6$M7v7elg>&j?}A9iY8L!??PqZB)C9T}rY68>)Tl%~Qi;;FScaU^zVNAdC66<-`v z8VlL*0Ku{QEj~G@0bEsBDAg{^gM+jm`cXMwZX(+fF{jmr=>416w*V&!DO#V5UwaoJ zJ|xQE8yq8O@jDb=&ZY6qUQ3J4pV73EjGt4Jli8vLr)0K<0l0=@P4Fc^nL|wod zYT`>1-WH9S3fyx2t#A$^mH}}=pt;G?(1V?jA?ui>DK^|_Y}j4-yWZ?cP~N# z&489RPFNy}w-_R@3h3Fp8NZE@h^Vh&{d)A*np8j7;-%(sP}*qHW_^4kFOF#2g&kMu@3<3cv#$M|ZaIlhgme zVqg<2JV8=OG_o({uc%e6RUEEunzN&s^9@72BI;-JB%Y?O?xDO1+!0e#Fj2dc3F4+< z{BM6}-po-PpWq>X#Iaj3T}@qv-ZK1dW2XC$u?{%w>=60cv-_GQU|R+=xbho`?D#|F zld(BAg}F^@oo$bFmUT_JXGHlO)G_;0zR|$&vUB==gHQXF$7a#uhYgPhD{SuwjD&Wo2bbZ0c!|YVKj4yZJLx%e-eudfxicZsq$W{;N>$_|`D@ zA3!b6n)(c#r;)Wszw81}bT;QZn@R)ApVs)+mVadp@`c!BItL8|XT;#FgM#~R=&V`~ zh_MpKPZYtt7Bh%*$QMD^u5>N9JF9I*KJ}qDp+}-O(w<9nQ&-$G(mX$6b{c<~8vf>k z;YVk&(x@`!(K119@rPT4nrM%nN8thHkPvmZ_&FBA!2`#xj~JpL&}^|A;NB~$ zJe5NLn$|J%+s)e0|FU4}1n@HU7fRecVFTWM3k`-NF$t@Zh9hI%uwd#Q7Nfpi^*lii zHi4)Mb_3DImPw9WU0nr^Ar8_P>~A)`kCl!7VZ1UzRA99tzpmt5AW(P-SJw4VQeHlL z;?a}*K@7PSKP0Wu8;Yd;E-TqILOmPyvO42QPB3wZ4-=AK;WFx{o(W1k9WpxZLM2h0 z4&}DwNZOE43mQ=%8sGx6tIfmA0=w7R+R9Szh@-@AsJi?N;rQ51{G1s$<74t(>{k{NkeacD)m#Zk&_=u>wuc*w8QwWw6WH#@>Ez2|wmU z1+nbC2%L1rloko84-Q>`I$W$LVD7p70Dft!sjamw;To#6j3Rg#!WH2e zzLg+&=fkhMK94#Gt$`}d$VS#jVoPFmit~=Bw}-$z^pu)343LP&0T#A;VB%1h0JSp& z0&RjS!aBk4y|HK_QlN-r{I3xMhA?z3!f+&f;&AyMVJIy!_*TthBXSoM?!E&(mekuld-mt`+d@0Z#rq?0(XV2XbK_k|042r^V2OK$7&L){dZlbe z;^kffv)bS%K^*;+&2%8}92q-w6j7iXpg)(6p#tB^IUWh{l&t&$aN`g@=s^e8A8)d? zu+J;{OAwEV1>zjeSBd)KUQoX1P=g&}cUy#Tz@P&L5O9WZkf$W1ahrXS7oR_)Nx74n znwmNR%z0^j{ftleDC4I&r%Y|P*;v8>7{cWV_6;F!dZ%4GtE+klAAoay^Y;AlVk`-f zv7tj&`s^dBJ1v~4k&bo4ZvtYyxQy5D(#*a9&h7)-2aExoPSr#mTChW>vk)BV0(rU6 zeQV-6J9fDj`3P|ROrjeg;dLU<9dLlNVVwr{7sD8BR4Q*5h#g7l>Y#7bV$j!3Jh3%h zF9Q&#*rXT%MixS83EhXB>9I;i8W1xT1re+Wk{O$$yHZqs9ckla~Z9QJMj9REXBAiyi~I3y$3~1e1+nk zrF&jxWu+8jo4c6^KIbvy&UM{85x7Aj+;fs{{JTl=+1e|X1qM^6-iWVNT`ju2rZ=%)F)+4D#%MD}Z3s&aXzqY^;rF!G+fui{ z@3B`jco40S)7J>pJNJXOI!i}jG$ zmlxUR)f(9-Y6$JpWp+0pQ(khp{pG0*#}kGzgT`3M!+MhHN$W6zF!9+Zwna#Y z>BQzBu_Z-zi!DPl|4@tOrcW_4Fyxc$OoS8>|NR#(213VxzZWwlEq7B!U5(wo4de18 zuE32TF}{^)u)R*$y*%O_K~l;uB*Y=(h!ATbGqqKE&eaB?eD@MqM6qA3-u2fXz+M+I zSp9|%uOm(N04RjRch;4)zEZo6|N67Z=TCF-zsjgcOv5VxR?0jXo-HVkX(YL}yk7sF zLf<3&8g#|2klS7{CxfWDlh{58g=!N^L=*RojEUcn`!gG#dTp6)Qp@#XdIG;B4zn*j z@UM`L6sXaSFP0}tfSnXtWBcqoxu-qXn5VROA8v9yS&}+z%rN1-kNor6&9*J<*ToIoyLmZZ=OQiXRCL|SIW-Cojodqp!j<54Dxhwn$MW?#` zWdck~5H)G;d#{$Ptr)QrCxmea`Bw+q4JZCBw;SPC=Myk@$1ku`V|Zd|Clwp&I*h_CWX>m;WLkfcHzNvCsMEPypBIc1P2jpzXS$3 zNdMfHvv823`Ap#aGoo-|qS+S^6F|YMWQdKr{^bdRztUEo7+IO zdmr==3)%B-%vv0F127kx$*4kdU}UcT9`6E-W!w&sK(Z!&n>EyYyvt8Wct;WPRKzYQ z2(FuD=k9veubchR)=JdW)Z_KM5$tGAeGxO9tL~znxA~i27h0Kro8-Ak!;zi|H;3Yu z0*3k%W>T~fxgbKy_OP-&vSQ=S}anRAq|Q4WyLa$pMVyXBHsTKZcXp_AqB zC*l8($xq1d1MndNMH1ktMuYu-oBnTCBVzxSiTmzoB=Ee;Z{o9#&LIUlaEZyI9sAve zTf;psS^58GY4PvXoMYdFAPG0gqZ#7V#+c;Mlr{UpyMm~D(td6fS~ z=WaY$U@I>G=Xlc@D00XX0J)VqP!jXs0gZu{gk{s|LZzm6ybp)f+YxNp{<@+wA^C_y z5$91v{8~vXlu4tl+!fEPKQ6RXR6YRigHE%W=6fdz(x1}#ipE5NO2fs?O)_-aftVP0 zQgHn;{em*(esU>s3Rn=Zt=yU*guvkq`@^CR0ilm&_{_?)Sr%fjkV9SosN(?`XyvxI z>nDvQ(5a8|55YOQ1BLu$V_?iuS~O6lcq5Q}p#D=ZF)4UzEW9NQ1Z*h>Ub~jqIu3rH@Q*j4fD25H=5F+F zNoCr4Hux5;2Po5q0Iq2vG&HF&b3&v=Q!=YPxJ}nH;&fbyG0hY14lu6Bt*%z~jFsqt z7(D_^)$+%#fJRsAv_>T=99G_m>HZo2gs>0}8}2+-`W0|W#1t{ln>o%HaBnXg z{lzK&cU_;lW}p$I$Isn{m&S`6&Jqh}92uBsH?(pc8vFd3{o+lrch#U+q?J$i8fD*e zo3Vq6aeSvJNA+sfY`1=dY~>fuy4PP`W7zr;&z)k;iyIi zznt&R0iiYLMs$8;av6)fV#yhGq)vxvUD%KAhY5>@#Z zsM>11W~Zl1h|C$GX0W^$1|yc=FpZ3Bq%(y_6^l>LNAs1(g@t;j+5fJP26ebK9s^NU zq^AZR172dR{e}>PDfFuGThym}NuXS|Fx}RxVZSVuP>NUvDVOz`ih)gJo#$cgxIX3daIBRb$hsT^23b=PvjZRJvUYJ zpjWD~7=sxCEDl*VPi|M+$tFu+yi&veRAvH620T| z!`z2spQWaL-KO21z{K{e@2(4p@!q1ZgQcd({Kc=Df*SDe ziG?I3*>tt-TQo4X00vmEgR#49byK*`Ow%S5q?!Wnvf0&xO&jvH**U}k%ACDr(WT7c ztQgLhIWAU8)9lApT`7@8^kb2af{+3i5&{#aZ_lgF*j+aBc)g`nS8rZYD6O9FceMsp&y7h>pjjDE@+q)_@20z+x6%Qxp zs8(e}L_~ozrQLzV;ZH`f)#z8s2DAX^U6c03>xfvKiV!Btapa{WeB03Ls95hRnSo!m zj2mn2_TW0x+~tdbY2CQry9a!2@ov|jF;I(z{@GG!i34xxln35o{pnal=+XjQJ?fupo zgQF@*gO0y`&Ro7-#YCx`legiE{m}9XV&&{jIfHRPXasy?D4xDkY`7{!L0WO)fNwl+ zXI5#vwIcYiP^k2i?{P_4$v)&@B+j6U)0C#I(4|(4+Qz?eSUI*BNXR^XIfeFx7Z29z zbPc)7W7FI1G&&^eDrzF-!=F#vx@w|Alt26CIxE{AbXsi^zJcY6i-jQAnE4-!X?rf` z2dZqH7CCsJKIXIgS8rzsCsG|1LcxSrm20Qs)@Id<2G=B9I>ITWfB0*;HK*P*?x_Zi zf{)K*(u{0Lu#Xp7TYnNCr5Yr86)5;btc-sv2CZxMTe-tIUzeov+L#W!>)eS{D3h*<#SRv?x%WS3G2}bdOl+y4inIK|d34V@Ouesgc=mc( za#L*k-}h3$_iu~8+3fqnqD33aEOVw`W|(*Sat57fDK2lUr3C@S=%<0uw42M@{PS=# z8%=sSW06erS1nF1oA;}37x-5=kjF5O;)DepfAuV7%IArzhUe+TP;kGAG2|Bu*@7|a zoYYr=IaD9WHkQMeM?}?H0-<&OH6PEIn@{llDOwt6xdYEGANK@mA^2c9jX&|~hSZ5< ztwZQ){1@~XJWy)5DBqaKLtZ{rtD9R;(-V$qUD|9d)L(z2`dxkJ-49b_yxS+{GMD)G zrDmrKzH(Ii8<=F#^m3$NuHAjZHYiOH1C8gmh_;nN?&@#*zf|yPE|{2^x8*iH&)>B^ zoNGs^U4P!)&c8Bdc3MBu=rX5oJT!SM?vrg)LEf2r>eQ;@0zbbz3`t$kkn(YkcernP-#_z`s`TXqtX zPdPo|oT5MWSA@7kcdmTALD@ZK{!`pGCbK~3BiEue_>?uB(f2#Un23r^EUQby>`tS^y;NM^_fa>dxIJ)Sm0cMTi8ssNhS|!d~dcp zKSGJ~-|Stk!Y`lSu(ae@Uo%&q3G}ZDadR=@lSJym zrDXH-2)Oy>&^ zss`f`*57zb+$7;IO3C$D|G@mG*TN>xWxo_t*2`L}Jgxh+H_IeDvEad)&CWb;HEI29 z?&QbH@R#q@<-Ct7VC!p#61Z!d_RY+$>wato=6P)c=Ji}j^BgDzOl`w2VVaTJErl(o zl@6S#vbs;?)XCX2bb1{FU3Vm$Km+RvJra&mU#QXAGp$#JpFU%99{l0EdYvOww&=xtg9gwECNowtuX*jz&7Z z_?dW}s^o0hz8IXJE>|j~_!X>aURq%=di{u; zdq|rRo)JJ9ADdrgWc+*5GF>~PWR%$I6pkqgI-Fl$*XMEk<)r7V5`d%tr;d;L=YxT419M4?1L0@Mv zviSwpdPSV%!Xn|**g5;Z?*jXVQ-^p$%t$_oqsa!FkJr|( zNgfZT^V)xQ^Jke+&FSD(#eDhWMBk)FwvE@mc?t51U+!%5p}W()w zna(DoRorXNG_t}ft+=Edys&76I_=>phRrQT8JBN?cXjxJXcHvE6#C6%c_<*olT;p2 z@?U&v#rQ2%HOM$zQscH;l?(0F6}C#s2eoQyOKqH;nPT$nV=fcjq<}?} z+jhLo%U^lpLeIEKXv9|8y9xUuwP@qJnXGqA0q+ox_?$Vbo;QO;fqb;D8H+%(mcUw&)dGC#_-uZp#9_~tQtM};hudLQmx$&|DAutd}GnIKV zE~nj5(2Z4qr zwSjkyejBR8LFJ*1?T3Y=_Qd_HwEi^O6fTq0KY#K@d@ND|o#)wtn@TVg7|Wepo)-el?;B}gnx^T`hkv-2ya zzEE2ase?*SD2zD~LNcvZlie|2k-Zn8y+!@7ojL(IkrJSe{F}-e-G!vRI!^ z?b0YMN%FuteSgboC?}7odq#4n!TT(}-KLjJoSJ-Q!F^C%tlb;io zWR(HRwh4Ti$>x8>Bo=3&IGB51yGK-U7R8sXK8b|q+-C>LJ~`aMk%>J5ro2vGXZw~Y z*)D&@GgSZ+V&~r-By~Ume{W%KN@yCl-x`O77VWn7oMneSWsWy`hJ%*GLA1t3mC7#$ zxyF$?$@E+sZ~70g=b~Yv==C%4^}Pw$_2gzvP&&xLFtOl5R3iB92n*3C+-kF&EebWi zV|5Wt*hbendSARXy_YtOkko)Nu^;qu^QKj>#zW#FB5at;J|3OV21NWSw83}Q`%m`8 z?jv59?}Vv)TtJj4R2A9{)uCj;kjaiAB9AZxF8-J+teyiYTPefnWkr6BPCvYZtTi2s zFpi~g@V^i9pbDUCIuqBlnd{Mh%{Ux3bDz14kg6OQ&;T{o`{-I zU216q_ORwq5)$ITm0-n(QnXLIwgt;3y4~&_7GS|S`fyon=bAl@Cxf@o zz7bbx10VS6P;oXxgfXLWh^@_FrxxY@BszAg2x;%QvsFY>j~`A7k$^Vj z;h9sA)RKQ)`eKr^21N+2^mol9u7oacKLDxOV2AKBKXeVd#rH`pP4<+P1xo6ELlcI> zfIsABC5}y|bMC(8o6e7R?l=V}W#(0hYa9DuA;NAuhAcXh>?dmn^^2FjFfQ$S0ogfoY z6}Mrf)D1JhYt_ASe74wJqpklDp!eQMqmf0W|DP2F=MlYS_s$*p1k!XHAZauhu68V8 ztHl4qc=;p07|$@NIhM9+fFnjEKfyF&7oyg+o*QKlp zG8!|WkbU_Hfl3*7s;octwbrX1H=Y=+m1dA5@NS{j4M94oX=V%tHmsku zt|v_OfXa=rPoF2R;tQhdsan0{f=_cU07P||4(OlS%w^&vZjJF--Nnh*0pf*b<>E3z zBQwYFQ6J2)MuOZi8OLnn2IYq*^21WFEsFY|%C47vsYKe^j@Y*`bvc-cIJzzkN(-AM zjGf+=Zrf_P+kojh4o%|9?z$QJZ_5sl!TfrhA`Apo+6)xWsM`ETZ;$aYKxhxpCKn&6&*NawgEy*DA~)hSzD2uAa6{YA-B04u010q}Xfe+2Wn*y5c^VYjcv#Qor1gzBI>Iu1a}vqV&U~gL^rz zhv#W%Us~N}!bgo;PeX@At?>KI)6#=CnM-Q>x(Fj>E%Pd0R$Y@t_fvKSAt$(fL;sPg zmMqM{x_fbDC61}f&+^7~Xx9!h+=`s*DbmR^=IOx6^O)psn^LxzTA9d;5#olvFqy6}XytR$G+s5akdj(vvLd{gZrfPM=C!HYV@^oe zDp~QJg8Wx{*~E4FCdo=b-_@-eRovWW9P`w~#$EYhj62IjEY-`$*9H+MGNY@prY zp0Dh>iDTawHCPo2D%OoY?N4ZpMPINdJ9Dd-K<;DG{>5lFw+2lb8BmTLSiC-*$-1)^ zj`gXqF`b(b(uFa<8XTD_c`~YFP5`laLh0q#Fx%3oz*DsO?{;SVZ>RVl`VVgIBrH`R zH(rSkTc!N(CYB!M)OQ0&eJ?=3OVb2;0f)8Pb~-=OFZ+rRYzRzd68uYsFmlHfDVaw3 zcv>zXrAxfJ@IsOXB`<@+B{sLVlB|2)Z5}((g)#uH!G?Yklj?z0xPbv+ z1Aw~%@aA}9SehCcin45v z_?SZusLqnA@$lghT_~FKAXJxog`d!m z85|@qXB%MZ9c$Tl$<;8+fNr#fIT?SaP6(sFk`Cd|H8C`c(Rqg_1q4ZMs>k<&E_WAY*IFKYLsz9PHRD8+~^h?u~ zv4RHXV3!L^P`%5Tz`ewW!wo2jCh+%B`0=Auq#*}8yA6QYeX<>?X9&wZ>vsVzZ|;Ah zcYZO^UQX1n=)QJBjizhx5d>A8$s8ax{9vUg@;iF^CciJzX?Mvs#;oOylB{g#F%s#} z#g9OrDvyDSgpA>d^R%GwViI=*7BMKx6fN zl_pk_oSfW;4FU`ctssCcHIbJCdD5#9puN|)LX2V+1Mi6N{I_U}_Z8FZg@LRrL)88A z^)}``PjpVeEIB|?afJbb|1VsSI2BL!>Q8`c-$M^3?Dd0@lr{$yS2@=F=Rh*^3 zPds+D{{=dKLWtf@DTvHQ?#>i}g;GI2$Nca0_pQYLd~iSBrCCpOJvak1!OPoz28WPx z%^zZL!_Su8q8{|$+U6HEc@Hj)=$;I(K-KG(R~l00l0Nn1GK?XxdbS|1C|oQ~vu522 zkwwVTo5ly{i%V@FCmAt5{`hbE4ZPv;@#-TO49$|{{^nbJuxyD1V;BTl&4TJ`?duiE zYgx%`+~i%P7>tYs&)G24RUvKV5Bp ziSiMZrw7qw3|PZiBCa{JZ)TkN=091`6Q%!-F%j5zkv}q*XG{Pdm(g6c#^Z4l*D-Sz z=(AaiXajwKcQpRJEAtjNbtOk7%uvL6x}Z%dG)74~+K5$txQ4%`jJ(Gx{ z`^LA3KR!05q@$B8C@kFP1wVR(s$KyT(6a{oF=oKdYk+PydOB0VyG^Zj=$yn0+J#so@|Fj$9d|9#kqBvcvp{u>QeNQA<~ zQe(Ok(704EJunS3S`xK#BD{|aLya~g?Fr)Wbe&F(LGT+NnRvqd{xAXGy z8s!{!q76lpuKzYdPdcW~63VX7At@Vee<>KW3pq)G#3i|VNf%>{F4aPr`$DlBb9t!; zq2X>BGZGQYl;DC>7}n<`cMCj}L*FH?Qz%Ya~4l>d)fKr0UAmM-rNOvfyAl)4U2+|EAp-6X!bfw?Q^Yj#UK^rrS9QT;h~_Q+>?=pE25yFS^z&$ zIB38-DI#GBz%NuAMXBc~`F%9&z#kaz#O1_MP>RCvFZD5jzj3Xk)ooBv2%B$zP}@Iz zH$p+tTbF^0D?93J*SXlfkxcoseY5MFQkLTI)cma+FK<7u;pR9V+Q&{>i0?z(Z#kzk;uy{>T?Vn9t6m!#ALwc-252nR9;eR&>1UR#=HKb1Gkx^8P;F=;vVVUFgw zcCLDBvg}WOUBBqsR%BA-rRZj0&5!zqACqM{_1{heWloHDXPSI*GxQ;1d<7-_y2Bi(FY&ctF&I)*xXD^ zOjPwpfdzsfLQlUNtb7KkOS6tWr8czgDo1LK+N*2m7Uox1SGTpbu|8r`$*m0bL60{^ z<#|s0n}XVthx}2Pk7O|U)n>!>=FUt_s^cQ^QL5|5{*|S48@Ypw8 zZGVtV_ZGWcAS$RkncNu3@gu;fs4?m!`K{}AJmT+<6`u(r4&JQzFjsH0);D86Wm!oA zQfW4>rCM8gKAhz@=6R&o*CH8qHM8p;aCX_Pd;M{#?Gy9) zNkxO(?|}yontwXfAJWq3?ObiBHh6C>g^9`6X(xTuO_XMOcXqHMb}`Yy*l11>F@F-njNRV`YlzQskYV4)o=;M#)e?ey3V=%!OhgI@!oaYb$KOr zbrjG0E=uPS1396$ijK#XUFH3J=8uIOwa25H6zpmY7?7C$7ZA3=f{UTdmpn4pmuq4T zXRC=d_S;Pxm;%p8;+eglF>BkbwjjZ}XL|z@MEa<~%wBZC9BtV2uAZGC$WMP>j<*oG z@1X-Lx!t+>MpRO9hLESDBE%LZrqL;iB4~@pC+5g^ekx9wE8Hc&66W}P= z4$D1fFyLyaJr4Q(xwYnNb+u1mL>Z>K}+_K!`#zh)?#h9(Zl}I zgY;iGzb&!SbREW9bgvI$Z!Y_9T1M>_n*1t}SpN}BR!t3cA}0GO;iq(|F!!TDsrFFX zW;zu(?$P+hhFK3nfRqzu_5SsU9EQw(3@*Kw&lft?8WcjbO|KVu;$CVcVIl{Ch$$QX zdgSj>l#;+!_o45&<2n0&k9LKAIgUx~%VE5I99CjE_1IS0b6>F|SB(HI4UQuwB~1R} z`eN=zX9JWpG;f%u&P-tN| zmFczX?4ZXake`#|gP0G(9QXNt#UB&hp}dLzavoSr zeaBJThB>&cD1?y)qfz&L)e3nUX_3H+FJig4oA4>+g|x*Bg(IW%t`FTrhLGRAT73;S z7Y)Ip`cu%47}7zmIB>$}r-GA3M53p9T40c@Dti*zhiivv>-Nz+%?gW&q3LOMBQ$~M zuU-)~y%wD1JW?Ydx1gTt_?F_lkr}Jgq=`{Z0>Q(UHW2f6$*FVs^L1*RscLfM=YZJx z?{pJPVFus%>e~a-K82Qq-yTN~88w9T5!5mr@{XK5a!u1XBQEaswqqx?Cp|<-iIIsv z_{CzS?)lB|F(;D^DCfbo#|~Paa5Alpk%ncCg1-sq2JZ+816j{}sNHYFr{Dyx)Q+1? z)S~t@m2UHM^wWO0J`Z89F;I<&YgR+J!&`$Xf=wA4g~cA{HNpE*#lmny(3firJ^(0a zp|)&d4PF?uiAJIXQcKAJ<%~q+0e9tBdW5ba}pesY8Z007Bza zxC6jUoU2tu603`4W#xkx`!lZ>fb9o9EWe8sjl9O)E>6;L9VfDtzV#&w{~1obCf$|a zgVKndj2l8mOH_`N1GQW1QDL#sQ{54?hjrc>pSBu*DD+1;wFzM|4@Hy@1S^?Vn5>l0 zjD(D0OF_kyGYN8JjAXT6Fy_0Mo3q)|2s`YwNVdNqvmyGREzox4a=!j;&UrHrz~zSz zJP8cOl0eX6lR*d#{yYUYXp2ElqT^;Gr(o7)_bEHE^7rCTg~@{6qlWF zb($>|2K%y{flm@BhTcy*4Q`RDogaj~dzmq~7mwrO2LmPXo}d-ys}V5rPocw%t=X}7 z05vOC%bwSJmg=-h8NVkUMz(u%-$9F8eQQB@?kseCDM`dhG0YDYups7E!~{uE&d)&Q z1qBV)LM_g@?5mCM=NVGnPAC%`Z?67W?*{eUVs*o5D_3Cy85O8oH5B~qZ?sfetL-+e*7vVbm7bBH zbuifX#`pEf8$(1wF%3vqTLN%!&@82V27jBg^iou?NGrRzRhMbK|v{9*$@iT5H-9-mu*q5e;%M~uQ>f-U{ ziiRsoeMn`_$1*nil(N}`|MR_K8ANej064|)okBLmPqVg zG|pUwz>qQOy4%ozC*j=(x{mE@I#>mTQ16lNt{Cn-!#*%j2gB$qMON3wWSl-AoMSW5zh46RZ9}H!6%US36wB1@j`ywHsm!W;RdsWH(P~UK{{pL`&9V9OdOYvnD(yXWFZWGaON`M( zu`w+z?Nqs?gy>t?4wscnK6y!?cA*~X`yf!h0x!9jnik%4-?J?<)rF75s4!z5DPViS z+#qX*!{rCjzjXEG;CuzH%cuKG5Gl`$j(}&&j;|D5Ef%oLNh)thWuKC>UB)(CGGREf zdH%%rl7I6i`c&^2*k_KNtJ-aE4$X2sMpiJ@@A3`q0mB85rKP1~^UsEH>o13EeaSvP zdemsmLe59OIVz^T%?7{5aY9CQ1W6u_{1^&)yCuT-fogeUsWNm#qbz-yq%1bGNZw0_ z+DB3%mvoC!3pB5yp?3B|+kciP$I_miaQk|EMIge7@>Au`xJCWxVsK?$D+e-;tg^n6 z5v7Yl;T9G*ZLlQOE}P)Kik?#e6?5(vfX(}k>hd`zrtCcFbO$fUaq zR@`#MtTP)-y9i}>x=VasX>+V#>>g%2F-|6Mfv*DTcLbUd8hsq|iPQtrU6A=;=cv_z@np_!YqAxhc-Zr``G5 zHAc^u>MwShHGL5RQ%~);#_0ve%NG4j{_M=`!N#%W$;%cr+z&b*2{6*kx2d&mP@Z9n!G5>+xZj?bZV;*fzsaAUH zRQe(V`MD0Xi|!2Xc@PD!oY9YCKL`#y>xqhPq80J-uaS{;(M63!ffYS4^1$z8UVm8d zL)~%ARyYwc4%3~Xb=PBon6(Cx!M2fp{FjRq4aCPcfcdCeOK~ZDk6P|jS69c{P+mSt zjn8fNu0N$U2DQD?bh6|fQGN?xaXz=$lRgf6+3goP-f(?fz>V*@gH)uQo}UjYFD=z; z6Ztc3(*Y`SeN0G5(+4BIMFHJfZCrZL2Mx7I19b=s4Zwze0ZZcs)lTSnKh3oU-07ZI zaX4ENfcK?Fs{aV${$24Dr&gi^SZj~i)%vTs>ywHH_GN61!b~R&d3kxTUXVGtBrLCY zM3f(-Hpxdw&U)_4gfYPDcjs&)iQPl(bLlGwJ)d@Cv{Snr_ENj=<4Rf<6y-Y+9upb*w5v zG15uq3C3~ViPneej?>HG; z%fZ?WGj3e$^e5nf&7)5bq0um`Cv2Z5g{!9x8n(X5HFP#i_k#3rsv$rB6#d-d1*3A5 z-Pm4?0@&n>P#SgMrKF^^U^Mtua{HE@;Rk}epEZo5pcxbeYgT<^dcUo=G5%)Ox}CO? z?PqTw`w3F=Pho7$dRAiLq#wew!f&NJHl20w#?yZ3tHGJY!(x&pyVNHi=vMEKayOzz z6nla@lR!lUwFh0h9ycx)JbTvnq1SVt&mU$u(K-k;e zEzuk-fo}c&JtrErW-bj3f-kdD{mu}O>@EU>fci1PC{y_>eNVNckHl>x6Ww}=Ips*w zTON%Qk0VaZA`X2{443_kB*q9QF@j)-XJFjD4eEl|tDKeHSTEgFTKqJYsU;RLnk7~r ze6|2H7~r*8MF>)3Jq28U!UHkY>;CX5DRTFM!r#|leRXie8JYr=37P4C(y&&a&|h~N z{A%$ouU_N_7etxLcOEU4R~IqCn8bg5ejsr){#gLMAW6xu3xAbhiKVqL+NT_pU5%92SVzRdTgkS_e%_WRvffaTJXJaq?n4U zQBe^Yj>|W7O{h= z+4^>Tu0(tJ?wQiob{vI6FkPE`lOTBoB7#=o_L1P*M@s(n$oUuyYP?wfheyu^Si?u| z;pjn_4C}A&VK=Y#voCkY)oJ`V?2_t4qXnMhY_MdAL&Kk|We&2bGhXcwd6nMR82-|t zZtcxeXjm|YKY8+Uv^AK88ahmFjef{MmV-QMAZubD^XHuE_5n1sz4GJ>?>w8kpCS<0lq&~`K|}u? z`wRw>gh9~~1@_Lpo`||OpFRg+RJ%V*=qR;rZYlW%MtdDsCexy*hjK+C4gD(J1}~iP zRNre@HAIml{(X*W3gHb32$Q1$)&d2+^g=y)3CO@3@sLsvQUVR>`QJZyq!Sza-&4Sh zmxeXMSEE9@Uj1)F**yBhDgA$b;EN=Mh9;E3XsPfBzUZQL!by!FohaaVRiP~A|CR;f3|>|yJfU^Oq-u?&u>X?itnID7U{J4 zXVH|0eYP<}s*DKj9&~76aCmr-0qlL!7suQErPDcr2XtQK5GOsdtQC)vlUU^GGRbBX zF+5OD7oVJCtmw(yqakG>fQB~Zpg&u2Ok>zvb&jIt)uw|)sOHv44hvFQWBWr9!D%nb zUfoKmGfw(z0kCx=N(cEB^WBwI+F7KB6C7>%XRuarV8(RRBy-P1cL%#jOfi&<>-~7G zHtMxET|%9zSt-I8M`s{ow)rvHDAQhP*0&1j2EN}8pA4Rut$F#tI0iizW^;ZPGB#_8U7l$t-%JIZ?=a8fMV&A8YAOet$6Zv;e)ZA~wzkH&6%U!o zV^D=$%LLLfs3hVP3NxDh{OhJ|W3Kz=Tnn2o?+i(@-KInjF zh+F{E){|Z#wqUY)83Di@dXG2?0)C-W7~!^OZ(;3&U~PF)uqph*h{~}+i2%uH^#-h2 z-M33i{g@YwyH8etdwraj)1=_FIGwCZ6oAh+PXK881vB@a&zlw6YbcC($M9hq!q*BF zJm}tIz2hW$wqc~mDjp$T{RPrN%Qp=l7C zt@Y-ZmotWSs@Ed(HV%7XEYd3u{XXcDHu<6`@AM{8(p{5#R#;l6{ea)$GVE@AM^IBql zE212Gw2te%Q)hG5LvOTcqF6y6FX=dtF3@ILz&wq;ga<=5LJ&}E{u#g&;L(Fk^~7LH z=S6Xg{)92u&O+OlC!utk-TQs^+V{o-q>Aybc)x97LnB&r|6q!HP&$s@+cos+^iBNI z_zcEog_6t|AJ}98*``gV-R_|)wd@`?W`X##n6; zkoRMx+TV5r85cPGIOBC;uiuh6CC<#VE>`djHO^x+FiUUMK|;&xu(a6q0fm1VMI1}W zkbqi(n=s?cqzf>SJ=sZk^QU6cIO)!~0uRRjVTjU^+zkVTqqTq5;374IvFULL_++3! zkD?x=mGNem4C%w%Gx^YfOmyOuA9Y}2mvb<8JO9Ka?bP_0T+ix5O9ar z=G^SilVJask-k$aoK<3;j_?xw0g3;`Z3Dq|bY{nk3=8*7RAGm_(iYz9I zM{-mPti5TL!|26C#l&<2k>bIF@Jsjar9ZFn;h|y(IKi`^prFDrwQ5^aC8gm{(9dCt zCSRg>S{mC<-*u9}KqpJttchpH3DZC?adC0f9rgFs>sgrBUr4E#jR|-@>toGdml!1t zK%3l2XU`m`it%Ffwbo`N0ejQ4%%W{*EIR_YL{tpz6H)s?E95C-Gz_|9x=YR#T!Qm8vNkq zqMG<(Z~Y?d8{1hLmP$zh$=ABI=%*|7NA?tj+vYEaeYq+eMtM+=GNLC-R)E>t5 z;GA5Gs!HS>FBbhbKL0^-VDbaT=kbB=Tkoq&t>C$gT=p5>I&Np@zZYcUvH=E@xOw}Z zW4a!0EQ1wBEAlOy_pNP=(peHaG8VmuzHV}-A?x9;Lq)dhLxR^|qC~;#F7ph2etsXF zjwnMj*VRUV6|X@wuR**^6}-7`*&=BVB@DBs1iTTxzbRTOcwfe_$FC5{0x7Q-bA1Ga zzSf1U*;nc?|5PM^s*bg(;DtO}$A;wnw-HDq5lKl&!?uv@>}>T)s|5mZN5PLDO*N3K zqz=6Y;i+I5a{V_jef^$?#a`3X)0dZ*8yg!y4$S#cN4%JaD8^CbEo-VBOOdJt6*hPdxD0so)y$HW-oEfV9H+;IT0fII&4 zHVa5qRh7F($`ezC2S1|kSt)*K)qEsQ@GwG+ab3-XQ%mkX0;3g3I^`4;e0cv}hZ6K1 zkL;_)F>h#g#bRD00~U!)Zn!NwAH^(`0Pt$Fb8^frEJ!`-SXqJLBv(M?i$88}>dd>l zDId7Wd%G)$jBWKBs&a5wx}{{n-53RTA3KBJf=$iLA||E9%g(DLhk|tOW}0hYp1xrmTVds%-Gv=vepr1&jwE&56zCZZo{&l0TVqdg)CWR z@HbADB#sSiJ`D8o?WY2bv$ejg^job+s+08h|Iaur&O`;#xNVcobw&776XD-Fu$6OC z6JN2_%GZ3v|3D~pCk|0_xLzJ9c!69)wp;xJ1QYza*WWA(v@?AguXZ!WT@!dG7uV6i z#=_^M3*-s?AIYW0&5msjP5s_vUl-`}ufhL4VYLTAR`|W|1Z$8DJFap@S=56+5Zv&Utq!GS zpFh(^Uiav!F6Z*6n-ZG`|CW&&TPyrdM(^^i^NkH(-tE7M$!8*pqN>AIwcVd1u%h!m zAB$AOOx56D$@crfb#+`nGBE$zOp4vo&@EOc7yCmh=VT0?&wJ{OXfBzKxD^AgF}#{R zCmL^GPLv)TK>>`s7bayj_t~##mcmkE5Xa%YnaOyz`kSvWB>bNJJ^#K0>|Shk_XBE}5e@}W z;-HGw(=ipoR857sm`E+t!%78h8%5M7IXbHXh#!j%}`cXW;9v3=SAa#~kU#u&b*O z1sUk`40$TKhXq@@{xUB}pOEWgm5fbZc9Jm~J;sJ1oSFKvy9z*Ehtv{WijO#e&^-nLVeC97h{{qp~5p4ufzo&BWpGt@m zG824`uQRo^B7>j!5;|Jc8LcC%y$B*jL*k;s**f9HRJC9rvdaJXvC1)r326JP-R*tS zom-ppZ~ptfX>~q+{?6!`iZ9xwoYmnkz%0qa)eIP)^@jpLk+6?U@zN@c2qSlo0SBn! zeKh`c<4JnO7x@ptoo0n0w{~`d{>zNxEiayEP8C9Rb{3kHeDCtDC*sJ~o-dGuh&#O4 z$uNk0b=Oje&V2K2!6^10`Isu24(|2jMHS{SVdKV}qvlM4ne*k|HTB^|yUWYzGZEGK zAHxO*t1pS4%sh-FpwC2&g59Jk zR(6~cyd|zIrD6Oc;aSa3sCP-(BUuR-saLs?{SYZpwx8ec=F@p-(`NHSXw)!1@BKW| zze`&!m6lB8rzb(=EQxBVu7*dg;eP&0!cUFa1kY9d-_y!f*y(EIvegK)IeDjzWvk7w zjJ_K-i~q^Z`sw%WzNQVe!!MyIPSUK7QX@r45h&e`K>Eanzns3x(dgoYv93;Pv@?m% z%O1#&+ouDamhcXHE!PzAokU*4|Nf(Aj z!TW;TQF-Y1RPU3wP~w{|c7O48pDcp8e&%R>L<_2HWvYpAwC5Fb+7jg(f#Q#A_U0mG z3oSLJ9Y@p#1xTw=V{hbM$&&W2$%g^wT`E5T;qYw!Gw?IRnf>u|j!#<(i#>qK=maqh zmp8&slJ~#3R;V9QarNEz06RwB_Mv1XDG+eTH0xU_x2=ZD>6hZ@!G5s>b4gJb} znrRKU)q{|YIE|s#Pw6&?k{%ZI&W><`fz$A^$cS|giHQEUkQ#*sW|TA;BMG!SAG7@U zHSGH;8Oy@>)cwT|0zQx*KkA-`UGIKq=at{CY6qfeUK{pNZ{j8?u;&YA+fSHIu~8@c z6LZ4jlUti9Q)n{y74TJ4pSqCWEUK*viPu;oT8_yLDKia8bK{NMuJ!C}z_UbCt?yh& z>5F*FMM1+00wl*pl(l$!DI&3n-`r;l>qz&Q5vBAJw~_4r-I7~h=F_QgRx zoiv0+9hlJe;sQ0EkD?9Z>gam&#p8H7Y)zE3_lrLh0qf7~{_HJ86uok2>8yEyDl<3i zi|qJ7xk*kRR`x!BNtYEbNie_Qq2QLwuu1)*T&bPP#`Q$(E2kybF^lsU#sBN09|w>cpw<^(Ylva8~IwA;8vhy9B??2 z4`lGjn^#$w-KAy&2HNI(+6b3jA(HEJ;vTiPctt7pwr6U1l2)@wF)Kt&U)_F+Nh+{0 z126?((>nV>o^NXHX}=~)pBE6HvOYEG+Pex-L{2bp#ethK21GE_=BT+Lr(OZ#s4l~0 z)>lb^PrulUJ|PM>)-hIVyLay%uZ0Tz)(UFGtWS=y`0|$-0ceB}H|Thi+J?S$+H$5e z@DVx3G&26mgyj`CqG9uOH7@k4DF)P7=dh}L$EL)%=@uc9`8V=p)5g~ImJW8t58lS> z-H#qT=9SG%MG~u{##p<#T^(JcY@hfc5pjZ$c7qef?tN%~#nq<0s3OEh5Kt|AYp_Z} zwQ%7PH3eRpM7G|8wYQzVelID3MA;J!sw=W@b}y)T!=bkWhaX3RqazRxb0|-tiXvB& zJV5Fe0R8dY+68|@uAa~BNmPa%;F;Vl@es$7K7E}X8 zbpjg%Eja{FWJ9E*j3Drhj6pcJII>4dhud2^#`ZQ#;)C%w2|6fo@n#5o=4*H#VT2Bl z9}!<|6dxd`BJLY-=jSkhAK*Zk{>_N)Gc2#J0vUUi>j*6&aW=Pod`ArNBPO+x|4^WK zc?B^IaZM*Dr?2ZkWzEpg(80k0i&9opbTk?8A}9YETpYp>NUZ0epsG4HKCTw+K7cp* zJoxMSHD5R`7^QO(uC?ZAkVXFKdnwqw|dp!XIP7^)*dR7*+sObWw zbfh1O=A%|)Z|UIhr^;q+o{>v!{uIcn3frs_OfNTjp^tz297YVk-y)w;T!2#oUg$TE z=^s3BLy7f$io@l##=OXhmF(%0w9?2wPunE=+1E$p0;m`iIV2ESSqPvfzX)$?dQQ3J z|MX3dJ9naUNL*F>7OvlQ>&BUT{-fwYMhKD}<)t%FeRvpBO1J*0m2;L+DG4B5TFS4w zH|rcT-mLO!>{gAN#~$m{N^wPf$rFG=VzRFI#CAyh-^7zDWg)zDQQ|P!CDf451UrU- zD#Tw(uNLax0_GPG`Cj<`QhT@Ky*Mlh0q+xqek@}gD*F`2X_o$919I@_E}K{u?+)d{ zj3Tww1T}|@KZ~uwMZr_X&O6iF6bpn>wwA^B*qT= zuS4mtwX%8Tx=j7nqnRs(1&)l>{|-ublPYSOOw#)>qz?n790-HGJMI@|#Byp2wKoEkkD|PP!u^52 zM;?LQ7FO|Wv$-(@rIgGBvbq8-j3srcP@UDM|87tMmP_C>1rq(0PF_llj3^-zo`(AL z<35ez@+l%ti!Hd!-QC^#5J5q~Vm#am;@MD(!9mMchg$FE`U2g^luWPq<8TfU*$jaM zZDVjf%4u>7iowekIW;2WJ4onpd(!iHZP`%+CDa}1<_$JBHXq?em>xMwe%dpVvo3K! z_3f8C!t?Vvr>`5~p6*hN9KIFV6P|V*6)%6V5x7I%?+$D??QqAkZcPb!cW4!6P*YRC zb1~7U8)^#O@pqW!_}GGq9#4{A%x5jG3B>DvDH^AO0v&7!$5?h1unEebw0fEkYSHCU z8x$B0fx@|JtA(b`f(G}KtqA~{n#2idg?y8xEiLm-oph$)QGO;*4cn)MQDm)c76owM zIkd0{otv{It@Tq`{elxy;=~+9FuiUo3^6BcP@=M+` zuysC1XqhtY%Rvnb*?U-W|ggbGCkaykgqUN)i=<*Jutm6aU>8dF*3 zGN|O6}5#IR3B3KW$VMc+(h*Hl$Ob5dh)>5=s+YX8Ch9Hg<*kL4cSkiSU0rh z{x}|u(e7-W!;IZn42PzU``L;QHW8heJIytueq}@sj&0fXRxK>IFO6}htxUv@St$3w zsA?FFumi!fyqIPN2DqC$H+<@fD0ZW8Q?{N~QeivP2TsNMI2237xUB_`=S$P#4|X}~ z?*_A)dX+E6;V6hHq2~5KK)q;FGP2{_vuh`Rpc}-jj7?C2Bqb;BOMF6ffp6XH?csGQ zeVjOVxoQ~p^o0IqpWcdcdU9*7f&NU`e(U~|_etC!bw6-vc0$pG#hmBwP`#OLlh#F= z)3ZWq{%*K!Y00BNM5_^mlW1Y}ftGJH&l+(h!u!IX{dv44GgTVmpb*b zdk{LX0fk2*r}I9yMK-w@Q=fsVO1vrJOHCCOC=L9oi$R=Nr!Zp^pyYqZOmgNB7yw8& zQ2{Drv)V&BUBb+%QyWOz1r$#>ZTw;pzgRZ+BdJhD zWn~PdIGAx4P+~Jr^+-OcQ6A$73}x1OYn*4gRgVY_+e=LV$5H|Za-V&Da#ABVp&r%p z_#4vm^3qjJ!pZ3jP&Yyv0Z+|D;D}f}oo9LUDrLIJ5WAe!j3^>9((qC`ngOTv_h343 zvdqoQScixJQ(^L&Z$=D?<{Q??BKz{CKIM2EyH0IoMg~UTS5XX`G(cjiMwgPR_LoUu03NIEN=DLo zd6H|q-ksN0@DTg}i#qOXFtH;J85{h;Gbo01tYU?HrFiM}O4YGtD{r?GDmIK=o@teU z!<;2Qn}+qy@zrHAe_A08y$^7Du_e|{c;L?$8i;@J_ROOH@OF0j2b}MhsoHZ)KBf^1 zMg2TwWjolMItVuQUQqVmey0Tl7I?;irI5bS;)MYW*xDcTE*$Vf?ptKE!t+ykw5}h& zXZRm)L;I*YuMK2G7@8Le82cc94-wT<&&l;ahmc(h<-|cXQcKcrQO~$rFA&3MBL3dW zGJI|H*MsiMwVdGyoK*_6FKzp_#czS6{@)06p#aA|z?n6xLh3?OQ-xF4@RGV1Y**yg zj4e+#FSQ<^=L<>v;SC*n47!?dC3!3x%I@ROP)r~OE=uL?q)X~lBBanS(RI-f)umk| zhQgdgFTmShei*trnqK2+vYRHehg!-4}bSTtYTptC1D-pm~vW zapR=R%#sI_*=I0{Z2M{GsPC+AI5 zj3=Ha5|P)LibAy++CKWh)F^=D9U&k{#jT9D^qNWLMy%57&z0n-(7+Q<MI2{tq$-*E4YHFBeWfEsvd|(t9UJiQy$l#S*%)qRsLAhz@pMjYRedv)R{E0XXTlHYiA4ohf=&+99%07jwWsJXPg@JF% z&4}&IfQ3DncuSD*{x%E;iCF&^1PQ_$-W_P+=^o-2n8Yk+bQyRmcAt>pF6gVv5B)Hn zv7p-N-OZKCS67+0QOyI(WLx^IoV>2k_8?m*T^SHUxKB1&JQ6`ed!zab*|M+qM3|u`}r7MVt-* zA2ZeC`b<9fkjA;U`7UJZf%^Z!>x`1aFye4L zR4T46O;c>2hH0QfVFi5GN*A}3T~^CMb2u}<{Ju+BN`qx_2We##>O<{!LU&hX*#?e@#J({X;n=5Rr+k3kLCaQ8|s8>zlim5I`0A1y!LB z<63%WcO?8}`dckx{*RLd?Mu;M18+=@HO!|-@guGvdN5-9N3Fl;&!uPL zmmmeOBy4}#kI%&UGx2&<^obMVyKzJFe}v#u{H3{m&UmBuJk>(n;7$ih?+n3CA+F zQxOJ`<8msU1H1JXRULP3WC!|<1jpMa%gi-3G;Xoxwt2IV$l_Vle=*g-lJ_}pa)fCz zt(K|!IW!Q^#SNd|Go6t?pL!v`f1u=8P1!Z8mZO^**OgJ>tp7DqU7@(jn~&Zdj|n3_BST-%$JNdy;)jI(A^G31X#Um82pJu0o`e2x6Up;Ly!I5#^B z5O?*sRwBu=wqVu#?pZ~>XUBLSs11p!N{TAmXw1$ythha(W}14AJ> z#BuKOa9io5Z&%R9682Ih8KSIYX~jmkNJIVW>hD#y;UeXO9%F`s(4K=)skJ?CLHvIG zdlsxK0D37~Oiz$c32H*wBXW3YWhLL9DDi^6hIuj~k_4Me%fVR9M?5dR* z&6e$u;;?o)7lJ$P`y)3OAppQuE~Eqr*c}})$fMib9ov4>r|!8}`g)*dtq5*}rzI;s zg>G4d4Fr}YhX4d%>F9BfKpx=(na4u^r!FT+f;Lz#pXhH++ks9>uDT-<8jO=P8Vu@4 zpvA69*cWNky?gw^6f)edD-69IKLC^%&HKUe#*6Y`95Ny#m+_;g<8DBpV1?TcfLWj; z9RCkLz?yG6t@UV72tNX}SpXlv58^;SAEWNa7=Te7Zo5ZK_D?(4(9z!?z#kPG%cWk7 z@rSFj?B`nyO(Msx&d5g&TjO4NL6bguQ{UY(64rtL*1vvEFa5j!EL<_`N(S(OhQe^@ zy_5j6+ypD+QLlBh25f4Qq+0}Dakuz0z^V$~P&fZG?|Q@qRP7A03M@YAveo06SYcq0 zy)7d9XmX1RM2t}5Wm#CbJ2u^G1qIJ-|I&N-<5Dr#el5&$aFO$eoaZ)*-Vg3>=|O$} z^|ShfOcb<rcXNUYd@l$i0;A$Nd{v_2xo(qH5Om> zUMe5EN6(wfxNu!vt+|ggR`Jy|#SbL>t@aBkEn7!yELZlbFpn(-&u(3;FD%U!`ISF1 zS_kiW<ulzKh-YUoW_y!kXZ1h$yjDh0=`5dX5U_ zSlTew{=5%tDe8Ur0=F59{qJ2(=jBbX{90Crqn`4`gswD^p99f9e|PQWX#xb@x|D4o zigBRmoQd%T(t8>-Fjf%~(|PoV_c_3j^&cwzlcN9skXnbafp+_pHB{2p8Ok!gJu3H`{}M+&ZTZ?j6$)cSk( zyQC2qZ$|z~qBCIvRS>d9b`uCz_XNIm?nPi1kR>k+5KCx(C-PqyKpY=g05wT5^ZGz* z$t%&b<<#q=Zvbl8099o`<82fs)m@K+1yoW@E}`R-fD$6ZsCKygoW0*h=-H4$F ztrWT~21U1R^+u+_;e0gwT|9AsPrp?k#w(Y+Q2woUNfbTo_egqVLK4!S> zTXkQ)?GPl?icx291!`plRz%-6CP?e!{K|+m^#C#}fUwl-UC(MA8`=Ak1bwbL(!)eq zOW27;^$-smUk%HazSuH{^aVJmh2>j`7&rSa4bncPRonoM_sKK?79ztUNMp|1qKI#mWL-dO_8#k%6^lfLsMDd-XsIKun3#iTKUw8QXpfwqCl@7sv`;*G~fK<&!Nm!}6WLNIb*p-2I` z+2*gxVl+g@`mgqea=eg;6s}Zp^^qdFZi~O-8%^h1-{*0$0G}kL^6S;m`4VlggZ4)g z40&VXYMAY&Np;@Ekb(BZjCv=SpiaJZL%v8fQKg%}|FtPx)t^2SCprOns%;`Ffwo4o zx>Ic2z3Q)b3)abk9@n)9O$HH7gm146Kb33TQPDnO>j@)8n8loXM#Y~Fp?LIiUEl1z zBcZs?)@>19NgBiZwcnSt74-N(V+mvP+jifvt*a#6y;5A?VIhoUqfZw#`n279zQ5(I zaVkTQ0S+fu>vmbqWLmDtuC?!NfeD{6TApf!s*3qL(qFH{U3kHP9>(O&Uyj6;fP8(F ztQgqZlSK4ES#r=4?R^`E^`T4~J~K13y|&*gK%tsS!vP)B!K# z+9cqJJfvD0*iVqgz8>U?*<$UJY^>gPkduQhib*(*sH8cB7zvuErPqn){CtzH{|HtF z^*}Sp6{gc{$PA~IO}fo&0!i~p^0k z{{H=0QGPTo@Yv&Cd%`{;=DY2M+1oV9^9(}W&lS8BL-tf|pnevrZl=R|l#{YW0-gs=Q5Z6(Pi3vv4G~BGzG)#g zP2J(#cr%3J4z98Fte|ah@s}H z7RLx%_|&wq87I5!2ftZ^mAz)K!K{H|$6WI8+2#`o#JtZn8;cQ6r(|kzHo*b|X7wJ4 zXl_K|c}$)#Y=A;A&&tTUPz2eZ zGT>oJOVhZ=l*hW`Re5UN*Stc1>SZ`sQunQ;Prs#q6T79sX873DxXS5=6WX6%3wN;l zd06eNmx=V5Nunx)mzABOaSMX6Yx&+2mtf*eBwjtK&o3``Z`e7h;A>pEiGAd~H&^C7 z&i9vA$dy!F$FN_ty$iG)Imws4SuFMT_38F~)t&;0Ftd&teA^@jorwyyFCnsvRg66i z%jh6@GKx3gFm0$9>$7_?)84VOLnIG59>JJjO0)Z=^g`MrTt>T*4EMjLUoCia=bcS$ zZ6;&SZY}4TNK$y$`4^E%mtwWVB3hbun~546aYUe<)9&*A9x46O#FdoHk3d1mk92>GfGYcX`Rpjbpc)ZT!`QHYkLc)i_BInqmOfpV=Cs#H9H%{E*rEia0h`5vU#x< z60VaX!A#D8is$n63_hBieg6FWlP?bKt;;Mq171pPYO?giz* zFVRoVE$S)*tHfqwt}`w!PEu0xxH|qq=!;|5O{HQdFQ3q!vQ8AlToK#(r=1JT5|IA| z`S8U0hq09SNzvTqA4N$l56}*19kzX?46T%~`TNsuhjQQE>(9ZKqpGJAWeSHu2X z`5%If7w6zsK{0~=-QulUPK5VuHOAtN6b$ z(q4ysh<(fHSB2nP3HV#i!2-p}oQ4R&tJ>ebFUmHd;>V2{c`WsNuZwtiAT@PA3bQE2}kc{98@~MNpzleY#9fQV7y_W zKnGjvF*H{{94A!8MP{_Lv>X>QSQ!0OUS7T@pt#dq$D}wsICwgv2}j`F?=|Z^S~*5! zX6GoS!cs!NGdG6&tk2}$C|et^37HgKKHSJw`~*-FLcB{3Ggoh#os zo=w;pG93cBl?|}nbMvas20$2AIs*J zN^j`4*Dlw%KPtf58k#OWmJgcuU(r9YJqQA+gQYvU>X zh4USZnx9BGz}s_M+c~_0zUg~r=c!jF-=1=h)3EXdnz$%btsh<9&6V$vT4;2{2%|9a zgKfX}nf;1>z5ln*tuGS<&btH%C|lToTznW89kU%@)NET)NZ@kSA&E>bK_9jyi8HVY zgCchDbjbwb&5qdxQS5rZd|?V^(v4D|yYV`c?trwwTJEDy>*|poxU&~!gd2!=(rOce z%Xb1Un)pW1k8|v^tSUMzuV5ry28qltv2&UUBKK_}3eDCsMrH1{zP@`i?aAgo)5kaU zgtVxWo>x|y&C!7N;Z*JvUt&`WD~2zwE1`lU#f2vWjle*Yc$Joa_2*$tiN?RG-VXtuV>sd&FO& zf8N!dMaBfdOt*D8I3@o(KvzqoWL7y9#U^UD4{$x zMi<_vo-Cs8_)Jfib-A|H*mv^%`i8pE0OU%r)KKb^0IY-jrT1D0JZs-gbdqLo`*-L1+l#h;_NAQse6|3-w-j_;S=wEP*Tmo* zdKy8lk`*@SP|m921dotq@~Y0t%hU0uUb=pxDrlC@rfRn*AIDPLvjPGcF8|nXY|&)f z9YEZ8{n5-aOKXkxVh+Kn-m*rL|UxTVR3P>zCL7Pz!42Z@SniGJB8x=c3~w= ztu3P_TQQow`rjIf$CPADKt_qMI6No`@5Nrq3zuc@AHSdQYd5ErWlsT7eD<2jmlrHV z)VkyS$pv;lswoTN9%Uu-La)Lo|E1pJy#h+%EjMP<5&ra%es;>3$6|?%yZoI*g^-I1 z;(Q2o_NOfeG=IL4h}&a8?zElg(ti2={ridvzm_M0wzjq))&(L+AgB;)dqAx{O$r8g zHeW{k&h*gOwW%=4-vvUju58`qv8qErh|32`5JAD__V)YfmmJbm^vkNA$8>0&>%?Jn zpJ4+5^Xs3Iz2HLY#+ljK)}YG!S?+;K{Ao!xDa2~t-aONxkrDr;VcG2_23DDXrLtMz z#ZyVkx{gD(1P`Y9|L*KNEo!f`z)Wy`^bS=@TcOfV_t^CjSjAgO_RsLy*!`6{E5$27 zITErLK9#jrzYm{Qt(%TM%EXdazI5K_p1<-GCYaPJ&T(O5Rh8l*!XbK69ljtaNm#t8 zuUip*XvmhWl|)$u5o0h4D>#OCe*|(VyE)m@RGYoW&#wx}xzeZ* zJIPf$oICRyeAOmt*yfcwLb33X;+zKM$@X*k!~)N&EbFmA6P{uaF*qg5D5Gr7V(s8c zz%yeq_Jho+X7naCs6Ra@_^f(;JGJShdaUR2Xk;>*3=Njjgb7tAcXzLvn;Pc6Hr0#} z(0LB#7z&h=W!|3k(|c|!H*{Xq8Y5Sv^VKJ;5>q=AdS4yUr|ha{t(M)uy*~?jml|I| z+-7`1xA;v46P(K9IrS;>6-q5;j)QdkZuqd0CAx;K!RQBK(o63GS`O|RL6Wjg?tc&AY>2ghA9!8;LzxDLoAL2=bx{h@g z7%avVxRnTZ)FcXiJmctEsR=*;Yp}hy9Bnw5Zj}7W&m_`$tn5j|D(nSY@zIG!V}hj3 zgxcr;y74-(?fq$`tlsPzP(REpdlA77$~Z}EG}}pps7+9mX^B1KAQ*LQNKeD|5tXyh zI7{7&>jXhMP zZQSgJJELEt-ymV)F(D@l_efG*=5(5mrQm6ncPA=DgfC*witm`P!0MtZ-l*a$*grc> zIQxku5}XJ(evMS{VY28EXdy!HW?xY86g9=^*qp283pzQD?VAbl@t$|@j*HAhN{Rr4Yz+n z>c{E91pS7@kcZ*ngF{2Wm))C5zFksMG8%Yz0Qing+R`H%V3QHr3-YQ~f^lQL_qW{^ zwCg`q5R(Y0w>H}nbG}ztYS!Xf*7Kv}4cLcBhc>(Od-ojIQt79aT85AQC>d7^kTK~`UI6JikQE1>?s2K8N=L$I0p;SCFHf48-p1B)49e@& z;xIF2cZP!BSaHd691JFK?nNI;^C8N>De(R_BGUTJcXnRdp$AP~k_PcBP2#y{W@3jN zdzUEQTt|JsG~XlHcjFGgv9^F+>V&&qL}r#&fJMbAUI&Eb=|cFliJqJZ`d$W4f6vqT z2Y+W%Ce>P9^{K{rS70Lw+`bEEomBvqF{h~L+ypOhl8TPdI_auJ;(Ic7pIje%l5}oy z{mwJXV;D+}wXpcvK&sntD*DC_en_3zkG?@kJp1Y3qlIk@ifci#4>CQS-{txQX<;T# zW=wMPLFTHku@pEfwv*G-kO<7T+y4|ImWDw_`4&`Up~80HfS43$zrb^7mhW&H;ZFgM zoX6MyLwx9Jeg{Z-ha0M8AivU`r4MVsRzX?l$~-IeQuKMwu=&>}drXoonpaVgr}wg% zStT%ZoG-XKb%7E+EvTrc1X}({P<~H+ z0sIx9?uS<2L@%bUsT-zncTp`-x?-UsvMF2zJoVG}K5{*fA8I=&l_ef{OM0?$73osg z&-Uf%JPMO%y<$7WbXULQX4jy*qD|X!_c_XFVks@(K4T3eGvLb2k-%WpfX0D#8$H8! z{C4|~eGi5fE-o#(KAWz@J%g2s-{9kxuS=xI?raVCgfn_WPiV3ry9xZlRIE=Kil?7R!U~Xb9(F6tMj9U%2Dz~$Y*}O-SVnYjB~u} zQn>z5aky{OBHDKS$B=$+DZ8>2jL@p0CN zz5DJxL_>j`7skK7qIO$N=x*&`4j^RofW>4 zq0+z*@)WfZDgk$5PPKNh*O09eE1&8q6#ZDs?gBXp+b}VT66#*UKM33M+!`e$*?x-Q zs5kI%8=}D@H%o{)v*#t9Obb+In`Go{Hc!Sv_rM`z^=18$pp!OIQ$?5EJ`dj{^6|&* zeJQ%L#6?{HVTw@#e!k(6$(@=?2&=+`*#^v8j_O82_bWF`k5*nO)}5Ze@+}3NwJ)@v zV}DC?hA5M}_r*BHHc6>sh=W0ja!so{@rX^(NwIjx&SQ5@(lf@qhVTV=%&UrUblnVf zzWWva1-C&V9eJV!&P%#xb=>AVVyUK+LPdTl9LqH`H+0>i!Mom*3iQ7cnK0@Y#Z^`{ ze#EgcsucwQbP2euC)HPfAfaJ}#|-mgPl|Kr0iq~%v5XHJ~B2&!)A zxWNz3|Ld9#(h4Oofeisd-V_oESTvOBAN$9I7~FNSxGxGRdE_vRdeX8kER$Q48MkGX z3}xN=ccJZXaue((1EqBD<8e3%Ow#Mc2k`cpj!X?_Dc z5`p4^AHu=G!3WSsEvW(Z@|-4rKaK?Of1a*ns2*@$tRUlJF@QhUaVL1B5!CD`9BgzM z32%}1miYx4gqdsQ>zNq=4eP%LS6q_YXDSBpXKzTuhdb|jdIBOV7~s}PfE)zqjNt8UD!NN8 zlnfxB-Xzj(8nL_>;X)0RIca`t#1)t=dYp_lU-GJY=pXv1Ov?tO$V^I{$#M(NrQsb= z%tRvntWsPMYAp}a+~07PAd3K!%7Q9?qZZQR1g%gal12(Y#HhQ?a>D>;0? zGrK-OKLH)DwF0#-=J)=}6s_E*+y(U{3m~2%4-lr=C=@SIi$!T=tyNS-_+LYR#oRpF z^oSG^pnP{lB?5N4(yUgktuwqq?bnA}L$qucpg);ehek(XAveKv0n^xZ{oF_$Nc%>k zp}ZkRzlXEXd)dnGwmo2ifFMy{cXz$#X{3;0-f)4X%A1DW&H1XG_S<>J#jihpM1t38 zB)m2uT0lQcB725iSnS4El~cix_)sM7?HrvFGsnXfitiMziJ;zQv71_n+&)8cDF_(? zJCuR0CN=8nO$%{K9$8Rk@u70_3_RB_hva-+;%>2~&oZp>uc;y=( zL}iiO1Z?>AwYAG7G7`+QGc#zhxL*tH+|m9}*KqO$PN*EURtIFv{YDM+ZKFbtg%f`Fi)bchHHCDI5CIdq7CbccX2w9;Kl$It^v zcS*x{jrZ?<-{*eoIgalSj_a6VX3tt{uf5`2=MK?SR|4IlzlDW`1yWH~(89vP0Rw;1 zLBkUSFg|&xuQCUBRybCN zd~|fcy-5yQ(nNOR+o%8mh=X!0QKxSU%WWKXx}V`~{QWGd6XpZZ@Q;>TEs*1L{n@d) zujdmDo+mqV`6p8It<32S4==y|`1-?p-jMMqE#2UV=~#LGMumtqfL!arywQ#EXamVj z-LTn`{q>4H=3Tj`_tRS4u4fs^g;oO~3!xa2pgi(}M-}PMk^ofgb<_0(D~T9>=xs(} z`?>lTgCUy}r4#6ixGsV}CKDgTSOZ35By3oQ7c|TMl;~AG^&ddUC%x!2!ae*}Q4*`u zWgaD&cYSPp^YTXWVyhxmFh9-X>&RO#>Fvsq6p6lg9&O{R4c)P(tLdgf;9pORahLlg zPi9bjs0{mPy9v_^^5n51-OckHu`NiIs-Cfmdzje-{QDE6DO|lpLBHM&nI!IN_n@@# z{Hq{#daeYdjIb_m1kgEBNFP`Cn?a%7k9CgYpN|I$c71LxHddu?z7wQV1PdX^no=4F zRtzom@0Uc*U;nA`D7ju@z9~p^TO@WKe{I=uCSpHVoLY~B4s%S#?~vmqt5cdKouKTOr_Hg4{18sF%p`dpuxG3M1zO8Ux+IF{gezIKj- z8~!zY;QG;%FzP~4r)jr-jwW}>l>9D-m;dBblW#Q(u*>zwOl!nJ;pw&g+ zemrfsKHuD!zqy(p+x0#XKi0T8(%7h)Fd9KWUPQ~p5V}8C)Wf|J>la;Ra6XxJEedAx zDB!M{b1NCJtJ(Rh z)c(Vx5sfZ6Tpv@_ujboRRbo+$(KlzS=@WA8cAPQ{!)kR~4nt39LYy-g7)}!!kM7Ts z5=qb784aI5?bWBMBmKz}3L+^S5h#OxFZ0KSWt!qFs)-$Bsp;h3kyB=j8J9s2Y`xl= ztWdgIAoJ0juCdphF!C&!!+(V|3k3FqkT6uDJ4{f~0cDe6++`_!@!GKS<}=?u;CnY( z2}T}Ps%E%#cgSfKg_>J@4wL6c6LiSEkZ@nd+lV~ZId>%=rO8sS<%j25Auod2A%o)} zFa&B_DVx4MUBlNqOZ3_dSDc=lp4_?4WqWuLcV9Ju_oc3pd+zz=s&wi1l_Z-F`Wm9k zRQc-idd6pG4nOy78Z|0p=?_l#T^JMfsle2EBaTYjfcVF}`0esV*cO`95Uj1qRCR)M ztD2h^h3RwO@`$ZeAO)*_jvwv(;}n&5(WP)K_1lZskta^+7Y18shk5Lj$AGn2LGIBs z7MkP6z^2*NE)ejr8+q+N;n56#*47yT9^jhcunCR4&5ZZnf*4 zX#7*<#)*CW#rHe{^{J0;=Ruw61Grm^u;_KV`@m_wq_m2^E-&!T+k1xUq zqz&8_0?in?g3aNz{(~}ggO&~(ZaBa9yO?#G&VJdC0!PC0>LmT4 zg^38nR_&Gkw(KMOM)}B=7=KMK4fqbE(0Ki8?jGCm2A;^xY_d01Zs3A$G6!^BrC&=L zaS*hiavYFY>O9C@>rRL(Vl)2e*Td3!%CS2H1_^B{ZumYEK)Xz;F=zx%5WM; z)E)t%GLvvWk4zh0dwgxp2oI4x%Tpdp#=bZ*5#pYaIRWJFqiJ0L! zI=tbs@BWG_^9UUq#Un(n+0^4d;38uL9cpZB{3+&O&WVGE#(-jd>ywr>|@4`#)yaxQlW&ti0&-gm4aX-R- z4Xp7TvI$jVY(Du&L~FhkkL6X38$gT~%54wCtCuk-<v`S^uu~sg=oOs{a&~RQzj&&(DB%* zXy@V>T#YSWT7f?^L0r64;BQ#OL$hNPEq^WbhaO=(lnk*QZZb&s+!4JZq3^%d&lE|T zfaP$==Ij3?WwHm*UE=b6Fvv^9CZnl;$_!V74`q{&y}Zwk;DIoC?a5v}WZuU6Bepq# zt>ksFT~p+9mf+KpwGi+KIB->r>I{(K4PTV+yJmq>{TlUMJ=%RNfgncG-;c%VS7J#S zgk0DiR5}d&dMUwBJA-mK!apLNK>GGj7hl6sY-4a6f*1?#GV45Hg$$_Y@-OfH?>plU#8yHm2g{>pZ?=2m_!_RnW4=;DZu)+ zKc!IWxKLv~l?0DVK~14umF3r#L&ojiBq3B7Gj{KX=1g71~Lt;XAX7yZ^X+D-j5IqqHa zw1l#-#CoH7%Hdxq8nRG`!W1`4=__s>hg&P&BB2ZV&Gv=DVDQQ{^%CcxF}Y?Qb2Pz1 zkS9-$STy0W*VU%+1P-nbU*oCSM{fKqEFVYFK8COsKL1^-kIM;xGJSU(7`$I&EGs)R zsZ2pE7DYj2$K+1KM#cY&uStk7)$b0z>#e5CgFfV-O!F$;8u z`;V#*JUwhmR=w2-MWnhc#maBdHk`|4nuY}q#&q^e3`y}xvRJVNj+)zA>VQC)TkV%@go2d*jyiLTr#ha+4vrl*O(u5Ps zeM_(m?)|I?_oOo8kzIFtMCqC*g391&3vW+ zKPK1p7WI_iuMzVa2x@p3a}Ux!8x~!u$}vo;LZOzwBx)&L`2eEUtg`aK<9EStw3q|5 zKH$@s5q~cywBdIoe4ERnYddZ~^#h8ikExrR8ZVgH??9_wL0;y*T$Ukqp>r^MM^M7V zj^%}0ZhgQJQS}nszqJ9+A@cN5i96N_iPuTp{8$1MU9f;f5rq+toars7a#KbM`o5w9 z?luka?dIP-ER&^UgI3$ke#KYPW3(_WU3TA)p+6_Q*LJzsWl4lr@rmVg%!bT&`;VLY z`^yissjb`;Xs}Nl(AT$(9a#8!Gm5`QaqK#pSaLmcFga1Aq;HnOoX9O0qK@(SE?BeQ z+cz3|_WRRgl{;YC@Y<34oLpj^w>fPNZY`I0D26I46D0;cv*o+5Tn8d8B$dv8HI%ap z6Ow0+Cr;y62?g2bD^h|70tpVQlB0Lpis7#+Sg0NNI(KI4jxDWzwMAkZDFk}CS>uT_ zbASLhu|s@IYShR)dFWEJqqseZ(1<|qhzk!qi|2!&<$)> z*jC+32^CSePDSPk&sm5JGNV@oLsj#6vt^tYDZ8ibn<5%VvD5Q4($hlHnIUD*f_lhZ zJwkCwefXzI1%f|q$D#HZob9wa6d&Sb)uP^!Gu4m|-7mOj#>PcX6q%8do)FE{f>E^0 zUlgVV`N+8mr*9`I+J0!~KNEis3Gd-InbHa}&PCLk=&h8QT{UPVed( zWb$F7-#&$E#kG7S144nB;``wO+@_8FY_j9iX6&U{ze199BreNxAZSZ5XAFM6?PAW^ zYM|I6k4w&gRVP_%k9*>Vt}`Tnly5XoKO34a3r>jq@*efPHOi0;8jj^(Amh`@v12d$ z8l_y+W*5e)`YzE|!dn#e*xAI+c_$6TxEmU_B7*Qh)G={q{z#*CaE zRM|G|Aob`WP@=zTI=b~|-hEZ$Tvv<lA4PX8?u(~7Lqb-)XMp>$aM2B?Z6ln%2oE^uO%@Jg>zaY6LRB@d$c>N&} z?Tka1%M#zl6A2gU!Fkj|v*aVxqzn@gX(e%j(4NBWVN73sSeYGo?j%wHp%tz~_$(Rntirr2GQ*GGq;N}wSaZX{RVUF9XkM7!q-T&wQ^r1p<+B3k z`4J3V_Zs)gnR+vy0_>1f&4}CECeUW(b3T$j-hcbgeqteo?B9If6?{SJ(9=csCn<8k zTPF2%*)Apj1)+z!M)0`^X-TCXikz%CVo#^rjN-Hii=ov z!Iu<_wpL`i(VdzS$L7P&j1cq^seV5k&AHCW>L7|KEE;-@1oi!lU2h1tMp>ALFMaC~ zBFo!Ai7EB~5}sJrA;$%!Z<)=EcHz0#Vk)O1hnu0DQ9LB8ov%KIGQ-8Lqd|Qy6A-88 z$Oz{qGx=*cQFK5y2!W@>0ApoES#v-ZC3GAZzf)k#BBm)sC!oK{=~=X40jIXIWz$pr z{Xz52@a0#Um7EZq9OS*6zWoUq*qHe=vpUIcU)hB47VRaPPd({-}7foK&^)&!$nt6;CK6Dl)0u~n=oxBV7SNo(!{le z5aQU=P7Tic7*s5V-dZ?{@UOvFN<#6wv#_vyFkvepWrJ*CQNUgOO=>z(A{pUl3C7#e zN-&gx_t~u#kO&JL^jd=BiqdhJ!Bbo;C zzjR9u19&OC47QK=#*B?ozKuRC^> z9l2EIk2M(A#JT6Nd05D?lUw{!87Q4I{u>s$c!DQb7EhaDk1daTK6_QHeZjRJO zt$|?H{Z|jYzW@yn!*FyLpJgfBePU8arKoMoq#2p)lZ|(_aNF_lfr~{r$beKf#j(EI z=0=~x?FW!o7h9TK$*>n~OiY>`f1|9Lm+!LqZu#~poSvxWh^d|3lSx_NraI(6RW`=< zQg;A88)nvqlluOrK-U^^P-JTrgt*|98WBb=+%2FZOj73r0IgU{PMTe zWM^kr!u-2p)3;tb%it}{bF}c{^75dT9ioU+n$98z5EX|Rw_Tt=&Fi%ou@aty8<)wt z*BNUDZ_9fPMrkpot`&XVXr6hBh{*g2WR=n&GUjjML=!(8f`zN$p?(Jd-o>UEKKLis z1AvN1{{tQ)6zeQ#5ENikCt`KZyJN$$*_}tTsf>tVi;{>Xmm^}$q2hx41?{-0zyL@` zR7e#H0^SaI@st+{U(MSeAXir`kc>wWv#Sl>~qWUHv${_<3rr z&?P9^ZM97m<;WSJ#I{AZcF3D;GQk$^8oEQL`O)f_6-1$vCPj<%*Grs`m3McNWZGi#ac^5? zOX&dWCwB^QC=$SM<+2(eD@#>rh};MX4fv4 zfJoK5e~oDs7~f7%yPD>yg-^H`!~6Nrk@p#G7t^*Fg7w$gDmnOD0dgtlsK9oA=PCOu zHFP8aVBuj(p0J2d>S8Ka*C$O;5~X1Bs1Jn(4`P}J6NJ4MScV*trW93oTlnNL+wmp7 zg8)_QXujA(<)u^~C!IXF5PYJE77RU0kt1vma-w^}@-iG_1a?_gq~jK{>udVWRALL+6DI#EL4?U(-B}?{Q?u&x~1$8}yG?-s-^ENbbS?m#}cU$BFp?TgZ*I>?j>2M&~%Ol|=KSRpL7w??}bB%2@A9 z{mZ=OE6Cu2{iQA%g5?E?y_y0Z?QvAh!S9yp=cAF_ebu#z<<8b59R%z6EDzKr_7%qF z=Swj?aGLkI)$wtukD=uC>C#4+@nzP==H|#*By^Fep@MKhfJ%X|L-7LezMtp63toxR zVE?~Ye_Lkb*hg&5RRknS)?vAu&-;NuN|J-K?=Cjv4>EA!V zLqTF)P<)+f$#WPFQ?Buw4etn}DFUL&a}tuvt4@)|%p3Z>t3Vo~!TO_NRk3*tB+mnN z5Ow^SD6p~DMn7mu)+%oe-nCValnv2W zgC^8PZC}41bDzR)tb8C&ih>x3ZzYst{N)D)0-p$kAVeSlB)uyO2dB+06$-JNDR|Pi zHk4hp#&yLM*@#`md1{}bp}a{huLnD7OWxH44lfB675L*VC+WniMLe$eW-_p!lBQ~x zh|ldW9dKNKQjB2zb@sM#hAK7(^7r4lwvioQ`9guK11~2I_F~gbks^BIFXISgD%Q{g8-u_d8Z`o^%A+Z8B9#iOywmm37Gf?p`uK zY+BtbF%8r)G;R9b(8@yT;x^hJxBoQUCRWajjR}@dp_6o1?|bX=5K$m}fwL^Ek0BN!N&sx>9}8YDq2Dp3Ucd3k^8 z_0-jyX~cG!F$3^FKU*t(*ePcuF}H=vuc4(c99;J}7ongVn&LVaF?Eg<=vQey^s{$k znFj95pY>oeGKqcnUZ2V>^>C)Y(2&>L}bW%s7WB>LS|`xq-+`|hY(Vo1j-Zv!$XQUa+*!Ec`qCjspm6%QDtO0 zI-g3I7<^Gs=L5Wks)Ww5S8KZ|oO#%B!H3QBsPUvh_xgH{FU;?f;fJ+1qkDDSHe07> zhh~5}al`S28{W{duK2GN#pMF|yf39k)T@Rxsv}UO7ad4oUs3VHu8{YaS}e!+onZKs zXU+<3viCa(V!<}BM9G_^ut%~nmfezf%LtzG3wfQ$as9dS-QzcV*Carw-lqqG$aFiWcWj< z(H>VM^yzVE$~TnwZauZ*YYs2Rz&nR8y2>!0qf^0md-<>83(`U$BW91(dq3E`qD(Vsbgc-R#*w2 zZ_YBy-dFHd%gleiBAgPQ?z7mgvLUsd(f?fci6fBlKRxf|LH?yo=rwY{8N&uO=vGQxHcRIiG{?*#%A4LNw(h*LC6WE z0O`}ZBlPY@zxeK`A8uIa;jUqnYFfd4bx$?f3%9#5`qo~W-k9R-*aZp;K?$R^8^! zUCLt{7V+~i>;v1vJ)UqYq49nCFSHkXH0MkW;kRt8p^mr0K)!v9s_~D8#3@ge##tZ^ zrJcQ>AC7I;%zv|I3Pd+I!Tp3Js?5S}vkCnj73SPhAi@iu98<2dl$RRsbmSPx?qidM zJw|3O>96_g6ez%i7%SFLCFWyK9?d81Rhb4U8iriM{EVgO(-p->+7(B~Z#ShI!vfg8 zFlMQ9KY)Z@_azem5s&|d(vr>g?OFhnVFvY?W6eP2!SWT&lk9RnK3!eJ`dE#{&}Rn( zd`W+y%U1K`r*%8_bdU{DBk5Z)u9FX$?FQv63AD1`UPw$9g$Fj=Lc*Ke zhVf3`qP1@ECK!0E=WdLoUswKEj@LiRM$#C5h&i7-Y0N*IyoYOsCx<)FsxV$^TsmK1 z)2=u*!bM-IK^fU6N;jyRdHTLRh)VBBv_w+@mQ$^reyFIR(vrq(B(+QX#CY>%ivBwV z_LVFqzQ;9!Q>o?sbO&J(p+Mks{Mq6W`>%RP#A*}j9Cbow({S}=sa_%zG##1FBq-ld zVEafLP>|8OFVrG3qVmswEd__$30R%+>fVzRqI!H^HDA%zx$f!!oCd841E+|oGE;xx zu!$Z4#103AAPRaii9KI$J$wsfk?9-~+r;>5jlPwQrT;sixyWM{meW9G{ny@t=~wv3 z*fji3o!V29GTS#(JFaxiLcd|_YnPvbEX$-tz6y7!;Ie1M)f zWXmra$*shiR?JJitu~owYvuQ^=HhD6tXB&bZHJqn6^^E*r5*IcP0)5Q4mz{J3*r$h zYlAa_h=57K9wp+Vy3ke4^B?4)5q}ClqY~e@%UX{XJP-VTcSJux6?O1Fc<1ETomP9||H}|i(-~}QRGI02 zX9zkXqAferB6fl2-9&!9%V3b^HGhC5i zN&rF1@ob4WaGT8hy=ub>8HD0p{0e`whki9m5~id?GJQd6v!n0&YZxuw zm1UrIv$5Mkv;7;&e+ zp|bq9nq;}4Sqe(-zS`%7dTF6YL z>rgBqI!`k5CsOEtrh(wNb6llVZ3gMDZx9QgI3?KM+I=05ag#_wrmEBNHOb$}X#$>1cNUj8u8cPHS z_*U%9B--r*Q-vpMuA>A2_(-7t&RPQ(FD(`i4ja;PbvFgIHlIl-Mdwm*tuRUOj=^vC z-7rBSkr6ukjX!aFAQ;NzEJF^)W{6TZ4XB}_U-Vmgj$66*7(0E&sdLa28AC-a<^lA^ zhl`-U-xYLoc(j4vm2Ko8fq6*9aY4>;xrc~63C^8DB1TV%gh*TLgyS5fP1W8S7Isws}eL0x80{8k>HCgmLzNTav&K zhQ*${?U3QvFgVnJ)0Op)@R)~upqc!S@K|2R@XoZ%68VzT>XDPn3|qQc&Fiy3)y37nrH07zW*UGwi&L^eQ6D(Eam^dg$e$~i@L%p z6uMur;Xg&;H)lON)B7jt07NkW&;6gnihqN*xt1NB*n|ujF~iDNXt$WJ?;tfCYKa-0 z;dI%gl1GD2Wf==pt#Mf!05IQt3x%})-5rBTCRJFrvdKBsW{%&{ekAQeTGCLX)Ld=S zYVB|)Nj>I~_K+U&g^CsGUK7w4G3T#h0NHo6HIQU1>q*#Ce26L7IfM5WsUTGk4UPC?NvIsFvnvJ+AIc2_2`Y_te z=f^5vXq*n{TNY{(%(Cm&I`{oXq}DS0M==wkf#jAIbLNXgApHiQbduFYQR?ZX({X*bz1cjLr>}VJ7(>dSdTq z8K!Hii#}O>))#e5^zmVC=IxCv6pMTeFc49{monfFWXTEORX*0%u=O;gT~@}*1O{z|$Z&sy?)+|BuYG5_xMdFhRw_}PzWm+PtK$%SG0 z)c$3c7BSz{q%1Qw4PzO86FboD+XVoBS)YXDj?YG6{zelR0vmK9OK`nBC;%o)w_oZ} zf}Qm<2B|9xAN-w)SaTj<9u5=%s9;gu&JR^XfO@%+Wb^GgFu16gs2~QI?uwEN!7JvZDsOGs0>HBT>!tIF9_5{V8e=-&YzhcC@9>hoX)om>H_o={dxQ1g<&BUpzIa^fkR9}jNPYEs18I1>@OV^(K zPBf3CI9%wg{FN+H1Q3Q*3_lD#*(k`4q&$p)P0{gr7VDJgjUka6Z;>$*EiNRe*UjJL zUjhktNUp$p*K=N5`m}{?6yVqjOHN4LoDCF(@io4Z1h#quAj?iTVtB)K)@3%+!10#H zjF8lS08^3)oXXr=NbJ?)t%`vS{NDhW@cb{C9Yf*;_=(&rgs0AjfG&jq6;?})bCQaY zFZ?d`H9!}9xofBF?$t~GFf`(P@Eht~^*7mf^#H4F15U^^n6(Hn;k8y%UDM_|fKgTU zkDKd@3#MHtLYVOzVWY&QlJQV(XLc)!!g9ix#2 zh!uPc0P0^;#a+Mh=vS{9^Q@-(G|t8QoUi9QH)x6aZl7Uz1~)%qhCM&!ZiLqDzSMmb zGkl=^rk-C1HRL<^&N(3~9J%3wjM+5Up>z<%jcmM)#9r__WJ*APYdp6;M*EXlvpND| zZp;eI5aJ=>=NRt%mH+RG^sD0Ye4lg9DytEW2G45qF48LRD-WK4a6>@mI4czxn!Vlm z>CC%?-g8^McUyB?`L;TB{&BXOzfO*=lgH`m#@=!o>v>UD;@dvZhM|8*2M~BSplSH+ zPFs(4fwT^gQaACH9#}Tx_%p^VMM)c}mHN@y*gtoj{}`qxF0X9-{$;d_z5l~_4|h4_ zV7Jp}y~P}nw!2_ceV8Xj@onIZDRL)Y7SC`y!dcwc& zg2~sP4J`BGEF#xNxV(+g0#g9@zV7@zF}wDn*HjvaUPV6=4wxX#aE(V5D?IJAi=x4k6qrKgj3%kUe%>yUSL-yRe8wBhZXTDd zJrb}u8{p)HiAE6BZ@z3zPi*7cJHwA<>l-%t)ZUr*GEmMl6SY`-0>uY9btZnmUZ_4`sv z_`#{f`lUxGXp4wrVA*MGtmTza!-Gqk&eM&TkH;3n?8BPoufA&(b{Ktu@?f1A)=f53 zl-}BqxVa1y6kQ5`+;M;vmAZ&;@uWRyp2qDfeqAz3)E|m!@T_5j>@DG%wHesK%W8oU zPnE;fibxY(<+0btke$3_yJ2x~C+~&~9pKDV7-ycqRhKfT`DwPT^N}%!!o~LB0CwvQ zUe-1=s!D&X5#3bMQtBWeiAk6MK;|aJbn>>?s6lW31Hu@3`q|I+`JtEO4q0^XlnmAa zO@9by8dqLIBp2k;Fr4V2{Wz~eRtg6dwM)|6YvRR&Em${1UXPLQv5uDB!@su3mK6z& z07AD;N$8@EA!Fq3DzDMjZ({roGw1!)txDgQ69i}W+BAHTjjMR-XIWvq#0npqx|4)A zE4gBN{ao4!R(u=2Ef@qh*KU>f9oe{7jd5Q{UQ_M0P#Hd;X|ZolbI ztWleHdx4rKQ~ADGo2ho~2t|;g^{2?vd{pz2HR`@;??Ea}x+%e#zT|=M$~%Xd{ZKGLC)HCdDRWBJL50s2_HM#tNM2UhlCX)^Mc#W)k|4KJZu6UPYXUiMbn{ zMRpuDygH5>QuYc>`oT-;d@cJJ6mWVR&KQBYe8VT?O-F3-7LMD-r^MZ0aWB}8!Bvjv z!&4Y4!NlyqP?6YGX4qcJ*13KQc_4eUbWKmB=sRGQ&^Sd`WJd$;adLlIYl9pP)jyjw znUN~e>2#-lf@2=@*=}XUb|Wj0!&m^F!dJg%=G<{t*NR05U65N3LD99S)`W(F>iA$t zc$fc3JHa@Ufgl{!ENSRw-2w1AuN2%K;MWNNE{I7+pE1dZx_&x?Xpqbk7Y-k=sonu~ z1SE8kTSr8gC1Klg0}-@p{8ac}ZWRtSHI%>d`BT1Bo%W#5HOs0@wg`~9h;4B8*#1-S zLj>UrDmWj++}fIiV)F)kyAb|N4?6H1F<6TN|^q9EwNEEh!bIj4ljPn z;IRk=&5#H6-3Oc)P4M`#-0EbdV+LWy_Wsghj^#lJ$d;`MMgBxhrfBIiL?WmqD#0JiMpRkqamIJ6FD_(~Bm|trq~&9Cx~P(^^RZb{k^umC=-Wg`VTB7o|$j zDEkzYtmvpM%6mgw_qO5RMVMV{YmcDqROM}*A|Aa*r>6Noo_9Ct_0}JDDo7DyQk5zU ztxM#+yf_gTmqsJe6T7N7JgiIsZY1ZI%Ualz$2K8}-FvR5@(8QRYYwcC?N%5;Q4*;; z^=cuO#yet>5`|%zUkOocEyj`zZ`Eg{BIysz%vtb1ly+Z?)ywH*rbG;B-q zbA5xxH*~|hPV?Lu;yZ5jbm8AEQzbycPhjXgO`_K`3fJ5AKH_tr7CS-(!l}2Vy1qaa z_XMcoYKciW@7cvZN_DE?>W)kmxn;$j!ShZVxFKb--(01`%|mu7927_};gNeJC=e+H zZ%C#fCjAsY^BRE{-1F&y&^-xH-A9gmQ2|-4g>kOCYcw-$>#h%r8|1rohzvu&FnBk(| z@?FXsI=t2TftNz#ZDFqJJe;Fq#uTeQw13cb>ky|&42fC_RCT~Z%>sAqqVtP^16M%y z5&+@5OpyA2Fl6WXWVj%r?MfEFB7bE86ZL$smy3k5?h(ni1inv|e zS6obY0AyZZ_z*^GkKJD7Z^LEG$_TL2}%Ll#66$itS+!#<1CcUZY%jE@uL z^QPo0=Hy~`5#}6z1;ew0{Y$lASQYZ{D#G-u50$vmzBxjeA`$2eF_&5+#dn||2_bf! ztvFJN86ZGGA+OUt1QQ%B3}@Rrm81?R zkR=cY7pM>Vl)Y;d+X*^n{Bv=g!VFf<;Wuyvfy8cMJ|MDkz1FuH-G zO$99dihr+K1=i=fB}p&uTi#W4$2{=+$$pfN!ACSMGdZJA{-k}=)u=2XBYXAO+oQFi zsWaK)B}yz8_aAh{xNfF8Q{QACBjIOocR#6^tpsdX@^@vxc=s>_y|mztyZT+uIJR=r zmw$9vA;!R#S$op{l+KJ>N z92~@|!_v4{b1U3-G*B$JntZqqVC>BjbSSzG{aV8B`iL?O28k&#I0m4kn16#GzYTZs z;NlE9w!^uSwwrv_E--spZvM7%ESp$Ajm&q;HrSAOYWMWorGKED7jsSAGwJLG;KClLN1q7}@^ID-7FAnd09c{=b100Le;n*PV>j zuJcPb_>~&Nen&#Wp6Zei=Mkn=5;1kv#@|NV+V& z2Pb#jt{Y{ao#$Gv8DUR+lM~Jpuhp$qv|jQuiu1Y7}pA>s3035x#6xLQ)_=F-Ty1>pwnHsT8{KZmlaY# z@frCD`0x4+JZ+;NpbGidTz1>6)(Q+XvE%XY&vR3%E9J&)>59DpV*`{#K!c&K>ymR+ zmsI@k0LQ7Cu&+rqUR4s-HMTi+QK&-&}qtba$5%oF2Ao zdiT^?Rb6a2UUrQJ!}yCSOhwVUUS4lx_>(@k-3wkFisUiRzYT_ksg`2+W#n>itT`d1 z=|_&j?|*lz8L5Kf$%Q=a>U`gu6U$&5U9E%S9cmiF0(fY==|ODY3%asaLf;%1cUh01&wG( z8~#cXR%LW-r#OdlsHbo-S#knGLOsrjgx~&5{6C5S3LpHhP|nm#ATO*}FCDT5Gh_jH z59dEito)AAdkKuw{tu$~_K~B8p#NYsKVnDJ#h;bDLdr!jOB>y=#70(p!tAE_l1WJ` zz|87&uQcyNakmjnFAf*k0-KT}0^D${F#6|q`Gg-pX?dE7+5@;5cH>;armX@0XQ_dC z={ztlaUa!!F}R>i1NT#df%!tGIvoT>onU19cbEX5oP1UJE+*-*rTRoUFu4x}5hEg& zESbMDkEsZ%&Svszm%fs4nGXr^;<8T(eq%GH`Yl3t)P3XYeLE(Tt(=;C(O*-7Cyj^o z)hXFJ?(Q-!`n~H3@26s;`YUq}ul{ZWfLuyd-2rp{q$?5cGF`uSOiO*vSW&Trqd5m9 zn;2L5>O+>&;r`XfitXmRxE$yE)z1-7lP$z{kBW!BT7#DU3^aNwUh6zJr@B&R*_JNH zn_Crjw5w@7Ad{Y`NL0uK^hdPoz-{Qdc#FRH^s$oXh<+Lm)3-_MNmfXaK7ypS@= zTc%1rY#LAF`M1_ww_nS^QlPHk-W;_pIhyQSuZvei_wS5JTopZjb$UVXh|$#c;ZHCk zw20naex*QsAwc4yQ@vdBOvR?Jy{Ny(t}jwly?*AOv!hsLOIPpRLk+%qD#1g!DxWwY zl}^O^Ysgz^k1tl*nq!3(qAq#-0zh`_OIs}(rmFb_sRBPvkCU7cq*n|`>F?H4Q-Gz` zx?IL{$J>LLswAy52Eum1L}so9*K3|r8OgYDBXUT`;d-Q<*Sy)WTUo8N6n{@d&2ZUX zGBi%4KikwM_J`iCb+KmAvq@UfUnrHzeEi?l423$I#$8(aYE$kT)90)8$8sZ4?k(J@gBdJPn+*@90n?4Qx%j zZ|zML47f5oEJOi<(^cLXD&lf5867hn3#;&Vs5jGpx}Fd_HGJ=LwFUVFzy_{lVL2Ml zb>3Oaa_M%wrVymszlyw0d%#tm$^%?NtAKNA0T`AQQhnEYYr48WWWN4kfz8BQ&Tot? zR2X*M4uI~d@dEcvk@eVo>;OaJ>fD~8_}a!mao@$_S=#T#xbK}+?2r%3!~H?Q(rchr zTs3`uRtU!cb7>^dW+k4T1Sd%E{P;mJVG{ad#S*UVtBF{|IOu$Q_ZQVx<2t?pr^Sd= z1Bh1iw4lap;JG(7RFJP}Wn(Z#()%V3s&1_+dZneVub10fefw=wPAI71Frl$b*uh7V zz4S}ob~L%1LDQYIhp(I3D|S?rX}~dMD8RU3fN?c#YR{(dkEM?W<*hINY~QPy^C+Ue zuk~8~F>>MD$@Qb-Mc1ZAL*92i7X6vKwU%u(kB3_m$R%{T^|(elI^8#t9)lf00L>uJ zYq$|PGNUN=UjLrbN=R~Ie2R75f060UTt$q8V=QN`D~=^kPyjiN5{#&yU-p&Er7$Vd zv6nl4Em^_xXJX8oZJn`NTl0h|_GNDDN?A&Vnwj@u)OWP#!1dU|+Aufi%WWyxOPEl$ z1=2JdZ0=XZCFY+-vC3ws_(hsrEUIYWUAr)%x^6)t3+k?K&>Hu-W+oyP2A=G;{Q{mf zfll;*@7N9jE-V`PX-Z4IDQ+ZQr?8yIf0A!f1fi!O&S=_ANwYv~hvO>L%BrYK8 zQfF_1BRnk4i}dbv*^Y{XFRH&e9(=~1+6Pd-uK<#0gqw-Y%DQ}v0aEmSShgAY3w9{_ z?nvE9aaPUaK~mh|Ny}r+D%(3ymVwo3P`{CUf)lI3lp|$`CYO93Z7s5`5kvgdu)npeTDT;9#n~t zgLMoh(f19>$+h(8flFj3SBDNFLck7Y8!mMB}Y1NY(;;I#?W zv7x-WE{QkepS?#CMp+Gf^vHXx$1IXpJWGBZ>a~_Km;BZ0-1H#NYsQjKyTY)qJt-K?R<7WjT(xB< zQbxM+Em+HQPs*@%w#9T*!2Z3i@lV_wJMJ_?X>PkP`l{2|8-ULf_-Zezi~kW$DCtRV z4g~{X9IoqvB2pqNTnqrXTLn2U@b6bdbSB9N5KhNGJD}}Zp=p?N$f6Xt6A^gS8IXzE z*`rIc7lnP{B{i4F;aXV7v;G)QU|Nxd^09iLu25^55`a!8Uj7QK+kqk4nTHzj{bG~vY{kakrH%3fcX%*)Ns^oKmfTCqi*VE6d+ZGr zE;u_goI_nyG4k4-&;L4sDl9wYBhw>`Tr(|QX@&9rpr~@Q(7g-a1G^bN<;)KQDsIn5 zirqI6!1rK-lZnp9G#+%zev2dccSkX13JZZ-h|^|9(-G!|iy8I9&`J47w1kj1xBiHe zERc_4(4m9&LPov+>fbc{{?Rm~#EHrTB1E53R}zG*w4Pw2vD1Ql$A9+9l4oIl100m& zY}<}B?SJpMO)5*6g{W{xT;*L+W)@;jD3bi474DLZ$^^dW#O85sAeq>wf8FdY+ag}R znlZpVknzO*Itvqj2WTxtQ&)GE5Zma)6W`+b1-Zl&TZHuA3f;sssA z7%vbf7xWoU#}A30X;0ccIuG<%dVH$Ds0(DWjUzUso%ezXClfCHKOFua z@!Xqx)G)vzc?3ljK+TFxHinW-{!x7) zMH!oE01G-Jdq>8F}`znOcLV z8I%YipT@GrOUS&F4o$AKk4SD4T};mCVOwTklf``{wczix6L{xIVi~TH+G-`nFYa)) z?#!mIFOi>bv>`z*;s{caK4?y@fDt(K1l0O--NaG%6mVYXZ1h%LA#SCq zWABmJR^2>dl_l=P?Ic*%-?nI zCl&uk%-!6_*&lTpU_I0mL{yhXqd0Eu#f_`S3=jvH2yvpxo11}Hn8z--mgPN`pQ5{i z+T&G3@g#6>Yoll_fS{qWqSNMCIe5g4(vwAzMz3jGPqSGE#tcUoF$qz__VkxGHlA$M z9v3{T7$Yje)&uMskbd?4vG&A!Z~skEhLp~fU#nubOyRiH7d4d8tczT$jBlfz|3kk* z0jIQ>cHIMUO$B}|w4lv!-naTWEvvXI8fM4LZeP6)=M}04k$3+6;8gMv<4sfLKFMFoM{aT%uSsGD>`6t=LCm9} zoEu<^#uf})cU+zTtfi7MM#c(iq=WtZD9-|6@(#Rg3VlZ<%WSBjT!(ttArPa;q)-^( z;djW4S`FQ9Xb2VMBASkvs8Kss0l1M$V#GWXXMD2d zCL>V|(`M~7(4hO9fUJ-X1wU^I^0pqYlJ_yWnb&lwf-y|UZlEh;{i*SYdEwW| zkJkd7%O-bewz0PFiH%S$Mvywczq0>yhA5U5XhGD!LyLo&TdMJL9Zqk;SA^_@PH!gf zR{yX2a-1tmlmKH~-|VxL_EIjJV1`EUFta!WAQw>@bSjL|%#qIShwNWVAEPiu$Hbg> z)PM671zT;+;rAn_`p4d8hDEmLh^HfZn97=-&!_bkeGidgMedD-)O_?<#;%QQmM9-z zEaUFJUYL#7B0s;jZ>JZpTGBxQyKP}lrge=>!oW6<*bP2g`n%eof=fT3x1D!W6L7(An|CVW)g@<9&%e{N@1xfE%OsGtK2z{GLlPHC1z`-pQ z%dyV*?$F4`^i>F1fMwFSOgxGfUq$i5<&3E>#}pN;edjyV{p_>?^f^S%nfbDuZCXFJ zXCxIlUeL2xO*@YxB}_)%{13%#%&!L-Q=^=g3BgLNphPoQW1r6 zATs^S;$9c&p)dlRPLz(kuI)AV9@1bb#jd{NrQ)FyrzG8EF7$pUt+*Cc8T9dVQoNe6 zs|CM2FU$G6F==uQk$>DX>J|r*mR5PGI&(F4`0&z(@o=O%6Sw-@poL7vlzF*awXrGY zai0$jjz5~Cy@Z1bcCd!Y5xqa6-5Dsv>9BCQC8VTYxf*Yj_)8_*>t$20&K!W01eS9^ zDXUQZOjx9%W$5g!$Pi4gxwfo_Gfo@VuHG}p(JYkPdl!KEp%21gMb4MwY7_}7-W&d1 z&4tC9?XZP$lFt;H1+ay8mQ=u=V29^rdo+jKeNwcCpv8-q8N-Gb$)dnwmq)Paw&$;} zI3|tj)_W7gy7Sy|xlH;ahKB0rNMDk)t!k>|U0O%m1X~cHRkksMXUArDCUP)Gm)0ge z*FkO2d6To{&L?||rsQU<5!=GATgt9ocSbUc5iQ{&QZ4dg9j+?KHFZftiBQ*31k3$; zxlo&IZ3Snv|LR=-m$$8n`CesgBh!(%`&-S2c|vShCojZ&9A*a9l1*=S(u4;7t~UK1 z71uwxN5?X#xZiiUj&!BZFUU?H$PfK{&3^1QpLzXQipo}O2Pn<0)giWGS8+M%^5O{^GWp_}lBL2M>?S(Nv9kO)M{` zdNF15C3^kMxzf8d%pVyi`QW-YqA^8_mxqbIlKfuDEgX)nyY^co_w+9Dn_yC}F5tm@ zLSdM9u@EL>rB-jT%aY;T5fPD7*kCfiQtV#NK+Iai*{_W=b%!rwhs^LX4;7my{UMmp zx%?Tiqu#?+ik_|qjzTiU_LG?+?(vUU8z>2~)kYCbCu~pm5@7Zd^rOy=&&tfo-fDEy zn2x7E)+b>N!C>wyg0P z(_6EZ)$^gS+sif*-F*JR*=L z0Ab?TAgFw5{`AZ9EYF4a7CU8H+>HC~rQ7cwL;l>|oAB<*bpE8ZuYJuZ*soXMvr7Sy z7)I(_kO!3cPG@QBdJ$y~4n=2)g}>w^R-5&4{HTStu$8gfV8 zGO*raj)04nPL_$@@U;J3_*x?GvC?5^YbV~azOZu(;Fe%tIrkd_u5TgGtMQkC_7Zpt zz7=t3WJet56a18pfBAe|ygVKaW^eM25sLc(g27GojeICQQ%nE9od;Pl0h>Tdf`u)G z+m74WG!6ntwYyd}WG(oVIl8u`B_)EO$j8D0cjT9+bR@!)!L5efKTa5=eZ}2MSI3^^ z96`b>KrV1S)0xpJ784^Ut7**--y3(3a(aCOnn9q(niUv_t6qdMoP=r@*Ut~yh6p5o zW24t=u#Q^K+%$BtWysP^CMun*n?orP*JFR-A&xWS)cY4Hu6iv?{PEba7jPGXrJfW; zNry^bI$+JReV~xIgbhN3nfASDUte&oK=M`s#i%@zPF8LdgGYjhQ@YrEjOr|5YW+}{qZP> ze}IBwCJ+&j){ztVm)b}K# z+j}p~MPEGLZAJ0fP_~`%yPIs}zPa@n`F~D-5#d?;GdHN?`ph#rcM7f?%taSo!pJjz zB6d}V${wHw=Sj2X5(O~4hUPg`gRjdnl>djw4*0U$(MNi&Q|NOw1NR*-Yis~rQ?k1e z6k|W*hfCJ0Xce?`8C3a_k6Kbsqy`xy_O)?yLvjg*nbMi`g{>b*Pg+7?H%&(hnW`&< zk;HtJ(yDBkPe4Rj6oIvPHlL-C!^tPAf7hoW;U1oyv|jH=(zX*}bQjQ}aN~lvKVYuF z)SD5nanyG>7@ThE`jvlBvrHN^2MA|zsI=P)kfJ|{*ZxwB@)ZCF14xsMBA_7M3&8=! z>j{2MVYEM76I3Xxu0Rk1sAd^+F9?+L@t}y+lma{pS!{4j`-gtvB88m0xSz?VkMRdn zX6pQFO>1>C#%pAERjkxLU}BcEf0P2~zv4J3kbItb!K)15X`>d?m&sUfoXqm=+IHWn z^Fkt7Q$eww#Wh>%Y5&O#-}5NUC#<|&@uQ!m`j#xlcxm}(f=gu+mvwf9sbRJGBA{Zj zomr+r45uiuwgexTn*$5c@kBRrblaRW|8)@c*pJwMV%K{88cy5qrm z*H*5|IXu}VgUOIbZhew^Cuk^Ma7?n%C_IwGB+^94J3V@2mE5%Zc63vBS?gbU!roEx zbCzA$=gCBB_}({%ua7A>E~=16L;~{Mlj`)1_a1d$4cxk?_=&A3p;Xt~JUk&?iHzVr z9TB{?ypj7r=wq%}okP18-M(UU)n?jgI6dO)8BU5N+lmt^b7_J)fF&~l^LwrdD1JK6 zQxG)b-NF zT*!kLd6On`$7@xNFX+p88?7gNc3J`h*=<#Q<+pj(E|-3wq|LZV((iI51=H)R_B)!O zys?g>aveE9F~5D+l)??(Xb{OG*hd0ASEYs7K7Jo`&$!wW4H)o*Ri-|nseGpg*#Wp zZe-#e9<87BiL_3uN#-(5eqerTcX?5Ea4)t^cRXsq4$Z=-eXYZ3Aah4FW-)4f@#DHZ zOg6?`PVZN3#MpAF(mrSSOtqeG)5q~?_)utM>5TqWe~i0IjYqy-?G-THb2$6c<3mak zI2ay2-@HX^qd%;d$>?=9JJs3eiE+K-h>D2g?2^2dCGrKaZ<{%2ynFxYjb#6E0P%$lNUy zzbg3dE~jMu2EVMyi~REAHU{7QR>c+ZY+H=FuPV&-W2A=|layO)lXlwj*aX#Ko?3Ang_WD7iJ7aiM8MQ@PB0z>2GiwAHPS( zgZ-GLe)&mZzGj`+I!$wk!@x4}X^Ty^iEO=9T+BPcVf86Lf$C*)hlv|{l=fdJteD!C z;0Jcd!Xcf^%D95-FL~vCNL}$VztS*V`kv1k|f}`{1w&*%U8PXsL*s_Z0_JV8kPmi;Uj8_64=#{3T8<#m9HO zbUss7HHm@XwZC5Bh=N#OLC}?n5m4yj@p|d!|;Bz&04YA%I39&D5c`UYjfA|9M+mRbnMhR)u2a;vI-o( z-kspt<`;9PLYWqZ0y`E(|2oJvvE$7NWC`_i#Life0~16@4-#mXWzd=|G%zw?QDY!} zDKic1%B^<9+}RM@&=bCE{9=F5ThN9#3D`jNmqBuO@%pym5N*#1Y3P&SubOXG!gy64K43Bi?8Yyfrc zh3EYHUM;fF-ZZpja(3OjBVGN!Zq+e*7f7KX8vnm_q`)@)Uvkl);J165>;d`SDLF_( z2gS%txUZbY^TOY$GNm0YdV@kQz!_0sD%<~{hFHvERBRyt^2*P6!^av@r!Mv}*MGjy zgTO}gy7N@9^-I%Np$vA&)i2GhV`Vl&06q=tc=-bRP_rA;doFEAu}88aT2AVX>vaos z5&+^_C~(xw?ug+N-?Q!1GG@!GF&jXF-}##j8f~HRyAFHEqUB}Kc``2!gcG9Z*8@)y zaOgW~s{LO?(DEap-Xs6RE@ixPBrv8uycKIJK8D zzJM*=HVfVoKWdtcpK6XQJ#ROLuDq5W)WHA zvg?tqL;gZDhcbNORV^i*>vgTVNwMalovP%#9xT&%_4*I9WFU;X5T+F_kWJSuYzve9J^t_lnnO z%>FvK;4L{z9ouA=ex;TDx^wUOdy{_dnJPbR6&R;7uoq#MUjbWyXqpyP%yJ6MdB{NbJzR&7W}a~}h&hZ&^dl+2@4;;wB|M07=azD8k>9yX z7&(de#8$DI{0G_nrM&eX^{T5_X-|v3i{Ug4e@@n8ahB7|T~l9E1t z7*g;gNgkS)v1)pVjFCqU6&mtCE8zc8B>aqlqJwL2TRP}$iY#Q32QvUxF6`g^ z{`Sj1>t-NZqvtBD#G+?dBMJH@h{pIwWANyowU|i)nFru=bAzRVL7NPbOadB$hFKnKx1DC??s)E4y0)``ODOU-c!rdHdsdwoPC8f0 z`EN~13L9Kv{~a7w#fv;HJw@uH zt53Ai#>sn7g{50;m8Z;Qxjzcji?dhTEY7hmPO;?&PGEiMrBEuVxxyU8yYmDjFBL+T zviZ{u-@4jsf<7k)?x)s&!Hc>g3=J((JU)UN($oj1F{|tx!1xo1)X3no<6tLG$WACs zsOWzIm$sC4lm>=M5!G;fXKiu}h&M+0Y|ge=|C;y`c-aSgRx5l`GEA)OeT=zL3Zixa zpvp&6ToAxqq6Ajo>eK;>;Kc1dI|OvvOnT_FkX?S37K5oTEC4-0!W;-z+1>9c-V}$q zTHa>qs+aiLjn9%9&&30G+?Sa8ODqA%{=Gs3?l4>&XQCJuIcry|wEkFmy_P6d_>;mO zcNyS-J!6vjhQ0j;+80!4s6Zvy^Ro{p)|BgO(-LIod*glcJ2uV+sasPB-f4trZy(8s0qDNZZHFCWLN+hVapMByz~9-q!N!O zGaub@x^k?J2g_ZNCkxEN$%kS$)$upjxzuc>XV%8^kr6Thh(H%WQj<|E6s$zB88J$@ zCD*EpKQFU!W=)m5iD0YlGOnQzsk#dhq0~LO{E&9Y?J79o`<#|Ws1pW&7bSJH&>Hf* zBS^^hm@a`Tl*{-)PzfSQ8fvEqn#eoDgApciZ$9eemIH2w(sJH$`R6i3ExY@nCO5)l z5?;nXWsD9E2K&l9f%AIWOC~MQc!c)L?yz1NAiv;Tj7<|4jSaW5V5)R1bIpIlHxQ0# zw|2%ftL03q9V6`;7auUJk5>&Vb2j5`tNjhkq0yqpUEuzv4Q7L9a3cbDwV&XI8g%~IiE0u&*2oovciP*M3dnG!av=+|{9Q_BF+{eMH# z2(tVvYmmqSk?0OpFv!4q0J-rg1}H;BA+lF9_L?A-tuIYV#p5nB9LVAcWP4NTAWR}_ zL)c!1d;pGLi$DNdVK3I7$q~~cKT|22McI~5#Ma*uc0U)vHo8!BR^#vJIER~zgn_0u z^k#&V^$}IfteHYYo!Zc&;MY{NoiY5_j1U1NhlPpC+l~X`LySNXS^O3Vpa(R1U zVF&C7*$d-TFx>}hkMu^KItiB7gNvge_0fJCFyRi`(wRj*s-woh$iKQX@;1Mmpv%0( zLcy>YzRUvJu5|IjV{y2t?ua{-&gx%7aM|{B!zi!68?QEVWtCzv^YaKlgfb4xS3c9i zuJngvKB*)TS>Z2mFWdxMYvvQM8tQ?PF#g*cD(?UfDdk_+t^7e6@dIE_3El#oyb5;s z3UdshRml6?e=RbVFh>I&Dl8OE9Tp25%&)~#jdXRKus^F6loi3NeAm(%O360p)W%4A z?!0~`jw6OQb>FXg&j||{rXtI^-Cj(}yQ^+8E0_K?9A-mUyqYcftZ5vO_OJF9BQnY?~Hs9z(By0#zj07qM3M8Fu=TW0>f+s z1Sc^UHVJ@aKegcdxRS!3zT#9(m?_o3iMYjAVPvCA#UC^XURURcc{P&aT00vGI+%9q z{A}OG_wGL?t%~yGn43)@YVh0TaS;lxph3z`jf-Q>e~|}C@!2pr)?*(@fJ{x*W`*x@ z#Y#Ocx$23#jBZaDsb4UC-Bok~wU))-#@xA?QlVL#0bE6)sNm2>w+HerLCYuHKc5f( zp^;x8iwRlY{-^Ku4+87dy@sTiyoL#aZ4cv!GdOj+3FU<$$?>KL5uv{Id~( zHwu3DuPyHX>j3`uaIOE;EB@a{C-c|;G;d3J;18s4$w9sktEQ#J#njB?`g`uZd5G?l z+6Y`J8`^-}Dnjn&K<{%lJy4UIcv@z@$xY)P>lAB^C@E!nPNQEx&}^ts}!W))Wz3@cH`?# zZakzA#ofwCcF1)w(s+|W34)XxTSKTbX#*E6{SUPtWCSCjg>m~!Grs#ypTdy%##idk zfgC?He2?O~H^vBkPYJ%s?2c}F<0^lTE_j~leK|4gV$E5p+oJ`&k6d=!q|PXdKUhgi z&R2Y2Wh6DUq$}kFWr3y6bt7@)ocp9fEaT^CH0=vv8IH>ZBEMH5;g7YQc|O&rl2mFO zme?_0U&Ue?(7`eVd!WM&OjD}0VZ(uwu`L4084dZC+;I~ib^wiAJV#h>GM@r+&+l*L z&oyM(aj3e#{Gf^KvDNn>T5p?%(IYHii2~C}Bd;kGbmClX9$Gj16qSw(!RYK6ZOJgN2EMiy~}AOq}S=11+S=i8$BmO zVOYc^zk;T#xb+2WC&_$K#UMZQIFRjGVwpz0s3f-FWI6Zfh||Se{+x9Kja~3%;o2@h zfrvV4b@(0R7S`%=Es8IauxHa5iEAI1yh-`>)UGXwsx;Dky^9LQZAQIoQL4eLy4NiC zipSCR$2&~UW+{}pTo+W%ygRDWy!&fToE-;r zANp_)`YfP^7;8UD(-NYd4NW{P-e52n03CDoJNLnHzJs?)=LoY)Bhkyxj8f^58LzfR zqXwMAV@+IVc{pK~y3DYNG_?mYDqvNNPwJYaXh*AsC+U1ChN zqK3v8^O>6m4giv$4c-!gSeJ79FenN`N=jfU(Aa!wgyrIn7~EL93)x1=>hh6Q@RuL( zBKRd^%}}T){dMQCRh3J*uTnc`In_NM6fBdGgW29M3H%=Sy-|y@aj4qW?4sp-VYOZS z%9~pO-Czi4GJHB$Ud~i^kAIG{BmdI(IsrIL>y}*@X}_7=zMYWFkr@muzD%C~u=ZfV z3R(|xxz=M7fawB490ZUs8AX_YsMmV5*bG{tEWskDI|C&xQ1NcaDjOvaEqyJ*?NaN{ zf3M+xwmfBRwBYl3#fwG$uBc3|00rd7B>OXxK_j|Eof7jLfMpHys3LOakyhqp=imFn zl0_XG|4h$fpdL*>`9RUO9&1^xlU)?395?USH!-LbGLX|tc&<+{2ev&(RFM}3d*;h$ z;zXV3-g_}fdS$YJhu<&02kw<;RSrYqpNZ zYWH8k+`Yd%s8dpQ#eN}sBFmL@N_IrEtG`EHzqPc@Zo}InYcX=%zo4Zp1wWvh?1dp@ zHQ*v?j%nk^zdy?{Im?8w&@uSNbT%>tql7EtdU$+}3JX+#qDDa--^JI z3i~Qy$uQS%_JKN_aVAR`vfAu!rJ0e}BG*quY2NOW@uakMt=(AgbqFIJSX5`qE_~3s zK44y(YdH<>jT+IUlb6*!;`qV{a8T)PH5x zbPxPeiji0#bRbFGMRnBe8{23O5(jO3Di&lCRmn>2wdm->T_qiTrfV%XNYo> zE|{Zpf5DZ#6+=&Z?*6=dwA^CMGCl`8OEffn$wx`=`h*X8Nyuq@4cQluJIK4QwaXW? z)uYyf%hxt(+Gqu>oQtg(e=u0T*^lb5=Wic1A5XE$F!-pfoTPGwRtW0rOMsK{DQmz= zLKshnQf|wnF0?F#$RJF%<~#1p;_q7b5*1Fx?0G*I5j3n}Ib&+W6LqL}n|$48Av^e` zwVPBRygPb3h||x2JX8Z`qXfmtKH4o-1Ypr>eNJS@k1i)*I1EiT72n)By@ITK#33CW zMmuuJnP)et0>3jbxs9EvZaluj<66u6L;hWx1eQ~M$Ca2xZ(N6Gw8gZoEp`P&>nSih zlW1+6YK=)jhg!0%9w~I*ZStl~WRIB}S&%lLxB80M3A%ihVlgIogg}<8EE2)&aQ<=g8t`Z}wQ}?wXg_on?n1lh2YbcC6ig;xXqj zeQIb*y>8{>FtAOKgg5BCOib3^62X{oM5e+xM~mim7TZ?2K@@{#74 zzVdRCfY^#NOLQFBng<0Dzo3x8cwEA6#dnZRBUU7dq~_{F*E3-m3nZor@T#lZF_U!M z8g`%3*P&kl6LuZ$>}vUY2`*^i}Wx%7oFa%PDOCG9`+yyvB!WFuommd^FwdwI{q z`g}P^VCkzXB}~VJ(OfZA_L--k*Oo+HI9Qy@(<0u?^GZ9KGPb4V-R8!_)8Ng6(*Lf} zPuVJMCf)a{)z91(cEfr?-jX*|*L^N%+ZT{M82w1-lf*$s|4 zcd-^AO(jIlsZKMHly>a8A#0r#Gc5fJl%-Y@N{6Kuo`y4Na5^p^0#*axSTcpTZR}+U z)g_DM7_L`-^q#Pt(pzuY|G4+{B(aF>Tm(M}vMS)2Hm1t+sOQfO%-+rrh z($ZV2{AeRzB09gTqV4s}C$b=;Z{ahy9iLJg)y&S9m^LK>wYE|bd103_OfTa*c+MIb z*=^mpJ8PY-SV7b4cn@jsMZKctkZ7>0-ecwFO&FFzS!(-DF5F9>jQg-0#zeV?^3I#0 zQ}pf|bh-hSrUja^=gOu#rQJe|K=xM;_AYHs^_3@_e4m?`WF1)4U!mfD@jvWZ4}NKs zwMvL$Db^(adqRBZGkBK#OUk%g&M8V-ZO2zA`zu;`A)X-)He#v57EMh%WL#MHi$R4c zFplY(N@tskSJ%ifbR&mtOh0xCp&O~{`Rdxt9k*8U6_}bDgscdW{zNwsXA;pp&Xujl z7V>+l?z=~h0Tw8M`ZCxhP7Mq`C%ZbGcFaSdj%G90>u$dere=TULJ@?H z0S4UxO&Oa^E}|-xIz%8==`5d^t)4ts7$#B|Wh#DBKDIo@AR|GZIJ(^*SZ;kR+E%Ik zW`Q}J$}NvJXuFGkn`8AsvJ}~r*nEDZy?2_Oo5Z9@1uwUg591kwS|^Kmjnj+GYyF+i z;&yATftfK@@bvzviodUrh*PoKU)Z8i=r5)CjEb(7W|9^ z27g6@tuFH8k|1c(qA0qvB^F_F8`>%TurGWOo>%rD&jjRa_bH*j=l?gL{{MM00mKM2 z7vZH9o}SA5*yx{7^FO0{)^O4yARv|t=-h9~){wI2A&XwQfZy?7wP*%p)$ju0tR%_6HW(Bm;eh3_XD;N;2#oQ66 zcr^0kIv4PjW$+0!ZH?lwy1k~pdSqdH6#>oUA=d)}WpKHehI=%yf9MBLziumViEC(0 zSH?4cNWjq4QSk~45Pq}(&%vqQ>@v4KXd1EtT7DLL&iE7T7``wpw|yT+zV`ynGKAeN zd2H(nWU4;%doQc309n96qyy;jU1mQi<_b8Kp=N#tFMU5iK0Q4HlywjC0DTs7gs0ru zDEIe$&-Ygh&v$`n@y6GTDerxQU8wu=W4!KD*vVa=2oD-_SwGn(0muQ@j?MY z(LL}znY&)r1u+g*gzF9?k3gTHLVSFDNNMoro7;bKT)S+KY^B*dQ|{^+CSssYuRwq$ z7pR6!->d1orrMB&Y&(N z@xp7BPYk^uwE?>QO>EG-l8bp70_z6VR(2_9p44Xjy}j#+bW( zU{Z?F*!oJg<}*erS9R@*YWl(Tfwi4F&4CFL|1c|c&=o%BSEIh&(-Lzv2EgOvt6F09 zR+ z#?C;nE?4|lrR=!eX(cy}I0Hw`&1U)eK3J5*^LBcfM#5mB#X>c)sHuwgd9vajxvEP$ zyx8f%a#{>RgyW_WxRYeLQF7IdB#~l`B##+K+mt|jcqVO1r<%+KMB2F+#E~#&8pkHi z%}Xc56m^Y??s1bQfI)g28L4n6_@H)j-n!mYm6bC-ZNV@I73DXr>3u825oN-^xKT@mhUKPLot`RSX|I zw)+|NRmCf;w|*4_uGRClr4~oL{&`@=f*?wGgB1RjwT7S(dj_rYFz?Yzsp}h{TjC7U zyQD6&TnB2VY;>TU&W`ps2!jf#N4LJ2#T#55!DZI> z0jp${Q=7f0Dd1T6?DtElPYO&Pa&?2bzYuIL9}D6l(_^EA&DvNZe`|%7&x@=I_4#>% zEJLLiC8afvq3zRk(%bBEf%YozFcfaaz-kP>2gQ|rNjhWy$&fe=Ngb2f-#Y}g7Gb2B z8I3|{e=$cxK!${x!|RjR+bc1lvByDqXGC0}j22e^DS}I)B5lp^Fs-IH2ddN%=3FHt zX5;Hz1ckeO$pjUSN;xR!)dj>D--hD46Vc+Fjcrzv#3JeOT#baG{;8*ofC*^EV)=j^ zDaC$td~C9b+lS+2zyg1vmF&%fpI15Y6b^)kzjFaoBPRP1Q=dh&#xw!aEuPufIMU&` zu^4GFd-fL6bf|-d9NJ%n8&E>>_#zanHlshk-=d;m<1z{Q7R3mf3f!fb&Ic-JyFr0! zPOTnjyr4=wqw#qDk@f&qoUmF7CpqG^W1Lnhk%s-=pqn3yFOh+H<2hr{Dsg}X>jvHeIl(3Iv zgj5qtVW1oB{w%2B4mUPnK-b`}Wi+m`2?;22HaVl7L1VHaq~%Ln(Y+-v1VNM21XFk6 zd9Qyp*;sT$tzP53q@r)^moyU6G=0-5fX>$- zQ8uZC&qmGuXJC7MN4ig6-R|yn+6RrYeJ;-fgvN06XLXd=9c-w5<`lpc9BDqn+yQpc z|0@NNyB8NJS-kuk_%IbgMP zH*NTydojNJe#Je4>ltsnH3mTiR;&T12*^=ek05M(1-0Ii!Uy4EJB=*Z2h>kKUx+g`Frs@9yT`r z@FcNcYBr zFSgZcR04#9$9|F^!LGSSgE3RAj!(Y@|Fjr132oCg(O(Ff)*c`1n_{LnM>S|$@Gs#c z?}3sSqa$~7bkn#*AxLJr0=Z6W7Qebm0^CicBj8ZF+Q(MV|I=T!j$#fx43 zJ`Jpm!CCbrI`RE_4APJB;sT!wnxr0p0S1wf5uwqLD+20bHrbIXSE1LdMuYd$m|zbQ zds=r(Cp(wE05*_s%TDQE7EC?eK>CPxX=sF!Vp-TBnZGq(#YTO)NXF-z1O-!)SWTav zXgy>8Q;do!c5KG4g`P#wn~sW+b6VGp#`A)y?IrrOl|Q}_HDTl~=SkhlIl)>=^?~U< S9cwJ`PwBq8T!D;H!2bbc!AUd# literal 15473 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 R1loi_WF!>De~21~{4aE)EiM26 diff --git a/doc/img/RemoteTCPSink_settings.png b/doc/img/RemoteTCPSink_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..491987f0b23795443f452303fde1c6c28f643afb GIT binary patch literal 55064 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�!_cg=?m(nUo;ngS|Pl#U2N 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 Clients + +Specify the maximum number of clients than can connect simultaneously. +If additional clients attempt to connect, they will be held in a queue. + +

Time Limit

+ +Specify a time limit in minutes for each client connection. Use 0 for no limit. +After the time limit expires, a client will be disconnected. +They will be allowed to reconnect if the maximum number of clients is not reached. + +

Max Channel Sample Rate

+ +Specify the maximum channel sample rate that can be set. This allows a limit to be set on network bandwidth. + +

IQ only

+ +When checked, only uncompressed IQ samples will be transmitted. This is for compatibilty with client software expecting the RTL0 protcol. +Checking this option will disable support for compression, messaging, device location and direction. + +

Compressor

+ +Specify the compressor to use. This can be FLAC or zlib. + +

Compression Level

+ +Specifies the compression effort level. Higher settings can improve compression, but require more CPU time. + +

Block size

+ +Specify the block size the compressor uses. Larger block sizes improve compression, but add latency. +Generally it should be fine to use the largest setting, unless the sample rate is very low. + +

SSL Certificate

+ +Specify an SSL certificate .pem file. This is required to use SDRangel wss protocol. + +

SSL Key

+ +Specify an SSL key .pem file. This is required to use SDRangel wss protocol. + +

List Server

+ +Check to list the server in a public directory on sdrangel.com. +This will allow other users to find and connect to the server via the Map feature. + +

Address

+ +Public IP address or hostname and port number to access the server. +The port number specified here may differ from (12) if your router's +port forwarding maps the port numbers. + +

Frequency Range

+ +Specify minimum and maximum frequencies that users can expect to receive on. +This will typically depend on the SDR and antenna. +For information only and will be displayed on the Map. + +

Antenna

+ +Optionally enter details of the antenna. +For information only and will be displayed on the Map. + +

Location

+ +Optionaly enter the location (Town and Country) of the antenna. +For information only and will be displayed on the Map. +The position the SDRangel icon will be plotted on the Map will be +taken from the device itself, which for most devices, will default +to the position in Preferences > My Position. + +

Isotropic

+ +Check to indicate the antenna is isotropic (non-directional). +When unchecked, the direction the antenna points in can be specified below. + +

Rotator

+ +Specify a Rotator feature that is controlling the direction of the antenna. +Set to None to manually set the direction the antenna points. + +

Direction

+ +Specify the direction the antenna is pointing, as Azimuth in degrees and Elevation in degrees. + +

IP Blacklist

+ +Specify a list of IP addresses that will be prevented from connecting to the server. +

15: Remote Control

When checked, remote clients will be able to change device settings. When unchecked, client requests to change settings will be ignored. From 70cf86d68a88a3a87a41fd048c781ae97e25b23d Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 19:05:18 +0100 Subject: [PATCH 12/20] Update docs. --- plugins/channelrx/remotetcpsink/readme.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/channelrx/remotetcpsink/readme.md b/plugins/channelrx/remotetcpsink/readme.md index 39fb4b4a72..6057b59372 100644 --- a/plugins/channelrx/remotetcpsink/readme.md +++ b/plugins/channelrx/remotetcpsink/readme.md @@ -86,8 +86,8 @@ TCP port on which the server will listen for connections. Specifies the protocol used for sending IQ samples and metadata to clients: - RTL0: Compatible with rtl_tcp - limited to 8-bit IQ data. -- SDRA: Enhanced version of protocol via TCP Socket. -- SDRA wss: SDRA protocol via a Secure Web Socket instead of a TCP Socket. You should use this with the WebAssembly version of SDRangel. +- SDRangel: Enhanced version of protocol via TCP Socket. +- SDRangel wss: SDRangel protocol via a WebSocket Secure instead of a TCP Socket. You should use this if you wish to allow connections from the WebAssembly version of SDRangel.

14: Display Settings

@@ -131,15 +131,17 @@ Generally it should be fine to use the largest setting, unless the sample rate i

SSL Certificate

Specify an SSL certificate .pem file. This is required to use SDRangel wss protocol. +This file can be generated in the same way as for a web server.

SSL Key

Specify an SSL key .pem file. This is required to use SDRangel wss protocol. +This file can be generated in the same way as for a web server.

List Server

-Check to list the server in a public directory on sdrangel.com. -This will allow other users to find and connect to the server via the Map feature. +Check to list the server in a public directory on https://sdrangel.com. +This will allow other users to find and connect to the server via the [Map Feature](../../feature/map/readme.md).

Address

@@ -151,17 +153,17 @@ port forwarding maps the port numbers. Specify minimum and maximum frequencies that users can expect to receive on. This will typically depend on the SDR and antenna. -For information only and will be displayed on the Map. +For information only and will be displayed on the [Map](../../feature/map/readme.md).

Antenna

Optionally enter details of the antenna. -For information only and will be displayed on the Map. +For information only and will be displayed on the [Map](../../feature/map/readme.md).

Location

Optionaly enter the location (Town and Country) of the antenna. -For information only and will be displayed on the Map. +For information only and will be displayed on the [Map](../../feature/map/readme.md). The position the SDRangel icon will be plotted on the Map will be taken from the device itself, which for most devices, will default to the position in Preferences > My Position. @@ -206,5 +208,5 @@ Displays text messages received from clients.

20: Connection Log

-Displays a the IP addresses and TCP port numbers of clients that have connected, along with when they connected and disconnected +Displays the IP addresses and TCP port numbers of clients that have connected, along with when they connected and disconnected and how long they were connected for. From 56c162fd7df89c123cf9bede004dd7a1d51e7f19 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 20:30:37 +0100 Subject: [PATCH 13/20] Fix FLAC library name on Mac. --- external/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index 1b1070471a..dc97cb3d8d 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -875,7 +875,7 @@ if(ENABLE_CHANNELRX_REMOTETCPSINK) if (WIN32) install(FILES "${SDRANGEL_BINARY_BIN_DIR}/FLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" DESTINATION "${INSTALL_LIB_DIR}") elseif (APPLE) - set(FLAC_LIBRARIES "${binary_dir}/flac/FLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") + set(FLAC_LIBRARIES "${binary_dir}/flac/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") install(DIRECTORY "${binary_dir}/libFLAC" DESTINATION "${INSTALL_LIB_DIR}" FILES_MATCHING PATTERN "libFLAC*${CMAKE_SHARED_LIBRARY_SUFFIX}") set(MACOS_EXTERNAL_LIBS_FIXUP "${MACOS_EXTERNAL_LIBS_FIXUP};${binary_dir}/libFLAC") From ffdcf89f7cd9c09da79337803afdd36a40434920 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 20:55:31 +0100 Subject: [PATCH 14/20] Fix lint warnings --- plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp | 6 +++--- .../remotetcpsink/remotetcpsinksettingsdialog.h | 2 +- sdrbase/channel/channelwebapiutils.cpp | 9 +++------ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp index c8f3af621f..c062740988 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp @@ -246,7 +246,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (DSPSignalNotification::match(message)) { - DSPSignalNotification& cfg = (DSPSignalNotification&) message; + const DSPSignalNotification& cfg = (const DSPSignalNotification&) message; if (cfg.getSampleRate() != m_basebandSampleRate) { m_bwAvg.reset(); } @@ -260,7 +260,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (RemoteTCPSink::MsgSendMessage::match(message)) { - RemoteTCPSink::MsgSendMessage& msg = (RemoteTCPSink::MsgSendMessage&) message; + const RemoteTCPSink::MsgSendMessage& msg = (const RemoteTCPSink::MsgSendMessage&) message; QString address = QString("%1:%2").arg(msg.getAddress().toString()).arg(msg.getPort()); QString callsign = msg.getCallsign(); QString text = msg.getText(); @@ -279,7 +279,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (RemoteTCPSink::MsgError::match(message)) { - RemoteTCPSink::MsgError& msg = (RemoteTCPSink::MsgError&) message; + const RemoteTCPSink::MsgError& msg = (const RemoteTCPSink::MsgError&) message; QString error = msg.getError(); QMessageBox::warning(this, "RemoteTCPSink", error, QMessageBox::Ok); return true; diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h index c136abf23a..8942351c40 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksettingsdialog.h @@ -37,7 +37,7 @@ class RemoteTCPSinkSettingsDialog : public QDialog { const QStringList& getSettingsKeys() const { return m_settingsKeys; }; private slots: - void accept(); + void accept() override; void on_browseCertificate_clicked(); void on_browseKey_clicked(); void on_addIP_clicked(); diff --git a/sdrbase/channel/channelwebapiutils.cpp b/sdrbase/channel/channelwebapiutils.cpp index 27e489c660..6a26b90188 100644 --- a/sdrbase/channel/channelwebapiutils.cpp +++ b/sdrbase/channel/channelwebapiutils.cpp @@ -2016,7 +2016,6 @@ bool DeviceOpener::open(const QString hwType, int direction, const QStringList& } int nbSamplingDevices = DeviceEnumerator::instance()->getNbRxSamplingDevices(); - bool found = false; for (int i = 0; i < nbSamplingDevices; i++) { @@ -2032,9 +2031,7 @@ bool DeviceOpener::open(const QString hwType, int direction, const QStringList& return true; } - if (!found) - { - qCritical() << "DeviceOpener::open: Failed to find device with hwType " << hwType; - return false; - } + + qWarning() << "DeviceOpener::open: Failed to find device with hwType " << hwType; + return false; } From b0a476735d0dab4cf9b948d68d536843e56ab425 Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 22:00:19 +0100 Subject: [PATCH 15/20] Fix flac on Mac --- external/CMakeLists.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index dc97cb3d8d..137e77f265 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -855,7 +855,9 @@ if(ENABLE_CHANNELRX_REMOTETCPSINK) if (WIN32) set(FLAC_LIBRARIES "${SDRANGEL_BINARY_LIB_DIR}/FLAC.lib" CACHE INTERNAL "") elseif (LINUX) - set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/lib${LIB_SUFFIX}/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") + set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") + elseif (APPLE) + set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") elseif (EMSCRIPTEN) set(FLAC_LIBRARIES "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC/libFLAC.a" CACHE INTERNAL "") endif() @@ -875,10 +877,9 @@ if(ENABLE_CHANNELRX_REMOTETCPSINK) if (WIN32) install(FILES "${SDRANGEL_BINARY_BIN_DIR}/FLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" DESTINATION "${INSTALL_LIB_DIR}") elseif (APPLE) - set(FLAC_LIBRARIES "${binary_dir}/flac/libFLAC${CMAKE_SHARED_LIBRARY_SUFFIX}" CACHE INTERNAL "") - install(DIRECTORY "${binary_dir}/libFLAC" DESTINATION "${INSTALL_LIB_DIR}" + install(DIRECTORY "${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC" DESTINATION "${INSTALL_LIB_DIR}" FILES_MATCHING PATTERN "libFLAC*${CMAKE_SHARED_LIBRARY_SUFFIX}") - set(MACOS_EXTERNAL_LIBS_FIXUP "${MACOS_EXTERNAL_LIBS_FIXUP};${binary_dir}/libFLAC") + set(MACOS_EXTERNAL_LIBS_FIXUP "${MACOS_EXTERNAL_LIBS_FIXUP};${EXTERNAL_BUILD_LIBRARIES}/flac/src/flac-build/src/libFLAC") endif () endif () From b83d514c3ba84a65804b4176a41595addcfd324a Mon Sep 17 00:00:00 2001 From: srcejon Date: Tue, 8 Oct 2024 22:19:29 +0100 Subject: [PATCH 16/20] Lint fixes --- plugins/channelrx/remotetcpsink/remotetcpsink.cpp | 10 +++++----- plugins/channelrx/remotetcpsink/remotetcpsink.h | 2 +- .../remotetcpsink/remotetcpsinkbaseband.cpp | 6 +++--- plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp | 8 ++++---- .../samplesource/remotetcpinput/remotetcpinput.cpp | 12 ++++++------ plugins/samplesource/remotetcpinput/remotetcpinput.h | 10 +++++----- .../remotetcpinput/remotetcpinputgui.cpp | 10 +++++----- .../remotetcpinput/remotetcpinputtcphandler.h | 2 +- sdrbase/util/socket.h | 4 ++-- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp index ce7f120df1..7d0ff2a43c 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.cpp @@ -167,7 +167,7 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) { if (MsgConfigureRemoteTCPSink::match(cmd)) { - MsgConfigureRemoteTCPSink& cfg = (MsgConfigureRemoteTCPSink&) cmd; + const MsgConfigureRemoteTCPSink& cfg = (const MsgConfigureRemoteTCPSink&) cmd; qDebug() << "RemoteTCPSink::handleMessage: MsgConfigureRemoteTCPSink"; applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRestartRequired()); @@ -175,7 +175,7 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) } else if (DSPSignalNotification::match(cmd)) { - DSPSignalNotification& notif = (DSPSignalNotification&) cmd; + const DSPSignalNotification& notif = (const DSPSignalNotification&) cmd; m_basebandSampleRate = notif.getSampleRate(); qDebug() << "RemoteTCPSink::handleMessage: DSPSignalNotification: m_basebandSampleRate:" << m_basebandSampleRate; @@ -191,7 +191,7 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) } else if (MsgSendMessage::match(cmd)) { - MsgSendMessage& msg = (MsgSendMessage&) cmd; + const MsgSendMessage& msg = (const MsgSendMessage&) cmd; // Forward to the sink m_basebandSink->getInputMessageQueue()->push(MsgSendMessage::create(msg.getAddress(), msg.getPort(), msg.getCallsign(), msg.getText(), msg.getBroadcast())); @@ -199,14 +199,14 @@ bool RemoteTCPSink::handleMessage(const Message& cmd) } else if (MsgReportConnection::match(cmd)) { - MsgReportConnection& msg = (MsgReportConnection&) cmd; + const MsgReportConnection& msg = (const MsgReportConnection&) cmd; m_clients = msg.getClients(); updatePublicListing(); return true; } else if (MsgReportDisconnect::match(cmd)) { - MsgReportDisconnect& msg = (MsgReportDisconnect&) cmd; + const MsgReportDisconnect& msg = (const MsgReportDisconnect&) cmd; m_clients = msg.getClients(); updatePublicListing(); return true; diff --git a/plugins/channelrx/remotetcpsink/remotetcpsink.h b/plugins/channelrx/remotetcpsink/remotetcpsink.h index b9f491ce74..bc030e08c8 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsink.h +++ b/plugins/channelrx/remotetcpsink/remotetcpsink.h @@ -198,7 +198,7 @@ class RemoteTCPSink : public BasebandSampleSink, public ChannelAPI { private: QString m_error; - MsgError(const QString& error) : + explicit MsgError(const QString& error) : Message(), m_error(error) { } diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp index 95373af9b7..6fc3d26a40 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkbaseband.cpp @@ -125,7 +125,7 @@ bool RemoteTCPSinkBaseband::handleMessage(const Message& cmd) if (RemoteTCPSink::MsgConfigureRemoteTCPSink::match(cmd)) { QMutexLocker mutexLocker(&m_mutex); - RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (RemoteTCPSink::MsgConfigureRemoteTCPSink&) cmd; + const RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (const RemoteTCPSink::MsgConfigureRemoteTCPSink&) cmd; qDebug() << "RemoteTCPSinkBaseband::handleMessage: MsgConfigureRemoteTCPSink"; applySettings(cfg.getSettings(), cfg.getSettingsKeys(), cfg.getForce(), cfg.getRestartRequired()); @@ -134,7 +134,7 @@ bool RemoteTCPSinkBaseband::handleMessage(const Message& cmd) } else if (DSPSignalNotification::match(cmd)) { - DSPSignalNotification& notif = (DSPSignalNotification&) cmd; + const DSPSignalNotification& notif = (const DSPSignalNotification&) cmd; qDebug() << "RemoteTCPSinkBaseband::handleMessage: DSPSignalNotification: basebandSampleRate:" << notif.getSampleRate(); setBasebandSampleRate(notif.getSampleRate()); m_sampleFifo.setSize(SampleSinkFifo::getSizePolicy(notif.getSampleRate())); @@ -143,7 +143,7 @@ bool RemoteTCPSinkBaseband::handleMessage(const Message& cmd) } else if (RemoteTCPSink::MsgSendMessage::match(cmd)) { - RemoteTCPSink::MsgSendMessage& msg = (RemoteTCPSink::MsgSendMessage&) cmd; + const RemoteTCPSink::MsgSendMessage& msg = (const RemoteTCPSink::MsgSendMessage&) cmd; m_sink.sendMessage(msg.getAddress(), msg.getPort(), msg.getCallsign(), msg.getText(), msg.getBroadcast()); diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp index c062740988..863fe575f0 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinkgui.cpp @@ -175,7 +175,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) { if (RemoteTCPSink::MsgConfigureRemoteTCPSink::match(message)) { - const RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (RemoteTCPSink::MsgConfigureRemoteTCPSink&) message; + const RemoteTCPSink::MsgConfigureRemoteTCPSink& cfg = (const RemoteTCPSink::MsgConfigureRemoteTCPSink&) message; if ((cfg.getSettings().m_channelSampleRate != m_settings.m_channelSampleRate) || (cfg.getSettings().m_sampleBits != m_settings.m_sampleBits)) { @@ -195,7 +195,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (RemoteTCPSink::MsgReportConnection::match(message)) { - const RemoteTCPSink::MsgReportConnection& report = (RemoteTCPSink::MsgReportConnection&) message; + const RemoteTCPSink::MsgReportConnection& report = (const RemoteTCPSink::MsgReportConnection&) message; ui->clients->setText(QString("%1/%2").arg(report.getClients()).arg(m_settings.m_maxClients)); QString ip = QString("%1:%2").arg(report.getAddress().toString()).arg(report.getPort()); @@ -208,7 +208,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (RemoteTCPSink::MsgReportDisconnect::match(message)) { - const RemoteTCPSink::MsgReportDisconnect& report = (RemoteTCPSink::MsgReportDisconnect&) message; + const RemoteTCPSink::MsgReportDisconnect& report = (const RemoteTCPSink::MsgReportDisconnect&) message; ui->clients->setText(QString("%1/%2").arg(report.getClients()).arg(m_settings.m_maxClients)); QString ip = QString("%1:%2").arg(report.getAddress().toString()).arg(report.getPort()); @@ -222,7 +222,7 @@ bool RemoteTCPSinkGUI::handleMessage(const Message& message) } else if (RemoteTCPSink::MsgReportBW::match(message)) { - const RemoteTCPSink::MsgReportBW& report = (RemoteTCPSink::MsgReportBW&) message; + const RemoteTCPSink::MsgReportBW& report = (const RemoteTCPSink::MsgReportBW&) message; m_bwAvg(report.getBW()); m_networkBWAvg(report.getNetworkBW()); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp index ce54ddeb47..ae27a4cf52 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.cpp @@ -220,14 +220,14 @@ bool RemoteTCPInput::handleMessage(const Message& message) else if (MsgConfigureRemoteTCPInput::match(message)) { qDebug() << "RemoteTCPInput::handleMessage:" << message.getIdentifier(); - MsgConfigureRemoteTCPInput& conf = (MsgConfigureRemoteTCPInput&) message; + const MsgConfigureRemoteTCPInput& conf = (const MsgConfigureRemoteTCPInput&) message; applySettings(conf.getSettings(), conf.getSettingsKeys(), conf.getForce()); return true; } else if (RemoteTCPInputTCPHandler::MsgReportConnection::match(message)) { qDebug() << "RemoteTCPInput::handleMessage:" << message.getIdentifier(); - RemoteTCPInputTCPHandler::MsgReportConnection& report = (RemoteTCPInputTCPHandler::MsgReportConnection&) message; + const RemoteTCPInputTCPHandler::MsgReportConnection& report = (const RemoteTCPInputTCPHandler::MsgReportConnection&) message; if (report.getConnected()) { qDebug() << "Disconnected - stopping DSP"; @@ -237,19 +237,19 @@ bool RemoteTCPInput::handleMessage(const Message& message) } else if (MsgSaveReplay::match(message)) { - MsgSaveReplay& cmd = (MsgSaveReplay&) message; + const MsgSaveReplay& cmd = (const MsgSaveReplay&) message; m_replayBuffer.save(cmd.getFilename(), m_settings.m_devSampleRate, getCenterFrequency()); return true; } else if (MsgSendMessage::match(message)) { - MsgSendMessage& msg = (MsgSendMessage&) message; + const MsgSendMessage& msg = (const MsgSendMessage&) message; m_remoteInputTCPPHandler->getInputMessageQueue()->push(MsgSendMessage::create(msg.getCallsign(), msg.getText(), msg.getBroadcast())); return true; } else if (MsgReportPosition::match(message)) { - MsgReportPosition& report = (MsgReportPosition&) message; + const MsgReportPosition& report = (const MsgReportPosition&) message; m_latitude = report.getLatitude(); m_longitude = report.getLongitude(); @@ -261,7 +261,7 @@ bool RemoteTCPInput::handleMessage(const Message& message) } else if (MsgReportDirection::match(message)) { - MsgReportDirection& report = (MsgReportDirection&) message; + const MsgReportDirection& report = (const MsgReportDirection&) message; m_isotropic = report.getIsotropic(); m_azimuth = report.getAzimuth(); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.h b/plugins/samplesource/remotetcpinput/remotetcpinput.h index 449109a315..a2c3831cce 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.h @@ -79,10 +79,10 @@ class RemoteTCPInput : public DeviceSampleSource { return new MsgStartStop(startStop); } - protected: + private: bool m_startStop; - MsgStartStop(bool startStop) : + explicit MsgStartStop(bool startStop) : Message(), m_startStop(startStop) { } @@ -105,7 +105,7 @@ class RemoteTCPInput : public DeviceSampleSource { outBytesAvailable, outSize, outSeconds); } - protected: + private: qint64 m_inBytesAvailable; qint64 m_inSize; float m_inSeconds; @@ -135,7 +135,7 @@ class RemoteTCPInput : public DeviceSampleSource { return new MsgSaveReplay(filename); } - protected: + private: QString m_filename; MsgSaveReplay(const QString& filename) : @@ -156,7 +156,7 @@ class RemoteTCPInput : public DeviceSampleSource { return new MsgSendMessage(callsign, text, broadcast); } - protected: + private: QString m_callsign; QString m_text; bool m_broadcast; diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp index ea52cbf892..7270b4c03b 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputgui.cpp @@ -151,7 +151,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) { if (RemoteTCPInput::MsgConfigureRemoteTCPInput::match(message)) { - const RemoteTCPInput::MsgConfigureRemoteTCPInput& cfg = (RemoteTCPInput::MsgConfigureRemoteTCPInput&) message; + const RemoteTCPInput::MsgConfigureRemoteTCPInput& cfg = (const RemoteTCPInput::MsgConfigureRemoteTCPInput&) message; if (cfg.getForce()) { m_settings = cfg.getSettings(); @@ -166,7 +166,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else if (RemoteTCPInput::MsgStartStop::match(message)) { - RemoteTCPInput::MsgStartStop& notif = (RemoteTCPInput::MsgStartStop&) message; + const RemoteTCPInput::MsgStartStop& notif = (const RemoteTCPInput::MsgStartStop&) message; m_connectionError = false; blockApplySettings(true); ui->startStop->setChecked(notif.getStartStop()); @@ -175,7 +175,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else if (RemoteTCPInput::MsgReportTCPBuffer::match(message)) { - const RemoteTCPInput::MsgReportTCPBuffer& report = (RemoteTCPInput::MsgReportTCPBuffer&) message; + const RemoteTCPInput::MsgReportTCPBuffer& report = (const RemoteTCPInput::MsgReportTCPBuffer&) message; ui->inGauge->setMaximum((int)report.getInSize()); ui->inGauge->setValue((int)report.getInBytesAvailable()); ui->inBufferLenSecsText->setText(QString("%1s").arg(report.getInSeconds(), 0, 'f', 2)); @@ -186,7 +186,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else if (RemoteTCPInputTCPHandler::MsgReportRemoteDevice::match(message)) { - const RemoteTCPInputTCPHandler::MsgReportRemoteDevice& report = (RemoteTCPInputTCPHandler::MsgReportRemoteDevice&) message; + const RemoteTCPInputTCPHandler::MsgReportRemoteDevice& report = (const RemoteTCPInputTCPHandler::MsgReportRemoteDevice&) message; QHash devices = { {RemoteTCPProtocol::RTLSDR_E4000, "RTLSDR E4000"}, {RemoteTCPProtocol::RTLSDR_FC0012, "RTLSDR FC0012"}, @@ -301,7 +301,7 @@ bool RemoteTCPInputGui::handleMessage(const Message& message) } else if (RemoteTCPInputTCPHandler::MsgReportConnection::match(message)) { - const RemoteTCPInputTCPHandler::MsgReportConnection& report = (RemoteTCPInputTCPHandler::MsgReportConnection&) message; + const RemoteTCPInputTCPHandler::MsgReportConnection& report = (const RemoteTCPInputTCPHandler::MsgReportConnection&) message; qDebug() << "RemoteTCPInputGui::handleMessage: MsgReportConnection connected: " << report.getConnected(); m_connectionError = !report.getConnected(); updateStatus(); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h index 192873f434..082c2a190d 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.h @@ -45,7 +45,7 @@ class DeviceAPI; class FIFO { public: - FIFO(qsizetype elements = 10); + explicit FIFO(qsizetype elements = 10); qsizetype write(quint8 *data, qsizetype elements); qsizetype read(quint8 *data, qsizetype elements); diff --git a/sdrbase/util/socket.h b/sdrbase/util/socket.h index 4e15b6d8bc..28adb865d5 100644 --- a/sdrbase/util/socket.h +++ b/sdrbase/util/socket.h @@ -54,7 +54,7 @@ class SDRBASE_API TCPSocket : public Socket { public: - TCPSocket(QTcpSocket *socket) ; + explicit TCPSocket(QTcpSocket *socket) ; qint64 write(const char *data, qint64 length) override; void flush() override; qint64 read(char *data, qint64 length) override; @@ -72,7 +72,7 @@ class SDRBASE_API WebSocket : public Socket { public: - WebSocket(QWebSocket *socket); + explicit WebSocket(QWebSocket *socket); qint64 write(const char *data, qint64 length) override; void flush() override; qint64 read(char *data, qint64 length) override; From 3dc0ac7f9153ad6ab9b286968351e7936e41b279 Mon Sep 17 00:00:00 2001 From: srcejon Date: Wed, 9 Oct 2024 08:49:23 +0100 Subject: [PATCH 17/20] Lint fixes --- .../channelrx/remotetcpsink/remotetcpsinksink.cpp | 6 +++--- .../samplesource/remotetcpinput/remotetcpinput.h | 2 +- .../remotetcpinput/remotetcpinputtcphandler.cpp | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp index f3f8fc2dd0..d5977e32bf 100644 --- a/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp +++ b/plugins/channelrx/remotetcpsink/remotetcpsinksink.cpp @@ -643,9 +643,9 @@ void RemoteTCPSinkSink::applySettings(const RemoteTCPSinkSettings& settings, con if (initZLib && (m_settings.m_compression == RemoteTCPSinkSettings::ZLIB)) { // Intialise zlib compression - m_zStream.zalloc = Z_NULL; - m_zStream.zfree = Z_NULL; - m_zStream.opaque = Z_NULL; + m_zStream.zalloc = nullptr; + m_zStream.zfree = nullptr; + m_zStream.opaque = nullptr; m_zStream.data_type = Z_BINARY; int windowBits = log2(m_settings.m_blockSize); diff --git a/plugins/samplesource/remotetcpinput/remotetcpinput.h b/plugins/samplesource/remotetcpinput/remotetcpinput.h index a2c3831cce..5ef3ec9e78 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinput.h +++ b/plugins/samplesource/remotetcpinput/remotetcpinput.h @@ -138,7 +138,7 @@ class RemoteTCPInput : public DeviceSampleSource { private: QString m_filename; - MsgSaveReplay(const QString& filename) : + explicit MsgSaveReplay(const QString& filename) : Message(), m_filename(filename) { } diff --git a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp index ad91f26d19..d383326b28 100644 --- a/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp +++ b/plugins/samplesource/remotetcpinput/remotetcpinputtcphandler.cpp @@ -67,11 +67,11 @@ RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler(SampleSinkFifo *sampleFifo, D m_reconnectTimer.setSingleShot(true); // Initialise zlib decompressor - m_zStream.zalloc = Z_NULL; - m_zStream.zfree = Z_NULL; - m_zStream.opaque = Z_NULL; + m_zStream.zalloc = nullptr; + m_zStream.zfree = nullptr; + m_zStream.opaque = nullptr; m_zStream.avail_in = 0; - m_zStream.next_in = Z_NULL; + m_zStream.next_in = nullptr; if (Z_OK != inflateInit(&m_zStream)) { qDebug() << "RemoteTCPInputTCPHandler::RemoteTCPInputTCPHandler: inflateInit failed."; } @@ -2059,7 +2059,7 @@ void RemoteTCPInputTCPHandler::processDecompressedData(int requiredSamples) calcPower(reinterpret_cast(buf), len / 2); - m_sampleFifo->write((quint8 *) buf, len * sizeof(FixReal)); + m_sampleFifo->write(reinterpret_cast(buf), len * sizeof(FixReal)); } m_uncompressedData.read(uncompressedBytes); @@ -2216,13 +2216,13 @@ bool RemoteTCPInputTCPHandler::handleMessage(const Message& cmd) if (MsgConfigureTcpHandler::match(cmd)) { qDebug() << "RemoteTCPInputTCPHandler::handleMessage: MsgConfigureTcpHandler"; - MsgConfigureTcpHandler& notif = (MsgConfigureTcpHandler&) cmd; + const MsgConfigureTcpHandler& notif = (const MsgConfigureTcpHandler&) cmd; applySettings(notif.getSettings(), notif.getSettingsKeys(), notif.getForce()); return true; } else if (RemoteTCPInput::MsgSendMessage::match(cmd)) { - RemoteTCPInput::MsgSendMessage& msg = (RemoteTCPInput::MsgSendMessage&) cmd; + const RemoteTCPInput::MsgSendMessage& msg = (const RemoteTCPInput::MsgSendMessage&) cmd; sendMessage(MainCore::instance()->getSettings().getStationName(), msg.getText(), msg.getBroadcast()); From bb5c7447db54a0291240987fc954454621d27bea Mon Sep 17 00:00:00 2001 From: srcejon Date: Wed, 9 Oct 2024 09:08:48 +0100 Subject: [PATCH 18/20] Add libflac to snap. Set ARCH_OPT to nehalem. --- snap/snapcraft.yaml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 5e7b45b4c0..ff20b53937 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -10,7 +10,7 @@ # # To install sdrangel local snap: # -# sudo snap install sdrangel_7.19.0_amd64.snap --dangerous +# sudo snap install sdrangel_7.22.1_amd64.snap --dangerous # # Users appear to need to grant h/w access manually from command line with: # @@ -36,11 +36,19 @@ # snapcraft clean uhd # snapcraft stage uhd --shell-after # +# +# To publish to snap store: +# +# snapcraft login +# snapcraft push sdrangel_7.22.1_amd64.snap --release=stable,edge,candidate,beta +# +# Can also manage/promote releases at: https://snapcraft.io/sdrangel/releases + name: sdrangel base: core22 type: app -version: "7.19.0" +version: "7.22.1" summary: SDRangel description: SDRangel is an Open Source Qt5 / OpenGL 3.0+ SDR and signal analyzer frontend to various hardware. SSE 4.2 required. confinement: strict @@ -139,7 +147,7 @@ parts: plugin: cmake source: https://github.com/f4exb/sdrangel source-type: git - source-tag: v7.19.0 + source-tag: v7.22.1 after: [apt, libdab, mbelib, serialdv, dsdcc, codec2, sgp4, cm265cc, libsigmf, airspy, rtlsdr, pluto, bladerf, hackrf, limesuite, airspyhf, uhd, uhdfpga, soapysdr, soapyremote] cmake-parameters: - -DDEBUG_OUTPUT=OFF @@ -167,6 +175,7 @@ parts: - -DSGP4_DIR=$SNAPCRAFT_STAGE/opt/install/sdrangel - -DLIBSIGMF_DIR=$SNAPCRAFT_STAGE/opt/install/sdrangel - -DDAB_DIR=$SNAPCRAFT_STAGE/opt/install/sdrangel + - -DARCH_OPT=nehalem #- -DQt5_DIR=/usr/lib/x86_64-linux-gnu/cmake/Qt5 build-packages: - libfftw3-dev @@ -192,6 +201,7 @@ parts: - libqt5texttospeech5-dev - libqt5gamepad5-dev - libfaad-dev + - libflac-dev - zlib1g-dev - libboost-all-dev - libasound2-dev From 64461171ac706641d1459193c12f6ead5cd5dc30 Mon Sep 17 00:00:00 2001 From: srcejon Date: Wed, 9 Oct 2024 09:25:06 +0100 Subject: [PATCH 19/20] snap: Try to get version number from latest tag. --- snap/snapcraft.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ff20b53937..0b753bfce5 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -48,7 +48,7 @@ name: sdrangel base: core22 type: app -version: "7.22.1" +adopt-info: sdrangel summary: SDRangel description: SDRangel is an Open Source Qt5 / OpenGL 3.0+ SDR and signal analyzer frontend to various hardware. SSE 4.2 required. confinement: strict @@ -147,7 +147,9 @@ parts: plugin: cmake source: https://github.com/f4exb/sdrangel source-type: git - source-tag: v7.22.1 + override-pull: | + snapcraftctl pull + snapcraftctl set-version "$(git describe --tags --abbrev=0 | sed 's/v//')" after: [apt, libdab, mbelib, serialdv, dsdcc, codec2, sgp4, cm265cc, libsigmf, airspy, rtlsdr, pluto, bladerf, hackrf, limesuite, airspyhf, uhd, uhdfpga, soapysdr, soapyremote] cmake-parameters: - -DDEBUG_OUTPUT=OFF From 5362c346988ce09c3af81fa8b5eaf21d826eabaf Mon Sep 17 00:00:00 2001 From: srcejon Date: Wed, 9 Oct 2024 14:39:29 +0100 Subject: [PATCH 20/20] Fix replay buffer when FixReal is qint16 --- sdrbase/dsp/replaybuffer.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdrbase/dsp/replaybuffer.h b/sdrbase/dsp/replaybuffer.h index f7dd2e4ed6..9e52ab0c16 100644 --- a/sdrbase/dsp/replaybuffer.h +++ b/sdrbase/dsp/replaybuffer.h @@ -209,9 +209,9 @@ class ReplayBuffer { return data; } - qint16 conv(FixReal data) const + qint16 conv(qint32 data) const { - return data; // FIXME: + return data >> 16; } qint16 conv(float data) const