Added SAP browser functionalities to the daemon and the WebUI

- added "sap_mcast_addr" parameter to daemon conguration and WebUI to configure the SAP multicast address used for sending and receiving source announcements
- added REST API to retrieve the remote sources collected by the daemon (GET /api/browse/sources)
- added Browser tab to the WebUI to visualize info on the available remote sources
- added Info function to sources listed in the Sources tab to visualize the associated SDP file
- extended daemon regression tests to test the SAP Browser
This commit is contained in:
Andrea Bondavalli 2020-03-06 10:58:07 -08:00
parent f57046a478
commit d99bf3ed4a
28 changed files with 1602 additions and 731 deletions

263
README.md
View File

@ -1,132 +1,131 @@
# AES67 Linux Daemon # AES67 Linux Daemon
## License ## ## License ##
AES67 daemon and the WebUI are licensed under [GNU GPL](https://www.gnu.org/licenses/gpl-3.0.en.html). AES67 daemon and the WebUI are licensed under [GNU GPL](https://www.gnu.org/licenses/gpl-3.0.en.html).
The daemon uses the following open source: The daemon uses the following open source:
* **Merging Technologies ALSA RAVENNA/AES67 Driver** licensed under [GNU GPL](https://www.gnu.org/licenses/gpl-3.0.en.html). * **Merging Technologies ALSA RAVENNA/AES67 Driver** licensed under [GNU GPL](https://www.gnu.org/licenses/gpl-3.0.en.html).
* **cpp-httplib** licensed under the [MIT License](https://github.com/yhirose/cpp-httplib/blob/master/LICENSE) * **cpp-httplib** licensed under the [MIT License](https://github.com/yhirose/cpp-httplib/blob/master/LICENSE)
* **Boost libraries** licensed with [Boost Software License](https://www.boost.org/LICENSE_1_0.txt) * **Boost libraries** licensed under the [Boost Software License](https://www.boost.org/LICENSE_1_0.txt)
## Repository content ## ## Repository content ##
### [daemon](daemon) directory ### ### [daemon](daemon) directory ###
This directory contains the AES67 daemon source code. This directory contains the AES67 daemon source code.
The daemon can be cross-compiled for multiple platforms and implements the following functionalities: The daemon can be cross-compiled for multiple platforms and implements the following functionalities:
* control and configuration of up to 64 sources and sinks using the ALSA RAVENNA/AES67 driver via netlink * control and configuration of up to 64 sources and sinks using the ALSA RAVENNA/AES67 driver via netlink
* session handling and SDP parsing and creation * session handling and SDP parsing and creation
* HTTP REST API for control and configuration * HTTP REST API for control and configuration
* SAP discovery protocol implementation * SAP discovery protocol implementation and SAP browser implementation
* IGMP handling for SAP, RTP and PTP multicast traffic * IGMP handling for SAP, RTP and PTP multicast traffic
The directory also contains the daemon regression tests in the [tests](daemon/tests) subdirectory. To run daemon tests install the ALSA RAVENNA/AES67 kernel module enter the [tests](daemon/tests) subdirectory and run *./daemon-test -l all* The directory also contains the daemon regression tests in the [tests](daemon/tests) subdirectory. To run daemon tests install the ALSA RAVENNA/AES67 kernel module enter the [tests](daemon/tests) subdirectory and run *./daemon-test -l all*
See the [README](daemon/README.md) file in this directory for additional information about the AES67 daemon configuration and the HTTP REST API. See the [README](daemon/README.md) file in this directory for additional information about the AES67 daemon configuration and the HTTP REST API.
### [webui](webui) directory ### ### [webui](webui) directory ###
This directory contains the AES67 daemon WebUI configuration implemented using React. This directory contains the AES67 daemon WebUI configuration implemented using React.
With the WebUI a user can do the following operations: With the WebUI a user can do the following operations:
* change the daemon configuration, this causes a daemon restart * change the daemon configuration, this causes a daemon restart
* edit PTP clock slave configuration and monitor PTP slave status * edit PTP clock slave configuration and monitor PTP slave status
* add and edit RTP Sources * add and edit RTP Sources
* add, edit and monitor RTP Sinks * add, edit and monitor RTP Sinks
### [3rdparty](3rdparty) directory ### ### [3rdparty](3rdparty) directory ###
This directory is used to download the 3rdparty open source. This directory is used to download the 3rdparty open source.
The [patches](3rdparty/patches) subdirectory contains patches applied to the ALSA RAVENNA/AES67 module to compile with the Linux Kernel 5.x and on ARMv7 platforms and to enable operations on the network loopback device (for testing purposes). The [patches](3rdparty/patches) subdirectory contains patches applied to the ALSA RAVENNA/AES67 module to compile with the Linux Kernel 5.x and on ARMv7 platforms and to enable operations on the network loopback device (for testing purposes).
The ALSA RAVENNA/AES67 kernel module is responsible for: The ALSA RAVENNA/AES67 kernel module is responsible for:
* registering as an ALSA driver * registering as an ALSA driver
* generating and receiving RTP audio packets * generating and receiving RTP audio packets
* PTP slave operations and PTP driven interrupt loop * PTP slave operations and PTP driven interrupt loop
* netlink communication between user and kernel * netlink communication between user and kernel
See [ALSA RAVENNA/AES67 Driver README](https://bitbucket.org/MergingTechnologies/ravenna-alsa-lkm/src/master/README.md) for additional information about the Merging Technologies module and for proper Linux Kernel configuration and tuning. See [ALSA RAVENNA/AES67 Driver README](https://bitbucket.org/MergingTechnologies/ravenna-alsa-lkm/src/master/README.md) for additional information about the Merging Technologies module and for proper Linux Kernel configuration and tuning.
### [demo](demo) directory ### ### [demo](demo) directory ###
This directory contains a the daemon configuration and status files used to run a short demo on the network loopback device. The [demo](#demo) is described below. This directory contains a the daemon configuration and status files used to run a short demo on the network loopback device. The [demo](#demo) is described below.
## Prerequisite ## ## Prerequisite ##
<a name="prerequisite"></a> <a name="prerequisite"></a>
The daemon and the demo have been tested with **Ubuntu 18.04** distro on **x86/ARMv7** and with **Ubuntu 19.10** distro on **x86** using: The daemon and the demo have been tested with **Ubuntu 18.04** distro on **x86/ARMv7** and with **Ubuntu 19.10** distro on **x86** using:
* Linux kernel version >= 4.14.x * Linux kernel version >= 4.14.x
* GCC version >= 7.4 / clang >= 6.0.0 (C++17 support required, clang is required to compile on ARMv7) * GCC version >= 7.4 / clang >= 6.0.0 (C++17 support required, clang is required to compile on ARMv7)
* cmake version >= 3.10.2 * cmake version >= 3.10.2
* node version >= 8.10.0 * node version >= 8.10.0
* npm version >= 3.5.2 * npm version >= 3.5.2
* boost libraries version >= 1.65.1 * boost libraries version >= 1.65.1
The BeagleBone® Black board with ARM Cortex-A8 32-Bit processor was used for testing on ARMv7. The BeagleBone® Black board with ARM Cortex-A8 32-Bit processor was used for testing on ARMv7.
See [Ubuntu 18.04 on BeagleBone® Black](https://elinux.org/BeagleBoardUbuntu) for additional information about how to setup Ubuntu on this board. See [Ubuntu 18.04 on BeagleBone® Black](https://elinux.org/BeagleBoardUbuntu) for additional information about how to setup Ubuntu on this board.
The [ubuntu-packages.sh](ubuntu-packages.sh) script can be used to install all the packages required to compile and run the AES67 daemon, the daemon tests and the [demo](#demo). See [PulseAudio and scripts notes](#notes). The [ubuntu-packages.sh](ubuntu-packages.sh) script can be used to install all the packages required to compile and run the AES67 daemon, the daemon tests and the [demo](#demo). See [PulseAudio and scripts notes](#notes).
## How to build ## ## How to build ##
Make sure you have all the required packages installed, see [prerequisite](#prerequisite). Make sure you have all the required packages installed, see [prerequisite](#prerequisite).
To compile the AES67 daemon and the WebUI you can use the [build.sh](build.sh) script, see [script notes](#notes). To compile the AES67 daemon and the WebUI you can use the [build.sh](build.sh) script, see [script notes](#notes).
The script performs the following operations: The script performs the following operations:
* checkout, patch and build the Merging Technologies ALSA RAVENNA/AES67 module * checkout, patch and build the Merging Technologies ALSA RAVENNA/AES67 module
* checkout the cpp-httplib * checkout the cpp-httplib
* build and deploy the WebUI * build and deploy the WebUI
* build the AES67 daemon * build the AES67 daemon
## Run the Demo ## ## Run the Demo ##
<a name="demo"></a> <a name="demo"></a>
To run a simple demo use the [run\_demo.sh](run_demo.sh) script. See [script notes](#notes). To run a simple demo use the [run\_demo.sh](run_demo.sh) script. See [script notes](#notes).
The demo performs the following operations: The demo performs the following operations:
* setup system parameters * setup system parameters
* stop PulseAudio (if installed). This uses and keeps busy the ALSA playback and capture devices causing instability problems. See [PulseAudio](#notes). * stop PulseAudio (if installed). This uses and keeps busy the ALSA playback and capture devices causing instability problems. See [PulseAudio](#notes).
* install the ALSA RAVENNA/AES67 module * install the ALSA RAVENNA/AES67 module
* start the ptp4l as master clock on the network loopback device * start the ptp4l as master clock on the network loopback device
* start the AES67 daemon and creates a source and a sink according to the status file in the demo directory * start the AES67 daemon and creates a source and a sink according to the status file in the demo directory
* open a browser on the daemon PTP status page * open a browser on the daemon PTP status page
* wait for the Ravenna driver PTP slave to synchronize * wait for the Ravenna driver PTP slave to synchronize
* start recording on the configured ALSA sink for 60 seconds to the wave file in *./demo/sink_test.wav* * start recording on the configured ALSA sink for 60 seconds to the wave file in *./demo/sink_test.wav*
* start playing a test sound on the configured ALSA source * start playing a test sound on the configured ALSA source
* wait for the recording to complete and terminate ptp4l and the AES67 daemon * wait for the recording to complete and terminate ptp4l and the AES67 daemon
## Interoperability tests ## ## Interoperability tests ##
To run interoperability tests using the [Hasseb audio over Ethernet receiver](http://hasseb.fi/shop2/index.php?route=product/product&product_id=62) follow these steps: To run interoperability tests using the [Hasseb audio over Ethernet receiver](http://hasseb.fi/shop2/index.php?route=product/product&product_id=62) follow these steps:
* open he daemon configuration file *daemon.conf* and change the following parameters: * open the daemon configuration file *daemon.conf* and change the following parameters:
* set the network interface name to your Ethernet card, e.g.: *"interface\_name": "eth0"* * set network interface name to your Ethernet card, e.g.: *"interface\_name": "eth0"*
* set the default sample rate to 48Khz: *"sample\_rate": 48000* * set default sample rate to 48Khz: *"sample\_rate": 48000*
* verify that PulseAdio is not running. See [PulseAudio](#notes). * verify that PulseAdio is not running. See [PulseAudio](#notes).
* install the ALSA RAVENNA/AES67 module with: * install the ALSA RAVENNA/AES67 module with:
*sudo insmod 3rdparty/ravenna-alsa-lkm/driver/MergingRavennaALSA.ko* *sudo insmod 3rdparty/ravenna-alsa-lkm/driver/MergingRavennaALSA.ko*
* run the daemon using the new configuration file: * run the daemon using the new configuration file:
*aes67-daemon -c daemon.conf* *aes67-daemon -c daemon.conf*
* open the Daemon WebUi *http://[address:8080]* and do the following: * open the Daemon WebUi *http://[address:8080]* and do the following:
* go to Config tab and verify that the sample rate is set to 48KHz * go to Config tab and verify that the sample rate is set to 48KHz
* go to Sources tab and add a new Source using the plus button, set Codec to L24 and press the Submit button * go to Sources tab and add a new Source using the plus button, set Codec to L24 and press the Submit button
* go to Sinks tab and add a new Sink using the plus button, use the specified Source URL and press the Submit button * go to Sources tab and click on the info icon on the right of the newly created source and copy the SDP file
* edit the newly created Sink and copy the SDP file reported in SDP * open the Hasseb WebUI and do the following:
* open the Hasseb WebUI and do the following: * deselect the "PTP slave only" checkbox to enable PTP master on Hasseb device
* deselect the "PTP slave only" checkbox to enable PTP master on Hasseb device * select the "Add SDP file manually" checkbox and copy the previous Source SDP into the SDP field
* select the "Add SDP file manually" checkbox and copy the previous Source SDP into the SDP field * press the Submit button
* press the Submit button * return to the daemon WebUI, click on the PTP tab and wait for the "PTP Status" to report "locked"
* return to the daemon WebUI, click on the PTP tab and wait for the "PTP Status" to report "locked" * open a shell on the Linux host and start the playback on the ravenna ALSA device. For example to playback a test sound use: *speaker-test -D plughw:RAVENNA -r 48000 -c 2 -t sine*
* open a shell on the Linux host and start the playback on the ravenna ALSA device. For example to playback a test sound use: *speaker-test -D plughw:RAVENNA -r 48000 -c 2 -t sine*
## Notes ##
## Notes ## <a name="notes"></a>
<a name="notes"></a>
* All the scripts in this repository are provided as a reference to help setting up the system and run a simple demo.
* All the scripts in this repository are provided as a reference to help setting up the system and run a simple demo. They have been tested on **Ubuntu 18.04** and **19.10** distros only.
They have been tested on **Ubuntu 18.04** and **19.10** distros only. * PulseAudio can create instability problems.
* PulseAudio can create instability problems. Before running the daemon verify that PulseAudio is not running with *ps ax | grep pulseaudio*
Before running the daemon verify that PulseAudio is not running with *ps ax | grep pulseaudio* In case it's running try to execute the script *daemon/scripts/disable_pulseaudio.sh* to stop it. If after this the process is still alive consider renaming the executable with *sudo mv /usr/bin/pulseaudio /usr/bin/_pulseaudio* and reboot the system.
In case it's running try to execute the script *daemon/scripts/disable_pulseaudio.sh* to stop it. If after this the process is still alive consider renaming the executable with *sudo mv /usr/bin/pulseaudio /usr/bin/_pulseaudio* and reboot the system.

View File

@ -7,6 +7,6 @@ set(RAVENNA_ALSA_LKM "../3rdparty/ravenna-alsa-lkm/")
set(CPP_HTTPLIB " ../3rdparty/cpp-httplib/") set(CPP_HTTPLIB " ../3rdparty/cpp-httplib/")
find_package(Boost COMPONENTS system thread log program_options REQUIRED) find_package(Boost COMPONENTS system thread log program_options REQUIRED)
include_directories(aes67-daemon ${RAVENNA_ALSA_LKM}/common ${RAVENNA_ALSA_LKM}/driver ${CPP_HTTPLIB} ${Boost_INCLUDE_DIR}) include_directories(aes67-daemon ${RAVENNA_ALSA_LKM}/common ${RAVENNA_ALSA_LKM}/driver ${CPP_HTTPLIB} ${Boost_INCLUDE_DIR})
add_executable(aes67-daemon error_code.cpp json.cpp main.cpp driver_handler.cpp driver_manager.cpp session_manager.cpp http_server.cpp config.cpp interface.cpp log.cpp) add_executable(aes67-daemon error_code.cpp json.cpp main.cpp driver_handler.cpp driver_manager.cpp session_manager.cpp http_server.cpp config.cpp interface.cpp log.cpp sap.cpp browser.cpp)
add_subdirectory(tests) add_subdirectory(tests)
target_link_libraries(aes67-daemon ${Boost_LIBRARIES}) target_link_libraries(aes67-daemon ${Boost_LIBRARIES})

File diff suppressed because it is too large Load Diff

137
daemon/browser.cpp Normal file
View File

@ -0,0 +1,137 @@
//
// browser.cpp
//
// Copyright (c) 2019 2020 Andrea Bondavalli. All rights reserved.
//
// 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, either version 3 of the License, or
// 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 for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
#include <experimental/map>
#include "browser.hpp"
using namespace std::chrono;
using second_t = std::chrono::duration<double, std::ratio<1> >;
std::shared_ptr<Browser> Browser::create(
std::shared_ptr<Config> config) {
// no need to be thread-safe here
static std::weak_ptr<Browser> instance;
if (auto ptr = instance.lock()) {
return ptr;
}
auto ptr =
std::shared_ptr<Browser>(new Browser(config));
instance = ptr;
return ptr;
}
std::list<RemoteSource> Browser::get_remote_sources() {
std::list<RemoteSource> sources_list;
std::shared_lock sources_lock(sources_mutex_);
for (auto const& [id, source] : sources_) {
sources_list.emplace_back(source);
}
return sources_list;
}
static std::string sdp_get_subject(const std::string& sdp) {
std::stringstream ssstrem(sdp);
std::string line;
while (getline(ssstrem, line, '\n')) {
if (line.substr(0, 2) == "s=") {
return line.substr(2);
}
}
return "";
}
bool Browser::worker() {
sap_.set_multicast_interface(config_->get_ip_addr_str());
// Join SAP muticast address
igmp_.join(config_->get_ip_addr_str(), config_->get_sap_mcast_addr());
auto startup = steady_clock::now();
auto sap_timepoint = steady_clock::now();
int sap_interval = 10;
while (running_) {
bool is_announce;
uint16_t msg_id_hash;
uint32_t addr;
std::string sdp;
if (sap_.receive(is_announce, msg_id_hash, addr, sdp)) {
char id[13];
snprintf(id, sizeof id, "%x%x", addr, msg_id_hash);
//BOOST_LOG_TRIVIAL(debug) << "browser:: received SAP message for " << id;
std::unique_lock sources_lock(sources_mutex_);
auto it = sources_.find(id);
if (it == sources_.end()) {
// Source is not in the map
if (is_announce) {
BOOST_LOG_TRIVIAL(info) << "browser:: adding SAP source " << id;
// annoucement, add new source
RemoteSource source;
source.id = id;
source.sdp = sdp;
source.source = "SAP";
source.address = ip::address_v4(ntohl(addr)).to_string();
source.name = sdp_get_subject(sdp);
source.last_seen =
duration_cast<second_t>(steady_clock::now() - startup).count();
source.announce_period = 360; //default period
sources_[id] = source;
}
} else {
// Source is already in the mamap
if (is_announce) {
BOOST_LOG_TRIVIAL(debug)
<< "browser:: refreshing SAP source " << (*it).second.id;
// annoucement, update last seen and announce periods
uint32_t last_seen =
duration_cast<second_t>(steady_clock::now() - startup).count();
(*it).second.announce_period = last_seen - (*it).second.last_seen;
(*it).second.last_seen = last_seen;
} else {
BOOST_LOG_TRIVIAL(info)
<< "browser:: removing SAP source " << (*it).second.id;
// deletion, remove entry
sources_.erase(it);
}
}
}
// check if it's time to check the SAP remote sources
if ((duration_cast<second_t>(steady_clock::now() - sap_timepoint).count())
> sap_interval) {
sap_timepoint = steady_clock::now();
// remove all sessions no longer announced
auto offset = duration_cast<second_t>(steady_clock::now() - startup).count();
std::unique_lock sources_lock(sources_mutex_);
std::experimental::erase_if(sources_, [offset](auto entry) {
if ((offset - entry.second.last_seen) > (entry.second.announce_period * 10)) {
// remove from remote SAP sources
BOOST_LOG_TRIVIAL(info)
<< "browser:: SAP source " << entry.second.id << " timeout";
return true;
}
return false;
});
}
}
return true;
}

88
daemon/browser.hpp Normal file
View File

@ -0,0 +1,88 @@
//
// browser.hpp
//
// Copyright (c) 2019 2020 Andrea Bondavalli. All rights reserved.
//
// 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, either version 3 of the License, or
// 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 for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
#ifndef _BROWSER_HPP_
#define _BROWSER_HPP_
#include <future>
#include <shared_mutex>
#include <thread>
#include <chrono>
#include "config.hpp"
#include "sap.hpp"
#include "igmp.hpp"
struct RemoteSource {
std::string id;
std::string source;
std::string address;
std::string name;
std::string sdp;
uint32_t last_seen; /* seconds from daemon startup */
uint32_t announce_period; /* period between annoucementis */
};
class Browser {
public:
static std::shared_ptr<Browser> create(
std::shared_ptr<Config> config);
Browser(const Browser&) = delete;
Browser& operator=(const Browser&) = delete;
virtual ~Browser(){ stop(); };
// session manager interface
bool start() {
if (!running_) {
running_ = true;
res_ = std::async(std::launch::async, &Browser::worker, this);
}
return true;
}
bool stop() {
if (running_) {
running_ = false;
return res_.get();
}
return true;
}
std::list<RemoteSource> get_remote_sources();
protected:
// singleton, use create() to build
Browser(std::shared_ptr<Config> config)
: config_(config){};
bool worker();
std::shared_ptr<Config> config_;
std::future<bool> res_;
std::atomic_bool running_{false};
/* current sources */
std::map<std::string /* id */, RemoteSource> sources_;
mutable std::shared_mutex sources_mutex_;
SAP sap_{config_->get_sap_mcast_addr()};
IGMP igmp_;
};
#endif

View File

@ -68,6 +68,10 @@ std::shared_ptr<Config> Config::parse(const std::string& filename) {
if (ip::address_v4::from_string(config.rtp_mcast_base_.c_str()).to_ulong() == if (ip::address_v4::from_string(config.rtp_mcast_base_.c_str()).to_ulong() ==
INADDR_NONE) INADDR_NONE)
config.rtp_mcast_base_ = "239.1.0.1"; config.rtp_mcast_base_ = "239.1.0.1";
if (ip::address_v4::from_string(config.sap_mcast_addr_.c_str()).to_ulong() ==
INADDR_NONE)
config.sap_mcast_addr_ = "224.2.127.254";
if (config.ptp_domain_ > 127)
if (config.ptp_domain_ > 127) if (config.ptp_domain_ > 127)
config.ptp_domain_ = 0; config.ptp_domain_ = 0;

View File

@ -40,6 +40,7 @@ class Config {
uint32_t get_max_tic_frame_size() const { return max_tic_frame_size_; }; uint32_t get_max_tic_frame_size() const { return max_tic_frame_size_; };
uint32_t get_sample_rate() const { return sample_rate_; }; uint32_t get_sample_rate() const { return sample_rate_; };
const std::string& get_rtp_mcast_base() const { return rtp_mcast_base_; }; const std::string& get_rtp_mcast_base() const { return rtp_mcast_base_; };
const std::string& get_sap_mcast_addr() const { return sap_mcast_addr_; };
uint16_t get_rtp_port() const { return rtp_port_; }; uint16_t get_rtp_port() const { return rtp_port_; };
uint8_t get_ptp_domain() const { return ptp_domain_; }; uint8_t get_ptp_domain() const { return ptp_domain_; };
uint8_t get_ptp_dscp() const { return ptp_dscp_; }; uint8_t get_ptp_dscp() const { return ptp_dscp_; };
@ -73,6 +74,9 @@ class Config {
void set_rtp_mcast_base(const std::string& rtp_mcast_base) { void set_rtp_mcast_base(const std::string& rtp_mcast_base) {
rtp_mcast_base_ = rtp_mcast_base; rtp_mcast_base_ = rtp_mcast_base;
}; };
void set_sap_mcast_addr(const std::string& sap_mcast_addr) {
sap_mcast_addr_ = sap_mcast_addr;
};
void set_rtp_port(uint16_t rtp_port) { rtp_port_ = rtp_port; }; void set_rtp_port(uint16_t rtp_port) { rtp_port_ = rtp_port; };
void set_ptp_domain(uint8_t ptp_domain) { ptp_domain_ = ptp_domain; }; void set_ptp_domain(uint8_t ptp_domain) { ptp_domain_ = ptp_domain; };
void set_ptp_dscp(uint8_t ptp_dscp) { ptp_dscp_ = ptp_dscp; }; void set_ptp_dscp(uint8_t ptp_dscp) { ptp_dscp_ = ptp_dscp; };
@ -108,6 +112,7 @@ class Config {
uint32_t max_tic_frame_size_{1024}; uint32_t max_tic_frame_size_{1024};
uint32_t sample_rate_{44100}; uint32_t sample_rate_{44100};
std::string rtp_mcast_base_{"239.1.0.1"}; std::string rtp_mcast_base_{"239.1.0.1"};
std::string sap_mcast_addr_{"224.2.127.254"};
uint16_t rtp_port_{5004}; uint16_t rtp_port_{5004};
uint8_t ptp_domain_{0}; uint8_t ptp_domain_{0};
uint8_t ptp_dscp_{46}; uint8_t ptp_dscp_{46};

View File

@ -10,6 +10,7 @@
"rtp_port": 5004, "rtp_port": 5004,
"ptp_domain": 0, "ptp_domain": 0,
"ptp_dscp": 48, "ptp_dscp": 48,
"sap_mcast_addr": "239.255.255.255",
"sap_interval": 30, "sap_interval": 30,
"syslog_proto": "none", "syslog_proto": "none",
"syslog_server": "255.255.255.254:1234", "syslog_server": "255.255.255.254:1234",

View File

@ -85,7 +85,7 @@ bool HttpServer::start() {
svr_.set_base_dir(config_->get_http_base_dir().c_str()); svr_.set_base_dir(config_->get_http_base_dir().c_str());
svr_.Get("(/|/Config|/PTP|/Sources|/Sinks)", [&](const Request& req, Response& res) { svr_.Get("(/|/Config|/PTP|/Sources|/Sinks|/Browser)", [&](const Request& req, Response& res) {
std::ifstream file(config_->get_http_base_dir() + "/index.html"); std::ifstream file(config_->get_http_base_dir() + "/index.html");
std::stringstream buffer; std::stringstream buffer;
buffer << file.rdbuf(); buffer << file.rdbuf();
@ -280,6 +280,13 @@ bool HttpServer::start() {
} }
}); });
/* get remote sources */
svr_.Get("/api/browse/sources", [this](const Request& req, Response& res) {
auto const sources = browser_->get_remote_sources();
set_headers(res, "application/json");
res.body = remote_sources_to_json(sources);
});
svr_.set_logger([](const Request& req, const Response& res) { svr_.set_logger([](const Request& req, const Response& res) {
if (res.status == 200) { if (res.status == 200) {
BOOST_LOG_TRIVIAL(info) << "http_server:: " << req.method << " " BOOST_LOG_TRIVIAL(info) << "http_server:: " << req.method << " "

View File

@ -24,19 +24,23 @@
#include "config.hpp" #include "config.hpp"
#include "session_manager.hpp" #include "session_manager.hpp"
#include "browser.hpp"
class HttpServer { class HttpServer {
public: public:
HttpServer() = delete; HttpServer() = delete;
HttpServer(std::shared_ptr<SessionManager> session_manager, HttpServer(std::shared_ptr<SessionManager> session_manager,
std::shared_ptr<Browser> browser,
std::shared_ptr<Config> config) std::shared_ptr<Config> config)
: session_manager_(session_manager), : session_manager_(session_manager),
browser_(browser),
config_(config) {}; config_(config) {};
bool start(); bool start();
bool stop(); bool stop();
private: private:
std::shared_ptr<SessionManager> session_manager_; std::shared_ptr<SessionManager> session_manager_;
std::shared_ptr<Browser> browser_;
std::shared_ptr<Config> config_; std::shared_ptr<Config> config_;
httplib::Server svr_; httplib::Server svr_;
std::future<bool> res_; std::future<bool> res_;

View File

@ -85,6 +85,7 @@ std::string config_to_json(const Config& config) {
<< ",\n \"rtp_port\": " << config.get_rtp_port() << ",\n \"rtp_port\": " << config.get_rtp_port()
<< ",\n \"ptp_domain\": " << unsigned(config.get_ptp_domain()) << ",\n \"ptp_domain\": " << unsigned(config.get_ptp_domain())
<< ",\n \"ptp_dscp\": " << unsigned(config.get_ptp_dscp()) << ",\n \"ptp_dscp\": " << unsigned(config.get_ptp_dscp())
<< ",\n \"sap_mcast_addr\": \"" << escape_json(config.get_sap_mcast_addr()) << "\""
<< ",\n \"sap_interval\": " << config.get_sap_interval() << ",\n \"sap_interval\": " << config.get_sap_interval()
<< ",\n \"syslog_proto\": \"" << escape_json(config.get_syslog_proto()) << "\"" << ",\n \"syslog_proto\": \"" << escape_json(config.get_syslog_proto()) << "\""
<< ",\n \"syslog_server\": \"" << escape_json(config.get_syslog_server()) << "\"" << ",\n \"syslog_server\": \"" << escape_json(config.get_syslog_server()) << "\""
@ -226,6 +227,34 @@ std::string streams_to_json(const std::list<StreamSource>& sources,
return ss.str(); return ss.str();
} }
std::string remote_source_to_json(const RemoteSource& source) {
std::stringstream ss;
ss << "\n {"
<< "\n \"source\": \"" << escape_json(source.source) << "\""
<< ",\n \"id\": \"" << escape_json(source.id) << "\""
<< ",\n \"name\": \"" << escape_json(source.name) << "\""
<< ",\n \"address\": \"" << escape_json(source.address) << "\""
<< ",\n \"sdp\": \"" << escape_json(source.sdp) << "\""
<< ",\n \"last_seen\": " << unsigned(source.last_seen)
<< ",\n \"announce_period\": " << unsigned(source.announce_period)
<< " \n }";
return ss.str();
}
std::string remote_sources_to_json(const std::list<RemoteSource>& sources) {
int count = 0;
std::stringstream ss;
ss << "{\n \"remote_sources\": [";
for (auto const& source: sources) {
if (count++) {
ss << ", ";
}
ss << remote_source_to_json(source);
}
ss << " ]\n}\n";
return ss.str();
}
Config json_to_config_(std::istream& js, Config& config) { Config json_to_config_(std::istream& js, Config& config) {
try { try {
boost::property_tree::ptree pt; boost::property_tree::ptree pt;
@ -256,6 +285,8 @@ Config json_to_config_(std::istream& js, Config& config) {
config.set_ptp_domain(val.get_value<uint8_t>()); config.set_ptp_domain(val.get_value<uint8_t>());
} else if (key == "ptp_dscp") { } else if (key == "ptp_dscp") {
config.set_ptp_dscp(val.get_value<uint8_t>()); config.set_ptp_dscp(val.get_value<uint8_t>());
} else if (key == "sap_mcast_addr") {
config.set_sap_mcast_addr(remove_undesired_chars(val.get_value<std::string>()));
} else if (key == "sap_interval") { } else if (key == "sap_interval") {
config.set_sap_interval(val.get_value<uint16_t>()); config.set_sap_interval(val.get_value<uint16_t>());
} else if (key == "status_file") { } else if (key == "status_file") {

View File

@ -22,6 +22,7 @@
#include <list> #include <list>
#include "session_manager.hpp" #include "session_manager.hpp"
#include "browser.hpp"
/* JSON serializers */ /* JSON serializers */
std::string config_to_json(const Config& config); std::string config_to_json(const Config& config);
@ -34,6 +35,8 @@ std::string sources_to_json(const std::list<StreamSource>& sources);
std::string sinks_to_json(const std::list<StreamSink>& sinks); std::string sinks_to_json(const std::list<StreamSink>& sinks);
std::string streams_to_json(const std::list<StreamSource>& sources, std::string streams_to_json(const std::list<StreamSource>& sources,
const std::list<StreamSink>& sinks); const std::list<StreamSink>& sinks);
std::string remote_source_to_json(const RemoteSource& source);
std::string remote_sources_to_json(const std::list<RemoteSource>& sources);
/* JSON deserializers */ /* JSON deserializers */
Config json_to_config(std::istream& jstream, const Config& curCconfig); Config json_to_config(std::istream& jstream, const Config& curCconfig);

View File

@ -27,6 +27,7 @@
#include "log.hpp" #include "log.hpp"
#include "session_manager.hpp" #include "session_manager.hpp"
#include "interface.hpp" #include "interface.hpp"
#include "browser.hpp"
namespace po = boost::program_options; namespace po = boost::program_options;
namespace postyle = boost::program_options::command_line_style; namespace postyle = boost::program_options::command_line_style;
@ -114,9 +115,16 @@ int main(int argc, char* argv[]) {
throw std::runtime_error( throw std::runtime_error(
std::string("SessionManager:: start failed")); std::string("SessionManager:: start failed"));
} }
/* start browser */
auto browser = Browser::create(config);
if (browser == nullptr || !browser->start()) {
throw std::runtime_error(
std::string("Browser:: start failed"));
}
/* start http server */ /* start http server */
HttpServer http_server(session_manager, config); HttpServer http_server(session_manager, browser, config);
if (!http_server.start()) { if (!http_server.start()) {
throw std::runtime_error(std::string("HttpServer:: start failed")); throw std::runtime_error(std::string("HttpServer:: start failed"));
} }
@ -151,6 +159,12 @@ int main(int argc, char* argv[]) {
std::string("HttpServer:: stop failed")); std::string("HttpServer:: stop failed"));
} }
/* stop browser */
if (!browser->stop()) {
throw std::runtime_error(
std::string("Browser:: stop failed"));
}
/* stop session manager */ /* stop session manager */
if (!session_manager->stop()) { if (!session_manager->stop()) {
throw std::runtime_error( throw std::runtime_error(
@ -161,7 +175,7 @@ int main(int argc, char* argv[]) {
if (!driver->terminate()) { if (!driver->terminate()) {
throw std::runtime_error( throw std::runtime_error(
std::string("DriverManager:: terminate failed")); std::string("DriverManager:: terminate failed"));
} }
} catch (std::exception& e) { } catch (std::exception& e) {
BOOST_LOG_TRIVIAL(fatal) << "main:: fatal exception error: " << e.what(); BOOST_LOG_TRIVIAL(fatal) << "main:: fatal exception error: " << e.what();

View File

@ -59,8 +59,7 @@ class nl_endpoint {
std::size_t size() const { return sizeof(sockaddr); } std::size_t size() const { return sizeof(sockaddr); }
void resize(std::size_t size) { /* nothing we can do here */ void resize(std::size_t size) { /* nothing we can do here */ }
}
std::size_t capacity() const { return sizeof(sockaddr); } std::size_t capacity() const { return sizeof(sockaddr); }
}; };

158
daemon/sap.cpp Normal file
View File

@ -0,0 +1,158 @@
//
// sap.hpp
//
// Copyright (c) 2019 2020 Andrea Bondavalli. All rights reserved.
//
// 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, either version 3 of the License, or
// 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 for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
#include <boost/bind.hpp>
#include "sap.hpp"
using namespace boost::asio;
using namespace boost::asio::ip;
SAP::SAP(const std::string& sap_mcast_addr) :
addr_(sap_mcast_addr)
// remote_endpoint_(ip::address::from_string(addr_), port)
{
socket_.open(boost::asio::ip::udp::v4());
socket_.set_option(udp::socket::reuse_address(true));
socket_.bind(listen_endpoint_);
check_deadline();
}
bool SAP::set_multicast_interface(const std::string& interface_ip) {
ip::address_v4 local_interface = ip::address_v4::from_string(interface_ip);
ip::multicast::outbound_interface oi_option(local_interface);
boost::system::error_code ec;
socket_.set_option(oi_option, ec);
if (ec) {
BOOST_LOG_TRIVIAL(error)
<< "sap::outbound_interface option " << ec.message();
return false;
}
ip::multicast::enable_loopback el_option(true);
socket_.set_option(el_option, ec);
if (ec) {
BOOST_LOG_TRIVIAL(error) << "sap::enable_loopback option " << ec.message();
return false;
}
return true;
}
bool SAP::announcement(uint16_t msg_id_hash,
uint32_t addr,
const std::string& sdp) {
BOOST_LOG_TRIVIAL(info) << "sap::announcement " << std::hex << msg_id_hash;
return send(true, msg_id_hash, htonl(addr), sdp);
}
bool SAP::deletion(uint16_t msg_id_hash,
uint32_t addr,
const std::string& sdp) {
BOOST_LOG_TRIVIAL(info) << "sap::deletion " << std::hex << msg_id_hash;
return send(false, msg_id_hash, htonl(addr), sdp);
}
bool SAP::receive(bool& is_announce,
uint16_t& msg_id_hash,
uint32_t& addr,
std::string& sdp,
int tout_secs) {
// Set a deadline for the asynchronous operation
deadline_.expires_from_now(boost::posix_time::seconds(tout_secs));
boost::system::error_code ec = boost::asio::error::would_block;
ip::udp::endpoint endpoint;
uint8_t buffer[max_length];
std::size_t length = 0;
// Start the asynchronous operation itself. The handle_receive function
// used as a callback will update the ec and length variables.
socket_.async_receive_from(
boost::asio::buffer(buffer), endpoint,
boost::bind(&SAP::handle_receive, _1, _2, &ec, &length));
// Block until the asynchronous operation has completed.
do {
io_service_.run_one();
} while (ec == boost::asio::error::would_block);
if (!ec && length > 4 && (buffer[0] == 0x20 || buffer[0] == 0x24)) {
// only accept SAP announce or delete v2 with IPv4
// no reserved, no compress, no encryption
// and content/type = application/sdp
is_announce = (buffer[0] == 0x20);
memcpy(&msg_id_hash, buffer + 2, sizeof(msg_id_hash));
memcpy(&addr, buffer + 4, sizeof(addr));
for (int i = 8; buffer[i] != 0 && i < length; i++) {
buffer[i] = std::tolower(buffer[i]);
}
if (!memcmp(buffer + 8, "application/sdp", 16)) {
sdp.assign(buffer + SAP::sap_header_len, buffer + length);
return true;
}
}
return false;
}
void SAP::handle_receive(const boost::system::error_code& ec,
std::size_t length,
boost::system::error_code* out_ec,
std::size_t* out_length) {
*out_ec = ec;
*out_length = length;
}
void SAP::check_deadline() {
if (deadline_.expires_at() <= deadline_timer::traits_type::now()) {
// cancel receive operation
socket_.cancel();
deadline_.expires_at(boost::posix_time::pos_infin);
//BOOST_LOG_TRIVIAL(debug) << "SAP:: timeout expired when receiving";
}
deadline_.async_wait(boost::bind(&SAP::check_deadline, this));
}
bool SAP::send(bool is_announce,
uint16_t msg_id_hash,
uint32_t addr,
const std::string& sdp) {
if (sdp.length() > max_length - sap_header_len) {
BOOST_LOG_TRIVIAL(error) << "sap:: SDP is too long";
return false;
}
uint8_t buffer[max_length];
buffer[0] = is_announce ? 0x20 : 0x24;
buffer[1] = 0;
memcpy(buffer + 2, &msg_id_hash, 2);
memcpy(buffer + 4, &addr, 4);
memcpy(buffer + 8, "application/sdp", 16); /* include trailing 0 */
memcpy(buffer + sap_header_len, sdp.c_str(), sdp.length());
try {
socket_.send_to(boost::asio::buffer(buffer, sap_header_len + sdp.length()),
remote_endpoint_);
} catch (boost::system::error_code& ec) {
BOOST_LOG_TRIVIAL(error) << "sap::send_to " << ec.message();
return false;
}
return true;
}

View File

@ -24,10 +24,10 @@
#include "log.hpp" #include "log.hpp"
using namespace boost::asio; using namespace boost::asio;
using boost::asio::deadline_timer;
class SAP { class SAP {
public: public:
constexpr static const char addr[] = "224.2.127.254";
constexpr static uint16_t port = 9875; constexpr static uint16_t port = 9875;
constexpr static uint16_t max_deletions = 3; constexpr static uint16_t max_deletions = 3;
constexpr static uint16_t bandwidth_limit = 4000; // bits x xsec constexpr static uint16_t bandwidth_limit = 4000; // bits x xsec
@ -35,72 +35,39 @@ class SAP {
constexpr static uint16_t sap_header_len = 24; constexpr static uint16_t sap_header_len = 24;
constexpr static uint16_t max_length = 4096; constexpr static uint16_t max_length = 4096;
SAP() { socket_.open(boost::asio::ip::udp::v4()); }; SAP() = delete;
SAP(const std::string& sap_mcast_addr);
bool set_multicast_interface(const std::string& interface_ip) {
ip::address_v4 local_interface = ip::address_v4::from_string(interface_ip);
ip::multicast::outbound_interface oi_option(local_interface);
boost::system::error_code ec;
socket_.set_option(oi_option, ec);
if (ec) {
BOOST_LOG_TRIVIAL(error)
<< "sap::outbound_interface option " << ec.message();
return false;
}
ip::multicast::enable_loopback el_option(true);
socket_.set_option(el_option, ec);
if (ec) {
BOOST_LOG_TRIVIAL(error)
<< "sap::enable_loopback option " << ec.message();
return false;
}
return true;
}
bool set_multicast_interface(const std::string& interface_ip);
bool announcement(uint16_t msg_id_hash, bool announcement(uint16_t msg_id_hash,
uint32_t addr, uint32_t addr,
const std::string& sdp) { const std::string& sdp);
BOOST_LOG_TRIVIAL(info) << "sap::announcement " << std::hex << msg_id_hash; bool deletion(uint16_t msg_id_hash, uint32_t addr, const std::string& sdp);
return send(true, msg_id_hash, htonl(addr), sdp); bool receive(bool& is_announce,
} uint16_t& msg_id_hash,
uint32_t& addr,
bool deletion(uint16_t msg_id_hash, uint32_t addr, const std::string& sdp) { std::string& sdp,
BOOST_LOG_TRIVIAL(info) << "sap::deletetion " << std::hex << msg_id_hash; int tout_secs = 1);
return send(false, msg_id_hash, htonl(addr), sdp);
}
private: private:
static void handle_receive(const boost::system::error_code& ec,
std::size_t length,
boost::system::error_code* out_ec,
std::size_t* out_length);
void check_deadline();
bool send(bool is_announce, bool send(bool is_announce,
uint16_t msg_id_hash, uint16_t msg_id_hash,
uint32_t addr, uint32_t addr,
const std::string& sdp) { const std::string& sdp);
if (sdp.length() > max_length - sap_header_len) {
BOOST_LOG_TRIVIAL(error) << "sap:: SDP is too long";
return false;
}
uint8_t buffer[max_length];
buffer[0] = is_announce ? 0x20 : 0x24;
buffer[1] = 0;
memcpy(buffer + 2, &msg_id_hash, 2);
memcpy(buffer + 4, &addr, 4);
memcpy(buffer + 8, "application/sdp", 16); /* include trailing 0 */
memcpy(buffer + sap_header_len, sdp.c_str(), sdp.length());
try {
socket_.send_to(
boost::asio::buffer(buffer, sap_header_len + sdp.length()), remote_);
} catch (boost::system::error_code& ec) {
BOOST_LOG_TRIVIAL(error) << "sap::send_to " << ec.message();
return false;
}
return true;
}
std::string addr_;
io_service io_service_; io_service io_service_;
ip::udp::socket socket_{io_service_}; ip::udp::socket socket_{io_service_};
ip::udp::endpoint remote_{ ip::udp::endpoint remote_endpoint_{
ip::udp::endpoint(ip::address::from_string(addr), port)}; ip::udp::endpoint(ip::address::from_string(addr_), port)};
ip::udp::endpoint listen_endpoint_{
ip::udp::endpoint(ip::address::from_string("0.0.0.0"), port)};
deadline_timer deadline_{io_service_};
}; };
#endif #endif

View File

@ -190,7 +190,7 @@ class SessionManager {
PTPStatus ptp_status_; PTPStatus ptp_status_;
mutable std::shared_mutex ptp_mutex_; mutable std::shared_mutex ptp_mutex_;
SAP sap_; SAP sap_{config_->get_sap_mcast_addr()};
IGMP igmp_; IGMP igmp_;
}; };

View File

@ -10,6 +10,7 @@
"rtp_port": 6004, "rtp_port": 6004,
"ptp_domain": 0, "ptp_domain": 0,
"ptp_dscp": 46, "ptp_dscp": 46,
"sap_mcast_addr": "224.2.127.254",
"sap_interval": 1, "sap_interval": 1,
"syslog_proto": "none", "syslog_proto": "none",
"syslog_server": "255.255.255.254:1234", "syslog_server": "255.255.255.254:1234",

View File

@ -50,7 +50,7 @@ struct DaemonInstance {
DaemonInstance() { DaemonInstance() {
BOOST_TEST_MESSAGE("Starting up test daemon instance ..."); BOOST_TEST_MESSAGE("Starting up test daemon instance ...");
int retry = 10; int retry = 10;
while (--retry && daemon_.running()) { while (retry-- && daemon_.running()) {
BOOST_TEST_MESSAGE("Checking daemon instance ..."); BOOST_TEST_MESSAGE("Checking daemon instance ...");
httplib::Client cli(g_daemon_address, g_daemon_port); httplib::Client cli(g_daemon_address, g_daemon_port);
auto res = cli.Get("/"); auto res = cli.Get("/");
@ -284,6 +284,12 @@ struct Client {
return true; return true;
} }
std::pair<bool, std::string> get_remote_sources() {
std::string url = std::string("/api/browse/sources");
auto res = cli_.Get(url.c_str());
return std::make_pair(res->status == 200, res->body);
}
private: private:
httplib::Client cli_{g_daemon_address, g_daemon_port}; httplib::Client cli_{g_daemon_address, g_daemon_port};
io_service io_service_; io_service io_service_;
@ -432,7 +438,30 @@ BOOST_AUTO_TEST_CASE(source_check_sap) {
cli.sap_wait_announcement(0, sdp.second); cli.sap_wait_announcement(0, sdp.second);
BOOST_REQUIRE_MESSAGE(cli.remove_source(0), "removed source 0"); BOOST_REQUIRE_MESSAGE(cli.remove_source(0), "removed source 0");
cli.sap_wait_deletion(0, sdp.second, 3); cli.sap_wait_deletion(0, sdp.second, 3);
std::this_thread::sleep_for(std::chrono::seconds(10)); }
BOOST_AUTO_TEST_CASE(source_check_browser) {
Client cli;
BOOST_REQUIRE_MESSAGE(cli.add_source(0), "added source 0");
auto sdp = cli.get_source_sdp(0);
BOOST_REQUIRE_MESSAGE(sdp.first, "got source sdp 0");
cli.sap_wait_announcement(0, sdp.second);
auto json = cli.get_remote_sources();
BOOST_REQUIRE_MESSAGE(json.first, "got remote sources");
boost::property_tree::ptree pt;
std::stringstream ss(json.second);
boost::property_tree::read_json(ss, pt);
BOOST_FOREACH (auto const& v, pt.get_child("remote_sources")) {
BOOST_REQUIRE_MESSAGE(v.second.get<std::string>("sdp") == sdp.second,
"returned source " + v.second.get<std::string>("id"));
}
BOOST_REQUIRE_MESSAGE(cli.remove_source(0), "removed source 0");
cli.sap_wait_deletion(0, sdp.second, 3);
json = cli.get_remote_sources();
BOOST_REQUIRE_MESSAGE(json.first, "got remote sources");
std::stringstream ss1(json.second);
boost::property_tree::read_json(ss1, pt);
BOOST_REQUIRE_MESSAGE(pt.get_child("remote_sources").size() == 0, "no remote sources");
} }
BOOST_AUTO_TEST_CASE(sink_check_status) { BOOST_AUTO_TEST_CASE(sink_check_status) {
@ -578,7 +607,7 @@ BOOST_AUTO_TEST_CASE(add_remove_update_check_all) {
} }
} }
BOOST_AUTO_TEST_CASE(add_remove_check_sap_all) { BOOST_AUTO_TEST_CASE(add_remove_check_sap_browser_all) {
Client cli; Client cli;
for (int id = 0; id < 64; id++) { for (int id = 0; id < 64; id++) {
BOOST_REQUIRE_MESSAGE(cli.add_source(id), BOOST_REQUIRE_MESSAGE(cli.add_source(id),
@ -589,15 +618,20 @@ BOOST_AUTO_TEST_CASE(add_remove_check_sap_all) {
BOOST_REQUIRE_MESSAGE(sdp.first, std::string("got source sdp ") + std::to_string(id)); BOOST_REQUIRE_MESSAGE(sdp.first, std::string("got source sdp ") + std::to_string(id));
cli.sap_wait_announcement(id, sdp.second); cli.sap_wait_announcement(id, sdp.second);
} }
auto json = cli.get_remote_sources();
BOOST_REQUIRE_MESSAGE(json.first, "got remote sources");
boost::property_tree::ptree pt;
std::stringstream ss(json.second);
boost::property_tree::read_json(ss, pt);
BOOST_REQUIRE_MESSAGE(pt.get_child("remote_sources").size() == 64, "found 64 remote sources");
for (int id = 0; id < 64; id++) { for (int id = 0; id < 64; id++) {
BOOST_REQUIRE_MESSAGE(cli.add_sink_sdp(id), BOOST_REQUIRE_MESSAGE(cli.add_sink_sdp(id),
std::string("added sink ") + std::to_string(id)); std::string("added sink ") + std::to_string(id));
} }
auto json = cli.get_streams(); json = cli.get_streams();
BOOST_REQUIRE_MESSAGE(json.first, "got streams"); BOOST_REQUIRE_MESSAGE(json.first, "got streams");
boost::property_tree::ptree pt; std::stringstream ss1(json.second);
std::stringstream ss(json.second); boost::property_tree::read_json(ss1, pt);
boost::property_tree::read_json(ss, pt);
uint8_t id = 0; uint8_t id = 0;
BOOST_FOREACH (auto const& v, pt.get_child("sources")) { BOOST_FOREACH (auto const& v, pt.get_child("sources")) {
BOOST_REQUIRE_MESSAGE(v.second.get<uint8_t>("id") == id, BOOST_REQUIRE_MESSAGE(v.second.get<uint8_t>("id") == id,
@ -615,6 +649,11 @@ BOOST_AUTO_TEST_CASE(add_remove_check_sap_all) {
std::string("removed source ") + std::to_string(id)); std::string("removed source ") + std::to_string(id));
} }
cli.sap_wait_all_deletions(); cli.sap_wait_all_deletions();
json = cli.get_remote_sources();
BOOST_REQUIRE_MESSAGE(json.first, "got remote sources");
std::stringstream ss2(json.second);
boost::property_tree::read_json(ss2, pt);
BOOST_REQUIRE_MESSAGE(pt.get_child("remote_sources").size() == 0, "no remote sources");
for (int id = 0; id < 64; id++) { for (int id = 0; id < 64; id++) {
BOOST_REQUIRE_MESSAGE(cli.remove_sink(id), BOOST_REQUIRE_MESSAGE(cli.remove_sink(id),
std::string("removed sink ") + std::to_string(id)); std::string("removed sink ") + std::to_string(id));

View File

@ -13,7 +13,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>AES64 Daemon WebUI</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
webui/public/info.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -78,6 +78,7 @@ class Config extends Component {
rtpPort: data.rtp_port, rtpPort: data.rtp_port,
ptpDomain: data.ptp_domain, ptpDomain: data.ptp_domain,
ptpDscp: data.ptp_dscp, ptpDscp: data.ptp_dscp,
sapMcastAddr: data.sap_mcast_addr,
sapInterval: data.sap_interval, sapInterval: data.sap_interval,
syslogProto: data.syslog_proto, syslogProto: data.syslog_proto,
syslogServer: data.syslog_server, syslogServer: data.syslog_server,
@ -95,6 +96,7 @@ class Config extends Component {
!this.state.ticFrameSizeAt1fsErr && !this.state.ticFrameSizeAt1fsErr &&
!this.state.maxTicFrameSizeErr && !this.state.maxTicFrameSizeErr &&
!this.state.rtpMcastBaseErr && !this.state.rtpMcastBaseErr &&
!this.state.sapMcastAddrErr &&
!this.state.rtpPortErr && !this.state.rtpPortErr &&
!this.state.sapIntervalErr && !this.state.sapIntervalErr &&
!this.state.syslogServerErr && !this.state.syslogServerErr &&
@ -113,6 +115,7 @@ class Config extends Component {
this.state.ticFrameSizeAt1fs, this.state.ticFrameSizeAt1fs,
this.state.sampleRate, this.state.sampleRate,
this.state.maxTicFrameSize, this.state.maxTicFrameSize,
this.state.sapMcastAddr,
this.state.sapInterval) this.state.sapInterval)
.then(response => toast.success('Config updated, daemon restart ...')); .then(response => toast.success('Config updated, daemon restart ...'));
} }
@ -154,12 +157,16 @@ class Config extends Component {
</tr> </tr>
<tr> <tr>
<th align="left"> <label>RTP base address</label> </th> <th align="left"> <label>RTP base address</label> </th>
<th align="left"> <input type="text" minLength="7" maxLength="15" size="15" pattern="^((\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$" value={this.state.rtpMcastBase} onChange={e => this.setState({rtpMcastBase: e.target.value, rtpMcastBaseErr: !e.currentTarget.checkValidity()})} required/> </th> <th align="left"> <input type="text" minLength="7" maxLength="15" size="15" pattern="^2(?:2[4-9]|3\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d?|0)){3}$" value={this.state.rtpMcastBase} onChange={e => this.setState({rtpMcastBase: e.target.value, rtpMcastBaseErr: !e.currentTarget.checkValidity()})} required/> </th>
</tr> </tr>
<tr> <tr>
<th align="left"> <label>RTP port</label> </th> <th align="left"> <label>RTP port</label> </th>
<th align="left"> <input type='number' min='1024' max='65536' className='input-number' value={this.state.rtpPort} onChange={e => this.setState({rtpPort: e.target.value, rtpPortErr: !e.currentTarget.checkValidity()})} required/> </th> <th align="left"> <input type='number' min='1024' max='65536' className='input-number' value={this.state.rtpPort} onChange={e => this.setState({rtpPort: e.target.value, rtpPortErr: !e.currentTarget.checkValidity()})} required/> </th>
</tr> </tr>
<tr>
<th align="left"> <label>SAP multicast address</label> </th>
<th align="left"> <input type="text" minLength="7" maxLength="15" size="15" pattern="^2(?:2[4-9]|3\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d?|0)){3}$" value={this.state.sapMcastAddr} onChange={e => this.setState({sapMcastAddr: e.target.value, sapMcastAddrErr: !e.currentTarget.checkValidity()})} required/> </th>
</tr>
<tr> <tr>
<th align="left"> <label>SAP interval (sec)</label> </th> <th align="left"> <label>SAP interval (sec)</label> </th>
<th align="left"> <input type='number' min='0' max='255' className='input-number' value={this.state.sapInterval} onChange={e => this.setState({sapInterval: e.target.value, sapIntervalErr: !e.currentTarget.checkValidity()})} required/> </th> <th align="left"> <input type='number' min='0' max='255' className='input-number' value={this.state.sapInterval} onChange={e => this.setState({sapInterval: e.target.value, sapIntervalErr: !e.currentTarget.checkValidity()})} required/> </th>

View File

@ -26,6 +26,7 @@ import PTP from './PTP';
import Config from './Config'; import Config from './Config';
import Sources from './Sources'; import Sources from './Sources';
import Sinks from './Sinks'; import Sinks from './Sinks';
import RemoteSources from './RemoteSources';
require('./styles.css'); require('./styles.css');
@ -51,6 +52,9 @@ class ConfigTabs extends Component {
<div label="Sinks"> <div label="Sinks">
<Sinks/> <Sinks/>
</div> </div>
<div label="Browser">
<RemoteSources/>
</div>
</Tabs> </Tabs>
</div> </div>
); );

196
webui/src/RemoteSources.js Normal file
View File

@ -0,0 +1,196 @@
//
// RemoteSource.js
//
// Copyright (c) 2019 2020 Andrea Bondavalli. All rights reserved.
//
// 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, either version 3 of the License, or
// 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 for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
//
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import RestAPI from './Services';
import Loader from './Loader';
import SourceInfo from './SourceInfo';
require('./styles.css');
class RemoteSourceEntry extends Component {
static propTypes = {
source: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
address: PropTypes.string.isRequired,
sdp: PropTypes.string.isRequired,
last_seen: PropTypes.number.isRequired,
period: PropTypes.number.isRequired,
onInfoClick: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
rtp_address: 'n/a',
port: 'n/a'
};
}
handleInfoClick = () => {
this.props.onInfoClick(this.props.id);
};
componentDidMount() {
var rtp_address = this.props.sdp.match(/(c=IN IP4 )([0-9.]+)/g);
var port = this.props.sdp.match(/(m=audio )([0-9]+)/g);
if (rtp_address && port) {
this.setState({ rtp_address: rtp_address[0].substr(9), port: port[0].substr(8) });
}
}
render() {
return (
<tr className='tr-browser'>
<td> <label>{this.props.source}</label> </td>
<td> <label>{this.props.address}</label> </td>
<td> <label>{this.props.name}</label> </td>
<td> <label>{this.state.rtp_address}</label> </td>
<td align='center'> <label>{this.state.port}</label> </td>
<td align='center'> <label>{this.props.last_seen}</label> </td>
<td align='center'> <label>{this.props.period}</label> </td>
<td> <span className='pointer-area' onClick={this.handleInfoClick}> <img width='20' height='20' src='/info.png' alt=''/> </span> </td>
</tr>
);
}
}
class RemoteSourceList extends Component {
static propTypes = {
onReloadClick: PropTypes.func.isRequired
};
handleReloadClick = () => {
this.props.onReloadClick();
};
render() {
return (
<div id='remote-sources-table'>
<table className="table-stream"><tbody>
{this.props.sources.length > 0 ?
<tr className='tr-stream'>
<th>Source</th>
<th>Address</th>
<th>Name</th>
<th>RTP Address</th>
<th>Port</th>
<th>Last seen</th>
<th>Period</th>
</tr>
: <tr>
<th>No remote sources found.</th>
</tr> }
{this.props.sources}
</tbody></table>
&nbsp;
<span className='pointer-area' onClick={this.handleReloadClick}> <img width='30' height='30' src='/reload.png' alt=''/> </span>
&nbsp;&nbsp;
</div>
);
}
}
class RemoteSources extends Component {
constructor(props) {
super(props);
this.state = {
sources: [],
isLoading: false,
infoIsOpen: false,
infoTitle: ''
};
this.onInfoClick = this.onInfoClick.bind(this);
this.onReloadClick = this.onReloadClick.bind(this);
this.openInfo = this.openInfo.bind(this);
this.closeInfo = this.closeInfo.bind(this);
this.fetchRemoteSources = this.fetchRemoteSources.bind(this);
}
fetchRemoteSources() {
this.setState({isLoading: true});
RestAPI.getRemoteSources()
.then(response => response.json())
.then(
data => this.setState( { sources: data.remote_sources, isLoading: false }))
.catch(err => this.setState( { isLoading: false } ));
}
componentDidMount() {
this.fetchRemoteSources();
}
openInfo(title, source) {
this.setState({infoIsOpen: true, infoTitle: title, source: source});
}
closeInfo() {
this.setState({infoIsOpen: false});
this.fetchRemoteSources();
}
onInfoClick(id) {
const source = this.state.sources.find(s => s.id === id);
this.openInfo("Announced Source Info", source, true);
}
onReloadClick() {
this.fetchRemoteSources();
}
render() {
this.state.sources.sort((a, b) => (a.id > b.id) ? 1 : -1);
const sources = this.state.sources.map((source) => (
<RemoteSourceEntry key={source.id}
source={source.source}
id={source.id}
address={source.address}
name={source.name}
sdp={source.sdp}
last_seen={source.last_seen}
period={source.announce_period}
onInfoClick={this.onInfoClick}
/>
));
return (
<div id='sources'>
{ this.state.isLoading ? <Loader/>
: <RemoteSourceList
onReloadClick={this.onReloadClick}
sources={sources} /> }
{ this.state.infoIsOpen ?
<SourceInfo infoIsOpen={this.state.infoIsOpen}
closeInfo={this.closeInfo}
infoTitle={this.state.infoTitle}
id={this.state.source.id}
address={this.state.source.address}
name={this.state.source.name}
sdp={this.state.source.sdp} />
: undefined }
</div>
);
}
}
export default RemoteSources;

View File

@ -31,6 +31,7 @@ const source = '/source';
const sdp = '/sdp'; const sdp = '/sdp';
const sink = '/sink'; const sink = '/sink';
const status = '/status'; const status = '/status';
const browseSources = '/browse/sources';
const defaultParams = { const defaultParams = {
credentials: 'same-origin', credentials: 'same-origin',
@ -75,7 +76,7 @@ export default class RestAPI {
}); });
} }
static setConfig(log_severity, syslog_proto, syslog_server, rtp_mcast_base, rtp_port, playout_delay, tic_frame_size_at_1fs, sample_rate, max_tic_frame_size, sap_interval) { static setConfig(log_severity, syslog_proto, syslog_server, rtp_mcast_base, rtp_port, playout_delay, tic_frame_size_at_1fs, sample_rate, max_tic_frame_size, sap_mcast_addr, sap_interval) {
return this.doFetch(config, { return this.doFetch(config, {
body: JSON.stringify({ body: JSON.stringify({
log_severity: parseInt(log_severity, 10), log_severity: parseInt(log_severity, 10),
@ -87,6 +88,7 @@ export default class RestAPI {
tic_frame_size_at_1fs: parseInt(tic_frame_size_at_1fs, 10), tic_frame_size_at_1fs: parseInt(tic_frame_size_at_1fs, 10),
sample_rate: parseInt(sample_rate, 10), sample_rate: parseInt(sample_rate, 10),
max_tic_frame_size: parseInt(max_tic_frame_size, 10), max_tic_frame_size: parseInt(max_tic_frame_size, 10),
sap_mcast_addr: sap_mcast_addr,
sap_interval: parseInt(sap_interval, 10) sap_interval: parseInt(sap_interval, 10)
}), }),
method: 'POST' method: 'POST'
@ -220,4 +222,11 @@ export default class RestAPI {
}); });
} }
static getRemoteSources() {
return this.doFetch(browseSources).catch(err => {
toast.error('Browse sources get failed: ' + err.message)
return Promise.reject(Error(err.message));
});
}
} }

94
webui/src/SourceInfo.js Normal file
View File

@ -0,0 +1,94 @@
//
// SourceInfo.js
//
// Copyright (c) 2019 2020 Andrea Bondavalli. All rights reserved.
//
// 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, either version 3 of the License, or
// 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 for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
//
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-modal';
require('./styles.css');
const infoCustomStyles = {
content : {
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
marginRight: '-50%',
transform: 'translate(-50%, -50%)'
}
};
class SourceInfo extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
sdp: PropTypes.string.isRequired,
closeInfo: PropTypes.func.isRequired,
infoIsOpen: PropTypes.bool.isRequired,
infoTitle: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.onClose = this.onClose.bind(this);
}
componentDidMount() {
Modal.setAppElement('body');
}
onClose() {
this.props.closeInfo();
}
render() {
return (
<div id='source-info'>
<Modal ariaHideApp={false}
isOpen={this.props.infoIsOpen}
onRequestClose={this.props.closeInfo}
style={infoCustomStyles}
contentLabel="Srouce SDP Info">
<h2><center>{this.props.infoTitle}</center></h2>
<table><tbody>
<tr>
<th align="left"> <label>ID</label> </th>
<th align="left"> <input value={this.props.id} readOnly/> </th>
</tr>
<tr>
<th align="left"> <label>Name</label> </th>
<th align="left"> <input value={this.props.name} readOnly/> </th>
</tr>
<tr>
<th align="left"> <label>SDP</label> </th>
<th align="left"> <textarea rows='15' cols='55' value={this.props.sdp} readOnly/> </th>
</tr>
</tbody></table>
<br/>
<div style={{textAlign: 'center'}}>
<button onClick={this.onClose}>Close</button>
</div>
</Modal>
</div>
);
}
}
export default SourceInfo;

View File

@ -25,6 +25,7 @@ import RestAPI from './Services';
import Loader from './Loader'; import Loader from './Loader';
import SourceEdit from './SourceEdit'; import SourceEdit from './SourceEdit';
import SourceRemove from './SourceRemove'; import SourceRemove from './SourceRemove';
import SourceInfo from './SourceInfo';
require('./styles.css'); require('./styles.css');
@ -34,6 +35,7 @@ class SourceEntry extends Component {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
channels: PropTypes.number.isRequired, channels: PropTypes.number.isRequired,
onEditClick: PropTypes.func.isRequired, onEditClick: PropTypes.func.isRequired,
onInfoClick: PropTypes.func.isRequired,
onTrashClick: PropTypes.func.isRequired onTrashClick: PropTypes.func.isRequired
}; };
@ -41,10 +43,15 @@ class SourceEntry extends Component {
super(props); super(props);
this.state = { this.state = {
address: 'n/a', address: 'n/a',
port: 'n/a' port: 'n/a',
sdp: ''
}; };
} }
handleInfoClick = () => {
this.props.onInfoClick(this.props.id, this.state.sdp);
};
handleEditClick = () => { handleEditClick = () => {
this.props.onEditClick(this.props.id); this.props.onEditClick(this.props.id);
}; };
@ -59,6 +66,7 @@ class SourceEntry extends Component {
.then(function(sdp) { .then(function(sdp) {
var address = sdp.match(/(c=IN IP4 )([0-9.]+)/g); var address = sdp.match(/(c=IN IP4 )([0-9.]+)/g);
var port = sdp.match(/(m=audio )([0-9]+)/g); var port = sdp.match(/(m=audio )([0-9]+)/g);
this.setState({ sdp: sdp });
if (address && port) { if (address && port) {
this.setState({ address: address[0].substr(9), port: port[0].substr(8) }); this.setState({ address: address[0].substr(9), port: port[0].substr(8) });
} }
@ -73,6 +81,7 @@ class SourceEntry extends Component {
<td> <label>{this.state.address}</label> </td> <td> <label>{this.state.address}</label> </td>
<td> <label>{this.state.port}</label> </td> <td> <label>{this.state.port}</label> </td>
<td align='center'> <label>{this.props.channels}</label> </td> <td align='center'> <label>{this.props.channels}</label> </td>
<td> <span className='pointer-area' onClick={this.handleInfoClick}> <img width='20' height='20' src='/info.png' alt=''/> </span> </td>
<td> <span className='pointer-area' onClick={this.handleEditClick}> <img width='20' height='20' src='/edit.png' alt=''/> </span> </td> <td> <span className='pointer-area' onClick={this.handleEditClick}> <img width='20' height='20' src='/edit.png' alt=''/> </span> </td>
<td> <span className='pointer-area' onClick={this.handleTrashClick}> <img width='20' height='20' src='/trash.png' alt=''/> </span> </td> <td> <span className='pointer-area' onClick={this.handleTrashClick}> <img width='20' height='20' src='/trash.png' alt=''/> </span> </td>
</tr> </tr>
@ -131,16 +140,21 @@ class Sources extends Component {
source: {}, source: {},
isLoading: false, isLoading: false,
isEdit: false, isEdit: false,
isInfo: false,
editIsOpen: false, editIsOpen: false,
infoIsOpen: false,
removeIsOpen: false, removeIsOpen: false,
editTitle: '' editTitle: ''
}; };
this.onInfoClick = this.onInfoClick.bind(this);
this.onEditClick = this.onEditClick.bind(this); this.onEditClick = this.onEditClick.bind(this);
this.onTrashClick = this.onTrashClick.bind(this); this.onTrashClick = this.onTrashClick.bind(this);
this.onAddClick = this.onAddClick.bind(this); this.onAddClick = this.onAddClick.bind(this);
this.onReloadClick = this.onReloadClick.bind(this); this.onReloadClick = this.onReloadClick.bind(this);
this.openInfo = this.openInfo.bind(this);
this.openEdit = this.openEdit.bind(this); this.openEdit = this.openEdit.bind(this);
this.closeEdit = this.closeEdit.bind(this); this.closeEdit = this.closeEdit.bind(this);
this.closeInfo = this.closeInfo.bind(this);
this.applyEdit = this.applyEdit.bind(this); this.applyEdit = this.applyEdit.bind(this);
this.fetchSources = this.fetchSources.bind(this); this.fetchSources = this.fetchSources.bind(this);
} }
@ -158,6 +172,10 @@ class Sources extends Component {
this.fetchSources(); this.fetchSources();
} }
openInfo(title, source, sdp, isInfo) {
this.setState({infoIsOpen: true, infoTitle: title, source: source, sdp: sdp, isInfo: isInfo});
}
openEdit(title, source, isEdit) { openEdit(title, source, isEdit) {
this.setState({editIsOpen: true, editTitle: title, source: source, isEdit: isEdit}); this.setState({editIsOpen: true, editTitle: title, source: source, isEdit: isEdit});
} }
@ -172,7 +190,16 @@ class Sources extends Component {
this.setState({removeIsOpen: false}); this.setState({removeIsOpen: false});
this.fetchSources(); this.fetchSources();
} }
closeInfo() {
this.setState({infoIsOpen: false});
}
onInfoClick(id, sdp) {
const source = this.state.sources.find(s => s.id === id);
this.openInfo("Local Source Info", source, sdp, true);
}
onEditClick(id) { onEditClick(id) {
const source = this.state.sources.find(s => s.id === id); const source = this.state.sources.find(s => s.id === id);
this.openEdit("Edit Source " + id, source, true); this.openEdit("Edit Source " + id, source, true);
@ -219,6 +246,7 @@ class Sources extends Component {
id={source.id} id={source.id}
name={source.name} name={source.name}
channels={source.map.length} channels={source.map.length}
onInfoClick={this.onInfoClick}
onEditClick={this.onEditClick} onEditClick={this.onEditClick}
onTrashClick={this.onTrashClick} onTrashClick={this.onTrashClick}
/> />
@ -229,6 +257,15 @@ class Sources extends Component {
: <SourceList onAddClick={this.onAddClick} : <SourceList onAddClick={this.onAddClick}
onReloadClick={this.onReloadClick} onReloadClick={this.onReloadClick}
sources={sources} /> } sources={sources} /> }
{ this.state.infoIsOpen ?
<SourceInfo infoIsOpen={this.state.infoIsOpen}
closeInfo={this.closeInfo}
infoTitle={this.state.infoTitle}
isInfo={this.state.isInfo}
id={this.state.source.id.toString()}
name={this.state.source.name}
sdp={this.state.sdp} />
: undefined }
{ this.state.editIsOpen ? { this.state.editIsOpen ?
<SourceEdit editIsOpen={this.state.editIsOpen} <SourceEdit editIsOpen={this.state.editIsOpen}
closeEdit={this.closeEdit} closeEdit={this.closeEdit}

View File

@ -16,9 +16,8 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
// //
//
//
//
import React from 'react' import React from 'react'
import { Route, BrowserRouter as Router, Switch } from 'react-router-dom' import { Route, BrowserRouter as Router, Switch } from 'react-router-dom'
import { render } from "react-dom"; import { render } from "react-dom";
@ -37,6 +36,7 @@ function App() {
<Route exact path='/PTP' component={() => <ConfigTabs key='PTP' currentTab='PTP' />} /> <Route exact path='/PTP' component={() => <ConfigTabs key='PTP' currentTab='PTP' />} />
<Route exact path='/Sources' component={() => <ConfigTabs key='Sources' currentTab='Sources' />} /> <Route exact path='/Sources' component={() => <ConfigTabs key='Sources' currentTab='Sources' />} />
<Route exact path='/Sinks' component={() => <ConfigTabs key='Sinks' currentTab='Sinks' />} /> <Route exact path='/Sinks' component={() => <ConfigTabs key='Sinks' currentTab='Sinks' />} />
<Route exact path='/Browser' component={() => <ConfigTabs key='Browser' currentTab='Browser' />} />
<Route component={() => <ConfigTabs key='Config' currentTab='Config' />} /> <Route component={() => <ConfigTabs key='Config' currentTab='Config' />} />
</Switch> </Switch>
</div> </div>