From ed6f5df0253ae8e88015d53b046612b02f240628 Mon Sep 17 00:00:00 2001 From: jameson-dev <178156579+jameson-dev@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:03:41 +0930 Subject: [PATCH 1/5] Move globals into respective class File-scope globals were shared across every instance of the class. If more than one SimpleStream instance were ever loaded, they would silently share the same stream list and TCP I/O service, likely causing data corruption and race conditions. --- plugins/simplestream/simplestream.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/simplestream/simplestream.cc b/plugins/simplestream/simplestream.cc index 16b9e995..7e2b6b2d 100644 --- a/plugins/simplestream/simplestream.cc +++ b/plugins/simplestream/simplestream.cc @@ -9,9 +9,6 @@ using namespace boost::asio; typedef struct plugin_t plugin_t; typedef struct stream_t stream_t; -std::vector streams; -io_service my_tcp_io_service; -long max_tcp_index = 0; struct plugin_t { Config* config; @@ -35,8 +32,11 @@ struct stream_t { class Simple_Stream : public Plugin_Api { typedef boost::asio::io_service io_service; io_service my_io_service; + io_service my_tcp_io_service; ip::udp::endpoint remote_endpoint; ip::udp::socket my_socket{my_io_service}; + std::vector streams; + long max_tcp_index = 0; public: Simple_Stream(){ From c881d4e2d57a5d7d1acc8257a45130ebe6d1e413 Mon Sep 17 00:00:00 2001 From: jameson-dev <178156579+jameson-dev@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:11:01 +0930 Subject: [PATCH 2/5] Use const auto & in BOOST_FOREACH stream loops Copying a struct with a raw pointer wont manage the socket lifetime, and two copies of the same pointer could interact badly. It also needlessly copies two std::string members and a UDP endpoint on every audio packet, which is a hot path. The const auto & form avoids all copies entirely. The start() and stop() loops already used auto& correctly, so this change just introduces consistency --- plugins/simplestream/simplestream.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/simplestream/simplestream.cc b/plugins/simplestream/simplestream.cc index 7e2b6b2d..65357e68 100644 --- a/plugins/simplestream/simplestream.cc +++ b/plugins/simplestream/simplestream.cc @@ -93,7 +93,7 @@ class Simple_Stream : public Plugin_Api { int recorder_id = local_recorder.get_num(); long wav_hz = local_recorder.get_wav_hz(); boost::system::error_code error; - BOOST_FOREACH (auto stream, streams){ + BOOST_FOREACH (const auto &stream, streams){ if (0==stream.short_name.compare(call_short_name) || (0==stream.short_name.compare(""))){ //Check if shortName matches or is not specified if (patched_talkgroups.size() == 0){ patched_talkgroups.push_back(call_tgid); //call_info.talkgroup may be negative - we cast stream.TGID to signed for comparison @@ -165,7 +165,7 @@ class Simple_Stream : public Plugin_Api { } } - BOOST_FOREACH (auto stream, streams){ + BOOST_FOREACH (const auto &stream, streams){ if (stream.sendJSON == true && stream.sendCallStart == true){ if (0==stream.short_name.compare(call_short_name) || (0==stream.short_name.compare(""))){ //Check if shortName matches or is not specified if (patched_talkgroups.size() == 0){ @@ -216,7 +216,7 @@ class Simple_Stream : public Plugin_Api { int call_end(Call_Data_t call_info) { boost::system::error_code error; - BOOST_FOREACH (auto stream, streams){ + BOOST_FOREACH (const auto &stream, streams){ if (stream.sendJSON == true && stream.sendCallEnd == true){ if (0==stream.short_name.compare(call_info.short_name) || (0==stream.short_name.compare(""))){ //Check if shortName matches or is not specified std::vector patched_talkgroups; From de96b8bb8b1ffb849e10541c8c3de1279dff7179 Mon Sep 17 00:00:00 2001 From: jameson-dev <178156579+jameson-dev@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:27:58 +0930 Subject: [PATCH 3/5] Wrap all TCP calls with try/catch and add error logging Dropped TCP connections throws boots::system::system_error, which would terminate the TR process. It should now log errors and continue. For UDP, there was no logging for errors to know when packets are dropped --- plugins/simplestream/simplestream.cc | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/plugins/simplestream/simplestream.cc b/plugins/simplestream/simplestream.cc index 65357e68..fd2b9840 100644 --- a/plugins/simplestream/simplestream.cc +++ b/plugins/simplestream/simplestream.cc @@ -56,7 +56,7 @@ class Simple_Stream : public Plugin_Api { stream.sendCallEnd = element.value("sendCallEnd",false); stream.tcp = element.value("useTCP",false); stream.short_name = element.value("shortName", ""); - BOOST_LOG_TRIVIAL(info) << "simplestreamer will stream audio from TGID " <send(send_buffer); + try { + stream.tcp_socket->send(send_buffer); + } catch (const boost::system::system_error &e) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream TCP send failed: " << e.what(); + } } else{ my_socket.send_to(send_buffer, stream.remote_endpoint, 0, error); + if (error) { + BOOST_LOG_TRIVIAL(warning) << "SimpleStream UDP send failed: " << error.message(); + } } } } @@ -201,10 +208,17 @@ class Simple_Stream : public Plugin_Api { send_buffer.push_back(buffer(json_string)); //prepend json data } if(stream.tcp == true){ - stream.tcp_socket->send(send_buffer); + try { + stream.tcp_socket->send(send_buffer); + } catch (const boost::system::system_error &e) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream TCP send failed: " << e.what(); + } } else{ my_socket.send_to(send_buffer, stream.remote_endpoint, 0, error); + if (error) { + BOOST_LOG_TRIVIAL(warning) << "SimpleStream UDP send failed: " << error.message(); + } } } } @@ -249,10 +263,17 @@ class Simple_Stream : public Plugin_Api { send_buffer.push_back(buffer(json_string)); //prepend json data } if(stream.tcp == true){ - stream.tcp_socket->send(send_buffer); + try { + stream.tcp_socket->send(send_buffer); + } catch (const boost::system::system_error &e) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream TCP send failed: " << e.what(); + } } else{ my_socket.send_to(send_buffer, stream.remote_endpoint, 0, error); + if (error) { + BOOST_LOG_TRIVIAL(warning) << "SimpleStream UDP send failed: " << error.message(); + } } } } From 3cccf3b8e5e23f9d52b2a97e56c669e052b861b2 Mon Sep 17 00:00:00 2001 From: jameson-dev <178156579+jameson-dev@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:35:02 +0930 Subject: [PATCH 4/5] Use resolver for hostname support and add error logging from_string() only accepts literal IP addresses, entering a hostname would throw an error and crash TR with no explanation. --- plugins/simplestream/simplestream.cc | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/plugins/simplestream/simplestream.cc b/plugins/simplestream/simplestream.cc index fd2b9840..d5494a49 100644 --- a/plugins/simplestream/simplestream.cc +++ b/plugins/simplestream/simplestream.cc @@ -49,7 +49,17 @@ class Simple_Stream : public Plugin_Api { stream.TGID = element["TGID"]; stream.address = element["address"]; stream.port = element["port"]; - stream.remote_endpoint = ip::udp::endpoint(ip::address::from_string(stream.address), stream.port); + if (!stream.tcp) { + ip::udp::resolver udp_resolver(my_io_service); + ip::udp::resolver::query udp_query(stream.address, std::to_string(stream.port)); + boost::system::error_code ec; + ip::udp::resolver::iterator iter = udp_resolver.resolve(udp_query, ec); + if (ec) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream: failed to resolve UDP address " << stream.address << ": " << ec.message(); + continue; + } + stream.remote_endpoint = iter->endpoint(); + } stream.sendTGID = element.value("sendTGID",false); stream.sendJSON = element.value("sendJSON",false); stream.sendCallStart = element.value("sendCallStart",false); @@ -288,7 +298,18 @@ class Simple_Stream : public Plugin_Api { if (stream.tcp == true){ ip::tcp::socket *my_tcp_socket = new ip::tcp::socket{my_tcp_io_service}; stream.tcp_socket = my_tcp_socket; - stream.tcp_socket->connect(ip::tcp::endpoint( boost::asio::ip::address::from_string(stream.address), stream.port )); + ip::tcp::resolver tcp_resolver(my_tcp_io_service); + ip::tcp::resolver::query tcp_query(stream.address, std::to_string(stream.port)); + boost::system::error_code ec; + ip::tcp::resolver::iterator iter = tcp_resolver.resolve(tcp_query, ec); + if (ec) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream: failed to resolve TCP address " << stream.address << ": " << ec.message(); + continue; + } + stream.tcp_socket->connect(iter->endpoint(), ec); + if (ec) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream: TCP connect failed to " << stream.address << ":" << stream.port << ": " << ec.message(); + } } } my_socket.open(ip::udp::v4()); From 318c58ce84714ca4d6db3624752aba405444a280 Mon Sep 17 00:00:00 2001 From: jameson-dev <178156579+jameson-dev@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:44:08 +0930 Subject: [PATCH 5/5] Implement URL-based configuration Adds support for a url field in the simplestream config. Can now be defined as udp://hostname:port or tcp://hostname:port instead of separate address, port, and useTCP fields. Added deprecation warning log and updated documentation to reflect changes and deprecation intent --- docs/CONFIGURE.md | 7 +++--- docs/Plugins.md | 7 +++--- plugins/simplestream/simplestream.cc | 32 +++++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/CONFIGURE.md b/docs/CONFIGURE.md index 30a047cc..c83774a7 100644 --- a/docs/CONFIGURE.md +++ b/docs/CONFIGURE.md @@ -674,15 +674,16 @@ This plugin does not, by itself, stream audio to any online services. Because i | Key | Required | Default Value | Type | Description | | --------- | :------: | ------------- | -------------------- | ------------------------------------------------------------ | -| address | ✓ | | string | IP address to send this audio stream to. Use "127.0.0.1" to send to the same computer that trunk-recorder is running on. | -| port | ✓ | | number | UDP or TCP port that this stream will send audio to. | +| url | | | string | URL specifying the protocol, host, and port for this stream, e.g. `udp://hostname.tld:8600` or `tcp://192.168.1.10:9000`. When set, `address` and `port` are not required, and `useTCP` is ignored — the protocol is determined by the URL scheme. Supports both hostnames and literal IP addresses. | +| address | | | string | **Deprecated.** Use `url` instead. IP address or hostname to send this audio stream to. | +| port | | | number | **Deprecated.** Use `url` instead. UDP or TCP port that this stream will send audio to. | | TGID | ✓ | | number | Audio from this Talkgroup ID will be sent on this stream. Set to 0 to stream all recorded talkgroups. | | sendJSON | | false | **true** / **false** | When set to true, JSON metadata will be prepended to the audio data each time a packet is sent. JSON fields are talkgroup, patched_talkgroups, src, src_tag, freq, audio_sample_rate, short_name, event (set to "audio"). The length of the JSON metadata is prepended to the metadata in long integer format (4 bytes, little endian). If this is set to **true**, the sendTGID field will be ignored. | | sendCallStart | | false | **true** / **false** | Only used if sendJSON is set to **true**. When set to true, a JSON message will be sent at the start of each call that includes the following JSON fields: talkgroup, talkgroup_tag, patched_talkgroups, patched_talkgroup_tags, src, src_tag, freq, short_name, event (set to "call_start"). The length of the JSON metadata is prepended to the metadata in long integer format (4 bytes, little endian). | sendCallEnd | | false | **true** / **false** | Only used if sendJSON is set to **true**. When set to true, a JSON message will be sent at the end of each call that includes the following JSON fields: talkgroup, patched_talkgroups, freq, short_name, event (set to "call_end"). The length of the JSON metadata is prepended to the metadata in long integer format (4 bytes, little endian). | sendTGID | | false | **true** / **false** | Deprecated. Recommend using sendJSON for metadata instead. If sendJSON is set to true, this setting will be ignored. When set to true, the TGID will be prepended in long integer format (4 bytes, little endian) to the audio data each time a packet is sent. | | shortName | | | string | shortName of the System that audio should be streamed for. This should match the shortName of a system that is defined in the main section of the config file. When omitted, all Systems will be streamed to the address and port configured. If TGIDs from Systems overlap, JSON metadata should be used to prevent interleaved audio for talkgroups from different Systems with the same TGID. -| useTCP | | false | **true** / **false** | When set to true, TCP will be used instead of UDP. +| useTCP | | false | **true** / **false** | **Deprecated.** Use `url` instead. When set to true, TCP will be used instead of UDP. Ignored if `url` is set — the protocol is determined by the URL scheme instead. ###### Plugin Object Example #1: This example will stream audio from talkgroup 58914 on system "CountyTrunked" to the local machine on UDP port 9123. diff --git a/docs/Plugins.md b/docs/Plugins.md index 93286a77..9b3dd46c 100644 --- a/docs/Plugins.md +++ b/docs/Plugins.md @@ -115,12 +115,13 @@ This plugin does not, by itself, stream audio to any online services. Because i | Key | Required | Default Value | Type | Description | | --------- | :------: | ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| address | ✓ | | string | IP address to send this audio stream to. Use "127.0.0.1" to send to the same computer that trunk-recorder is running on. | -| port | ✓ | | number | UDP or TCP port that this stream will send audio to. | +| url | | | string | URL specifying the protocol, host, and port for this stream, e.g. `udp://hostname.tld:8600` or `tcp://192.168.1.10:9000`. When set, `address` and `port` are not required, and `useTCP` is ignored — the protocol is determined by the URL scheme. Supports both hostnames and literal IP addresses. | +| address | | | string | **Deprecated.** Use `url` instead. IP address or hostname to send this audio stream to. | +| port | | | number | **Deprecated.** Use `url` instead. UDP or TCP port that this stream will send audio to. | | TGID | ✓ | | number | Audio from this Talkgroup ID will be sent on this stream. Set to 0 to stream all recorded talkgroups. | | sendTGID | | false | **true** / **false** | When set to true, the TGID will be prepended in long integer format (4 bytes, little endian) to the audio data each time a packet is sent. | | shortName | | | string | shortName of the System that audio should be streamed for. This should match the shortName of a system that is defined in the main section of the config file. When omitted, all Systems will be streamed to the address and port configured. If TGIDs from Systems overlap, each system must be sent to a different port to prevent interleaved audio for talkgroups from different Systems with the same TGID. | -| useTCP | | false | **true** / **false** | When set to true, TCP will be used instead of UDP. | +| useTCP | | false | **true** / **false** | **Deprecated.** Use `url` instead. When set to true, TCP will be used instead of UDP. Ignored if `url` is set — the protocol is determined by the URL scheme instead. | ###### Plugin Object Example #1: This example will stream audio from talkgroup 58914 on system "CountyTrunked" to the local machine on UDP port 9123. diff --git a/plugins/simplestream/simplestream.cc b/plugins/simplestream/simplestream.cc index d5494a49..3d810477 100644 --- a/plugins/simplestream/simplestream.cc +++ b/plugins/simplestream/simplestream.cc @@ -47,8 +47,34 @@ class Simple_Stream : public Plugin_Api { for (json element : config_data["streams"]) { stream_t stream; stream.TGID = element["TGID"]; - stream.address = element["address"]; - stream.port = element["port"]; + + if (element.contains("url")) { + // Parse url field: udp://hostname:port or tcp://hostname:port + std::string url = element["url"]; + if (url.substr(0, 6) == "udp://") { + stream.tcp = false; + url = url.substr(6); + } else if (url.substr(0, 6) == "tcp://") { + stream.tcp = true; + url = url.substr(6); + } else { + BOOST_LOG_TRIVIAL(error) << "SimpleStream: invalid URL scheme in \"" << element["url"] << "\", expected udp:// or tcp://"; + continue; + } + size_t colon = url.rfind(':'); + if (colon == std::string::npos) { + BOOST_LOG_TRIVIAL(error) << "SimpleStream: missing port in URL \"" << element["url"].get() << "\""; + continue; + } + stream.address = url.substr(0, colon); + stream.port = static_cast(std::stoul(url.substr(colon + 1))); + } else { + BOOST_LOG_TRIVIAL(warning) << "SimpleStream: address/port/useTCP are deprecated, please use the url field instead (e.g. \"url\": \"udp://hostname:port\")"; + stream.address = element["address"]; + stream.port = element["port"]; + stream.tcp = element.value("useTCP", false); + } + if (!stream.tcp) { ip::udp::resolver udp_resolver(my_io_service); ip::udp::resolver::query udp_query(stream.address, std::to_string(stream.port)); @@ -60,11 +86,11 @@ class Simple_Stream : public Plugin_Api { } stream.remote_endpoint = iter->endpoint(); } + stream.sendTGID = element.value("sendTGID",false); stream.sendJSON = element.value("sendJSON",false); stream.sendCallStart = element.value("sendCallStart",false); stream.sendCallEnd = element.value("sendCallEnd",false); - stream.tcp = element.value("useTCP",false); stream.short_name = element.value("shortName", ""); BOOST_LOG_TRIVIAL(info) << "SimpleStream will stream audio from TGID " <