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
## License ##
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:
* **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)
* **Boost libraries** licensed with [Boost Software License](https://www.boost.org/LICENSE_1_0.txt)
## Repository content ##
### [daemon](daemon) directory ###
This directory contains the AES67 daemon source code.
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
* session handling and SDP parsing and creation
* HTTP REST API for control and configuration
* SAP discovery protocol implementation
* 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*
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 ###
This directory contains the AES67 daemon WebUI configuration implemented using React.
With the WebUI a user can do the following operations:
* change the daemon configuration, this causes a daemon restart
* edit PTP clock slave configuration and monitor PTP slave status
* add and edit RTP Sources
* add, edit and monitor RTP Sinks
### [3rdparty](3rdparty) directory ###
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 ALSA RAVENNA/AES67 kernel module is responsible for:
* registering as an ALSA driver
* generating and receiving RTP audio packets
* PTP slave operations and PTP driven interrupt loop
* 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.
### [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.
## Prerequisite ##
<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:
* 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)
* cmake version >= 3.10.2
* node version >= 8.10.0
* npm version >= 3.5.2
* boost libraries version >= 1.65.1
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.
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 ##
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).
The script performs the following operations:
* checkout, patch and build the Merging Technologies ALSA RAVENNA/AES67 module
* checkout the cpp-httplib
* build and deploy the WebUI
* build the AES67 daemon
## Run the Demo ##
<a name="demo"></a>
To run a simple demo use the [run\_demo.sh](run_demo.sh) script. See [script notes](#notes).
The demo performs the following operations:
* setup system parameters
* 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
* 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
* open a browser on the daemon PTP status page
* 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 playing a test sound on the configured ALSA source
* wait for the recording to complete and terminate ptp4l and the AES67 daemon
## 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:
* open he 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 the default sample rate to 48Khz: *"sample\_rate": 48000*
* verify that PulseAdio is not running. See [PulseAudio](#notes).
* install the ALSA RAVENNA/AES67 module with:
*sudo insmod 3rdparty/ravenna-alsa-lkm/driver/MergingRavennaALSA.ko*
* run the daemon using the new configuration file:
*aes67-daemon -c daemon.conf*
* 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 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
* edit the newly created Sink and copy the SDP file reported in SDP
* open the Hasseb WebUI and do the following:
* 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
* press the Submit button
* 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*
## Notes ##
<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.
They have been tested on **Ubuntu 18.04** and **19.10** distros only.
* PulseAudio can create instability problems.
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.
# AES67 Linux Daemon
## License ##
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:
* **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)
* **Boost libraries** licensed under the [Boost Software License](https://www.boost.org/LICENSE_1_0.txt)
## Repository content ##
### [daemon](daemon) directory ###
This directory contains the AES67 daemon source code.
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
* session handling and SDP parsing and creation
* HTTP REST API for control and configuration
* SAP discovery protocol implementation and SAP browser implementation
* 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*
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 ###
This directory contains the AES67 daemon WebUI configuration implemented using React.
With the WebUI a user can do the following operations:
* change the daemon configuration, this causes a daemon restart
* edit PTP clock slave configuration and monitor PTP slave status
* add and edit RTP Sources
* add, edit and monitor RTP Sinks
### [3rdparty](3rdparty) directory ###
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 ALSA RAVENNA/AES67 kernel module is responsible for:
* registering as an ALSA driver
* generating and receiving RTP audio packets
* PTP slave operations and PTP driven interrupt loop
* 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.
### [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.
## Prerequisite ##
<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:
* 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)
* cmake version >= 3.10.2
* node version >= 8.10.0
* npm version >= 3.5.2
* boost libraries version >= 1.65.1
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.
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 ##
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).
The script performs the following operations:
* checkout, patch and build the Merging Technologies ALSA RAVENNA/AES67 module
* checkout the cpp-httplib
* build and deploy the WebUI
* build the AES67 daemon
## Run the Demo ##
<a name="demo"></a>
To run a simple demo use the [run\_demo.sh](run_demo.sh) script. See [script notes](#notes).
The demo performs the following operations:
* setup system parameters
* 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
* 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
* open a browser on the daemon PTP status page
* 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 playing a test sound on the configured ALSA source
* wait for the recording to complete and terminate ptp4l and the AES67 daemon
## 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:
* open the daemon configuration file *daemon.conf* and change the following parameters:
* set network interface name to your Ethernet card, e.g.: *"interface\_name": "eth0"*
* set default sample rate to 48Khz: *"sample\_rate": 48000*
* verify that PulseAdio is not running. See [PulseAudio](#notes).
* install the ALSA RAVENNA/AES67 module with:
*sudo insmod 3rdparty/ravenna-alsa-lkm/driver/MergingRavennaALSA.ko*
* run the daemon using the new configuration file:
*aes67-daemon -c daemon.conf*
* 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 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 click on the info icon on the right of the newly created source and copy the SDP file
* open the Hasseb WebUI and do the following:
* 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
* press the Submit button
* 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*
## Notes ##
<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.
They have been tested on **Ubuntu 18.04** and **19.10** distros only.
* PulseAudio can create instability problems.
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.

View File

@ -7,6 +7,6 @@ set(RAVENNA_ALSA_LKM "../3rdparty/ravenna-alsa-lkm/")
set(CPP_HTTPLIB " ../3rdparty/cpp-httplib/")
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})
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)
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() ==
INADDR_NONE)
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)
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_sample_rate() const { return sample_rate_; };
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_; };
uint8_t get_ptp_domain() const { return ptp_domain_; };
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) {
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_ptp_domain(uint8_t ptp_domain) { ptp_domain_ = ptp_domain; };
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 sample_rate_{44100};
std::string rtp_mcast_base_{"239.1.0.1"};
std::string sap_mcast_addr_{"224.2.127.254"};
uint16_t rtp_port_{5004};
uint8_t ptp_domain_{0};
uint8_t ptp_dscp_{46};

View File

@ -10,6 +10,7 @@
"rtp_port": 5004,
"ptp_domain": 0,
"ptp_dscp": 48,
"sap_mcast_addr": "239.255.255.255",
"sap_interval": 30,
"syslog_proto": "none",
"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_.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::stringstream buffer;
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) {
if (res.status == 200) {
BOOST_LOG_TRIVIAL(info) << "http_server:: " << req.method << " "

View File

@ -24,19 +24,23 @@
#include "config.hpp"
#include "session_manager.hpp"
#include "browser.hpp"
class HttpServer {
public:
HttpServer() = delete;
HttpServer(std::shared_ptr<SessionManager> session_manager,
std::shared_ptr<Browser> browser,
std::shared_ptr<Config> config)
: session_manager_(session_manager),
browser_(browser),
config_(config) {};
bool start();
bool stop();
private:
std::shared_ptr<SessionManager> session_manager_;
std::shared_ptr<Browser> browser_;
std::shared_ptr<Config> config_;
httplib::Server svr_;
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 \"ptp_domain\": " << unsigned(config.get_ptp_domain())
<< ",\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 \"syslog_proto\": \"" << escape_json(config.get_syslog_proto()) << "\""
<< ",\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();
}
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) {
try {
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>());
} else if (key == "ptp_dscp") {
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") {
config.set_sap_interval(val.get_value<uint16_t>());
} else if (key == "status_file") {

View File

@ -22,6 +22,7 @@
#include <list>
#include "session_manager.hpp"
#include "browser.hpp"
/* JSON serializers */
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 streams_to_json(const std::list<StreamSource>& sources,
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 */
Config json_to_config(std::istream& jstream, const Config& curCconfig);

View File

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

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"
using namespace boost::asio;
using boost::asio::deadline_timer;
class SAP {
public:
constexpr static const char addr[] = "224.2.127.254";
constexpr static uint16_t port = 9875;
constexpr static uint16_t max_deletions = 3;
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 max_length = 4096;
SAP() { socket_.open(boost::asio::ip::udp::v4()); };
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;
}
SAP() = delete;
SAP(const std::string& sap_mcast_addr);
bool set_multicast_interface(const std::string& interface_ip);
bool 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 deletion(uint16_t msg_id_hash, uint32_t addr, const std::string& sdp) {
BOOST_LOG_TRIVIAL(info) << "sap::deletetion " << std::hex << msg_id_hash;
return send(false, msg_id_hash, htonl(addr), sdp);
}
const std::string& sdp);
bool deletion(uint16_t msg_id_hash, uint32_t addr, const std::string& sdp);
bool receive(bool& is_announce,
uint16_t& msg_id_hash,
uint32_t& addr,
std::string& sdp,
int tout_secs = 1);
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,
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_);
} catch (boost::system::error_code& ec) {
BOOST_LOG_TRIVIAL(error) << "sap::send_to " << ec.message();
return false;
}
return true;
}
const std::string& sdp);
std::string addr_;
io_service io_service_;
ip::udp::socket socket_{io_service_};
ip::udp::endpoint remote_{
ip::udp::endpoint(ip::address::from_string(addr), port)};
ip::udp::endpoint remote_endpoint_{
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

View File

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

View File

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

View File

@ -50,7 +50,7 @@ struct DaemonInstance {
DaemonInstance() {
BOOST_TEST_MESSAGE("Starting up test daemon instance ...");
int retry = 10;
while (--retry && daemon_.running()) {
while (retry-- && daemon_.running()) {
BOOST_TEST_MESSAGE("Checking daemon instance ...");
httplib::Client cli(g_daemon_address, g_daemon_port);
auto res = cli.Get("/");
@ -284,6 +284,12 @@ struct Client {
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:
httplib::Client cli_{g_daemon_address, g_daemon_port};
io_service io_service_;
@ -432,7 +438,30 @@ BOOST_AUTO_TEST_CASE(source_check_sap) {
cli.sap_wait_announcement(0, sdp.second);
BOOST_REQUIRE_MESSAGE(cli.remove_source(0), "removed source 0");
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) {
@ -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;
for (int id = 0; id < 64; 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));
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++) {
BOOST_REQUIRE_MESSAGE(cli.add_sink_sdp(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::property_tree::ptree pt;
std::stringstream ss(json.second);
boost::property_tree::read_json(ss, pt);
std::stringstream ss1(json.second);
boost::property_tree::read_json(ss1, pt);
uint8_t id = 0;
BOOST_FOREACH (auto const& v, pt.get_child("sources")) {
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));
}
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++) {
BOOST_REQUIRE_MESSAGE(cli.remove_sink(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.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>AES64 Daemon WebUI</title>
</head>
<body>
<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,
ptpDomain: data.ptp_domain,
ptpDscp: data.ptp_dscp,
sapMcastAddr: data.sap_mcast_addr,
sapInterval: data.sap_interval,
syslogProto: data.syslog_proto,
syslogServer: data.syslog_server,
@ -95,6 +96,7 @@ class Config extends Component {
!this.state.ticFrameSizeAt1fsErr &&
!this.state.maxTicFrameSizeErr &&
!this.state.rtpMcastBaseErr &&
!this.state.sapMcastAddrErr &&
!this.state.rtpPortErr &&
!this.state.sapIntervalErr &&
!this.state.syslogServerErr &&
@ -113,6 +115,7 @@ class Config extends Component {
this.state.ticFrameSizeAt1fs,
this.state.sampleRate,
this.state.maxTicFrameSize,
this.state.sapMcastAddr,
this.state.sapInterval)
.then(response => toast.success('Config updated, daemon restart ...'));
}
@ -154,12 +157,16 @@ class Config extends Component {
</tr>
<tr>
<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>
<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>
</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>
<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>

View File

@ -26,6 +26,7 @@ import PTP from './PTP';
import Config from './Config';
import Sources from './Sources';
import Sinks from './Sinks';
import RemoteSources from './RemoteSources';
require('./styles.css');
@ -51,6 +52,9 @@ class ConfigTabs extends Component {
<div label="Sinks">
<Sinks/>
</div>
<div label="Browser">
<RemoteSources/>
</div>
</Tabs>
</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 sink = '/sink';
const status = '/status';
const browseSources = '/browse/sources';
const defaultParams = {
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, {
body: JSON.stringify({
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),
sample_rate: parseInt(sample_rate, 10),
max_tic_frame_size: parseInt(max_tic_frame_size, 10),
sap_mcast_addr: sap_mcast_addr,
sap_interval: parseInt(sap_interval, 10)
}),
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 SourceEdit from './SourceEdit';
import SourceRemove from './SourceRemove';
import SourceInfo from './SourceInfo';
require('./styles.css');
@ -34,6 +35,7 @@ class SourceEntry extends Component {
name: PropTypes.string.isRequired,
channels: PropTypes.number.isRequired,
onEditClick: PropTypes.func.isRequired,
onInfoClick: PropTypes.func.isRequired,
onTrashClick: PropTypes.func.isRequired
};
@ -41,10 +43,15 @@ class SourceEntry extends Component {
super(props);
this.state = {
address: 'n/a',
port: 'n/a'
port: 'n/a',
sdp: ''
};
}
handleInfoClick = () => {
this.props.onInfoClick(this.props.id, this.state.sdp);
};
handleEditClick = () => {
this.props.onEditClick(this.props.id);
};
@ -59,6 +66,7 @@ class SourceEntry extends Component {
.then(function(sdp) {
var address = sdp.match(/(c=IN IP4 )([0-9.]+)/g);
var port = sdp.match(/(m=audio )([0-9]+)/g);
this.setState({ sdp: sdp });
if (address && port) {
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.port}</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.handleTrashClick}> <img width='20' height='20' src='/trash.png' alt=''/> </span> </td>
</tr>
@ -131,16 +140,21 @@ class Sources extends Component {
source: {},
isLoading: false,
isEdit: false,
isInfo: false,
editIsOpen: false,
infoIsOpen: false,
removeIsOpen: false,
editTitle: ''
};
this.onInfoClick = this.onInfoClick.bind(this);
this.onEditClick = this.onEditClick.bind(this);
this.onTrashClick = this.onTrashClick.bind(this);
this.onAddClick = this.onAddClick.bind(this);
this.onReloadClick = this.onReloadClick.bind(this);
this.openInfo = this.openInfo.bind(this);
this.openEdit = this.openEdit.bind(this);
this.closeEdit = this.closeEdit.bind(this);
this.closeInfo = this.closeInfo.bind(this);
this.applyEdit = this.applyEdit.bind(this);
this.fetchSources = this.fetchSources.bind(this);
}
@ -158,6 +172,10 @@ class Sources extends Component {
this.fetchSources();
}
openInfo(title, source, sdp, isInfo) {
this.setState({infoIsOpen: true, infoTitle: title, source: source, sdp: sdp, isInfo: isInfo});
}
openEdit(title, source, isEdit) {
this.setState({editIsOpen: true, editTitle: title, source: source, isEdit: isEdit});
}
@ -172,7 +190,16 @@ class Sources extends Component {
this.setState({removeIsOpen: false});
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) {
const source = this.state.sources.find(s => s.id === id);
this.openEdit("Edit Source " + id, source, true);
@ -219,6 +246,7 @@ class Sources extends Component {
id={source.id}
name={source.name}
channels={source.map.length}
onInfoClick={this.onInfoClick}
onEditClick={this.onEditClick}
onTrashClick={this.onTrashClick}
/>
@ -229,6 +257,15 @@ class Sources extends Component {
: <SourceList onAddClick={this.onAddClick}
onReloadClick={this.onReloadClick}
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 ?
<SourceEdit editIsOpen={this.state.editIsOpen}
closeEdit={this.closeEdit}

View File

@ -16,9 +16,8 @@
// 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 from 'react'
import { Route, BrowserRouter as Router, Switch } from 'react-router-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='/Sources' component={() => <ConfigTabs key='Sources' currentTab='Sources' />} />
<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' />} />
</Switch>
</div>